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:
13
WebUI/src/api/backup.ts
Normal file
13
WebUI/src/api/backup.ts
Normal 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' },
|
||||
})
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -27,6 +27,7 @@ function validate(): string | null {
|
||||
}
|
||||
|
||||
async function handleSetup() {
|
||||
if (loading.value) return
|
||||
const msg = validate()
|
||||
if (msg) {
|
||||
error.value = msg
|
||||
|
||||
Reference in New Issue
Block a user