Files
ToDoList/WebUI/src/views/SettingsView.vue
祀梦 2979197b1c release: Elysia ToDo v1.0.0
鍏ㄦ爤涓汉淇℃伅绠$悊搴旂敤锛岄泦鎴愬緟鍔炰换鍔°€佷範鎯墦鍗°€佺邯蹇垫棩鎻愰啋銆佽祫浜ф€昏鍔熻兘銆

Made-with: Cursor
2026-03-14 22:21:26 +08:00

670 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>