feat: add monitoring settings tab, workbench view, and inspection alert improvements
Some checks failed
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled

This commit is contained in:
2026-10-30 12:12:04 +08:00
committed by trae-solo-agent
parent 16dd04f746
commit 4a02370006
26 changed files with 2569 additions and 233 deletions

View File

@@ -1,4 +1,4 @@
/**
/**
* 运行监控 - 3D 拓扑大屏
*
* 移植自 ITYUNWEI TopologyView.vue
@@ -503,47 +503,32 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
fetchJSON(`/api/dashboard-title?token=${encodeURIComponent(token)}`)
.then(json => { if (json?.ok && json.title) setMonitoringTitle(json.title); });
// 2. 拓扑位置
fetchJSON(`/api/topology-positions?token=${encodeURIComponent(token)}`)
.then(json => {
if (json?.ok && json.positions && Object.keys(json.positions).length > 0) {
setSavedPositions(json.positions);
}
});
// 2. 拓扑位置(layoutDevices 内部已经同步 XHR 加载了,这里不需要)
// 3. 连接线
fetchJSON(`/api/topology-links?token=${encodeURIComponent(token)}`)
.then(json => { if (json?.ok && Array.isArray(json.links)) setCustomLinks(json.links); });
}, []);
// Electron 环境:监听 localStorage 变化 + mount 时同步 hosts/title 给主进程
// Electron 环境:mount 时同步 hosts/title/拓扑位置/连线给主进程持久化
useEffect(() => {
const nc = (window as any).netcatty;
console.log('[MonitoringTopologyView sync] nc exists:', !!nc, 'nc.inspection exists:', !!nc?.inspection, 'hosts.length:', hosts?.length);
if (!nc?.inspection) {
console.log('[MonitoringTopologyView sync] skip — not Electron env');
return;
}
if (!nc?.inspection) return; // 不是 Electron 环境
// mount 时一次性同步 vault hosts + 标题 + 拓扑位置 + 拓扑连线
if (hosts?.length > 0) {
console.log('[MonitoringTopologyView sync] saving vault hosts:', hosts.length);
nc.inspection.saveVaultHosts(hosts).then((r: any) => console.log('saveVaultHosts result:', r)).catch((e: any) => console.warn('saveVaultHosts error:', e));
nc.inspection.saveVaultHosts(hosts).catch(() => {});
}
const savedTitle = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
console.log('[MonitoringTopologyView sync] savedTitle:', savedTitle);
if (savedTitle) nc.inspection.saveDashboardTitle(savedTitle).catch(() => {});
const savedPos = localStorage.getItem('monitoring-topology-positions');
if (savedPos) nc.inspection.saveTopologyPositions(JSON.parse(savedPos)).catch(() => {});
const savedLinks = localStorage.getItem('monitoring-topology-links');
console.log('[MonitoringTopologyView sync] savedLinks exists:', !!savedLinks);
if (savedLinks) {
try { nc.inspection.saveTopologyLinks(JSON.parse(savedLinks)).catch(() => {}); } catch {}
}
if (!savedLinks && nc.inspection.getTopologyLinks) {
nc.inspection.getTopologyLinks().then((linksFromMain: any) => {
if (Array.isArray(linksFromMain) && linksFromMain.length > 0) {
console.info('[MonitoringTopologyView] restore links from main:', linksFromMain.length);
localStorage.setItem('monitoring-topology-links', JSON.stringify(linksFromMain));
try { setCustomLinks(linksFromMain); } catch {}
}
@@ -605,6 +590,44 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
// 相机参数 —— 正常模式(45°俯视)和大屏模式(正俯视)
const normalCameraRef = useRef({ pos: new THREE.Vector3(0, 55, 115), target: new THREE.Vector3(-20, -25, 20), minDist: 30, maxDist: 150, autoRotate: true, autoRotateSpeed: 0.4, fov: 60 });
// ===== 外部相机配置(Settings 页面驱动)=====
const CAMERA_CONFIG_KEY = "monitoring-camera-config";
const CAMERA_TARGET = new THREE.Vector3(-20, -25, 20); // OrbitControls 的旋转中心(与 init scene 一致)
interface ICameraConfig {
elevation: number; azimuth: number; distance: number;
fov: number; autoRotate: boolean; autoRotateSpeed: number;
}
// 从球坐标(elevation 仰角、azimuth 方位角、distance 距离)算 camera.position(y 向上的右手系)
const computePosFromCfg = (cfg: ICameraConfig, target: THREE.Vector3 = CAMERA_TARGET): THREE.Vector3 => {
const el = (cfg.elevation * Math.PI) / 180;
const az = (cfg.azimuth * Math.PI) / 180;
const d = cfg.distance;
const offset = new THREE.Vector3(
d * Math.cos(el) * Math.cos(az),
d * Math.sin(el),
d * Math.cos(el) * Math.sin(az),
);
return offset.add(target);
};
const readCameraCfg = (): ICameraConfig | null => {
try {
const raw = localStorage.getItem(CAMERA_CONFIG_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch { return null; }
};
// 外部相机配置的过渡锁:避免用户手动拖拽相机时被外部事件反写
const externalCameraCfgAppliedRef = useRef(false);
// 地图底图热替换用的 ref(init scene useEffect 外也能访问 → dispose / rebuild)
const mapPlaneRef = useRef<THREE.Mesh | null>(null);
const glowPlaneRef = useRef<THREE.Mesh | null>(null);
const mapTextureRef = useRef<THREE.Texture | null>(null);
const gridOnMapRef = useRef<THREE.GridHelper | null>(null);
const gridOutsideRef = useRef<THREE.GridHelper | null>(null);
// 相机平滑过渡动画(Vector3.lerp 逐帧插值)
interface CameraAnim {
fromPos: THREE.Vector3;
@@ -1382,13 +1405,19 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
scene.fog = new THREE.Fog(0xcfe2f3, 120, 280);
sceneRef.current = scene;
// 【根治跳帧 Bug】Three.js 初始化**第一帧**就读 Settings 里配置的相机参数
// 这样相机一出生就是对的位置,不会先渲染硬编码位置再被 useEffect 异步改掉
const bootCfg = readCameraCfg();
const bootFov = bootCfg?.fov ?? 60;
const bootPos = bootCfg ? computePosFromCfg(bootCfg) : new THREE.Vector3(0, 55, 115);
const camera = new THREE.PerspectiveCamera(
60,
bootFov,
container.clientWidth / container.clientHeight,
0.1,
500
);
camera.position.set(0, 55, 115);
camera.position.copy(bootPos);
cameraRef.current = camera;
// 复用全局单例 WebGLRenderer —— 整个应用只创建一个 WebGL context
@@ -1485,12 +1514,12 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
controls.enableZoom = true;
controls.target.set(-20, -25, 20); // 往上抬 target,让顶部露出更多蓝天
controls.update();
controls.minDistance = 30;
controls.maxDistance = 150;
controls.minDistance = bootCfg ? Math.max(20, bootCfg.distance - 80) : 30;
controls.maxDistance = bootCfg ? Math.min(350, bootCfg.distance + 80) : 150;
controls.maxPolarAngle = Math.PI / 2 - 0.1;
controls.minPolarAngle = 0;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.4;
controls.autoRotate = bootCfg?.autoRotate ?? true;
controls.autoRotateSpeed = bootCfg?.autoRotateSpeed ?? 0.4;
controlsRef.current = controls;
// TransformControls 拖拽
@@ -1541,12 +1570,13 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
new THREE.Plane(new THREE.Vector3(0, 0, -1), h / 2),
];
// 地图外网格(深蓝)
// 地图外网格(深蓝)——先按默认正方形创建,地图纹理加载完再按真实 aspect 更新裁剪平面
const gridOutside = new THREE.GridHelper(gridSize, gridDiv, 0x3d6aa0, 0x1e3a60);
gridOutside.position.y = -0.45;
const outsidePlanes = makeClipPlanes(mapW, mapH).map((p) => p.clone().negate());
(gridOutside.material as THREE.Material).clippingPlanes = outsidePlanes;
scene.add(gridOutside);
gridOutsideRef.current = gridOutside;
// 地图上网格(极浅半透明)
const gridOnMap = new THREE.GridHelper(gridSize, gridDiv, 0x0d1f30, 0x060f1a);
@@ -1555,6 +1585,7 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
onMapMat.transparent = true;
onMapMat.opacity = 0.18;
scene.add(gridOnMap);
gridOnMapRef.current = gridOnMap;
// Glow ring
const ringGeo = new THREE.RingGeometry(25, 30, 64);
@@ -1623,100 +1654,161 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
sky.name = 'SkyDome';
scene.add(sky);
// ===== ditu.png 地图纹理 + 边缘发光晕 =====
const texLoader = new THREE.TextureLoader();
texLoader.setCrossOrigin('anonymous');
texLoader.load(
'/ditu.png',
(texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
const mapAspect = texture.image.width / texture.image.height;
mapW = gridSize;
mapH = gridSize / mapAspect;
// ===== 地图纹理(用户上传 → data URL,或默认 /ditu.png)=====
// rebuildMapFromTexture:dispose 旧 mesh/material/texture → 重建地图层级
// 初始化调一次,热替换时再调一次(从外部事件监听调用)
const _sceneForHot = scene; // 闭包内缓存,给外部 hot-rebuild 用
const _gridSizeForHot = gridSize;
// 地图发光底层(比地图稍大,形成发光边缘)
const glowW = mapW + 6;
const glowH = mapH + 6;
const glowGeo = new THREE.PlaneGeometry(glowW, glowH);
const glowMat = new THREE.ShaderMaterial({
uniforms: {
uMap: { value: texture },
uColor: { value: new THREE.Color('#38bdf8') },
uFade: { value: 0.12 },
},
vertexShader: `
varying vec2 vUv;
void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }
`,
fragmentShader: `
varying vec2 vUv;
uniform sampler2D uMap;
uniform vec3 uColor;
uniform float uFade;
void main(){
vec4 c = texture2D(uMap, vUv);
float dx = min(vUv.x, 1.0 - vUv.x) * 2.0;
float dy = min(vUv.y, 1.0 - vUv.y) * 2.0;
float edge = min(dx, dy);
float glow = 1.0 - smoothstep(0.0, uFade, edge);
gl_FragColor = vec4(uColor, glow * 0.5);
}
`,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
});
const glowPlane = new THREE.Mesh(glowGeo, glowMat);
glowPlane.rotation.x = -Math.PI / 2;
glowPlane.position.y = -0.58;
scene.add(glowPlane);
const mapGeo = new THREE.PlaneGeometry(mapW, mapH);
const mapMat = new THREE.ShaderMaterial({
uniforms: {
uMap: { value: texture },
uOpacity: { value: 0.85 },
uFade: { value: 0.08 },
},
vertexShader: `
varying vec2 vUv;
void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }
`,
fragmentShader: `
varying vec2 vUv;
uniform sampler2D uMap;
uniform float uOpacity;
uniform float uFade;
void main(){
vec4 c = texture2D(uMap, vUv);
float dx = min(vUv.x, 1.0 - vUv.x) * 2.0;
float dy = min(vUv.y, 1.0 - vUv.y) * 2.0;
float edge = min(dx, dy);
float a = smoothstep(0.0, uFade, edge);
gl_FragColor = vec4(c.rgb, c.a * a * uOpacity);
}
`,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
});
const mapPlane = new THREE.Mesh(mapGeo, mapMat);
mapPlane.rotation.x = -Math.PI / 2;
mapPlane.position.y = -0.6;
scene.add(mapPlane);
// 地图加载完更新网格裁剪平面
const newOnMapPlanes = makeClipPlanes(mapW, mapH);
;(gridOnMap.material as THREE.Material).clippingPlanes = newOnMapPlanes;
const newOutsidePlanes = makeClipPlanes(mapW, mapH).map((p) => p.clone().negate());
;(gridOutside.material as THREE.Material).clippingPlanes = newOutsidePlanes;
},
undefined,
() => {
// 地图加载失败,静默跳过
console.warn('[Monitoring] ditu.png load failed, skipping map texture');
function rebuildMapFromTexture(loadedTexture: THREE.Texture) {
// dispose 旧的
if (mapPlaneRef.current) {
_sceneForHot.remove(mapPlaneRef.current);
mapPlaneRef.current.geometry.dispose();
disposeMatTextures(mapPlaneRef.current.material);
mapPlaneRef.current = null;
}
);
if (glowPlaneRef.current) {
_sceneForHot.remove(glowPlaneRef.current);
glowPlaneRef.current.geometry.dispose();
disposeMatTextures(glowPlaneRef.current.material);
glowPlaneRef.current = null;
}
if (mapTextureRef.current) {
mapTextureRef.current.dispose();
mapTextureRef.current = null;
}
loadedTexture.colorSpace = THREE.SRGBColorSpace;
mapTextureRef.current = loadedTexture;
const img: any = loadedTexture.image ?? { width: 1, height: 1 };
const mapAspect = (img.width || 1) / Math.max(1, img.height || 1);
mapW = _gridSizeForHot;
mapH = _gridSizeForHot / Math.max(0.01, mapAspect);
// 发光底层
const glowW = mapW + 6;
const glowH = mapH + 6;
const glowGeo = new THREE.PlaneGeometry(glowW, glowH);
const glowMat = new THREE.ShaderMaterial({
uniforms: {
uMap: { value: loadedTexture },
uColor: { value: new THREE.Color('#38bdf8') },
uFade: { value: 0.12 },
},
vertexShader: `
varying vec2 vUv;
void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }
`,
fragmentShader: `
varying vec2 vUv;
uniform sampler2D uMap;
uniform vec3 uColor;
uniform float uFade;
void main(){
vec4 c = texture2D(uMap, vUv);
float dx = min(vUv.x, 1.0 - vUv.x) * 2.0;
float dy = min(vUv.y, 1.0 - vUv.y) * 2.0;
float edge = min(dx, dy);
float glow = 1.0 - smoothstep(0.0, uFade, edge);
gl_FragColor = vec4(uColor, glow * 0.5);
}
`,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
});
const glowPlane = new THREE.Mesh(glowGeo, glowMat);
glowPlane.rotation.x = -Math.PI / 2;
glowPlane.position.y = -0.58;
_sceneForHot.add(glowPlane);
glowPlaneRef.current = glowPlane;
const mapGeo = new THREE.PlaneGeometry(mapW, mapH);
const mapMat = new THREE.ShaderMaterial({
uniforms: {
uMap: { value: loadedTexture },
uOpacity: { value: 0.85 },
uFade: { value: 0.08 },
},
vertexShader: `
varying vec2 vUv;
void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }
`,
fragmentShader: `
varying vec2 vUv;
uniform sampler2D uMap;
uniform float uOpacity;
uniform float uFade;
void main(){
vec4 c = texture2D(uMap, vUv);
float dx = min(vUv.x, 1.0 - vUv.x) * 2.0;
float dy = min(vUv.y, 1.0 - vUv.y) * 2.0;
float edge = min(dx, dy);
float a = smoothstep(0.0, uFade, edge);
gl_FragColor = vec4(c.rgb, c.a * a * uOpacity);
}
`,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
});
const mapPlane = new THREE.Mesh(mapGeo, mapMat);
mapPlane.rotation.x = -Math.PI / 2;
mapPlane.position.y = -0.6;
_sceneForHot.add(mapPlane);
mapPlaneRef.current = mapPlane;
// 更新网格裁剪平面(按真实 aspect)
const newOnMapPlanes = makeClipPlanes(mapW, mapH);
if (gridOnMapRef.current) {
(gridOnMapRef.current.material as THREE.Material).clippingPlanes = newOnMapPlanes;
}
const newOutsidePlanes = makeClipPlanes(mapW, mapH).map((p) => p.clone().negate());
if (gridOutsideRef.current) {
(gridOutsideRef.current.material as THREE.Material).clippingPlanes = newOutsidePlanes;
}
}
function loadMapByUrl(url: string) {
const loader = new THREE.TextureLoader();
loader.setCrossOrigin('anonymous');
loader.load(
url,
(texture) => rebuildMapFromTexture(texture),
undefined,
() => {
if (url !== '/ditu.png') {
console.warn('[Monitoring] 用户地图加载失败,fallback /ditu.png');
loader.load('/ditu.png', rebuildMapFromTexture);
} else {
console.warn('[Monitoring] ditu.png load failed, skipping map texture');
}
}
);
}
// 初始化:优先用户上传底图 → fallback /ditu.png
void (async () => {
let targetUrl = '/ditu.png';
try {
const nc = (window as any).netcatty;
if (nc?.inspection?.getMapBgData) {
const res = await nc.inspection.getMapBgData();
if (res?.ok && res.dataUrl) {
targetUrl = res.dataUrl;
console.info('[Monitoring] 使用用户上传底图:', res.filename);
}
}
} catch (e) {
console.warn('[Monitoring] getMapBgData 失败,使用默认 ditu.png:', e);
}
loadMapByUrl(targetUrl);
})();
// 热替换暴露给外部(useEffect 里监听事件时调用)
(scene as any).__rebuildMapByUrl = loadMapByUrl;
// 设备/连线创建已移到下方独立的 useEffect(依赖 [devices, links])
@@ -2232,6 +2324,149 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 只在 mount/unmount 时跑,renderer 全局唯一
// ===== 外部相机配置监听(Settings 页面驱动)=====
// 主路径:主进程 IPC 广播(inspection:camera-config-updated),最可靠
// 兜底:storage 事件(浏览器远程访问模式)
useEffect(() => {
console.info("[MonitoringTopologyView] camera cfg listener mounted");
let lastAppliedTs = 0; // debounce:连续触发合并
let ipcUnsubscribe: (() => void) | null = null;
const applyCameraCfg = (cfg: ICameraConfig) => {
const camera = cameraRef.current;
const controls = controlsRef.current;
if (!camera || !controls) {
console.warn("[MonitoringTopologyView] applyCameraCfg: camera/controls not ready yet");
return;
}
// debounce:至少隔 80ms
const now = performance.now();
if (now - lastAppliedTs < 80) return;
lastAppliedTs = now;
// 先更新 OrbitControls 的距离/FOV/自动旋转
const newMinDist = Math.max(20, cfg.distance - 80);
const newMaxDist = Math.min(350, cfg.distance + 80);
controls.minDistance = newMinDist;
controls.maxDistance = newMaxDist;
controls.autoRotate = !!cfg.autoRotate;
controls.autoRotateSpeed = cfg.autoRotateSpeed ?? 0.4;
const target = CAMERA_TARGET.clone();
const newPos = computePosFromCfg(cfg, target);
const newFov = cfg.fov ?? 60;
console.info("[MonitoringTopologyView] applyCameraCfg", {
elevation: cfg.elevation, azimuth: cfg.azimuth, distance: cfg.distance,
fov: newFov, autoRotate: cfg.autoRotate,
newPos: newPos.toArray().map((v) => Number(v.toFixed(2))),
curPos: camera.position.toArray().map((v) => Number(v.toFixed(2))),
});
cameraAnimRef.current = {
fromPos: camera.position.clone(),
toPos: newPos,
fromTarget: controls.target.clone(),
toTarget: target,
fromFov: camera.fov,
toFov: newFov,
startMs: performance.now(),
durationMs: 400,
};
normalCameraRef.current.pos.copy(newPos);
normalCameraRef.current.target.copy(target);
normalCameraRef.current.fov = newFov;
normalCameraRef.current.autoRotate = !!cfg.autoRotate;
normalCameraRef.current.autoRotateSpeed = cfg.autoRotateSpeed ?? 0.4;
externalCameraCfgAppliedRef.current = true;
};
// mount 时应用一次初始配置(用户可能在 Settings 调了,然后才打开监控大屏)
const initialCfg = readCameraCfg();
if (initialCfg) {
console.info("[MonitoringTopologyView] mount: found initial camera cfg");
const t = setTimeout(() => applyCameraCfg(initialCfg), 800);
// cleanup 里会清 timeout,但先记一下让 ipcUnsubscribe 覆盖
} else {
console.info("[MonitoringTopologyView] mount: no initial camera cfg (use Three.js defaults)");
}
// 主路径:preload 暴露的 IPC 事件(Electron 环境)
try {
const nc = (window as any).netcatty;
if (nc?.inspection?.onCameraConfigUpdated) {
ipcUnsubscribe = nc.inspection.onCameraConfigUpdated((cfg: ICameraConfig) => {
console.info("[MonitoringTopologyView] IPC camera-config-updated", cfg);
if (cfg) applyCameraCfg(cfg);
});
}
} catch (e) {
console.warn("[MonitoringTopologyView] IPC listener unavailable:", e);
}
// 兜底:storage 事件(跨窗口/跨标签)+ 同窗口 CustomEvent(Settings 页面触发)
const onStorage = (e: StorageEvent) => {
if (e.key === CAMERA_CONFIG_KEY) {
const cfg = e.newValue ? JSON.parse(e.newValue) : null;
if (cfg) applyCameraCfg(cfg);
}
};
const onCustomEvent = (e: Event) => {
try {
const cfg = localStorage.getItem(CAMERA_CONFIG_KEY);
if (cfg) applyCameraCfg(JSON.parse(cfg));
} catch {}
};
window.addEventListener("storage", onStorage);
window.addEventListener("monitoring-camera-changed", onCustomEvent);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener("monitoring-camera-changed", onCustomEvent);
if (ipcUnsubscribe) ipcUnsubscribe();
};
}, []);
// ===== 地图底图热替换:Settings 里改了地图 → monitoring 端实时重建 =====
useEffect(() => {
const scene: any = sceneRef.current;
if (!scene) return;
const applyMapBg = async () => {
let url = '/ditu.png';
try {
const nc = (window as any).netcatty;
if (nc?.inspection?.getMapBgData) {
const res = await nc.inspection.getMapBgData();
if (res?.ok && res.dataUrl) {
url = res.dataUrl;
}
}
} catch (e) { /* ignore — 浏览器远程环境无此 API */ }
const rebuilder = scene.__rebuildMapByUrl;
if (typeof rebuilder === 'function') rebuilder(url);
};
const onCustomEvent = () => { void applyMapBg(); };
const onStorageEvent = (e: StorageEvent) => {
if (e.key && /map_bg|monitoring.*bg/i.test(e.key)) {
void applyMapBg();
}
};
// SettingsMonitoringTab 切换后 dispatchEvent 触发(同 window)
window.addEventListener('monitoring-map-bg-changed', onCustomEvent);
// localStorage storage 事件(跨 window 同步;Settings 独立 window 时监听)
window.addEventListener('storage', onStorageEvent);
return () => {
window.removeEventListener('monitoring-map-bg-changed', onCustomEvent);
window.removeEventListener('storage', onStorageEvent);
};
}, []);
// ===== 设备/连线创建:每次 devices/links 变化重建 scene 内容,不重建 renderer =====
useEffect(() => {
const scene = sceneRef.current;
@@ -2546,12 +2781,36 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
}, [typeDistribution, monitoring.alarmTrend]);
const resetView = useCallback(() => {
if (cameraRef.current && controlsRef.current) {
cameraRef.current.position.set(0, 80, 125);
controlsRef.current.target.set(-20, -40, 20);
controlsRef.current.autoRotate = true;
controlsRef.current.update();
if (!cameraRef.current || !controlsRef.current) return;
const camera = cameraRef.current;
const controls = controlsRef.current;
// 优先用 Settings 里配置好的相机参数重置;没有才 fallback 硬编码默认
const cfg = readCameraCfg();
if (cfg) {
const target = CAMERA_TARGET.clone();
const newPos = computePosFromCfg(cfg, target);
camera.position.copy(newPos);
controls.target.copy(target);
const newMin = Math.max(20, cfg.distance - 80);
const newMax = Math.min(350, cfg.distance + 80);
controls.minDistance = newMin;
controls.maxDistance = newMax;
controls.autoRotate = !!cfg.autoRotate;
controls.autoRotateSpeed = cfg.autoRotateSpeed ?? 0.4;
camera.fov = cfg.fov ?? 60;
camera.updateProjectionMatrix();
normalCameraRef.current.pos.copy(newPos);
normalCameraRef.current.target.copy(target);
normalCameraRef.current.fov = cfg.fov ?? 60;
normalCameraRef.current.autoRotate = !!cfg.autoRotate;
normalCameraRef.current.autoRotateSpeed = cfg.autoRotateSpeed ?? 0.4;
} else {
camera.position.set(0, 80, 125);
controls.target.set(-20, -40, 20);
controls.autoRotate = true;
}
controls.update();
}, []);
const refreshData = useCallback(() => {
@@ -2769,6 +3028,8 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
background: 'linear-gradient(to right, transparent 0%, rgba(74,155,255,0.5) 30%, #4A9BFF 100%)',
marginRight: '12px',
boxShadow: '0 0 6px rgba(74,155,255,0.5)',
position: 'relative',
top: '14px',
}}
/>
<div
@@ -2779,6 +3040,8 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
borderBottom: '1px solid #4A9BFF',
background: 'linear-gradient(180deg, rgba(58,123,213,0.22) 0%, rgba(58,123,213,0.06) 100%)',
boxShadow: '0 0 16px rgba(74,155,255,0.3), inset 0 0 12px rgba(74,155,255,0.15)',
position: 'relative',
top: '14px',
}}
>
<h1
@@ -2802,6 +3065,8 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
background: 'linear-gradient(to left, transparent 0%, rgba(74,155,255,0.5) 30%, #4A9BFF 100%)',
marginLeft: '12px',
boxShadow: '0 0 6px rgba(74,155,255,0.5)',
position: 'relative',
top: '14px',
}}
/>
</div>