feat(URL协议): 增强URL协议注册健壮性

- 新增check_url_protocol_status命令检测注册状态
- 新增unregister_url_protocol命令取消注册
- 改进register_url_protocol命令增加验证逻辑
- 前端增加状态检测、自动重试和操作日志功能
- 更新多语言翻译支持新功能
This commit is contained in:
Yukino_fox
2026-05-04 12:11:18 +08:00
parent 3e85c32c9c
commit a2ec923788
28 changed files with 3246 additions and 429 deletions
+6 -6
View File
@@ -630,9 +630,9 @@ export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined):
? stringifyRewardActionValue(parsedValue.rewardId, parsedValue.rewardName) || ""
: ""
})()
: draft.event === "settle_score"
? ""
: toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value),
: draft.event === "settle_score"
? ""
: toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value),
} satisfies ActionDraft
})
.filter((item): item is ActionDraft => Boolean(item))
@@ -664,9 +664,9 @@ export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => {
? stringifyRewardActionValue(parsedValue.rewardId, parsedValue.rewardName) || ""
: ""
})()
: action.event === "settle_score"
? ""
: toStringValue(action.value),
: action.event === "settle_score"
? ""
: toStringValue(action.value),
} satisfies ActionDraft
})
.filter((item): item is ActionDraft => Boolean(item))
-1
View File
@@ -686,4 +686,3 @@ export function ContentArea({
</Layout>
)
}
+1 -5
View File
@@ -17,7 +17,6 @@ interface OAuthLoginProps {
interface OAuthConfig {
platform_id: string
platform_secret: string
}
interface OAuthCallbackResult {
@@ -56,15 +55,13 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
if (!api) return null
const platformId = import.meta.env.VITE_OAUTH_PLATFORM_ID
const platformSecret = import.meta.env.VITE_OAUTH_PLATFORM_SECRET
if (!platformId || !platformSecret) {
if (!platformId) {
return null
}
return {
platform_id: platformId,
platform_secret: platformSecret,
}
}
@@ -138,7 +135,6 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
const tokenRes = await api.oauthExchangeCode(
result.code,
config.platform_id,
config.platform_secret,
callbackUrl,
codeVerifier
)
+402
View File
@@ -0,0 +1,402 @@
import React, { useState, useEffect, useCallback } from "react"
import {
Card,
Button,
Space,
Tag,
Table,
Progress,
message,
Spin,
Tooltip,
Modal,
Radio,
Divider,
Alert,
} from "antd"
import {
CloudSyncOutlined,
CloudUploadOutlined,
CloudDownloadOutlined,
ReloadOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
InfoCircleOutlined,
} from "@ant-design/icons"
import { useTranslation } from "react-i18next"
import { useSectl } from "../contexts/SectlContext"
import { sectlCloudSync, CloudSyncResult, CloudSyncStatus } from "../services/sectlCloudSync"
import { sectlAuth } from "../services/sectlAuth"
const TABLE_LABELS: Record<string, string> = {
students: "学生",
reasons: "理由",
score_events: "积分事件",
tags: "标签",
student_tags: "学生标签",
reward_settings: "奖励设置",
reward_redemptions: "奖励兑换",
}
interface StorageStats {
used: number
total: number
percentage: number
table_stats: Array<{ table: string; count: number; size: number }>
}
export const SectlCloudSyncPanel: React.FC = () => {
const { t } = useTranslation()
const { isAuthenticated, isLoading: authLoading } = useSectl()
const [syncStatus, setSyncStatus] = useState<CloudSyncStatus>(sectlCloudSync.getStatus())
const [storageStats, setStorageStats] = useState<StorageStats | null>(null)
const [syncDirection, setSyncDirection] = useState<"push" | "pull" | "bidirectional">(
"bidirectional"
)
const [lastResult, setLastResult] = useState<CloudSyncResult | null>(null)
const [loadingStats, setLoadingStats] = useState(false)
const [messageApi, contextHolder] = message.useMessage()
const refreshStatus = useCallback(() => {
setSyncStatus(sectlCloudSync.getStatus())
}, [])
useEffect(() => {
refreshStatus()
const interval = setInterval(refreshStatus, 10_000)
return () => clearInterval(interval)
}, [refreshStatus])
const loadStorageStats = useCallback(async () => {
if (!isAuthenticated) return
setLoadingStats(true)
try {
const stats = await sectlCloudSync.getCloudStorageUsage()
setStorageStats(stats)
} catch {
// ignore
} finally {
setLoadingStats(false)
}
}, [isAuthenticated])
useEffect(() => {
if (isAuthenticated) loadStorageStats()
}, [isAuthenticated, loadStorageStats])
const handleSync = async (direction: "push" | "pull" | "bidirectional") => {
if (!sectlAuth.isAuthenticated()) {
messageApi.warning(t("settings.cloudSync.loginRequired"))
return
}
refreshStatus()
if (syncStatus.is_syncing) {
messageApi.warning(t("settings.cloudSync.syncInProgress"))
return
}
const directionLabel =
direction === "push"
? t("settings.cloudSync.uploadToCloud")
: direction === "pull"
? t("settings.cloudSync.downloadFromCloud")
: t("settings.cloudSync.fullSync")
Modal.confirm({
title: directionLabel,
content: t("settings.cloudSync.syncConfirm", { direction: directionLabel }),
okText: t("common.confirm"),
cancelText: t("common.cancel"),
onOk: async () => {
try {
let result: CloudSyncResult
if (direction === "push") {
result = await sectlCloudSync.syncToCloud()
} else if (direction === "pull") {
result = await sectlCloudSync.syncFromCloud()
} else {
result = await sectlCloudSync.fullSync("bidirectional")
}
setLastResult(result)
refreshStatus()
await loadStorageStats()
if (result.success) {
messageApi.success(result.message)
window.dispatchEvent(
new CustomEvent("ss:data-updated", { detail: { category: "all" } })
)
} else {
messageApi.error(result.message)
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
messageApi.error(`同步失败:${msg}`)
}
},
})
}
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 B"
const k = 1024
const sizes = ["B", "KB", "MB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
const formatTime = (iso: string | null): string => {
if (!iso) return t("settings.cloudSync.never")
try {
return new Date(iso).toLocaleString()
} catch {
return iso
}
}
if (authLoading) {
return (
<Card>
<div style={{ textAlign: "center", padding: "40px 0" }}>
<Spin />
</div>
</Card>
)
}
if (!isAuthenticated) {
return (
<Card>
<Alert
type="warning"
showIcon
icon={<InfoCircleOutlined />}
message={t("settings.cloudSync.loginRequired")}
description={t("settings.cloudSync.loginHint")}
/>
</Card>
)
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{contextHolder}
<Card
title={
<Space>
<CloudSyncOutlined />
<span>{t("settings.cloudSync.statusTitle")}</span>
</Space>
}
style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
}}
>
<Space>
<Tag color={syncStatus.is_configured ? "success" : "default"}>
{syncStatus.is_configured
? t("settings.cloudSync.configured")
: t("settings.cloudSync.notConfigured")}
</Tag>
{syncStatus.is_syncing && (
<Tag color="processing">
<Spin size="small" /> {t("settings.cloudSync.syncing")}
</Tag>
)}
</Space>
<span style={{ fontSize: "12px", color: "var(--ss-text-secondary)" }}>
{t("settings.cloudSync.lastSync")}: {formatTime(syncStatus.last_sync_at)}
</span>
</div>
{lastResult && (
<Alert
type={lastResult.success ? "success" : "error"}
showIcon
icon={lastResult.success ? <CheckCircleOutlined /> : <ExclamationCircleOutlined />}
message={lastResult.message}
description={
lastResult.tables.length > 0 && (
<div style={{ marginTop: 8 }}>
{lastResult.tables
.filter((tr) => tr.uploaded > 0 || tr.downloaded > 0 || tr.errors.length > 0)
.map((tr) => (
<Tag
key={tr.table}
color={tr.errors.length > 0 ? "error" : "success"}
style={{ marginBottom: 4 }}
>
{TABLE_LABELS[tr.table] || tr.table}
{tr.uploaded > 0 && `${tr.uploaded}`}
{tr.downloaded > 0 && `${tr.downloaded}`}
{tr.errors.length > 0 && `${tr.errors.length}`}
</Tag>
))}
</div>
)
}
style={{ marginBottom: 16 }}
closable
onClose={() => setLastResult(null)}
/>
)}
</Card>
<Card
title={t("settings.cloudSync.syncActions")}
style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}
>
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>
{t("settings.cloudSync.syncDirection")}
</div>
<Radio.Group
value={syncDirection}
onChange={(e) => setSyncDirection(e.target.value)}
style={{ width: "100%" }}
>
<Space direction="vertical" style={{ width: "100%" }}>
<Radio value="bidirectional">{t("settings.cloudSync.bidirectional")}</Radio>
<Radio value="push">{t("settings.cloudSync.pushOnly")}</Radio>
<Radio value="pull">{t("settings.cloudSync.pullOnly")}</Radio>
</Space>
</Radio.Group>
</div>
<Divider style={{ margin: "12px 0" }} />
<Space direction="vertical" style={{ width: "100%" }} size="middle">
<Button
type="primary"
icon={<CloudSyncOutlined />}
onClick={() => handleSync(syncDirection)}
loading={syncStatus.is_syncing}
block
size="large"
>
{t("settings.cloudSync.startSync")}
</Button>
<Space style={{ width: "100%" }}>
<Button
icon={<CloudUploadOutlined />}
onClick={() => handleSync("push")}
loading={syncStatus.is_syncing}
style={{ flex: 1 }}
>
{t("settings.cloudSync.uploadToCloud")}
</Button>
<Button
icon={<CloudDownloadOutlined />}
onClick={() => handleSync("pull")}
loading={syncStatus.is_syncing}
style={{ flex: 1 }}
>
{t("settings.cloudSync.downloadFromCloud")}
</Button>
</Space>
</Space>
</Card>
<Card
title={
<Space>
<span>{t("settings.cloudSync.storageUsage")}</span>
<Tooltip title={t("common.refresh")}>
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
onClick={loadStorageStats}
loading={loadingStats}
/>
</Tooltip>
</Space>
}
style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}
>
{storageStats ? (
<>
<div style={{ marginBottom: 16 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: 8,
fontSize: "13px",
}}
>
<span>{t("settings.cloudSync.usedStorage")}</span>
<span>
{formatBytes(storageStats.used)} / {formatBytes(storageStats.total)}
</span>
</div>
<Progress
percent={storageStats.percentage}
status={storageStats.percentage > 90 ? "exception" : "active"}
size="small"
/>
</div>
<Table
dataSource={storageStats.table_stats}
rowKey="table"
size="small"
pagination={false}
columns={[
{
title: t("settings.cloudSync.table"),
dataIndex: "table",
key: "table",
render: (val: string) => TABLE_LABELS[val] || val,
},
{
title: t("settings.cloudSync.records"),
dataIndex: "count",
key: "count",
width: 80,
align: "center",
},
{
title: t("settings.cloudSync.size"),
dataIndex: "size",
key: "size",
width: 100,
align: "right",
render: (val: number) => formatBytes(val),
},
]}
/>
</>
) : (
<div
style={{ textAlign: "center", padding: "20px 0", color: "var(--ss-text-secondary)" }}
>
{loadingStats ? <Spin /> : t("settings.cloudSync.clickToLoadStats")}
</div>
)}
</Card>
<Card style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}>
<div style={{ fontWeight: 600, marginBottom: 12 }}>
{t("settings.cloudSync.description")}
</div>
<div style={{ fontSize: "13px", color: "var(--ss-text-secondary)", lineHeight: 1.8 }}>
<p>{t("settings.cloudSync.descriptionPoint1")}</p>
<p>{t("settings.cloudSync.descriptionPoint2")}</p>
<p>{t("settings.cloudSync.descriptionPoint3")}</p>
<p>{t("settings.cloudSync.descriptionPoint4")}</p>
</div>
</Card>
</div>
)
}
+523 -139
View File
@@ -14,9 +14,14 @@ import {
message,
Avatar,
Typography,
Radio,
Collapse,
} from "antd"
import { CloudOutlined, DatabaseOutlined } from "@ant-design/icons"
import { ThemeQuickSettings } from "./ThemeQuickSettings"
import { OAuthLogin } from "./OAuth/OAuthLogin"
import { SectlCloudSyncPanel } from "./SectlCloudSyncPanel"
import { SectlProvider } from "../contexts/SectlContext"
import { useTranslation } from "react-i18next"
import { pinyin } from "pinyin-pro"
import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n"
@@ -168,6 +173,21 @@ export const Settings: React.FC<{
const [settleDialogVisible, setSettleDialogVisible] = useState(false)
const [urlRegisterLoading, setUrlRegisterLoading] = useState(false)
const [elevationStatus, setElevationStatus] = useState<{
is_elevated: boolean
platform: string
can_request_elevation: boolean
} | null>(null)
const [elevationLoading, setElevationLoading] = useState(false)
const [urlStatus, setUrlStatus] = useState<{
registered: boolean
protocol: string
platform: string
details: string
} | null>(null)
const [urlStatusLoading, setUrlStatusLoading] = useState(false)
const [urlUnregisterLoading, setUrlUnregisterLoading] = useState(false)
const [urlOperationLogs, setUrlOperationLogs] = useState<string[]>([])
const [appQuitLoading, setAppQuitLoading] = useState(false)
const [appRestartLoading, setAppRestartLoading] = useState(false)
const [mcpLoading, setMcpLoading] = useState(false)
@@ -187,6 +207,8 @@ export const Settings: React.FC<{
const canAdmin = permission === "admin"
const [messageApi, contextHolder] = message.useMessage()
const [syncMethod, setSyncMethod] = useState<"postgresql" | "sectl_cloud">("postgresql")
const [pgConnectionString, setPgConnectionString] = useState("")
const [pgConnectionStatus, setPgConnectionStatus] = useState<{
connected: boolean
@@ -307,6 +329,11 @@ export const Settings: React.FC<{
setSettings(res.data)
setPgConnectionString(res.data.pg_connection_string || "")
setPgConnectionStatus(res.data.pg_connection_status || { connected: true, type: "sqlite" })
if (res.data.sync_method === "sectl_cloud") {
setSyncMethod("sectl_cloud")
} else {
setSyncMethod("postgresql")
}
savedFontFamily = res.data.font_family || "system"
}
const authRes = await api.authGetStatus()
@@ -326,6 +353,28 @@ export const Settings: React.FC<{
await loadMcpStatus()
await loadSystemFonts(savedFontFamily)
if (api.checkElevation) {
try {
const elevRes = await api.checkElevation()
if (elevRes?.success && elevRes.data) {
setElevationStatus(elevRes.data)
}
} catch {
// ignore
}
}
if (api.checkUrlProtocolStatus) {
try {
const urlRes = await api.checkUrlProtocolStatus()
if (urlRes?.success && urlRes.data) {
setUrlStatus(urlRes.data)
}
} catch {
// ignore
}
}
}
const handleOAuthLogout = async () => {
@@ -345,6 +394,118 @@ export const Settings: React.FC<{
}
}
const handleRequestElevation = async () => {
const api = (window as any).api
if (!api?.requestElevation) return
setElevationLoading(true)
try {
const res = await api.requestElevation()
if (res?.success) {
messageApi.success(t("settings.elevation.requestSuccess"))
} else {
messageApi.error(res?.message || t("settings.elevation.requestFailed"))
}
} catch (error: any) {
messageApi.error(error?.message || t("settings.elevation.requestFailed"))
} finally {
setElevationLoading(false)
}
}
const appendUrlLog = (msg: string) => {
const ts = new Date().toLocaleTimeString()
setUrlOperationLogs((prev) => [...prev.slice(-19), `[${ts}] ${msg}`])
}
const loadUrlStatus = async () => {
const api = (window as any).api
if (!api?.checkUrlProtocolStatus) return
setUrlStatusLoading(true)
try {
const res = await api.checkUrlProtocolStatus()
if (res?.success && res.data) {
setUrlStatus(res.data)
}
} catch {
// ignore
} finally {
setUrlStatusLoading(false)
}
}
const handleUrlRegister = async () => {
const api = (window as any).api
if (!api?.registerUrlProtocol || !api?.checkUrlProtocolStatus) return
setUrlRegisterLoading(true)
appendUrlLog(t("settings.url.logRegisterStart"))
const maxRetries = 7
let success = false
const doRegister = async () => {
await api.registerUrlProtocol()
appendUrlLog(t("settings.url.logRegisterVerify"))
const statusRes = await api.checkUrlProtocolStatus()
if (statusRes?.success && statusRes.data?.registered) {
setUrlStatus(statusRes.data)
return true
}
return false
}
success = await doRegister()
if (!success) {
for (let retry = 1; retry <= maxRetries; retry++) {
appendUrlLog(t("settings.url.retrying", { current: retry, max: maxRetries }))
await new Promise((r) => setTimeout(r, retry * 1000))
success = await doRegister()
if (success) {
appendUrlLog(t("settings.url.retrySuccess", { n: retry }))
break
}
appendUrlLog(t("settings.url.logRegisterFailed"))
}
}
setUrlRegisterLoading(false)
if (success) {
appendUrlLog(t("settings.url.logRegisterSuccess"))
messageApi.success(t("settings.url.registered"))
} else {
appendUrlLog(t("settings.url.logMaxRetries"))
messageApi.error(t("settings.url.retryFailed"))
}
}
const handleUrlUnregister = async () => {
const api = (window as any).api
if (!api?.unregisterUrlProtocol || !api?.checkUrlProtocolStatus) return
setUrlUnregisterLoading(true)
appendUrlLog(t("settings.url.logUnregisterStart"))
try {
const res = await api.unregisterUrlProtocol()
if (res?.success) {
const statusRes = await api.checkUrlProtocolStatus()
if (statusRes?.success && statusRes.data) {
setUrlStatus(statusRes.data)
}
appendUrlLog(t("settings.url.logUnregisterSuccess"))
messageApi.success(t("settings.url.unregistered"))
} else {
appendUrlLog(t("settings.url.logUnregisterFailed"))
messageApi.error(res?.message || t("settings.url.unregisterFailed"))
}
} catch (error: any) {
appendUrlLog(t("settings.url.logUnregisterFailed"))
messageApi.error(error?.message || t("settings.url.unregisterFailed"))
} finally {
setUrlUnregisterLoading(false)
}
}
const loadAboutContent = async () => {
try {
const res = await fetch("/about-content.json")
@@ -1178,7 +1339,7 @@ export const Settings: React.FC<{
},
{
key: "database",
label: t("settings.database.title"),
label: t("settings.cloudSync.title"),
children: (
<>
<Card
@@ -1189,110 +1350,220 @@ export const Settings: React.FC<{
}}
>
<div style={{ fontWeight: 600, marginBottom: "12px" }}>
{t("settings.database.currentStatus")}
</div>
<Space>
<Tag color={pgConnectionStatus.type === "postgresql" ? "blue" : "green"}>
{pgConnectionStatus.type === "postgresql"
? t("settings.database.postgresqlRemote")
: t("settings.database.sqliteLocal")}
</Tag>
<Tag color={pgConnectionStatus.connected ? "success" : "error"}>
{pgConnectionStatus.connected
? t("settings.database.connected")
: t("settings.database.disconnected")}
</Tag>
{pgConnectionStatus.error && (
<span style={{ color: "var(--ant-color-error, #ff4d4f)", fontSize: "12px" }}>
{pgConnectionStatus.error}
</span>
)}
</Space>
</Card>
<Card
style={{
backgroundColor: "var(--ss-card-bg)",
color: "var(--ss-text-main)",
marginBottom: "16px",
}}
>
<div style={{ fontWeight: 600, marginBottom: "12px" }}>
{t("settings.database.postgresqlConnection")}
{t("settings.cloudSync.selectMethod")}
</div>
<div
style={{ marginBottom: "12px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
style={{ marginBottom: "16px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
>
{t("settings.database.connectionHint")}
{t("settings.cloudSync.selectMethodHint")}
</div>
<Space.Compact style={{ width: "100%", marginBottom: "12px" }}>
<Input
value={pgConnectionString}
onChange={(e) => setPgConnectionString(e.target.value)}
placeholder="postgresql://user:password@host:port/database?sslmode=require"
style={{ flex: 1 }}
disabled={!canAdmin}
/>
<Button
onClick={testPgConnection}
loading={pgTestLoading}
disabled={!canAdmin || !pgConnectionString}
>
{t("settings.database.testConnection")}
</Button>
</Space.Compact>
<div
style={{ marginBottom: "12px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
>
{t("settings.database.connectionExample")}
</div>
<Space
direction={isMobile ? "vertical" : "horizontal"}
size={isMobile ? 8 : "small"}
<Radio.Group
value={syncMethod}
onChange={async (e) => {
const next = e.target.value as "postgresql" | "sectl_cloud"
setSyncMethod(next)
if ((window as any).api) {
await (window as any).api.setSetting("sync_method", next)
}
}}
disabled={!canAdmin}
style={{ width: "100%" }}
>
<Button
type="primary"
onClick={switchToPg}
loading={pgSwitchLoading}
disabled={!canAdmin || !pgConnectionString}
style={{ width: isMobile ? "100%" : "auto" }}
>
{t("settings.database.switchToPostgreSQL")}
</Button>
<Button
onClick={switchToSQLite}
loading={pgSwitchLoading}
disabled={!canAdmin || pgConnectionStatus.type === "sqlite"}
style={{ width: isMobile ? "100%" : "auto" }}
>
{t("settings.database.switchToSQLite")}
</Button>
<Button
onClick={uploadLocalToRemote}
loading={pgUploadLoading}
disabled={!canAdmin || pgConnectionStatus.type !== "postgresql"}
style={{ width: isMobile ? "100%" : "auto" }}
>
{t("settings.database.uploadButton")}
</Button>
</Space>
<div style={{ marginTop: "8px", fontSize: "12px", color: "var(--ss-text-secondary)" }}>
{t("settings.database.uploadHint")}
</div>
<Space direction="vertical" style={{ width: "100%" }} size={12}>
<Card
hoverable
size="small"
style={{
borderColor:
syncMethod === "postgresql" ? "var(--ant-color-primary)" : undefined,
backgroundColor:
syncMethod === "postgresql" ? "var(--ant-color-primary-bg)" : undefined,
}}
>
<Radio value="postgresql">
<Space>
<DatabaseOutlined />
<span style={{ fontWeight: 500 }}>{t("settings.cloudSync.postgresql")}</span>
</Space>
<div
style={{
fontSize: "12px",
color: "var(--ss-text-secondary)",
marginTop: 4,
marginLeft: 22,
}}
>
{t("settings.cloudSync.postgresqlDesc")}
</div>
</Radio>
</Card>
<Card
hoverable
size="small"
style={{
borderColor:
syncMethod === "sectl_cloud" ? "var(--ant-color-primary)" : undefined,
backgroundColor:
syncMethod === "sectl_cloud" ? "var(--ant-color-primary-bg)" : undefined,
}}
>
<Radio value="sectl_cloud">
<Space>
<CloudOutlined />
<span style={{ fontWeight: 500 }}>{t("settings.cloudSync.sectlCloud")}</span>
</Space>
<div
style={{
fontSize: "12px",
color: "var(--ss-text-secondary)",
marginTop: 4,
marginLeft: 22,
}}
>
{t("settings.cloudSync.sectlCloudDesc")}
</div>
</Radio>
</Card>
</Space>
</Radio.Group>
</Card>
<Card style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}>
<div style={{ fontWeight: 600, marginBottom: "12px" }}>
{t("settings.database.syncDescription")}
</div>
<div style={{ fontSize: "13px", color: "var(--ss-text-secondary)", lineHeight: "1.8" }}>
<p>{t("settings.database.syncPoint1")}</p>
<p>{t("settings.database.syncPoint2")}</p>
<p>{t("settings.database.syncPoint3")}</p>
<p>{t("settings.database.syncPoint4")}</p>
</div>
</Card>
{syncMethod === "postgresql" ? (
<>
<Card
style={{
backgroundColor: "var(--ss-card-bg)",
color: "var(--ss-text-main)",
marginBottom: "16px",
}}
>
<div style={{ fontWeight: 600, marginBottom: "12px" }}>
{t("settings.database.currentStatus")}
</div>
<Space>
<Tag color={pgConnectionStatus.type === "postgresql" ? "blue" : "green"}>
{pgConnectionStatus.type === "postgresql"
? t("settings.database.postgresqlRemote")
: t("settings.database.sqliteLocal")}
</Tag>
<Tag color={pgConnectionStatus.connected ? "success" : "error"}>
{pgConnectionStatus.connected
? t("settings.database.connected")
: t("settings.database.disconnected")}
</Tag>
{pgConnectionStatus.error && (
<span style={{ color: "var(--ant-color-error, #ff4d4f)", fontSize: "12px" }}>
{pgConnectionStatus.error}
</span>
)}
</Space>
</Card>
<Card
style={{
backgroundColor: "var(--ss-card-bg)",
color: "var(--ss-text-main)",
marginBottom: "16px",
}}
>
<div style={{ fontWeight: 600, marginBottom: "12px" }}>
{t("settings.database.postgresqlConnection")}
</div>
<div
style={{
marginBottom: "12px",
fontSize: "12px",
color: "var(--ss-text-secondary)",
}}
>
{t("settings.database.connectionHint")}
</div>
<Space.Compact style={{ width: "100%", marginBottom: "12px" }}>
<Input
value={pgConnectionString}
onChange={(e) => setPgConnectionString(e.target.value)}
placeholder="postgresql://user:password@host:port/database?sslmode=require"
style={{ flex: 1 }}
disabled={!canAdmin}
/>
<Button
onClick={testPgConnection}
loading={pgTestLoading}
disabled={!canAdmin || !pgConnectionString}
>
{t("settings.database.testConnection")}
</Button>
</Space.Compact>
<div
style={{
marginBottom: "12px",
fontSize: "12px",
color: "var(--ss-text-secondary)",
}}
>
{t("settings.database.connectionExample")}
</div>
<Space
direction={isMobile ? "vertical" : "horizontal"}
size={isMobile ? 8 : "small"}
style={{ width: "100%" }}
>
<Button
type="primary"
onClick={switchToPg}
loading={pgSwitchLoading}
disabled={!canAdmin || !pgConnectionString}
style={{ width: isMobile ? "100%" : "auto" }}
>
{t("settings.database.switchToPostgreSQL")}
</Button>
<Button
onClick={switchToSQLite}
loading={pgSwitchLoading}
disabled={!canAdmin || pgConnectionStatus.type === "sqlite"}
style={{ width: isMobile ? "100%" : "auto" }}
>
{t("settings.database.switchToSQLite")}
</Button>
<Button
onClick={uploadLocalToRemote}
loading={pgUploadLoading}
disabled={!canAdmin || pgConnectionStatus.type !== "postgresql"}
style={{ width: isMobile ? "100%" : "auto" }}
>
{t("settings.database.uploadButton")}
</Button>
</Space>
<div
style={{ marginTop: "8px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
>
{t("settings.database.uploadHint")}
</div>
</Card>
<Card style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}>
<div style={{ fontWeight: 600, marginBottom: "12px" }}>
{t("settings.database.syncDescription")}
</div>
<div
style={{
fontSize: "13px",
color: "var(--ss-text-secondary)",
lineHeight: "1.8",
}}
>
<p>{t("settings.database.syncPoint1")}</p>
<p>{t("settings.database.syncPoint2")}</p>
<p>{t("settings.database.syncPoint3")}</p>
<p>{t("settings.database.syncPoint4")}</p>
</div>
</Card>
</>
) : (
<SectlProvider>
<SectlCloudSyncPanel />
</SectlProvider>
)}
</>
),
},
@@ -1412,56 +1683,169 @@ export const Settings: React.FC<{
},
{
key: "url",
label: t("settings.url.title"),
label: t("settings.tabs.urlProtocol"),
children: (
<Card style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}>
<div style={{ fontSize: "16px", fontWeight: 600, marginBottom: "8px" }}>
{t("settings.url.protocol")}
</div>
<Divider />
<div
style={{ marginBottom: "12px", fontSize: "13px", color: "var(--ss-text-secondary)" }}
>
{t("settings.url.description")}
</div>
<div
<>
<Card
style={{
display: "flex",
flexDirection: "column",
gap: "4px",
fontSize: "12px",
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", monospace',
backgroundColor: "var(--ss-card-bg)",
color: "var(--ss-text-main)",
marginBottom: "16px",
}}
>
<div>secscore://settings</div>
<div>secscore://score</div>
</div>
<Divider />
<Space>
<div style={{ fontSize: "16px", fontWeight: 600, marginBottom: "8px" }}>
{t("settings.elevation.title")}
</div>
<Divider />
<div
style={{ marginBottom: "12px", fontSize: "13px", color: "var(--ss-text-secondary)" }}
>
{t("settings.elevation.description")}
</div>
<Space style={{ marginBottom: 12 }}>
<Tag color={elevationStatus?.is_elevated ? "success" : "warning"}>
{elevationStatus?.is_elevated
? t("settings.elevation.elevated")
: t("settings.elevation.notElevated")}
</Tag>
{elevationStatus?.platform && <Tag color="blue">{elevationStatus.platform}</Tag>}
</Space>
<Divider />
<Button
type="primary"
loading={urlRegisterLoading}
disabled={!canAdmin}
onClick={async () => {
if (!(window as any).api) return
setUrlRegisterLoading(true)
const res = await (window as any).api.registerUrlProtocol()
setUrlRegisterLoading(false)
if (res && res.success) {
messageApi.success(t("settings.url.registered"))
} else {
messageApi.error(res?.message || t("settings.url.registerFailed"))
}
loading={elevationLoading}
disabled={
!canAdmin || !elevationStatus?.can_request_elevation || elevationStatus?.is_elevated
}
onClick={handleRequestElevation}
>
{t("settings.elevation.request")}
</Button>
<div style={{ marginTop: "8px", fontSize: "12px", color: "var(--ss-text-secondary)" }}>
{elevationStatus?.platform === "windows"
? t("settings.elevation.windowsHint")
: elevationStatus?.platform === "macos"
? t("settings.elevation.macosHint")
: t("settings.elevation.linuxHint")}
</div>
</Card>
<Card style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}>
<div style={{ fontSize: "16px", fontWeight: 600, marginBottom: "8px" }}>
{t("settings.url.protocol")}
</div>
<Divider />
<div
style={{ marginBottom: "12px", fontSize: "13px", color: "var(--ss-text-secondary)" }}
>
{t("settings.url.description")}
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "4px",
fontSize: "12px",
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", monospace',
}}
>
{t("settings.url.register")}
</Button>
</Space>
<div style={{ marginTop: "8px", fontSize: "12px", color: "var(--ss-text-secondary)" }}>
{t("settings.url.installerRequired")}
</div>
</Card>
<div>secscore://settings</div>
<div>secscore://score</div>
</div>
<Divider />
<Space style={{ marginBottom: 12 }}>
<Tag color={urlStatus?.registered ? "success" : "error"}>
{urlStatus?.registered
? t("settings.url.statusRegistered")
: t("settings.url.statusNotRegistered")}
</Tag>
<Button size="small" loading={urlStatusLoading} onClick={loadUrlStatus}>
{t("settings.url.checkStatus")}
</Button>
</Space>
{urlStatus?.details && (
<div
style={{
marginBottom: "12px",
fontSize: "12px",
color: "var(--ss-text-secondary)",
wordBreak: "break-all",
}}
>
{urlStatus.details}
</div>
)}
<Divider />
<Space>
{!urlStatus?.registered ? (
<Button
type="primary"
loading={urlRegisterLoading}
disabled={!canAdmin}
onClick={handleUrlRegister}
>
{t("settings.url.register")}
</Button>
) : (
<>
<Button
loading={urlRegisterLoading}
disabled={!canAdmin}
onClick={handleUrlRegister}
>
{t("settings.url.reRegister")}
</Button>
<Button
danger
loading={urlUnregisterLoading}
disabled={!canAdmin}
onClick={handleUrlUnregister}
>
{t("settings.url.unregister")}
</Button>
</>
)}
</Space>
<div style={{ marginTop: "8px", fontSize: "12px", color: "var(--ss-text-secondary)" }}>
{t("settings.url.installerRequired")}
</div>
{urlOperationLogs.length > 0 && (
<>
<Divider />
<Collapse
size="small"
items={[
{
key: "logs",
label: (
<span style={{ fontSize: "13px" }}>
{t("settings.url.operationLog")} ({urlOperationLogs.length})
</span>
),
children: (
<div
style={{
fontSize: "12px",
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
maxHeight: "200px",
overflowY: "auto",
lineHeight: "1.8",
}}
>
{urlOperationLogs.map((log, i) => (
<div key={i}>{log}</div>
))}
</div>
),
},
]}
/>
</>
)}
</Card>
</>
),
},
{