release: bump version to 0.71.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.70.0",
|
||||
"version": "0.71.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
104
frontend/public/earth/js/controls.js
vendored
104
frontend/public/earth/js/controls.js
vendored
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: '更新时间' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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 : {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" : ""}"
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user