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

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.48.0",
"version": "0.49.0",
"private": true,
"packageManager": "bun@1",
"dependencies": {

View File

@@ -307,6 +307,10 @@
-webkit-user-select: none;
}
.info-card.compute_unresolved .info-card-content {
max-height: min(calc(330px * var(--hud-scale)), calc(100vh - 180px));
}
.info-card-content::-webkit-scrollbar {
width: 4px;
}
@@ -529,3 +533,180 @@
max-width: none;
text-align: left;
}
.info-card-compute-collect {
margin-top: calc(8px * var(--hud-scale));
padding-top: calc(8px * var(--hud-scale));
border-top: 1px solid rgba(214, 229, 245, 0.06);
pointer-events: auto;
}
.info-card-compute-collect-button {
display: inline-flex;
align-items: center;
gap: 4px;
padding: calc(3px * var(--hud-scale)) calc(8px * var(--hud-scale));
background: transparent;
color: var(--hud-text-soft);
border: 1px solid rgba(214, 229, 245, 0.18);
border-radius: 4px;
cursor: pointer;
font-size: calc(0.7rem * var(--hud-scale));
letter-spacing: 0.04em;
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.info-card-compute-collect-button:hover:not(:disabled) {
color: #c9dcff;
background: rgba(72, 138, 255, 0.14);
border-color: rgba(72, 138, 255, 0.45);
}
.info-card-compute-collect-button:disabled {
opacity: 0.55;
cursor: progress;
}
.info-card-compute-collect-button .material-symbols-rounded {
font-size: calc(13px * var(--hud-scale));
}
.info-card-compute-collect-status {
margin-top: calc(8px * var(--hud-scale));
color: var(--hud-text-soft);
font-size: calc(0.7rem * var(--hud-scale));
}
.info-card-compute-collect-candidates {
margin-top: calc(6px * var(--hud-scale));
display: flex;
flex-direction: column;
gap: calc(6px * var(--hud-scale));
}
.info-card-compute-candidate {
background: rgba(214, 229, 245, 0.04);
border: 1px solid rgba(214, 229, 245, 0.08);
border-radius: 6px;
padding: calc(6px * var(--hud-scale)) calc(8px * var(--hud-scale));
font-size: calc(0.7rem * var(--hud-scale));
}
.info-card-compute-candidate.is-best {
border-color: rgba(72, 138, 255, 0.5);
background: rgba(72, 138, 255, 0.1);
}
.info-card-compute-candidate-line {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
}
.info-card-compute-candidate-precision {
color: #cfe1ff;
font-weight: 600;
}
.info-card-compute-candidate-preview {
background: transparent;
color: #c9dcff;
border: 1px solid rgba(214, 229, 245, 0.18);
border-radius: 4px;
cursor: pointer;
padding: 2px 6px;
font-size: calc(0.68rem * var(--hud-scale));
}
.info-card-compute-candidate-preview:hover {
background: rgba(72, 138, 255, 0.18);
}
.info-card-unresolved-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(8px * var(--hud-scale));
padding: calc(4px * var(--hud-scale)) 0 calc(8px * var(--hud-scale));
color: var(--hud-text-soft);
font-size: calc(0.72rem * var(--hud-scale));
line-height: 1.35;
}
.info-card-unresolved-summary > span {
min-width: 0;
}
.info-card-unresolved-list {
display: flex;
flex-direction: column;
gap: calc(7px * var(--hud-scale));
}
.info-card-unresolved-item {
padding: calc(7px * var(--hud-scale)) calc(8px * var(--hud-scale));
border: 1px solid rgba(214, 229, 245, 0.08);
border-radius: 6px;
background: rgba(214, 229, 245, 0.035);
pointer-events: auto;
}
.info-card-unresolved-main {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: calc(8px * var(--hud-scale));
}
.info-card-unresolved-index {
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(18px * var(--hud-scale));
height: calc(18px * var(--hud-scale));
border-radius: 999px;
background: rgba(255, 171, 81, 0.14);
color: #ffd59b;
font-size: calc(0.62rem * var(--hud-scale));
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.info-card-unresolved-copy {
min-width: 0;
}
.info-card-unresolved-name {
overflow: hidden;
color: var(--hud-title);
font-size: calc(0.78rem * var(--hud-scale));
font-weight: 600;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.info-card-unresolved-meta {
overflow: hidden;
color: var(--hud-text-soft);
font-size: calc(0.64rem * var(--hud-scale));
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.info-card-unresolved-adopt {
color: #ffe0aa;
border-color: rgba(255, 171, 81, 0.32);
}
.info-card-unresolved-adopt:hover {
background: rgba(255, 171, 81, 0.16);
}
.info-card-unresolved-empty {
padding: calc(10px * var(--hud-scale)) 0;
color: var(--hud-text-soft);
font-size: calc(0.74rem * var(--hud-scale));
}

View File

@@ -180,6 +180,7 @@
}
.layer-row {
position: relative;
display: flex;
align-items: center;
gap: calc(8px * var(--hud-scale));
@@ -324,6 +325,42 @@
transform: translateX(calc(14px * var(--hud-scale)));
}
.layer-row-notification-badge {
appearance: none;
position: absolute;
top: calc(4px * var(--hud-scale));
left: calc(19px * var(--hud-scale));
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: calc(16px * var(--hud-scale));
height: calc(16px * var(--hud-scale));
padding: 0 calc(4px * var(--hud-scale));
border: 1px solid rgba(255, 226, 186, 0.62);
border-radius: 999px;
background: linear-gradient(180deg, rgba(255, 171, 81, 0.96), rgba(213, 78, 54, 0.96));
box-shadow:
0 calc(2px * var(--hud-scale)) calc(6px * var(--hud-scale)) rgba(2, 8, 20, 0.42),
0 0 calc(10px * var(--hud-scale)) rgba(255, 123, 67, 0.34);
color: #fff8e8;
font-size: calc(0.5rem * var(--hud-scale));
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
cursor: pointer;
transition:
filter 0.16s ease,
transform 0.16s ease,
border-color 0.16s ease;
}
.layer-row-notification-badge:hover {
filter: brightness(1.08);
transform: translateY(calc(-1px * var(--hud-scale)));
border-color: rgba(255, 238, 205, 0.78);
}
@keyframes layer-toggle-loading-track {
0% {
background-position: 0% 50%;

View File

@@ -1,7 +1,10 @@
import * as THREE from "three";
import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js";
import { createInteractableLayer } from "./interactable.js";
import {
createInteractableLayer,
SURFACE_AVOIDANCE_PROFILES,
} from "./interactable.js";
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
const bgpGroup = new THREE.Group();
@@ -397,6 +400,7 @@ const bgpCollectorIconLayer = createInteractableLayer({
activity,
};
},
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
});
function clamp(value, min, max) {
@@ -1480,20 +1484,28 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
scale *= 1 + 0.05 * pulse;
}
// When this collector shares a city-level avoidance bucket with another
// interactable layer (e.g. compute centers), the icon is fanned out by
// ~1.4u but the decorative halos extend 1140u and would still cover the
// neighbour. Shrink+fade them so the other layer remains visible.
const crossLayer = Boolean(marker.userData.icon_avoidance_cross_layer);
const haloOpacityMul = crossLayer && !isLocked && !isHovered ? 0.18 : 1;
const haloScaleMul = crossLayer && !isLocked && !isHovered ? 0.45 : 1;
if (marker.userData.heatHalo) {
marker.userData.heatHalo.position.copy(marker.position);
marker.userData.heatHalo.material.opacity = haloOpacity;
marker.userData.heatHalo.material.opacity = haloOpacity * haloOpacityMul;
marker.userData.heatHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
marker.userData.heatHalo.scale.setScalar(
marker.userData.activity?.haloScale * 0.58 * (1 + pulse * 0.01),
marker.userData.activity?.haloScale * 0.58 * (1 + pulse * 0.01) * haloScaleMul,
);
}
if (marker.userData.pulseHalo) {
marker.userData.pulseHalo.position.copy(marker.position);
marker.userData.pulseHalo.material.opacity = pulseOpacity;
marker.userData.pulseHalo.material.opacity = pulseOpacity * haloOpacityMul;
marker.userData.pulseHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
marker.userData.pulseHalo.scale.setScalar(
marker.userData.activity?.pulseHaloScale * 0.48 * (1 + pulse * 0.02),
marker.userData.activity?.pulseHaloScale * 0.48 * (1 + pulse * 0.02) * haloScaleMul,
);
}
if (marker.userData.statusCore) {
@@ -1511,10 +1523,10 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
}
if (marker.userData.coverageHalo) {
marker.userData.coverageHalo.position.copy(marker.position);
marker.userData.coverageHalo.material.opacity = coverageOpacity;
marker.userData.coverageHalo.material.opacity = coverageOpacity * haloOpacityMul;
marker.userData.coverageHalo.scale.set(
marker.userData.activity?.coverageHaloScale * 0.82 * (1 + pulse * 0.012),
marker.userData.activity?.coverageHaloScale * 0.56 * (1 + pulse * 0.012),
marker.userData.activity?.coverageHaloScale * 0.82 * (1 + pulse * 0.012) * haloScaleMul,
marker.userData.activity?.coverageHaloScale * 0.56 * (1 + pulse * 0.012) * haloScaleMul,
1,
);
}

View File

@@ -1,5 +1,8 @@
import { COMPUTE_CENTER_CONFIG, PATHS } from "./constants.js";
import { createInteractableLayer } from "./interactable.js";
import {
createInteractableLayer,
SURFACE_AVOIDANCE_PROFILES,
} from "./interactable.js";
const COMPUTE_CENTER_RENDER_ORDER = 4.5;
const COMPUTE_CENTER_POINT_SIZE = 36;
@@ -13,6 +16,9 @@ const COMPUTE_CENTER_ICON_SOURCES = {
let showComputeCenters = true;
let supercomputerCount = 0;
let gpuClusterCount = 0;
let unresolvedComputeCenters = [];
const COLLECT_LOCATION_API_BASE = "/api/v1/visualization/compute-centers";
function buildComputeCenterMarkerData(feature) {
const props = feature?.properties || {};
@@ -88,6 +94,13 @@ function drawComputeCenterEstimatedBadge(context, isEstimated = false) {
}
}
function shouldShowComputeCenterEstimatedBadge(data) {
if (!data) return false;
if (data.needs_confirmation === true) return true;
if (data.location_source === "nominatim_online_geocode") return true;
return false;
}
function normalizeSiteType(siteType) {
return siteType === "supercomputer" ? "supercomputer" : "gpu_cluster";
}
@@ -133,9 +146,10 @@ const computeCenterIconLayer = createInteractableLayer({
);
},
afterDraw(context, { marker, item }) {
const data = marker?.userData || item;
drawComputeCenterEstimatedBadge(
context,
Boolean(marker?.userData?.is_estimated ?? item?.is_estimated),
shouldShowComputeCenterEstimatedBadge(data),
);
},
},
@@ -147,12 +161,13 @@ const computeCenterIconLayer = createInteractableLayer({
getBucketKey: (marker) =>
[
marker.userData?.site_type || "gpu_cluster",
marker.userData?.is_estimated ? "estimated" : "precise",
shouldShowComputeCenterEstimatedBadge(marker.userData) ? "estimated" : "verified",
].join(":"),
getUserData: (item) => ({
...item,
pulseOffset: Math.random() * Math.PI * 2,
}),
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
});
export function formatComputeCenterTypeLabel(siteType) {
@@ -176,9 +191,33 @@ export function formatComputeCenterUpdatedAt(value) {
export function formatComputeCenterLocationPrecision(markerData) {
const precision = markerData?.location_precision;
if (precision === "precise") return "精确坐标";
if (precision === "estimated_site") return "估算位置(站点级";
if (precision === "estimated_country") return "估算位置(国家级)";
return "位置未";
if (precision === "site") return "站点级位置";
if (precision === "city") return "城市级位置";
return "位置未确认";
}
const COMPUTE_CENTER_LOCATION_SOURCE_LABELS = {
source_coordinates: "源数据自带坐标",
ror_organization_registry: "ROR 组织注册 API",
nominatim_online_geocode: "Nominatim 在线搜索",
};
export function formatComputeCenterLocationSource(markerData) {
const source = markerData?.location_source;
if (!source) return "未知来源";
return COMPUTE_CENTER_LOCATION_SOURCE_LABELS[source] || source;
}
export function formatComputeCenterNeedsConfirmation(markerData) {
if (markerData?.needs_confirmation === true) return "待人工核验";
if (markerData?.is_estimated === true) return "估算位置";
return "已确认";
}
export function formatComputeCenterLocationConfidence(markerData) {
const confidence = Number(markerData?.location_confidence);
if (!Number.isFinite(confidence)) return "-";
return `${Math.round(confidence * 100)}%`;
}
export function getComputeCenterLegendItems() {
@@ -226,9 +265,92 @@ export function clearComputeCenterSelection() {
export function clearComputeCenterData(earth) {
supercomputerCount = 0;
gpuClusterCount = 0;
unresolvedComputeCenters = [];
computeCenterIconLayer.clearData(earth);
}
export function getUnresolvedComputeCenters() {
return unresolvedComputeCenters.slice();
}
// Generic helper used by every entity type that wires the shared
// /collect-location backend pipeline. The endpoint shape (sources, payload
// keys) is domain-specific; the request/response envelope is unified
// (success / candidates / attempted_queries / failure_reason / context).
export async function collectLocationCandidates(endpoint, payload = {}) {
if (!endpoint) throw new Error("endpoint is required");
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`Collect location failed: HTTP ${response.status} ${text}`.trim(),
);
}
return response.json();
}
export async function collectComputeCenterLocation(sourceId, context = {}) {
if (!sourceId) {
throw new Error("sourceId is required");
}
const url = `${COLLECT_LOCATION_API_BASE}/${encodeURIComponent(sourceId)}/collect-location`;
return collectLocationCandidates(url, {
name: context?.name ?? null,
operator: context?.operator ?? null,
site: context?.site ?? null,
organization: context?.organization ?? null,
city: context?.city ?? null,
country: context?.country ?? null,
source: context?.source ?? null,
id: context?.record_id ?? null,
});
}
export async function saveComputeCenterLocation(sourceId, candidate = {}, context = {}) {
if (!sourceId) {
throw new Error("sourceId is required");
}
const latitude = Number(candidate?.latitude);
const longitude = Number(candidate?.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error("candidate latitude/longitude are required");
}
const url = `${COLLECT_LOCATION_API_BASE}/${encodeURIComponent(sourceId)}/location`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source: context?.source ?? null,
name: context?.name ?? candidate?.matched_location_name ?? candidate?.display_name ?? null,
operator: context?.operator ?? null,
site: context?.site ?? null,
city: candidate?.city ?? context?.city ?? null,
country: candidate?.country ?? context?.country ?? null,
latitude,
longitude,
precision: candidate?.precision ?? "city",
confidence: candidate?.confidence ?? null,
location_source: candidate?.source ?? "manual_selection",
source_url: candidate?.source_url ?? null,
source_note: candidate?.source_note ?? null,
raw_payload: candidate || {},
needs_confirmation: false,
verification_status: "verified",
}),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`Save compute center location failed: HTTP ${response.status} ${text}`.trim(),
);
}
return response.json();
}
export function toggleComputeCenters(show) {
showComputeCenters = Boolean(show);
computeCenterIconLayer.setVisible(showComputeCenters);
@@ -245,8 +367,10 @@ export async function loadComputeCenters(_scene, earth) {
}
const payload = await response.json();
const features = Array.isArray(payload?.features) ? payload.features : [];
const unresolved = Array.isArray(payload?.unresolved) ? payload.unresolved : [];
clearComputeCenterData(earth);
unresolvedComputeCenters = unresolved;
const markerData = spreadComputeCenterPositions(
features
@@ -271,6 +395,8 @@ export async function loadComputeCenters(_scene, earth) {
totalCount: getComputeCenterCount(),
supercomputerCount,
gpuClusterCount,
unresolvedCount: unresolvedComputeCenters.length,
unresolved: unresolvedComputeCenters.slice(),
summary: getComputeCenterStatusSummary(),
};
}

View File

@@ -160,6 +160,7 @@ export const TERRAIN_CONFIG = {
exaggeration: 34,
landRevealFadeMeters: 220,
maxConcurrentRequests: 10,
batchRequestSize: 64,
opacity: 0.68,
color: 0x8aa884,
emissive: 0x030704,
@@ -167,6 +168,7 @@ export const TERRAIN_CONFIG = {
shininess: 16,
urlTemplate:
"/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png",
batchUrl: "/api/v1/visualization/terrain/terrarium/batch",
};
export const COUNTRY_BOUNDARY_CONFIG = {

View File

@@ -132,7 +132,8 @@ let settingsModalTimer = null;
let settingsSheetAnimation = null;
let terrainToggleToken = 0;
let terrainPrefetchStarted = false;
let terrainPrefetchScheduled = false;
let terrainPrefetchTimer = null;
let terrainPrefetchIdleHandle = null;
let focusViewAnimationToken = 0;
let earthSettingsDefaults = null;
let lastZoomStatusUpdateTime = 0;
@@ -1852,7 +1853,7 @@ function applyTerrainUiState(button, enabled) {
}
function prewarmTerrainIfNeeded() {
if (terrainPrefetchStarted || isTerrainReady()) return;
if (!getHighResTextureEnabled() || terrainPrefetchStarted || isTerrainReady()) return;
terrainPrefetchStarted = true;
ensureTerrainReady().catch((error) => {
terrainPrefetchStarted = false;
@@ -1860,23 +1861,45 @@ function prewarmTerrainIfNeeded() {
});
}
function scheduleTerrainPrefetch() {
if (terrainPrefetchScheduled || terrainPrefetchStarted || isTerrainReady()) {
function clearScheduledTerrainPrefetch() {
if (terrainPrefetchTimer !== null) {
window.clearTimeout(terrainPrefetchTimer);
terrainPrefetchTimer = null;
}
if (
terrainPrefetchIdleHandle !== null &&
typeof window !== "undefined" &&
"cancelIdleCallback" in window
) {
window.cancelIdleCallback(terrainPrefetchIdleHandle);
}
terrainPrefetchIdleHandle = null;
}
export function scheduleTerrainPrefetch({ delayMs = 4500, idleTimeoutMs = 6000 } = {}) {
clearScheduledTerrainPrefetch();
if (!getHighResTextureEnabled() || terrainPrefetchStarted || isTerrainReady()) {
return;
}
terrainPrefetchScheduled = true;
const runPrefetch = () => {
terrainPrefetchScheduled = false;
terrainPrefetchIdleHandle = null;
prewarmTerrainIfNeeded();
};
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
window.requestIdleCallback(runPrefetch, { timeout: 2200 });
return;
}
window.setTimeout(runPrefetch, 1400);
terrainPrefetchTimer = window.setTimeout(() => {
terrainPrefetchTimer = null;
if (!getHighResTextureEnabled() || terrainPrefetchStarted || isTerrainReady()) {
return;
}
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
terrainPrefetchIdleHandle = window.requestIdleCallback(runPrefetch, {
timeout: idleTimeoutMs,
});
return;
}
runPrefetch();
}, delayMs);
}
export function applyImmediateView(targetEarthObj, camera, options = {}) {
@@ -3154,8 +3177,6 @@ function setupTerrainControls() {
prewarmTerrainIfNeeded();
});
scheduleTerrainPrefetch();
bindListener(reloadBtn, "click", async () => {
await reloadData();
});
@@ -3614,6 +3635,7 @@ function setupToolbarHubCluster() {
}
export function teardownControls() {
clearScheduledTerrainPrefetch();
resetCleanup();
activeCamera = null;
}

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) {

View File

@@ -11,6 +11,26 @@ const DEFAULT_AVOIDANCE_PRECISION = 4;
const DEFAULT_AVOIDANCE_RADIUS = 1.1;
const DEFAULT_AVOIDANCE_STEP = 0.35;
const AVOIDANCE_RING_SLOT_COUNT = 8;
// Named avoidance profiles. Layers that should mutex with each other (e.g. fan
// out when sharing the same city center) must reference the SAME profile —
// markers are bucketed by the resulting key, and only equal keys collide.
//
// city — ~1.1km grid (precision 2). Use for site/observatory/POI markers
// that often share a city-center coordinate from geocoding.
// precise — ~11m grid (precision 4). Use for markers with building-level
// coordinates (default; preserves prior behavior).
//
// Layers that need a fully custom cluster identity (e.g. a city ID string)
// can pass `getKey: (item, position) => "..."` instead of using a profile.
export const SURFACE_AVOIDANCE_PROFILES = Object.freeze({
city: Object.freeze({ precision: 2, radius: 1.4, step: 0.5 }),
precise: Object.freeze({
precision: DEFAULT_AVOIDANCE_PRECISION,
radius: DEFAULT_AVOIDANCE_RADIUS,
step: DEFAULT_AVOIDANCE_STEP,
}),
});
const TANGENT_EPSILON_SQ = 1e-6;
const avoidanceNorthPole = new THREE.Vector3(0, 1, 0);
const avoidanceFallbackEast = new THREE.Vector3(1, 0, 0);
@@ -58,7 +78,16 @@ function createCanvas(width, height) {
return canvas;
}
function getAvoidanceKey(position, basePosition, precision = 4) {
function getAvoidanceKey(item, position, basePosition, config) {
if (typeof config?.getKey === "function") {
const key = config.getKey(item, position, basePosition);
if (key) return String(key);
}
const precision = Number.isFinite(config?.precision)
? config.precision
: DEFAULT_AVOIDANCE_PRECISION;
if (position instanceof THREE.Vector3) {
return [
"vec",
@@ -85,11 +114,14 @@ function recomputeAvoidanceBucket(key) {
if (!entries || entries.length === 0) return;
const affectedLayerIds = new Set(entries.map((entry) => entry.layerId));
const crossLayer = affectedLayerIds.size > 1;
if (entries.length === 1) {
const entry = entries[0];
entry.marker.position.copy(entry.marker.userData.icon_base_position);
entry.marker.userData.icon_avoidance_index = 0;
entry.marker.userData.icon_avoidance_count = 1;
entry.marker.userData.icon_avoidance_layer_count = 1;
entry.marker.userData.icon_avoidance_cross_layer = false;
notifyAvoidancePositionChanged(affectedLayerIds);
return;
}
@@ -127,6 +159,8 @@ function recomputeAvoidanceBucket(key) {
entry.marker.position.copy(avoidancePositionScratch);
entry.marker.userData.icon_avoidance_index = index;
entry.marker.userData.icon_avoidance_count = count;
entry.marker.userData.icon_avoidance_layer_count = affectedLayerIds.size;
entry.marker.userData.icon_avoidance_cross_layer = crossLayer;
});
notifyAvoidancePositionChanged(affectedLayerIds);
@@ -657,9 +691,10 @@ export function createInteractableLayer(options = {}) {
if (!position) return;
const kind = getKind(item);
const avoidanceKey = getAvoidanceKey(
item,
rawPosition,
position,
avoidanceConfig.precision,
avoidanceConfig,
);
const marker = new THREE.Object3D();
marker.position.copy(position);

View File

@@ -160,6 +160,9 @@ import {
clearComputeCenterSelection,
formatComputeCenterCapacity,
formatComputeCenterLocationPrecision,
formatComputeCenterLocationConfidence,
formatComputeCenterLocationSource,
formatComputeCenterNeedsConfirmation,
formatComputeCenterTypeLabel,
formatComputeCenterUpdatedAt,
getComputeCenterCount,
@@ -167,6 +170,7 @@ import {
getComputeCenterMarkers,
getComputeCenterPointerIntersections as getComputeCenterIconPointerIntersections,
getShowComputeCenters,
getUnresolvedComputeCenters,
loadComputeCenters,
setComputeCenterMarkerState,
toggleComputeCenters,
@@ -208,6 +212,7 @@ import {
setTerrainLayerInteractable,
setDayNightInteractable,
applyDeferredLayerVisibilitySettings,
scheduleTerrainPrefetch,
} from "./controls.js";
import {
createLayerStartupTaskMap,
@@ -224,6 +229,7 @@ import {
initInfoCard,
showInfoCard,
hideInfoCard,
isInfoCardSticky,
} from "./info-card.js";
import {
initLegend,
@@ -239,6 +245,10 @@ import {
registerEarthClientErrorHandlers,
reportEarthClientLog,
} from "./client-logs.js";
import {
clearMobileCenterCountryHighlight,
updateMobileCenterCountryHighlight,
} from "./mobile-center-country-highlight.js";
export let scene;
export let camera;
@@ -762,22 +772,36 @@ function showComputeCenterInfo(marker, coords) {
? "supercomputer"
: "gpu_cluster";
setLegendMode("computeCenters");
const ud = marker.userData || {};
showInfoCard(siteType, {
name: marker.userData?.name || "-",
id: ud.id,
source_id: ud.source_id,
name: ud.name || "-",
site_type_label: formatComputeCenterTypeLabel(siteType),
rank: marker.userData?.rank ?? "-",
capacity: formatComputeCenterCapacity(marker.userData),
vendor: marker.userData?.vendor || "-",
operator: marker.userData?.operator || "-",
gpu_count: marker.userData?.gpu_count ?? "-",
gpu_type: marker.userData?.gpu_type || "-",
cores: marker.userData?.cores ?? "-",
power: marker.userData?.power ?? "-",
country: marker.userData?.country || "-",
city: marker.userData?.city || "-",
location_precision_label: formatComputeCenterLocationPrecision(marker.userData),
source: marker.userData?.source || "-",
updated_at: formatComputeCenterUpdatedAt(marker.userData?.updated_at),
rank: ud.rank ?? "-",
capacity: formatComputeCenterCapacity(ud),
vendor: ud.vendor || "-",
operator: ud.operator || "-",
gpu_count: ud.gpu_count ?? "-",
gpu_type: ud.gpu_type || "-",
cores: ud.cores ?? "-",
power: ud.power ?? "-",
country: ud.country || "-",
city: ud.city || "-",
metadata: ud.metadata || {},
location_precision: ud.location_precision,
location_precision_label: formatComputeCenterLocationPrecision(ud),
location_source: ud.location_source,
location_source_label: formatComputeCenterLocationSource(ud),
location_status_label: formatComputeCenterNeedsConfirmation(ud),
needs_confirmation: ud.needs_confirmation === true,
estimated_reason: ud.estimated_reason || "-",
location_confidence: formatComputeCenterLocationConfidence(ud),
location_source_note: ud.location_source_note || "-",
matched_location_name: ud.matched_location_name || "-",
location_verified_at: ud.location_verified_at || "-",
source: ud.source || "-",
updated_at: formatComputeCenterUpdatedAt(ud.updated_at),
}, coords);
}
@@ -1193,6 +1217,26 @@ async function focusSearchComputeCenter(marker) {
);
}
async function previewLocationCandidate(detail) {
const lat = Number(detail?.latitude);
const lon = Number(detail?.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
interruptCruisePresentation({ resetLoop: true });
setAutoRotate(false);
await focusSearchTarget({ lat, lon }, Math.max(getZoomLevel(), 1.16));
}
async function refreshComputeCentersAfterLocationSave() {
if (!scene || !earth) return;
const result = await loadComputeCenters(scene, earth);
toggleComputeCenters(getShowComputeCenters());
updateComputeCenterHud(result);
setLegendItems("computeCenters", getComputeCenterLegendItems());
refreshLegend();
updateStatsSummary();
showStatusMessage("算力中心坐标已保存", "success");
}
async function focusSearchVessel(marker) {
await setVesselsEnabled(true, {
suppressStatus: true,
@@ -1514,17 +1558,81 @@ async function loadEarthStatsSummary({ shouldApply = () => true } = {}) {
function updateComputeCenterHud(computeCenterResult) {
const computeBtn = document.getElementById("toggle-compute-centers");
const unresolvedCount = Number(computeCenterResult?.unresolvedCount) || 0;
const unresolvedTooltip =
unresolvedCount > 0 ? `${unresolvedCount} 个待定位)` : "";
if (computeBtn) {
setLayerButtonState(computeBtn, {
active: getShowComputeCenters(),
loading: false,
tooltip: getShowComputeCenters() ? "隐藏算力中心" : "显示算力中心",
tooltip: getShowComputeCenters()
? `隐藏算力中心${unresolvedTooltip}`
: `显示算力中心${unresolvedTooltip}`,
});
updateComputeCenterUnresolvedBadge(computeBtn, unresolvedCount);
}
setEarthStatValue("compute-center-count", `${computeCenterResult.totalCount}`);
}
function updateComputeCenterUnresolvedBadge(computeBtn, unresolvedCount) {
const count = Math.max(0, Number(unresolvedCount) || 0);
const layerRow = computeBtn.closest(".layer-row");
if (!layerRow) return;
let badge = layerRow.querySelector("[data-compute-center-unresolved-badge]");
if (count <= 0) {
delete computeBtn.dataset.unresolvedCount;
badge?.remove();
return;
}
if (!(badge instanceof HTMLButtonElement)) {
badge?.remove();
badge = document.createElement("button");
badge.type = "button";
badge.className = "layer-row-notification-badge";
badge.dataset.computeCenterUnresolvedBadge = "true";
layerRow.appendChild(badge);
}
badge.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
showComputeCenterUnresolvedQueue(event.currentTarget);
};
computeBtn.dataset.unresolvedCount = String(count);
badge.textContent = count > 99 ? "99+" : String(count);
badge.title = `${count} 个算力中心待定位`;
badge.setAttribute("aria-label", `${count} 个算力中心待定位,点击查看`);
}
function syncComputeCenterUnresolvedCount(unresolvedCount) {
const computeBtn = document.getElementById("toggle-compute-centers");
if (!computeBtn) return;
updateComputeCenterUnresolvedBadge(computeBtn, unresolvedCount);
}
function showComputeCenterUnresolvedQueue(anchorEl) {
const items = getUnresolvedComputeCenters();
const panelRect = document.getElementById("layer-toggles")?.getBoundingClientRect();
const anchorRect = anchorEl instanceof HTMLElement
? anchorEl.getBoundingClientRect()
: null;
const rect = panelRect || anchorRect;
showInfoCard("compute_center_unresolved", {
totalCount: items.length,
items,
}, {
x: rect ? rect.right + 10 : 284,
y: rect ? rect.top : 120,
absolute: true,
anchorStable: true,
sticky: true,
});
}
function updateBGPHud(bgpResult) {
const bgpBtn = document.getElementById("toggle-bgp");
if (bgpBtn) {
@@ -2045,15 +2153,21 @@ function applyCableVisualState() {
(lockedObjectType === "satellite" && lockedSatellite) ||
(lockedObjectType === "bgp" && lockedObject) ||
(isCruiseModeActive() && isCruisePresentationPinned());
const isHoveredCable = isSameCable(cable, hoveredCable);
switch (state) {
case CABLE_STATE.LOCKED:
cable.material.opacity = isHoveredCable
? CABLE_CONFIG.lockedOpacityMax
: THREE.MathUtils.lerp(
CABLE_CONFIG.lockedOpacityMin,
CABLE_CONFIG.lockedOpacityMax,
pulse,
);
cable.material.color.setRGB(0.92, 0.98, 1.0);
break;
case CABLE_STATE.HOVERED:
cable.material.opacity = THREE.MathUtils.lerp(
CABLE_CONFIG.lockedOpacityMin,
CABLE_CONFIG.lockedOpacityMax,
pulse,
);
cable.material.opacity = CABLE_CONFIG.lockedOpacityMax;
cable.material.color.setRGB(0.92, 0.98, 1.0);
break;
case CABLE_STATE.NORMAL:
@@ -2740,9 +2854,15 @@ async function loadData() {
queueStatusMessage("数据已加载", "success");
}
applyDeferredLayerVisibilitySettings().catch((error) => {
console.warn("恢复 Earth 图层可见性失败:", error);
});
applyDeferredLayerVisibilitySettings()
.catch((error) => {
console.warn("恢复 Earth 图层可见性失败:", error);
})
.finally(() => {
if (loadToken === currentLoadToken && !destroyed) {
scheduleTerrainPrefetch();
}
});
}
const POSITION_UPDATE_FORCE_DELTA = 250;
@@ -3053,6 +3173,20 @@ function setupEventListeners() {
const handleRotationMode = (event) => handleRotationModeChange(event);
const handleCruiseModules = () => handleCruiseModulesChange();
const handleInfoCardDrag = () => repositionCruiseConnector();
const handlePreviewLocationCandidate = (event) => {
previewLocationCandidate(event?.detail || {}).catch((error) => {
console.warn("预览候选位置失败:", error);
});
};
const handleComputeCenterLocationSaved = () => {
refreshComputeCentersAfterLocationSave().catch((error) => {
console.warn("刷新算力中心图层失败:", error);
showStatusMessage("坐标已保存,但刷新算力中心失败", "error");
});
};
const handleComputeCenterUnresolvedCountChange = (event) => {
syncComputeCenterUnresolvedCount(event?.detail?.unresolvedCount);
};
bindListener(window, "resize", handleResize);
bindListener(document, "visibilitychange", handleVisibilityChange);
@@ -3061,6 +3195,21 @@ function setupEventListeners() {
bindListener(window, "earth:rotation-mode-change", handleRotationMode);
bindListener(window, "earth:cruise-modules-change", handleCruiseModules);
bindListener(window, "earth:info-card-drag", handleInfoCardDrag);
bindListener(
window,
"earth:preview-location-candidate",
handlePreviewLocationCandidate,
);
bindListener(
window,
"earth:compute-center-location-saved",
handleComputeCenterLocationSaved,
);
bindListener(
window,
"earth:compute-center-unresolved-count-change",
handleComputeCenterUnresolvedCountChange,
);
getCruiseModuleDefinitions().forEach((module) => {
if (!module.externalEventName) return;
bindListener(window, module.externalEventName, (event) => {
@@ -3158,7 +3307,12 @@ function onMouseMove(event) {
applyBGPHoverState(lockedObject);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
applyBGPHoverState(lockedObject);
} else if (!lockedObject && !lockedSatellite && !isCruisePresentationPinned()) {
} else if (
!lockedObject &&
!lockedSatellite &&
!isCruisePresentationPinned() &&
!isInfoCardSticky()
) {
hideInfoCard();
}
hideTooltip();
@@ -3329,7 +3483,7 @@ function onMouseMove(event) {
applyComputeCenterHoverState(lockedObject);
} else if (lockedObjectType === "vessel" && lockedObject) {
applyVesselHoverState(lockedObject);
} else if (!lockedObjectType && !isCruisePresentationPinned()) {
} else if (!lockedObjectType && !isCruisePresentationPinned() && !isInfoCardSticky()) {
resetTransientBGPStates();
resetTransientComputeCenterStates();
if (vesselPick.checked) {
@@ -3824,6 +3978,12 @@ function animate() {
const currentSunDirection = getSunDirection();
setEarthSunDirection(currentSunDirection);
setSatelliteSunDirection(currentSunDirection);
updateMobileCenterCountryHighlight({
camera,
earth: getEarthSurfacePickTarget() || earth,
renderer,
now: performance.now(),
});
updateNewsViewFocus(getCurrentViewCenterCoords());
const satPositions = getSatellitePositions();
if (
@@ -3863,6 +4023,7 @@ export function destroy() {
}
clearLockedObject();
clearMobileCenterCountryHighlight();
clearCableData(getEarth());
clearBGPData(getEarth());
clearComputeCenterData(getEarth());

View File

@@ -0,0 +1,128 @@
import * as THREE from "three";
import {
clearCountryBoundaryHover,
getShowCountryBoundaries,
updateCountryBoundaryHover,
} from "./country-boundaries.js";
import { screenToEarthCoords, vector3ToLatLon } from "./utils.js";
const UPDATE_INTERVAL_MS = 120;
const MIN_COORD_DELTA_DEGREES = 0.05;
const BLOCKING_BODY_CLASSES = [
"earth-mobile-drawer-open",
"earth-search-open",
"earth-settings-open",
"earth-media-open",
"earth-info-open",
];
const centerRaycaster = new THREE.Raycaster();
const centerMouse = new THREE.Vector2();
let ownsHighlight = false;
let lastUpdateAt = 0;
let lastLat = null;
let lastLon = null;
function isMobileLayout() {
return document.body.classList.contains("layout-mode-mobile");
}
function hasBlockingForegroundUi() {
return BLOCKING_BODY_CLASSES.some((className) =>
document.body.classList.contains(className),
);
}
function resetCachedCenter() {
lastUpdateAt = 0;
lastLat = null;
lastLon = null;
}
export function clearMobileCenterCountryHighlight() {
if (ownsHighlight) {
clearCountryBoundaryHover();
}
ownsHighlight = false;
resetCachedCenter();
}
function clearAnyMobileCountryHighlight() {
clearCountryBoundaryHover();
ownsHighlight = false;
resetCachedCenter();
}
function shouldSkipForSmallMovement(coords) {
if (lastLat === null || lastLon === null) return false;
return (
Math.abs(coords.lat - lastLat) < MIN_COORD_DELTA_DEGREES &&
Math.abs(coords.lon - lastLon) < MIN_COORD_DELTA_DEGREES
);
}
export function updateMobileCenterCountryHighlight({
camera,
earth,
renderer,
now = performance.now(),
isBlocked = false,
} = {}) {
const mobile = isMobileLayout();
if (!mobile) {
clearMobileCenterCountryHighlight();
return null;
}
if (
!camera ||
!earth ||
!renderer?.domElement ||
isBlocked ||
hasBlockingForegroundUi() ||
!getShowCountryBoundaries()
) {
clearAnyMobileCountryHighlight();
return null;
}
if (now - lastUpdateAt < UPDATE_INTERVAL_MS) {
return null;
}
const rect = renderer.domElement.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
clearMobileCenterCountryHighlight();
return null;
}
const earthPoint = screenToEarthCoords(
rect.left + rect.width / 2,
rect.top + rect.height / 2,
camera,
earth,
renderer.domElement,
centerRaycaster,
centerMouse,
);
lastUpdateAt = now;
if (!earthPoint) {
clearMobileCenterCountryHighlight();
return null;
}
const coords = vector3ToLatLon(earthPoint);
if (shouldSkipForSmallMovement(coords)) {
return null;
}
lastLat = coords.lat;
lastLon = coords.lon;
ownsHighlight = true;
return updateCountryBoundaryHover(coords);
}

View File

@@ -33,6 +33,10 @@ function buildTileUrl(z, x, y) {
.replace("{y}", String(y));
}
function buildTerrainTileKey(z, x, y) {
return `${z}/${x}/${y}`;
}
function getTerrainCanvas(size) {
if (typeof OffscreenCanvas !== "undefined") {
return new OffscreenCanvas(size, size);
@@ -44,8 +48,34 @@ function getTerrainCanvas(size) {
return canvas;
}
async function decodeTerrainBlob(blob, cacheKey) {
const bitmap = await createImageBitmap(blob);
const canvas = getTerrainCanvas(TERRAIN_CONFIG.tileSize);
const ctx = canvas.getContext("2d", { willReadFrequently: true });
ctx.drawImage(bitmap, 0, 0, TERRAIN_CONFIG.tileSize, TERRAIN_CONFIG.tileSize);
bitmap.close?.();
const { data, width, height } = ctx.getImageData(
0,
0,
TERRAIN_CONFIG.tileSize,
TERRAIN_CONFIG.tileSize,
);
const tileData = { data, width, height };
resolvedTileCache.set(cacheKey, tileData);
return tileData;
}
function base64ToBlob(base64, contentType = "image/png") {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new Blob([bytes], { type: contentType });
}
async function decodeTerrainTile(z, x, y) {
const cacheKey = `${z}/${x}/${y}`;
const cacheKey = buildTerrainTileKey(z, x, y);
if (terrainTileCache.has(cacheKey)) {
return terrainTileCache.get(cacheKey);
}
@@ -56,27 +86,89 @@ async function decodeTerrainTile(z, x, y) {
throw new Error(`HTTP ${response.status} for terrain tile ${cacheKey}`);
}
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
const canvas = getTerrainCanvas(TERRAIN_CONFIG.tileSize);
const ctx = canvas.getContext("2d", { willReadFrequently: true });
ctx.drawImage(bitmap, 0, 0, TERRAIN_CONFIG.tileSize, TERRAIN_CONFIG.tileSize);
bitmap.close?.();
const { data, width, height } = ctx.getImageData(
0,
0,
TERRAIN_CONFIG.tileSize,
TERRAIN_CONFIG.tileSize,
);
const tileData = { data, width, height };
resolvedTileCache.set(cacheKey, tileData);
return tileData;
return decodeTerrainBlob(await response.blob(), cacheKey);
})();
terrainTileCache.set(cacheKey, tilePromise);
return tilePromise;
}
async function fetchTerrainTileBatch(keys) {
const uncachedKeys = keys.filter((key) => !terrainTileCache.has(key));
if (uncachedKeys.length === 0) {
return;
}
if (!TERRAIN_CONFIG.batchUrl || typeof atob !== "function") {
uncachedKeys.forEach((key) => {
const [z, x, y] = key.split("/").map(Number);
terrainTileCache.set(key, decodeTerrainTile(z, x, y));
});
return;
}
const batchPromise = (async () => {
const response = await fetch(TERRAIN_CONFIG.batchUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
tiles: uncachedKeys.map((key) => {
const [z, x, y] = key.split("/").map(Number);
return { z, x, y };
}),
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} for terrain tile batch`);
}
const payload = await response.json();
const tiles = Array.isArray(payload?.tiles) ? payload.tiles : [];
const returnedKeys = new Set();
await Promise.all(
tiles.map(async (tile) => {
const z = Number(tile?.z);
const x = Number(tile?.x);
const y = Number(tile?.y);
const data = typeof tile?.data === "string" ? tile.data : "";
if (!Number.isFinite(z) || !Number.isFinite(x) || !Number.isFinite(y) || !data) {
return;
}
const key = buildTerrainTileKey(z, x, y);
returnedKeys.add(key);
const blob = base64ToBlob(data, tile.content_type || "image/png");
terrainTileCache.set(key, decodeTerrainBlob(blob, key));
}),
);
uncachedKeys.forEach((key) => {
if (returnedKeys.has(key)) return;
const [z, x, y] = key.split("/").map(Number);
terrainTileCache.delete(key);
terrainTileCache.set(key, decodeTerrainTile(z, x, y));
});
})().catch((error) => {
console.warn("批量地形瓦片加载失败,回退到单瓦片请求:", error);
uncachedKeys.forEach((key) => {
terrainTileCache.delete(key);
const [z, x, y] = key.split("/").map(Number);
terrainTileCache.set(key, decodeTerrainTile(z, x, y));
});
});
uncachedKeys.forEach((key) => {
terrainTileCache.set(
key,
batchPromise.then(() => terrainTileCache.get(key)),
);
});
await batchPromise;
}
function decodeTerrariumHeight(tile, pixelX, pixelY) {
const safeX = THREE.MathUtils.clamp(pixelX, 0, tile.width - 1);
const safeY = THREE.MathUtils.clamp(pixelY, 0, tile.height - 1);
@@ -168,19 +260,39 @@ async function runWithConcurrency(items, limit, worker) {
async function fetchRequiredTiles(samples) {
const uniqueKeys = Array.from(
new Set(samples.map((sample) => `${TERRAIN_CONFIG.baseZoom}/${sample.tileX}/${sample.tileY}`)),
new Set(
samples.map((sample) =>
buildTerrainTileKey(TERRAIN_CONFIG.baseZoom, sample.tileX, sample.tileY),
),
),
);
const resolvedTiles = new Map();
const batchSize = Math.max(1, Number(TERRAIN_CONFIG.batchRequestSize) || 1);
const batchChunks = [];
for (let i = 0; i < uniqueKeys.length; i += batchSize) {
batchChunks.push(uniqueKeys.slice(i, i + batchSize));
}
await runWithConcurrency(
uniqueKeys,
batchChunks,
TERRAIN_CONFIG.maxConcurrentRequests,
async (key) => {
const [z, x, y] = key.split("/").map(Number);
resolvedTiles.set(key, await decodeTerrainTile(z, x, y));
async (keys) => {
await fetchTerrainTileBatch(keys);
},
);
await Promise.all(
uniqueKeys.map(async (key) => {
const tilePromise = terrainTileCache.get(key);
if (!tilePromise) {
const [z, x, y] = key.split("/").map(Number);
resolvedTiles.set(key, await decodeTerrainTile(z, x, y));
return;
}
resolvedTiles.set(key, await tilePromise);
}),
);
return resolvedTiles;
}

View File

@@ -1,10 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import axios from 'axios'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import SegmentedControl from '../../components/SegmentedControl/SegmentedControl'
import { useAuthStore } from '../../stores/auth'
import {
createHeadingIdResolver,
defaultDocsSlug,
@@ -15,12 +17,23 @@ import {
groupDocsEntries,
slugFromDocsHref,
} from './docs-content'
import type { DocsHeading, DocsLang } from './docs-content'
import type { DocsCatalogItem, DocsEntry, DocsHeading, DocsLang } from './docs-content'
import { buildDocsSearchRecords, searchDocs } from './docs-search'
import type { DocsSearchRecord } from './docs-search'
import './Docs.css'
type DocsThemeMode = 'system' | 'light' | 'dark'
type DocsErrorState = 'none' | 'unauthenticated' | 'forbidden' | 'not_found' | 'load_failed'
interface DocsCatalogResponse {
items: DocsCatalogItem[]
authenticated: boolean
}
interface DocsContentResponse extends DocsEntry {
lang: DocsLang
markdown: string
}
const MIN_TOC_HEADING_LEVEL = 2
const MAX_TOC_HEADING_LEVEL = 3
@@ -56,11 +69,16 @@ function getSystemTheme(): 'light' | 'dark' {
export default function Docs() {
const { slug } = useParams()
const navigate = useNavigate()
const { token } = useAuthStore()
const [lang, setLang] = useState<DocsLang>(readStoredLang)
const [themeMode, setThemeMode] = useState<DocsThemeMode>(readStoredThemeMode)
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme)
const [catalogItems, setCatalogItems] = useState<DocsCatalogItem[]>([])
const [isCatalogLoading, setIsCatalogLoading] = useState(true)
const [markdown, setMarkdown] = useState('')
const [activeContentEntry, setActiveContentEntry] = useState<DocsEntry | null>(null)
const [docError, setDocError] = useState<DocsErrorState>('none')
const [isLoading, setIsLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [isSearchOpen, setIsSearchOpen] = useState(false)
@@ -69,9 +87,10 @@ export default function Docs() {
const articleRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLDivElement>(null)
const docsEntries = useMemo(() => getDocsEntries(lang), [lang])
const docsEntries = useMemo(() => getDocsEntries(lang, catalogItems), [catalogItems, lang])
const activeSlug = slug || defaultDocsSlug
const activeEntry = useMemo(() => getDocsEntry(activeSlug, lang), [activeSlug, lang])
const activeEntry = useMemo(() => getDocsEntry(activeSlug, docsEntries), [activeSlug, docsEntries])
const activeHeaderEntry = activeEntry || activeContentEntry
const groupedEntries = useMemo(() => groupDocsEntries(docsEntries), [docsEntries])
const effectiveTheme = themeMode === 'system' ? systemTheme : themeMode
const langOptions = useMemo(() => [
@@ -140,20 +159,54 @@ export default function Docs() {
useEffect(() => {
let isCancelled = false
if (!activeEntry) {
setMarkdown('')
return
}
setIsCatalogLoading(true)
axios.get<DocsCatalogResponse>('/api/v1/docs/catalog')
.then((response) => {
if (!isCancelled) setCatalogItems(response.data.items || [])
})
.catch(() => {
if (!isCancelled) setCatalogItems([])
})
.finally(() => {
if (!isCancelled) setIsCatalogLoading(false)
})
return () => { isCancelled = true }
}, [token])
useEffect(() => {
let isCancelled = false
if (isCatalogLoading) return
setIsLoading(true)
activeEntry.loader()
.then((content) => {
if (!isCancelled) setMarkdown(content)
setDocError('none')
setMarkdown('')
setActiveContentEntry(activeEntry || null)
axios.get<DocsContentResponse>(`/api/v1/docs/${lang}/${activeSlug}`)
.then((response) => {
if (isCancelled) return
const content = response.data
setMarkdown(content.markdown)
setActiveContentEntry({
slug: content.slug,
filename: content.filename,
title: content.title,
group: content.group,
order: content.order,
access: content.access,
})
})
.catch((error) => {
if (isCancelled) return
const status = (error as { response?: { status?: number } })?.response?.status
if (status === 401) setDocError('unauthenticated')
else if (status === 403) setDocError('forbidden')
else if (status === 404) setDocError('not_found')
else setDocError('load_failed')
})
.finally(() => {
if (!isCancelled) setIsLoading(false)
})
return () => { isCancelled = true }
}, [activeEntry])
}, [activeEntry, activeSlug, isCatalogLoading, lang])
useEffect(() => {
if (!markdown || !window.location.hash) return
@@ -164,11 +217,17 @@ export default function Docs() {
useEffect(() => {
let isCancelled = false
buildDocsSearchRecords(docsEntries).then((records) => {
const loadMarkdown = async (entry: DocsEntry) => {
const response = await axios.get<DocsContentResponse>(`/api/v1/docs/${lang}/${entry.slug}`)
return response.data.markdown
}
buildDocsSearchRecords(docsEntries, loadMarkdown).then((records) => {
if (!isCancelled) setSearchRecords(records)
}).catch(() => {
if (!isCancelled) setSearchRecords([])
})
return () => { isCancelled = true }
}, [docsEntries])
}, [docsEntries, lang])
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
@@ -243,9 +302,9 @@ export default function Docs() {
// Sidebar H2 sub-items for active Manual doc
const sidebarSubHeadings = useMemo(() => {
if (activeEntry?.group !== 'Manual' || headings.length === 0) return []
if (activeHeaderEntry?.group !== 'Manual' || headings.length === 0) return []
return headings.filter((h) => h.level === 2)
}, [activeEntry, headings])
}, [activeHeaderEntry, headings])
return (
<main className="docs-page" data-theme={effectiveTheme}>
@@ -326,9 +385,11 @@ export default function Docs() {
<header className="docs-header">
<div>
<p className="docs-header__eyebrow">
{activeEntry ? getDocsGroupLabel(activeEntry.group, lang) : 'Docs'}
{activeHeaderEntry ? getDocsGroupLabel(activeHeaderEntry.group, lang) : 'Docs'}
</p>
<h1 className="docs-header__title">{activeEntry?.title || 'Document not found'}</h1>
<h1 className="docs-header__title">
{activeHeaderEntry?.title || (lang === 'zh' ? '文档不可用' : 'Document unavailable')}
</h1>
</div>
<div className="docs-search" ref={searchRef}>
@@ -382,30 +443,50 @@ export default function Docs() {
<div className="docs-content-layout">
<Scrollbar className="docs-article" viewportRef={articleRef}>
{activeEntry ? (
isLoading ? (
{isCatalogLoading || isLoading ? (
<div className="docs-state">
{lang === 'zh' ? '加载中...' : 'Loading document...'}
</div>
) : (
) : docError === 'none' ? (
<MarkdownRenderer
markdown={markdown}
className="docs-markdown"
getHeadingId={makeHeadingIdResolver}
transformLink={transformLink}
/>
)
) : (
<div className="docs-not-found">
<h2>{lang === 'zh' ? '文档未找到' : 'Document not found'}</h2>
<p>
{lang === 'zh'
? '请求的文档不在公开文档集中。'
: 'The requested guide is not part of the public technical documentation set.'}
</p>
<Link to="/docs">
{lang === 'zh' ? '返回文档首页' : 'Return to docs overview'}
</Link>
{docError === 'unauthenticated' ? (
<>
<h2>{lang === 'zh' ? '需要登录' : 'Login required'}</h2>
<p>
{lang === 'zh'
? '这份文档需要登录并具备对应 Gatekeeper 权限组后才能阅读。'
: 'This document requires login and the matching Gatekeeper permission group.'}
</p>
<Link to="/admin">{lang === 'zh' ? '前往登录' : 'Go to login'}</Link>
</>
) : docError === 'forbidden' ? (
<>
<h2>{lang === 'zh' ? '无权访问' : 'Permission required'}</h2>
<p>
{lang === 'zh'
? '当前账号没有阅读这份文档所需的 Gatekeeper 权限组。'
: 'Your account does not have the Gatekeeper permission group required for this document.'}
</p>
<Link to="/docs">{lang === 'zh' ? '返回文档首页' : 'Return to docs overview'}</Link>
</>
) : (
<>
<h2>{lang === 'zh' ? '文档未找到' : 'Document not found'}</h2>
<p>
{lang === 'zh'
? '请求的文档不存在,或当前语言没有对应内容。'
: 'The requested guide does not exist or is not available in the current language.'}
</p>
<Link to="/docs">{lang === 'zh' ? '返回文档首页' : 'Return to docs overview'}</Link>
</>
)}
</div>
)}
</Scrollbar>

View File

@@ -7,7 +7,19 @@ export interface DocsEntry {
title: string
group: DocsGroup
order: number
loader: () => Promise<string>
access: DocsAccess
}
export type DocsAccess = 'public' | 'docs_user' | 'docs_developer' | 'docs_admin'
export interface DocsCatalogItem {
slug: string
filename: string
lang: DocsLang
title: string
group: DocsGroup
order: number
access: DocsAccess
}
export interface DocsHeading {
@@ -16,7 +28,7 @@ export interface DocsHeading {
text: string
}
interface DocsMetadataEntry {
export interface DocsMetadataEntry {
zh: { title: string; group: DocsGroup; order: number }
en: { title: string; group: DocsGroup; order: number }
}
@@ -48,17 +60,7 @@ const DOCS_README_FILENAME = 'README.md'
const MAX_HEADING_ID_LENGTH = 80
export const defaultDocsSlug = 'overview'
const zhModules = import.meta.glob('../../../../docs/technical/zh/*.md', {
query: '?raw',
import: 'default',
})
const enModules = import.meta.glob('../../../../docs/technical/en/*.md', {
query: '?raw',
import: 'default',
})
const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
export const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
[DOCS_README_FILENAME]: {
zh: { title: '技术文档', group: 'Overview', order: 0 },
en: { title: 'Technical Docs', group: 'Overview', order: 0 },
@@ -71,6 +73,10 @@ const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
zh: { title: 'Planet 使用手册', group: 'Manual', order: 2 },
en: { title: 'Planet Manual', group: 'Manual', order: 2 },
},
'location-pipeline-user.md': {
zh: { title: 'Earth 位置候选采集使用手册', group: 'Manual', order: 3 },
en: { title: 'Earth Location Candidate Collection User Guide', group: 'Manual', order: 3 },
},
'earth-frontend-context.md': {
zh: { title: 'Earth 前端结构', group: 'Earth', order: 10 },
en: { title: 'Earth Frontend Context', group: 'Earth', order: 10 },
@@ -99,6 +105,10 @@ const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
zh: { title: 'Earth 可交互图标接入', group: 'Earth', order: 16 },
en: { title: 'Earth Interactable Usage', group: 'Earth', order: 16 },
},
'earth-toolbar-overlay-coordination.md': {
zh: { title: 'Earth 工具栏与浮层协同', group: 'Earth', order: 17 },
en: { title: 'Earth Toolbar and Overlay Coordination', group: 'Earth', order: 17 },
},
'frontend-admin-frontend-context.md': {
zh: { title: '控制台前端结构', group: 'Frontend', order: 20 },
en: { title: 'Admin Frontend Context', group: 'Frontend', order: 20 },
@@ -107,6 +117,10 @@ const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
zh: { title: '前端布局指南', group: 'Frontend', order: 21 },
en: { title: 'Frontend Layout Guidelines', group: 'Frontend', order: 21 },
},
'docs-gatekeeper-development.md': {
zh: { title: 'Docs Gatekeeper 开发说明', group: 'Frontend', order: 22 },
en: { title: 'Docs Gatekeeper Development Guide', group: 'Frontend', order: 22 },
},
'backend-collectors.md': {
zh: { title: '数据采集系统', group: 'Backend', order: 30 },
en: { title: 'Data Collectors', group: 'Backend', order: 30 },
@@ -119,6 +133,14 @@ const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
zh: { title: '数据源、采集器设置与连接验证', group: 'Backend', order: 32 },
en: { title: 'Datasource Collector Settings and Connectivity', group: 'Backend', order: 32 },
},
'backend-datasources-api-performance.md': {
zh: { title: '数据源 API 性能', group: 'Backend', order: 33 },
en: { title: 'Datasource API Performance', group: 'Backend', order: 33 },
},
'location-pipeline-development.md': {
zh: { title: '通用位置估算管线开发说明', group: 'Backend', order: 34 },
en: { title: 'Shared Location Resolution Pipeline Development Guide', group: 'Backend', order: 34 },
},
'agents-aiprovider.md': {
zh: { title: 'AI Provider 指南', group: 'Agents', order: 40 },
en: { title: 'AI Provider Guide', group: 'Agents', order: 40 },
@@ -127,47 +149,35 @@ const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
zh: { title: 'Docker + Compose + Buildx 升级', group: 'Ops', order: 50 },
en: { title: 'Docker + Compose + Buildx Upgrade', group: 'Ops', order: 50 },
},
'ops-planet-sh-startup.md': {
zh: { title: 'planet.sh 启动机制', group: 'Ops', order: 51 },
en: { title: 'planet.sh Startup', group: 'Ops', order: 51 },
},
}
const GROUP_ORDER: DocsGroup[] = ['Overview', 'Manual', 'Earth', 'Frontend', 'Backend', 'Agents', 'Ops', 'Other']
const ALL_KNOWN_SLUGS = new Set(
Object.keys(DOCS_METADATA).map((filename) =>
filename === DOCS_README_FILENAME ? defaultDocsSlug : filename.replace(/\.md$/, '')
)
)
function filenameFromPath(path: string): string {
return path.split('/').pop() || path
}
export function slugFromFilename(filename: string): string {
return filename === DOCS_README_FILENAME ? defaultDocsSlug : filename.replace(/\.md$/, '')
}
export function getDocsEntries(lang: DocsLang): DocsEntry[] {
const modules = lang === 'zh' ? zhModules : enModules
return Object.entries(modules)
.filter(([path]) => DOCS_METADATA[filenameFromPath(path)])
.map(([path, loader]) => {
const filename = filenameFromPath(path)
const meta = DOCS_METADATA[filename]
const langMeta = meta[lang]
return {
slug: slugFromFilename(filename),
filename,
title: langMeta.title,
group: langMeta.group,
order: langMeta.order,
loader: loader as () => Promise<string>,
}
})
export function getDocsEntries(lang: DocsLang, catalogItems: DocsCatalogItem[]): DocsEntry[] {
return catalogItems
.filter((item) => item.lang === lang)
.map((item) => ({
slug: item.slug,
filename: item.filename,
title: item.title,
group: item.group,
order: item.order,
access: item.access,
}))
.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title))
}
export function getDocsEntry(slug: string | undefined, lang: DocsLang): DocsEntry | undefined {
export function getDocsEntry(slug: string | undefined, entries: DocsEntry[]): DocsEntry | undefined {
const normalizedSlug = slug || defaultDocsSlug
return getDocsEntries(lang).find((entry) => entry.slug === normalizedSlug)
return entries.find((entry) => entry.slug === normalizedSlug)
}
export function groupDocsEntries(entries: DocsEntry[]): Array<{ group: DocsGroup; entries: DocsEntry[] }> {
@@ -236,6 +246,5 @@ export function slugFromDocsHref(href: string): string | null {
return null
}
const slug = slugFromFilename(filename)
return ALL_KNOWN_SLUGS.has(slug) ? slug : null
return slugFromFilename(filename)
}

View File

@@ -52,10 +52,13 @@ function createExcerpt(text: string, query: string): string {
return `${prefix}${text.slice(start, end)}${suffix}`
}
export async function buildDocsSearchRecords(entries: DocsEntry[]): Promise<DocsSearchRecord[]> {
export async function buildDocsSearchRecords(
entries: DocsEntry[],
loadMarkdown: (entry: DocsEntry) => Promise<string>,
): Promise<DocsSearchRecord[]> {
const records = await Promise.all(
entries.map(async (entry) => {
const markdown = await entry.loader()
const markdown = await loadMarkdown(entry)
return {
entry,
markdown,

View File

@@ -6,17 +6,20 @@ import { TableActions, actionCellProps } from '../../components/TableActions/Tab
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import { useAuthStore } from '../../stores/auth'
interface User {
id: number
username: string
email: string
role: string
gatekeeper_groups: string[]
is_active: boolean
created_at: string
}
function Users() {
const { user: currentUser } = useAuthStore()
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
@@ -64,6 +67,9 @@ function Users() {
const handleSubmit = async (values: Record<string, unknown>) => {
try {
if (currentUser?.role !== 'super_admin') {
delete values.gatekeeper_groups
}
if (editingUser) {
await axios.put(`/api/v1/users/${editingUser.id}`, values)
message.success('更新成功')
@@ -98,6 +104,21 @@ function Users() {
return <Tag color={colors[role] || 'default'}>{role}</Tag>
},
},
{
title: 'Gatekeeper',
dataIndex: 'gatekeeper_groups',
key: 'gatekeeper_groups',
width: 260,
render: (groups: string[] = []) => (
<>
{groups.length > 0 ? groups.map((group) => (
<Tag key={group} color={group === 'docs_admin' ? 'red' : group === 'docs_developer' ? 'blue' : 'green'}>
{group}
</Tag>
)) : <Tag></Tag>}
</>
),
},
{
title: '状态',
dataIndex: 'is_active',
@@ -177,6 +198,18 @@ function Users() {
<Select.Option value="viewer"></Select.Option>
</Select>
</Form.Item>
<Form.Item name="gatekeeper_groups" label="Gatekeeper 权限组">
<Select
mode="multiple"
disabled={currentUser?.role !== 'super_admin'}
placeholder="选择 Docs 鉴权权限组"
options={[
{ value: 'docs_user', label: 'Docs 用户文档' },
{ value: 'docs_developer', label: 'Docs 开发文档' },
{ value: 'docs_admin', label: 'Docs 管理/运维文档' },
]}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block></Button>
</Form.Item>

View File

@@ -6,6 +6,7 @@ interface User {
id: number
username: string
role: string
gatekeeper_groups?: string[]
}
interface AuthState {