2026-10-15 13:53:04 +08:00
|
|
|
|
import React, { useEffect, useState } from "react";
|
2026-10-19 21:11:57 +08:00
|
|
|
|
import { ArrowUpCircle, Check, Copy, Globe, Loader2, RefreshCcw, Server } from "lucide-react";
|
2026-10-15 13:53:04 +08:00
|
|
|
|
import AppLogo from "./AppLogo";
|
|
|
|
|
|
import AppWordmark from "./AppWordmark";
|
|
|
|
|
|
import { Button } from "./ui/button";
|
2026-10-19 21:11:57 +08:00
|
|
|
|
import { Switch } from "./ui/switch";
|
2026-10-15 13:53:04 +08:00
|
|
|
|
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";
|
2026-10-19 21:11:57 +08:00
|
|
|
|
import { cn } from "../lib/utils";
|
2026-10-15 13:53:04 +08:00
|
|
|
|
|
|
|
|
|
|
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, '');
|
|
|
|
|
|
|
2026-10-19 21:11:57 +08:00
|
|
|
|
// 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 状态
|
2026-10-30 12:12:04 +08:00
|
|
|
|
const [remoteStatus, setRemoteStatus] = useState<{ running: boolean; port: number; token: string | null; url: string | null }>({
|
|
|
|
|
|
running: false, port: 8848, token: null, url: null,
|
2026-10-19 21:11:57 +08:00
|
|
|
|
});
|
|
|
|
|
|
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) {
|
2026-10-30 12:12:04 +08:00
|
|
|
|
setRemoteStatus({
|
|
|
|
|
|
running: !!s.running,
|
|
|
|
|
|
port: s.port || 8848,
|
|
|
|
|
|
token: s.token || null,
|
|
|
|
|
|
url: s.url || null,
|
|
|
|
|
|
});
|
2026-10-19 21:11:57 +08:00
|
|
|
|
setRemotePortDraft(String(s.port || 8848));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { void loadRemoteStatus(); }, []);
|
|
|
|
|
|
|
2026-10-15 13:53:04 +08:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-19 21:11:57 +08:00
|
|
|
|
// ─── 远程访问 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,
|
2026-10-30 12:12:04 +08:00
|
|
|
|
url: res.url || null,
|
2026-10-19 21:11:57 +08:00
|
|
|
|
});
|
|
|
|
|
|
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 () => {
|
2026-10-30 12:12:04 +08:00
|
|
|
|
if (!remoteStatus.url) { toast.error("请先启用远程访问"); return; }
|
2026-10-19 21:11:57 +08:00
|
|
|
|
try {
|
2026-10-30 12:12:04 +08:00
|
|
|
|
await navigator.clipboard.writeText(remoteStatus.url);
|
2026-10-19 21:11:57 +08:00
|
|
|
|
toast.success("访问链接已复制");
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
toast.error("复制失败");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-15 13:53:04 +08:00
|
|
|
|
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)}
|
|
|
|
|
|
/>
|
2026-10-19 21:11:57 +08:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-10-30 12:12:04 +08:00
|
|
|
|
{remoteStatus.running && remoteStatus.url && (
|
2026-10-19 21:11:57 +08:00
|
|
|
|
<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">
|
2026-10-30 12:12:04 +08:00
|
|
|
|
{remoteStatus.url}
|
2026-10-19 21:11:57 +08:00
|
|
|
|
</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>
|
2026-10-15 13:53:04 +08:00
|
|
|
|
</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
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|