/**
* 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 (
);
}
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 && (
<>
{/* 当前底图 + 紧凑上传 */}
{previewUrl ? (
{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="°"
/>
);
}
export default SettingsMonitoringTab;