Files
SenMeshNet/components/SettingsApplicationTab.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

413 lines
16 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.
import React, { useEffect, useState } from "react";
import { ArrowUpCircle, Check, Copy, Globe, Loader2, RefreshCcw, Server } from "lucide-react";
import AppLogo from "./AppLogo";
import AppWordmark from "./AppWordmark";
import { Button } from "./ui/button";
import { Switch } from "./ui/switch";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import type { UpdateState, UseUpdateCheckResult } from "../application/state/useUpdateCheck";
import { useI18n } from "../application/i18n/I18nProvider";
import { SettingsAnchor, SettingsTabContent } from "./settings/settings-ui";
import { toast } from "./ui/toast";
import { useStoredString } from "../application/state/useStoredString";
import { STORAGE_KEY_MONITORING_DASHBOARD_TITLE, STORAGE_KEY_REPORT_PRODUCT_NAME } from "../infrastructure/config/storageKeys";
import { cn } from "../lib/utils";
type AppInfo = {
name: string;
version: string;
platform?: string;
};
interface SettingsApplicationTabProps {
updateState: UpdateState;
checkNow: UseUpdateCheckResult['checkNow'];
openReleasePage: UseUpdateCheckResult['openReleasePage'];
installUpdate: UseUpdateCheckResult['installUpdate'];
startDownload: UseUpdateCheckResult['startDownload'];
isUpdateDemoMode: boolean;
productName: string;
setProductName: (value: string) => void;
}
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate, startDownload, isUpdateDemoMode, productName, setProductName }: SettingsApplicationTabProps) {
const { t } = useI18n();
const { openExternal, getApplicationInfo } = useApplicationBackend();
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
const [lastCheckResult, setLastCheckResult] = useState<'none' | 'available' | 'upToDate'>('none');
const [monitoringTitle, setMonitoringTitle] = useStoredString<string>(STORAGE_KEY_MONITORING_DASHBOARD_TITLE, '云启Net智能巡检监测系统');
const [reportProductName, setReportProductName] = useStoredString<string>(STORAGE_KEY_REPORT_PRODUCT_NAME, '');
// Electron 本地:启动时从主进程恢复 dashboardTitle / reportProductName
// 解决绿色便携版首次启动 Local Storage 空时显示旧默认值的问题
useEffect(() => {
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
if (IS_REMOTE) return;
const nc = (window as any).netcatty;
if (!nc?.inspection) return;
// 恢复大屏标题(如果 Local Storage 空或用的是默认值)
nc.inspection.getDashboardTitle?.().then((v: string) => {
if (v && v.length > 0 && v !== '云启Net智能巡检监测系统') {
const local = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
if (!local || local === '云启Net智能巡检监测系统') {
setMonitoringTitle(v);
console.info('[Settings] restored monitoringTitle:', v);
}
}
}).catch(() => {});
// 恢复报告产品名
nc.inspection.getReportProductName?.().then((v: string) => {
if (v && v.length > 0) {
const local = localStorage.getItem(STORAGE_KEY_REPORT_PRODUCT_NAME);
if (!local || local === '') {
setReportProductName(v);
console.info('[Settings] restored reportProductName:', v);
}
}
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 同步到主进程 inspection-data.json(远程浏览器 API 可读)
useEffect(() => {
try {
const nc = (window as any).netcatty;
if (monitoringTitle) nc?.inspection?.saveDashboardTitle?.(monitoringTitle).catch(() => {});
nc?.inspection?.saveReportProductName?.(reportProductName || '').catch(() => {});
} catch {}
}, [monitoringTitle, reportProductName]);
// 远程 HTTP Server 状态
const [remoteStatus, setRemoteStatus] = useState<{ running: boolean; port: number; token: string | null; url: string | null }>({
running: false, port: 8848, token: null, url: null,
});
const [remoteLoading, setRemoteLoading] = useState(false);
const [remotePortDraft, setRemotePortDraft] = useState("8848");
const loadRemoteStatus = async () => {
try {
const s = await (window as any).netcatty?.remoteHttp?.getStatus?.();
if (s) {
setRemoteStatus({
running: !!s.running,
port: s.port || 8848,
token: s.token || null,
url: s.url || null,
});
setRemotePortDraft(String(s.port || 8848));
}
} catch {}
};
useEffect(() => { void loadRemoteStatus(); }, []);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const info = await getApplicationInfo();
if (!cancelled && info?.name && typeof info?.version === "string") {
setAppInfo(info);
}
} catch {
// Ignore: running in browser/dev without Electron bridge
}
};
void load();
return () => {
cancelled = true;
};
}, [getApplicationInfo]);
const handleOpenExternal = async (url: string) => {
try {
await openExternal(url);
} catch (err) {
console.warn("[SettingsApplicationTab] openExternal failed:", err);
toast.error(
t("settings.application.openExternal.failedBody"),
t("settings.application.openExternal.failedTitle"),
);
}
};
const handleCheckForUpdates = async () => {
// In demo mode, allow checking even for dev builds
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
// Dev build - just open releases page
openReleasePage();
return;
}
setLastCheckResult('none');
const result = await checkNow();
if (result?.hasUpdate && result.latestRelease) {
setLastCheckResult('available');
toast.info(
t('update.available.message', { version: result.latestRelease.version }),
t('update.available.title')
);
// Don't auto-open the release page here — checkNow() already triggers
// electron-updater on supported platforms, and the Settings > System tab
// shows a "Manual Download" link on unsupported platforms.
} else if (result) {
setLastCheckResult('upToDate');
toast.success(
t('update.upToDate.message', { version: appInfo.version }),
t('update.upToDate.title')
);
}
// Reset the result after 3 seconds
setTimeout(() => setLastCheckResult('none'), 3000);
};
// ─── 远程访问 handlers ───
const handleToggleRemote = async (checked: boolean) => {
setRemoteLoading(true);
try {
const port = parseInt(remotePortDraft, 10) || 8848;
const res = await (window as any).netcatty?.remoteHttp?.configure?.(checked, port);
if (res?.ok) {
setRemoteStatus({
running: !!checked,
port: res.port || port,
token: res.token || remoteStatus.token,
url: res.url || null,
});
setRemotePortDraft(String(res.port || port));
if (checked) toast.success("远程访问已启动");
else toast.success("远程访问已停止");
} else {
toast.error(res?.error || "操作失败");
await loadRemoteStatus();
}
} catch (e: any) {
toast.error(e?.message || "操作失败");
await loadRemoteStatus();
} finally {
setRemoteLoading(false);
}
};
const handleCopyUrl = async () => {
if (!remoteStatus.url) { toast.error("请先启用远程访问"); return; }
try {
await navigator.clipboard.writeText(remoteStatus.url);
toast.success("访问链接已复制");
} catch {
toast.error("复制失败");
}
};
return (
<SettingsTabContent value="application">
<div className="flex flex-col lg:flex-row gap-10 lg:gap-14">
<div className="lg:w-[320px] shrink-0">
<div className="flex items-center gap-4">
<AppLogo className="w-16 h-16" />
<div>
<AppWordmark accessibleLabel={productName} text={productName} className="h-8 w-auto text-foreground" />
<div className="flex items-center gap-2 mt-1">
<span className="text-sm text-muted-foreground">
{appInfo.version ? appInfo.version : " "}
</span>
{/* Update badge - reflects auto-download state */}
{updateState.latestRelease && (updateState.hasUpdate || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready') && (
<button
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : updateState.autoDownloadStatus === 'downloading' ? undefined : startDownload()}
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
updateState.autoDownloadStatus === 'ready'
? "bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-800"
: "bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800",
"transition-colors cursor-pointer"
)}
>
<ArrowUpCircle size={12} />
v{updateState.latestRelease.version}{' '}
{updateState.autoDownloadStatus === 'ready'
? t('update.restartNow')
: updateState.autoDownloadStatus === 'downloading'
? `${updateState.downloadPercent}%`
: t('update.downloadNow')}
</button>
)}
</div>
</div>
</div>
{/* 检查更新按钮已隐藏 */}
{/* 产品名称配置 —— 本地 state 缓冲输入,blur/Enter 才提交,避免每次按键都触发全局持久化 + IPC 导致闪动 */}
<SettingsAnchor anchorId="application-product-name" className="mt-6">
<ProductNameInput value={productName} onChange={setProductName} />
</SettingsAnchor>
{/* 大屏名称配置 —— 同样用本地 state 缓冲,避免每次按键都触发 localStorage 写入和事件广播 */}
<SettingsAnchor anchorId="application-monitoring-title" className="mt-6">
<SettingsInputBuffered
value={monitoringTitle}
placeholder="云启Net智能巡检监测系统"
label="大屏标题"
hint="3D 拓扑监控大屏页面顶部显示的系统标题(回车或失焦后生效)"
onCommit={(v) => {
setMonitoringTitle(v);
try { window.dispatchEvent(new CustomEvent('monitoringTitleChanged')); } catch {}
}}
/>
</SettingsAnchor>
{/* 报告产品名配置 —— 专门用于 PDF 巡检报告标题 */}
<SettingsAnchor anchorId="application-report-product-name" className="mt-6">
<SettingsInputBuffered
value={reportProductName}
placeholder={productName}
label="报告产品名"
hint="巡检报告 PDF 首页显示的产品名称(回车或失焦后生效)。留空则使用上方「产品名称」。"
trim
onCommit={(v) => setReportProductName(v)}
/>
</SettingsAnchor>
{/* 远程访问(浏览器 HTTP Server) */}
<SettingsAnchor anchorId="application-remote-access" className="mt-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Server size={16} className="text-muted-foreground" />
<label className="text-sm font-medium text-foreground">远程访问</label>
{remoteStatus.running ? (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
运行中
</span>
) : (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-muted text-muted-foreground">
已停止
</span>
)}
</div>
<Switch
checked={remoteStatus.running}
disabled={remoteLoading}
onCheckedChange={handleToggleRemote}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground w-16 shrink-0">端口</label>
<input
type="number"
min={1}
max={65535}
value={remotePortDraft}
onChange={(e) => setRemotePortDraft(e.target.value)}
className="w-24 px-2 py-1 rounded-md border border-input bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
<span className="text-xs text-muted-foreground ml-2">(默认 8848,修改后需重启远程访问生效)</span>
</div>
{remoteStatus.running && remoteStatus.url && (
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground shrink-0" />
<code className="flex-1 px-2 py-1 rounded-md bg-muted text-xs text-foreground truncate select-all">
{remoteStatus.url}
</code>
<Button variant="outline" size="sm" onClick={handleCopyUrl} className="shrink-0">
<Copy size={14} className="mr-1" /> 复制
</Button>
</div>
)}
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
启用后可在同一局域网内通过浏览器访问本机数据看板(设备列表、告警、巡检快照)。
连接时需带上上方 token,建议仅在可信内网环境使用。
</p>
</div>
</SettingsAnchor>
</div>
</div>
</SettingsTabContent>
);
}
/**
* 带本地 state 缓冲的设置输入框。输入过程只更新本地 draft,
* 回车或失焦才调用 onCommit,避免每次按键都触发全局持久化 + IPC 导致闪动。
*/
function SettingsInputBuffered({
value,
onCommit,
placeholder,
label,
hint,
trim = false,
}: {
value: string;
onCommit: (v: string) => void;
placeholder?: string;
label: string;
hint?: string;
trim?: boolean;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
const commit = () => {
const next = trim ? draft.trim() : draft;
if (next !== value) onCommit(next);
};
return (
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">{label}</label>
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
(e.target as HTMLInputElement).blur();
} else if (e.key === "Escape") {
setDraft(value);
(e.target as HTMLInputElement).blur();
}
}}
placeholder={placeholder}
className="w-full px-3 py-2 rounded-lg border border-input bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
);
}
/**
* 产品名称输入框 —— SettingsInputBuffered + trim 空白。
*/
function ProductNameInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<SettingsInputBuffered
value={value}
onCommit={onChange}
placeholder="云启智能运维平台"
label="产品名称"
hint="用于窗口标题、侧边栏和设置页面(回车或失焦后生效)"
trim
/>
);
}