Files
SenMeshNet/components/settings/tabs/SettingsMonitoringTab.tsx
zhaolei 4a02370006
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
feat: add monitoring settings tab, workbench view, and inspection alert improvements
2026-10-30 12:12:04 +08:00

475 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{label}</span>
<span className="font-mono text-foreground/80">
{value}{unit || ""}
</span>
</div>
<input
type="range"
min={min}
max={max}
step={step ?? 1}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-primary"
/>
</div>
);
}
export function SettingsMonitoringTab() {
const api = getInspectionApi();
const fileInputRef = useRef<HTMLInputElement>(null);
const [entries, setEntries] = useState<MapBgEntry[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [camera, setCamera] = useState<CameraConfig>(() => 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 (
<SettingsTabContent value="monitoring">
<SectionHeader title="监控大屏" />
{!isAvailable && (
<SettingCard padded>
<div className="flex items-start gap-2 text-amber-500">
<AlertTriangle size={18} className="mt-0.5" />
<div className="text-sm text-muted-foreground">
底图上传仅在 Electron 本机可用。远程浏览器版本暂不支持。
</div>
</div>
</SettingCard>
)}
{isAvailable && (
<>
{/* 当前底图 + 紧凑上传 */}
<SettingCard padded>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Image size={14} className="text-muted-foreground" />
<span className="text-sm font-medium">底图</span>
<span className="text-xs text-muted-foreground">
({selected ? "自定义" : "内置 /ditu.png"})
</span>
</div>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
accept={ALLOWED_ACCEPT}
className="hidden"
onChange={(e) => void handleFiles(e.target.files)}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
>
<Upload size={12} className="mr-1" />
{uploading ? "上传中..." : "上传新底图"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleReset}
disabled={!selected}
>
<RotateCcw size={12} className="mr-1" /> 恢复默认
</Button>
</div>
</div>
<div className="rounded-md border border-border bg-muted/30 overflow-hidden">
{previewUrl ? (
<div className="relative bg-black">
<img
src={previewUrl}
alt="current map bg"
className="w-full max-h-36 object-contain"
draggable={false}
/>
<div className="absolute bottom-1 right-2 rounded bg-black/60 px-2 py-0.5 text-[11px] text-white/90">
{selected}
</div>
</div>
) : (
<div className="flex h-20 items-center justify-center text-xs text-muted-foreground">
使用内置 /ditu.png 默认底图
</div>
)}
</div>
</SettingCard>
{/* 已上传底图列表 */}
<SettingCard padded>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">已上传底图</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
{sortedEntries.length}
</span>
</div>
<Button variant="ghost" size="sm" onClick={() => void refresh()}>
刷新
</Button>
</div>
<ScrollArea className="max-h-56 rounded-md border border-border">
{sortedEntries.length === 0 ? (
<div className="flex h-20 items-center justify-center text-xs text-muted-foreground">
尚未上传自定义底图
</div>
) : (
<ul className="divide-y divide-border">
{sortedEntries.map((entry) => {
const isActive = selected === entry.filename;
return (
<li
key={entry.filename}
className={cn(
"flex items-center gap-3 px-3 py-1.5 text-sm",
isActive && "bg-primary/5",
)}
>
<div className="flex-1 min-w-0">
<div
className={cn(
"truncate",
isActive ? "font-medium text-foreground" : "text-foreground/90",
)}
title={entry.filename}
>
{entry.filename}
</div>
<div className="text-[11px] text-muted-foreground">
{formatSize(entry.size)} · {formatDate(entry.mtimeMs)}
</div>
</div>
{isActive ? (
<span className="flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
<Check size={10} /> 当前
</span>
) : (
<Button
variant="outline"
size="sm"
onClick={() => void handleSelect(entry.filename)}
>
应用
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => void handleDelete(entry.filename)}
title="删除"
className="text-destructive hover:text-destructive"
>
<Trash2 size={12} />
</Button>
</li>
);
})}
</ul>
)}
</ScrollArea>
</SettingCard>
</>
)}
{/* 相机视角配置(任何环境都渲染 —— 不依赖 inspection API) */}
<SettingCard padded className="space-y-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Camera size={14} className="text-muted-foreground" />
<span className="text-sm font-medium">拓扑相机视角</span>
</div>
<Button variant="ghost" size="sm" onClick={handleResetCamera}>
<RotateCcw size={12} className="mr-1" /> 默认
</Button>
</div>
<div className="space-y-4">
<SliderRow
label={<span className="flex items-center gap-1.5"><Maximize2 size={12} className="text-muted-foreground" />倾斜角(俯视 → 侧视)</span>}
value={camera.elevation}
min={15}
max={85}
onChange={(v) => setCamera((c) => ({ ...c, elevation: v }))}
unit="°"
/>
<SliderRow
label={<span className="flex items-center gap-1.5"><ZoomIn size={12} className="text-muted-foreground" />相机距离(近 → 远)</span>}
value={camera.distance}
min={30}
max={220}
onChange={(v) => setCamera((c) => ({ ...c, distance: v }))}
/>
<SliderRow
label="方位角(相机水平旋转)"
value={camera.azimuth}
min={0}
max={359}
onChange={(v) => setCamera((c) => ({ ...c, azimuth: v }))}
unit="°"
/>
<SliderRow
label="视野角 FOV(广角 → 长焦)"
value={camera.fov}
min={25}
max={95}
onChange={(v) => setCamera((c) => ({ ...c, fov: v }))}
unit="°"
/>
</div>
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={camera.autoRotate}
onChange={(e) => setCamera((c) => ({ ...c, autoRotate: e.target.checked }))}
className="accent-primary"
/>
自动旋转
</label>
<div className="flex items-center gap-2">
<span>旋转速度</span>
<input
type="range"
min={0.05}
max={2}
step={0.05}
value={camera.autoRotateSpeed}
onChange={(e) => setCamera((c) => ({ ...c, autoRotateSpeed: Number(e.target.value) }))}
className="w-28 accent-primary"
/>
<span className="font-mono w-10">{camera.autoRotateSpeed.toFixed(2)}</span>
</div>
</div>
</SettingCard>
</SettingsTabContent>
);
}
export default SettingsMonitoringTab;