实现本地与远程数据库自动同步及冲突选择保留策略

This commit is contained in:
JSR
2026-03-19 17:33:33 +08:00
parent fe0ae4b4ff
commit 27f61ddd0c
4 changed files with 942 additions and 2 deletions
+142 -1
View File
@@ -1,5 +1,5 @@
import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme } from "antd"
import { useEffect, useMemo, useState } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import { HashRouter, useLocation, useNavigate, Routes, Route } from "react-router-dom"
import { useTranslation } from "react-i18next"
import { Sidebar } from "./components/Sidebar"
@@ -54,6 +54,13 @@ function MainContent(): React.JSX.Element {
const [isPortraitMode, setIsPortraitMode] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [floatingSidebarExpanded, setFloatingSidebarExpanded] = useState(false)
const [syncConflictVisible, setSyncConflictVisible] = useState(false)
const [syncConflicts, setSyncConflicts] = useState<
Array<{ table: string; key: string; local_summary: string; remote_summary: string }>
>([])
const [syncApplyLoading, setSyncApplyLoading] = useState(false)
const syncCheckingRef = useRef(false)
const syncApplyLoadingRef = useRef(false)
const activeMenu = useMemo(() => {
const p = location.pathname
@@ -94,6 +101,85 @@ function MainContent(): React.JSX.Element {
loadAuthAndSettings()
}, [])
const applySyncStrategy = async (strategy: "keep_local" | "keep_remote") => {
const api = (window as any).api
if (!api) return
setSyncApplyLoading(true)
syncApplyLoadingRef.current = true
try {
const res = await api.dbSyncApply(strategy)
if (res?.success && res?.data?.success) {
messageApi.success(
res.data.message || `同步完成(同步 ${res.data.synced_records} 条,解决冲突 ${res.data.resolved_conflicts} 条)`
)
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category: "all" } }))
} else {
messageApi.error(res?.data?.message || res?.message || "同步失败")
}
} catch (error: any) {
messageApi.error(error?.message || "同步失败")
} finally {
setSyncApplyLoading(false)
syncApplyLoadingRef.current = false
setSyncConflictVisible(false)
setSyncConflicts([])
}
}
useEffect(() => {
const api = (window as any).api
if (!api) return
let disposed = false
const checkAndSync = async () => {
if (disposed || syncCheckingRef.current || syncApplyLoadingRef.current) return
if (permission !== "admin") return
try {
syncCheckingRef.current = true
const statusRes = await api.dbGetStatus()
if (!statusRes?.success || statusRes?.data?.type !== "postgresql" || !statusRes?.data?.connected) {
return
}
const previewRes = await api.dbSyncPreview()
if (!previewRes?.success || !previewRes?.data?.can_sync || !previewRes?.data?.need_sync) {
return
}
const conflicts = previewRes.data.conflicts || []
if (conflicts.length > 0) {
setSyncConflicts(conflicts)
setSyncConflictVisible(true)
return
}
const applyRes = await api.dbSyncApply("keep_local")
if (applyRes?.success && applyRes?.data?.success && applyRes?.data?.synced_records > 0) {
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category: "all" } }))
}
} catch (error) {
console.error("Auto sync failed:", error)
} finally {
syncCheckingRef.current = false
}
}
checkAndSync()
const timer = window.setInterval(checkAndSync, 30000)
const onDataUpdated = () => {
window.setTimeout(() => {
checkAndSync().catch(() => void 0)
}, 1200)
}
window.addEventListener("ss:data-updated", onDataUpdated)
return () => {
disposed = true
window.clearInterval(timer)
window.removeEventListener("ss:data-updated", onDataUpdated)
}
}, [permission])
const login = async () => {
if (!(window as any).api) return
setAuthLoading(true)
@@ -234,6 +320,61 @@ function MainContent(): React.JSX.Element {
</div>
</Modal>
<Modal
title="检测到本地与远程数据冲突"
open={syncConflictVisible}
onCancel={() => {
if (syncApplyLoading) return
setSyncConflictVisible(false)
}}
footer={null}
closable={!syncApplyLoading}
maskClosable={false}
destroyOnClose
>
<div style={{ marginBottom: "10px", color: "var(--ss-text-secondary)", fontSize: "12px" }}>
</div>
<div
style={{
maxHeight: "280px",
overflow: "auto",
border: "1px solid var(--ss-border-color)",
borderRadius: "6px",
padding: "8px",
marginBottom: "12px",
fontSize: "12px",
}}
>
{syncConflicts.slice(0, 30).map((item) => (
<div key={`${item.table}-${item.key}`} style={{ marginBottom: "8px" }}>
<div>
<b>{item.table}</b> / <b>{item.key}</b>
</div>
<div>: {item.local_summary}</div>
<div>: {item.remote_summary}</div>
</div>
))}
{syncConflicts.length > 30 && <div> 30 ...</div>}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}>
<button
style={{ padding: "6px 10px", cursor: syncApplyLoading ? "not-allowed" : "pointer" }}
disabled={syncApplyLoading}
onClick={() => applySyncStrategy("keep_remote")}
>
</button>
<button
style={{ padding: "6px 10px", cursor: syncApplyLoading ? "not-allowed" : "pointer" }}
disabled={syncApplyLoading}
onClick={() => applySyncStrategy("keep_local")}
>
</button>
</div>
</Modal>
{import.meta.env.DEV ? (
<div
style={{
+20
View File
@@ -228,6 +228,26 @@ const api = {
}> => invoke("db_get_status"),
dbSync: (): Promise<{ success: boolean; data: { success: boolean; message?: string } }> =>
invoke("db_sync"),
dbSyncPreview: (): Promise<{
success: boolean
data: {
can_sync: boolean
need_sync: boolean
local_only: number
remote_only: number
conflicts: Array<{
table: string
key: string
local_summary: string
remote_summary: string
}>
message?: string
}
}> => invoke("db_sync_preview"),
dbSyncApply: (strategy: "keep_local" | "keep_remote"): Promise<{
success: boolean
data: { success: boolean; synced_records: number; resolved_conflicts: number; message?: string }
}> => invoke("db_sync_apply", { strategy }),
// HTTP Server
httpServerStart: (config?: {