release: Elysia ToDo v1.0.0
鍏ㄦ爤涓汉淇℃伅绠$悊搴旂敤锛岄泦鎴愬緟鍔炰换鍔°€佷範鎯墦鍗°€佺邯蹇垫棩鎻愰啋銆佽祫浜ф€昏鍔熻兘銆 Made-with: Cursor
This commit is contained in:
669
WebUI/src/views/SettingsView.vue
Normal file
669
WebUI/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,669 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user