release: bump version to 0.49.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
linkong
2026-05-08 17:42:27 +08:00
parent bb9183b8a4
commit e1984c7a35
86 changed files with 9165 additions and 412 deletions

View File

@@ -33,6 +33,16 @@ function formatInfoCardValue(field, rawValue) {
return value;
}
function escapeInfoCardHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[char]));
}
function getNewsSummaryText(data) {
return (data?.summary || data?.title || '').trim() || '暂无摘要';
}
@@ -130,6 +140,10 @@ function renderMobileDetailContent(type, config, data) {
renderMobileNewsCardContent(content, data);
return;
}
if (config.className === 'compute_unresolved') {
renderComputeCenterUnresolvedContent(content, data);
return;
}
let html = '';
for (const field of config.fields) {
@@ -182,6 +196,11 @@ function ensureMobileDetailsListener() {
}
function renderDefaultCardContent(content, config, data) {
if (config.className === 'compute_unresolved') {
renderComputeCenterUnresolvedContent(content, data);
return;
}
let html = '';
for (const field of config.fields) {
const value = formatInfoCardValue(field, data[field.key]);
@@ -198,7 +217,472 @@ function renderDefaultCardContent(content, config, data) {
html += renderVesselEnrichmentSection(data?.enrichment);
}
const collectContext = buildLocationCollectContext(config, data);
if (collectContext) {
html += renderLocationCollectSection(collectContext);
}
content.innerHTML = html;
if (collectContext) {
bindLocationCollectControls(content, collectContext);
}
}
// Resolve which entity (if any) supports the shared "collect candidate location"
// flow on this info card. Returns a context object the renderer / binder both
// consume, or null when the entity has no location-collection backend.
function buildLocationCollectContext(config, data) {
if (!data || typeof data !== 'object') return null;
if (config.className === 'supercomputer' || config.className === 'gpu_cluster') {
if (!data.source_id) return null;
return {
entityType: 'compute_center',
entityId: data.source_id,
data,
needsConfirmation:
data.needs_confirmation === true
|| data.location_source === 'nominatim_online_geocode',
collect: async () => {
const mod = await import('./compute-centers.js');
return mod.collectComputeCenterLocation(data.source_id, {
name: data.name,
operator: data.operator,
site: data.site || data.metadata?.site,
organization: data.metadata?.organization,
city: data.city,
country: data.country,
source: data.source,
record_id: data.id,
});
},
save: async (candidate) => {
const mod = await import('./compute-centers.js');
return mod.saveComputeCenterLocation(data.source_id, candidate, {
name: data.name,
operator: data.operator,
site: data.site || data.metadata?.site,
city: data.city,
country: data.country,
source: data.source,
});
},
};
}
if (config.className === 'bgp') {
const collectorId = data.collector;
if (!collectorId) return null;
return {
entityType: 'bgp_collector',
entityId: collectorId,
data,
needsConfirmation:
data.needs_confirmation === true
|| (data.location_source && data.location_source !== 'source_coordinates'),
collect: async () => {
const mod = await import('./compute-centers.js');
return mod.collectLocationCandidates(
`/api/v1/bgp/collectors/${encodeURIComponent(collectorId)}/collect-location`,
{
site: data.site || data.matched_location_name,
city: data.city,
country: data.country,
operator: data.operator,
},
);
},
};
}
return null;
}
function renderLocationCollectSection(context) {
const buttonLabel = context.needsConfirmation
? '重新自动采集坐标'
: '自动采集坐标候选';
return `
<div class="info-card-compute-collect" data-collect-entity-id="${context.entityId}" data-collect-entity-type="${context.entityType}">
<button type="button" class="info-card-compute-collect-button" data-collect-action="run">
<span class="material-symbols-rounded" aria-hidden="true">explore</span>
<span>${buttonLabel}</span>
</button>
<div class="info-card-compute-collect-status" data-collect-status></div>
<div class="info-card-compute-collect-candidates" data-collect-candidates></div>
</div>
`;
}
function bindLocationCollectControls(content, context) {
const collectRoot = content.querySelector('[data-collect-entity-id]');
if (!collectRoot) return;
const button = collectRoot.querySelector('[data-collect-action="run"]');
const statusEl = collectRoot.querySelector('[data-collect-status]');
const candidatesEl = collectRoot.querySelector('[data-collect-candidates]');
if (!button) return;
button.addEventListener('click', async (event) => {
event.stopPropagation();
button.disabled = true;
statusEl.textContent = '正在采集坐标候选...';
candidatesEl.innerHTML = '';
try {
const result = await context.collect();
if (!result?.success) {
statusEl.textContent = `未能采集到坐标:${result?.failure_reason || '未知原因'}`;
return;
}
const candidates = Array.isArray(result.candidates) ? result.candidates : [];
statusEl.textContent = `共找到 ${candidates.length} 个候选位置`;
candidatesEl.innerHTML = candidates
.slice(0, 5)
.map((candidate, index) => renderCollectCandidateRow(candidate, index === 0))
.join('');
bindCandidatePreviewButtons(candidatesEl, context);
bindCandidateSaveButtons(candidatesEl, context, statusEl);
} catch (error) {
console.error('collect-location failed', error);
statusEl.textContent = `采集失败:${error?.message || error}`;
} finally {
button.disabled = false;
}
}, { once: false });
}
function renderCollectCandidateRow(candidate, isBest) {
const precisionLabel = {
precise: '精确',
site: '站点',
city: '城市',
}[candidate.precision] || candidate.precision || '未知';
const confidence = Number.isFinite(Number(candidate.confidence))
? `${Math.round(Number(candidate.confidence) * 100)}%`
: '-';
const candidateJson = JSON.stringify(candidate).replace(/"/g, '&quot;');
return `
<div class="info-card-compute-candidate ${isBest ? 'is-best' : ''}">
<div class="info-card-compute-candidate-line">
<span class="info-card-compute-candidate-name">${candidate.matched_location_name || candidate.display_name || '候选'}</span>
<span class="info-card-compute-candidate-precision">${precisionLabel}</span>
</div>
<div class="info-card-compute-candidate-line">
<span class="info-card-compute-candidate-source">${candidate.source}</span>
<span class="info-card-compute-candidate-confidence">置信 ${confidence}</span>
</div>
<div class="info-card-compute-candidate-line">
<span class="info-card-compute-candidate-coords">${Number(candidate.latitude).toFixed(4)}, ${Number(candidate.longitude).toFixed(4)}</span>
<button type="button" class="info-card-compute-candidate-preview" data-preview-candidate
data-lat="${candidate.latitude}" data-lon="${candidate.longitude}"
data-candidate-json="${candidateJson}">预览</button>
<button type="button" class="info-card-compute-candidate-preview" data-save-candidate
data-candidate-json="${candidateJson}">保存</button>
</div>
</div>
`;
}
function getUnresolvedComputeCenterContext(item) {
const metadata = item?.metadata && typeof item.metadata === 'object'
? item.metadata
: {};
return {
sourceId: item?.source_id || item?.id || '',
recordId: item?.id || item?.record_id || '',
name: item?.name || item?.title || '未命名算力中心',
operator: item?.operator || item?.vendor || metadata.operator || '',
site: item?.site || metadata.site || metadata.organization || '',
city: item?.city || metadata.city || '',
country: item?.country || metadata.country || '',
source: item?.source || metadata.source || '',
};
}
function renderComputeCenterUnresolvedContent(content, data) {
const items = Array.isArray(data?.items) ? data.items : [];
if (!items.length) {
content.innerHTML = `
<div class="info-card-unresolved-empty">
当前没有待定位算力中心
</div>
`;
return;
}
const rows = items
.map((item, index) => {
const context = getUnresolvedComputeCenterContext(item);
const contextJson = JSON.stringify(context).replace(/"/g, '&quot;');
const meta = [context.site || context.operator, context.city, context.country]
.filter(Boolean)
.join(' · ') || '缺少可用地址字段';
return `
<div class="info-card-unresolved-item" data-unresolved-item>
<div class="info-card-unresolved-main">
<div class="info-card-unresolved-index">${index + 1}</div>
<div class="info-card-unresolved-copy">
<div class="info-card-unresolved-name">${escapeInfoCardHtml(context.name)}</div>
<div class="info-card-unresolved-meta">${escapeInfoCardHtml(meta)}</div>
</div>
<button type="button" class="info-card-compute-candidate-preview" data-unresolved-collect
data-context-json="${contextJson}">采集</button>
</div>
<div class="info-card-compute-collect-status" data-unresolved-status></div>
<div class="info-card-compute-collect-candidates" data-unresolved-candidates></div>
</div>
`;
})
.join('');
content.innerHTML = `
<div class="info-card-unresolved-summary">
<span data-unresolved-summary-text>${items.length} 个算力中心没有可信坐标</span>
<button type="button" class="info-card-compute-candidate-preview info-card-unresolved-adopt" data-unresolved-adopt-all>
一键采用
</button>
</div>
<div class="info-card-compute-collect-status" data-unresolved-batch-status></div>
<div class="info-card-unresolved-list">
${rows}
</div>
`;
bindComputeCenterUnresolvedControls(content);
}
function updateUnresolvedSummary(content) {
const remainingCount = content.querySelectorAll('[data-unresolved-item]').length;
const summaryText = content.querySelector('[data-unresolved-summary-text]');
if (summaryText) {
summaryText.textContent = remainingCount > 0
? `${remainingCount} 个算力中心没有可信坐标`
: '当前没有待定位算力中心';
}
const adoptAllButton = content.querySelector('[data-unresolved-adopt-all]');
if (adoptAllButton instanceof HTMLButtonElement) {
adoptAllButton.hidden = remainingCount <= 0;
}
window.dispatchEvent(
new CustomEvent('earth:compute-center-unresolved-count-change', {
detail: { unresolvedCount: remainingCount },
}),
);
return remainingCount;
}
function renumberUnresolvedItems(content) {
content.querySelectorAll('[data-unresolved-item]').forEach((item, index) => {
const indexEl = item.querySelector('.info-card-unresolved-index');
if (indexEl) indexEl.textContent = String(index + 1);
});
}
function removeResolvedUnresolvedItem(content, itemRoot) {
itemRoot?.remove();
renumberUnresolvedItems(content);
return updateUnresolvedSummary(content);
}
async function collectUnresolvedComputeCenterCandidates(context) {
const mod = await import('./compute-centers.js');
const result = await mod.collectComputeCenterLocation(context.sourceId, {
name: context.name,
operator: context.operator,
site: context.site,
city: context.city,
country: context.country,
source: context.source,
record_id: context.recordId,
});
return {
mod,
result,
candidates: Array.isArray(result?.candidates) ? result.candidates : [],
};
}
function getBestLocationCandidate(candidates) {
return candidates
.filter((candidate) => (
Number.isFinite(Number(candidate?.latitude))
&& Number.isFinite(Number(candidate?.longitude))
))
.slice()
.sort((a, b) => {
const confidenceA = Number.isFinite(Number(a?.confidence))
? Number(a.confidence)
: -1;
const confidenceB = Number.isFinite(Number(b?.confidence))
? Number(b.confidence)
: -1;
return confidenceB - confidenceA;
})[0] || null;
}
function bindCandidatePreviewButtons(container, context) {
container.querySelectorAll('[data-preview-candidate]').forEach((el) => {
el.addEventListener('click', (clickEvt) => {
clickEvt.stopPropagation();
const lat = Number(el.dataset.lat);
const lon = Number(el.dataset.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
window.dispatchEvent(
new CustomEvent('earth:preview-location-candidate', {
detail: {
latitude: lat,
longitude: lon,
entityType: context.entityType,
entityId: context.entityId,
candidate: JSON.parse(el.dataset.candidateJson || '{}'),
},
}),
);
});
});
}
function bindCandidateSaveButtons(container, context, statusEl) {
container.querySelectorAll('[data-save-candidate]').forEach((el) => {
el.addEventListener('click', async (clickEvt) => {
clickEvt.stopPropagation();
if (typeof context.save !== 'function') return;
const candidate = JSON.parse(el.dataset.candidateJson || '{}');
el.disabled = true;
if (statusEl) statusEl.textContent = '正在保存所选坐标...';
try {
await context.save(candidate);
if (statusEl) statusEl.textContent = '坐标已保存,正在刷新图层...';
window.dispatchEvent(
new CustomEvent('earth:compute-center-location-saved', {
detail: {
entityType: context.entityType,
entityId: context.entityId,
candidate,
},
}),
);
} catch (error) {
console.error('save compute-center location failed', error);
if (statusEl) statusEl.textContent = `保存失败:${error?.message || error}`;
} finally {
el.disabled = false;
}
});
});
}
function bindComputeCenterUnresolvedControls(content) {
content.querySelectorAll('[data-unresolved-collect]').forEach((button) => {
button.addEventListener('click', async (event) => {
event.stopPropagation();
const itemRoot = button.closest('[data-unresolved-item]');
const statusEl = itemRoot?.querySelector('[data-unresolved-status]');
const candidatesEl = itemRoot?.querySelector('[data-unresolved-candidates]');
const context = JSON.parse(button.dataset.contextJson || '{}');
if (!context.sourceId || !statusEl || !candidatesEl) return;
button.disabled = true;
statusEl.textContent = '正在采集坐标候选...';
candidatesEl.innerHTML = '';
try {
const { mod, result, candidates } = await collectUnresolvedComputeCenterCandidates(context);
if (!result?.success) {
statusEl.textContent = `未能采集到坐标:${result?.failure_reason || '未知原因'}`;
return;
}
statusEl.textContent = `共找到 ${candidates.length} 个候选位置`;
candidatesEl.innerHTML = candidates
.slice(0, 5)
.map((candidate, index) => renderCollectCandidateRow(candidate, index === 0))
.join('');
const actionContext = {
entityType: 'compute_center',
entityId: context.sourceId,
save: (candidate) => mod.saveComputeCenterLocation(context.sourceId, candidate, context),
};
bindCandidatePreviewButtons(candidatesEl, actionContext);
bindCandidateSaveButtons(candidatesEl, actionContext, statusEl);
} catch (error) {
console.error('collect unresolved compute-center location failed', error);
statusEl.textContent = `采集失败:${error?.message || error}`;
} finally {
button.disabled = false;
}
});
});
const adoptAllButton = content.querySelector('[data-unresolved-adopt-all]');
if (adoptAllButton instanceof HTMLButtonElement) {
adoptAllButton.addEventListener('click', async (event) => {
event.stopPropagation();
const statusEl = content.querySelector('[data-unresolved-batch-status]');
const buttons = Array.from(content.querySelectorAll('button'));
const pendingItems = Array.from(content.querySelectorAll('[data-unresolved-item]'))
.map((itemRoot) => {
const collectButton = itemRoot.querySelector('[data-unresolved-collect]');
const context = JSON.parse(collectButton?.dataset.contextJson || '{}');
return { itemRoot, context };
})
.filter(({ context }) => context.sourceId);
if (!pendingItems.length) return;
buttons.forEach((button) => { button.disabled = true; });
let savedCount = 0;
let missedCount = 0;
try {
for (const [index, { itemRoot, context }] of pendingItems.entries()) {
const itemStatusEl = itemRoot.querySelector('[data-unresolved-status]');
if (statusEl) {
statusEl.textContent = `正在采用最高置信候选 ${index + 1}/${pendingItems.length}...`;
}
try {
const { mod, result, candidates } = await collectUnresolvedComputeCenterCandidates(context);
if (!result?.success) {
if (itemStatusEl) {
itemStatusEl.textContent = `未找到可采用候选:${result?.failure_reason || '未知原因'}`;
}
missedCount += 1;
continue;
}
const bestCandidate = getBestLocationCandidate(candidates);
if (!bestCandidate) {
if (itemStatusEl) {
itemStatusEl.textContent = '未找到包含有效经纬度的候选';
}
missedCount += 1;
continue;
}
await mod.saveComputeCenterLocation(context.sourceId, bestCandidate, context);
savedCount += 1;
removeResolvedUnresolvedItem(content, itemRoot);
} catch (error) {
console.error('adopt unresolved compute-center location failed', error);
if (itemStatusEl) {
itemStatusEl.textContent = `一键采用失败:${error?.message || error}`;
}
missedCount += 1;
}
}
if (statusEl) {
statusEl.textContent = savedCount > 0
? `已采用 ${savedCount} 个最高置信候选${missedCount ? `${missedCount} 个仍需手动处理` : ''}`
: `${missedCount} 个都没有可自动采用的候选,需要手动处理`;
}
if (savedCount > 0) {
window.dispatchEvent(
new CustomEvent('earth:compute-center-location-saved', {
detail: {
entityType: 'compute_center',
entityId: 'batch',
savedCount,
missedCount,
},
}),
);
}
} finally {
buttons.forEach((button) => { button.disabled = false; });
}
});
}
}
function getFieldSourceLabel(data, fieldKey) {
@@ -283,6 +767,7 @@ function getMobilePopupTitle(type, data) {
case 'bgp': return data.anomaly_type || 'BGP事件';
case 'news': return data.title || '新闻事件';
case 'bgp_collector': return data.collector || 'BGP观测站';
case 'compute_center_unresolved': return '待定位算力中心';
case 'supercomputer': return data.name || '超算';
case 'gpu_cluster': return data.name || 'GPU集群';
case 'vessel': return data.name || '船只';
@@ -298,6 +783,7 @@ function getMobilePopupSubtitle(type, data) {
case 'bgp': return data.severity || 'BGP路由异常';
case 'news': return getNewsSummaryPreview(data, 30) || '态势新闻';
case 'bgp_collector': return data.location || 'BGP观测站';
case 'compute_center_unresolved': return `${data?.totalCount || 0} 个待定位`;
case 'supercomputer': return data.country || '超级计算机';
case 'gpu_cluster': return data.country || 'GPU集群';
case 'vessel': return data.vessel_type || 'AIS 船只';
@@ -593,6 +1079,12 @@ const CARD_CONFIG = {
{ key: 'status', label: '状态' }
]
},
compute_center_unresolved: {
icon: '📍',
title: '待定位算力中心',
className: 'compute_unresolved',
fields: []
},
supercomputer: {
icon: '🖥️',
title: '超算中心详情',
@@ -609,6 +1101,13 @@ const CARD_CONFIG = {
{ key: 'country', label: '国家' },
{ key: 'city', label: '城市' },
{ key: 'location_precision_label', label: '位置精度' },
{ key: 'location_source_label', label: '位置来源' },
{ key: 'location_confidence', label: '位置置信度' },
{ key: 'location_status_label', label: '核验状态' },
{ key: 'estimated_reason', label: '解析依据' },
{ key: 'location_source_note', label: '位置来源说明' },
{ key: 'matched_location_name', label: '匹配的位置名称' },
{ key: 'location_verified_at', label: '位置核验时间' },
{ key: 'source', label: '来源' },
{ key: 'updated_at', label: '更新时间' }
]
@@ -628,6 +1127,13 @@ const CARD_CONFIG = {
{ key: 'country', label: '国家' },
{ key: 'city', label: '城市' },
{ key: 'location_precision_label', label: '位置精度' },
{ key: 'location_source_label', label: '位置来源' },
{ key: 'location_confidence', label: '位置置信度' },
{ key: 'location_status_label', label: '核验状态' },
{ key: 'estimated_reason', label: '解析依据' },
{ key: 'location_source_note', label: '位置来源说明' },
{ key: 'matched_location_name', label: '匹配的位置名称' },
{ key: 'location_verified_at', label: '位置核验时间' },
{ key: 'source', label: '来源' },
{ key: 'updated_at', label: '更新时间' }
]
@@ -875,6 +1381,7 @@ function showPanel(x, y, options = {}) {
const panel = getPanel();
if (!panel) return;
panel.classList.toggle('hud-panel-info--anchor-stable', options.anchorStable === true);
panel.dataset.sticky = options.sticky === true ? 'true' : 'false';
panel.removeAttribute('hidden');
panel.setAttribute('aria-hidden', 'false');
if (x != null && y != null) positionPanel(panel, x, y, options);
@@ -896,6 +1403,7 @@ function hidePanel() {
if (panel) {
panel.classList.remove('is-visible');
panel.classList.remove('hud-panel-info--anchor-stable');
delete panel.dataset.sticky;
panel.setAttribute('aria-hidden', 'true');
panel.setAttribute('hidden', '');
}
@@ -915,6 +1423,10 @@ export function setInfoCardNoBorder(noBorder = true) {
}
}
export function isInfoCardSticky() {
return getPanel()?.dataset.sticky === 'true';
}
export function showInfoCard(type, data, options = {}) {
const config = CARD_CONFIG[type];
if (!config) {