/** * Settings Monitoring Tab —— 监控大屏地图底图 + 相机参数配置 * * 底图:上传 png/jpg/webp 到 userData/monitoring/maps/,拓扑图热替换。 * 相机:倾斜角 / 距离 / FOV / 方位角 / 自动旋转 写 localStorage, * MonitoringTopologyView 通过 storage 事件监听 + cameraAnimRef 平滑过渡。 */ import { Image, Upload, Trash2, Check, RotateCcw, Camera, Maximize2, ZoomIn, AlertTriangle } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "../../ui/button"; import { ScrollArea } from "../../ui/scroll-area"; import { cn } from "../../../lib/utils"; import { toast } from "../../ui/toast"; import { SectionHeader, SettingCard, SettingsTabContent } from "../settings-ui"; const ALLOWED_EXTS = [".png", ".jpg", ".jpeg", ".webp"]; const ALLOWED_ACCEPT = "image/png,image/jpeg,image/jpg,image/webp"; const MAX_BYTES = 20 * 1024 * 1024; const BG_CHANGED_EVENT = "monitoring-map-bg-changed"; const CAMERA_CONFIG_KEY = "monitoring-camera-config"; interface MapBgEntry { filename: string; size: number; mtimeMs: number; } interface CameraConfig { elevation: number; azimuth: number; distance: number; fov: number; autoRotate: boolean; autoRotateSpeed: number; } const DEFAULT_CAMERA: CameraConfig = { elevation: 40, azimuth: 45, distance: 130, fov: 60, autoRotate: true, autoRotateSpeed: 0.4, }; function formatSize(bytes: number): string { if (!bytes || bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } function formatDate(ms: number): string { if (!ms) return ""; try { return new Date(ms).toLocaleString(); } catch { return ""; } } function emitBgChanged() { try { window.dispatchEvent(new Event(BG_CHANGED_EVENT)); } catch {} } function emitCameraChanged() { try { window.dispatchEvent(new Event("monitoring-camera-changed")); } catch {} } function getInspectionApi() { const nc = typeof window !== "undefined" ? (window as any).netcatty : null; return nc?.inspection ?? null; } function readCameraConfig(): CameraConfig { try { const raw = localStorage.getItem(CAMERA_CONFIG_KEY); if (raw) { const v = JSON.parse(raw); return { ...DEFAULT_CAMERA, ...v }; } } catch {} return { ...DEFAULT_CAMERA }; } function writeCameraConfig(cfg: CameraConfig) { try { localStorage.setItem(CAMERA_CONFIG_KEY, JSON.stringify(cfg)); } catch {} } function SliderRow({ label, value, min, max, step, unit, onChange, }: { label: React.ReactNode; value: number; min: number; max: number; step?: number; unit?: string; onChange: (v: number) => void; }) { return (
{label} {value}{unit || ""}
onChange(Number(e.target.value))} className="w-full accent-primary" />
); } export function SettingsMonitoringTab() { const api = getInspectionApi(); const fileInputRef = useRef(null); const [entries, setEntries] = useState([]); const [selected, setSelected] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [uploading, setUploading] = useState(false); const [camera, setCamera] = useState(() => readCameraConfig()); const isAvailable = !!api; const refresh = useCallback(async () => { if (!api) return; let list: MapBgEntry[] = []; let previewDataUrl: string | null = null; let previewFilename: string | null = null; try { const raw = (await api.listMapBgs()) as MapBgEntry[]; list = Array.isArray(raw) ? raw : []; } catch (e) { console.warn("[SettingsMonitoringTab] listMapBgs failed:", e); } try { const res = await api.getMapBgData(); if (res?.ok && typeof res.dataUrl === "string" && res.dataUrl.length > 0) { previewDataUrl = res.dataUrl; previewFilename = res.filename || null; } } catch (e) { console.warn("[SettingsMonitoringTab] getMapBgData failed:", e); } setEntries(list); setSelected(previewFilename); setPreviewUrl(previewDataUrl); }, [api]); useEffect(() => { void refresh(); }, [refresh]); // 相机参数任何变化 → 写 localStorage + debounced 走主进程 IPC 广播 useEffect(() => { writeCameraConfig(camera); emitCameraChanged(); // debounce 后调 IPC:Slider 拖动很频繁,主进程广播需要节流 if (!api?.setCameraConfig) return; const t = setTimeout(() => { api.setCameraConfig(camera).catch(() => {}); }, 150); return () => clearTimeout(t); }, [camera, api]); const handleFiles = useCallback(async (files: FileList | null) => { if (!api || !files || files.length === 0) return; setUploading(true); try { for (let i = 0; i < files.length; i++) { const file = files[i]; const ext = "." + file.name.split(".").pop()?.toLowerCase(); if (!ALLOWED_EXTS.includes(ext)) { toast.warning(`跳过不支持的文件 ${file.name}`); continue; } if (file.size > MAX_BYTES) { toast.warning(`${file.name} 超过 20MB 上限`); continue; } const dataUrl: string = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = () => reject(new Error("FileReader failed")); reader.readAsDataURL(file); }); const base64 = dataUrl.split(",", 2)[1] || ""; const res = await api.uploadMapBg(file.name, base64); if (res?.ok) { toast.success("上传成功"); const sel = await api.selectMapBg(res.filename); if (sel?.ok) emitBgChanged(); } else { toast.error(`上传失败: ${res?.error || "未知"}`); } } } catch (e: any) { console.error("[SettingsMonitoringTab] upload error:", e); toast.error(`上传失败: ${e?.message || String(e)}`); } finally { setUploading(false); if (fileInputRef.current) fileInputRef.current.value = ""; void refresh(); } }, [api, refresh]); const handleSelect = useCallback(async (filename: string) => { if (!api) return; const res = await api.selectMapBg(filename); if (res?.ok) { emitBgChanged(); await refresh(); } else toast.error(`切换失败: ${res?.error || ""}`); }, [api, refresh]); const handleDelete = useCallback(async (filename: string) => { if (!api) return; const res = await api.deleteMapBg(filename); if (res?.ok) { emitBgChanged(); await refresh(); } else toast.error(`删除失败: ${res?.error || ""}`); }, [api, refresh]); const handleReset = useCallback(async () => { if (!api) return; const res = await api.resetMapBg(); if (res?.ok) { emitBgChanged(); toast.success("已恢复默认地图"); setSelected(null); setPreviewUrl(null); await refresh(); } else toast.error(`恢复默认失败: ${res?.error || ""}`); }, [api, refresh]); const handleResetCamera = useCallback(() => { setCamera({ ...DEFAULT_CAMERA }); toast.success("已恢复默认相机参数"); }, []); const sortedEntries = useMemo( () => [...entries].sort((a, b) => b.mtimeMs - a.mtimeMs), [entries], ); return ( {!isAvailable && (
底图上传仅在 Electron 本机可用。远程浏览器版本暂不支持。
)} {isAvailable && ( <> {/* 当前底图 + 紧凑上传 */}
底图 ({selected ? "自定义" : "内置 /ditu.png"})
void handleFiles(e.target.files)} />
{previewUrl ? (
current map bg
{selected}
) : (
使用内置 /ditu.png 默认底图
)}
{/* 已上传底图列表 */}
已上传底图 {sortedEntries.length}
{sortedEntries.length === 0 ? (
尚未上传自定义底图
) : (
    {sortedEntries.map((entry) => { const isActive = selected === entry.filename; return (
  • {entry.filename}
    {formatSize(entry.size)} · {formatDate(entry.mtimeMs)}
    {isActive ? ( 当前 ) : ( )}
  • ); })}
)}
)} {/* 相机视角配置(任何环境都渲染 —— 不依赖 inspection API) */}
拓扑相机视角
倾斜角(俯视 → 侧视)} value={camera.elevation} min={15} max={85} onChange={(v) => setCamera((c) => ({ ...c, elevation: v }))} unit="°" /> 相机距离(近 → 远)} value={camera.distance} min={30} max={220} onChange={(v) => setCamera((c) => ({ ...c, distance: v }))} /> setCamera((c) => ({ ...c, azimuth: v }))} unit="°" /> setCamera((c) => ({ ...c, fov: v }))} unit="°" />
旋转速度 setCamera((c) => ({ ...c, autoRotateSpeed: Number(e.target.value) }))} className="w-28 accent-primary" /> {camera.autoRotateSpeed.toFixed(2)}
); } export default SettingsMonitoringTab;