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
+23
View File
@@ -36,6 +36,8 @@ import { ThemeProvider, useTheme } from "./contexts/ThemeContext"
import { MOBILE_NAV_ITEMS, MobileNavKey, sanitizeMobileNavKeys } from "./shared/mobileNavigation"
import { resolveStoredFontFamily } from "./shared/fontFamily"
import { getPluginRuntime } from "./plugins/runtime"
import { sectlCloudSync } from "./services/sectlCloudSync"
import { sectlAuth } from "./services/sectlAuth"
const DEFAULT_MOBILE_BOTTOM_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key)
const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice(
@@ -389,6 +391,27 @@ function MainContent(): React.JSX.Element {
if (permission !== "admin") return
try {
syncCheckingRef.current = true
const settingsRes = await api.getAllSettings()
const syncMethod = settingsRes?.success ? settingsRes.data?.sync_method : null
if (syncMethod === "sectl_cloud") {
if (!sectlAuth.isAuthenticated()) return
const status = sectlCloudSync.getStatus()
if (!status.is_configured || status.is_syncing) return
const now = Date.now()
const recentLocal = now - lastLocalMutationAtRef.current < 15000
const direction = recentLocal ? "push" : "bidirectional"
const result = await sectlCloudSync.fullSync(direction)
if (result.success) {
window.dispatchEvent(
new CustomEvent("ss:data-updated", { detail: { category: "all", source: "sync" } })
)
}
return
}
const statusRes = await api.dbGetStatus()
if (
!statusRes?.success ||
+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>
</>
),
},
{
+73 -2
View File
@@ -128,7 +128,7 @@
"appearance": "المظهر",
"security": "الأمان",
"account": "الحساب",
"database": "اتصال قاعدة البيانات",
"database": "المزامنة السحابية",
"dataManagement": "إدارة البيانات",
"urlProtocol": "بروتوكول URL",
"about": "حول"
@@ -255,6 +255,45 @@
"uploadSuccess": "تم رفع البيانات المحلية إلى الخادم البعيد",
"uploadFailed": "فشل الرفع"
},
"cloudSync": {
"title": "المزامنة السحابية",
"selectMethod": "اختر طريقة المزامنة",
"selectMethodHint": "اختر طريقة مزامنة البيانات. الطريقتان متنافيتان.",
"postgresql": "PostgreSQL ذاتي الاستضافة",
"postgresqlDesc": "اتصل بقاعدة بيانات PostgreSQL الخاصة بك للتحكم الكامل في البيانات.",
"sectlCloud": "مزامنة SECTL السحابية",
"sectlCloudDesc": "مزامنة البيانات عبر منصة SECTL السحابية. لا حاجة لقاعدة بيانات ذاتية.",
"loginRequired": "يرجى تسجيل الدخول إلى SECTL Auth أولاً",
"loginHint": "تتطلب المزامنة السحابية حساب SECTL Auth. يرجى تسجيل الدخول في تبويب 'الحساب'.",
"statusTitle": "حالة المزامنة",
"configured": "جاهز",
"notConfigured": "غير جاهز",
"syncing": "جارٍ المزامنة...",
"lastSync": "آخر مزامنة",
"never": "لم تتم المزامنة قط",
"syncActions": "إجراءات المزامنة",
"syncDirection": "اتجاه المزامنة",
"bidirectional": "ثنائي الاتجاه (موصى به) — دمج تلقائي للبيانات المحلية والسحابية",
"pushOnly": "رفع فقط — استبدال البيانات السحابية بالبيانات المحلية",
"pullOnly": "تنزيل فقط — استبدال البيانات المحلية بالبيانات السحابية",
"startSync": "بدء المزامنة",
"uploadToCloud": "رفع إلى السحابة",
"downloadFromCloud": "تنزيل من السحابة",
"fullSync": "مزامنة ثنائية الاتجاه",
"syncInProgress": "المزامنة قيد التنفيذ، يرجى الانتظار",
"syncConfirm": "تأكيد {{direction}}؟",
"storageUsage": "استخدام التخزين",
"usedStorage": "المستخدم",
"table": "الجدول",
"records": "السجلات",
"size": "الحجم",
"clickToLoadStats": "انقر على التحديث لتحميل الإحصائيات",
"description": "دليل المزامنة السحابية SECTL",
"descriptionPoint1": "مزامنة البيانات عبر خدمة SECTL السحابية. لا حاجة لقاعدة بيانات ذاتية. البيانات معزولة بحساب SECTL Auth.",
"descriptionPoint2": "بناءً على بنية تخزين KV. كل جدول بيانات مخزن كزوج قيمة-مفتاح.",
"descriptionPoint3": "يدعم أنماط ثنائي الاتجاه والرفع فقط والتنزيل فقط.",
"descriptionPoint4": "البيانات منقولة عبر HTTPS مع مصادقة OAuth 2.0."
},
"data": {
"title": "إدارة البيانات",
"settlement": "التسوية",
@@ -301,7 +340,39 @@
"register": "تسجيل بروتوكول URL",
"registered": "تم تسجيل بروتوكول URL",
"registerFailed": "فشل التسجيل",
"installerRequired": "يتطلب الإصدار المثبت من SecScore، قد لا يعمل في وضع التطوير."
"installerRequired": "يتطلب الإصدار المثبت من SecScore، قد لا يعمل في وضع التطوير.",
"checkStatus": "التحقق من الحالة",
"reRegister": "إعادة التسجيل",
"unregister": "إلغاء التسجيل",
"unregistered": "تم إلغاء تسجيل بروتوكول URL",
"unregisterFailed": "فشل إلغاء التسجيل",
"statusRegistered": "مسجل",
"statusNotRegistered": "غير مسجل",
"statusCheckFailed": "فشل التحقق من الحالة",
"retrying": "إعادة المحاولة ({{current}}/{{max}})",
"retryFailed": "تم الوصول إلى الحد الأقصى من المحاولات، فشل التسجيل",
"retrySuccess": "إعادة المحاولة #{{n}} نجحت",
"operationLog": "سجل العمليات",
"logRegisterStart": "بدء تسجيل بروتوكول URL...",
"logRegisterVerify": "تم تنفيذ أمر التسجيل، جارٍ التحقق...",
"logRegisterSuccess": "تم التحقق من التسجيل بنجاح",
"logRegisterFailed": "فشل التحقق من التسجيل",
"logUnregisterStart": "جارٍ إلغاء التسجيل...",
"logUnregisterSuccess": "تم إلغاء التسجيل بنجاح",
"logUnregisterFailed": "فشل إلغاء التسجيل",
"logMaxRetries": "تم الوصول إلى الحد الأقصى من المحاولات (7)"
},
"elevation": {
"title": "صلاحيات المسؤول",
"description": "التحقق مما إذا كان التطبيق يعمل بصلاحيات المسؤول/الجذر. تتطلب بعض الميزات (استماع المنفذ، العمليات على مستوى النظام) صلاحيات مرتفعة.",
"elevated": "مرتفع",
"notElevated": "غير مرتفع",
"request": "طلب صلاحيات المسؤول",
"requestSuccess": "تم طلب صلاحيات المسؤول، سيتم إعادة تشغيل التطبيق",
"requestFailed": "فشل طلب صلاحيات المسؤول",
"windowsHint": "ويندوز: ستظهر رسالة UAC. بعد التأكيد، سيتم إعادة تشغيل التطبيق كمسؤول.",
"macosHint": "macOS: ستظهر نافذة مصادقة النظام. أدخل كلمة مرور المسؤول لإعادة التشغيل.",
"linuxHint": "لينكس: يستخدم pkexec لطلب صلاحيات الجذر. أدخل كلمة المرور لإعادة التشغيل."
},
"mcp": {
"title": "خدمة MCP",
+74 -3
View File
@@ -128,7 +128,7 @@
"appearance": "Erscheinungsbild",
"security": "Sicherheit",
"account": "Konto",
"database": "Datenbankverbindung",
"database": "Cloud-Synchronisierung",
"dataManagement": "Datenverwaltung",
"urlProtocol": "URL-Protokoll",
"about": "Über"
@@ -252,9 +252,48 @@
"noNeedUpload": "Lokale und Remote-Daten sind bereits konsistent",
"uploadConfirmTitle": "Lokale Daten auf Remote-Server hochladen bestätigen?",
"uploadConfirmContent": "Die Synchronisierung priorisiert lokale Daten. Erkannt: nur lokal {{localOnly}}, nur remote {{remoteOnly}}, Konflikte {{conflicts}}.",
"uploadSuccess": "Lokale Daten auf Remote-Server hochgeladen",
"uploadSuccess": "Lokale Daten auf den Server hochgeladen",
"uploadFailed": "Hochladen fehlgeschlagen"
},
"cloudSync": {
"title": "Cloud-Synchronisierung",
"selectMethod": "Synchronisierungsmethode wählen",
"selectMethodHint": "Wählen Sie eine Datensynchronisierungsmethode. Die beiden Methoden schließen sich gegenseitig aus.",
"postgresql": "Selbst-gehostetes PostgreSQL",
"postgresqlDesc": "Verbinden Sie Ihre eigene PostgreSQL-Datenbank für volle Datenkontrolle. Ideal für Benutzer mit eigenem Server.",
"sectlCloud": "SECTL Cloud-Sync",
"sectlCloudDesc": "Daten über die SECTL-Cloud-Plattform synchronisieren. Keine eigene Datenbank nötig, sofort einsatzbereit.",
"loginRequired": "Bitte zuerst bei SECTL Auth anmelden",
"loginHint": "SECTL Cloud-Sync erfordert ein SECTL Auth-Konto. Bitte im Tab 'Konto' anmelden.",
"statusTitle": "Synchronisierungsstatus",
"configured": "Bereit",
"notConfigured": "Nicht bereit",
"syncing": "Synchronisiere...",
"lastSync": "Letzte Sync",
"never": "Nie synchronisiert",
"syncActions": "Synchronisierungsaktionen",
"syncDirection": "Synchronisierungsrichtung",
"bidirectional": "Bidirektional (Empfohlen) — Lokale und Cloud-Daten automatisch zusammenführen",
"pushOnly": "Nur hochladen — Cloud-Daten mit lokalen Daten überschreiben",
"pullOnly": "Nur herunterladen — Lokale Daten mit Cloud-Daten überschreiben",
"startSync": "Synchronisierung starten",
"uploadToCloud": "In die Cloud hochladen",
"downloadFromCloud": "Aus der Cloud herunterladen",
"fullSync": "Bidirektionale Synchronisierung",
"syncInProgress": "Synchronisierung läuft, bitte warten",
"syncConfirm": "{{direction}} bestätigen?",
"storageUsage": "Speichernutzung",
"usedStorage": "Verwendet",
"table": "Tabelle",
"records": "Datensätze",
"size": "Größe",
"clickToLoadStats": "Aktualisieren klicken um Statistiken zu laden",
"description": "SECTL Cloud-Sync-Anleitung",
"descriptionPoint1": "Daten über den SECTL-Cloud-Dienst synchronisieren. Keine eigene Datenbank nötig. Daten werden durch SECTL Auth-Konto isoliert.",
"descriptionPoint2": "Basierend auf KV-Speicherarchitektur. Jede Tabelle wird als Schlüssel-Wert-Paar gespeichert.",
"descriptionPoint3": "Unterstützt bidirektionale, Nur-Upload- und Nur-Download-Modi.",
"descriptionPoint4": "Daten werden über HTTPS mit OAuth 2.0-Authentifizierung übertragen."
},
"data": {
"title": "Datenverwaltung",
"settlement": "Abrechnung",
@@ -301,7 +340,39 @@
"register": "URL-Protokoll registrieren",
"registered": "URL-Protokoll registriert",
"registerFailed": "Registrierung fehlgeschlagen",
"installerRequired": "Erfordert die installierte Version von SecScore, kann im Entwicklungsmodus nicht funktionieren."
"installerRequired": "Erfordert die installierte Version von SecScore, kann im Entwicklungsmodus nicht funktionieren.",
"checkStatus": "Status prüfen",
"reRegister": "Erneut registrieren",
"unregister": "Registrierung aufheben",
"unregistered": "URL-Protokoll-Registrierung aufgehoben",
"unregisterFailed": "Registrierung konnte nicht aufgehoben werden",
"statusRegistered": "Registriert",
"statusNotRegistered": "Nicht registriert",
"statusCheckFailed": "Statusprüfung fehlgeschlagen",
"retrying": "Wiederholung ({{current}}/{{max}})",
"retryFailed": "Maximale Wiederholungen erreicht, Registrierung fehlgeschlagen",
"retrySuccess": "Wiederholung #{{n}} erfolgreich",
"operationLog": "Protokoll",
"logRegisterStart": "URL-Protokoll-Registrierung gestartet...",
"logRegisterVerify": "Registrierungsbefehl ausgeführt, Überprüfung läuft...",
"logRegisterSuccess": "Registrierung erfolgreich überprüft",
"logRegisterFailed": "Registrierungsüberprüfung fehlgeschlagen",
"logUnregisterStart": "Registrierung wird aufgehoben...",
"logUnregisterSuccess": "Registrierung erfolgreich aufgehoben",
"logUnregisterFailed": "Registrierung konnte nicht aufgehoben werden",
"logMaxRetries": "Maximale Wiederholungen (7) erreicht"
},
"elevation": {
"title": "Administratorenrechte",
"description": "Überprüfen, ob die Anwendung mit Administrator/Root-Rechten ausgeführt wird. Einige Funktionen (Port-Listening, Systemoperationen) erfordern erhöhte Rechte.",
"elevated": "Erhöht",
"notElevated": "Nicht erhöht",
"request": "Admin-Rechte anfordern",
"requestSuccess": "Admin-Rechte angefordert, Anwendung wird neu gestartet",
"requestFailed": "Admin-Rechte konnten nicht angefordert werden",
"windowsHint": "Windows: Ein UAC-Dialog erscheint. Nach Bestätigung wird die App als Administrator neu gestartet.",
"macosHint": "macOS: Ein System-Authentifizierungsdialog erscheint. Admin-Passwort eingeben, um die App neu zu starten.",
"linuxHint": "Linux: Verwendet pkexec für Root-Rechte. Passwort eingeben, um die App neu zu starten."
},
"mcp": {
"title": "MCP-Dienst",
+73 -2
View File
@@ -128,7 +128,7 @@
"appearance": "Appearance",
"security": "Security",
"account": "Account",
"database": "Database Connection",
"database": "Cloud Sync",
"dataManagement": "Data Management",
"urlProtocol": "URL Protocol",
"about": "About"
@@ -255,6 +255,45 @@
"uploadSuccess": "Local data uploaded to remote",
"uploadFailed": "Upload failed"
},
"cloudSync": {
"title": "Cloud Sync",
"selectMethod": "Select Sync Method",
"selectMethodHint": "Choose a data sync method. The two methods are mutually exclusive. Switching will use the new sync channel.",
"postgresql": "Self-hosted PostgreSQL",
"postgresqlDesc": "Connect to your own PostgreSQL database for full data control. Ideal for users with their own server.",
"sectlCloud": "SECTL Cloud Sync",
"sectlCloudDesc": "Sync data via SECTL cloud platform. No self-hosted database needed, works out of the box.",
"loginRequired": "Please log in to SECTL Auth first",
"loginHint": "SECTL Cloud Sync requires a SECTL Auth account. Please go to the 'Account' tab to log in.",
"statusTitle": "Sync Status",
"configured": "Ready",
"notConfigured": "Not Ready",
"syncing": "Syncing...",
"lastSync": "Last synced",
"never": "Never synced",
"syncActions": "Sync Actions",
"syncDirection": "Sync Direction",
"bidirectional": "Bidirectional (Recommended) — Automatically merge local and cloud data",
"pushOnly": "Upload only — Overwrite cloud data with local data",
"pullOnly": "Download only — Overwrite local data with cloud data",
"startSync": "Start Sync",
"uploadToCloud": "Upload to Cloud",
"downloadFromCloud": "Download from Cloud",
"fullSync": "Bidirectional Sync",
"syncInProgress": "Sync is in progress, please wait",
"syncConfirm": "Confirm {{direction}}?",
"storageUsage": "Storage Usage",
"usedStorage": "Used",
"table": "Table",
"records": "Records",
"size": "Size",
"clickToLoadStats": "Click refresh to load stats",
"description": "SECTL Cloud Sync Guide",
"descriptionPoint1": "Sync data via SECTL cloud service. No self-hosted database needed. Data is isolated by SECTL Auth account.",
"descriptionPoint2": "Based on KV storage architecture. Each data table is stored as a key-value pair for efficient read/write.",
"descriptionPoint3": "Supports bidirectional, upload-only, and download-only modes for different scenarios.",
"descriptionPoint4": "Data is transmitted over HTTPS with OAuth 2.0 authentication for security."
},
"data": {
"title": "Data Management",
"settlement": "Settlement",
@@ -301,7 +340,39 @@
"register": "Register URL Protocol",
"registered": "URL protocol registered",
"registerFailed": "Registration failed",
"installerRequired": "Requires installed SecScore, may not work in development mode."
"installerRequired": "Requires installed SecScore, may not work in development mode.",
"checkStatus": "Check Status",
"reRegister": "Re-register",
"unregister": "Unregister",
"unregistered": "URL protocol unregistered",
"unregisterFailed": "Failed to unregister",
"statusRegistered": "Registered",
"statusNotRegistered": "Not Registered",
"statusCheckFailed": "Status check failed",
"retrying": "Retrying ({{current}}/{{max}})",
"retryFailed": "Max retries reached, registration failed",
"retrySuccess": "Retry #{{n}} succeeded",
"operationLog": "Operation Log",
"logRegisterStart": "Starting URL protocol registration...",
"logRegisterVerify": "Registration command executed, verifying...",
"logRegisterSuccess": "Registration verified successfully",
"logRegisterFailed": "Registration verification failed",
"logUnregisterStart": "Unregistering...",
"logUnregisterSuccess": "Unregistered successfully",
"logUnregisterFailed": "Unregister failed",
"logMaxRetries": "Max retries (7) reached"
},
"elevation": {
"title": "Administrator Privileges",
"description": "Check if the application is running with administrator/root privileges. Some features (like port listening, system-level operations) require elevated privileges.",
"elevated": "Elevated",
"notElevated": "Not Elevated",
"request": "Request Admin Privileges",
"requestSuccess": "Admin privileges requested, application will restart",
"requestFailed": "Failed to request admin privileges",
"windowsHint": "Windows: A UAC prompt will appear. After confirmation, the app will restart as administrator.",
"macosHint": "macOS: A system authentication dialog will appear. Enter admin password to restart the app.",
"linuxHint": "Linux: Uses pkexec to request root privileges. Enter password to restart the app."
},
"mcp": {
"title": "MCP Service",
+73 -2
View File
@@ -128,7 +128,7 @@
"appearance": "Apariencia",
"security": "Seguridad",
"account": "Cuenta",
"database": "Conexión a base de datos",
"database": "Sincronización en la nube",
"dataManagement": "Gestión de datos",
"urlProtocol": "Protocolo URL",
"about": "Acerca de"
@@ -255,6 +255,45 @@
"uploadSuccess": "Datos locales cargados al servidor remoto",
"uploadFailed": "Fallo al cargar"
},
"cloudSync": {
"title": "Sincronización en la nube",
"selectMethod": "Seleccionar método de sincronización",
"selectMethodHint": "Elija un método de sincronización de datos. Los dos métodos son mutuamente excluyentes.",
"postgresql": "PostgreSQL auto-alojado",
"postgresqlDesc": "Conéctese a su propia base de datos PostgreSQL para control total. Ideal para usuarios con servidor propio.",
"sectlCloud": "SECTL Cloud Sync",
"sectlCloudDesc": "Sincronice datos a través de la plataforma cloud SECTL. Sin base de datos propia necesaria.",
"loginRequired": "Por favor, inicie sesión en SECTL Auth primero",
"loginHint": "La sincronización cloud SECTL requiere una cuenta SECTL Auth. Inicie sesión en la pestaña 'Cuenta'.",
"statusTitle": "Estado de sincronización",
"configured": "Listo",
"notConfigured": "No listo",
"syncing": "Sincronizando...",
"lastSync": "Última sincronización",
"never": "Nunca sincronizado",
"syncActions": "Acciones de sincronización",
"syncDirection": "Dirección de sincronización",
"bidirectional": "Bidireccional (Recomendado) — Fusionar automáticamente datos locales y en la nube",
"pushOnly": "Solo subir — Sobrescribir datos en la nube con datos locales",
"pullOnly": "Solo descargar — Sobrescribir datos locales con datos en la nube",
"startSync": "Iniciar sincronización",
"uploadToCloud": "Subir a la nube",
"downloadFromCloud": "Descargar de la nube",
"fullSync": "Sincronización bidireccional",
"syncInProgress": "Sincronización en progreso, por favor espere",
"syncConfirm": "¿Confirmar {{direction}}?",
"storageUsage": "Uso de almacenamiento",
"usedStorage": "Usado",
"table": "Tabla",
"records": "Registros",
"size": "Tamaño",
"clickToLoadStats": "Haga clic en actualizar para cargar estadísticas",
"description": "Guía de sincronización cloud SECTL",
"descriptionPoint1": "Sincronice datos a través del servicio cloud SECTL. Sin base de datos propia. Los datos se aíslan por cuenta SECTL Auth.",
"descriptionPoint2": "Basado en arquitectura de almacenamiento KV. Cada tabla se almacena como par clave-valor.",
"descriptionPoint3": "Soporta modos bidireccional, solo subida y solo descarga.",
"descriptionPoint4": "Los datos se transmiten por HTTPS con autenticación OAuth 2.0."
},
"data": {
"title": "Gestión de datos",
"settlement": "Liquidación",
@@ -301,7 +340,39 @@
"register": "Registrar protocolo URL",
"registered": "Protocolo URL registrado",
"registerFailed": "Fallo al registrar",
"installerRequired": "Requiere la versión instalada de SecScore, puede no funcionar en modo desarrollo."
"installerRequired": "Requiere la versión instalada de SecScore, puede no funcionar en modo desarrollo.",
"checkStatus": "Verificar estado",
"reRegister": "Re-registrar",
"unregister": "Anular registro",
"unregistered": "Registro del protocolo URL anulado",
"unregisterFailed": "Error al anular el registro",
"statusRegistered": "Registrado",
"statusNotRegistered": "No registrado",
"statusCheckFailed": "Error al verificar el estado",
"retrying": "Reintentando ({{current}}/{{max}})",
"retryFailed": "Se alcanzó el máximo de reintentos, fallo en el registro",
"retrySuccess": "Reintento #{{n}} exitoso",
"operationLog": "Registro de operaciones",
"logRegisterStart": "Iniciando registro del protocolo URL...",
"logRegisterVerify": "Comando de registro ejecutado, verificando...",
"logRegisterSuccess": "Registro verificado exitosamente",
"logRegisterFailed": "Error en la verificación del registro",
"logUnregisterStart": "Anulando registro...",
"logUnregisterSuccess": "Registro anulado exitosamente",
"logUnregisterFailed": "Error al anular el registro",
"logMaxRetries": "Se alcanzó el máximo de reintentos (7)"
},
"elevation": {
"title": "Privilegios de administrador",
"description": "Verificar si la aplicación se ejecuta con privilegios de administrador/root. Algunas funciones (escucha de puertos, operaciones de sistema) requieren privilegios elevados.",
"elevated": "Elevado",
"notElevated": "No elevado",
"request": "Solicitar privilegios de admin",
"requestSuccess": "Privilegios de admin solicitados, la aplicación se reiniciará",
"requestFailed": "Error al solicitar privilegios de admin",
"windowsHint": "Windows: Aparecerá un prompt UAC. Después de confirmar, la app se reiniciará como administrador.",
"macosHint": "macOS: Aparecerá un diálogo de autenticación del sistema. Ingrese la contraseña de admin para reiniciar.",
"linuxHint": "Linux: Usa pkexec para solicitar privilegios root. Ingrese la contraseña para reiniciar."
},
"mcp": {
"title": "Servicio MCP",
+73 -2
View File
@@ -128,7 +128,7 @@
"appearance": "Apparence",
"security": "Sécurité",
"account": "Compte",
"database": "Connexion à la base de données",
"database": "Synchronisation cloud",
"dataManagement": "Gestion des données",
"urlProtocol": "Protocole URL",
"about": "À propos"
@@ -255,6 +255,45 @@
"uploadSuccess": "Données locales téléchargées vers le serveur distant",
"uploadFailed": "Échec du téléchargement"
},
"cloudSync": {
"title": "Synchronisation cloud",
"selectMethod": "Choisir la méthode de synchronisation",
"selectMethodHint": "Choisissez une méthode de synchronisation des données. Les deux méthodes sont mutuellement exclusives.",
"postgresql": "PostgreSQL auto-hébergé",
"postgresqlDesc": "Connectez-vous à votre propre base de données PostgreSQL pour un contrôle total. Idéal pour les utilisateurs ayant leur propre serveur.",
"sectlCloud": "SECTL Cloud Sync",
"sectlCloudDesc": "Synchronisez les données via la plateforme cloud SECTL. Aucune base de données auto-hébergée nécessaire.",
"loginRequired": "Veuillez vous connecter à SECTL Auth d'abord",
"loginHint": "La synchronisation cloud SECTL nécessite un compte SECTL Auth. Veuillez vous connecter dans l'onglet « Compte ».",
"statusTitle": "État de la synchronisation",
"configured": "Prêt",
"notConfigured": "Non prêt",
"syncing": "Synchronisation...",
"lastSync": "Dernière sync",
"never": "Jamais synchronisé",
"syncActions": "Actions de synchronisation",
"syncDirection": "Direction de synchronisation",
"bidirectional": "Bidirectionnelle (Recommandé) — Fusion automatique des données locales et cloud",
"pushOnly": "Upload uniquement — Écraser les données cloud avec les données locales",
"pullOnly": "Download uniquement — Écraser les données locales avec les données cloud",
"startSync": "Démarrer la synchronisation",
"uploadToCloud": "Uploader vers le cloud",
"downloadFromCloud": "Télécharger depuis le cloud",
"fullSync": "Synchronisation bidirectionnelle",
"syncInProgress": "Synchronisation en cours, veuillez patienter",
"syncConfirm": "Confirmer {{direction}} ?",
"storageUsage": "Utilisation du stockage",
"usedStorage": "Utilisé",
"table": "Table",
"records": "Enregistrements",
"size": "Taille",
"clickToLoadStats": "Cliquez sur actualiser pour charger les stats",
"description": "Guide de synchronisation cloud SECTL",
"descriptionPoint1": "Synchronisez les données via le service cloud SECTL. Aucune base de données auto-hébergée nécessaire. Les données sont isolées par compte SECTL Auth.",
"descriptionPoint2": "Basé sur l'architecture de stockage KV. Chaque table est stockée en paire clé-valeur pour une lecture/écriture efficace.",
"descriptionPoint3": "Prend en charge les modes bidirectionnel, upload uniquement et download uniquement.",
"descriptionPoint4": "Les données sont transmises via HTTPS avec authentification OAuth 2.0."
},
"data": {
"title": "Gestion des données",
"settlement": "Règlement",
@@ -301,7 +340,39 @@
"register": "Enregistrer le protocole URL",
"registered": "Protocole URL enregistré",
"registerFailed": "Échec de l'enregistrement",
"installerRequired": "Nécessite la version installée de SecScore, peut ne pas fonctionner en mode développement."
"installerRequired": "Nécessite la version installée de SecScore, peut ne pas fonctionner en mode développement.",
"checkStatus": "Vérifier le statut",
"reRegister": "Ré-enregistrer",
"unregister": "Désinscrire",
"unregistered": "Protocole URL désinscrit",
"unregisterFailed": "Échec de la désinscription",
"statusRegistered": "Inscrit",
"statusNotRegistered": "Non inscrit",
"statusCheckFailed": "Échec de la vérification du statut",
"retrying": "Nouvelle tentative ({{current}}/{{max}})",
"retryFailed": "Nombre maximal de tentatives atteint, échec de l'inscription",
"retrySuccess": "Tentative #{{n}} réussie",
"operationLog": "Journal des opérations",
"logRegisterStart": "Démarrage de l'inscription du protocole URL...",
"logRegisterVerify": "Commande d'inscription exécution, vérification en cours...",
"logRegisterSuccess": "Inscription vérifiée avec succès",
"logRegisterFailed": "Échec de la vérification de l'inscription",
"logUnregisterStart": "Désinscription en cours...",
"logUnregisterSuccess": "Désinscription réussie",
"logUnregisterFailed": "Échec de la désinscription",
"logMaxRetries": "Nombre maximal de tentatives (7) atteint"
},
"elevation": {
"title": "Privilèges administrateur",
"description": "Vérifier si l'application s'exécute avec des privilèges administrateur/root. Certaines fonctionnalités (écoute de port, opérations système) nécessitent des privilèges élevés.",
"elevated": "Élevé",
"notElevated": "Non élevé",
"request": "Demander les privilèges admin",
"requestSuccess": "Privilèges admin demandés, l'application va redémarrer",
"requestFailed": "Échec de la demande de privilèges admin",
"windowsHint": "Windows : une invite UAC apparaîtra. Après confirmation, l'app redémarrera en tant qu'administrateur.",
"macosHint": "macOS : une boîte de dialogue d'authentification système apparaîtra. Entrez le mot de passe admin pour redémarrer.",
"linuxHint": "Linux : utilise pkexec pour demander les privilèges root. Entrez le mot de passe pour redémarrer."
},
"mcp": {
"title": "Service MCP",
+74 -3
View File
@@ -128,7 +128,7 @@
"appearance": "外観",
"security": "セキュリティ",
"account": "アカウント",
"database": "データベース接続",
"database": "クラウド同期",
"dataManagement": "データ管理",
"urlProtocol": "URLプロトコル",
"about": "について"
@@ -253,7 +253,46 @@
"uploadConfirmTitle": "ローカルデータをリモートにアップロードしますか?",
"uploadConfirmContent": "ローカルデータを優先して同期します。ローカルのみ {{localOnly}}、リモートのみ {{remoteOnly}}、競合 {{conflicts}} を検出。",
"uploadSuccess": "ローカルデータをリモートにアップロードしました",
"uploadFailed": "アップロード失敗しました"
"uploadFailed": "アップロード失敗"
},
"cloudSync": {
"title": "クラウド同期",
"selectMethod": "同期方法を選択",
"selectMethodHint": "データ同期方法を選択してください。2つの方法は排他で、切り替えると新しい同期チャネルが使用されます。",
"postgresql": "自建 PostgreSQL データベース",
"postgresqlDesc": "自建の PostgreSQL データベースに接続し、データを完全に管理できます。サーバーをお持ちのユーザーに最適です。",
"sectlCloud": "SECTL クラウド同期",
"sectlCloudDesc": "SECTL クラウドプラットフォームでデータ同期。自建データベース不要、すぐに使えます。",
"loginRequired": "先に SECTL Auth アカウントにログインしてください",
"loginHint": "SECTL クラウド同期には SECTL Auth アカウントが必要です。「アカウント」タブからログインしてください。",
"statusTitle": "同期状態",
"configured": "準備完了",
"notConfigured": "未準備",
"syncing": "同期中...",
"lastSync": "前回の同期",
"never": "未同期",
"syncActions": "同期操作",
"syncDirection": "同期方向",
"bidirectional": "双方向同期(推奨)— ローカルとクラウドのデータを自動的にマージ",
"pushOnly": "アップロードのみ — ローカルデータでクラウドデータを上書き",
"pullOnly": "ダウンロードのみ — クラウドデータでローカルデータを上書き",
"startSync": "同期開始",
"uploadToCloud": "クラウドにアップロード",
"downloadFromCloud": "クラウドからダウンロード",
"fullSync": "双方向同期",
"syncInProgress": "同期中です。お待ちください",
"syncConfirm": "{{direction}}を実行しますか?",
"storageUsage": "ストレージ使用量",
"usedStorage": "使用済み",
"table": "テーブル",
"records": "レコード数",
"size": "サイズ",
"clickToLoadStats": "更新ボタンをクリックして統計を読み込む",
"description": "SECTL クラウド同期ガイド",
"descriptionPoint1": "SECTL クラウドサービスでデータ同期。自建データベース不要。データは SECTL Auth アカウントで分離されます。",
"descriptionPoint2": "KV ストレージアーキテクチャに基づき、各データテーブルはキーバリューペアとして保存され、効率的な読み書きを実現。",
"descriptionPoint3": "双方向、アップロードのみ、ダウンロードのみの3つのモードをサポート。",
"descriptionPoint4": "データは HTTPS で暗号化され、OAuth 2.0 認証でセキュリティを確保。"
},
"data": {
"title": "データ管理",
@@ -301,7 +340,39 @@
"register": "URLプロトコルを登録",
"registered": "URLプロトコルが登録されました",
"registerFailed": "登録に失敗しました",
"installerRequired": "インストール版のSecScoreが必要です。開発モードでは動作しない場合があります。"
"installerRequired": "インストール版のSecScoreが必要です。開発モードでは動作しない場合があります。",
"checkStatus": "ステータス確認",
"reRegister": "再登録",
"unregister": "登録解除",
"unregistered": "URLプロトコルの登録が解除されました",
"unregisterFailed": "登録解除に失敗しました",
"statusRegistered": "登録済み",
"statusNotRegistered": "未登録",
"statusCheckFailed": "ステータス確認に失敗しました",
"retrying": "リトライ中 ({{current}}/{{max}})",
"retryFailed": "最大リトライ回数に達しました。登録に失敗しました",
"retrySuccess": "リトライ #{{n}} が成功しました",
"operationLog": "操作ログ",
"logRegisterStart": "URLプロトコルの登録を開始...",
"logRegisterVerify": "登録コマンドを実行しました。検証中...",
"logRegisterSuccess": "登録の検証に成功しました",
"logRegisterFailed": "登録の検証に失敗しました",
"logUnregisterStart": "登録解除中...",
"logUnregisterSuccess": "登録解除に成功しました",
"logUnregisterFailed": "登録解除に失敗しました",
"logMaxRetries": "最大リトライ回数 (7回) に達しました"
},
"elevation": {
"title": "管理者権限",
"description": "アプリケーションが管理者/root権限で実行されているか確認します。一部の機能(ポートリッスニング、システムレベル操作など)には昇格権限が必要です。",
"elevated": "昇格済み",
"notElevated": "未昇格",
"request": "管理者権限をリクエスト",
"requestSuccess": "管理者権限をリクエストしました。アプリが再起動します",
"requestFailed": "管理者権限のリクエストに失敗しました",
"windowsHint": "Windows:UAC プロンプトが表示されます。確認後、アプリが管理者として再起動します。",
"macosHint": "macOS:システム認証ダイアログが表示されます。管理者パスワードを入力してアプリを再起動します。",
"linuxHint": "Linuxpkexec を使用して root 権限をリクエストします。パスワードを入力してアプリを再起動します。"
},
"mcp": {
"title": "MCPサービス",
+73 -2
View File
@@ -128,7 +128,7 @@
"appearance": "모양새",
"security": "보안",
"account": "계정",
"database": "데이터베이스 연결",
"database": "클라우드 동기화",
"dataManagement": "데이터 관리",
"urlProtocol": "URL 프로토콜",
"about": "정보"
@@ -255,6 +255,45 @@
"uploadSuccess": "로컬 데이터가 원격 서버에 업로드되었습니다",
"uploadFailed": "업로드 실패"
},
"cloudSync": {
"title": "클라우드 동기화",
"selectMethod": "동기화 방법 선택",
"selectMethodHint": "데이터 동기화 방법을 선택하세요. 두 방법은 상호 배제적이며, 전환 시 새 동기화 채널을 사용합니다.",
"postgresql": "자체 호스팅 PostgreSQL",
"postgresqlDesc": "자체 PostgreSQL 데이터베이스에 연결하여 데이터를 완전히 제어합니다. 서버가 있는 사용자에게 적합합니다.",
"sectlCloud": "SECTL 클라우드 동기화",
"sectlCloudDesc": "SECTL 클라우드 플랫폼을 통해 데이터를 동기화합니다. 자체 데이터베이스 불필요, 즉시 사용 가능.",
"loginRequired": "먼저 SECTL Auth에 로그인하세요",
"loginHint": "SECTL 클라우드 동기화에는 SECTL Auth 계정이 필요합니다. '계정' 탭에서 로그인하세요.",
"statusTitle": "동기화 상태",
"configured": "준비 완료",
"notConfigured": "준비 안 됨",
"syncing": "동기화 중...",
"lastSync": "마지막 동기화",
"never": "동기화 안 됨",
"syncActions": "동기화 작업",
"syncDirection": "동기화 방향",
"bidirectional": "양방향 동기화 (권장) — 로컬과 클라우드 데이터를 자동으로 병합",
"pushOnly": "업로드만 — 로컬 데이터로 클라우드 데이터 덮어쓰기",
"pullOnly": "다운로드만 — 클라우드 데이터로 로컬 데이터 덮어쓰기",
"startSync": "동기화 시작",
"uploadToCloud": "클라우드에 업로드",
"downloadFromCloud": "클라우드에서 다운로드",
"fullSync": "양방향 동기화",
"syncInProgress": "동기화 진행 중, 잠시만 기다려주세요",
"syncConfirm": "{{direction}}를 실행하시겠습니까?",
"storageUsage": "저장소 사용량",
"usedStorage": "사용됨",
"table": "테이블",
"records": "레코드 수",
"size": "크기",
"clickToLoadStats": "새로고침 버튼을 클릭하여 통계를 로드하세요",
"description": "SECTL 클라우드 동기화 가이드",
"descriptionPoint1": "SECTL 클라우드 서비스로 데이터를 동기화합니다. 자체 데이터베이스 불필요. 데이터는 SECTL Auth 계정으로 격리됩니다.",
"descriptionPoint2": "KV 저장소 아키텍처 기반. 각 데이터 테이블은 키-값 쌍으로 저장되어 효율적인 읽기/쓰기를 지원합니다.",
"descriptionPoint3": "양방향, 업로드만, 다운로드만 세 가지 모드를 지원합니다.",
"descriptionPoint4": "데이터는 HTTPS로 전송되며 OAuth 2.0 인증으로 보안을 보장합니다."
},
"data": {
"title": "데이터 관리",
"settlement": "결제",
@@ -301,7 +340,39 @@
"register": "URL 프로토콜 등록",
"registered": "URL 프로토콜이 등록되었습니다",
"registerFailed": "등록 실패",
"installerRequired": "설치된 SecScore 버전이 필요합니다. 개발 모드에서는 작동하지 않을 수 있습니다."
"installerRequired": "설치된 SecScore 버전이 필요합니다. 개발 모드에서는 작동하지 않을 수 있습니다.",
"checkStatus": "상태 확인",
"reRegister": "재등록",
"unregister": "등록 해제",
"unregistered": "URL 프로토콜 등록이 해제되었습니다",
"unregisterFailed": "등록 해제 실패",
"statusRegistered": "등록됨",
"statusNotRegistered": "등록되지 않음",
"statusCheckFailed": "상태 확인 실패",
"retrying": "재시도 중 ({{current}}/{{max}})",
"retryFailed": "최대 재시도 횟수에 도달했습니다. 등록 실패",
"retrySuccess": "재시도 #{{n}} 성공",
"operationLog": "작업 로그",
"logRegisterStart": "URL 프로토콜 등록 시작...",
"logRegisterVerify": "등록 명령 실행됨, 확인 중...",
"logRegisterSuccess": "등록 확인 성공",
"logRegisterFailed": "등록 확인 실패",
"logUnregisterStart": "등록 해제 중...",
"logUnregisterSuccess": "등록 해제 성공",
"logUnregisterFailed": "등록 해제 실패",
"logMaxRetries": "최대 재시도 횟수 (7회) 도달"
},
"elevation": {
"title": "관리자 권한",
"description": "응용 프로그램이 관리자/root 권한으로 실행 중인지 확인합니다. 일부 기능(포트 리스닝, 시스템 수준 작업)에는 권한 상승이 필요합니다.",
"elevated": "권한 상승됨",
"notElevated": "권한 상승 안 됨",
"request": "관리자 권한 요청",
"requestSuccess": "관리자 권한이 요청되었습니다. 앱이 재시작됩니다",
"requestFailed": "관리자 권한 요청 실패",
"windowsHint": "Windows: UAC 프롬프트가 나타납니다. 확인 후 앱이 관리자로 재시작됩니다.",
"macosHint": "macOS: 시스템 인증 대화 상자가 나타납니다. 관리자 비밀번호를 입력하여 앱을 재시작합니다.",
"linuxHint": "Linux: pkexec를 사용하여 root 권한을 요청합니다. 비밀번호를 입력하여 앱을 재시작합니다."
},
"mcp": {
"title": "MCP 서비스",
+73 -2
View File
@@ -128,7 +128,7 @@
"appearance": "Aparência",
"security": "Segurança",
"account": "Conta",
"database": "Conexão com banco de dados",
"database": "Sincronização na nuvem",
"dataManagement": "Gerenciamento de dados",
"urlProtocol": "Protocolo URL",
"about": "Sobre"
@@ -255,6 +255,45 @@
"uploadSuccess": "Dados locais carregados para o servidor remoto",
"uploadFailed": "Falha ao carregar"
},
"cloudSync": {
"title": "Sincronização na nuvem",
"selectMethod": "Selecionar método de sincronização",
"selectMethodHint": "Escolha um método de sincronização de dados. Os dois métodos são mutuamente exclusivos.",
"postgresql": "PostgreSQL auto-hospedado",
"postgresqlDesc": "Conecte-se ao seu próprio banco de dados PostgreSQL para controle total. Ideal para usuários com servidor próprio.",
"sectlCloud": "SECTL Cloud Sync",
"sectlCloudDesc": "Sincronize dados pela plataforma cloud SECTL. Sem necessidade de banco de dados próprio.",
"loginRequired": "Por favor, faça login no SECTL Auth primeiro",
"loginHint": "A sincronização cloud SECTL requer uma conta SECTL Auth. Faça login na aba 'Conta'.",
"statusTitle": "Status da sincronização",
"configured": "Pronto",
"notConfigured": "Não pronto",
"syncing": "Sincronizando...",
"lastSync": "Última sincronização",
"never": "Nunca sincronizado",
"syncActions": "Ações de sincronização",
"syncDirection": "Direção da sincronização",
"bidirectional": "Bidirecional (Recomendado) — Mesclar automaticamente dados locais e na nuvem",
"pushOnly": "Apenas upload — Sobrescrever dados na nuvem com dados locais",
"pullOnly": "Apenas download — Sobrescrever dados locais com dados na nuvem",
"startSync": "Iniciar sincronização",
"uploadToCloud": "Enviar para a nuvem",
"downloadFromCloud": "Baixar da nuvem",
"fullSync": "Sincronização bidirecional",
"syncInProgress": "Sincronização em andamento, aguarde",
"syncConfirm": "Confirmar {{direction}}?",
"storageUsage": "Uso de armazenamento",
"usedStorage": "Usado",
"table": "Tabela",
"records": "Registros",
"size": "Tamanho",
"clickToLoadStats": "Clique em atualizar para carregar estatísticas",
"description": "Guia de sincronização cloud SECTL",
"descriptionPoint1": "Sincronize dados pelo serviço cloud SECTL. Sem banco de dados próprio. Dados isolados por conta SECTL Auth.",
"descriptionPoint2": "Baseado em arquitetura de armazenamento KV. Cada tabela é armazenada como par chave-valor.",
"descriptionPoint3": "Suporta modos bidirecional, apenas upload e apenas download.",
"descriptionPoint4": "Dados transmitidos por HTTPS com autenticação OAuth 2.0."
},
"data": {
"title": "Gerenciamento de dados",
"settlement": "Liquidação",
@@ -301,7 +340,39 @@
"register": "Registrar protocolo URL",
"registered": "Protocolo URL registrado",
"registerFailed": "Falha ao registrar",
"installerRequired": "Requer a versão instalada do SecScore, pode não funcionar no modo de desenvolvimento."
"installerRequired": "Requer a versão instalada do SecScore, pode não funcionar no modo de desenvolvimento.",
"checkStatus": "Verificar status",
"reRegister": "Re-registrar",
"unregister": "Cancelar registro",
"unregistered": "Registro do protocolo URL cancelado",
"unregisterFailed": "Falha ao cancelar o registro",
"statusRegistered": "Registrado",
"statusNotRegistered": "Não registrado",
"statusCheckFailed": "Falha ao verificar o status",
"retrying": "Tentando novamente ({{current}}/{{max}})",
"retryFailed": "Número máximo de tentativas atingido, falha no registro",
"retrySuccess": "Tentativa #{{n}} bem-sucedida",
"operationLog": "Log de operações",
"logRegisterStart": "Iniciando registro do protocolo URL...",
"logRegisterVerify": "Comando de registro executado, verificando...",
"logRegisterSuccess": "Registro verificado com sucesso",
"logRegisterFailed": "Falha na verificação do registro",
"logUnregisterStart": "Cancelando registro...",
"logUnregisterSuccess": "Registro cancelado com sucesso",
"logUnregisterFailed": "Falha ao cancelar o registro",
"logMaxRetries": "Número máximo de tentativas (7) atingido"
},
"elevation": {
"title": "Privilégios de administrador",
"description": "Verificar se o aplicativo está sendo executado com privilégios de administrador/root. Algumas funcionalidades (escuta de porta, operações de sistema) requerem privilégios elevados.",
"elevated": "Elevado",
"notElevated": "Não elevado",
"request": "Solicitar privilégios de admin",
"requestSuccess": "Privilégios de admin solicitados, o aplicativo será reiniciado",
"requestFailed": "Falha ao solicitar privilégios de admin",
"windowsHint": "Windows: Um prompt UAC aparecerá. Após confirmação, o app será reiniciado como administrador.",
"macosHint": "macOS: Um diálogo de autenticação do sistema aparecerá. Digite a senha de admin para reiniciar.",
"linuxHint": "Linux: Usa pkexec para solicitar privilégios root. Digite a senha para reiniciar."
},
"mcp": {
"title": "Serviço MCP",
+73 -2
View File
@@ -128,7 +128,7 @@
"appearance": "Внешний вид",
"security": "Безопасность",
"account": "Учетная запись",
"database": "Подключение к базе данных",
"database": "Облачная синхронизация",
"dataManagement": "Управление данными",
"urlProtocol": "Протокол URL",
"about": "О программе"
@@ -255,6 +255,45 @@
"uploadSuccess": "Локальные данные загружены на удаленный сервер",
"uploadFailed": "Ошибка загрузки"
},
"cloudSync": {
"title": "Облачная синхронизация",
"selectMethod": "Выберите метод синхронизации",
"selectMethodHint": "Выберите метод синхронизации данных. Два метода взаимоисключающие.",
"postgresql": "Самостоятельный PostgreSQL",
"postgresqlDesc": "Подключитесь к собственной базе данных PostgreSQL для полного контроля данных.",
"sectlCloud": "SECTL Облачная синхронизация",
"sectlCloudDesc": "Синхронизация данных через облачную платформу SECTL. Без собственной базы данных.",
"loginRequired": "Пожалуйста, войдите в SECTL Auth",
"loginHint": "Для облачной синхронизации SECTL требуется аккаунт SECTL Auth.",
"statusTitle": "Статус синхронизации",
"configured": "Готово",
"notConfigured": "Не готово",
"syncing": "Синхронизация...",
"lastSync": "Последняя синхронизация",
"never": "Никогда",
"syncActions": "Действия синхронизации",
"syncDirection": "Направление синхронизации",
"bidirectional": "Двунаправленная (Рекомендуется) — Автоматическое слияние локальных и облачных данных",
"pushOnly": "Только загрузка — Перезаписать облачные данные локальными",
"pullOnly": "Только скачивание — Перезаписать локальные данные облачными",
"startSync": "Начать синхронизацию",
"uploadToCloud": "Загрузить в облако",
"downloadFromCloud": "Скачать из облака",
"fullSync": "Двунаправленная синхронизация",
"syncInProgress": "Синхронизация выполняется, подождите",
"syncConfirm": "Подтвердить {{direction}}?",
"storageUsage": "Использование хранилища",
"usedStorage": "Использовано",
"table": "Таблица",
"records": "Записи",
"size": "Размер",
"clickToLoadStats": "Нажмите обновить для загрузки статистики",
"description": "Руководство по облачной синхронизации SECTL",
"descriptionPoint1": "Синхронизация данных через облачный сервис SECTL. Без собственной базы данных.",
"descriptionPoint2": "На основе архитектуры KV-хранилища. Каждая таблица хранится как пара ключ-значение.",
"descriptionPoint3": "Поддержка двунаправленного, только загрузки и только скачивания режимов.",
"descriptionPoint4": "Данные передаются по HTTPS с аутентификацией OAuth 2.0."
},
"data": {
"title": "Управление данными",
"settlement": "Расчет",
@@ -301,7 +340,39 @@
"register": "Зарегистрировать протокол URL",
"registered": "Протокол URL зарегистрирован",
"registerFailed": "Ошибка регистрации",
"installerRequired": "Требуется установленная версия SecScore, может не работать в режиме разработки."
"installerRequired": "Требуется установленная версия SecScore, может не работать в режиме разработки.",
"checkStatus": "Проверить статус",
"reRegister": "Перерегистрировать",
"unregister": "Отменить регистрацию",
"unregistered": "Регистрация протокола URL отменена",
"unregisterFailed": "Не удалось отменить регистрацию",
"statusRegistered": "Зарегистрирован",
"statusNotRegistered": "Не зарегистрирован",
"statusCheckFailed": "Ошибка проверки статуса",
"retrying": "Повторная попытка ({{current}}/{{max}})",
"retryFailed": "Достигнуто максимальное количество попыток, ошибка регистрации",
"retrySuccess": "Повторная попытка #{{n}} успешна",
"operationLog": "Журнал операций",
"logRegisterStart": "Начало регистрации протокола URL...",
"logRegisterVerify": "Команда регистрации выполнена, проверка...",
"logRegisterSuccess": "Регистрация успешно подтверждена",
"logRegisterFailed": "Ошибка подтверждения регистрации",
"logUnregisterStart": "Отмена регистрации...",
"logUnregisterSuccess": "Регистрация успешно отменена",
"logUnregisterFailed": "Не удалось отменить регистрацию",
"logMaxRetries": "Достигнуто максимальное количество попыток (7)"
},
"elevation": {
"title": "Права администратора",
"description": "Проверить, запущено ли приложение с правами администратора/root. Некоторые функции (прослушивание портов, системные операции) требуют повышенных привилегий.",
"elevated": "Повышено",
"notElevated": "Не повышено",
"request": "Запросить права администратора",
"requestSuccess": "Права администратора запрошены, приложение перезапустится",
"requestFailed": "Не удалось запросить права администратора",
"windowsHint": "Windows: Появится запрос UAC. После подтверждения приложение перезапустится от имени администратора.",
"macosHint": "macOS: Появится диалог аутентификации. Введите пароль администратора для перезапуска.",
"linuxHint": "Linux: Используется pkexec для запроса root-прав. Введите пароль для перезапуска."
},
"mcp": {
"title": "Сервис MCP",
+73 -2
View File
@@ -128,7 +128,7 @@
"appearance": "外观",
"security": "安全",
"account": "账户",
"database": "数据库连接",
"database": "云同步",
"dataManagement": "数据管理",
"urlProtocol": "URL 链接",
"about": "关于"
@@ -255,6 +255,45 @@
"uploadSuccess": "本地数据已上传到远程",
"uploadFailed": "上传失败"
},
"cloudSync": {
"title": "云同步",
"selectMethod": "选择同步方式",
"selectMethodHint": "选择一种数据同步方式,两种方式互斥,切换后将使用新的同步通道。",
"postgresql": "自建 PostgreSQL 数据库",
"postgresqlDesc": "连接自建的 PostgreSQL 数据库,数据完全自主掌控,适合有服务器的用户。",
"sectlCloud": "SECTL 云服务同步",
"sectlCloudDesc": "通过 SECTL 云平台进行数据同步,无需自建数据库,开箱即用。",
"loginRequired": "请先登录 SECTL Auth 账户",
"loginHint": "使用 SECTL 云服务同步需要先登录 SECTL Auth 账户,请前往「账户」标签页登录。",
"statusTitle": "同步状态",
"configured": "已就绪",
"notConfigured": "未就绪",
"syncing": "同步中...",
"lastSync": "上次同步",
"never": "从未同步",
"syncActions": "同步操作",
"syncDirection": "同步方向",
"bidirectional": "双向同步(推荐)— 自动合并本地和云端数据",
"pushOnly": "仅上传 — 将本地数据覆盖到云端",
"pullOnly": "仅下载 — 将云端数据覆盖到本地",
"startSync": "开始同步",
"uploadToCloud": "上传到云端",
"downloadFromCloud": "从云端下载",
"fullSync": "双向同步",
"syncInProgress": "同步正在进行中,请稍候",
"syncConfirm": "确认执行 {{direction}}",
"storageUsage": "存储用量",
"usedStorage": "已使用",
"table": "数据表",
"records": "记录数",
"size": "大小",
"clickToLoadStats": "点击刷新按钮加载统计",
"description": "SECTL 云服务同步说明",
"descriptionPoint1": "使用 SECTL 云服务进行数据同步,无需自建数据库,数据通过 SECTL Auth 账户隔离。",
"descriptionPoint2": "基于 KV 存储架构,每张数据表存储为一个键值对,支持高效的数据读写。",
"descriptionPoint3": "支持双向同步、仅上传、仅下载三种模式,满足不同场景需求。",
"descriptionPoint4": "数据传输使用 HTTPS 加密,结合 OAuth 2.0 认证保障数据安全。"
},
"data": {
"title": "数据管理",
"settlement": "结算",
@@ -301,7 +340,39 @@
"register": "注册 URL 协议",
"registered": "URL 协议已注册",
"registerFailed": "注册失败",
"installerRequired": "需要安装版 SecScore,开发模式下可能无效。"
"installerRequired": "需要安装版 SecScore,开发模式下可能无效。",
"checkStatus": "检测状态",
"reRegister": "重新注册",
"unregister": "取消注册",
"unregistered": "URL 协议已取消注册",
"unregisterFailed": "取消注册失败",
"statusRegistered": "已注册",
"statusNotRegistered": "未注册",
"statusCheckFailed": "状态检测失败",
"retrying": "重试中 ({{current}}/{{max}})",
"retryFailed": "已达最大重试次数,注册失败",
"retrySuccess": "第 {{n}} 次重试成功",
"operationLog": "操作日志",
"logRegisterStart": "开始注册 URL 协议...",
"logRegisterVerify": "注册命令已执行,正在验证...",
"logRegisterSuccess": "注册验证成功",
"logRegisterFailed": "注册验证失败",
"logUnregisterStart": "正在取消注册...",
"logUnregisterSuccess": "取消注册成功",
"logUnregisterFailed": "取消注册失败",
"logMaxRetries": "已达最大重试次数 (7次)"
},
"elevation": {
"title": "管理员权限",
"description": "检测当前应用是否以管理员/root 权限运行。部分功能(如端口监听、系统级操作)需要管理员权限。",
"elevated": "已提权",
"notElevated": "未提权",
"request": "请求管理员权限",
"requestSuccess": "已请求管理员权限,应用将重启",
"requestFailed": "请求管理员权限失败",
"windowsHint": "Windows:将弹出 UAC 提示框,确认后应用将以管理员身份重启。",
"macosHint": "macOS:将弹出系统认证对话框,输入管理员密码后应用将重启。",
"linuxHint": "Linux:将使用 pkexec 请求 root 权限,输入密码后应用将重启。"
},
"mcp": {
"title": "MCP 服务",
+29 -13
View File
@@ -456,7 +456,6 @@ const api = {
oauthExchangeCode: (
code: string,
platformId: string,
platformSecret: string,
callbackUrl: string,
codeVerifier: string
): Promise<{
@@ -468,8 +467,7 @@ const api = {
expires_in: number
}
message?: string
}> =>
invoke("oauth_exchange_code", { code, platformId, platformSecret, callbackUrl, codeVerifier }),
}> => invoke("oauth_exchange_code", { code, platformId, callbackUrl, codeVerifier }),
oauthGetUserInfo: (
accessToken: string
): Promise<{
@@ -485,8 +483,7 @@ const api = {
}> => invoke("oauth_get_user_info", { accessToken }),
oauthRefreshToken: (
refreshToken: string,
platformId: string,
platformSecret: string
platformId: string
): Promise<{
success: boolean
data: {
@@ -496,12 +493,11 @@ const api = {
expires_in: number
}
message?: string
}> => invoke("oauth_refresh_token", { refreshToken, platformId, platformSecret }),
}> => invoke("oauth_refresh_token", { refreshToken, platformId }),
oauthRevokeToken: (
token: string,
tokenTypeHint: string | null,
platformId: string,
platformSecret: string
platformId: string
): Promise<{
success: boolean
message?: string
@@ -510,12 +506,10 @@ const api = {
token,
tokenTypeHint,
platformId,
platformSecret,
}),
oauthIntrospectToken: (
token: string,
platformId: string,
platformSecret: string
platformId: string
): Promise<{
success: boolean
data: {
@@ -535,7 +529,6 @@ const api = {
invoke("oauth_introspect_token", {
token,
platformId,
platformSecret,
}),
oauthStartCallbackServer: (): Promise<{
success: boolean
@@ -781,6 +774,30 @@ const api = {
data?: { registered: boolean }
message?: string
}> => invoke("register_url_protocol"),
checkUrlProtocolStatus: (): Promise<{
success: boolean
data?: {
registered: boolean
protocol: string
platform: string
details: string
}
message?: string
}> => invoke("check_url_protocol_status"),
unregisterUrlProtocol: (): Promise<{
success: boolean
data?: { registered: boolean }
message?: string
}> => invoke("unregister_url_protocol"),
checkElevation: (): Promise<{
success: boolean
data?: { is_elevated: boolean; platform: string; can_request_elevation: boolean }
message?: string
}> => invoke("check_elevation"),
requestElevation: (): Promise<{
success: boolean
message?: string
}> => invoke("request_elevation"),
appQuit: (): Promise<void> => invoke("app_quit"),
appRestart: (): Promise<void> => invoke("app_restart"),
@@ -863,4 +880,3 @@ const api = {
export default api
export { api }
+7 -3
View File
@@ -160,7 +160,7 @@ class ScoreSyncService {
}
try {
const result = await sectlKVStorage.getKV(DATA_KEYS.SETTINGS)
const result = await sectlKVStorage.getKV<{ reasons: any[]; tags: any[] }>(DATA_KEYS.SETTINGS)
return result.value || { reasons: [], tags: [] }
} catch (error: any) {
if (error.message.includes("不存在")) {
@@ -192,8 +192,12 @@ class ScoreSyncService {
platform: string
} | null> {
try {
const result = await sectlKVStorage.getKV(DATA_KEYS.SYNC_META)
return result.value
const result = await sectlKVStorage.getKV<{
version: string
lastSyncTime: string
platform: string
}>(DATA_KEYS.SYNC_META)
return result.value ?? null
} catch {
return null
}
+507
View File
@@ -0,0 +1,507 @@
import { sectlKVStorage } from "./sectlKVStorage"
import { sectlAuth } from "./sectlAuth"
const SYNC_PREFIX = "ss"
const SYNC_TABLES = [
"students",
"reasons",
"score_events",
"tags",
"student_tags",
"reward_settings",
"reward_redemptions",
] as const
type SyncTable = (typeof SYNC_TABLES)[number]
interface SyncMetadata {
last_sync_at: string
table_versions: Record<SyncTable, string>
device_id: string
}
interface TableSyncResult {
table: SyncTable
uploaded: number
downloaded: number
errors: string[]
}
export interface CloudSyncResult {
success: boolean
message: string
tables: TableSyncResult[]
synced_at: string
}
export interface CloudSyncStatus {
is_configured: boolean
last_sync_at: string | null
last_error: string | null
table_count: number
is_syncing: boolean
}
type SyncDirection = "push" | "pull" | "bidirectional"
const generateDeviceId = (): string => {
const stored = localStorage.getItem("ss_cloud_device_id")
if (stored) return stored
const id = `dev_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
localStorage.setItem("ss_cloud_device_id", id)
return id
}
const buildKey = (table: SyncTable): string => `${SYNC_PREFIX}:${table}`
const buildMetadataKey = (): string => `${SYNC_PREFIX}:__metadata`
const safeJsonParse = <T>(value: unknown): T | null => {
if (typeof value === "string") {
try {
return JSON.parse(value) as T
} catch {
return null
}
}
if (value && typeof value === "object") return value as T
return null
}
class SectlCloudSyncService {
private deviceId: string
private isSyncing: boolean = false
private lastSyncAt: string | null = null
private lastError: string | null = null
constructor() {
this.deviceId = generateDeviceId()
}
getStatus(): CloudSyncStatus {
const cached = localStorage.getItem("ss_cloud_sync_status")
let lastSyncAt = this.lastSyncAt
if (!lastSyncAt && cached) {
try {
const parsed = JSON.parse(cached)
lastSyncAt = parsed.last_sync_at || null
} catch {
// ignore
}
}
return {
is_configured: sectlAuth.isAuthenticated(),
last_sync_at: lastSyncAt,
last_error: this.lastError,
table_count: SYNC_TABLES.length,
is_syncing: this.isSyncing,
}
}
private saveStatus() {
localStorage.setItem(
"ss_cloud_sync_status",
JSON.stringify({
last_sync_at: this.lastSyncAt,
last_error: this.lastError,
})
)
}
async fetchCloudData(table: SyncTable): Promise<unknown[] | null> {
try {
const key = buildKey(table)
const result = await sectlKVStorage.getKV(key)
if (result && "value" in result) {
const parsed = safeJsonParse<unknown[]>(result.value)
return Array.isArray(parsed) ? parsed : null
}
return null
} catch {
return null
}
}
async pushTableData(table: SyncTable, data: unknown[]): Promise<number> {
try {
const key = buildKey(table)
const jsonValue = JSON.stringify(data)
const result = await sectlKVStorage.setKV(key, jsonValue)
return result.success ? data.length : 0
} catch {
return 0
}
}
async fetchMetadata(): Promise<SyncMetadata | null> {
try {
const key = buildMetadataKey()
const result = await sectlKVStorage.getKV(key)
if (result && "value" in result) {
return safeJsonParse<SyncMetadata>(result.value)
}
return null
} catch {
return null
}
}
async pushMetadata(metadata: SyncMetadata): Promise<boolean> {
try {
const key = buildMetadataKey()
const result = await sectlKVStorage.setKV(key, JSON.stringify(metadata))
return result.success
} catch {
return false
}
}
private async getLocalData(table: SyncTable): Promise<unknown[]> {
const api = (window as any).api
if (!api) return []
try {
const apiMap: Record<SyncTable, string> = {
students: "getStudents",
reasons: "getReasons",
score_events: "getScoreEvents",
tags: "getTags",
student_tags: "getStudentTags",
reward_settings: "getRewardSettings",
reward_redemptions: "getRewardRedemptions",
}
const methodName = apiMap[table]
if (!methodName || !api[methodName]) return []
const res = await api[methodName]()
return res.success && Array.isArray(res.data) ? res.data : []
} catch {
return []
}
}
private async setLocalData(table: SyncTable, data: unknown[]): Promise<boolean> {
const api = (window as any).api
if (!api) return false
try {
const methodName = `import${table.charAt(0).toUpperCase() + table.slice(1)}Data`
if (api[methodName]) {
const res = await api[methodName](JSON.stringify(data))
return res.success
}
const res = await api.importTableData(table, JSON.stringify(data))
return res.success
} catch {
return false
}
}
async syncToCloud(localDataMap?: Map<SyncTable, unknown[]>): Promise<CloudSyncResult> {
if (this.isSyncing) {
return {
success: false,
message: "同步正在进行中",
tables: [],
synced_at: new Date().toISOString(),
}
}
if (!sectlAuth.isAuthenticated()) {
return {
success: false,
message: "请先登录 SECTL Auth 账户",
tables: [],
synced_at: new Date().toISOString(),
}
}
this.isSyncing = true
const results: TableSyncResult[] = []
const syncedAt = new Date().toISOString()
try {
for (const table of SYNC_TABLES) {
const tableResult: TableSyncResult = {
table,
uploaded: 0,
downloaded: 0,
errors: [],
}
try {
const localData = localDataMap?.get(table) ?? (await this.getLocalData(table))
const uploaded = await this.pushTableData(table, localData)
tableResult.uploaded = uploaded
} catch (err: unknown) {
tableResult.errors.push(err instanceof Error ? err.message : String(err))
}
results.push(tableResult)
}
const metadata: SyncMetadata = {
last_sync_at: syncedAt,
table_versions: Object.fromEntries(SYNC_TABLES.map((t) => [t, syncedAt])) as Record<
SyncTable,
string
>,
device_id: this.deviceId,
}
await this.pushMetadata(metadata)
this.lastSyncAt = syncedAt
this.lastError = null
this.saveStatus()
return {
success: true,
message: `已上传 ${results.reduce((s, r) => s + r.uploaded, 0)} 条记录到云端`,
tables: results,
synced_at: syncedAt,
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
this.lastError = msg
this.saveStatus()
return {
success: false,
message: `同步失败:${msg}`,
tables: results,
synced_at: syncedAt,
}
} finally {
this.isSyncing = false
}
}
async syncFromCloud(): Promise<CloudSyncResult> {
if (this.isSyncing) {
return {
success: false,
message: "同步正在进行中",
tables: [],
synced_at: new Date().toISOString(),
}
}
if (!sectlAuth.isAuthenticated()) {
return {
success: false,
message: "请先登录 SECTL Auth 账户",
tables: [],
synced_at: new Date().toISOString(),
}
}
this.isSyncing = true
const results: TableSyncResult[] = []
const syncedAt = new Date().toISOString()
try {
for (const table of SYNC_TABLES) {
const tableResult: TableSyncResult = {
table,
uploaded: 0,
downloaded: 0,
errors: [],
}
try {
const cloudData = await this.fetchCloudData(table)
if (cloudData) {
const applied = await this.setLocalData(table, cloudData)
if (applied) {
tableResult.downloaded = cloudData.length
} else {
tableResult.errors.push("应用云端数据失败")
}
}
} catch (err: unknown) {
tableResult.errors.push(err instanceof Error ? err.message : String(err))
}
results.push(tableResult)
}
this.lastSyncAt = syncedAt
this.lastError = null
this.saveStatus()
return {
success: true,
message: `已从云端下载 ${results.reduce((s, r) => s + r.downloaded, 0)} 条记录`,
tables: results,
synced_at: syncedAt,
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
this.lastError = msg
this.saveStatus()
return {
success: false,
message: `同步失败:${msg}`,
tables: results,
synced_at: syncedAt,
}
} finally {
this.isSyncing = false
}
}
async fullSync(direction: SyncDirection = "bidirectional"): Promise<CloudSyncResult> {
if (this.isSyncing) {
return {
success: false,
message: "同步正在进行中",
tables: [],
synced_at: new Date().toISOString(),
}
}
if (!sectlAuth.isAuthenticated()) {
return {
success: false,
message: "请先登录 SECTL Auth 账户",
tables: [],
synced_at: new Date().toISOString(),
}
}
this.isSyncing = true
const results: TableSyncResult[] = []
const syncedAt = new Date().toISOString()
try {
const metadata = await this.fetchMetadata()
const isRemoteDevice = metadata?.device_id !== this.deviceId
for (const table of SYNC_TABLES) {
const tableResult: TableSyncResult = {
table,
uploaded: 0,
downloaded: 0,
errors: [],
}
try {
if (direction === "push") {
const localData = await this.getLocalData(table)
const uploaded = await this.pushTableData(table, localData)
tableResult.uploaded = uploaded
} else if (direction === "pull") {
const cloudData = await this.fetchCloudData(table)
if (cloudData) {
const applied = await this.setLocalData(table, cloudData)
if (applied) tableResult.downloaded = cloudData.length
else tableResult.errors.push("应用云端数据失败")
}
} else {
const cloudData = await this.fetchCloudData(table)
const localData = await this.getLocalData(table)
if (!cloudData || cloudData.length === 0) {
const uploaded = await this.pushTableData(table, localData)
tableResult.uploaded = uploaded
} else if (localData.length === 0) {
const applied = await this.setLocalData(table, cloudData)
if (applied) tableResult.downloaded = cloudData.length
else tableResult.errors.push("应用云端数据失败")
} else {
const cloudTs = metadata?.table_versions?.[table]
const cloudTime = cloudTs ? new Date(cloudTs).getTime() : 0
const now = Date.now()
if (isRemoteDevice || cloudTime < now - 60_000) {
const uploaded = await this.pushTableData(table, localData)
tableResult.uploaded = uploaded
} else {
const applied = await this.setLocalData(table, cloudData)
if (applied) tableResult.downloaded = cloudData.length
else tableResult.errors.push("应用云端数据失败")
}
}
}
} catch (err: unknown) {
tableResult.errors.push(err instanceof Error ? err.message : String(err))
}
results.push(tableResult)
}
const newMetadata: SyncMetadata = {
last_sync_at: syncedAt,
table_versions: Object.fromEntries(SYNC_TABLES.map((t) => [t, syncedAt])) as Record<
SyncTable,
string
>,
device_id: this.deviceId,
}
await this.pushMetadata(newMetadata)
this.lastSyncAt = syncedAt
this.lastError = null
this.saveStatus()
const totalUploaded = results.reduce((s, r) => s + r.uploaded, 0)
const totalDownloaded = results.reduce((s, r) => s + r.downloaded, 0)
return {
success: true,
message: `同步完成:上传 ${totalUploaded} 条,下载 ${totalDownloaded}`,
tables: results,
synced_at: syncedAt,
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
this.lastError = msg
this.saveStatus()
return {
success: false,
message: `同步失败:${msg}`,
tables: results,
synced_at: syncedAt,
}
} finally {
this.isSyncing = false
}
}
async getCloudStorageUsage(): Promise<{
used: number
total: number
percentage: number
table_stats: Array<{ table: SyncTable; count: number; size: number }>
} | null> {
try {
const tableStats: Array<{ table: SyncTable; count: number; size: number }> = []
let totalSize = 0
for (const table of SYNC_TABLES) {
const key = buildKey(table)
const result = await sectlKVStorage.getKV(key)
if (result && "value" in result) {
const size = (result as any).size || 0
const parsed = safeJsonParse<unknown[]>(result.value)
tableStats.push({
table,
count: Array.isArray(parsed) ? parsed.length : 0,
size,
})
totalSize += size
} else {
tableStats.push({ table, count: 0, size: 0 })
}
}
const maxStorage = 64 * 1024 * SYNC_TABLES.length
return {
used: totalSize,
total: maxStorage,
percentage: maxStorage > 0 ? Math.round((totalSize / maxStorage) * 100) : 0,
table_stats: tableStats,
}
} catch {
return null
}
}
}
export const sectlCloudSync = new SectlCloudSyncService()
+80 -200
View File
@@ -1,22 +1,14 @@
/**
* SECTL KV 键值对存储服务
* 基于 SECTL-One-Stop SDK 规范实现
* 提供键值对存储功能,支持 JSON 格式和字段级操作
*/
import { SECTL_CONFIG, sectlAuth } from "./sectlAuth"
// KV 数据类型
export interface KVItem {
key: string
value: any
value: unknown
size: number
is_json: boolean
created_at: string
updated_at: string
}
// KV 列表选项
export interface ListKVOptions {
prefix?: string
limit?: number
@@ -24,12 +16,60 @@ export interface ListKVOptions {
}
class SectlKVStorageService {
/**
* 设置 KV 键值对
* @param key 键名(最大 255 字符)
* @param value 值(任意 JSON 可序列化数据,最大 64KB)
* @param ttl 过期时间(秒),可选
*/
private get platformId(): string {
return SECTL_CONFIG.platformId
}
private getAuthHeaders(): Record<string, string> {
const token = sectlAuth.getAccessToken()
if (!token) throw new Error("未授权,请先登录")
return { Authorization: `Bearer ${token}` }
}
private async makeRequest<T>(
method: string,
endpoint: string,
options: { jsonData?: Record<string, unknown>; params?: Record<string, unknown> } = {}
): Promise<T> {
const headers: Record<string, string> = {
...this.getAuthHeaders(),
"Content-Type": "application/json",
}
let url = `${SECTL_CONFIG.baseUrl}${endpoint}`
if (options.params) {
const searchParams = new URLSearchParams()
for (const [k, v] of Object.entries(options.params)) {
if (v !== undefined && v !== null) searchParams.set(k, String(v))
}
const qs = searchParams.toString()
if (qs) url += `?${qs}`
}
const init: RequestInit = { method, headers }
if (options.jsonData && method !== "GET") {
init.body = JSON.stringify(options.jsonData)
}
const response = await fetch(url, init)
if (!response.ok) {
let errorData: { error?: string; error_description?: string; message?: string } = {}
try {
errorData = await response.json()
} catch {
// ignore
}
const desc = errorData.error_description || errorData.message || `HTTP ${response.status}`
const err = new Error(desc) as Error & { status?: number; error?: string }
err.status = response.status
err.error = errorData.error
throw err
}
return response.json() as Promise<T>
}
async setKV(
key: string,
value: unknown,
@@ -43,51 +83,16 @@ class SectlKVStorageService {
updated_at?: string
message: string
}> {
const accessToken = sectlAuth.getAccessToken()
if (!accessToken) {
throw new Error("未授权,请先登录")
}
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv`
const body: Record<string, unknown> = {
client_id: SECTL_CONFIG.platformId,
user_id: sectlAuth.getToken()?.user_id || "",
const jsonData: Record<string, unknown> = {
client_id: this.platformId,
key,
value,
}
if (ttl !== undefined) jsonData.ttl = ttl
if (ttl !== undefined) {
body.ttl = ttl
}
try {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "设置键值对失败")
}
return await response.json()
} catch (error) {
console.error("设置键值对失败:", error)
throw error
}
return this.makeRequest("POST", "/api/cloud/kv", { jsonData })
}
/**
* 获取 KV 键值对
* @param key 键名
* @param field JSON 字段路径,如 "theme" 或 "user.name",可选
*/
async getKV<T = unknown>(
key: string,
field?: string
@@ -95,93 +100,25 @@ class SectlKVStorageService {
| (KVItem & { key: string; value: T })
| { key: string; field: string; value: T; is_json: boolean }
> {
const accessToken = sectlAuth.getAccessToken()
if (!accessToken) {
throw new Error("未授权,请先登录")
}
const params: Record<string, string> = { client_id: this.platformId }
if (field) params.field = field
const params = new URLSearchParams({
client_id: SECTL_CONFIG.platformId,
user_id: sectlAuth.getToken()?.user_id || "",
})
if (field) {
params.append("field", field)
}
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}?${params.toString()}`
try {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "获取键值对失败")
}
return await response.json()
} catch (error) {
console.error("获取键值对失败:", error)
throw error
}
return this.makeRequest("GET", `/api/cloud/kv/${encodeURIComponent(key)}`, { params })
}
/**
* 获取 KV 键值对列表
* @param options 列表选项
*/
async listKV(options: ListKVOptions = {}): Promise<{
kv_list: KVItem[]
total: number
has_more: boolean
}> {
const accessToken = sectlAuth.getAccessToken()
if (!accessToken) {
throw new Error("未授权,请先登录")
async listKV(
options: ListKVOptions = {}
): Promise<{ kv_list: KVItem[]; total: number; has_more: boolean }> {
const params: Record<string, unknown> = {
client_id: this.platformId,
limit: Math.min(options.limit || 100, 1000),
offset: options.offset || 0,
}
if (options.prefix) params.prefix = options.prefix
const params = new URLSearchParams({
client_id: SECTL_CONFIG.platformId,
user_id: sectlAuth.getToken()?.user_id || "",
limit: String(options.limit || 100),
offset: String(options.offset || 0),
})
if (options.prefix) {
params.append("prefix", options.prefix)
}
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv?${params.toString()}`
try {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "获取键值对列表失败")
}
return await response.json()
} catch (error) {
console.error("获取键值对列表失败:", error)
throw error
}
return this.makeRequest("GET", "/api/cloud/kv", { params })
}
/**
* 更新 KV JSON 字段
* @param key 键名
* @param field JSON 字段路径,支持嵌套如 "user.name" 或数组索引 "items.0.id"
* @param value 要设置的值
*/
async updateKVField(
key: string,
field: string,
@@ -194,77 +131,20 @@ class SectlKVStorageService {
updated_at: string
message: string
}> {
const accessToken = sectlAuth.getAccessToken()
if (!accessToken) {
throw new Error("未授权,请先登录")
}
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`
try {
const response = await fetch(url, {
method: "PATCH",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: SECTL_CONFIG.platformId,
user_id: sectlAuth.getToken()?.user_id || "",
field,
value,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "更新字段失败")
}
return await response.json()
} catch (error) {
console.error("更新字段失败:", error)
throw error
}
return this.makeRequest("PATCH", `/api/cloud/kv/${encodeURIComponent(key)}`, {
jsonData: { client_id: this.platformId, field, value },
})
}
/**
* 删除 KV 键值对
* @param key 键名
*/
async deleteKV(key: string): Promise<{ success: boolean; message: string }> {
const accessToken = sectlAuth.getAccessToken()
if (!accessToken) {
throw new Error("未授权,请先登录")
}
return this.makeRequest("DELETE", `/api/cloud/kv/${encodeURIComponent(key)}`, {
jsonData: { client_id: this.platformId },
})
}
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`
try {
const response = await fetch(url, {
method: "DELETE",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: SECTL_CONFIG.platformId,
user_id: sectlAuth.getToken()?.user_id || "",
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "删除键值对失败")
}
return await response.json()
} catch (error) {
console.error("删除键值对失败:", error)
throw error
}
getPlatformId(): string {
return this.platformId
}
}
// 导出单例
export const sectlKVStorage = new SectlKVStorageService()