feat: add WebDAV sync support and startup/shutdown scripts

Backend:
- Add uuid, sync_version, is_deleted fields to all syncable models
- Add SyncSettings model for WebDAV configuration (AES-256-GCM encrypted passwords)
- Add crypto.py: AES-256-GCM encryption derived from JWT_SECRET via PBKDF2
- Add sync_lock.py: thread-level sync lock with 503 middleware for write blocking
- Add webdav.py: WebDAV client using requests (PUT/GET/MKCOL/DELETE)
- Add sync_service.py: push/pull/bidirectional merge with LWW conflict resolution
- Add sync router with 8 endpoints: config, test, push, pull, sync, status, remote delete
- Add UUID backfill for existing records in init_db()
- Add SQLAlchemy before_update event to auto-increment sync_version
- Register sync middleware to block writes during sync (503)

Frontend:
- Add sync API client (WebUI/src/api/sync.ts)
- Add useSyncStore with config, test, push/pull/sync operations
- Add WebDAV config + sync UI in SettingsView
- Add 503 status code handling in axios interceptor
- Add uuid field to all TypeScript type definitions

Scripts:
- Add scripts/start.bat and scripts/stop.bat for project management

Design doc: docs/plan/webdav-sync-design.md
This commit is contained in:
祀梦
2026-05-17 21:18:54 +08:00
parent 944d20dcc7
commit 0ab719500b
31 changed files with 2194 additions and 41 deletions

View File

@@ -43,6 +43,9 @@ instance.interceptors.response.use(
case 429:
message = data?.detail || '请求过于频繁,请稍后再试~'
break
case 503:
message = data?.detail || '正在同步数据,请稍后再试~'
break
case 500:
message = '服务器内部错误~'
break

52
WebUI/src/api/sync.ts Normal file
View File

@@ -0,0 +1,52 @@
import { get, put, post, del } from './request'
export interface SyncConfig {
webdav_url: string | null
webdav_username: string | null
webdav_password: string | null
webdav_path: string
sync_enabled: boolean
auto_sync: boolean
auto_sync_interval: number
last_sync_at: string | null
last_sync_version: number
}
export interface SyncConfigUpdate {
webdav_url?: string | null
webdav_username?: string | null
webdav_password?: string | null
webdav_path?: string
auto_sync?: boolean
auto_sync_interval?: number
}
export interface SyncStatus {
syncing: boolean
last_sync_at: string | null
last_sync_version: number
sync_enabled: boolean
}
export interface SyncResult {
success: boolean
message: string
}
export const syncApi = {
getConfig: () => get<SyncConfig>('/sync/config'),
updateConfig: (data: SyncConfigUpdate) => put<SyncConfig>('/sync/config', data),
testConnection: () => post<SyncResult>('/sync/test'),
push: () => post<SyncResult>('/sync/push'),
pull: () => post<SyncResult>('/sync/pull'),
sync: () => post<SyncResult>('/sync/sync'),
getStatus: () => get<SyncStatus>('/sync/status'),
clearRemote: () => del<SyncResult>('/sync/remote'),
}

View File

@@ -2,6 +2,7 @@ export type QuadrantPriority = 'q1' | 'q2' | 'q3' | 'q4'
export interface Task {
id: number
uuid?: string
title: string
description?: string
priority: QuadrantPriority
@@ -16,6 +17,7 @@ export interface Task {
export interface Category {
id: number
uuid?: string
name: string
color: string
icon: string
@@ -23,6 +25,7 @@ export interface Category {
export interface Tag {
id: number
uuid?: string
name: string
}
@@ -88,6 +91,7 @@ export interface UserSettingsUpdate {
export interface HabitGroup {
id: number
uuid?: string
name: string
color: string
icon: string
@@ -105,6 +109,7 @@ export type HabitFrequency = 'daily' | 'weekly'
export interface Habit {
id: number
uuid?: string
name: string
description?: string
group_id?: number
@@ -128,6 +133,7 @@ export interface HabitFormData {
export interface HabitCheckin {
id: number
uuid?: string
habit_id: number
checkin_date: string
count: number
@@ -146,6 +152,7 @@ export interface HabitStats {
export interface AnniversaryCategory {
id: number
uuid?: string
name: string
icon: string
color: string
@@ -161,6 +168,7 @@ export interface AnniversaryCategoryFormData {
export interface Anniversary {
id: number
uuid?: string
title: string
date: string
year?: number | null
@@ -195,6 +203,7 @@ export type StepStatus = 'pending' | 'in_progress' | 'completed'
export interface Goal {
id: number
uuid?: string
title: string
description?: string | null
status: GoalStatus
@@ -220,6 +229,7 @@ export interface GoalDetail extends Goal {
export interface GoalStep {
id: number
uuid?: string
goal_id: number
parent_id?: number | null
title: string
@@ -234,6 +244,7 @@ export interface GoalStep {
export interface GoalReview {
id: number
uuid?: string
goal_id: number
content: string
rating?: number | null

View File

@@ -0,0 +1,147 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { SyncConfig, SyncConfigUpdate, SyncStatus } from '@/api/sync'
import { syncApi } from '@/api/sync'
import { ElMessage, ElMessageBox } from 'element-plus'
export type SyncDirection = 'push' | 'pull' | 'sync'
export const useSyncStore = defineStore('sync', () => {
const config = ref<SyncConfig | null>(null)
const status = ref<SyncStatus | null>(null)
const loading = ref(false)
const syncing = ref(false)
const syncMessage = ref('')
const isConfigured = computed(() => !!config.value?.webdav_url)
async function fetchConfig() {
loading.value = true
try {
config.value = await syncApi.getConfig()
} finally {
loading.value = false
}
}
async function saveConfig(data: SyncConfigUpdate) {
loading.value = true
try {
config.value = await syncApi.updateConfig(data)
ElMessage.success('保存成功')
} catch {
ElMessage.error('保存失败')
} finally {
loading.value = false
}
}
async function testConnection() {
loading.value = true
try {
const result = await syncApi.testConnection()
if (result.success) {
ElMessage.success(result.message)
} else {
ElMessage.error(result.message)
}
return result
} catch {
ElMessage.error('连接测试失败')
return { success: false as const, message: '连接测试失败' }
} finally {
loading.value = false
}
}
async function fetchStatus() {
try {
status.value = await syncApi.getStatus()
syncing.value = status.value.syncing
} catch {
// 静默失败
}
}
async function startSync(direction: SyncDirection) {
if (syncing.value) {
ElMessage.warning('正在同步中,请稍后再试')
return
}
const directionLabel = direction === 'push' ? '推送' : direction === 'pull' ? '拉取' : '双向合并'
if (direction === 'push' || direction === 'pull') {
try {
await ElMessageBox.confirm(
`${direction === 'push' ? '推送' : '拉取'}操作会覆盖${direction === 'push' ? '远端' : '本地'}数据,确定继续吗?`,
'警告',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
}
syncing.value = true
syncMessage.value = `正在${directionLabel}...`
try {
let result
switch (direction) {
case 'push':
result = await syncApi.push()
break
case 'pull':
result = await syncApi.pull()
break
case 'sync':
result = await syncApi.sync()
break
}
if (result.success) {
ElMessage.success(result.message)
} else {
ElMessage.error(result.message)
}
} catch {
ElMessage.error(`${directionLabel}失败`)
} finally {
syncing.value = false
syncMessage.value = ''
await fetchStatus()
}
}
async function clearRemote() {
try {
await ElMessageBox.confirm(
'确定要清空远端所有同步数据吗?此操作不可恢复!',
'危险操作',
{ confirmButtonText: '确定清空', cancelButtonText: '取消', type: 'error' }
)
const result = await syncApi.clearRemote()
if (result.success) {
ElMessage.success(result.message)
} else {
ElMessage.error(result.message)
}
} catch {
// 取消操作
}
}
return {
config,
status,
loading,
syncing,
syncMessage,
isConfigured,
fetchConfig,
saveConfig,
testConnection,
fetchStatus,
startSync,
clearRemote,
}
})

View File

@@ -6,6 +6,7 @@ import { useTaskStore } from '@/stores/useTaskStore'
import { useCategoryStore } from '@/stores/useCategoryStore'
import { useTagStore } from '@/stores/useTagStore'
import { useHabitStore } from '@/stores/useHabitStore'
import { useSyncStore, type SyncDirection } from '@/stores/useSyncStore'
import { get, post, del } from '@/api/request'
import type { Task, Category, Tag, HabitGroup, Habit } from '@/api/types'
@@ -14,6 +15,7 @@ const taskStore = useTaskStore()
const categoryStore = useCategoryStore()
const tagStore = useTagStore()
const habitStore = useHabitStore()
const syncStore = useSyncStore()
const saving = ref(false)
const exporting = ref(false)
@@ -42,13 +44,48 @@ const prefs = ref({
default_sort_order: 'desc'
})
const webdavConfig = ref({
webdav_url: '',
webdav_username: '',
webdav_password: '',
webdav_path: '/elysia-todo/',
auto_sync: false,
auto_sync_interval: 300
})
const syncDirection = ref<SyncDirection>('sync')
let pollTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
prefs.value.site_name = userStore.siteName || '爱莉希雅待办'
prefs.value.default_view = userStore.defaultView || 'list'
prefs.value.default_sort_by = userStore.defaultSortBy || 'priority'
prefs.value.default_sort_order = userStore.defaultSortOrder || 'desc'
syncStore.fetchConfig().then(() => {
if (syncStore.config) {
webdavConfig.value.webdav_url = syncStore.config.webdav_url || ''
webdavConfig.value.webdav_username = syncStore.config.webdav_username || ''
webdavConfig.value.webdav_password = ''
webdavConfig.value.webdav_path = syncStore.config.webdav_path || '/elysia-todo/'
webdavConfig.value.auto_sync = syncStore.config.auto_sync || false
webdavConfig.value.auto_sync_interval = syncStore.config.auto_sync_interval || 300
}
})
syncStore.fetchStatus()
pollTimer = setInterval(() => {
syncStore.fetchStatus()
}, 5000)
})
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
async function handleSave() {
saving.value = true
try {
@@ -60,7 +97,6 @@ async function handleSave() {
})
userStore.syncFromSettings(userStore.settings!)
// 保存排序后立即应用
taskStore.setFilters({
sort_by: prefs.value.default_sort_by as 'priority' | 'due_date' | 'created_at',
sort_order: prefs.value.default_sort_order as 'asc' | 'desc'
@@ -74,6 +110,28 @@ async function handleSave() {
}
}
async function saveWebdavConfig() {
const data: Record<string, unknown> = {
webdav_url: webdavConfig.value.webdav_url || null,
webdav_username: webdavConfig.value.webdav_username || null,
webdav_path: webdavConfig.value.webdav_path || '/elysia-todo/',
auto_sync: webdavConfig.value.auto_sync,
auto_sync_interval: webdavConfig.value.auto_sync_interval,
}
if (webdavConfig.value.webdav_password) {
data.webdav_password = webdavConfig.value.webdav_password
}
await syncStore.saveConfig(data)
}
async function testWebdavConnection() {
await syncStore.testConnection()
}
async function startSync() {
await syncStore.startSync(syncDirection.value)
}
async function exportData() {
exporting.value = true
try {
@@ -444,6 +502,166 @@ async function clearCompleted() {
</div>
</div>
</div>
<!-- 数据同步 -->
<div class="settings-card">
<div class="card-header">
<div class="card-icon sync-icon">
<el-icon :size="24"><Connection /></el-icon>
</div>
<div>
<h3 class="card-title">数据同步</h3>
<p class="card-subtitle">通过 WebDAV 同步你的数据</p>
</div>
</div>
<div class="card-body">
<div class="setting-item">
<div class="setting-label">
<span class="label-text">服务器地址</span>
<span class="label-desc">Alist WebDAV 地址</span>
</div>
<el-input
v-model="webdavConfig.webdav_url"
placeholder="https://alist.example.com/dav"
style="width: 260px"
/>
</div>
<div class="setting-item">
<div class="setting-label">
<span class="label-text">用户名</span>
<span class="label-desc">WebDAV 登录用户名</span>
</div>
<el-input
v-model="webdavConfig.webdav_username"
placeholder="用户名"
style="width: 200px"
/>
</div>
<div class="setting-item">
<div class="setting-label">
<span class="label-text">密码</span>
<span class="label-desc">WebDAV 登录密码</span>
</div>
<div style="display: flex; gap: 8px; align-items: center;">
<el-input
v-model="webdavConfig.webdav_password"
type="password"
show-password
:placeholder="syncStore.config?.webdav_password ? '已保存(留空不变更)' : '输入密码'"
style="width: 200px"
/>
<el-button @click="testWebdavConnection" :loading="syncStore.loading" size="default">
测试连接
</el-button>
</div>
</div>
<div class="setting-item">
<div class="setting-label">
<span class="label-text">远端路径</span>
<span class="label-desc">WebDAV 上的存储路径</span>
</div>
<el-input
v-model="webdavConfig.webdav_path"
placeholder="/elysia-todo/"
style="width: 200px"
/>
</div>
<div class="form-actions">
<el-button
type="primary"
:loading="syncStore.loading"
@click="saveWebdavConfig"
class="save-btn"
>
保存配置
</el-button>
</div>
</div>
</div>
<!-- 同步操作 -->
<div class="settings-card" v-if="syncStore.isConfigured">
<div class="card-header">
<div class="card-icon sync-icon">
<el-icon :size="24"><Refresh /></el-icon>
</div>
<div>
<h3 class="card-title">同步操作</h3>
<p class="card-subtitle">选择同步方向并执行</p>
</div>
</div>
<div class="card-body">
<div class="setting-item">
<div class="setting-label">
<span class="label-text">同步方向</span>
<span class="label-desc">选择数据同步的方向</span>
</div>
<el-radio-group v-model="syncDirection">
<el-radio-button value="sync">双向合并</el-radio-button>
<el-radio-button value="push">推送</el-radio-button>
<el-radio-button value="pull">拉取</el-radio-button>
</el-radio-group>
</div>
<div class="sync-info" v-if="syncStore.status">
<span class="info-item">
<el-icon><Clock /></el-icon>
上次同步: {{ syncStore.status.last_sync_at ? new Date(syncStore.status.last_sync_at).toLocaleString() : '从未同步' }}
</span>
<span class="info-item" v-if="syncStore.status.last_sync_version > 0">
版本: v{{ syncStore.status.last_sync_version }}
</span>
</div>
<div class="sync-overlay-hint" v-if="syncStore.syncing">
<el-icon class="sync-spin" :size="20"><Loading /></el-icon>
<span>{{ syncStore.syncMessage || '正在同步...' }}</span>
</div>
<div class="data-actions">
<div class="data-action-item">
<div class="action-info">
<span class="action-title">执行同步</span>
<span class="action-desc" v-if="syncDirection === 'push'">将本地数据推送到远端远端数据将被覆盖</span>
<span class="action-desc warning" v-else-if="syncDirection === 'pull'">从远端拉取数据本地数据将被覆盖</span>
<span class="action-desc" v-else>合并本地和远端数据以最新版本为准</span>
</div>
<el-button
type="primary"
:loading="syncStore.syncing"
:disabled="syncStore.syncing"
@click="startSync"
class="action-btn"
>
<el-icon><Refresh /></el-icon>
{{ syncStore.syncing ? syncStore.syncMessage : '开始同步' }}
</el-button>
</div>
<div class="data-action-item">
<div class="action-info">
<span class="action-title">清空远端数据</span>
<span class="action-desc danger">删除 WebDAV 上的所有同步数据不可恢复</span>
</div>
<el-button
type="danger"
plain
@click="syncStore.clearRemote()"
class="action-btn"
>
<el-icon><Delete /></el-icon>
清空远端
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -628,6 +846,46 @@ async function clearCompleted() {
}
}
.sync-icon {
background: linear-gradient(135deg, rgba(100, 200, 255, 0.2) 0%, rgba(150, 150, 255, 0.2) 100%) !important;
color: #6495ed !important;
}
.sync-info {
display: flex;
gap: 16px;
padding: 12px 0;
font-size: 13px;
color: var(--text-secondary);
.info-item {
display: flex;
align-items: center;
gap: 4px;
}
}
.sync-overlay-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
margin: 8px 0;
background: linear-gradient(135deg, rgba(100, 200, 255, 0.08) 0%, rgba(150, 150, 255, 0.08) 100%);
border-radius: var(--radius-md);
font-size: 14px;
color: #6495ed;
.sync-spin {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.settings-page {
padding: 16px;