import React, { useEffect, useMemo, useRef, useState } from "react" import { Tabs, Card, Form, Select, Input, Button, Space, Divider, Tag, Modal, Switch, message, Avatar, Typography, } from "antd" import { ThemeQuickSettings } from "./ThemeQuickSettings" import { OAuthLogin } from "./OAuth/OAuthLogin" import { useTranslation } from "react-i18next" import { pinyin } from "pinyin-pro" import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n" import { useResponsive } from "../hooks/useResponsive" import { buildSystemFontFamily, buildSystemFontValue, SYSTEM_FONT_STACK, } from "../shared/fontFamily" const { Text, Paragraph } = Typography type permissionLevel = "admin" | "points" | "view" type appSettings = { is_wizard_completed: boolean log_level: "debug" | "info" | "warn" | "error" window_zoom?: string search_keyboard_layout?: "t9" | "qwerty26" disable_search_keyboard?: boolean font_family?: string } interface FontOption { value: string label: string fontFamily: string searchText: string } const CHINESE_FONT_CHAR_PATTERN = /[\u3400-\u9fff]/ const CHINESE_FONT_SEGMENT_PATTERN = /[\u3400-\u9fff]+/g const toPascalCasePinyin = (value: string): string => pinyin(value, { toneType: "none" }) .split(/\s+/) .filter(Boolean) .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase()) .join("") const formatFontDisplayName = (name: string): string => { if (!CHINESE_FONT_CHAR_PATTERN.test(name)) return name return name.replace(CHINESE_FONT_SEGMENT_PATTERN, (segment) => toPascalCasePinyin(segment)) } const buildFontSearchText = (name: string, label: string): string => `${name} ${label}`.toLowerCase() const defaultFontOptions: FontOption[] = [ { value: "system", label: "系统默认", fontFamily: SYSTEM_FONT_STACK, searchText: "系统默认 system default", }, ] const mergeFontOptions = (options: FontOption[]): FontOption[] => { const map = new Map() for (const option of options) { if (!map.has(option.value)) { map.set(option.value, option) } } return Array.from(map.values()) } const findFontOption = (options: FontOption[], value?: string): FontOption | undefined => { if (!value) return options.find((item) => item.value === "system") || options[0] return ( options.find((item) => item.value === value) || options.find((item) => item.value === "system") ) } const applyFontFamily = (fontFamily: string) => { document.documentElement.style.setProperty("--ss-font-family", fontFamily) document.body.style.fontFamily = fontFamily } const withTimeout = async ( promise: Promise, ms: number, timeoutMessage: string ): Promise => { let timer: ReturnType | undefined try { return await Promise.race([ promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(timeoutMessage)), ms) }), ]) } finally { if (timer) clearTimeout(timer) } } export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission }) => { const { t } = useTranslation() const breakpoint = useResponsive() const isMobile = breakpoint === "xs" || breakpoint === "sm" const [activeTab, setActiveTab] = useState("appearance") const [currentLanguage, setCurrentLanguage] = useState(getCurrentLanguage()) const [settings, setSettings] = useState({ is_wizard_completed: false, log_level: "info", window_zoom: "1.0", search_keyboard_layout: "qwerty26", disable_search_keyboard: false, }) const [fontOptions, setFontOptions] = useState(defaultFontOptions) const [isLoadingFonts, setIsLoadingFonts] = useState(false) const fontOptionsRef = useRef(defaultFontOptions) const [securityStatus, setSecurityStatus] = useState<{ permission: permissionLevel hasAdminPassword: boolean hasPointsPassword: boolean hasRecoveryString: boolean } | null>(null) const [adminPassword, setAdminPassword] = useState("") const [pointsPassword, setPointsPassword] = useState("") const [recoveryToReset, setRecoveryToReset] = useState("") const [recoveryDialogVisible, setRecoveryDialogVisible] = useState(false) const [recoveryDialogHeader, setRecoveryDialogHeader] = useState("") const [recoveryDialogString, setRecoveryDialogString] = useState("") const [recoveryDialogFilename, setRecoveryDialogFilename] = useState("") const [aboutContent, setAboutContent] = useState<{ title: string description: string content: string rawMarkdown: string version?: string } | null>(null) const [logsDialogVisible, setLogsDialogVisible] = useState(false) const [logsText, setLogsText] = useState("") const [logsLoading, setLogsLoading] = useState(false) const [clearDialogVisible, setClearDialogVisible] = useState(false) const [clearLoading, setClearLoading] = useState(false) const importInputRef = useRef(null) const [settleLoading, setSettleLoading] = useState(false) const [settleDialogVisible, setSettleDialogVisible] = useState(false) const [urlRegisterLoading, setUrlRegisterLoading] = useState(false) const [appQuitLoading, setAppQuitLoading] = useState(false) const [appRestartLoading, setAppRestartLoading] = useState(false) const [mcpLoading, setMcpLoading] = useState(false) const [mcpConfig, setMcpConfig] = useState<{ host: string; port: number }>({ host: "127.0.0.1", port: 3901, }) const [mcpStatus, setMcpStatus] = useState<{ is_running: boolean config: { host: string; port: number } url?: string | null }>({ is_running: false, config: { host: "127.0.0.1", port: 3901 }, url: null, }) const canAdmin = permission === "admin" const [messageApi, contextHolder] = message.useMessage() const [pgConnectionString, setPgConnectionString] = useState("") const [pgConnectionStatus, setPgConnectionStatus] = useState<{ connected: boolean type: "sqlite" | "postgresql" error?: string }>({ connected: true, type: "sqlite" }) const [pgTestLoading, setPgTestLoading] = useState(false) const [pgSwitchLoading, setPgSwitchLoading] = useState(false) const [pgUploadLoading, setPgUploadLoading] = useState(false) const [oauthLoginVisible, setOAuthLoginVisible] = useState(false) const [oauthUserInfo, setOAuthUserInfo] = useState<{ user_id: string email: string name: string github_username?: string permission: number } | null>(null) const getOAuthPermissionLabel = (oauthPermission: number) => { if (oauthPermission >= 18) return t("permissions.admin") if (oauthPermission >= 1) return t("permissions.points") return t("permissions.view") } const permissionTag = useMemo(() => { return ( {permission === "admin" ? t("permissions.admin") : permission === "points" ? t("permissions.points") : t("permissions.view")} ) }, [permission, t]) useEffect(() => { fontOptionsRef.current = fontOptions }, [fontOptions]) const emitDataUpdated = (category: "events" | "students" | "reasons" | "all") => { window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } })) } const loadMcpStatus = async () => { if (!(window as any).api?.mcpServerStatus) return try { const res = await (window as any).api.mcpServerStatus() if (res.success && res.data) { setMcpStatus(res.data) if (res.data.config?.host && res.data.config?.port) { setMcpConfig({ host: res.data.config.host, port: res.data.config.port }) } } } catch { // ignore status polling errors in settings page } } const loadSystemFonts = async (selectedFontValue?: string) => { setIsLoadingFonts(true) const applySelectedFont = (options: FontOption[]) => { const current = findFontOption(options, selectedFontValue || settings.font_family) if (current) applyFontFamily(current.fontFamily) } const api = (window as any).api if (!api?.getSystemFonts) { setFontOptions(defaultFontOptions) applySelectedFont(defaultFontOptions) setIsLoadingFonts(false) return } try { const res = await api.getSystemFonts() if (res.success && Array.isArray(res.data)) { const remoteOptions = res.data .map((name: string) => String(name || "").trim()) .filter((name: string) => Boolean(name)) .map((name: string) => { const label = formatFontDisplayName(name) return { value: buildSystemFontValue(name), label, fontFamily: buildSystemFontFamily(name), searchText: buildFontSearchText(name, label), } satisfies FontOption }) .sort((a: FontOption, b: FontOption) => a.label.localeCompare(b.label, "zh-Hans-CN-u-co-pinyin") ) const mergedOptions = mergeFontOptions([...defaultFontOptions, ...remoteOptions]) setFontOptions(mergedOptions) applySelectedFont(mergedOptions) } else { setFontOptions(defaultFontOptions) applySelectedFont(defaultFontOptions) } } catch (error) { console.error("Failed to load system fonts:", error) setFontOptions(defaultFontOptions) applySelectedFont(defaultFontOptions) } finally { setIsLoadingFonts(false) } } const loadAll = async () => { const api = (window as any).api if (!api) { setFontOptions(defaultFontOptions) setIsLoadingFonts(false) return } let savedFontFamily = settings.font_family || "system" const res = await api.getAllSettings() if (res.success && res.data) { setSettings(res.data) setPgConnectionString(res.data.pg_connection_string || "") setPgConnectionStatus(res.data.pg_connection_status || { connected: true, type: "sqlite" }) savedFontFamily = res.data.font_family || "system" } const authRes = await api.authGetStatus() if (authRes.success && authRes.data) setSecurityStatus(authRes.data) const oauthStateRes = await api.oauthLoadLoginState() if (oauthStateRes?.success && oauthStateRes.data) { setOAuthUserInfo({ user_id: oauthStateRes.data.user_id, email: oauthStateRes.data.email, name: oauthStateRes.data.name, github_username: oauthStateRes.data.github_username, permission: oauthStateRes.data.permission, }) } else { setOAuthUserInfo(null) } await loadMcpStatus() await loadSystemFonts(savedFontFamily) } const handleOAuthLogout = async () => { const api = (window as any).api if (!api) { setOAuthUserInfo(null) return } try { await api.oauthClearLoginState() setOAuthUserInfo(null) messageApi.success(t("auth.logout")) } catch (error: any) { messageApi.error(error?.message || t("common.error")) } } const loadAboutContent = async () => { try { const res = await fetch("/about-content.json") if (res.ok) { const data = await res.json() setAboutContent(data) } } catch { // ignore if about-content.json not found } } useEffect(() => { loadAll() loadAboutContent() const api = (window as any).api if (!api) return let disposed = false let unlisten: (() => void) | null = null api .onSettingChanged((change: any) => { setSettings((prev) => { if (change?.key === "log_level") return { ...prev, log_level: change.value } if (change?.key === "is_wizard_completed") return { ...prev, is_wizard_completed: change.value } if (change?.key === "window_zoom") return { ...prev, window_zoom: change.value } if (change?.key === "search_keyboard_layout") return { ...prev, search_keyboard_layout: change.value } if (change?.key === "disable_search_keyboard") return { ...prev, disable_search_keyboard: Boolean(change.value) } if (change?.key === "auto_score_enabled") return { ...prev, auto_score_enabled: change.value } if (change?.key === "font_family") { const value = String(change.value || "system") const fontOpt = findFontOption(fontOptionsRef.current, value) if (fontOpt) { applyFontFamily(fontOpt.fontFamily) } return { ...prev, font_family: value } } return prev }) }) .then((fn: () => void) => { if (disposed) { fn() return } unlisten = fn }) .catch(() => void 0) return () => { disposed = true if (unlisten) unlisten() } }, []) const showLogs = async () => { if (!(window as any).api) return setLogsLoading(true) const res = await (window as any).api.queryLogs(200) setLogsLoading(false) if (!res.success) { messageApi.error(res.message || t("settings.data.readLogsFailed")) return } setLogsText((res.data || []).join("\n")) setLogsDialogVisible(true) } const exportLogs = async () => { if (!(window as any).api) return const res = await (window as any).api.queryLogs(5000) if (!res.success) { messageApi.error(res.message || t("settings.data.readLogsFailed")) return } const dateTime = new Date().toISOString().replace(/[:.]/g, "-") downloadTextFile(`secscore_logs_${dateTime}.txt`, `${(res.data || []).join("\n")}\n`) messageApi.success(t("settings.data.logsExported")) } const downloadTextFile = (filename: string, text: string) => { const blob = new Blob(["\ufeff" + text], { type: "text/plain;charset=utf-8" }) const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = filename a.click() URL.revokeObjectURL(url) } const showRecoveryDialog = (header: string, recoveryString: string) => { const date = new Date().toISOString().slice(0, 10) const filename = `secscore_recovery_${date}.txt` setRecoveryDialogHeader(header) setRecoveryDialogString(recoveryString) setRecoveryDialogFilename(filename) setRecoveryDialogVisible(true) } const exportJson = async () => { if (!(window as any).api) return const res = await (window as any).api.exportDataJson() if (!res.success || !res.data) { messageApi.error(res.message || t("settings.data.exportFailed")) return } const blob = new Blob([res.data], { type: "application/json;charset=utf-8" }) const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = `secscore_export_${new Date().toISOString().slice(0, 10)}.json` a.click() URL.revokeObjectURL(url) messageApi.success(t("settings.data.exportSuccess")) } const importJson = async (file: File) => { if (!(window as any).api) return const text = await file.text() const res = await (window as any).api.importDataJson(text) if (res.success) { messageApi.success(t("settings.data.importSuccess")) setTimeout(() => window.location.reload(), 300) } else { messageApi.error(res.message || t("settings.data.importFailed")) } } const savePasswords = async () => { if (!(window as any).api) return const res = await (window as any).api.authSetPasswords({ adminPassword: adminPassword ? adminPassword : undefined, pointsPassword: pointsPassword ? pointsPassword : undefined, }) if (res.success) { setAdminPassword("") setPointsPassword("") await loadAll() if (res.data?.recoveryString) { showRecoveryDialog( t("settings.security.recoveryString") + ` (${t("recovery.hint")})`, res.data.recoveryString ) } else { messageApi.success(t("settings.security.saved")) } } else { messageApi.error(res.message || t("settings.general.saveFailed")) } } const generateRecovery = async () => { if (!(window as any).api) return const res = await (window as any).api.authGenerateRecovery() if (!res.success || !res.data?.recoveryString) { messageApi.error(res.message || t("settings.security.generateFailed")) return } await loadAll() showRecoveryDialog( t("settings.security.newRecoveryString", "New recovery string (please save it)"), res.data.recoveryString ) } const resetByRecovery = async () => { if (!(window as any).api) return const res = await (window as any).api.authResetByRecovery(recoveryToReset) if (!res.success || !res.data?.recoveryString) { messageApi.error(res.message || t("settings.security.resetFailed")) return } setRecoveryToReset("") await loadAll() showRecoveryDialog( t("settings.security.passwordClearedNewRecovery", "Password cleared, new recovery string"), res.data.recoveryString ) } const clearAllPasswords = () => { setClearDialogVisible(true) } const handleConfirmClearAll = async () => { if (!(window as any).api) return setClearLoading(true) const res = await (window as any).api.authClearAll() setClearLoading(false) if (res.success) { messageApi.success(t("settings.security.cleared")) await loadAll() setClearDialogVisible(false) } else { messageApi.error(res.message || t("settings.security.clearFailed")) } } const confirmSettlement = () => { setSettleDialogVisible(true) } const testPgConnection = async () => { if (!(window as any).api) return if (!pgConnectionString) { messageApi.warning(t("settings.database.enterConnectionString")) return } setPgTestLoading(true) try { const res = await withTimeout( (window as any).api.dbTestConnection(pgConnectionString), 20_000, "测试连接超时,请检查网络或连接字符串" ) if (res.success && res.data?.success) { messageApi.success(t("settings.database.connectionTestSuccess")) } else { messageApi.error( res.data?.error || res.message || t("settings.database.connectionTestFailed") ) } } catch (e: any) { messageApi.error(e?.message || t("settings.database.connectionTestFailed")) } finally { setPgTestLoading(false) } } const switchToPg = async () => { if (!(window as any).api) return if (!pgConnectionString.trim()) { messageApi.warning(t("settings.database.enterConnectionString")) return } setPgSwitchLoading(true) try { const res = await withTimeout( (window as any).api.dbSwitchConnection(pgConnectionString.trim()), 25_000, "切换 PostgreSQL 超时,请检查网络或连接字符串" ) if (res.success) { messageApi.success( t("settings.database.switchedTo", { type: res.data?.type === "postgresql" ? "PostgreSQL" : "SQLite", }) ) await loadAll() } else { messageApi.error(res.message || t("settings.database.switchFailed")) } } catch (e: any) { messageApi.error(e?.message || t("settings.database.switchFailed")) } finally { setPgSwitchLoading(false) } } const switchToSQLite = async () => { if (!(window as any).api) return setPgSwitchLoading(true) try { const res = await withTimeout( (window as any).api.dbSwitchConnection(""), 15_000, "切换 SQLite 超时,请稍后重试" ) if (res.success) { messageApi.success(t("settings.database.switchedToSQLite")) setPgConnectionString("") await loadAll() } else { messageApi.error(res.message || t("settings.database.switchFailed")) } } catch (e: any) { messageApi.error(e?.message || t("settings.database.switchFailed")) } finally { setPgSwitchLoading(false) } } const uploadLocalToRemote = async () => { if (!(window as any).api) return try { const statusRes = await (window as any).api.dbGetStatus() if ( !statusRes.success || !statusRes.data?.connected || statusRes.data?.type !== "postgresql" ) { messageApi.warning(t("settings.database.uploadNeedRemote")) return } const previewRes = await withTimeout( (window as any).api.dbSyncPreview(), 25_000, "同步预检查超时,请稍后重试" ) if (!previewRes.success || !previewRes.data?.can_sync) { messageApi.error( previewRes.message || previewRes.data?.message || t("settings.database.uploadFailed") ) return } if (!previewRes.data.need_sync) { messageApi.info(t("settings.database.noNeedUpload")) return } Modal.confirm({ title: t("settings.database.uploadConfirmTitle"), content: t("settings.database.uploadConfirmContent", { localOnly: previewRes.data.local_only || 0, remoteOnly: previewRes.data.remote_only || 0, conflicts: previewRes.data.conflicts?.length || 0, }), okText: t("settings.database.uploadButton"), cancelText: t("common.cancel"), onOk: async () => { setPgUploadLoading(true) try { const applyRes = await withTimeout( (window as any).api.dbSyncApply("keep_local"), 45_000, "执行同步超时,请稍后重试" ) if (applyRes.success && applyRes.data?.success) { messageApi.success(applyRes.data?.message || t("settings.database.uploadSuccess")) emitDataUpdated("all") await loadAll() } else { messageApi.error( applyRes.data?.message || applyRes.message || t("settings.database.uploadFailed") ) } } catch (e: any) { messageApi.error(e?.message || t("settings.database.uploadFailed")) } finally { setPgUploadLoading(false) } }, }) } catch (e: any) { messageApi.error(e?.message || t("settings.database.uploadFailed")) } } const startMcpServer = async () => { if (!(window as any).api?.mcpServerStart) return const host = mcpConfig.host.trim() const port = Number(mcpConfig.port) if (!host) { messageApi.warning(t("settings.mcp.hostRequired")) return } if (!Number.isInteger(port) || port <= 0 || port > 65535) { messageApi.warning(t("settings.mcp.portInvalid")) return } setMcpLoading(true) try { const res = await withTimeout( (window as any).api.mcpServerStart({ host, port }), 10_000, t("settings.mcp.startTimeout") ) if (res.success) { messageApi.success(t("settings.mcp.startSuccess")) } else { messageApi.error(res.message || t("settings.mcp.startFailed")) } } catch (e: any) { messageApi.error(e?.message || t("settings.mcp.startFailed")) } finally { await loadMcpStatus() setMcpLoading(false) } } const stopMcpServer = async () => { if (!(window as any).api?.mcpServerStop) return setMcpLoading(true) try { const res = await withTimeout( (window as any).api.mcpServerStop(), 10_000, t("settings.mcp.stopTimeout") ) if (res.success) { messageApi.success(t("settings.mcp.stopSuccess")) } else { messageApi.error(res.message || t("settings.mcp.stopFailed")) } } catch (e: any) { messageApi.error(e?.message || t("settings.mcp.stopFailed")) } finally { await loadMcpStatus() setMcpLoading(false) } } const currentYear = new Date().getFullYear() const confirmQuitApp = () => { Modal.confirm({ title: t("settings.app.confirmQuitTitle"), content: t("settings.app.confirmQuitContent"), okText: t("settings.app.quit"), okButtonProps: { danger: true }, cancelText: t("common.cancel"), onOk: async () => { if (!(window as any).api) return setAppQuitLoading(true) try { await (window as any).api.appQuit() } catch (e: any) { setAppQuitLoading(false) messageApi.error(e?.message || t("settings.app.quitFailed")) } }, }) } const confirmRestartApp = () => { Modal.confirm({ title: t("settings.app.confirmRestartTitle"), content: t("settings.app.confirmRestartContent"), okText: t("settings.app.restart"), cancelText: t("common.cancel"), onOk: async () => { if (!(window as any).api) return setAppRestartLoading(true) try { await (window as any).api.appRestart() } catch (e: any) { setAppRestartLoading(false) messageApi.error(e?.message || t("settings.app.restartFailed")) } }, }) } const tabItems = [ { key: "appearance", label: t("settings.tabs.appearance"), children: (
{ const fontOpt = findFontOption(fontOptions, String(v)) if (!fontOpt) return applyFontFamily(fontOpt.fontFamily) setSettings((prev) => ({ ...prev, font_family: String(v) })) if ((window as any).api) { const res = await (window as any).api.setSetting("font_family", String(v)) if (res.success) { messageApi.success(t("settings.general.saved")) } else { messageApi.error(res.message || t("settings.general.saveFailed")) } } else { messageApi.success(t("settings.general.saved")) } }} style={{ width: "320px" }} loading={isLoadingFonts} options={fontOptions.map((opt) => ({ value: opt.value, label: opt.label, searchText: opt.searchText, }))} showSearch filterOption={(input, option) => String(option?.searchText ?? option?.label ?? "") .toLowerCase() .includes(input.toLowerCase()) } />
{t("settings.fontFamilyHint")}
{!isMobile && ( <> { if (!(window as any).api) return const next = String(v) const res = await (window as any).api.setSetting("window_zoom", next) if (res.success) { setSettings((prev) => ({ ...prev, window_zoom: next })) messageApi.success(t("settings.general.saved")) } else { messageApi.error(res.message || t("settings.general.saveFailed")) } }} style={{ width: "320px" }} disabled={!canAdmin} options={[ { value: "0.7", label: t("settings.zoomOptions.small70") }, { value: "0.8", label: "80%" }, { value: "0.9", label: "90%" }, { value: "1.0", label: t("settings.zoomOptions.default100") }, { value: "1.1", label: "110%" }, { value: "1.2", label: "120%" }, { value: "1.3", label: "130%" }, { value: "1.5", label: t("settings.zoomOptions.large150") }, ]} />
{t("settings.zoomHint")}
), }, { key: "security", label: t("settings.tabs.security"), children: ( <>
{t("settings.security.title")}
{t("settings.security.adminPassword")}{" "} {securityStatus?.hasAdminPassword ? t("settings.security.set") : t("settings.security.notSet")} {t("settings.security.pointsPassword")}{" "} {securityStatus?.hasPointsPassword ? t("settings.security.set") : t("settings.security.notSet")} {t("settings.security.recoveryString")}{" "} {securityStatus?.hasRecoveryString ? t("settings.security.generated") : t("settings.security.notGenerated")}
setAdminPassword(e.target.value)} placeholder={t("settings.security.adminPasswordPlaceholder")} maxLength={6} disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)} /> setPointsPassword(e.target.value)} placeholder={t("settings.security.pointsPasswordPlaceholder")} maxLength={6} disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)} />
{t("settings.security.recoveryReset")}
setRecoveryToReset(e.target.value)} placeholder={t("settings.security.recoveryPlaceholder")} style={{ width: "420px" }} />
{t("settings.security.resetHint")}
), }, { key: "account", label: t("settings.tabs.account"), children: ( <>
{t("settings.account.title")}
{oauthUserInfo ? (
{oauthUserInfo.name.charAt(0).toUpperCase()}
{oauthUserInfo.name}
{oauthUserInfo.email}
{oauthUserInfo.github_username && (
GitHub: @{oauthUserInfo.github_username}
)}
{t("settings.account.userId")}: {oauthUserInfo.user_id}
{t("settings.account.permission")}: {getOAuthPermissionLabel(oauthUserInfo.permission)}
) : (
{t("settings.account.notLoggedIn")} {t("settings.account.oauthHint")}
)}
), }, { key: "database", label: t("settings.database.title"), children: ( <>
{t("settings.database.currentStatus")}
{pgConnectionStatus.type === "postgresql" ? t("settings.database.postgresqlRemote") : t("settings.database.sqliteLocal")} {pgConnectionStatus.connected ? t("settings.database.connected") : t("settings.database.disconnected")} {pgConnectionStatus.error && ( {pgConnectionStatus.error} )}
{t("settings.database.postgresqlConnection")}
{t("settings.database.connectionHint")}
setPgConnectionString(e.target.value)} placeholder="postgresql://user:password@host:port/database?sslmode=require" style={{ flex: 1 }} disabled={!canAdmin} />
{t("settings.database.connectionExample")}
{t("settings.database.uploadHint")}
{t("settings.database.syncDescription")}

{t("settings.database.syncPoint1")}

{t("settings.database.syncPoint2")}

{t("settings.database.syncPoint3")}

{t("settings.database.syncPoint4")}

), }, { key: "data", label: t("settings.data.title"), children: ( <>
{t("settings.data.settlementHint")}
{t("settings.data.importExport")}
{ const file = e.target.files?.[0] if (file) importJson(file) if (importInputRef.current) importInputRef.current.value = "" }} />
{t("settings.data.importHint")}
setMcpConfig((prev) => ({ ...prev, host: e.target.value }))} disabled={!canAdmin || mcpStatus.is_running} style={{ width: "280px" }} placeholder="127.0.0.1" /> { const next = Number(e.target.value.replace(/[^\d]/g, "")) if (Number.isFinite(next) && next > 0) { setMcpConfig((prev) => ({ ...prev, port: next })) } else if (!e.target.value) { setMcpConfig((prev) => ({ ...prev, port: 0 })) } }} disabled={!canAdmin || mcpStatus.is_running} style={{ width: "180px" }} placeholder="3901" />
{t("settings.mcp.hint")}
{t("settings.app.title")}
{t("settings.app.description")}
), }, ] return (
{contextHolder}

{t("settings.title")}

{permissionTag}
setRecoveryDialogVisible(false)} footer={ } width="70%" >
{recoveryDialogString}
{t("recovery.exportHint")}
setLogsDialogVisible(false)} footer={} width="80%" >
{logsText || t("settings.data.noLogs")}
!settleLoading && setSettleDialogVisible(false)} onOk={async () => { if (!(window as any).api) return setSettleLoading(true) const res = await (window as any).api.createSettlement() setSettleLoading(false) if (res.success && res.data) { messageApi.success(t("settings.data.settlementSuccess")) emitDataUpdated("all") setSettleDialogVisible(false) } else { messageApi.error(res.message || t("settings.data.settlementFailed")) } }} confirmLoading={settleLoading} okText={t("settings.data.settle")} >
{t("settings.data.settlementConfirm1")}
{t("settings.data.settlementConfirm2")}
!clearLoading && setClearDialogVisible(false)} onOk={handleConfirmClearAll} confirmLoading={clearLoading} okText="确认清空" okButtonProps={{ danger: true }} > 清空后将关闭保护(无密码时默认视为管理权限)。 setOAuthLoginVisible(false)} onSuccess={(userInfo) => { setOAuthUserInfo(userInfo) messageApi.success(t("settings.account.loginSuccess")) }} />
) }