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
475 lines
17 KiB
TypeScript
475 lines
17 KiB
TypeScript
/**
|
||
* 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;
|