670 lines
18 KiB
Vue
670 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { useUserSettingsStore } from '@/stores/useUserSettingsStore'
|
||
import { useTaskStore } from '@/stores/useTaskStore'
|
||
import { useCategoryStore } from '@/stores/useCategoryStore'
|
||
import { useTagStore } from '@/stores/useTagStore'
|
||
import { useHabitStore } from '@/stores/useHabitStore'
|
||
import { get, post, del } from '@/api/request'
|
||
import type { Task, Category, Tag, HabitGroup, Habit } from '@/api/types'
|
||
|
||
const userStore = useUserSettingsStore()
|
||
const taskStore = useTaskStore()
|
||
const categoryStore = useCategoryStore()
|
||
const tagStore = useTagStore()
|
||
const habitStore = useHabitStore()
|
||
|
||
const saving = ref(false)
|
||
const exporting = ref(false)
|
||
|
||
const viewOptions = [
|
||
{ label: '列表', value: 'list' },
|
||
{ label: '日历', value: 'calendar' },
|
||
{ label: '四象限', value: 'quadrant' }
|
||
]
|
||
|
||
const sortByOptions = [
|
||
{ label: '创建时间', value: 'created_at' },
|
||
{ label: '截止日期', value: 'due_date' },
|
||
{ label: '优先级', value: 'priority' }
|
||
]
|
||
|
||
const sortOrderOptions = [
|
||
{ label: '降序 (新到旧)', value: 'desc' },
|
||
{ label: '升序 (旧到新)', value: 'asc' }
|
||
]
|
||
|
||
const prefs = ref({
|
||
site_name: '爱莉希雅待办',
|
||
default_view: 'list',
|
||
default_sort_by: 'priority',
|
||
default_sort_order: 'desc'
|
||
})
|
||
|
||
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'
|
||
})
|
||
|
||
async function handleSave() {
|
||
saving.value = true
|
||
try {
|
||
await userStore.updateSettings({
|
||
site_name: prefs.value.site_name,
|
||
default_view: prefs.value.default_view,
|
||
default_sort_by: prefs.value.default_sort_by,
|
||
default_sort_order: prefs.value.default_sort_order
|
||
})
|
||
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'
|
||
})
|
||
|
||
ElMessage.success('偏好设置已保存~')
|
||
} catch {
|
||
ElMessage.error('保存失败了呢,请稍后再试~')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
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 url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `todo-backup-${new Date().toISOString().slice(0, 10)}.json`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
|
||
ElMessage.success('数据导出成功~')
|
||
} catch {
|
||
ElMessage.error('导出失败了呢~')
|
||
} finally {
|
||
exporting.value = false
|
||
}
|
||
}
|
||
|
||
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
|
||
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
'导入数据会覆盖现有的所有任务、分类、标签和习惯数据,确定要继续吗?',
|
||
'确认导入',
|
||
{ confirmButtonText: '确定导入', cancelButtonText: '取消', type: 'warning' }
|
||
)
|
||
|
||
const text = await file.text()
|
||
const data = JSON.parse(text)
|
||
|
||
if (!data.tasks || !Array.isArray(data.tasks)) {
|
||
ElMessage.error('数据格式不正确呢~')
|
||
return
|
||
}
|
||
|
||
// 先删除所有现有数据
|
||
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('导入失败了呢~')
|
||
}
|
||
}
|
||
}
|
||
input.click()
|
||
}
|
||
|
||
async function clearCompleted() {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
'确定要清除所有已完成的任务吗?这个操作不可撤销哦~',
|
||
'确认清除',
|
||
{ confirmButtonText: '确定清除', cancelButtonText: '取消', type: 'warning' }
|
||
)
|
||
|
||
const completed = taskStore.completedTasks
|
||
for (const task of completed) {
|
||
await del(`/tasks/${task.id}`)
|
||
}
|
||
|
||
await taskStore.fetchTasks()
|
||
ElMessage.success(`已清除 ${completed.length} 个已完成的任务~`)
|
||
} catch {
|
||
// 用户取消
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="settings-page">
|
||
<div class="settings-container">
|
||
<!-- 应用偏好 -->
|
||
<div class="settings-card">
|
||
<div class="card-header">
|
||
<div class="card-icon">
|
||
<el-icon :size="24"><Brush /></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-input
|
||
v-model="prefs.site_name"
|
||
placeholder="爱莉希雅待办"
|
||
maxlength="50"
|
||
style="width: 200px"
|
||
/>
|
||
</div>
|
||
|
||
<div class="setting-item">
|
||
<div class="setting-label">
|
||
<span class="label-text">默认视图</span>
|
||
<span class="label-desc">打开应用时首先显示的页面</span>
|
||
</div>
|
||
<el-select v-model="prefs.default_view" style="width: 180px">
|
||
<el-option
|
||
v-for="opt in viewOptions"
|
||
:key="opt.value"
|
||
:label="opt.label"
|
||
:value="opt.value"
|
||
/>
|
||
</el-select>
|
||
</div>
|
||
|
||
<div class="setting-item">
|
||
<div class="setting-label">
|
||
<span class="label-text">默认排序方式</span>
|
||
<span class="label-desc">任务列表的默认排序依据</span>
|
||
</div>
|
||
<el-select v-model="prefs.default_sort_by" style="width: 180px">
|
||
<el-option
|
||
v-for="opt in sortByOptions"
|
||
:key="opt.value"
|
||
:label="opt.label"
|
||
:value="opt.value"
|
||
/>
|
||
</el-select>
|
||
</div>
|
||
|
||
<div class="setting-item">
|
||
<div class="setting-label">
|
||
<span class="label-text">默认排序顺序</span>
|
||
<span class="label-desc">列表的默认排列方向</span>
|
||
</div>
|
||
<el-select v-model="prefs.default_sort_order" style="width: 180px">
|
||
<el-option
|
||
v-for="opt in sortOrderOptions"
|
||
:key="opt.value"
|
||
:label="opt.label"
|
||
:value="opt.value"
|
||
/>
|
||
</el-select>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<el-button
|
||
type="primary"
|
||
:loading="saving"
|
||
@click="handleSave"
|
||
class="save-btn"
|
||
>
|
||
保存偏好
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 数据管理 -->
|
||
<div class="settings-card">
|
||
<div class="card-header">
|
||
<div class="card-icon">
|
||
<el-icon :size="24"><FolderOpened /></el-icon>
|
||
</div>
|
||
<div>
|
||
<h3 class="card-title">数据管理</h3>
|
||
<p class="card-subtitle">备份、恢复和清理你的数据</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card-body">
|
||
<div class="data-actions">
|
||
<div class="data-action-item">
|
||
<div class="action-info">
|
||
<span class="action-title">导出数据</span>
|
||
<span class="action-desc">将所有任务、分类、标签和习惯导出为 JSON 文件</span>
|
||
</div>
|
||
<el-button
|
||
:loading="exporting"
|
||
@click="exportData"
|
||
class="action-btn"
|
||
>
|
||
<el-icon><Download /></el-icon>
|
||
导出
|
||
</el-button>
|
||
</div>
|
||
|
||
<div class="data-action-item">
|
||
<div class="action-info">
|
||
<span class="action-title">导入数据</span>
|
||
<span class="action-desc warning">从 JSON 文件恢复数据(会覆盖现有数据)</span>
|
||
</div>
|
||
<el-button
|
||
@click="importData"
|
||
class="action-btn"
|
||
>
|
||
<el-icon><Upload /></el-icon>
|
||
导入
|
||
</el-button>
|
||
</div>
|
||
|
||
<div class="data-action-item">
|
||
<div class="action-info">
|
||
<span class="action-title">清除已完成任务</span>
|
||
<span class="action-desc danger">删除所有标记为已完成的任务(不可撤销)</span>
|
||
</div>
|
||
<el-button
|
||
type="danger"
|
||
plain
|
||
@click="clearCompleted"
|
||
class="action-btn"
|
||
>
|
||
<el-icon><Delete /></el-icon>
|
||
清除
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
.settings-page {
|
||
min-height: calc(100vh - 60px);
|
||
padding: 32px;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.settings-container {
|
||
width: 100%;
|
||
max-width: 640px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24px;
|
||
}
|
||
|
||
.settings-card {
|
||
background: white;
|
||
border-radius: var(--radius-xl);
|
||
box-shadow: var(--shadow-md);
|
||
overflow: hidden;
|
||
animation: fadeInUp 0.4s ease;
|
||
|
||
&:nth-child(2) {
|
||
animation-delay: 0.1s;
|
||
animation-fill-mode: both;
|
||
}
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 24px 28px;
|
||
border-bottom: 1px solid rgba(255, 183, 197, 0.15);
|
||
|
||
.card-icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 12px;
|
||
background: linear-gradient(135deg, rgba(255, 183, 197, 0.2) 0%, rgba(200, 162, 200, 0.2) 100%);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--primary);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
margin: 0 0 4px;
|
||
}
|
||
|
||
.card-subtitle {
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
margin: 0;
|
||
}
|
||
}
|
||
|
||
.card-body {
|
||
padding: 24px 28px;
|
||
}
|
||
|
||
.setting-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px 0;
|
||
border-bottom: 1px solid rgba(255, 183, 197, 0.08);
|
||
|
||
&:last-of-type {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.setting-label {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
flex: 1;
|
||
margin-right: 24px;
|
||
|
||
.label-text {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.label-desc {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
}
|
||
}
|
||
}
|
||
|
||
.form-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 16px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid rgba(255, 183, 197, 0.15);
|
||
|
||
.save-btn {
|
||
min-width: 120px;
|
||
height: 40px;
|
||
border-radius: var(--radius-md);
|
||
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||
border: none;
|
||
font-weight: 500;
|
||
|
||
&:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 12px rgba(255, 183, 197, 0.4);
|
||
}
|
||
}
|
||
}
|
||
|
||
.data-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.data-action-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px 0;
|
||
border-bottom: 1px solid rgba(255, 183, 197, 0.08);
|
||
|
||
&:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.action-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
flex: 1;
|
||
margin-right: 24px;
|
||
|
||
.action-title {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.action-desc {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
|
||
&.warning {
|
||
color: var(--warning);
|
||
}
|
||
|
||
&.danger {
|
||
color: var(--danger);
|
||
}
|
||
}
|
||
}
|
||
|
||
.action-btn {
|
||
min-width: 100px;
|
||
border-radius: var(--radius-md);
|
||
flex-shrink: 0;
|
||
}
|
||
}
|
||
|
||
@keyframes fadeInUp {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(20px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.settings-page {
|
||
padding: 16px;
|
||
}
|
||
|
||
.card-header,
|
||
.card-body {
|
||
padding: 16px;
|
||
}
|
||
|
||
.setting-item {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
|
||
.setting-label {
|
||
margin-right: 0;
|
||
}
|
||
|
||
:deep(.el-select) {
|
||
width: 100% !important;
|
||
}
|
||
}
|
||
|
||
.data-action-item {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
|
||
.action-info {
|
||
margin-right: 0;
|
||
}
|
||
|
||
.action-btn {
|
||
width: 100%;
|
||
}
|
||
}
|
||
}
|
||
</style>
|