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
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user