release: bump version to 0.71.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled

This commit is contained in:
linkong
2026-06-11 16:47:24 +08:00
parent 8c204717cd
commit 899e3bce43
56 changed files with 4618 additions and 260 deletions

View File

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

View File

@@ -1173,6 +1173,28 @@
<button type="button" class="earth-mobile-settings-pill" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
</div>
</div>
<div
class="earth-mobile-settings-card earth-mobile-settings-card--stacked"
data-gatekeeper-permission="earth.motion_debug"
>
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">识别动作</span>
<span class="earth-mobile-settings-subtitle">关闭后不会触发对应星球控制</span>
</div>
<div class="earth-mobile-settings-chip-grid" role="group" aria-label="移动端选择动捕识别动作">
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="rotate_left" aria-pressed="true">左旋</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="rotate_right" aria-pressed="true">右旋</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="rotate_up" aria-pressed="true">上旋</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="rotate_down" aria-pressed="true">下旋</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="zoom_in" aria-pressed="true">放大</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="zoom_out" aria-pressed="true">缩小</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="focus_prev" aria-pressed="true">上个焦点</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="focus_next" aria-pressed="true">下个焦点</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="layer_prev" aria-pressed="true">上一图层</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="layer_next" aria-pressed="true">下一图层</button>
<button type="button" class="earth-mobile-settings-chip is-active" data-motion-gesture-toggle="confirm" aria-pressed="true">确认</button>
</div>
</div>
</div>
<div class="earth-mobile-settings-group" data-settings-tab-panel="shortcuts" hidden>
<div class="earth-mobile-settings-title">快捷键</div>
@@ -1705,6 +1727,28 @@
<button type="button" class="earth-settings-segmented-btn" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
</div>
</div>
<div
class="earth-settings-item earth-settings-item--stacked"
data-gatekeeper-permission="earth.motion_debug"
>
<div class="earth-settings-copy">
<span class="earth-settings-item-title">识别动作</span>
<span class="earth-settings-item-subtitle">未勾选的动作不会触发星球端控制Motion Agent 会同步过滤这些动作</span>
</div>
<div class="earth-settings-chip-grid" role="group" aria-label="选择动捕识别动作">
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="rotate_left" aria-pressed="true">左旋</button>
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="rotate_right" aria-pressed="true">右旋</button>
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="rotate_up" aria-pressed="true">上旋</button>
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="rotate_down" aria-pressed="true">下旋</button>
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="zoom_in" aria-pressed="true">放大</button>
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="zoom_out" aria-pressed="true">缩小</button>
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="focus_prev" aria-pressed="true">上个焦点</button>
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="focus_next" aria-pressed="true">下个焦点</button>
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="layer_prev" aria-pressed="true">上一图层</button>
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="layer_next" aria-pressed="true">下一图层</button>
<button type="button" class="earth-settings-chip is-active" data-motion-gesture-toggle="confirm" aria-pressed="true">确认</button>
</div>
</div>
</div>
</section>
<section class="earth-settings-section" data-settings-tab-panel="shortcuts" hidden>

View File

@@ -111,6 +111,7 @@ import {
} from "./layer-button-state.js";
import {
DEFAULT_MOTION_PROVIDER,
MOTION_GESTURES,
normalizeMotionProvider,
} from "./motion-protocol.js";
@@ -125,6 +126,7 @@ let autoRotationSpeed = CONFIG.rotationSpeed;
let motionDebugEnabled = false;
let motionProvider = DEFAULT_MOTION_PROVIDER;
let motionDebugSkeletonOnly = false;
let motionEnabledGestures = [];
let activeCamera = null;
let settingsApplyPromise = Promise.resolve();
let boundaryBuildPollTimer = null;
@@ -159,7 +161,7 @@ const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2";
const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
const EARTH_SETTINGS_VERSION = 16;
const EARTH_SETTINGS_VERSION = 17;
const GRID_LINES_DEFAULT_VERSION = 3;
const SATELLITE_DISPLAY_DEFAULT_VERSION = 4;
const MEDIA_PANEL_DEFAULT_VERSION = 5;
@@ -173,6 +175,22 @@ const KEYBOARD_SHORTCUTS_DEFAULT_VERSION = 13;
const CRUISE_QUEUE_DEFAULT_VERSION = 14;
const AUTO_ROTATION_SPEED_DEFAULT_VERSION = 15;
const NEWS_CATEGORY_FILTERS_DEFAULT_VERSION = 16;
const MOTION_GESTURES_DEFAULT_VERSION = 17;
const MOTION_GESTURE_DEFINITIONS = [
{ id: "rotate_left", label: "左旋" },
{ id: "rotate_right", label: "右旋" },
{ id: "rotate_up", label: "上旋" },
{ id: "rotate_down", label: "下旋" },
{ id: "zoom_in", label: "放大" },
{ id: "zoom_out", label: "缩小" },
{ id: "focus_prev", label: "上个焦点" },
{ id: "focus_next", label: "下个焦点" },
{ id: "layer_prev", label: "上一图层" },
{ id: "layer_next", label: "下一图层" },
{ id: "confirm", label: "确认" },
];
const DEFAULT_MOTION_ENABLED_GESTURES = MOTION_GESTURE_DEFINITIONS.map((item) => item.id);
motionEnabledGestures = [...DEFAULT_MOTION_ENABLED_GESTURES];
const DEFAULT_NEWS_CATEGORY_FILTERS = {
politics: true,
business: true,
@@ -1427,6 +1445,7 @@ function getCurrentSharedSettingsSnapshot() {
motionDebugEnabled,
motionProvider,
motionDebugSkeletonOnly,
motionEnabledGestures: getMotionEnabledGestures(),
mediaPanelActiveTab: normalizeMediaPanelActiveTab(getActiveTVTab()),
satelliteIdleBreathingEnabled: getSatelliteIdleBreathingEnabled(),
satelliteRealAltitudeEnabled: getSatelliteRealAltitudeEnabled(),
@@ -1489,6 +1508,7 @@ function cloneEarthSettings(settings) {
DEFAULT_MOTION_PROVIDER,
),
motionDebugSkeletonOnly: Boolean(settings.shared.motionDebugSkeletonOnly),
motionEnabledGestures: normalizeMotionEnabledGestures(settings.shared.motionEnabledGestures),
mediaPanelActiveTab: normalizeMediaPanelActiveTab(settings.shared.mediaPanelActiveTab),
satelliteIdleBreathingEnabled:
settings.shared.satelliteIdleBreathingEnabled !== false,
@@ -1621,6 +1641,10 @@ function normalizeEarthSettings(rawSettings, defaults) {
typeof sharedSettings?.motionDebugSkeletonOnly === "boolean"
? sharedSettings.motionDebugSkeletonOnly
: defaults.shared.motionDebugSkeletonOnly;
const nextMotionEnabledGestures =
(rawSettings?.version || 0) >= MOTION_GESTURES_DEFAULT_VERSION
? normalizeMotionEnabledGestures(sharedSettings?.motionEnabledGestures)
: normalizeMotionEnabledGestures(defaults.shared.motionEnabledGestures);
const nextMediaPanelActiveTab =
(rawSettings?.version || 0) >= MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION
? normalizeMediaPanelActiveTab(sharedSettings?.mediaPanelActiveTab)
@@ -1689,6 +1713,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
motionDebugEnabled: nextMotionDebugEnabled,
motionProvider: nextMotionProvider,
motionDebugSkeletonOnly: nextMotionDebugSkeletonOnly,
motionEnabledGestures: nextMotionEnabledGestures,
mediaPanelActiveTab: nextMediaPanelActiveTab,
satelliteIdleBreathingEnabled: nextSatelliteIdleBreathingEnabled,
satelliteRealAltitudeEnabled: nextSatelliteRealAltitudeEnabled,
@@ -1746,6 +1771,17 @@ function syncMotionDebugSkeletonOnlyToggle(nextEnabled = motionDebugSkeletonOnly
});
}
function syncMotionGestureControls() {
const enabledGestures = new Set(getMotionEnabledGestures());
document.querySelectorAll("[data-motion-gesture-toggle]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const gesture = button.dataset.motionGestureToggle || "";
const active = enabledGestures.has(gesture);
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
}
function dispatchMotionSettingsChange() {
const effectiveDebugEnabled =
rotationMode === ROTATION_MODE.MOTION && autoRotate && motionDebugEnabled;
@@ -1756,6 +1792,7 @@ function dispatchMotionSettingsChange() {
preferredEnabled: motionDebugEnabled,
provider: motionProvider,
skeletonOnly: motionDebugSkeletonOnly,
enabledGestures: getMotionEnabledGestures(),
},
}),
);
@@ -1866,6 +1903,18 @@ function normalizeCruiseModules(nextModules) {
: [...DEFAULT_CRUISE_MODULES];
}
function normalizeMotionEnabledGestures(nextGestures) {
const sourceGestures = Array.isArray(nextGestures)
? nextGestures
: DEFAULT_MOTION_ENABLED_GESTURES;
const normalizedGestures = Array.from(
new Set(sourceGestures.filter((gesture) => MOTION_GESTURES.has(gesture))),
);
return normalizedGestures.length > 0
? normalizedGestures
: [...DEFAULT_MOTION_ENABLED_GESTURES];
}
function normalizeCruiseQueueMode(mode) {
return ALLOWED_CRUISE_QUEUE_MODES.has(mode) ? mode : DEFAULT_CRUISE_QUEUE_MODE;
}
@@ -2396,6 +2445,10 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
persist: false,
suppressStatus: true,
});
setMotionEnabledGestures(settings.shared.motionEnabledGestures, {
persist: false,
suppressStatus: true,
});
setActiveTVTab(settings.shared.mediaPanelActiveTab);
keyboardShortcuts = normalizeKeyboardShortcuts(settings.shared.keyboardShortcuts);
renderShortcutSettings();
@@ -2428,6 +2481,10 @@ export function getMotionDebugSkeletonOnly() {
return motionDebugSkeletonOnly;
}
export function getMotionEnabledGestures() {
return normalizeMotionEnabledGestures(motionEnabledGestures);
}
export function setMotionDebugEnabled(
nextEnabled,
{ persist = true, suppressStatus = false } = {},
@@ -2517,6 +2574,34 @@ export function setMotionDebugSkeletonOnly(
return motionDebugSkeletonOnly;
}
export function setMotionEnabledGestures(
nextGestures,
{ persist = true, suppressStatus = false } = {},
) {
const normalized = normalizeMotionEnabledGestures(nextGestures);
const previous = getMotionEnabledGestures();
const changed =
normalized.length !== previous.length ||
normalized.some((gesture, index) => previous[index] !== gesture);
motionEnabledGestures = normalized;
syncMotionGestureControls();
ensureMutableEarthSettingsState();
earthSettingsState.shared.motionEnabledGestures = [...motionEnabledGestures];
if (changed) {
dispatchMotionSettingsChange();
}
if (persist) {
persistEarthSettings();
}
if (!suppressStatus && changed) {
showStatusMessage("动捕识别动作已更新", "info");
}
return getMotionEnabledGestures();
}
export async function applyDeferredLayerVisibilitySettings(options = {}) {
const layerVisibility = deferredLayerVisibilitySettings;
deferredLayerVisibilitySettings = null;
@@ -3886,6 +3971,7 @@ function integrateMotionSettingsIntoRuntime() {
markRuntimeModeSection("[data-auto-rotation-speed-slider]", ROTATION_MODE.ROTATE);
markRuntimeModeSection("[data-motion-debug-toggle]", ROTATION_MODE.MOTION);
markRuntimeModeSection("[data-motion-provider]", ROTATION_MODE.MOTION);
markRuntimeModeSection("[data-motion-gesture-toggle]", ROTATION_MODE.MOTION);
syncRuntimeModeSections();
}
@@ -4434,6 +4520,21 @@ function setupSettingsControls() {
});
});
document.querySelectorAll("[data-motion-gesture-toggle]").forEach((motionGestureButton) => {
if (!(motionGestureButton instanceof HTMLButtonElement)) return;
bindListener(motionGestureButton, "click", () => {
const gesture = motionGestureButton.dataset.motionGestureToggle;
if (!gesture) return;
const nextGestures = new Set(getMotionEnabledGestures());
if (nextGestures.has(gesture)) {
nextGestures.delete(gesture);
} else {
nextGestures.add(gesture);
}
setMotionEnabledGestures(Array.from(nextGestures));
});
});
const mobileSettingsReset = document.getElementById("mobile-settings-reset");
bindListener(mobileSettingsReset, "click", () => {
resetEarthSettings();
@@ -4454,6 +4555,7 @@ function setupSettingsControls() {
syncMotionDebugToggle(motionDebugEnabled);
syncMotionProviderControls(motionProvider);
syncMotionDebugSkeletonOnlyToggle(motionDebugSkeletonOnly);
syncMotionGestureControls();
void setupBoundaryPrecisionControls();
}

View File

@@ -165,3 +165,21 @@ export function applyEarthInteractableEvent(earth, payload = {}) {
export function getEarthInteractableMarkers() {
return earthInteractableLayer.getMarkers();
}
export function getEarthInteractablePointerIntersections(options = {}) {
return earthInteractableLayer.getPointerIntersections(options);
}
export function setEarthInteractableMarkerState(marker, state = "normal") {
earthInteractableLayer.setMarkerState(marker, state);
}
export function clearEarthInteractableSelection() {
earthInteractableLayer.getMarkers().forEach((marker) => {
earthInteractableLayer.setMarkerState(marker, "normal");
});
}
export function updateEarthInteractableVisualState(focusType, focusObject, camera) {
earthInteractableLayer.updateVisualState(focusType, focusObject, camera);
}

View File

@@ -1677,6 +1677,22 @@ const CARD_CONFIG = {
{ key: 'length', label: '船长', unit: 'm' },
{ key: 'received_at', label: '更新时间' }
]
},
earth_interactable: {
icon: '📍',
title: '交互点详情',
className: 'earth_interactable',
fields: [
{ key: 'label', label: '名称' },
{ key: 'kind', label: '类型' },
{ key: 'id', label: '标识' },
{ key: 'latitude', label: '纬度' },
{ key: 'longitude', label: '经度' },
{ key: 'description', label: '说明' },
{ key: 'source', label: '来源' },
{ key: 'status', label: '状态' },
{ key: 'updated_at', label: '更新时间' }
]
}
};

View File

@@ -210,9 +210,13 @@ import {
} from "./vessels.js";
import {
applyEarthInteractableEvent,
clearEarthInteractableSelection,
clearEarthInteractables,
getEarthInteractablePointerIntersections as getEarthInteractableIconPointerIntersections,
loadEarthInteractables,
refreshEarthInteractables,
setEarthInteractableMarkerState,
updateEarthInteractableVisualState,
} from "./earth-interactables.js";
import {
setupControls,
@@ -235,6 +239,7 @@ import {
getDayNightEnabled,
getMotionDebugEnabled,
getMotionDebugSkeletonOnly,
getMotionEnabledGestures,
getMotionProvider,
getVisibleMotionLayerDefinitions,
setMotionDebugEnabled,
@@ -317,6 +322,7 @@ let hoveredCable = null;
let hoveredBGP = null;
let hoveredComputeCenter = null;
let hoveredVessel = null;
let hoveredEarthInteractable = null;
let hoveredSatellite = null;
let hoveredSatelliteIndex = null;
let lockedSatellite = null;
@@ -618,6 +624,7 @@ export function clearLockedObject() {
clearBGPSelection();
clearComputeCenterSelection();
clearVesselSelection();
clearEarthInteractableSelection();
clearRelatedSatelliteHighlights();
setSatelliteRingState(null, "none", null);
clearRuntimeSelection();
@@ -692,9 +699,11 @@ function clearTransientHoverState() {
resetTransientBGPStates();
resetTransientComputeCenterStates();
resetTransientVesselStates();
resetTransientEarthInteractableStates();
hoveredBGP = null;
hoveredComputeCenter = null;
hoveredVessel = null;
hoveredEarthInteractable = null;
if (hoveredCable && !isSameCable(hoveredCable, lockedObject)) {
setCableState(hoveredCable.userData.cableId, CABLE_STATE.NORMAL);
@@ -720,6 +729,17 @@ function getVesselPointerIntersections() {
});
}
function getEarthInteractablePointerIntersections() {
const earth = getEarth();
return getEarthInteractableIconPointerIntersections({
earth,
camera,
pointer: interactionMouse,
radiusPx: INTERACTABLE_POINTER_RADIUS_PX,
frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
});
}
function getBGPEventPointerIntersections() {
const earth = getEarth();
return getBGPEventIconPointerIntersections({
@@ -824,6 +844,30 @@ function isSameVessel(marker1, marker2) {
return Boolean(marker1 && marker2 && marker1.userData?.mmsi === marker2.userData?.mmsi);
}
function isSameEarthInteractable(marker1, marker2) {
return Boolean(marker1 && marker2 && marker1.userData?.id === marker2.userData?.id);
}
function resetTransientEarthInteractableStates() {
if (hoveredEarthInteractable && hoveredEarthInteractable !== lockedObject) {
setEarthInteractableMarkerState(hoveredEarthInteractable, "normal");
}
hoveredEarthInteractable = null;
}
function applyEarthInteractableHoverState(marker) {
if (isSameEarthInteractable(hoveredEarthInteractable, marker)) return;
resetTransientEarthInteractableStates();
if (!marker) {
hoveredEarthInteractable = null;
return;
}
hoveredEarthInteractable = marker;
if (marker !== lockedObject) {
setEarthInteractableMarkerState(marker, "hover");
}
}
function getFirstObjectIntersection(intersections) {
return intersections.find((hit) => !hit?.cluster && hit?.object)?.object || null;
}
@@ -943,7 +987,8 @@ function getMotionCandidateScreenCoords(candidate) {
candidate.type === "bgp" ||
candidate.type === "bgp_collector" ||
candidate.type === "compute_center" ||
candidate.type === "vessel"
candidate.type === "vessel" ||
candidate.type === "earth_interactable"
) {
return getMarkerMotionScreenPoint(candidate.object) || candidate.screen || getMotionCenterScreenPoint();
}
@@ -971,7 +1016,8 @@ function getMotionCandidateAnchor(candidate) {
candidate.type === "bgp" ||
candidate.type === "bgp_collector" ||
candidate.type === "compute_center" ||
candidate.type === "vessel"
candidate.type === "vessel" ||
candidate.type === "earth_interactable"
) {
return getMotionAnchorRectFromCenter(
getMarkerMotionScreenPoint(candidate.object) || candidate.screen,
@@ -1021,6 +1067,10 @@ function getMotionIconCandidates() {
candidates.push({ type: "vessel", object: hit.object, screen: getMotionScreenPointFromWorld(hit.point), distancePxSq: hit.distancePxSq });
});
}
getEarthInteractableIconPointerIntersections(sharedOptions).forEach((hit) => {
if (hit.cluster || !hit.object) return;
candidates.push({ type: "earth_interactable", object: hit.object, screen: getMotionScreenPointFromWorld(hit.point), distancePxSq: hit.distancePxSq });
});
return candidates;
}
@@ -1096,7 +1146,7 @@ function collectMotionFocusCandidates() {
...getMotionSatelliteCandidates(),
...getMotionCableCandidates(),
]
.filter((candidate) => !layerId || getMotionCandidateLayerId(candidate) === layerId)
.filter((candidate) => candidate.type === "earth_interactable" || !layerId || getMotionCandidateLayerId(candidate) === layerId)
.sort((left, right) => left.distancePxSq - right.distancePxSq);
}
@@ -1107,6 +1157,7 @@ function getMotionCandidateLayerId(candidate) {
if (candidate.type === "bgp" || candidate.type === "bgp_collector") return "bgp";
if (candidate.type === "compute_center") return "computeCenters";
if (candidate.type === "vessel") return "vessels";
if (candidate.type === "earth_interactable") return "interactables";
return null;
}
@@ -1308,6 +1359,7 @@ function clearMotionFocusVisual() {
if (candidate.type === "bgp" || candidate.type === "bgp_collector") resetTransientBGPStates();
if (candidate.type === "compute_center") resetTransientComputeCenterStates();
if (candidate.type === "vessel") resetTransientVesselStates();
if (candidate.type === "earth_interactable") resetTransientEarthInteractableStates();
if (candidate.type === "satellite" && candidate.index !== lockedSatelliteIndex) {
setSatelliteRingState(candidate.index, "none", null);
setHoveredSatelliteIndex(null);
@@ -1331,6 +1383,8 @@ function applyMotionFocusVisual(candidate) {
applyComputeCenterHoverState(candidate.object);
} else if (candidate.type === "vessel") {
applyVesselHoverState(candidate.object);
} else if (candidate.type === "earth_interactable") {
applyEarthInteractableHoverState(candidate.object);
} else if (candidate.type === "satellite") {
hoveredSatelliteIndex = candidate.index;
hoveredSatellite = { properties: candidate.object };
@@ -1467,6 +1521,8 @@ function showMotionCandidateInfo(candidate, options = {}) {
showComputeCenterInfo(candidate.object, options);
} else if (candidate.type === "vessel") {
showVesselInfo(candidate.object, options);
} else if (candidate.type === "earth_interactable") {
showEarthInteractableInfo(candidate.object, options);
} else if (candidate.type === "cable") {
showCableInfo(candidate.object, options);
} else if (candidate.type === "satellite") {
@@ -1481,6 +1537,7 @@ function getMotionCandidateFocusCoords(candidate) {
if (candidate.type === "bgp" || candidate.type === "bgp_collector") return getBGPFocusCoords(candidate.object);
if (candidate.type === "compute_center") return getComputeCenterFocusCoords(candidate.object);
if (candidate.type === "vessel") return getVesselFocusCoords(candidate.object);
if (candidate.type === "earth_interactable") return getEarthInteractableFocusCoords(candidate.object);
return null;
}
@@ -1491,17 +1548,58 @@ function getMotionCandidateLabel(candidate) {
candidate.object?.userData?.name ||
candidate.object?.userData?.collector ||
candidate.object?.userData?.mmsi ||
candidate.object?.userData?.label ||
candidate.object?.userData?.id ||
"目标"
);
}
function confirmMotionCandidate(candidate) {
function getMotionCandidateFromCruiseItem(item) {
const payload = item?.payload || null;
if (!payload) return null;
if (
payload.type === "bgp" ||
payload.type === "bgp_collector" ||
payload.type === "compute_center" ||
payload.type === "vessel" ||
payload.type === "earth_interactable" ||
payload.type === "cable" ||
payload.type === "satellite"
) {
return payload;
}
const userDataType = payload?.userData?.type;
if (userDataType === "bgp" || userDataType === "bgp_collector" || userDataType === "earth_interactable") {
return { type: userDataType, object: payload };
}
return null;
}
function getCurrentMotionConfirmationCandidate() {
return (
motionFocusedCandidate ||
getMotionCandidateFromCruiseItem(motionCruiseSequencer?.getCurrentItem?.()) ||
getMotionCandidateFromCruiseItem(motionSharedCruiseSequencer?.getCurrentItem?.()) ||
motionFocusCandidates[motionFocusIndex] ||
null
);
}
function dismissMotionPresentationsForConfirmation() {
motionCruiseSequencer?.stop?.({ preservePresentation: false });
motionSharedCruiseSequencer?.stop?.({ preservePresentation: false });
presentationController?.dismiss?.("motion_confirm");
}
function confirmMotionCandidate(candidate, { showMotionStatus = true } = {}) {
const earth = getEarth();
if (!candidate || !earth) return false;
interruptCruisePresentation();
dismissMotionPresentationsForConfirmation();
clearLockedObject();
setAutoRotate(false);
const sourceCoords = candidate.screen || getMotionCenterScreenPoint();
const sourceCoords = candidate.screen || getMotionCandidateScreenCoords(candidate) || getMotionCenterScreenPoint();
if (candidate.type === "bgp") {
const marker = candidate.object;
@@ -1538,6 +1636,11 @@ function confirmMotionCandidate(candidate) {
showVesselTrack(marker, earth).catch((error) => {
console.warn("船只轨迹加载失败:", error);
});
} else if (candidate.type === "earth_interactable") {
const marker = candidate.object;
setEarthInteractableMarkerState(marker, "locked");
lockedObject = marker;
lockedObjectType = "earth_interactable";
} else if (candidate.type === "cable") {
const cable = candidate.object;
setCableState(cable.userData.cableId, CABLE_STATE.LOCKED);
@@ -1562,8 +1665,16 @@ function confirmMotionCandidate(candidate) {
}
motionFocusedCandidate = candidate;
showMotionCandidateInfo(candidate, {
x: sourceCoords.x,
y: sourceCoords.y,
absolute: true,
anchorStable: true,
});
window.dispatchEvent(new CustomEvent("earth:open-details-tab"));
showStatusMessage(`动捕: 已确认${getMotionCandidateLabel(candidate)}`, "info");
if (showMotionStatus) {
showStatusMessage(`动捕: 已确认${getMotionCandidateLabel(candidate)}`, "info");
}
return true;
}
@@ -1716,6 +1827,28 @@ function showVesselInfo(marker, coords) {
}, coords);
}
function showEarthInteractableInfo(marker, coords) {
const ud = marker?.userData || {};
showInfoCard("earth_interactable", {
id: ud.id || "-",
label: ud.label || ud.name || "-",
kind: ud.kind || "-",
latitude: Number.isFinite(Number(ud.latitude)) ? Number(ud.latitude).toFixed(4) : "-",
longitude: Number.isFinite(Number(ud.longitude)) ? Number(ud.longitude).toFixed(4) : "-",
description: ud.description || ud.summary || "-",
source: ud.source || "-",
status: ud.status || "-",
updated_at: ud.updated_at || ud.updatedAt || "-",
}, coords);
}
function getEarthInteractableBriefHtml(marker) {
const ud = marker?.userData || {};
const name = ud.label || ud.name || "交互点";
const kind = ud.kind || "数据点";
return `<strong>${name}</strong><br>${kind}`;
}
function getVesselBriefHtml(marker) {
const name = marker.userData?.name || `MMSI ${marker.userData?.mmsi}`;
const speed = marker.userData?.sog ?? "-";
@@ -1939,6 +2072,13 @@ function getVesselFocusCoords(marker) {
return { lat, lon };
}
function getEarthInteractableFocusCoords(marker) {
const lat = Number(marker?.userData?.latitude);
const lon = Number(marker?.userData?.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return { lat, lon };
}
async function focusSearchTarget(coords, zoom = Math.max(getZoomLevel(), 1.12)) {
if (!coords || !camera) return;
await focusEarthView(camera, {
@@ -4108,6 +4248,8 @@ function setupMotionControl() {
onFocus: applyMotionFocus,
onLayer: applyMotionLayerSwitch,
onStatus: (message, type) => showStatusMessage(message, type),
debugSkeleton: motionDebugEnabled,
enabledGestures: getMotionEnabledGestures(),
});
motionControlAdapter.start();
}
@@ -4157,10 +4299,10 @@ export function applyMotionConfirm() {
const releaseGate = beginMotionActionGate("confirm");
if (!releaseGate) return false;
try {
let candidate = motionFocusedCandidate || motionFocusCandidates[motionFocusIndex] || null;
let candidate = getCurrentMotionConfirmationCandidate();
if (!candidate) {
refreshMotionFocusCandidates({ force: true });
candidate = motionFocusedCandidate || motionFocusCandidates[motionFocusIndex] || null;
candidate = getCurrentMotionConfirmationCandidate();
}
if (candidate && confirmMotionCandidate(candidate)) {
return true;
@@ -5146,8 +5288,21 @@ function setupEventListeners() {
const handleComputeCenterUnresolvedCountChange = (event) => {
syncComputeCenterUnresolvedCount(event?.detail?.unresolvedCount ?? event?.detail);
};
const handleMotionDebugModeChange = () => {
setupMotionControl();
const handleMotionDebugModeChange = (event) => {
const motionModeActive = getRotationMode() === ROTATION_MODE.MOTION && getAutoRotate();
const nextEnabled = motionModeActive && Boolean(event?.detail?.enabled);
setMotionDebugPanelVisible(nextEnabled);
setMotionDebugPanelSkeletonOnly(Boolean(event?.detail?.skeletonOnly));
motionControlAdapter?.sendCommand?.(
"set_debug_options",
{ skeleton: nextEnabled },
"earth-motion-debug-toggle",
);
motionControlAdapter?.setEnabledGestures?.(
Array.isArray(event?.detail?.enabledGestures)
? event.detail.enabledGestures
: getMotionEnabledGestures(),
);
};
const handleMotionDebugClose = () => {
setMotionDebugEnabled(false);
@@ -5333,6 +5488,7 @@ function onMouseMove(event) {
: [];
const vesselPick = getVesselHoverIntersections();
const vesselIntersects = vesselPick.intersects;
const earthInteractableIntersects = getEarthInteractablePointerIntersections();
let hoveredSat = null;
let hoveredSatIndexFromIntersect = null;
@@ -5351,6 +5507,7 @@ function onMouseMove(event) {
bgpAnomalyIntersects,
bgpCollectorIntersects,
computeCenterIntersects,
earthInteractableIntersects,
);
if (hoveredBGP && !isSameBGPMarker(hoveredBGP, hoveredBGPMarker)) {
@@ -5360,6 +5517,7 @@ function onMouseMove(event) {
const hoveredComputeCenterMarker = getFirstObjectIntersection(computeCenterIntersects);
const hoveredVesselMarker =
vesselPick.checked && vesselIntersects.length > 0 ? vesselIntersects[0].object : null;
const hoveredEarthInteractableMarker = getFirstObjectIntersection(earthInteractableIntersects);
const earthPoint = screenToEarthCoords(
event.clientX,
event.clientY,
@@ -5402,6 +5560,12 @@ function onMouseMove(event) {
) {
clearTransientHoverState();
}
if (
hoveredEarthInteractable &&
!isSameEarthInteractable(hoveredEarthInteractable, hoveredEarthInteractableMarker)
) {
clearTransientHoverState();
}
if (
hoveredCable &&
@@ -5472,6 +5636,17 @@ function onMouseMove(event) {
getVesselBriefHtml(hoveredVesselMarker),
);
objectTooltipShown = true;
} else if (
hoveredEarthInteractableMarker &&
lockedObjectType !== "earth_interactable"
) {
applyEarthInteractableHoverState(hoveredEarthInteractableMarker);
showTooltip(
event.clientX + TOOLTIP_CURSOR_OFFSET,
event.clientY + TOOLTIP_CURSOR_OFFSET,
getEarthInteractableBriefHtml(hoveredEarthInteractableMarker),
);
objectTooltipShown = true;
} else if (cableIntersects.length > 0 && getShowCables()) {
const cable = cableIntersects[0].object;
hoveredCable = cable;
@@ -5504,6 +5679,8 @@ function onMouseMove(event) {
applyComputeCenterHoverState(lockedObject);
} else if (lockedObjectType === "vessel" && lockedObject) {
applyVesselHoverState(lockedObject);
} else if (lockedObjectType === "earth_interactable" && lockedObject) {
applyEarthInteractableHoverState(lockedObject);
} else if (
!lockedObjectType &&
!isCruisePresentationPinned() &&
@@ -5515,6 +5692,7 @@ function onMouseMove(event) {
if (vesselPick.checked) {
resetTransientVesselStates();
}
resetTransientEarthInteractableStates();
hideInfoCard();
}
@@ -5736,6 +5914,7 @@ function onClick(event) {
const vesselIntersects = getShowVessels()
? getVesselPointerIntersections()
: [];
const earthInteractableIntersects = getEarthInteractablePointerIntersections();
const satIntersects = getSatellitePointerIntersections(event);
const clickedBGPMarker = getShowBGP()
@@ -5745,6 +5924,7 @@ function onClick(event) {
const clickedVesselMarker = vesselIntersects.length > 0
? vesselIntersects[0].object
: null;
const clickedEarthInteractableMarker = getFirstObjectIntersection(earthInteractableIntersects);
if (clickedBGPMarker?.userData?.type === "bgp") {
interruptCruisePresentation();
@@ -5832,6 +6012,22 @@ function onClick(event) {
return;
}
if (clickedEarthInteractableMarker?.userData?.type === "earth_interactable") {
const clickedMarker = clickedEarthInteractableMarker;
if (confirmMotionCandidate({
type: "earth_interactable",
object: clickedMarker,
screen: { x: event.clientX, y: event.clientY },
distancePxSq: 0,
}, { showMotionStatus: false })) {
showStatusMessage(
`已选择交互点: ${clickedMarker.userData?.label || clickedMarker.userData?.name || clickedMarker.userData?.id || "未知目标"}`,
"info",
);
}
return;
}
if (cableIntersects.length > 0 && getShowCables()) {
interruptCruisePresentation();
clearLockedObject();
@@ -5876,31 +6072,13 @@ function onClick(event) {
const sat = selectSatellite(selectedIndex);
if (!sat?.properties) return;
interruptCruisePresentation();
clearLockedObject();
lockedObject = sat;
lockedObjectType = "satellite";
lockedSatellite = sat;
lockedSatelliteIndex = selectedIndex;
setLockedSatelliteIndex(selectedIndex);
showPredictedOrbit(sat);
setAutoRotate(false);
const satPositions = getSatellitePositions();
if (satPositions?.[selectedIndex]) {
setSatelliteRingState(
selectedIndex,
"locked",
satPositions[selectedIndex].current,
);
if (hoveredSatelliteIndex === selectedIndex) {
setHoveredSatelliteIndex(selectedIndex);
}
}
showSatelliteInfo(sat.properties, { x: event.clientX, y: event.clientY });
confirmMotionCandidate({
type: "satellite",
index: selectedIndex,
object: sat,
screen: { x: event.clientX, y: event.clientY },
distancePxSq: 0,
}, { showMotionStatus: false });
showStatusMessage("已选择: " + sat.properties.name, "info");
return;
}
@@ -5986,6 +6164,7 @@ function animate() {
updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker);
updateComputeCenterVisualState(lockedObjectType, lockedObject, camera);
updateVesselVisualState(lockedObjectType, lockedObject, camera);
updateEarthInteractableVisualState(lockedObjectType, lockedObject, camera);
if (lockedObjectType === "cable" && lockedObject) {
applyLandingPointVisualState(lockedObject.userData.name, false, camera);

View File

@@ -10,12 +10,15 @@ export function createMotionAgentProvider(options = {}) {
onMessage = () => {},
onState = () => {},
onStatus = () => {},
debugSkeleton = false,
enabledGestures = [],
} = options;
let socket = null;
let reconnectTimer = null;
let disposed = false;
let connected = false;
let requestSeq = 0;
function emitState(detail = {}) {
onState({
@@ -55,6 +58,24 @@ export function createMotionAgentProvider(options = {}) {
}
}
function sendCommand(command, payload = {}, requestId = null) {
if (!socket || !connected || socket.readyState !== 1) {
return {
ok: false,
error: "motion_agent_not_connected",
requestId: requestId || null,
};
}
const nextRequestId = requestId || `earth-motion-${Date.now()}-${++requestSeq}`;
socket.send(JSON.stringify({
type: "command",
command,
request_id: nextRequestId,
payload,
}));
return { ok: true, requestId: nextRequestId };
}
function connect() {
if (disposed || socket) return;
if (!WebSocketCtor) {
@@ -73,6 +94,15 @@ export function createMotionAgentProvider(options = {}) {
socket.onopen = () => {
connected = true;
emitState({ connected: true });
sendCommand("set_debug_options", { skeleton: Boolean(debugSkeleton) }, "earth-motion-debug-on-connect");
if (Array.isArray(enabledGestures) && enabledGestures.length > 0) {
sendCommand(
"set_enabled_gestures",
{ gestures: enabledGestures },
"earth-motion-enabled-gestures-on-connect",
);
}
sendCommand("set_armed", { armed: true }, "earth-motion-armed-on-connect");
onStatus("动捕 Agent 已连接", "info");
};
socket.onmessage = (rawMessage) => onMessage(rawMessage?.data ?? rawMessage);
@@ -104,5 +134,8 @@ export function createMotionAgentProvider(options = {}) {
isConnected() {
return connected;
},
sendCommand(command, payload = {}, requestId = null) {
return sendCommand(command, payload, requestId);
},
};
}

View File

@@ -5,6 +5,7 @@ import {
import { createBrowserCameraProvider } from "./motion-browser-provider.js";
import {
DEFAULT_MOTION_PROVIDER,
MOTION_GESTURES,
MOTION_PROVIDER_AGENT,
normalizeGestureMessage,
normalizeMotionProvider,
@@ -24,6 +25,7 @@ const DEFAULT_LAYER_COOLDOWN_MS = 1400;
const DEFAULT_CONFIRM_COOLDOWN_MS = 1200;
const ENABLED_STORAGE_KEY = "planet-earth-motion-control-enabled";
const URL_STORAGE_KEY = "planet-earth-motion-control-url";
const DEFAULT_ENABLED_GESTURES = Array.from(MOTION_GESTURES);
const GESTURE_POLICIES = {
rotate_left: { group: "rotate_left", cooldownMs: DEFAULT_COOLDOWN_MS },
@@ -85,6 +87,8 @@ export function createMotionControlAdapter(options = {}) {
minConfidence = DEFAULT_MIN_CONFIDENCE,
cooldownMs = DEFAULT_COOLDOWN_MS,
providerFactories = {},
debugSkeleton = false,
enabledGestures = DEFAULT_ENABLED_GESTURES,
WebSocketCtor = typeof WebSocket !== "undefined" ? WebSocket : null,
onRotate = () => false,
onZoom = () => false,
@@ -102,6 +106,7 @@ export function createMotionControlAdapter(options = {}) {
let activeProvider = null;
let connected = false;
let recognitionPaused = false;
let enabledGestureSet = normalizeEnabledGestureSet(enabledGestures);
const lastHandledByGestureGroup = new Map();
function emitState(detail) {
@@ -120,6 +125,7 @@ export function createMotionControlAdapter(options = {}) {
}
function shouldHandleGesture(event) {
if (!enabledGestureSet.has(event?.gesture)) return false;
if (!event || event.confidence < minConfidence) return false;
const policy = GESTURE_POLICIES[event.gesture] || {
group: event.gesture,
@@ -208,6 +214,8 @@ export function createMotionControlAdapter(options = {}) {
...sharedOptions,
url,
WebSocketCtor,
debugSkeleton,
enabledGestures: Array.from(enabledGestureSet),
});
}
return createBrowserCameraProvider(sharedOptions);
@@ -242,6 +250,21 @@ export function createMotionControlAdapter(options = {}) {
isConnected() {
return Boolean(activeProvider?.isConnected?.());
},
sendCommand(command, payload = {}, requestId = null) {
return activeProvider?.sendCommand?.(command, payload, requestId) || {
ok: false,
error: "motion_provider_commands_unavailable",
requestId,
};
},
setEnabledGestures(nextGestures) {
enabledGestureSet = normalizeEnabledGestureSet(nextGestures);
lastHandledByGestureGroup.clear();
activeProvider?.sendCommand?.("set_enabled_gestures", {
gestures: Array.from(enabledGestureSet),
}, "earth-motion-enabled-gestures");
return Array.from(enabledGestureSet);
},
getProvider() {
return selectedProvider;
},
@@ -253,6 +276,14 @@ export function createMotionControlAdapter(options = {}) {
}
}
function normalizeEnabledGestureSet(gestures) {
const values = Array.isArray(gestures) ? gestures : DEFAULT_ENABLED_GESTURES;
const normalized = values
.map((gesture) => String(gesture || "").trim())
.filter((gesture) => MOTION_GESTURES.has(gesture));
return new Set(normalized.length > 0 ? normalized : DEFAULT_ENABLED_GESTURES);
}
export {
DEFAULT_AGENT_URL,
DEFAULT_MOTION_PROVIDER,

View File

@@ -201,6 +201,44 @@ describe("motion-control provider manager", () => {
expect(debugEvent?.detail.confidence).toBe(0);
});
test("disabled gestures are ignored and can be re-enabled at runtime", () => {
installWindow();
const rotations = [];
const sentCommands = [];
const adapter = createMotionControlAdapter({
enabled: true,
cooldownMs: 0,
enabledGestures: ["zoom_in"],
onRotate: (...args) => rotations.push(args),
providerFactories: {
browser_camera: () => ({
start() {
return true;
},
stop() {},
isConnected: () => true,
sendCommand(command, payload) {
sentCommands.push({ command, payload });
return { ok: true };
},
}),
},
});
adapter.start();
adapter.handleMessage({ type: "gesture", gesture: "rotate_left", confidence: 0.91 });
expect(rotations).toHaveLength(0);
adapter.setEnabledGestures(["rotate_left"]);
adapter.handleMessage({ type: "gesture", gesture: "rotate_left", confidence: 0.91 });
expect(rotations).toHaveLength(1);
expect(sentCommands.at(-1)).toEqual({
command: "set_enabled_gestures",
payload: { gestures: ["rotate_left"] },
});
});
test("mock vertical gesture and focus gesture use dedicated callbacks", () => {
installWindow();
const rotations = [];

View File

@@ -47,6 +47,10 @@ export function normalizeGestureMessage(raw, fallbackSource = "motion-provider")
seq: Number(raw.seq || 0),
source: raw.source || fallbackSource,
mode: raw.mode || "single",
protocolVersion: raw.protocol_version || raw.protocolVersion || "motion.v1",
cameraId: raw.camera_id || raw.cameraId || "unknown",
inputMode: raw.input_mode || raw.inputMode || raw.mode || "single",
fusion: raw.fusion && typeof raw.fusion === "object" ? raw.fusion : null,
payload: raw.payload && typeof raw.payload === "object" ? raw.payload : {},
};
}

View File

@@ -46,6 +46,7 @@ const SOURCE_TYPE_LABELS = {
rss: "RSS",
atom: "Atom",
aggregated: "Aggregated",
manual: "手动添加",
reference: "Reference",
};
@@ -162,6 +163,7 @@ export function isRegionalMonitorFeed(feedName) {
export function getNewsFetchChannelLabel(feedName, sourceType = "") {
const normalized = normalizeText(sourceType).toLowerCase();
if (isRegionalMonitorFeed(feedName) || normalized === "aggregated") return "区域监测";
if (normalized === "manual") return "手动添加";
if (normalized === "atom") return "单源 Atom";
if (normalized === "reference") return "配置保留";
return "单源 RSS";

View File

@@ -68,6 +68,7 @@ const NEWS_CATEGORY_ALIASES = {
culture: ["culture", "arts", "entertainment", "文化", "艺术", "娱乐"],
other: ["other", "general", "misc", "其他", "综合"],
};
const NEWS_CATEGORY_KEYS = Object.keys(NEWS_CATEGORY_ALIASES);
function loadNewsSourceFilters() {
try {
@@ -459,7 +460,7 @@ function getNewsSourceDescriptor(item, sourcesByName, sourcesById) {
}
function getEnabledNewsCategoryKeys(filters = activeNewsCategoryFilters) {
if (!filters || typeof filters !== "object") return [];
if (!filters || typeof filters !== "object") return [...NEWS_CATEGORY_KEYS].sort();
return Object.entries(filters)
.filter(([, enabled]) => enabled !== false)
.map(([key]) => key)
@@ -469,7 +470,7 @@ function getEnabledNewsCategoryKeys(filters = activeNewsCategoryFilters) {
function getNewsCategorySignature(filters = activeNewsCategoryFilters) {
const enabled = getEnabledNewsCategoryKeys(filters);
const total = Object.keys(NEWS_CATEGORY_ALIASES).length;
const total = NEWS_CATEGORY_KEYS.length;
if (enabled.length === 0) return "__none__";
if (enabled.length === total) return "";
return enabled.join(",");
@@ -490,7 +491,30 @@ function getEnabledNewsSourceIds(nextPayload = payload) {
return available.filter((id) => allowed.has(id));
}
function reconcileNewsSourceFilters(nextPayload = payload) {
const available = getAvailableSourceIds(nextPayload);
if (!available.length || !Array.isArray(activeNewsSourceFilters)) return;
const valid = activeNewsSourceFilters.filter((id) => available.includes(id));
const changed = valid.length !== activeNewsSourceFilters.length;
if (activeNewsSourceFilters.length > 0 && valid.length === 0) {
activeNewsSourceFilters = null;
persistNewsSourceFilters(null);
return;
}
if (valid.length === available.length) {
activeNewsSourceFilters = null;
persistNewsSourceFilters(null);
return;
}
if (changed) {
activeNewsSourceFilters = valid;
persistNewsSourceFilters(valid);
}
}
function getNewsSourceSignature(nextPayload = payload) {
reconcileNewsSourceFilters(nextPayload);
const available = getAvailableSourceIds(nextPayload);
const enabled = getEnabledNewsSourceIds(nextPayload);
if (available.length > 0 && enabled.length === 0) return "__none__";
@@ -520,7 +544,7 @@ function summarizeSelection(enabledCount, totalCount) {
function syncFilterSummaries(nextPayload = payload) {
const categories = getEnabledNewsCategoryKeys();
const totalCategories = Object.keys(NEWS_CATEGORY_ALIASES).length;
const totalCategories = NEWS_CATEGORY_KEYS.length;
const sources = getAvailableSourceIds(nextPayload);
const enabledSources = getEnabledNewsSourceIds(nextPayload);
document.querySelectorAll('[data-news-filter-summary="category"]').forEach((el) => {
@@ -550,7 +574,7 @@ function closeNewsFilterPopover() {
function renderCategoryFilterChips() {
const enabled = new Set(getEnabledNewsCategoryKeys());
return Object.keys(NEWS_CATEGORY_ALIASES)
return NEWS_CATEGORY_KEYS
.map((key) => `
<button
class="news-filter-chip${enabled.has(key) ? " is-active" : ""}"

View File

@@ -833,6 +833,7 @@ const fieldLabels: Record<string, string> = {
region: '地区',
language: '语言',
source_type: '播放类型',
sourceType: '来源类型',
embed_url: '嵌入地址',
stream_url: '播放流地址',
homepage_url: '主页地址',
@@ -870,6 +871,18 @@ const fieldLabels: Record<string, string> = {
status: '状态',
state: '状态',
source: '来源',
feed_name: 'Feed 名称',
published_at: '发布时间',
category: '新闻类型',
tags_text: '标签',
summary: '摘要',
content: '正文',
latitude: '纬度',
longitude: '经度',
location_label: '位置标签',
enrichment_status: '处理状态',
translated: '已翻译',
verified: '已定位',
display_name: '显示名称',
module: '层级',
product: '产品',
@@ -1590,7 +1603,7 @@ function newsSourceGroup(source: AnyRecord, healthPayload: AnyRecord = {}): Hier
const health: AnyRecord = isObjectRecord(maybeHealth) ? maybeHealth : {}
const editor: AnyRecord = newsSourceToEditor(source, health)
return {
key: `news-source:${text(editor.id, text(editor.name, crypto.randomUUID()))}`,
key: `news-source:${text(editor.id, text(editor.name, 'unnamed'))}`,
label: pick(editor, ['name', 'id'], '新闻源'),
description: [text(editor.source_type, '').toUpperCase(), text(editor.region, ''), text(editor.default_category, ''), text(editor.__healthLabel, '')].filter(Boolean).join(' · '),
status: newsSourceStatusLabel(editor),
@@ -1658,6 +1671,97 @@ function newsSourceValidationError(source: AnyRecord, existingSources: AnyRecord
return ''
}
function newsItemSourceType(item: AnyRecord) {
return text(item.source_type || item.feed_type, 'rss').toLowerCase()
}
function newsItemToEditor(item: AnyRecord): AnyRecord {
const tags = Array.isArray(item.item_tags)
? item.item_tags.map((tag) => String(tag)).filter(Boolean)
: Array.isArray(item.tags)
? item.tags.map((tag) => String(tag)).filter(Boolean)
: []
const sourceType = newsItemSourceType(item)
const editable = Boolean(item.editable || sourceType === 'manual' || text(item.id, '').startsWith('manual:'))
return {
...item,
title: text(item.title || item.display_title, ''),
summary: text(item.summary || item.display_summary, ''),
content: text(item.manual_content || item.content, ''),
source: text(item.source, editable ? '手动添加' : ''),
url: text(item.url, ''),
region: text(item.region, 'global'),
published_at: text(item.published_at, ''),
category: text(item.category, 'other'),
tags_text: tags.join(', '),
latitude: item.latitude ?? '',
longitude: item.longitude ?? '',
location_label: text(item.location_label, ''),
source_type: sourceType,
editable,
__module: sourceType === 'manual' ? '手动新闻' : '新闻条目',
__status: text(item.status || item.enrichment_status, editable ? 'pending' : ''),
__title: pick(item, ['title', 'display_title', 'id'], '新闻条目'),
}
}
function newsItemFromEditor(record: AnyRecord) {
const next = cleanRecord(record)
const latitudeText = text(next.latitude, '').trim()
const longitudeText = text(next.longitude, '').trim()
const location = latitudeText && longitudeText ? {
label: text(next.location_label, ''),
latitude: Number(latitudeText),
longitude: Number(longitudeText),
} : undefined
return {
title: text(next.title, '').trim(),
summary: text(next.summary, '').trim(),
content: text(next.content, '').trim(),
url: text(next.url, '').trim(),
source: text(next.source, '').trim(),
region: text(next.region, 'global'),
published_at: text(next.published_at, '').trim() || undefined,
category: text(next.category, 'other'),
tags: text(next.tags_text, '').split(/[,\n]/).map((item) => item.trim()).filter(Boolean),
location,
}
}
function newsItemValidationError(payload: AnyRecord) {
if (!text(payload.title, '').trim()) return '新闻标题不能为空。'
if (payload.location) {
const location = payload.location as AnyRecord
const latitude = Number(location.latitude)
const longitude = Number(location.longitude)
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return '坐标必须是数字。'
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return '坐标超出范围。'
}
return ''
}
function newsContentGroup(group: AnyRecord): HierarchyGroup {
const groupType = text(group.group_type, 'rss')
const sourceType = text(group.source_type, groupType)
return {
key: `news-group:${text(group.id, text(group.name, 'unnamed'))}`,
label: pick(group, ['name', 'feed_name', 'source', 'id'], '新闻组'),
description: [
groupType === 'manual' ? '手动新闻组' : 'RSS 来源',
sourceType.toUpperCase(),
text(group.region, ''),
].filter(Boolean).join(' · '),
status: group.editable === false ? '只读' : '可编辑',
count: Number(group.count || arrayAt(group, 'items').length || 0),
record: {
...group,
__module: groupType === 'manual' ? '手动新闻组' : 'RSS 来源',
__status: group.editable === false ? '只读' : '可编辑',
__title: pick(group, ['name', 'feed_name', 'source', 'id'], '新闻组'),
},
}
}
function updateNewsFeedDraft(
draft: string,
fallback: AnyRecord,
@@ -2573,6 +2677,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const [hierarchyDraft, setHierarchyDraft] = useState('')
const [tvDraftGroup, setTvDraftGroup] = useState<HierarchyGroup | null>(null)
const [newsDraftGroup, setNewsDraftGroup] = useState<HierarchyGroup | null>(null)
const [newsItemEditorDraft, setNewsItemEditorDraft] = useState('')
const [newsItemEditorId, setNewsItemEditorId] = useState('')
const [newsImportDialogOpen, setNewsImportDialogOpen] = useState(false)
const [newsImportTargetGroupId, setNewsImportTargetGroupId] = useState('')
const [newsFilters, setNewsFilters] = useState({ status: 'all', sourceType: 'all', region: 'all', tag: 'all' })
const [collectionDraftGroup, setCollectionDraftGroup] = useState<HierarchyGroup | null>(null)
const [snapshotSelectionBySource, setSnapshotSelectionBySource] = useState<Record<string, string>>({})
@@ -2597,6 +2705,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const [visibleSecretFields, setVisibleSecretFields] = useState<Record<string, boolean>>({})
const [smtpTestEmail, setSmtpTestEmail] = useState('')
const [brandUploadFile, setBrandUploadFile] = useState<File | null>(null)
const [newsImportFile, setNewsImportFile] = useState<File | null>(null)
const newsImportInputRef = useRef<HTMLInputElement>(null)
const [resolveTarget, setResolveTarget] = useState<TableRecord | null>(null)
const [resolutionText, setResolutionText] = useState('已处理')
const [credentialGuide, setCredentialGuide] = useState<AnyRecord | null>(null)
@@ -3140,6 +3250,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
setActiveGroupKey('')
setHierarchyDraft('')
setTvDraftGroup(null)
setNewsItemEditorDraft('')
setNewsItemEditorId('')
setNewsImportDialogOpen(false)
setNewsImportFile(null)
setSelected(null)
setSelectedHistory([])
setMobileResourceDetailOpen(false)
@@ -3791,6 +3905,71 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
setBrandUploadFile(file)
}
const createManualNewsGroup = async () => {
setActionLoading(true)
try {
const response = await axios.post(apiPath('/earth/news-groups'), { name: '新建新闻组' })
const group = isObjectRecord(response.data?.group) ? response.data.group : {}
const groupId = text(group.id, '')
toast({ title: '新闻组已创建', tone: 'success' })
await load()
if (groupId) {
setActiveGroupKey(`news-group:${groupId}`)
setHierarchyDraft(formatRaw(group))
setMobileHierarchyDetailOpen(true)
}
} catch (error) {
toast({ title: '创建新闻组失败', description: actionErrorMessage(error), tone: 'error' })
} finally {
setActionLoading(false)
}
}
const openManualNewsImportDialog = (groupId: string) => {
setNewsImportTargetGroupId(groupId)
setNewsImportFile(null)
setNewsImportDialogOpen(true)
}
const importManualNewsJson = async () => {
if (!newsImportFile) {
toast({ title: '请选择 JSON 文件', tone: 'error' })
return
}
if (!newsImportTargetGroupId) {
toast({ title: '请选择新闻组', description: 'JSON 导入需要在手动新闻组详情页中执行。', tone: 'error' })
return
}
if (!newsImportFile.name.toLowerCase().endsWith('.json')) {
toast({ title: '文件类型不支持', description: '首版只支持 JSON 数组文件。', tone: 'error' })
return
}
const formData = new FormData()
formData.append('file', newsImportFile)
formData.append('group_id', newsImportTargetGroupId)
setActionLoading(true)
try {
const response = await axios.post(apiPath('/earth/news-items/import'), formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const result = response.data as AnyRecord
setNewsImportFile(null)
setNewsImportDialogOpen(false)
toast({
title: '新闻导入完成',
description: `新增 ${text(result.created, '0')} 条,更新 ${text(result.updated, '0')} 条,失败 ${text(result.failed, '0')} 条。`,
tone: Number(result.failed || 0) > 0 ? 'error' : 'success',
})
const activeKey = activeGroupKey
await load()
if (activeKey) setActiveGroupKey(activeKey)
} catch (error) {
toast({ title: '导入新闻失败', description: actionErrorMessage(error), tone: 'error' })
} finally {
setActionLoading(false)
}
}
const saveAdvancedJson = async () => {
if (!selected) return
let payload: unknown
@@ -4893,6 +5072,9 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const newsPayload = config === configs.earthContent && activeSection.key === 'news_sources'
? newsSourcesPayload(activeState.raw)
: {}
const newsSettingsPayload = config === configs.earthContent
? newsSourcesPayload(states.find((state) => state.section.key === 'news_sources')?.raw)
: {}
if (config === configs.earthContent && activeSection.key === 'news_sources') {
const matchesNewsFilter = (group: HierarchyGroup) => {
const source = group.record
@@ -4913,6 +5095,9 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
groups = sortGroupsByStatus(arrayAt(newsPayload, 'sources').filter(isObjectRecord).map((source) => newsSourceGroup(source, newsHealthPayload))).filter(matchesNewsFilter)
if (newsDraftGroup) groups.push(newsDraftGroup)
}
if (config === configs.earthContent && activeSection.key === 'news_items') {
groups = activeState.rows.map((row) => newsContentGroup(row))
}
if (config === configs.collection && activeSection.key === 'collection_history') {
groups = activeState.rows.map((row) => ({
key: row.__rowId,
@@ -5027,7 +5212,32 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
{ key: 'cooldown_minutes', label: '熔断冷却(分钟)', type: 'number' },
{ key: 'circuit_breaker', label: '熔断开关', type: 'boolean' },
] : []
const fields = newsSourceFields.length ? newsSourceFields : [...scalarFields, ...objectFields]
const newsItemReadonly = config === configs.earthContent
&& activeSection.key === 'news_items'
&& activeGroup
&& !activeGroup.record.__isDraft
&& activeGroup.record.editable === false
const newsItemFields: FieldConfig[] = config === configs.earthContent && activeSection.key === 'news_items' ? [
{ key: 'name', label: '组名 / 来源名', disabled: newsItemReadonly },
{ key: 'group_type', label: '组类型', disabled: true },
{ key: 'source_type', label: '来源类型', disabled: true },
{ key: 'count', label: '新闻数量', type: 'number', disabled: true },
] : []
const manualNewsItemFields: FieldConfig[] = [
{ key: 'title', label: '标题' },
{ key: 'summary', label: '摘要', type: 'textarea', wide: true },
{ key: 'content', label: '正文', type: 'textarea', wide: true },
{ key: 'source', label: '内容来源' },
{ key: 'url', label: '原文链接', wide: true },
{ key: 'region', label: '缺省区域', type: 'select', options: NEWS_REGION_OPTIONS.filter((option) => !['china', 'us'].includes(option.value)) },
{ key: 'published_at', label: '发布时间' },
{ key: 'category', label: '新闻类型', type: 'select', options: newsCategoryOptions(newsSettingsPayload) },
{ key: 'tags_text', label: '标签', wide: true },
{ key: 'latitude', label: '纬度', type: 'number' },
{ key: 'longitude', label: '经度', type: 'number' },
{ key: 'location_label', label: '位置标签' },
]
const fields = newsSourceFields.length ? newsSourceFields : newsItemFields.length ? newsItemFields : [...scalarFields, ...objectFields]
const renderNewsFeedEditor = () => {
if (!activeGroup) return null
const currentSource = draftRecord(hierarchyDraft, activeGroup.record)
@@ -5106,6 +5316,146 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
)
}
const startNewsItemEditor = (item?: AnyRecord) => {
const groupId = text(activeGroup?.record.id, '')
const baseline = item
? newsItemToEditor(item)
: newsItemToEditor({
id: '__new__',
title: '',
summary: '',
content: '',
source: '手动添加',
source_type: 'manual',
region: 'global',
category: 'other',
published_at: new Date().toISOString(),
editable: true,
group_id: groupId,
})
setNewsItemEditorId(text(baseline.id, '__new__'))
setNewsItemEditorDraft(formatRaw(baseline))
}
const saveNewsItemEditor = async () => {
if (!activeGroup) return
const groupId = text(activeGroup.record.id, '')
if (!groupId || text(activeGroup.record.group_type, '') !== 'manual') return
const current = draftRecord(newsItemEditorDraft, {})
const payload = { ...newsItemFromEditor(current), group_id: groupId }
const validationError = newsItemValidationError(payload)
if (validationError) {
toast({ title: '新闻内容不完整', description: validationError, tone: 'error' })
return
}
const itemId = text(current.id, newsItemEditorId)
const isNew = !itemId || itemId === '__new__'
await requestAction(
isNew ? '新增新闻内容' : '保存新闻内容',
isNew ? 'post' : 'put',
isNew ? '/earth/news-items' : `/earth/news-items/${encodeURIComponent(itemId)}`,
payload,
)
setNewsItemEditorDraft('')
setNewsItemEditorId('')
}
const renderNewsContentGroupEditor = () => {
if (!activeGroup) return null
const group = record
const groupId = text(activeGroup.record.id, '')
const isManual = text(group.group_type, '') === 'manual'
const items = arrayAt(group, 'items')
const currentEditorRecord = newsItemEditorDraft ? draftRecord(newsItemEditorDraft, {}) : {}
return (
<>
<section className="an-field-cluster">
<div className="an-field-cluster__heading">
<h3>{isManual ? '手动新闻组' : 'RSS 来源'}</h3>
<p>{isManual ? '组内可按条添加,也可以上传 JSON 数组批量导入。' : 'RSS 来源只读,新闻由抓取与增强链路维护。'}</p>
</div>
<FieldGrid
record={activeGroup.record}
draft={hierarchyDraft}
onDraftChange={setHierarchyDraft}
fields={fields}
searchGroupKey={activeGroup.key}
/>
{isManual ? (
<TactileControlGroup className="an-hierarchy-list__footer-actions">
<Button size="icon" variant="subtle" icon="plus" title="单条添加" aria-label="单条添加" onClick={() => startNewsItemEditor()} />
<Button size="icon" variant="subtle" title="上传 JSON" aria-label="上传 JSON" onClick={() => openManualNewsImportDialog(groupId)}><ImageUp size={15} /></Button>
</TactileControlGroup>
) : null}
</section>
<section className="an-field-cluster">
<div className="an-field-cluster__heading">
<h3></h3>
<p>{items.length} /</p>
</div>
<div className="an-news-feed-list">
{items.length ? items.map((item, itemIndex) => {
const itemId = text(item.id, '')
return (
<div className="an-news-feed-card" key={itemId || `${text(item.title, 'news-item')}:${itemIndex}`}>
<div className="an-news-feed-card__header">
<strong>{pick(item, ['title', 'display_title', 'id'], '新闻条目')}</strong>
<div className="an-news-feed-card__actions">
<StatusText tone={item.verified ? 'success' : 'warning'}>{item.verified ? '已定位' : '待定位'}</StatusText>
{isManual ? (
<>
<Button size="icon" variant="subtle" title="编辑新闻" aria-label="编辑新闻" onClick={() => startNewsItemEditor(item)}><Redo2 size={14} /></Button>
<Button size="icon" variant="subtle" title="重新处理" aria-label="重新处理" onClick={() => void requestAction('重新处理新闻', 'post', `/earth/news-items/${encodeURIComponent(itemId)}/reprocess`, undefined, { refresh: true, successDescription: '新闻已重新进入清洗、翻译和定位队列。' })}><RefreshCw size={14} /></Button>
<Button size="icon" variant="danger" title="删除新闻" aria-label="删除新闻" onClick={() => setConfirmAction({
title: '删除新闻',
description: `确认删除 ${pick(item, ['title', 'id'], '新闻条目')}`,
danger: true,
confirmLabel: '删除',
run: async () => {
await requestAction('删除新闻', 'delete', `/earth/news-items/${encodeURIComponent(itemId)}`)
setNewsItemEditorDraft('')
setNewsItemEditorId('')
},
})}><Trash2 size={14} /></Button>
</>
) : null}
</div>
</div>
<p>{text(item.summary || item.display_summary, '暂无摘要')}</p>
<small>{[text(item.source, ''), text(item.region, ''), text(item.category, ''), text(item.published_at, '')].filter(Boolean).join(' · ')}</small>
</div>
)
}) : <EmptyState title="暂无新闻" description={isManual ? '可以单条添加或上传 JSON 数组导入。' : '该 RSS 来源暂无入库新闻。'} />}
</div>
</section>
{newsItemEditorDraft ? (
<section className="an-field-cluster">
<div className="an-field-cluster__heading">
<h3>{newsItemEditorId === '__new__' ? '新增新闻' : '编辑新闻'}</h3>
<p></p>
</div>
<FieldGrid
record={currentEditorRecord}
draft={newsItemEditorDraft}
onDraftChange={setNewsItemEditorDraft}
fields={manualNewsItemFields}
searchGroupKey={`${activeGroup.key}:news-editor`}
/>
<TactileControlGroup className="an-hierarchy-list__footer-actions">
<Button variant="subtle" onClick={() => {
setNewsItemEditorDraft('')
setNewsItemEditorId('')
}}><X size={15} /></Button>
<Button variant="primary" onClick={() => void saveNewsItemEditor()} loading={actionLoading}><Save size={15} /></Button>
</TactileControlGroup>
</section>
) : null}
</>
)
}
const saveCurrent = async () => {
if (!activeGroup) return
const payload = draftRecord(hierarchyDraft, activeGroup.record)
@@ -5184,6 +5534,17 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
}
return
}
if (activeSection.key === 'news_items') {
if (activeGroup.record.editable === false || text(activeGroup.record.group_type, '') !== 'manual') {
toast({ title: 'RSS 来源只读', description: 'RSS 来源组不能在这里重命名或编辑。', tone: 'error' })
return
}
const groupId = text(activeGroup.record.id, '')
await requestAction('保存新闻组', 'put', `/earth/news-groups/${encodeURIComponent(groupId)}`, {
name: text(payload.name, '').trim(),
})
return
}
if (activeSection.key === 'tv') {
const tv = tvSettingsFromRaw(activeState.raw)
const sources = Array.isArray(tv.sources) ? tv.sources.filter(isObjectRecord) : []
@@ -5368,6 +5729,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
selectHierarchyGroup(group)
}} />
</TactileControlGroup>
) : config === configs.earthContent && activeSection.key === 'news_items' ? (
<TactileControlGroup className="an-hierarchy-list__footer-actions">
<Button size="icon" variant="subtle" icon="plus" title="新增新闻组" aria-label="新增新闻组" onClick={() => void createManualNewsGroup()} loading={actionLoading} />
</TactileControlGroup>
) : null
const hierarchyHeader = config === configs.earthContent && activeSection.key === 'news_sources' ? (
@@ -5487,6 +5852,16 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
) : null}
</>
) : null}
{config === configs.earthContent && activeSection.key === 'news_items' && activeGroup ? (
<>
<Button size="icon" variant="subtle" title="恢复当前表单" aria-label="恢复当前表单" onClick={() => {
setHierarchyDraft(formatRaw(activeGroup.record))
setNewsItemEditorDraft('')
setNewsItemEditorId('')
toast({ title: '已恢复当前项', description: '表单已恢复到加载时状态。', tone: 'success' })
}}><Redo2 size={15} /></Button>
</>
) : null}
{config === configs.earthContent && activeSection.key === 'tv' && activeGroup ? (
<>
<Button size="icon" variant="subtle" title={text(record.id, '') === tvDefaultSourceId(activeState.raw) ? '当前已是默认频道' : '设为默认频道'} aria-label={text(record.id, '') === tvDefaultSourceId(activeState.raw) ? '当前已是默认频道' : '设为默认频道'} onClick={() => {
@@ -5724,6 +6099,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
/>
</section>
</>
) : config === configs.earthContent && activeSection.key === 'news_items' ? (
renderNewsContentGroupEditor()
) : config === configs.earthContent && activeSection.key === 'tv' ? (
<>
<div className="an-tv-edit-layout">
@@ -6618,6 +6995,41 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
</label>
</Dialog>
<Dialog
open={newsImportDialogOpen}
onOpenChange={(open) => {
setNewsImportDialogOpen(open)
if (!open) setNewsImportFile(null)
}}
title="导入 JSON 新闻"
description="上传 JSON 数组后,所有条目都会归入当前手动新闻组。"
width={520}
footer={(
<>
<Button variant="subtle" onClick={() => {
setNewsImportDialogOpen(false)
setNewsImportFile(null)
}} disabled={actionLoading}></Button>
<Button variant="primary" onClick={() => void importManualNewsJson()} loading={actionLoading} disabled={!newsImportFile}><ImageUp size={15} /></Button>
</>
)}
>
<input
ref={newsImportInputRef}
type="file"
accept="application/json,.json"
hidden
onChange={(event) => setNewsImportFile(event.target.files?.[0] ?? null)}
/>
<section className="an-field-cluster an-field-cluster--compact">
<div className="an-field-cluster__heading">
<h3>{newsImportFile ? newsImportFile.name : '选择 JSON 文件'}</h3>
<p> JSON </p>
</div>
<Button variant="subtle" onClick={() => newsImportInputRef.current?.click()}><ImageUp size={15} /></Button>
</section>
</Dialog>
<ConfirmDialog
open={Boolean(confirmAction)}
onOpenChange={(open) => {
@@ -6784,6 +7196,20 @@ const configs = {
}]
: [],
},
{
key: 'news_items',
label: '新闻内容',
url: '/earth/news-groups',
map: (payload) => arrayAt(payload, 'groups').map((group) => ({
...group,
__title: pick(group, ['name', 'feed_name', 'source', 'id'], '新闻组'),
__module: text(group.group_type, '') === 'manual' ? '手动新闻组' : 'RSS 来源',
__status: group.editable === false ? '只读' : '可编辑',
__metric: `${text(group.count, '0')}`,
__endpointKey: 'newsGroups',
__endpointLabel: '新闻内容',
})),
},
{ key: 'basemap', label: '底图资源', map: emptyRows },
{ key: 'layer_resources', label: '图层资源', map: emptyRows },
{ key: 'models_3d', label: '3D 模型', map: emptyRows },