Files
SenMeshNet/components/SettingsApplicationTab.tsx
zhaolei 2f9f8da48e
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
Initial commit: NetMesh smart ops platform with 3D topology, inspection reports, RCA and AI ops assistant
2026-10-15 13:53:04 +08:00

249 lines
9.5 KiB
TypeScript
Raw 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, Loader2, RefreshCcw } from "lucide-react";
import AppLogo from "./AppLogo";
import AppWordmark from "./AppWordmark";
import { Button } from "./ui/button";
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";
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, '');
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);
};
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>
</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
/>
);
}