release: Elysia ToDo v1.0.0

鍏ㄦ爤涓汉淇℃伅绠$悊搴旂敤锛岄泦鎴愬緟鍔炰换鍔°€佷範鎯墦鍗°€佺邯蹇垫棩鎻愰啋銆佽祫浜ф€昏鍔熻兘銆

Made-with: Cursor
This commit is contained in:
祀梦
2026-03-14 22:21:26 +08:00
commit 2979197b1c
104 changed files with 21737 additions and 0 deletions

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