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:
474
components/settings/tabs/SettingsMonitoringTab.tsx
Normal file
474
components/settings/tabs/SettingsMonitoringTab.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user