import React, { useEffect, useMemo, useRef, useState } from "react" import { RightOutlined } from "@ant-design/icons" import { Tabs, Card, Form, Select, Input, Button, Space, Divider, Tag, Modal, Switch, message, } from "antd" import { ThemeQuickSettings } from "./ThemeQuickSettings" import { useTranslation } from "react-i18next" import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n" import { useResponsive } from "../hooks/useResponsive" import { useLocation, useNavigate } from "react-router-dom" 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 auto_score_enabled?: boolean } 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 mobileNavigationEnabled?: boolean }> = ({ permission, mobileNavigationEnabled = false }) => { const { t } = useTranslation() const navigate = useNavigate() const location = useLocation() 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 [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 [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 permissionTag = useMemo(() => { return ( {permission === "admin" ? t("permissions.admin") : permission === "points" ? t("permissions.points") : t("permissions.view")} ) }, [permission, t]) const mobilePageItems = useMemo( () => [ { key: "students", path: "/students", label: t("sidebar.students"), disabled: !canAdmin }, { key: "score", path: "/score", label: t("sidebar.score"), disabled: false }, { key: "auto-score", path: "/auto-score", label: t("sidebar.autoScore"), disabled: false }, { key: "reward-settings", path: "/reward-settings", label: t("sidebar.rewardSettings"), disabled: !canAdmin, }, { key: "boards", path: "/boards", label: t("sidebar.boards"), disabled: false }, { key: "leaderboard", path: "/leaderboard", label: t("sidebar.leaderboard"), disabled: false }, { key: "settlements", path: "/settlements", label: t("sidebar.settlements"), disabled: false }, { key: "reasons", path: "/reasons", label: t("sidebar.reasons"), disabled: !canAdmin }, ], [canAdmin, t] ) 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 loadAll = async () => { if (!(window as any).api) return const res = await (window as any).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" }) } const authRes = await (window as any).api.authGetStatus() if (authRes.success && authRes.data) setSecurityStatus(authRes.data) await loadMcpStatus() } useEffect(() => { loadAll() 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 } 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: (
{ if (!(window as any).api) return const next = String(v) as "t9" | "qwerty26" const res = await (window as any).api.setSetting("search_keyboard_layout", next) if (res.success) { setSettings((prev) => ({ ...prev, search_keyboard_layout: next })) messageApi.success(t("settings.general.saved")) } else { messageApi.error(res.message || t("settings.general.saveFailed")) } }} style={{ width: "320px" }} disabled={!canAdmin} options={[ { value: "qwerty26", label: t("settings.searchKeyboard.options.qwerty26") }, { value: "t9", label: t("settings.searchKeyboard.options.t9") }, ]} />
{t("settings.searchKeyboard.hint")}
{ if (!(window as any).api) return const res = await (window as any).api.setSetting( "disable_search_keyboard", checked ) if (res.success) { setSettings((prev) => ({ ...prev, disable_search_keyboard: checked })) messageApi.success(t("settings.general.saved")) } else { messageApi.error(res.message || t("settings.general.saveFailed")) } }} disabled={!canAdmin} />
{t("settings.searchKeyboard.disableHint")}
)} 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: "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}
{mobileNavigationEnabled && isMobile && (
{mobilePageItems.map((item) => ( ))}
)} 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 }} > 清空后将关闭保护(无密码时默认视为管理权限)。
) }