Files
SenMeshNet/components/settings/tabs/SettingsMonitoringTab.tsx

475 lines
17 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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;