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:
@@ -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
52
WebUI/src/api/sync.ts
Normal 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'),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
147
WebUI/src/stores/useSyncStore.ts
Normal file
147
WebUI/src/stores/useSyncStore.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user