feat: add data backup/import, goal step ordering, and PostgreSQL migration

- Add GET /api/backup/export and POST /api/backup/import endpoints for full data backup
- Add drag-and-drop reorder for goal steps with PUT /api/goals/{id}/steps/reorder
- Auto-assign sort_order on step creation (preserves creation order)
- Fix duplicate milestone rendering in goal detail page
- Add category management button in goal dialog
- Migrate database default from SQLite to PostgreSQL
- Fix router guard redirect loop for logged-in users on setup/login pages
- Fix ALTER TABLE ADD COLUMN crash on callable defaults (uuid.uuid4)
- Add auth status rate limiter and token version caching
- Update CLAUDE.md to reflect current architecture

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
祀梦
2026-05-18 00:02:18 +08:00
parent 0ab719500b
commit 5048de4fa1
21 changed files with 543 additions and 225 deletions

13
WebUI/src/api/backup.ts Normal file
View File

@@ -0,0 +1,13 @@
import request from './request'
export function exportBackup(): Promise<Blob> {
return request.get('/backup/export', { responseType: 'blob' })
}
export function importBackup(file: File): Promise<{ message: string; count: number }> {
const form = new FormData()
form.append('file', file)
return request.post('/backup/import', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
}

View File

@@ -50,6 +50,10 @@ export function toggleStep(goalId: number, stepId: number): Promise<GoalStep> {
return patch<GoalStep>(`/goals/${goalId}/steps/${stepId}/toggle`)
}
export function reorderSteps(goalId: number, items: { id: number; sort_order: number }[]): Promise<{ message: string }> {
return put<{ message: string }>(`/goals/${goalId}/steps/reorder`, { items })
}
// ============ Reviews ============
export function createReview(goalId: number, data: GoalReviewFormData): Promise<GoalReview> {

View File

@@ -2,6 +2,7 @@
import { ref, watch } from 'vue'
import { useGoalStore } from '@/stores/useGoalStore'
import { useCategoryStore } from '@/stores/useCategoryStore'
import { useUIStore } from '@/stores/useUIStore'
import type { Goal, GoalFormData } from '@/api/types'
const props = defineProps<{
@@ -16,6 +17,7 @@ const emit = defineEmits<{
const goalStore = useGoalStore()
const categoryStore = useCategoryStore()
const uiStore = useUIStore()
const form = ref<GoalFormData>({
title: '',
@@ -119,14 +121,19 @@ const iconOptions = ['flag', 'star', 'trophy', 'aim', 'medal', 'magic', 'sunny',
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-col :span="16">
<el-form-item label="分类">
<el-select v-model="form.category_id" style="width:100%" clearable placeholder="无分类">
<el-option v-for="cat in categoryStore.categories" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
<div class="category-row">
<el-select v-model="form.category_id" style="flex:1" clearable placeholder="无分类">
<el-option v-for="cat in categoryStore.categories" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
<el-button text size="small" class="manage-cat-btn" @click="uiStore.openCategoryDialog()">
<el-icon><Setting /></el-icon>
</el-button>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :span="8">
<el-form-item label="颜色">
<div class="color-picker">
<button
@@ -165,15 +172,28 @@ const iconOptions = ['flag', 'star', 'trophy', 'aim', 'medal', 'magic', 'sunny',
</template>
<style scoped lang="scss">
.category-row {
display: flex;
align-items: center;
gap: 6px;
}
.manage-cat-btn {
flex-shrink: 0;
color: #999;
&:hover { color: var(--primary); }
}
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
padding-top: 4px;
.color-dot {
width: 28px;
height: 28px;
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;

View File

@@ -93,6 +93,18 @@ router.beforeEach(async (to, from) => {
const siteName = userStore.siteName || '爱莉希雅待办'
document.title = page ? `${page} - ${siteName}` : siteName
// 已登录用户访问 setup/login 页面时重定向到主页
if ((to.path === '/setup' || to.path === '/login')) {
const authStore = useAuthStore()
if (authStore.checked && authStore.isLoggedIn && !authStore.needSetup) {
return { path: '/' }
}
if (authStore.checked && authStore.needSetup && to.path === '/login') {
return { path: '/setup' }
}
return
}
if (to.meta.noAuth) return
const authStore = useAuthStore()

View File

@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Plus, Delete } from '@element-plus/icons-vue'
import { useGoalStore } from '@/stores/useGoalStore'
import { useTaskStore } from '@/stores/useTaskStore'
import { reorderSteps } from '@/api/goals'
import GoalDialog from '@/components/GoalDialog.vue'
import type { Goal, GoalStep } from '@/api/types'
import { ElMessageBox, ElMessage } from 'element-plus'
@@ -43,6 +44,13 @@ const stepLabel: Record<string, string> = {
completed: '已完成',
}
function hasPhaseParent(step: GoalStep): boolean {
if (!step.parent_id) return false
return goalStore.currentGoal?.steps.some(
(s: GoalStep) => s.id === step.parent_id && s.step_type === 'phase'
) ?? false
}
const stepColor: Record<string, string> = {
pending: '#909399',
in_progress: '#E6A23C',
@@ -67,7 +75,6 @@ async function handleAddStep() {
step_type: stepType.value,
status: 'pending',
parent_id: stepParentId.value,
sort_order: 0,
})
stepTitle.value = ''
stepParentId.value = null
@@ -121,6 +128,82 @@ const unlinkedTasks = computed(() => {
return taskStore.activeTasks.filter(t => !linkedIds.has(t.id))
})
// ============ Drag & Drop ============
interface DragItem {
id: number
step_type: string
parent_id: number | null
}
const dragItem = ref<DragItem | null>(null)
const dragOverId = ref<number | null>(null)
function onDragStart(step: GoalStep) {
dragItem.value = { id: step.id, step_type: step.step_type, parent_id: step.parent_id }
}
function onDragOver(e: DragEvent, step: GoalStep) {
e.preventDefault()
if (dragItem.value && dragItem.value.id !== step.id) {
dragOverId.value = step.id
}
}
function onDragLeave() {
dragOverId.value = null
}
async function onDrop(targetStep: GoalStep) {
dragOverId.value = null
if (!dragItem.value || dragItem.value.id === targetStep.id) return
if (!goalStore.currentGoal) return
const src = dragItem.value
const currentGoal = goalStore.currentGoal
// Only allow reorder within the same parent scope
if (src.parent_id !== targetStep.parent_id) {
dragItem.value = null
return
}
const siblingSteps = currentGoal.steps.filter(
(s: GoalStep) => s.parent_id === targetStep.parent_id && s.step_type === targetStep.step_type
)
const children = targetStep.parent_id
? (currentGoal.steps.find((s: GoalStep) => s.id === targetStep.parent_id)?.children || [])
: []
// Build ordered list: remove dragged item, insert at target position
const ordered = siblingSteps.filter((s: GoalStep) => s.id !== src.id)
const targetIdx = ordered.findIndex((s: GoalStep) => s.id === targetStep.id)
ordered.splice(targetIdx, 0, currentGoal.steps.find((s: GoalStep) => s.id === src.id)!)
// Assign new sort_order values
const items = ordered.map((s: GoalStep, i: number) => ({ id: s.id, sort_order: i }))
// Optimistic local update
for (const item of items) {
const step = currentGoal.steps.find((s: GoalStep) => s.id === item.id)
if (step) (step as any).sort_order = item.sort_order
}
// Persist to backend
try {
await reorderSteps(currentGoal.id, items)
} catch {
ElMessage.error('排序更新失败')
await goalStore.fetchGoal(currentGoal.id)
}
dragItem.value = null
}
function onDragEnd() {
dragItem.value = null
dragOverId.value = null
}
const statusLabel: Record<string, string> = {
active: '进行中', paused: '已暂停', completed: '已完成', abandoned: '已放弃',
}
@@ -197,7 +280,16 @@ const statusLabel: Record<string, string> = {
<div v-for="step in goalStore.currentGoal.steps" :key="step.id" class="step-tree">
<!-- Phase -->
<div v-if="step.step_type === 'phase'" class="phase-node">
<div class="step-row" :class="step.status">
<div
class="step-row"
:class="[step.status, { 'drag-over': dragOverId === step.id, 'dragging': dragItem?.id === step.id }]"
draggable="true"
@dragstart="onDragStart(step)"
@dragover="onDragOver($event, step)"
@dragleave="onDragLeave"
@drop="onDrop(step)"
@dragend="onDragEnd"
>
<el-button
text
class="toggle-btn"
@@ -219,7 +311,16 @@ const statusLabel: Record<string, string> = {
</div>
<!-- child milestones -->
<div v-for="child in step.children" :key="child.id" class="child-milestone">
<div class="step-row" :class="child.status">
<div
class="step-row"
:class="[child.status, { 'drag-over': dragOverId === child.id, 'dragging': dragItem?.id === child.id }]"
draggable="true"
@dragstart="onDragStart(child)"
@dragover="onDragOver($event, child)"
@dragleave="onDragLeave"
@drop="onDrop(child)"
@dragend="onDragEnd"
>
<el-button
text
class="toggle-btn"
@@ -243,7 +344,17 @@ const statusLabel: Record<string, string> = {
</div>
<!-- Standalone milestone (no parent phase) -->
<div v-else class="step-row standalone" :class="step.status">
<div
v-else-if="!hasPhaseParent(step)"
class="step-row standalone"
:class="[step.status, { 'drag-over': dragOverId === step.id, 'dragging': dragItem?.id === step.id }]"
draggable="true"
@dragstart="onDragStart(step)"
@dragover="onDragOver($event, step)"
@dragleave="onDragLeave"
@drop="onDrop(step)"
@dragend="onDragEnd"
>
<el-button
text
class="toggle-btn"
@@ -420,10 +531,15 @@ section {
gap: 10px;
padding: 8px 4px;
border-radius: 6px;
transition: background 0.15s;
transition: background 0.15s, opacity 0.15s;
cursor: grab;
&:hover { background: #fafafa; }
&:active { cursor: grabbing; }
.toggle-btn { padding: 4px; }
&.dragging { opacity: 0.4; }
&.drag-over { background: #e6f7ff; border: 1px dashed #69b1ff; }
.toggle-btn { padding: 4px; flex-shrink: 0; cursor: pointer; }
.step-title { flex: 1; font-size: 14px; }
.step-date { font-size: 12px; color: #bbb; }
}

View File

@@ -17,7 +17,7 @@ const error = ref('')
const redirect = (route.query.redirect as string) || '/'
async function handleLogin() {
if (!password.value) return
if (!password.value || loading.value) return
loading.value = true
error.value = ''
try {

View File

@@ -6,8 +6,10 @@ import { useTaskStore } from '@/stores/useTaskStore'
import { useCategoryStore } from '@/stores/useCategoryStore'
import { useTagStore } from '@/stores/useTagStore'
import { useHabitStore } from '@/stores/useHabitStore'
import { useGoalStore } from '@/stores/useGoalStore'
import { useAnniversaryStore } from '@/stores/useAnniversaryStore'
import { useSyncStore, type SyncDirection } from '@/stores/useSyncStore'
import { get, post, del } from '@/api/request'
import { exportBackup, importBackup } from '@/api/backup'
import type { Task, Category, Tag, HabitGroup, Habit } from '@/api/types'
const userStore = useUserSettingsStore()
@@ -15,10 +17,13 @@ const taskStore = useTaskStore()
const categoryStore = useCategoryStore()
const tagStore = useTagStore()
const habitStore = useHabitStore()
const goalStore = useGoalStore()
const anniversaryStore = useAnniversaryStore()
const syncStore = useSyncStore()
const saving = ref(false)
const exporting = ref(false)
const importing = ref(false)
const viewOptions = [
{ label: '列表', value: 'list' },
@@ -135,32 +140,14 @@ async function startSync() {
async function exportData() {
exporting.value = true
try {
const [tasks, categories, tags, habitGroups, habits] = await Promise.all([
get<Task[]>('/tasks'),
get<Category[]>('/categories'),
get<Tag[]>('/tags'),
get<HabitGroup[]>('/habit-groups'),
get<Habit[]>('/habits', { params: { include_archived: true } })
])
const exportObj = {
version: 2,
exportedAt: new Date().toISOString(),
tasks,
categories,
tags,
habitGroups,
habits
}
const blob = new Blob([JSON.stringify(exportObj, null, 2)], { type: 'application/json' })
const blob = await exportBackup()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `todo-backup-${new Date().toISOString().slice(0, 10)}.json`
const now = new Date().toISOString().slice(0, 19).replace(/:/g, '-')
a.download = `elysia-backup-${now}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('数据导出成功~')
} catch {
ElMessage.error('导出失败了呢~')
@@ -169,168 +156,46 @@ async function exportData() {
}
}
const importFileRef = ref<HTMLInputElement>()
function importData() {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json'
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
importFileRef.value?.click()
}
try {
await ElMessageBox.confirm(
'导入数据会覆盖现有的所有任务、分类、标签和习惯数据,确定要继续吗?',
'确认导入',
{ confirmButtonText: '确定导入', cancelButtonText: '取消', type: 'warning' }
)
async function handleImportFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const text = await file.text()
const data = JSON.parse(text)
try {
await ElMessageBox.confirm(
'导入数据会覆盖当前的所有数据(包括任务、分类、标签、习惯、纪念日、目标等),确定要继续吗?',
'确认导入',
{ confirmButtonText: '确定导入', cancelButtonText: '取消', type: 'warning' }
)
if (!data.tasks || !Array.isArray(data.tasks)) {
ElMessage.error('数据格式不正确呢~')
return
}
importing.value = true
const result = await importBackup(file)
ElMessage.success(`数据导入成功~ 共导入 ${result.count} 条记录`)
// 先删除所有现有数据
const allTasks = await get<Task[]>('/tasks')
for (const t of allTasks) {
await del(`/tasks/${t.id}`)
}
const allCategories = await get<Category[]>('/categories')
for (const c of allCategories) {
await del(`/categories/${c.id}`)
}
const allTags = await get<Tag[]>('/tags')
for (const t of allTags) {
await del(`/tags/${t.id}`)
}
// 删除习惯数据(如果有的话)
if (data.habits && Array.isArray(data.habits)) {
const allHabits = await get<Habit[]>('/habits', { params: { include_archived: true } })
for (const h of allHabits) {
await del(`/habits/${h.id}`)
}
const allGroups = await get<HabitGroup[]>('/habit-groups')
for (const g of allGroups) {
await del(`/habit-groups/${g.id}`)
}
}
// 重新导入
if (data.categories && Array.isArray(data.categories)) {
for (const cat of data.categories) {
await post('/categories', { name: cat.name, color: cat.color, icon: cat.icon })
}
}
if (data.tags && Array.isArray(data.tags)) {
for (const tag of data.tags) {
await post('/tags', { name: tag.name })
}
}
if (data.tasks && Array.isArray(data.tasks)) {
// 建立新旧ID到名称的映射
const oldCatMap = new Map<number, string>()
const oldTagMap = new Map<number, string>()
if (data.categories) {
data.categories.forEach((c: Category) => oldCatMap.set(c.id, c.name))
}
if (data.tags) {
data.tags.forEach((t: Tag) => oldTagMap.set(t.id, t.name))
}
// 获取新建后的分类和标签
const newCategories = await get<Category[]>('/categories')
const newTags = await get<Tag[]>('/tags')
const catNameToId = new Map(newCategories.map(c => [c.name, c.id]))
const tagNameToId = new Map(newTags.map(t => [t.name, t.id]))
for (const task of data.tasks) {
const taskData: Record<string, unknown> = {
title: task.title,
description: task.description || null,
priority: task.priority,
due_date: task.due_date || null
}
if (task.category_id && oldCatMap.has(task.category_id)) {
const catName = oldCatMap.get(task.category_id)
if (catName && catNameToId.has(catName)) {
taskData.category_id = catNameToId.get(catName)!
}
}
const tagIds: number[] = []
if (task.tags && Array.isArray(task.tags)) {
for (const tag of task.tags) {
if (tagNameToId.has(tag.name)) {
tagIds.push(tagNameToId.get(tag.name)!)
}
}
}
taskData.tag_ids = tagIds
await post('/tasks', taskData)
}
}
// 导入习惯数据
if (data.habitGroups && Array.isArray(data.habitGroups)) {
for (const grp of data.habitGroups) {
await post('/habit-groups', { name: grp.name, color: grp.color, icon: grp.icon, sort_order: grp.sort_order })
}
}
if (data.habits && Array.isArray(data.habits)) {
const oldGroupMap = new Map<number, string>()
if (data.habitGroups) {
data.habitGroups.forEach((g: HabitGroup) => oldGroupMap.set(g.id, g.name))
}
const newGroups = await get<HabitGroup[]>('/habit-groups')
const groupNameToId = new Map(newGroups.map(g => [g.name, g.id]))
for (const habit of data.habits) {
const habitData: Record<string, unknown> = {
name: habit.name,
description: habit.description || null,
target_count: habit.target_count || 1,
frequency: habit.frequency || 'daily',
active_days: habit.active_days || null,
is_archived: habit.is_archived || false
}
if (habit.group_id && oldGroupMap.has(habit.group_id)) {
const grpName = oldGroupMap.get(habit.group_id)
if (grpName && groupNameToId.has(grpName)) {
habitData.group_id = groupNameToId.get(grpName)!
}
}
await post('/habits', habitData)
}
}
// 刷新数据
await Promise.all([
taskStore.fetchTasks(),
categoryStore.fetchCategories(),
tagStore.fetchTags()
])
if (data.habits || data.habitGroups) {
await habitStore.init()
}
ElMessage.success('数据导入成功~')
} catch (err) {
if ((err as { toString?: () => string })?.toString?.() !== 'cancel') {
ElMessage.error('导入失败了呢~')
}
// 刷新所有 store
await Promise.all([
taskStore.fetchTasks(),
categoryStore.fetchCategories(),
tagStore.fetchTags(),
habitStore.init(),
goalStore.fetchGoals(),
anniversaryStore.fetchCategories(),
anniversaryStore.fetchAnniversaries(),
])
} catch (err: any) {
if (err?.toString?.() !== 'cancel') {
ElMessage.error(err?.response?.data?.detail || '导入失败了呢~')
}
} finally {
importing.value = false
// 重置 file input允许重新选择同一文件
if (importFileRef.value) importFileRef.value.value = ''
}
input.click()
}
async function clearCompleted() {
@@ -458,7 +323,7 @@ async function clearCompleted() {
<div class="data-action-item">
<div class="action-info">
<span class="action-title">导出数据</span>
<span class="action-desc">将所有任务分类标签和习惯导出为 JSON 文件</span>
<span class="action-desc">将所有数据任务目标习惯纪念日分类标签导出为 JSON 文件</span>
</div>
<el-button
:loading="exporting"
@@ -473,9 +338,17 @@ async function clearCompleted() {
<div class="data-action-item">
<div class="action-info">
<span class="action-title">导入数据</span>
<span class="action-desc warning"> JSON 文件恢复数据会覆盖有数据</span>
<span class="action-desc warning"> JSON 备份文件恢复全部数据会覆盖当前所有数据</span>
</div>
<input
ref="importFileRef"
type="file"
accept=".json"
style="display:none"
@change="handleImportFile"
/>
<el-button
:loading="importing"
@click="importData"
class="action-btn"
>

View File

@@ -27,6 +27,7 @@ function validate(): string | null {
}
async function handleSetup() {
if (loading.value) return
const msg = validate()
if (msg) {
error.value = msg