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

74
.gitignore vendored Normal file
View File

@@ -0,0 +1,74 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
ENV/
env/
.venv
# Database
*.db
*.sqlite
*.sqlite3
# Logs
logs/
*.log
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Node
node_modules/
# Frontend build output
api/webui/assets/
WebUI/dist/
# API runtime data
api/data/
api/logs/
# Test cache
.pytest_cache/
.coverage
htmlcov/
# Temporary files
*.tmp
*.bak
*.swp
todo_check_temp.*
check_*.py
# Environment variables
.env
.env.*

1580
API_DOCS.md Normal file

File diff suppressed because it is too large Load Diff

173
README.md Normal file
View File

@@ -0,0 +1,173 @@
# Elysia ToDo - 爱莉希雅待办事项
一款全栈个人信息管理应用,集待办任务、习惯打卡、纪念日提醒、资产总览于一体。
## 功能概览
### 任务管理
- **待办列表** — 创建、编辑、删除任务,支持分类、标签、优先级
- **四象限视图** — 基于艾森豪威尔矩阵(重要/紧急)的四象限优先级模型
- **日历视图** — 按月/周/日查看任务排布
- **拼音搜索** — 支持中文拼音快速检索任务和分类
### 习惯打卡
- 习惯分组管理(学习、运动、生活等)
- 每日打卡记录,支持周期配置与休息日
- 周视图打卡进度展示,一目了然
### 纪念日管理
- 自定义纪念日分类
- 支持农历/公历日期
- 倒计时提醒,不错过重要日子
### 资产总览
- 财务账户管理(现金、银行卡、电子钱包等)
- 收支记录与历史查询
- 分期还款跟踪
- 资产汇总统计
### 系统功能
- 偏好设置(站点名称、默认视图等)
- 可折叠侧边栏
- 响应式布局
- SPA 单页应用History 路由模式
## 技术栈
| 层级 | 技术 |
|------|------|
| 前端框架 | Vue 3 + TypeScript |
| UI 组件库 | Element Plus |
| 状态管理 | Pinia |
| 路由 | Vue Router 4 |
| 构建工具 | Vite |
| 后端框架 | FastAPI |
| ORM | SQLAlchemy |
| 数据库 | SQLite |
| ASGI 服务器 | Uvicorn |
## 项目结构
```
ToDoList/
├── main.py # 启动入口(编译前端 + 启动后端)
├── requirements.txt # Python 依赖
├── .gitignore
├── api/ # 后端
│ └── app/
│ ├── config.py # 配置端口、路径、CORS 等)
│ ├── database.py # 数据库引擎与会话管理
│ ├── main.py # FastAPI 应用(路由、中间件、静态文件)
│ ├── models/ # SQLAlchemy 数据模型
│ ├── schemas/ # Pydantic 请求/响应模型
│ ├── routers/ # API 路由
│ └── utils/ # 工具函数CRUD、日志、日期
├── WebUI/ # 前端
│ ├── package.json
│ ├── vite.config.ts
│ └── src/
│ ├── api/ # Axios 接口封装
│ ├── components/ # 通用组件
│ ├── views/ # 页面视图
│ ├── stores/ # Pinia 状态管理
│ ├── router/ # 路由配置
│ ├── styles/ # 全局样式 (SCSS)
│ └── utils/ # 前端工具(拼音、优先级、日期)
└── tests/ # 测试
```
## 快速开始
### 环境要求
- Python 3.10+
- Node.js 18+
- npm
### 安装与运行
```bash
# 1. 克隆项目
git clone <your-repo-url>
cd ToDoList
# 2. 安装 Python 依赖
pip install -r requirements.txt
# 3. 一键启动(自动编译前端 + 启动后端)
python main.py
```
启动后访问:
- 前端页面http://localhost:23994
- API 文档http://localhost:23994/docs
### 前端开发模式
如果需要前后端分离开发(热更新):
```bash
# 终端 1 — 启动后端
cd api
uvicorn app.main:app --host 0.0.0.0 --port 23994
# 终端 2 — 启动前端开发服务器
cd WebUI
npm install
npm run dev
```
前端开发服务器运行在 http://localhost:5173已配置 API 代理到后端。
## API 概览
所有接口均以 `/api` 为前缀,详细的请求/响应格式参见 [API_DOCS.md](./API_DOCS.md) 或访问 `/docs` 查看 Swagger 文档。
| 模块 | 前缀 | 说明 |
|------|------|------|
| 任务 | `/api/tasks` | 任务 CRUD、状态切换、批量操作 |
| 分类 | `/api/categories` | 分类 CRUD |
| 标签 | `/api/tags` | 标签 CRUD |
| 习惯 | `/api/habits` | 习惯、习惯组、打卡记录 |
| 纪念日 | `/api/anniversaries` | 纪念日、纪念日分类 |
| 资产 | `/api/accounts` | 账户、交易记录、分期还款 |
| 设置 | `/api/user-settings` | 用户偏好设置 |
| 健康检查 | `/health` | 服务状态检查 |
## 数据模型关系
```
Category ──< Task >── Tag
HabitGroup ──< Habit ──< HabitCheckin
AnniversaryCategory ──< Anniversary
FinancialAccount ──< AccountHistory
──< DebtInstallment
UserSettings (单例)
```
## 配置说明
`api/app/config.py` 中可以修改:
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| HOST | `0.0.0.0` | 监听地址 |
| PORT | `23994` | 服务端口 |
| DATABASE_PATH | `api/data/todo.db` | SQLite 数据库路径 |
| WEBUI_PATH | `api/webui` | 前端静态文件目录 |
| CORS_ORIGINS | `localhost:5173, 23994` | 允许的跨域来源 |
数据库文件和日志文件会在首次运行时自动创建,无需手动初始化。
## 部署
项目支持在 Windows 和 Linux 上运行。`main.py` 会自动处理平台差异npm 命令、端口占用检测等)。
对于生产环境部署,建议:
- 使用 `gunicorn` + `uvicorn worker` 替代直接运行
- 配置反向代理Nginx
- 数据库可替换为 PostgreSQL 或 MySQL修改 `config.py` 中的 `DATABASE_URL`

24
WebUI/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
WebUI/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
WebUI/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2499
WebUI/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
WebUI/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "webui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "vue-tsc -b --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.6",
"element-plus": "^2.13.5",
"pinia": "^3.0.4",
"pinyin-pro": "^3.28.0",
"sass": "^1.98.0",
"vue": "^3.5.25",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vue-tsc": "^3.1.5"
}
}

1
WebUI/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

154
WebUI/src/App.vue Normal file
View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTaskStore } from '@/stores/useTaskStore'
import { useCategoryStore } from '@/stores/useCategoryStore'
import { useTagStore } from '@/stores/useTagStore'
import { useUIStore } from '@/stores/useUIStore'
import { useUserSettingsStore } from '@/stores/useUserSettingsStore'
import AppHeader from '@/components/AppHeader.vue'
import TaskDialog from '@/components/TaskDialog.vue'
import CategoryDialog from '@/components/CategoryDialog.vue'
const route = useRoute()
const router = useRouter()
const taskStore = useTaskStore()
const categoryStore = useCategoryStore()
const tagStore = useTagStore()
const uiStore = useUIStore()
const userSettingsStore = useUserSettingsStore()
// 路由变化时同步 currentView
watch(() => route.meta.view, (view) => {
if (view) {
uiStore.setCurrentView(view as 'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries' | 'assets')
}
}, { immediate: true })
// 网站名称变化时更新页面标题
watch(() => userSettingsStore.siteName, (name) => {
const page = (route.meta.title as string) || ''
document.title = page ? `${page} - ${name}` : name
})
onMounted(async () => {
await userSettingsStore.fetchAndSync()
// 根据用户设置初始化默认排序
taskStore.setFilters({
sort_by: userSettingsStore.defaultSortBy as 'priority' | 'due_date' | 'created_at',
sort_order: userSettingsStore.defaultSortOrder as 'asc' | 'desc'
})
// 首次访问根路径时根据设置跳转到默认视图
if (route.path === '/') {
const viewMap: Record<string, string> = {
list: '/tasks',
calendar: '/calendar',
quadrant: '/quadrant'
}
const target = viewMap[userSettingsStore.defaultView] || '/tasks'
router.replace(target)
}
await Promise.all([
taskStore.fetchTasks(),
categoryStore.fetchCategories(),
tagStore.fetchTags()
])
})
</script>
<template>
<div class="app-container">
<div class="decoration-star" style="top: 20%; right: 8%; animation-delay: 0.5s;"></div>
<div class="decoration-star" style="top: 60%; left: 3%; animation-delay: 1s;"></div>
<div class="decoration-star" style="top: 80%; right: 5%; animation-delay: 1.5s;"></div>
<AppHeader />
<div class="app-main">
<main
class="main-content"
:class="{
'full-width': uiStore.currentView !== 'list'
}"
>
<router-view v-slot="{ Component }">
<Transition name="view-fade" mode="out-in">
<component :is="Component" />
</Transition>
</router-view>
</main>
</div>
<div v-if="uiStore.currentView !== 'settings' && uiStore.currentView !== 'profile' && uiStore.currentView !== 'habits' && uiStore.currentView !== 'anniversaries' && uiStore.currentView !== 'assets'" class="fab-container">
<el-button
type="primary"
circle
size="large"
class="add-btn btn-glow"
@click="uiStore.openTaskDialog()"
>
<el-icon :size="24"><Plus /></el-icon>
</el-button>
</div>
<TaskDialog />
<CategoryDialog />
</div>
</template>
<style scoped lang="scss">
.app-container {
min-height: 100vh;
position: relative;
}
.app-main {
margin-top: 60px;
min-height: calc(100vh - 60px);
}
.main-content {
padding: 0;
&.full-width {
padding: 0;
}
}
.fab-container {
position: fixed;
bottom: 32px;
right: 32px;
z-index: 100;
.add-btn {
width: 60px;
height: 60px;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
border: none;
box-shadow: var(--shadow-lg);
&:hover {
transform: scale(1.1);
}
}
}
.view-fade-enter-active,
.view-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.view-fade-enter-from {
opacity: 0;
transform: translateY(10px);
}
.view-fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>

64
WebUI/src/api/accounts.ts Normal file
View File

@@ -0,0 +1,64 @@
import { get, post, put, del, patch } from './request'
import type {
FinancialAccount, AccountFormData, BalanceUpdateData,
AccountHistoryResponse, DebtInstallment, DebtInstallmentFormData
} from './types'
export interface GetAccountHistoryParams {
page?: number
page_size?: number
}
export const accountApi = {
// ============ 账户 CRUD ============
getAccounts(): Promise<FinancialAccount[]> {
return get<FinancialAccount[]>('/accounts')
},
getAccount(id: number): Promise<FinancialAccount> {
return get<FinancialAccount>(`/accounts/${id}`)
},
createAccount(data: AccountFormData): Promise<FinancialAccount> {
return post<FinancialAccount>('/accounts', data)
},
updateAccount(id: number, data: Partial<AccountFormData>): Promise<FinancialAccount> {
return put<FinancialAccount>(`/accounts/${id}`, data)
},
deleteAccount(id: number): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/accounts/${id}`)
},
// ============ 余额操作 ============
updateBalance(id: number, data: BalanceUpdateData): Promise<FinancialAccount> {
return post<FinancialAccount>(`/accounts/${id}/balance`, data)
},
// ============ 变更历史 ============
getHistory(id: number, params?: GetAccountHistoryParams): Promise<AccountHistoryResponse> {
return get<AccountHistoryResponse>(`/accounts/${id}/history`, { params })
},
// ============ 分期计划 ============
getInstallments(): Promise<DebtInstallment[]> {
return get<DebtInstallment[]>('/debt-installments')
},
createInstallment(data: DebtInstallmentFormData): Promise<DebtInstallment> {
return post<DebtInstallment>('/debt-installments', data)
},
updateInstallment(id: number, data: Partial<DebtInstallmentFormData>): Promise<DebtInstallment> {
return put<DebtInstallment>(`/debt-installments/${id}`, data)
},
deleteInstallment(id: number): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/debt-installments/${id}`)
},
payInstallment(id: number): Promise<DebtInstallment> {
return patch<DebtInstallment>(`/debt-installments/${id}/pay`)
},
}

View File

@@ -0,0 +1,49 @@
import { get, post, put, del } from './request'
import type { Anniversary, AnniversaryFormData, AnniversaryCategory, AnniversaryCategoryFormData } from './types'
export type AnniversaryResponse = Anniversary
export type AnniversaryCategoryResponse = AnniversaryCategory
export interface GetAnniversariesParams {
category_id?: number
}
export const anniversaryApi = {
// ============ 纪念日 ============
getAnniversaries(params?: GetAnniversariesParams): Promise<AnniversaryResponse[]> {
return get<AnniversaryResponse[]>('/anniversaries', { params })
},
getAnniversary(id: number): Promise<AnniversaryResponse> {
return get<AnniversaryResponse>(`/anniversaries/${id}`)
},
createAnniversary(data: AnniversaryFormData): Promise<AnniversaryResponse> {
return post<AnniversaryResponse>('/anniversaries', data)
},
updateAnniversary(id: number, data: Partial<AnniversaryFormData>): Promise<AnniversaryResponse> {
return put<AnniversaryResponse>(`/anniversaries/${id}`, data)
},
deleteAnniversary(id: number): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/anniversaries/${id}`)
},
// ============ 纪念日分类 ============
getCategories(): Promise<AnniversaryCategoryResponse[]> {
return get<AnniversaryCategoryResponse[]>('/anniversary-categories')
},
createCategory(data: AnniversaryCategoryFormData): Promise<AnniversaryCategoryResponse> {
return post<AnniversaryCategoryResponse>('/anniversary-categories', data)
},
updateCategory(id: number, data: Partial<AnniversaryCategoryFormData>): Promise<AnniversaryCategoryResponse> {
return put<AnniversaryCategoryResponse>(`/anniversary-categories/${id}`, data)
},
deleteCategory(id: number): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/anniversary-categories/${id}`)
},
}

View File

@@ -0,0 +1,29 @@
import { get, post, put, del, patch } from './request'
import type { CategoryFormData } from './types'
export interface CategoryResponse {
id: number
name: string
color: string
icon: string
}
export type { CategoryFormData }
export const categoryApi = {
getCategories(): Promise<CategoryResponse[]> {
return get<CategoryResponse[]>('/categories')
},
createCategory(data: CategoryFormData): Promise<CategoryResponse> {
return post<CategoryResponse>('/categories', data)
},
updateCategory(id: number, data: CategoryFormData): Promise<CategoryResponse> {
return put<CategoryResponse>(`/categories/${id}`, data)
},
deleteCategory(id: number): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/categories/${id}`)
}
}

78
WebUI/src/api/habits.ts Normal file
View File

@@ -0,0 +1,78 @@
import { get, post, put, del, patch } from './request'
import type {
Habit, HabitFormData, HabitGroup, HabitGroupFormData,
HabitCheckin, HabitStats
} from './types'
export type HabitResponse = Habit
export type HabitGroupResponse = HabitGroup
export type HabitCheckinResponse = HabitCheckin
export type HabitStatsResponse = HabitStats
export const habitGroupApi = {
getGroups(): Promise<HabitGroupResponse[]> {
return get<HabitGroupResponse[]>('/habit-groups')
},
createGroup(data: HabitGroupFormData): Promise<HabitGroupResponse> {
return post<HabitGroupResponse>('/habit-groups', data)
},
updateGroup(id: number, data: Partial<HabitGroupFormData>): Promise<HabitGroupResponse> {
return put<HabitGroupResponse>(`/habit-groups/${id}`, data)
},
deleteGroup(id: number): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/habit-groups/${id}`)
}
}
export interface GetHabitsParams {
include_archived?: boolean
}
export const habitApi = {
getHabits(params?: GetHabitsParams): Promise<HabitResponse[]> {
return get<HabitResponse[]>('/habits', { params })
},
getHabit(id: number): Promise<HabitResponse> {
return get<HabitResponse>(`/habits/${id}`)
},
createHabit(data: HabitFormData): Promise<HabitResponse> {
return post<HabitResponse>('/habits', data)
},
updateHabit(id: number, data: Partial<HabitFormData>): Promise<HabitResponse> {
return put<HabitResponse>(`/habits/${id}`, data)
},
deleteHabit(id: number): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/habits/${id}`)
},
toggleArchive(id: number): Promise<HabitResponse> {
return patch<HabitResponse>(`/habits/${id}/archive`, {})
},
getCheckins(habitId: number, fromDate?: string, toDate?: string): Promise<HabitCheckinResponse[]> {
const params: Record<string, string> = {}
if (fromDate) params.from_date = fromDate
if (toDate) params.to_date = toDate
return get<HabitCheckinResponse[]>(`/habits/${habitId}/checkins`, { params })
},
checkin(habitId: number, count?: number): Promise<HabitCheckinResponse> {
const data = count !== undefined ? { count } : {}
return post<HabitCheckinResponse>(`/habits/${habitId}/checkins`, data)
},
cancelCheckin(habitId: number, count: number = 1): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/habits/${habitId}/checkins`, { params: { count } })
},
getStats(habitId: number): Promise<HabitStatsResponse> {
return get<HabitStatsResponse>(`/habits/${habitId}/checkins/stats`)
}
}

72
WebUI/src/api/request.ts Normal file
View File

@@ -0,0 +1,72 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
const instance: AxiosInstance = axios.create({
baseURL: '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
instance.interceptors.response.use(
(response: AxiosResponse) => {
return response
},
(error) => {
let message = '服务器开小差了,请稍后再试~'
if (error.response) {
const data = error.response.data
if (data?.detail) {
message = Array.isArray(data.detail)
? data.detail.map((d: any) => d.msg || d.loc?.join('.')).join('; ')
: data.detail
}
switch (error.response.status) {
case 400:
message = data?.detail || '请求参数有误,请检查一下~'
break
case 401:
message = '登录状态已失效~'
break
case 403:
message = '没有权限访问呢~'
break
case 404:
message = '请求的资源不存在~'
break
case 500:
message = '服务器内部错误~'
break
}
}
ElMessage.error({
message,
duration: 3000,
showClose: true
})
return Promise.reject(error)
}
)
export default instance
export function get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
return instance.get(url, config).then((res) => res.data)
}
export function post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return instance.post(url, data, config).then((res) => res.data)
}
export function put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return instance.put(url, data, config).then((res) => res.data)
}
export function del<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
return instance.delete(url, config).then((res) => res.data)
}
export function patch<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return instance.patch(url, data, config).then((res) => res.data)
}

23
WebUI/src/api/tags.ts Normal file
View File

@@ -0,0 +1,23 @@
import { get, post, del } from './request'
import type { TagFormData } from './types'
export interface TagResponse {
id: number
name: string
}
export type { TagFormData }
export const tagApi = {
getTags(): Promise<TagResponse[]> {
return get<TagResponse[]>('/tags')
},
createTag(data: TagFormData): Promise<TagResponse> {
return post<TagResponse>('/tags', data)
},
deleteTag(id: number): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/tags/${id}`)
}
}

44
WebUI/src/api/tasks.ts Normal file
View File

@@ -0,0 +1,44 @@
import { get, post, put, del, patch } from './request'
import type { TaskFormData } from './types'
export type TaskResponse = import('./types').Task
export type CategoryResponse = import('./types').Category
export type TagResponse = import('./types').Tag
export type { TaskFormData }
export interface GetTasksParams {
status?: string
category_id?: number
priority?: string
sort_by?: string
sort_order?: string
skip?: number
limit?: number
}
export const taskApi = {
getTasks(params?: GetTasksParams): Promise<TaskResponse[]> {
return get<TaskResponse[]>('/tasks', { params })
},
getTask(id: number): Promise<TaskResponse> {
return get<TaskResponse>(`/tasks/${id}`)
},
createTask(data: TaskFormData): Promise<TaskResponse> {
return post<TaskResponse>('/tasks', data)
},
updateTask(id: number, data: TaskFormData): Promise<TaskResponse> {
return put<TaskResponse>(`/tasks/${id}`, data)
},
deleteTask(id: number): Promise<{ success: boolean; message?: string }> {
return del<{ success: boolean; message?: string }>(`/tasks/${id}`)
},
toggleTask(id: number): Promise<TaskResponse> {
return patch<TaskResponse>(`/tasks/${id}/toggle`, {})
}
}

278
WebUI/src/api/types.ts Normal file
View File

@@ -0,0 +1,278 @@
export type QuadrantPriority = 'q1' | 'q2' | 'q3' | 'q4'
export interface Task {
id: number
title: string
description?: string
priority: QuadrantPriority
due_date?: string
is_completed: boolean
category_id?: number
category?: Category
tags?: Tag[]
created_at: string
updated_at: string
}
export interface Category {
id: number
name: string
color: string
icon: string
}
export interface Tag {
id: number
name: string
}
export interface TaskFormData {
title: string
description?: string
priority: QuadrantPriority
due_date?: string
category_id?: number
tag_ids?: number[]
}
export interface CategoryFormData {
name: string
color: string
icon: string
}
export interface TagFormData {
name: string
}
export interface TaskFilters {
status?: 'all' | 'active' | 'completed'
category_id?: number
sort_by?: 'priority' | 'due_date' | 'created_at'
sort_order?: 'asc' | 'desc'
search?: string
}
export interface UserSettings {
id: number
nickname: string
avatar?: string
signature?: string
birthday?: string
email?: string
site_name: string
theme: string
language: string
default_view: string
default_sort_by: string
default_sort_order: string
created_at: string
updated_at: string
}
export interface UserSettingsUpdate {
nickname?: string
avatar?: string
signature?: string
birthday?: string
email?: string
site_name?: string
theme?: string
language?: string
default_view?: string
default_sort_by?: string
default_sort_order?: string
}
// ============ 习惯相关 ============
export interface HabitGroup {
id: number
name: string
color: string
icon: string
sort_order: number
}
export interface HabitGroupFormData {
name: string
color: string
icon: string
sort_order?: number
}
export type HabitFrequency = 'daily' | 'weekly'
export interface Habit {
id: number
name: string
description?: string
group_id?: number
target_count: number
frequency: HabitFrequency
active_days?: string
is_archived: boolean
created_at: string
updated_at: string
group?: HabitGroup
}
export interface HabitFormData {
name: string
description?: string
group_id?: number | null
target_count: number
frequency: HabitFrequency
active_days?: string | null
}
export interface HabitCheckin {
id: number
habit_id: number
checkin_date: string
count: number
created_at: string
}
export interface HabitStats {
total_days: number
current_streak: number
longest_streak: number
today_count: number
today_completed: boolean
}
// ============ 纪念日相关 ============
export interface AnniversaryCategory {
id: number
name: string
icon: string
color: string
sort_order: number
}
export interface AnniversaryCategoryFormData {
name: string
icon: string
color: string
sort_order?: number
}
export interface Anniversary {
id: number
title: string
date: string
year?: number | null
category_id?: number | null
description?: string | null
is_recurring: boolean
remind_days_before: number
created_at: string
updated_at: string
category?: AnniversaryCategory | null
next_date?: string | null
days_until?: number | null
year_count?: number | null
}
export interface AnniversaryFormData {
title: string
date: string
year?: number | null
category_id?: number | null
description?: string | null
is_recurring: boolean
remind_days_before: number
}
// ============ 资产账户相关 ============
export type AccountType = 'savings' | 'debt'
export interface FinancialAccount {
id: number
name: string
account_type: AccountType
balance: number
icon: string
color: string
sort_order: number
is_active: boolean
description?: string | null
created_at: string
updated_at: string
installments?: InstallmentInfo[]
}
export interface AccountFormData {
name: string
account_type: AccountType
balance: number
icon: string
color: string
sort_order: number
is_active: boolean
description?: string | null
}
export interface BalanceUpdateData {
new_balance: number
note?: string | null
}
export interface InstallmentInfo {
next_payment_date: string | null
days_until_payment: number | null
remaining_periods: number
}
export interface AccountHistoryRecord {
id: number
account_id: number
change_amount: number
balance_before: number
balance_after: number
note?: string | null
created_at: string
}
export interface AccountHistoryResponse {
total: number
page: number
page_size: number
records: AccountHistoryRecord[]
}
// ============ 分期还款计划相关 ============
export interface DebtInstallment {
id: number
account_id: number
total_amount: number
total_periods: number
current_period: number
payment_day: number
payment_amount: number
start_date: string
is_completed: boolean
created_at: string
updated_at: string
next_payment_date: string | null
days_until_payment: number | null
remaining_periods: number | null
account_name: string | null
account_icon: string | null
account_color: string | null
}
export interface DebtInstallmentFormData {
account_id: number
total_amount: number
total_periods: number
current_period: number
payment_day: number
payment_amount: number
start_date: string
is_completed: boolean
}

View File

@@ -0,0 +1,10 @@
import { get, put } from './request'
import type { UserSettings, UserSettingsUpdate } from './types'
export function getUserSettings(): Promise<UserSettings> {
return get<UserSettings>('/user-settings')
}
export function updateUserSettings(data: UserSettingsUpdate): Promise<UserSettings> {
return put<UserSettings>('/user-settings', data)
}

1
WebUI/src/assets/vue.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,361 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAccountStore } from '@/stores/useAccountStore'
import type { FinancialAccount } from '@/api/types'
import { ElMessage } from 'element-plus'
interface Props {
visible: boolean
editAccount?: FinancialAccount | null
}
const props = withDefaults(defineProps<Props>(), {
editAccount: null
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const store = useAccountStore()
const isEdit = computed(() => !!props.editAccount)
const dialogTitle = computed(() => isEdit.value ? '编辑账户' : '新建账户')
const form = ref({
name: '',
account_type: 'savings' as 'savings' | 'debt',
balance: 0,
icon: 'wallet',
color: '#FFB7C5',
is_active: true,
description: ''
})
const iconOptions = [
{ label: '钱包', value: 'wallet' },
{ label: '微信', value: 'wechat' },
{ label: '支付宝', value: 'alipay' },
{ label: '银行卡', value: 'bank' },
{ label: '信用卡', value: 'credit-card' },
{ label: '花呗', value: 'huabei' },
{ label: '白条', value: 'baitiao' },
{ label: '现金', value: 'cash' },
{ label: '投资', value: 'investment' },
{ label: '其他', value: 'other' },
]
const colorOptions = [
'#FFB7C5', '#C8A2C8', '#98D8C8', '#FFB347',
'#87CEEB', '#FF6B6B', '#A8E6CF', '#DDA0DD',
'#F0E68C', '#20B2AA', '#FF8C69', '#9370DB',
]
watch(() => props.visible, (val) => {
if (val) {
if (props.editAccount) {
const a = props.editAccount
form.value = {
name: a.name,
account_type: a.account_type,
balance: a.balance,
icon: a.icon,
color: a.color,
is_active: a.is_active,
description: a.description || ''
}
} else {
form.value = {
name: '',
account_type: 'savings',
balance: 0,
icon: 'wallet',
color: '#FFB7C5',
is_active: true,
description: ''
}
}
}
})
async function handleSave() {
if (!form.value.name.trim()) {
ElMessage.warning('请输入账户名称~')
return
}
const data = {
name: form.value.name.trim(),
account_type: form.value.account_type,
balance: form.value.balance,
icon: form.value.icon,
color: form.value.color,
sort_order: 0,
is_active: form.value.is_active,
description: form.value.description.trim() || null
}
if (isEdit.value && props.editAccount) {
const result = await store.updateAccount(props.editAccount.id, data)
if (result) ElMessage.success('账户更新成功~')
} else {
const result = await store.createAccount(data)
if (result) ElMessage.success('账户创建成功~')
}
emit('update:visible', false)
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="480px"
@close="handleClose"
class="account-dialog"
>
<div class="form-content">
<div class="form-item">
<label class="form-label">账户名称</label>
<el-input
v-model="form.name"
placeholder="如:微信、支付宝、花呗、招商银行..."
maxlength="100"
show-word-limit
/>
</div>
<div class="form-item">
<label class="form-label">账户类型</label>
<div class="type-switch">
<button
class="type-btn"
:class="{ active: form.account_type === 'savings' }"
@click="form.account_type = 'savings'"
>
<el-icon><Wallet /></el-icon>
<span>存款</span>
</button>
<button
class="type-btn"
:class="{ active: form.account_type === 'debt' }"
@click="form.account_type = 'debt'"
>
<el-icon><CreditCard /></el-icon>
<span>欠款</span>
</button>
</div>
</div>
<div class="form-item">
<label class="form-label">
当前余额
<span class="form-hint">{{ form.account_type === 'savings' ? '存款金额' : '欠款金额' }}</span>
</label>
<el-input-number
v-model="form.balance"
:precision="2"
:step="100"
:min="0"
style="width: 100%"
/>
</div>
<div class="form-item">
<label class="form-label">图标</label>
<div class="icon-grid">
<button
v-for="opt in iconOptions"
:key="opt.value"
class="icon-btn"
:class="{ active: form.icon === opt.value }"
@click="form.icon = opt.value"
:title="opt.label"
>
<el-icon :size="18">
<Wallet v-if="opt.value === 'wallet'" />
<ChatDotRound v-else-if="opt.value === 'wechat'" />
<ShoppingCart v-else-if="opt.value === 'alipay'" />
<CreditCard v-else-if="opt.value === 'bank' || opt.value === 'credit-card' || opt.value === 'huabei'" />
<Ticket v-else-if="opt.value === 'baitiao'" />
<Money v-else-if="opt.value === 'cash'" />
<TrendCharts v-else-if="opt.value === 'investment'" />
<MoreFilled v-else />
</el-icon>
</button>
</div>
</div>
<div class="form-item">
<label class="form-label">主题色</label>
<div class="color-grid">
<button
v-for="color in colorOptions"
:key="color"
class="color-btn"
:class="{ active: form.color === color }"
:style="{ background: color }"
@click="form.color = color"
/>
</div>
</div>
<div class="form-item">
<label class="form-label">备注</label>
<el-input
v-model="form.description"
type="textarea"
:rows="2"
placeholder="可选的备注信息"
maxlength="500"
/>
</div>
<div v-if="isEdit" class="form-item">
<label class="form-label">启用状态</label>
<el-switch v-model="form.is_active" />
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.form-content {
padding: 8px 0;
}
.form-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.form-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
.form-hint {
font-size: 12px;
color: var(--text-secondary);
font-weight: 400;
}
}
}
.type-switch {
display: flex;
gap: 12px;
.type-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border: 2px solid rgba(255, 183, 197, 0.2);
border-radius: var(--radius-md);
background: white;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
color: var(--primary);
}
&.active {
border-color: var(--primary);
background: rgba(255, 183, 197, 0.1);
color: var(--primary);
box-shadow: 0 2px 8px rgba(255, 183, 197, 0.2);
}
}
}
.icon-grid {
display: flex;
gap: 8px;
flex-wrap: wrap;
.icon-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid rgba(255, 183, 197, 0.15);
border-radius: var(--radius-sm);
background: white;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
color: var(--primary);
}
&.active {
border-color: var(--primary);
background: rgba(255, 183, 197, 0.1);
color: var(--primary);
}
}
}
.color-grid {
display: flex;
gap: 8px;
flex-wrap: wrap;
.color-btn {
width: 28px;
height: 28px;
border: 2px solid transparent;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
&:hover {
transform: scale(1.15);
}
&.active {
border-color: var(--text-primary);
box-shadow: 0 0 0 2px white, 0 0 0 4px var(--text-primary);
transform: scale(1.1);
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,235 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useAccountStore } from '@/stores/useAccountStore'
import type { FinancialAccount, AccountHistoryRecord } from '@/api/types'
interface Props {
visible: boolean
account: FinancialAccount | null
}
const props = withDefaults(defineProps<Props>(), {
account: null
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const store = useAccountStore()
const records = ref<AccountHistoryRecord[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const loading = ref(false)
async function fetchHistory() {
if (!props.account) return
loading.value = true
const result = await store.fetchHistory(props.account.id, page.value, pageSize)
records.value = result.records
total.value = result.total
loading.value = false
}
watch(() => props.visible, (val) => {
if (val && props.account) {
page.value = 1
fetchHistory()
}
})
function handlePageChange(newPage: number) {
page.value = newPage
fetchHistory()
}
function formatAmount(amount: number): string {
if (amount > 0) return `+${amount.toFixed(2)}`
return amount.toFixed(2)
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="account ? `${account.name} - 变更历史` : '变更历史'"
width="600px"
@close="handleClose"
class="history-dialog"
>
<div class="history-content">
<div v-if="loading" class="loading-state">
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div v-else-if="records.length === 0" class="empty-state">
<el-icon :size="40" color="#C8A2C8"><Document /></el-icon>
<p>暂无变更记录</p>
</div>
<div v-else class="history-list">
<div
v-for="record in records"
:key="record.id"
class="history-item"
>
<div class="history-left">
<div
class="amount-badge"
:class="{ positive: record.change_amount > 0, negative: record.change_amount < 0 }"
>
<el-icon v-if="record.change_amount > 0"><Top /></el-icon>
<el-icon v-else-if="record.change_amount < 0"><Bottom /></el-icon>
<el-icon v-else><Minus /></el-icon>
<span>{{ formatAmount(record.change_amount) }}</span>
</div>
<div class="history-info">
<span class="history-note">{{ record.note || '未备注' }}</span>
<span class="history-date">{{ formatDate(record.created_at) }}</span>
</div>
</div>
<div class="history-right">
<span class="balance-change">
{{ record.balance_before.toFixed(2) }}
<el-icon :size="12"><Right /></el-icon>
{{ record.balance_after.toFixed(2) }}
</span>
</div>
</div>
</div>
<div v-if="total > pageSize" class="pagination-wrapper">
<el-pagination
:current-page="page"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
small
@current-change="handlePageChange"
/>
</div>
</div>
<template #footer>
<el-button @click="handleClose">关闭</el-button>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.history-content {
min-height: 200px;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 40px 0;
color: var(--text-secondary);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 40px 0;
color: var(--text-secondary);
p {
margin: 0;
font-size: 14px;
}
}
.history-list {
display: flex;
flex-direction: column;
}
.history-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 0;
border-bottom: 1px dashed rgba(255, 183, 197, 0.15);
&:last-child {
border-bottom: none;
}
}
.history-left {
display: flex;
align-items: center;
gap: 14px;
}
.amount-badge {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
&.positive {
color: #67C23A;
background: rgba(103, 194, 58, 0.1);
}
&.negative {
color: #F56C6C;
background: rgba(245, 108, 108, 0.1);
}
}
.history-info {
display: flex;
flex-direction: column;
gap: 2px;
.history-note {
font-size: 14px;
color: var(--text-primary);
}
.history-date {
font-size: 12px;
color: var(--text-secondary);
}
}
.history-right {
.balance-change {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
}
}
.pagination-wrapper {
display: flex;
justify-content: center;
padding-top: 16px;
}
</style>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAnniversaryStore } from '@/stores/useAnniversaryStore'
import type { AnniversaryCategory } from '@/api/types'
import { ElMessage } from 'element-plus'
interface Props {
visible: boolean
editCategory?: AnniversaryCategory | null
}
const props = withDefaults(defineProps<Props>(), {
editCategory: null
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const store = useAnniversaryStore()
const isEdit = computed(() => !!props.editCategory)
const dialogTitle = computed(() => isEdit.value ? '编辑分类' : '新建分类')
const form = ref({
name: '',
icon: 'calendar',
color: '#FFB7C5',
sort_order: 0
})
const presetColors = [
'#FFB7C5', '#FF9AA2', '#FFB347', '#FFDAC1',
'#B5EAD7', '#98D8C8', '#C7CEEA', '#E2F0CB',
'#FF6B6B', '#A86A7A', '#C8A2C8', '#87CEEB'
]
watch(() => props.visible, (val) => {
if (val) {
if (props.editCategory) {
form.value = {
name: props.editCategory.name,
icon: props.editCategory.icon,
color: props.editCategory.color,
sort_order: props.editCategory.sort_order
}
} else {
form.value = {
name: '',
icon: 'calendar',
color: '#FFB7C5',
sort_order: store.categories.length
}
}
}
})
async function handleSave() {
if (!form.value.name.trim()) {
ElMessage.warning('请输入分类名称~')
return
}
const data = {
name: form.value.name.trim(),
icon: form.value.icon,
color: form.value.color,
sort_order: form.value.sort_order
}
if (isEdit.value && props.editCategory) {
const result = await store.updateCategory(props.editCategory.id, data)
if (result) ElMessage.success('分类更新成功~')
} else {
const result = await store.createCategory(data)
if (result) ElMessage.success('分类创建成功~')
}
emit('update:visible', false)
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="400px"
@close="handleClose"
class="anniversary-category-dialog"
>
<div class="form-content">
<div class="form-item">
<label class="form-label">分类名称</label>
<el-input
v-model="form.name"
placeholder="如:生日、纪念日、节日..."
maxlength="50"
show-word-limit
/>
</div>
<div class="form-item">
<label class="form-label">颜色</label>
<div class="color-picker">
<div
v-for="color in presetColors"
:key="color"
class="color-dot"
:class="{ active: form.color === color }"
:style="{ background: color }"
@click="form.color = color"
></div>
</div>
</div>
<div class="form-item">
<label class="form-label">排序</label>
<el-input-number
v-model="form.sort_order"
:min="0"
:max="99"
size="default"
/>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.form-content {
padding: 8px 0;
}
.form-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.form-label {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
display: block;
}
}
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
.color-dot {
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
&:hover {
transform: scale(1.15);
}
&.active {
border-color: var(--text-primary);
box-shadow: 0 0 0 2px rgba(139, 69, 87, 0.2);
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,257 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAnniversaryStore } from '@/stores/useAnniversaryStore'
import type { Anniversary } from '@/api/types'
import { ElMessage } from 'element-plus'
interface Props {
visible: boolean
editAnniversary?: Anniversary | null
}
const props = withDefaults(defineProps<Props>(), {
editAnniversary: null
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const store = useAnniversaryStore()
const isEdit = computed(() => !!props.editAnniversary)
const dialogTitle = computed(() => isEdit.value ? '编辑纪念日' : '新建纪念日')
const form = ref({
title: '',
date: '',
year: null as number | null,
category_id: null as number | null,
description: '',
is_recurring: true,
remind_days_before: 3
})
watch(() => props.visible, (val) => {
if (val) {
if (props.editAnniversary) {
const a = props.editAnniversary
form.value = {
title: a.title,
date: a.date,
year: a.year ?? null,
category_id: a.category_id ?? null,
description: a.description || '',
is_recurring: a.is_recurring,
remind_days_before: a.remind_days_before
}
} else {
form.value = {
title: '',
date: '',
year: null,
category_id: null,
description: '',
is_recurring: true,
remind_days_before: 3
}
}
}
})
async function handleSave() {
if (!form.value.title.trim()) {
ElMessage.warning('请输入纪念日标题~')
return
}
if (!form.value.date) {
ElMessage.warning('请选择纪念日日期~')
return
}
const data = {
title: form.value.title.trim(),
date: form.value.date,
year: form.value.year,
category_id: form.value.category_id,
description: form.value.description.trim() || null,
is_recurring: form.value.is_recurring,
remind_days_before: form.value.remind_days_before
}
if (isEdit.value && props.editAnniversary) {
const result = await store.updateAnniversary(props.editAnniversary.id, data)
if (result) ElMessage.success('纪念日更新成功~')
} else {
const result = await store.createAnniversary(data)
if (result) ElMessage.success('纪念日创建成功~')
}
emit('update:visible', false)
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="460px"
@close="handleClose"
class="anniversary-dialog"
>
<div class="form-content">
<div class="form-item">
<label class="form-label">纪念日标题</label>
<el-input
v-model="form.title"
placeholder="如:小明的生日、结婚纪念日..."
maxlength="200"
show-word-limit
/>
</div>
<div class="form-item">
<label class="form-label">日期</label>
<el-date-picker
v-model="form.date"
type="date"
placeholder="选择日期"
format="MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
:clearable="false"
/>
</div>
<div class="form-item">
<label class="form-label">
年份
<span class="form-hint">可选用于计算周年</span>
</label>
<el-date-picker
v-model="form.year"
type="year"
placeholder="选择年份(可留空)"
format="YYYY"
value-format="YYYY"
style="width: 100%"
clearable
/>
</div>
<div class="form-item">
<label class="form-label">分类</label>
<el-select
v-model="form.category_id"
placeholder="选择分类(可留空)"
clearable
style="width: 100%"
>
<el-option
v-for="cat in store.categories"
:key="cat.id"
:label="cat.name"
:value="cat.id"
>
<span class="category-option">
<span
class="cat-dot"
:style="{ background: cat.color }"
></span>
{{ cat.name }}
</span>
</el-option>
</el-select>
</div>
<div class="form-item">
<label class="form-label">备注</label>
<el-input
v-model="form.description"
type="textarea"
:rows="2"
placeholder="可选的备注信息"
maxlength="500"
/>
</div>
<div class="form-item">
<label class="form-label">每年重复</label>
<el-switch v-model="form.is_recurring" />
</div>
<div v-if="form.is_recurring" class="form-item">
<label class="form-label">提前提醒天数</label>
<el-input-number
v-model="form.remind_days_before"
:min="0"
:max="90"
size="default"
/>
<span class="form-hint" style="margin-left: 8px;"></span>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.form-content {
padding: 8px 0;
}
.form-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.form-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
.form-hint {
font-size: 12px;
color: var(--text-secondary);
font-weight: 400;
}
}
}
.category-option {
display: flex;
align-items: center;
gap: 8px;
.cat-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,271 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUIStore } from '@/stores/useUIStore'
import { useUserSettingsStore } from '@/stores/useUserSettingsStore'
const uiStore = useUIStore()
const userSettingsStore = useUserSettingsStore()
const router = useRouter()
const route = useRoute()
const displayAvatar = computed(() => {
const name = userSettingsStore.nickname
if (!name) return '爱'
return name.charAt(0)
})
function setView(view: string) {
uiStore.setCurrentView(view as 'list' | 'calendar' | 'quadrant')
router.push(`/${view === 'list' ? 'tasks' : view}`)
}
function handleCommand(command: string) {
router.push(`/${command}`)
}
const currentRouteName = computed(() => route.name as string)
</script>
<template>
<header class="app-header">
<div class="header-left">
<el-button
v-if="currentRouteName === 'tasks'"
text
class="menu-btn"
@click="uiStore.toggleSidebar()"
>
<el-icon :size="20">
<Fold v-if="!uiStore.sidebarCollapsed" />
<Expand v-else />
</el-icon>
</el-button>
<div class="logo">
<span class="logo-icon"></span>
<span class="logo-text">{{ userSettingsStore.siteName }}</span>
</div>
</div>
<nav class="header-nav">
<button
class="nav-item"
:class="{ active: currentRouteName === 'tasks' }"
@click="setView('list')"
>
<el-icon><List /></el-icon>
<span>列表</span>
</button>
<button
class="nav-item"
:class="{ active: currentRouteName === 'calendar' }"
@click="setView('calendar')"
>
<el-icon><Calendar /></el-icon>
<span>日历</span>
</button>
<button
class="nav-item"
:class="{ active: currentRouteName === 'quadrant' }"
@click="setView('quadrant')"
>
<el-icon><Grid /></el-icon>
<span>四象限</span>
</button>
<button
class="nav-item"
:class="{ active: currentRouteName === 'habits' }"
@click="router.push('/habits')"
>
<el-icon><Flag /></el-icon>
<span>习惯</span>
</button>
<button
class="nav-item"
:class="{ active: currentRouteName === 'anniversaries' }"
@click="router.push('/anniversaries')"
>
<el-icon><Cherry /></el-icon>
<span>纪念日</span>
</button>
<button
class="nav-item"
:class="{ active: currentRouteName === 'assets' }"
@click="router.push('/assets')"
>
<el-icon><Wallet /></el-icon>
<span>资产</span>
</button>
</nav>
<div class="header-right">
<el-dropdown trigger="click" @command="handleCommand">
<div class="avatar-btn" @click.stop>
<el-avatar
v-if="userSettingsStore.avatar"
:size="36"
:src="userSettingsStore.avatar"
class="user-avatar"
/>
<el-avatar
v-else
:size="36"
class="user-avatar default-avatar"
>
{{ displayAvatar }}
</el-avatar>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>
<span>个人信息</span>
</el-dropdown-item>
<el-dropdown-item command="settings">
<el-icon><Setting /></el-icon>
<span>偏好设置</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
</template>
<style scoped lang="scss">
.app-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 60px;
background: white;
box-shadow: var(--shadow-sm);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
z-index: 1000;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
min-width: 200px;
.logo {
display: flex;
align-items: center;
gap: 8px;
.logo-icon {
font-size: 28px;
color: var(--primary);
animation: twinkle 2s ease-in-out infinite;
}
.logo-text {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}
.menu-btn {
color: var(--text-secondary);
&:hover {
color: var(--primary);
}
}
}
.header-nav {
display: flex;
align-items: center;
gap: 8px;
background: var(--background);
padding: 4px;
border-radius: var(--radius-lg);
.nav-item {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
border: none;
background: transparent;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
color: var(--text-primary);
background: rgba(255, 183, 197, 0.2);
}
&.active {
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
box-shadow: 0 2px 8px rgba(255, 183, 197, 0.4);
}
.el-icon {
font-size: 16px;
}
}
}
.header-right {
min-width: 200px;
display: flex;
justify-content: flex-end;
align-items: center;
.avatar-btn {
cursor: pointer;
display: flex;
align-items: center;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.08);
}
}
.user-avatar {
border: 2px solid var(--primary);
transition: box-shadow 0.2s ease;
}
.avatar-btn:hover .user-avatar {
box-shadow: 0 0 0 3px rgba(255, 183, 197, 0.3);
}
.default-avatar {
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
color: white;
font-size: 16px;
font-weight: 600;
}
}
@keyframes twinkle {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.1);
}
}
</style>

View File

@@ -0,0 +1,185 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAccountStore } from '@/stores/useAccountStore'
import type { FinancialAccount } from '@/api/types'
import { ElMessage } from 'element-plus'
interface Props {
visible: boolean
account: FinancialAccount | null
}
const props = withDefaults(defineProps<Props>(), {
account: null
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const store = useAccountStore()
const newBalance = ref(0)
const note = ref('')
const saving = ref(false)
const changeAmount = computed(() => {
if (!props.account) return 0
return Math.round((newBalance.value - props.account.balance) * 100) / 100
})
const changeText = computed(() => {
const diff = changeAmount.value
if (diff > 0) return `+${diff.toFixed(2)}`
if (diff < 0) return diff.toFixed(2)
return '0.00'
})
const isPositive = computed(() => changeAmount.value >= 0)
watch(() => props.visible, (val) => {
if (val && props.account) {
newBalance.value = props.account.balance
note.value = ''
saving.value = false
}
})
async function handleSave() {
if (!props.account) return
saving.value = true
try {
const result = await store.updateBalance(props.account.id, {
new_balance: newBalance.value,
note: note.value.trim() || null
})
if (result) {
ElMessage.success('余额更新成功~')
emit('update:visible', false)
}
} finally {
saving.value = false
}
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
title="更新余额"
width="420px"
@close="handleClose"
class="balance-dialog"
>
<div class="form-content">
<div v-if="account" class="balance-preview">
<span class="current-label">{{ account.name }}</span>
<span class="current-balance">{{ account.balance.toFixed(2) }}</span>
</div>
<div class="change-indicator" :class="{ positive: isPositive, negative: !isPositive }">
<el-icon><Top v-if="isPositive" /><Bottom v-else /></el-icon>
<span>{{ changeText }}</span>
</div>
<div class="form-item">
<label class="form-label">新余额</label>
<el-input-number
v-model="newBalance"
:precision="2"
:step="100"
style="width: 100%"
/>
</div>
<div class="form-item">
<label class="form-label">备注</label>
<el-input
v-model="note"
placeholder="如:工资到账、还花呗、日常消费..."
maxlength="200"
show-word-limit
/>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="saving">
确认更新
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.form-content {
padding: 8px 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.balance-preview {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(255, 183, 197, 0.08);
border-radius: var(--radius-md);
.current-label {
font-size: 14px;
color: var(--text-secondary);
}
.current-balance {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
}
.change-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
border-radius: var(--radius-md);
font-size: 16px;
font-weight: 600;
&.positive {
color: #67C23A;
background: rgba(103, 194, 58, 0.1);
}
&.negative {
color: #F56C6C;
background: rgba(245, 108, 108, 0.1);
}
}
.form-item {
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,227 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useCategoryStore } from '@/stores/useCategoryStore'
import { useUIStore } from '@/stores/useUIStore'
import type { CategoryFormData } from '@/api/types'
import { ElMessage, ElMessageBox } from 'element-plus'
const categoryStore = useCategoryStore()
const uiStore = useUIStore()
const form = ref<CategoryFormData>({
name: '',
color: '#FFB7C5',
icon: 'Folder'
})
const formRef = ref()
const loading = ref(false)
const isEditMode = computed(() => !!uiStore.editingCategory)
const dialogTitle = computed(() => isEditMode.value ? '编辑分类' : '新建分类')
const submitButtonText = computed(() => isEditMode.value ? '保存' : '创建')
const colorOptions = [
'#FFB7C5', '#FFC0CB', '#C8A2C8', '#98D8C8',
'#FFB347', '#FF6B6B', '#87CEEB', '#DDA0DD'
]
const iconOptions = [
'Folder', 'Document', 'FolderOpened', 'Notebook',
'Star', 'Sunny', 'Moon', 'Flag',
'Calendar', 'Clock', 'Bell', 'AlarmClock',
'House', 'Briefcase', 'ShoppingCart', 'Goods',
'Coffee', 'Goblet', 'Dish', 'IceCream',
'Headset', 'VideoCamera', 'Camera', 'Picture',
'Present', 'Trophy', 'Medal', 'Cherry'
]
const rules = {
name: [
{ required: true, message: '请输入分类名称~', trigger: 'blur' },
{ min: 1, max: 100, message: '名称长度在 1 到 100 个字符~', trigger: 'blur' }
]
}
watch(() => uiStore.categoryDialogVisible, (visible) => {
if (visible) {
if (uiStore.editingCategory) {
form.value = {
name: uiStore.editingCategory.name,
color: uiStore.editingCategory.color,
icon: uiStore.editingCategory.icon
}
} else {
form.value = {
name: '',
color: '#FFB7C5',
icon: 'Folder'
}
}
}
})
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
loading.value = true
try {
if (isEditMode.value && uiStore.editingCategory) {
await categoryStore.updateCategory(uiStore.editingCategory.id, form.value)
ElMessage.success('分类更新成功~')
} else {
await categoryStore.createCategory(form.value)
ElMessage.success('分类创建成功~')
}
uiStore.closeCategoryDialog()
} finally {
loading.value = false
}
})
}
function handleClose() {
uiStore.closeCategoryDialog()
}
</script>
<template>
<el-dialog
v-model="uiStore.categoryDialogVisible"
:title="dialogTitle"
width="400px"
:close-on-click-modal="false"
class="category-dialog"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="category-form"
@submit.prevent="handleSubmit"
>
<el-form-item label="分类名称" prop="name">
<el-input
v-model="form.name"
placeholder="请输入分类名称~"
maxlength="100"
/>
</el-form-item>
<el-form-item label="选择颜色">
<div class="color-picker">
<div
v-for="color in colorOptions"
:key="color"
class="color-option"
:class="{ active: form.color === color }"
:style="{ background: color }"
@click="form.color = color"
/>
</div>
</el-form-item>
<el-form-item label="选择图标">
<div class="icon-picker">
<div
v-for="icon in iconOptions"
:key="icon"
class="icon-option"
:class="{ active: form.icon === icon }"
@click="form.icon = icon"
>
<el-icon :size="20"><component :is="icon" /></el-icon>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
:loading="loading"
@click="handleSubmit"
>
{{ submitButtonText }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.category-form {
padding: 8px 0;
}
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.color-option {
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
transition: all var(--transition-fast);
border: 2px solid transparent;
&:hover {
transform: scale(1.1);
}
&.active {
border-color: var(--text-primary);
box-shadow: 0 0 0 2px white, 0 0 0 4px var(--primary);
}
}
.icon-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.icon-option {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
background: var(--background);
color: var(--text-primary);
&:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
&.active {
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
color: white;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(255, 183, 197, 0.4);
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,698 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTaskStore } from '@/stores/useTaskStore'
import { useCategoryStore } from '@/stores/useCategoryStore'
import { useTagStore } from '@/stores/useTagStore'
import { useUIStore } from '@/stores/useUIStore'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { Category, Tag } from '@/api/types'
const taskStore = useTaskStore()
const categoryStore = useCategoryStore()
const tagStore = useTagStore()
const uiStore = useUIStore()
const statusFilters = [
{ label: '全部任务', value: 'all', icon: 'List' },
{ label: '进行中', value: 'active', icon: 'Clock' },
{ label: '已完成', value: 'completed', icon: 'CircleCheck' }
]
const currentStatus = computed(() => taskStore.filters.status)
const currentCategoryId = computed(() => taskStore.filters.category_id)
const categoryCollapsed = ref(false)
const tagCollapsed = ref(false)
function setStatusFilter(status: 'all' | 'active' | 'completed') {
taskStore.setFilters({ status })
}
function setCategoryFilter(categoryId?: number) {
taskStore.setFilters({ category_id: categoryId })
}
function handleEditCategory(category: Category, event: Event) {
event.stopPropagation()
uiStore.openCategoryDialog(category)
}
async function handleDeleteCategory(category: Category, event: Event) {
event.stopPropagation()
try {
await ElMessageBox.confirm(
`确定要删除分类「${category.name}」吗?`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const success = await categoryStore.deleteCategory(category.id)
if (success) {
ElMessage.success('分类删除成功~')
if (currentCategoryId.value === category.id) {
taskStore.setFilters({ category_id: undefined })
}
} else {
ElMessage.error('该分类下存在关联任务,无法删除~')
}
} catch {
// 用户取消删除
}
}
async function handleAddTag() {
try {
const { value } = await ElMessageBox.prompt(
'请输入标签名称~',
'新建标签',
{
confirmButtonText: '创建',
cancelButtonText: '取消',
inputPlaceholder: '标签名称',
inputPattern: /\S+/,
inputErrorMessage: '标签名称不能为空哦~'
}
)
const name = (value as string).trim()
if (!name) return
const newTag = await tagStore.createTag({ name })
if (newTag) {
ElMessage.success(`标签「${name}」创建成功~`)
} else {
ElMessage.error('标签创建失败,可能已存在同名标签~')
}
} catch {
// 用户取消
}
}
async function handleDeleteTag(tag: Tag, event: Event) {
event.stopPropagation()
try {
await ElMessageBox.confirm(
`确定要删除标签「${tag.name}」吗?`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const success = await tagStore.deleteTag(tag.id)
if (success) {
ElMessage.success('标签已删除~')
}
} catch {
// 用户取消
}
}
</script>
<template>
<aside class="sidebar" :class="{ collapsed: uiStore.sidebarCollapsed }">
<div class="sidebar-scroll">
<div class="sidebar-section">
<h3 class="section-title">任务筛选</h3>
<ul class="filter-list">
<li
v-for="filter in statusFilters"
:key="filter.value"
class="filter-item"
:class="{ active: currentStatus === filter.value && !currentCategoryId }"
@click="setStatusFilter(filter.value as 'all' | 'active' | 'completed')"
>
<el-icon><component :is="filter.icon" /></el-icon>
<span>{{ filter.label }}</span>
<span class="count">
{{ filter.value === 'all' ? taskStore.totalTasks.length :
filter.value === 'active' ? taskStore.activeTasks.length :
taskStore.completedTasks.length }}
</span>
</li>
</ul>
</div>
<div class="sidebar-section">
<div class="section-header clickable" @click="categoryCollapsed = !categoryCollapsed">
<div class="section-title-row">
<h3 class="section-title no-margin">分类</h3>
<el-icon class="collapse-icon" :class="{ rotated: categoryCollapsed }"><ArrowRight /></el-icon>
</div>
<div class="section-actions" v-show="!categoryCollapsed" @click.stop>
<el-button
v-if="currentCategoryId"
text
size="small"
class="clear-filter-btn"
@click="setCategoryFilter()"
>
<el-icon><Close /></el-icon>
<span>清除</span>
</el-button>
<el-button
text
size="small"
class="add-category-btn"
@click="uiStore.openCategoryDialog()"
>
<el-icon><Plus /></el-icon>
</el-button>
</div>
</div>
<Transition name="collapse">
<ul v-show="!categoryCollapsed" class="category-list">
<li
v-for="category in categoryStore.categories"
:key="category.id"
class="category-item"
:class="{ active: currentCategoryId === category.id }"
@click="setCategoryFilter(category.id)"
>
<span class="category-dot" :style="{ background: category.color }"></span>
<span class="category-name">{{ category.name }}</span>
<div class="category-actions">
<el-button
text
size="small"
class="action-btn"
@click="handleEditCategory(category, $event)"
>
<el-icon><Edit /></el-icon>
</el-button>
<el-button
text
size="small"
class="action-btn delete-btn"
@click="handleDeleteCategory(category, $event)"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</li>
</ul>
</Transition>
</div>
<div class="sidebar-section">
<div class="section-header clickable" @click="tagCollapsed = !tagCollapsed">
<div class="section-title-row">
<h3 class="section-title no-margin">标签</h3>
<el-icon class="collapse-icon" :class="{ rotated: tagCollapsed }"><ArrowRight /></el-icon>
</div>
<el-button
v-show="!tagCollapsed"
text
size="small"
class="add-category-btn"
@click.stop="handleAddTag"
>
<el-icon><Plus /></el-icon>
</el-button>
</div>
<Transition name="collapse">
<div v-show="!tagCollapsed">
<ul v-if="tagStore.tags.length > 0" class="tag-list">
<li
v-for="tag in tagStore.tags"
:key="tag.id"
class="tag-item"
>
<span class="tag-icon">#</span>
<span class="tag-name">{{ tag.name }}</span>
<el-button
text
size="small"
class="tag-delete-btn"
@click="handleDeleteTag(tag, $event)"
>
<el-icon><Delete /></el-icon>
</el-button>
</li>
</ul>
<p v-else class="tag-empty">还没有标签创建一个吧~</p>
</div>
</Transition>
</div>
</div>
<div class="sidebar-footer">
<div class="sidebar-section" style="margin-bottom: 0;">
<h3 class="section-title">排序</h3>
<div class="sort-options">
<el-select
:model-value="taskStore.filters.sort_by"
placeholder="排序方式"
@change="taskStore.setFilters({ sort_by: $event })"
>
<el-option label="优先级" value="priority" />
<el-option label="截止日期" value="due_date" />
<el-option label="创建时间" value="created_at" />
</el-select>
<el-button
:icon="taskStore.filters.sort_order === 'asc' ? 'ArrowUp' : 'ArrowDown'"
@click="taskStore.setFilters({ sort_order: taskStore.filters.sort_order === 'asc' ? 'desc' : 'asc' })"
/>
</div>
</div>
</div>
</aside>
</template>
<style scoped lang="scss">
.sidebar {
position: fixed;
top: 60px;
left: 0;
width: 240px;
height: calc(100vh - 60px);
background: white;
box-shadow: 0 2px 8px rgba(255, 183, 197, 0.15);
display: flex;
flex-direction: column;
z-index: 100;
will-change: transform;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.collapsed {
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
@media (max-width: 768px) {
transform: translateX(0);
}
}
@media (max-width: 768px) {
width: 200px;
}
}
.sidebar-scroll {
flex: 1;
overflow-y: auto;
padding: 20px 16px;
min-height: 0;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--background-dark);
border-radius: 2px;
}
}
.sidebar-footer {
flex-shrink: 0;
padding: 16px;
border-top: 1px solid var(--background-dark);
}
.sidebar-section {
margin-bottom: 24px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.section-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
&.no-margin {
margin-bottom: 0;
}
}
.section-title-row {
display: flex;
align-items: center;
gap: 4px;
}
.collapse-icon {
font-size: 12px;
color: var(--text-secondary);
transition: transform 0.25s ease;
&.rotated {
transform: rotate(0deg);
}
&:not(.rotated) {
transform: rotate(90deg);
}
}
.section-header.clickable {
cursor: pointer;
user-select: none;
&:hover {
.section-title-row {
.section-title {
color: var(--primary);
}
}
.collapse-icon {
color: var(--primary);
}
}
}
.collapse-enter-active,
.collapse-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.collapse-enter-from,
.collapse-leave-to {
opacity: 0;
max-height: 0;
margin-top: 0;
}
.collapse-enter-to,
.collapse-leave-from {
opacity: 1;
max-height: 500px;
}
.section-actions {
display: flex;
align-items: center;
gap: 4px;
}
.add-category-btn {
color: var(--text-secondary);
font-size: 16px;
&:hover {
color: var(--primary);
}
}
.clear-filter-btn {
color: #f56c6c;
font-size: 12px;
padding: 2px 6px;
height: auto;
display: flex;
align-items: center;
gap: 2px;
span {
font-weight: 500;
}
&:hover {
color: #f56c6c;
background: rgba(245, 108, 108, 0.1);
}
}
.filter-list,
.category-list {
list-style: none;
}
.filter-item,
.category-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
color: var(--text-primary);
&:hover {
background: var(--background);
}
&.active {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
color: white;
font-weight: 500;
.count {
background: rgba(255, 255, 255, 0.3);
color: white;
}
}
.count {
margin-left: auto;
font-size: 12px;
padding: 2px 8px;
background: var(--background-dark);
border-radius: 10px;
color: var(--text-secondary);
}
}
.category-item {
.category-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.category-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity var(--transition-fast);
.action-btn {
padding: 4px;
min-width: 24px;
height: 24px;
color: var(--text-secondary);
&:hover {
color: var(--primary);
background: rgba(255, 114, 159, 0.1);
}
&.delete-btn:hover {
color: #f56c6c;
background: rgba(245, 108, 108, 0.1);
}
}
}
&:hover .category-actions {
opacity: 1;
}
&.active .category-actions .action-btn {
color: rgba(255, 255, 255, 0.8);
&:hover {
color: white;
background: rgba(255, 255, 255, 0.2);
}
&.delete-btn:hover {
color: white;
background: rgba(245, 108, 108, 0.3);
}
}
}
.tag-list {
list-style: none;
}
.tag-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: var(--radius-md);
transition: all var(--transition-fast);
color: var(--text-primary);
&:hover {
background: var(--background);
.tag-delete-btn {
opacity: 1;
}
}
.tag-icon {
font-size: 14px;
font-weight: 700;
color: var(--primary);
flex-shrink: 0;
}
.tag-name {
flex: 1;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-delete-btn {
opacity: 0;
transition: opacity var(--transition-fast);
padding: 2px;
min-width: 20px;
height: 20px;
color: var(--text-secondary);
&:hover {
color: #f56c6c;
}
}
}
.tag-empty {
font-size: 12px;
color: var(--text-secondary);
text-align: center;
padding: 8px 0;
}
.sort-options {
display: flex;
gap: 8px;
:deep(.el-select) {
flex: 1;
.el-select__wrapper {
background: var(--background);
border: 1px solid var(--background-dark);
border-radius: var(--radius-md);
box-shadow: none;
transition: all var(--transition-fast);
padding: 6px 12px;
min-height: 36px;
&:hover {
background: white;
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(255, 183, 197, 0.15);
}
&.is-focused {
background: white;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(255, 114, 159, 0.15), 0 2px 8px rgba(255, 183, 197, 0.2);
}
.el-select__selected-item {
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
}
}
.el-select__suffix {
color: var(--text-secondary);
transition: transform var(--transition-fast);
.el-select__suffix-inner {
transition: transform var(--transition-fast);
}
}
&.is-focused .el-select__suffix {
color: var(--primary);
.el-select__suffix-inner {
transform: rotate(180deg);
}
}
}
:deep(.el-select-dropdown) {
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
border: 1px solid var(--background-dark);
.el-select-dropdown__item {
color: var(--text-primary);
font-size: 14px;
padding: 8px 16px;
transition: all var(--transition-fast);
&:hover {
background: var(--background);
}
&.is-selected {
color: var(--primary);
font-weight: 600;
background: rgba(255, 114, 159, 0.08);
&::after {
content: '✓';
position: absolute;
right: 12px;
color: var(--primary);
font-weight: bold;
}
}
}
}
:deep(.el-button) {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
border: none;
border-radius: var(--radius-md);
color: white;
transition: all var(--transition-fast);
min-width: 36px;
padding: 6px;
&:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
&:active {
transform: translateY(0);
}
.el-icon {
font-size: 16px;
}
}
}
</style>

View File

@@ -0,0 +1,330 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useHabitStore } from '@/stores/useHabitStore'
import type { Habit } from '@/api/types'
import { ElMessage } from 'element-plus'
interface Props {
visible: boolean
editHabit?: Habit | null
}
const props = withDefaults(defineProps<Props>(), {
editHabit: null
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const habitStore = useHabitStore()
const isEdit = computed(() => !!props.editHabit)
const dialogTitle = computed(() => isEdit.value ? '编辑习惯' : '新建习惯')
const form = ref({
name: '',
description: '',
group_id: null as number | null,
target_count: 1,
frequency: 'daily' as 'daily' | 'weekly',
active_days: [] as number[]
})
const weekDays = [
{ value: 0, label: '一' },
{ value: 1, label: '二' },
{ value: 2, label: '三' },
{ value: 3, label: '四' },
{ value: 4, label: '五' },
{ value: 5, label: '六' },
{ value: 6, label: '日' },
]
watch(() => props.visible, (val) => {
if (val) {
if (props.editHabit) {
form.value = {
name: props.editHabit.name,
description: props.editHabit.description || '',
group_id: props.editHabit.group_id ?? null,
target_count: props.editHabit.target_count,
frequency: props.editHabit.frequency,
active_days: props.editHabit.active_days
? JSON.parse(props.editHabit.active_days)
: []
}
} else {
form.value = {
name: '',
description: '',
group_id: null,
target_count: 1,
frequency: 'daily',
active_days: []
}
}
}
})
function toggleDay(day: number) {
const idx = form.value.active_days.indexOf(day)
if (idx >= 0) {
form.value.active_days.splice(idx, 1)
} else {
form.value.active_days.push(day)
}
form.value.active_days.sort()
}
function selectAllDays() {
if (form.value.active_days.length === 7) {
form.value.active_days = []
} else {
form.value.active_days = [0, 1, 2, 3, 4, 5, 6]
}
}
const activeDaysLabel = computed(() => {
if (form.value.active_days.length === 0) return '请选择'
if (form.value.active_days.length === 7) return '每天'
return form.value.active_days.map(d => `${weekDays[d].label}`).join('、')
})
async function handleSave() {
if (!form.value.name.trim()) {
ElMessage.warning('请输入习惯名称~')
return
}
if (form.value.frequency === 'weekly' && form.value.active_days.length === 0) {
ElMessage.warning('请至少选择一天~')
return
}
const data = {
name: form.value.name.trim(),
description: form.value.description.trim() || undefined,
group_id: form.value.group_id,
target_count: form.value.target_count,
frequency: form.value.frequency,
active_days: form.value.frequency === 'weekly'
? JSON.stringify(form.value.active_days)
: null
}
if (isEdit.value && props.editHabit) {
const result = await habitStore.updateHabit(props.editHabit.id, data)
if (result) ElMessage.success('习惯更新成功~')
} else {
const result = await habitStore.createHabit(data)
if (result) ElMessage.success('习惯创建成功~')
}
emit('update:visible', false)
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="460px"
@close="handleClose"
class="habit-dialog"
>
<div class="form-content">
<div class="form-item">
<label class="form-label">习惯名称</label>
<el-input
v-model="form.name"
placeholder="如:跑步、阅读、冥想..."
maxlength="50"
show-word-limit
/>
</div>
<div class="form-item">
<label class="form-label">备注描述</label>
<el-input
v-model="form.description"
type="textarea"
:rows="2"
placeholder="可选的备注信息"
maxlength="200"
/>
</div>
<div class="form-item">
<label class="form-label">所属分组</label>
<el-select
v-model="form.group_id"
placeholder="选择分组(可留空)"
clearable
style="width: 100%"
>
<el-option
v-for="group in habitStore.groups"
:key="group.id"
:label="group.name"
:value="group.id"
/>
</el-select>
</div>
<div class="form-item">
<label class="form-label">每日目标次数</label>
<div class="count-control">
<el-button
circle
size="small"
:disabled="form.target_count <= 1"
@click="form.target_count--"
>
<el-icon><Minus /></el-icon>
</el-button>
<span class="count-value">{{ form.target_count }}</span>
<el-button
circle
size="small"
:disabled="form.target_count >= 99"
@click="form.target_count++"
>
<el-icon><Plus /></el-icon>
</el-button>
<span class="count-hint">/</span>
</div>
</div>
<div class="form-item">
<label class="form-label">打卡频率</label>
<el-radio-group v-model="form.frequency">
<el-radio value="daily">每天</el-radio>
<el-radio value="weekly">每周特定几天</el-radio>
</el-radio-group>
</div>
<div v-if="form.frequency === 'weekly'" class="form-item days-selector">
<label class="form-label">
选择打卡日
<el-button type="primary" link size="small" @click="selectAllDays">
{{ form.active_days.length === 7 ? '清除全选' : '全选' }}
</el-button>
</label>
<div class="week-days">
<div
v-for="day in weekDays"
:key="day.value"
class="day-btn"
:class="{ active: form.active_days.includes(day.value) }"
@click="toggleDay(day.value)"
>
{{ day.label }}
</div>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.form-content {
padding: 8px 0;
}
.form-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.form-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
}
}
.count-control {
display: flex;
align-items: center;
gap: 16px;
.count-value {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
min-width: 36px;
text-align: center;
}
.count-hint {
font-size: 13px;
color: var(--text-secondary);
margin-left: 4px;
}
}
.days-selector {
.form-label {
justify-content: space-between;
}
}
.week-days {
display: flex;
gap: 8px;
flex-wrap: wrap;
.day-btn {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
background: var(--background);
transition: all 0.2s ease;
user-select: none;
&:hover {
color: var(--primary);
background: rgba(255, 183, 197, 0.15);
}
&.active {
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
box-shadow: 0 2px 8px rgba(255, 183, 197, 0.4);
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,468 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useHabitStore } from '@/stores/useHabitStore'
import type { HabitGroup } from '@/api/types'
import { ElMessage, ElMessageBox } from 'element-plus'
interface Props {
visible: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const habitStore = useHabitStore()
const mode = ref<'manage' | 'edit' | 'create'>('manage')
const editingGroup = ref<HabitGroup | null>(null)
const form = ref({
name: '',
color: '#FFB7C5',
icon: 'flag'
})
const colors = [
'#FFB7C5', '#FF6B6B', '#FFB347', '#FFD93D',
'#98D8C8', '#6BC5D2', '#C8A2C8', '#A8E6CF'
]
const iconOptions = [
{ value: 'flag', label: '旗帜' },
{ value: 'star', label: '星星' },
{ value: 'sunny', label: '太阳' },
{ value: 'trophy', label: '奖杯' },
{ value: 'medal', label: '奖牌' },
{ value: 'apple', label: '苹果' },
{ value: 'cherry', label: '樱桃' },
{ value: 'grape', label: '葡萄' },
]
const dialogTitle = computed(() => {
if (mode.value === 'create') return '新建分组'
if (mode.value === 'edit') return '编辑分组'
return '管理分组'
})
const dialogWidth = computed(() => {
if (mode.value === 'manage') return '480px'
return '420px'
})
watch(() => props.visible, (val) => {
if (val) {
mode.value = 'manage'
editingGroup.value = null
}
})
function openCreate() {
mode.value = 'create'
editingGroup.value = null
form.value = { name: '', color: '#FFB7C5', icon: 'flag' }
}
function openEdit(group: HabitGroup) {
mode.value = 'edit'
editingGroup.value = group
form.value = {
name: group.name,
color: group.color,
icon: group.icon
}
}
async function handleSave() {
if (!form.value.name.trim()) {
ElMessage.warning('请输入分组名称~')
return
}
if (mode.value === 'edit' && editingGroup.value) {
const result = await habitStore.updateGroup(editingGroup.value.id, form.value)
if (result) {
ElMessage.success('分组更新成功~')
mode.value = 'manage'
}
} else {
const result = await habitStore.createGroup(form.value)
if (result) {
ElMessage.success('分组创建成功~')
mode.value = 'manage'
}
}
}
async function handleDeleteGroup(group: HabitGroup) {
try {
await ElMessageBox.confirm(
`删除分组「${group.name}」后,其中的习惯会变为未分组状态~`,
'确认删除',
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' }
)
const success = await habitStore.deleteGroup(group.id)
if (success) ElMessage.success('分组已删除~')
} catch {
// 用户取消
}
}
function goBack() {
mode.value = 'manage'
editingGroup.value = null
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
:width="dialogWidth"
@close="handleClose"
class="habit-group-dialog"
>
<!-- 管理分组列表 -->
<div v-if="mode === 'manage'" class="manage-mode">
<div v-if="habitStore.groups.length === 0" class="no-groups">
<p>还没有分组呢创建一个吧~</p>
</div>
<div v-else class="group-list">
<div
v-for="group in habitStore.groups"
:key="group.id"
class="group-item"
>
<div class="group-item-left">
<div class="group-item-icon" :style="{ background: group.color + '22', color: group.color }">
<el-icon :size="18">
<component :is="group.icon || 'Flag'" />
</el-icon>
</div>
<div class="group-item-info">
<span class="group-item-name">{{ group.name }}</span>
<span class="group-item-count">{{ habitStore.habits.filter(h => h.group_id === group.id).length }} 个习惯</span>
</div>
</div>
<div class="group-item-actions">
<button class="icon-btn" title="编辑" @click="openEdit(group)">
<el-icon :size="16"><Edit /></el-icon>
</button>
<button class="icon-btn icon-btn--danger" title="删除" @click="handleDeleteGroup(group)">
<el-icon :size="16"><Delete /></el-icon>
</button>
</div>
</div>
</div>
<button class="add-group-btn" @click="openCreate">
<el-icon :size="18"><Plus /></el-icon>
<span>新建分组</span>
</button>
</div>
<!-- 创建/编辑分组 -->
<div v-else class="form-content">
<button class="back-btn" @click="goBack">
<el-icon :size="16"><ArrowLeft /></el-icon>
<span>返回</span>
</button>
<div class="form-item">
<label class="form-label">分组名称</label>
<el-input
v-model="form.name"
placeholder="如:运动、学习、阅读..."
maxlength="20"
show-word-limit
/>
</div>
<div class="form-item">
<label class="form-label">颜色</label>
<div class="color-picker">
<div
v-for="color in colors"
:key="color"
class="color-dot"
:class="{ active: form.color === color }"
:style="{ background: color }"
@click="form.color = color"
/>
</div>
</div>
<div class="form-item">
<label class="form-label">图标</label>
<div class="icon-picker">
<div
v-for="icon in iconOptions"
:key="icon.value"
class="icon-item"
:class="{ active: form.icon === icon.value }"
:title="icon.label"
@click="form.icon = icon.value"
>
<el-icon :size="20">
<component :is="icon.value" />
</el-icon>
<span class="icon-label">{{ icon.label }}</span>
</div>
</div>
</div>
</div>
<template #footer>
<div v-if="mode === 'manage'" class="dialog-footer">
<el-button @click="handleClose">关闭</el-button>
</div>
<div v-else class="dialog-footer">
<el-button @click="goBack">取消</el-button>
<el-button type="primary" @click="handleSave">
{{ mode === 'edit' ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
// 管理模式
.manage-mode {
display: flex;
flex-direction: column;
gap: 12px;
}
.no-groups {
text-align: center;
padding: 20px;
color: var(--text-secondary);
}
.group-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 400px;
overflow-y: auto;
}
.group-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-radius: var(--radius-md);
background: var(--background);
transition: all 0.2s ease;
&:hover {
background: rgba(255, 183, 197, 0.1);
.group-item-actions {
opacity: 1;
}
}
.group-item-left {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.group-item-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.group-item-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.group-item-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.group-item-count {
font-size: 12px;
color: var(--text-secondary);
}
.group-item-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
}
}
.icon-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 183, 197, 0.15);
color: var(--text-primary);
}
&--danger:hover {
background: rgba(255, 107, 107, 0.15);
color: var(--danger);
}
}
.add-group-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border: 2px dashed var(--secondary);
border-radius: var(--radius-md);
background: transparent;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
color: var(--primary);
background: rgba(255, 183, 197, 0.05);
}
}
// 表单模式
.back-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
border-radius: 8px;
margin-bottom: 16px;
transition: all 0.2s ease;
&:hover {
color: var(--text-primary);
background: rgba(255, 183, 197, 0.1);
}
}
.form-content {
padding: 8px 0;
}
.form-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
}
}
.color-picker {
display: flex;
gap: 12px;
flex-wrap: wrap;
.color-dot {
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
&:hover {
transform: scale(1.15);
}
&.active {
border-color: var(--text-primary);
box-shadow: 0 0 0 3px rgba(139, 69, 87, 0.2);
}
}
}
.icon-picker {
display: flex;
gap: 8px;
flex-wrap: wrap;
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 56px;
padding: 8px 4px 6px;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
color: var(--text-secondary);
background: var(--background);
&:hover {
color: var(--text-primary);
background: rgba(255, 183, 197, 0.15);
}
&.active {
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
}
.icon-label {
font-size: 11px;
white-space: nowrap;
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,270 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAccountStore } from '@/stores/useAccountStore'
import type { DebtInstallment, FinancialAccount } from '@/api/types'
import { ElMessage } from 'element-plus'
interface Props {
visible: boolean
editInstallment?: DebtInstallment | null
accountId?: number | null
}
const props = withDefaults(defineProps<Props>(), {
editInstallment: null,
accountId: null
})
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const store = useAccountStore()
const isEdit = computed(() => !!props.editInstallment)
const dialogTitle = computed(() => isEdit.value ? '编辑分期计划' : '新建分期计划')
const form = ref({
account_id: null as number | null,
total_amount: 0,
total_periods: 3,
current_period: 1,
payment_day: 12,
payment_amount: 0,
start_date: '',
is_completed: false
})
const debtAccounts = computed(() => store.debtAccounts)
watch(() => props.visible, (val) => {
if (val) {
if (props.editInstallment) {
const inst = props.editInstallment
form.value = {
account_id: inst.account_id,
total_amount: inst.total_amount,
total_periods: inst.total_periods,
current_period: inst.current_period,
payment_day: inst.payment_day,
payment_amount: inst.payment_amount,
start_date: inst.start_date,
is_completed: inst.is_completed
}
} else {
form.value = {
account_id: props.accountId || (debtAccounts.value.length > 0 ? debtAccounts.value[0].id : null),
total_amount: 0,
total_periods: 3,
current_period: 1,
payment_day: 12,
payment_amount: 0,
start_date: '',
is_completed: false
}
}
}
})
watch([() => form.value.total_amount, () => form.value.total_periods], ([amount, periods]) => {
if (!isEdit.value && amount > 0 && periods > 0) {
form.value.payment_amount = Math.round((amount / periods) * 100) / 100
}
})
async function handleSave() {
if (!form.value.account_id) {
ElMessage.warning('请选择关联的欠款账户~')
return
}
if (form.value.total_amount <= 0) {
ElMessage.warning('请输入分期总额~')
return
}
if (form.value.payment_amount <= 0) {
ElMessage.warning('请输入每期还款金额~')
return
}
if (!form.value.start_date) {
ElMessage.warning('请选择首次还款日期~')
return
}
const data = {
account_id: form.value.account_id,
total_amount: form.value.total_amount,
total_periods: form.value.total_periods,
current_period: form.value.current_period,
payment_day: form.value.payment_day,
payment_amount: form.value.payment_amount,
start_date: form.value.start_date,
is_completed: form.value.is_completed
}
if (isEdit.value && props.editInstallment) {
const result = await store.updateInstallment(props.editInstallment.id, data)
if (result) ElMessage.success('分期计划更新成功~')
} else {
const result = await store.createInstallment(data)
if (result) ElMessage.success('分期计划创建成功~')
}
emit('update:visible', false)
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="460px"
@close="handleClose"
class="installment-dialog"
>
<div class="form-content">
<div class="form-item">
<label class="form-label">关联账户</label>
<el-select
v-model="form.account_id"
placeholder="选择欠款账户"
style="width: 100%"
:disabled="isEdit"
>
<el-option
v-for="acc in debtAccounts"
:key="acc.id"
:label="acc.name"
:value="acc.id"
/>
</el-select>
<div v-if="debtAccounts.length === 0" class="form-hint" style="margin-top: 8px;">
暂无欠款账户请先创建一个欠款类型的账户~
</div>
</div>
<div class="form-item">
<label class="form-label">分期总额</label>
<el-input-number
v-model="form.total_amount"
:precision="2"
:step="500"
:min="0"
style="width: 100%"
/>
</div>
<div class="form-item form-row">
<div class="form-col">
<label class="form-label">总期数</label>
<el-input-number
v-model="form.total_periods"
:min="1"
:max="36"
style="width: 100%"
/>
</div>
<div class="form-col">
<label class="form-label">每月还款日</label>
<el-input-number
v-model="form.payment_day"
:min="1"
:max="31"
style="width: 100%"
/>
</div>
</div>
<div class="form-item">
<label class="form-label">每期还款金额</label>
<el-input-number
v-model="form.payment_amount"
:precision="2"
:step="100"
:min="0"
style="width: 100%"
/>
<div v-if="!isEdit && form.total_amount > 0 && form.total_periods > 0" class="form-hint" style="margin-top: 8px;">
自动计算: {{ form.total_amount.toFixed(2) }} / {{ form.total_periods }} = {{ (form.total_amount / form.total_periods).toFixed(2) }}
</div>
</div>
<div v-if="isEdit" class="form-item">
<label class="form-label">当前期数第几期待还</label>
<el-input-number
v-model="form.current_period"
:min="1"
:max="form.total_periods"
style="width: 100%"
/>
</div>
<div class="form-item">
<label class="form-label">首次还款日期</label>
<el-date-picker
v-model="form.start_date"
type="date"
placeholder="选择首次还款日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
:clearable="false"
/>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.form-content {
padding: 8px 0;
}
.form-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
}
.form-hint {
font-size: 12px;
color: var(--text-secondary);
}
}
.form-row {
display: flex;
gap: 16px;
.form-col {
flex: 1;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,376 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTaskStore } from '@/stores/useTaskStore'
import { useUIStore } from '@/stores/useUIStore'
import type { TaskResponse } from '@/api/tasks'
import { ElMessageBox, ElMessage } from 'element-plus'
const props = defineProps<{
task: TaskResponse
}>()
const taskStore = useTaskStore()
const uiStore = useUIStore()
const isToggling = ref(false)
const isDeleting = ref(false)
const isAnimating = ref(false)
const showStars = ref(false)
const starPositions = [
{ x: -10, y: -10, delay: 0 },
{ x: 50, y: -15, delay: 50 },
{ x: 100, y: -10, delay: 100 },
{ x: -15, y: 30, delay: 75 },
{ x: 105, y: 35, delay: 125 }
]
const formattedDueDate = computed(() => {
if (!props.task.due_date) return null
const date = new Date(props.task.due_date)
const now = new Date()
const diff = date.getTime() - now.getTime()
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
if (days < 0) return { text: '已过期', class: 'overdue' }
if (days === 0) return { text: '今天截止', class: 'today' }
if (days === 1) return { text: '明天截止', class: 'tomorrow' }
return { text: `${date.getMonth() + 1}/${date.getDate()}`, class: '' }
})
const categoryColor = computed(() => props.task.category?.color)
const categoryName = computed(() => props.task.category?.name)
const tags = computed(() => props.task.tags ?? [])
async function handleToggle() {
if (isToggling.value) return
if (!props.task.is_completed) {
isAnimating.value = true
showStars.value = true
await new Promise(r => setTimeout(r, 500))
isAnimating.value = false
setTimeout(() => {
showStars.value = false
}, 600)
}
isToggling.value = true
try {
const result = await taskStore.toggleTask(props.task.id)
if (result && result.is_completed) {
ElMessage.success({
message: '太棒了!任务完成啦~',
duration: 2000
})
}
} finally {
isToggling.value = false
}
}
function handleEdit() {
uiStore.openTaskDialog(props.task)
}
async function handleDelete() {
try {
await ElMessageBox.confirm(
'确定要删除这个任务吗?删除后可就找不回来了呢~',
'确认删除',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
}
)
isDeleting.value = true
const success = await taskStore.deleteTask(props.task.id)
if (success) {
ElMessage.success('任务已删除')
}
} catch {
// 用户取消
} finally {
isDeleting.value = false
}
}
</script>
<template>
<div
class="quadrant-task-card"
:class="{
completed: task.is_completed,
'task-complete': isAnimating
}"
>
<div
v-if="showStars"
v-for="star in starPositions"
:key="`star-${star.delay}-${star.x}`"
class="star-burst"
:style="{
left: star.x + '%',
top: star.y + '%',
animationDelay: star.delay + 'ms'
}"
/>
<div class="card-content">
<el-checkbox
:model-value="task.is_completed"
:loading="isToggling"
size="small"
class="task-checkbox"
@change="handleToggle"
/>
<div class="task-info">
<div class="task-title-row">
<h4
class="task-title"
:class="{ 'line-through': task.is_completed }"
>
{{ task.title }}
</h4>
<span
v-if="formattedDueDate"
class="task-due"
:class="formattedDueDate.class"
>
<el-icon><Calendar /></el-icon>
{{ formattedDueDate.text }}
</span>
<el-button
text
size="small"
class="action-btn edit-btn"
@click="handleEdit"
>
<el-icon><Edit /></el-icon>
</el-button>
</div>
<div class="task-bottom-row">
<div v-if="categoryName || tags.length > 0" class="task-tags">
<span
v-if="categoryName"
class="meta-chip category-chip"
:style="{ '--chip-color': categoryColor }"
>
{{ categoryName }}
</span>
<span
v-for="tag in tags"
:key="tag.id"
class="meta-chip tag-chip"
>
#{{ tag.name }}
</span>
</div>
<el-button
text
size="small"
class="action-btn delete-btn"
:loading="isDeleting"
@click="handleDelete"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.quadrant-task-card {
position: relative;
padding: 10px 12px;
background: white;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
display: flex;
align-items: flex-start;
gap: 16px;
transition: all var(--transition-normal);
border-left: 3px solid var(--quadrant-color, var(--priority-q4));
overflow: hidden;
&:hover {
box-shadow: var(--shadow-md);
}
&.completed {
opacity: 0.6;
background: var(--background-dark);
.task-title {
text-decoration: line-through;
color: var(--text-secondary);
}
}
}
.task-checkbox {
flex-shrink: 0;
margin-right: 8px;
:deep(.el-checkbox__inner) {
border-radius: 50%;
border-color: var(--primary);
width: 18px;
height: 18px;
&::after {
height: 8px;
width: 4px;
}
}
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: var(--success);
border-color: var(--success);
}
:deep(.el-checkbox__label) {
display: none;
}
}
.card-content {
display: flex;
align-items: flex-start;
flex: 1;
min-width: 0;
}
.task-info {
flex: 1;
min-width: 0;
}
.task-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.task-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
&.line-through {
text-decoration: line-through;
}
}
.task-due {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
color: var(--text-secondary);
padding: 2px 6px;
background: var(--background-dark);
border-radius: 4px;
flex-shrink: 0;
&.overdue {
background: rgba(255, 107, 107, 0.15);
color: var(--danger);
}
&.today {
background: rgba(255, 179, 71, 0.15);
color: var(--warning);
}
&.tomorrow {
background: rgba(152, 216, 200, 0.15);
color: var(--success);
}
}
.task-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 1;
min-width: 0;
}
.task-bottom-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.meta-chip {
display: inline-flex;
align-items: center;
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
white-space: nowrap;
line-height: 1.5;
}
.category-chip {
background: color-mix(in srgb, var(--chip-color, #ccc) 15%, transparent);
color: var(--chip-color, var(--text-secondary));
font-weight: 500;
}
.tag-chip {
background: var(--background-dark);
color: var(--text-secondary);
}
.action-btn {
color: var(--text-secondary);
padding: 4px;
min-height: auto;
flex-shrink: 0;
&:hover {
color: var(--primary);
}
&.delete-btn:hover {
color: var(--danger);
}
}
.star-burst {
position: absolute;
width: 16px;
height: 16px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23FFB7C5'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E") no-repeat center;
animation: starBurst 0.6s ease forwards;
}
@keyframes starBurst {
0% {
opacity: 1;
transform: scale(0) translate(0, 0);
}
50% {
opacity: 1;
transform: scale(1) translate(10px, -10px);
}
100% {
opacity: 0;
transform: scale(0.5) translate(20px, -20px);
}
}
</style>

View File

@@ -0,0 +1,368 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTaskStore } from '@/stores/useTaskStore'
import { useUIStore } from '@/stores/useUIStore'
import type { Task } from '@/api/types'
import { ElMessageBox, ElMessage } from 'element-plus'
import { highlightMatch } from '@/utils/pinyin'
import { getPriorityConfig } from '@/utils/priority'
const props = defineProps<{
task: Task
}>()
const taskStore = useTaskStore()
const uiStore = useUIStore()
const isToggling = ref(false)
const isDeleting = ref(false)
const isAnimating = ref(false)
const showStars = ref(false)
// 星星位置配置
const starPositions = [
{ x: -10, y: -10, delay: 0 },
{ x: 50, y: -15, delay: 50 },
{ x: 100, y: -10, delay: 100 },
{ x: -15, y: 30, delay: 75 },
{ x: 105, y: 35, delay: 125 }
]
const priority = computed(() => getPriorityConfig(props.task.priority))
const formattedDueDate = computed(() => {
if (!props.task.due_date) return null
const date = new Date(props.task.due_date)
const now = new Date()
const diff = date.getTime() - now.getTime()
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
if (days < 0) return { text: '已过期', class: 'overdue' }
if (days === 0) return { text: '今天截止', class: 'today' }
if (days === 1) return { text: '明天截止', class: 'tomorrow' }
return { text: `${date.getMonth() + 1}/${date.getDate()}`, class: '' }
})
const categoryColor = computed(() => props.task.category?.color || 'var(--primary)')
// 搜索关键词
const searchKeyword = computed(() => taskStore.filters.search || '')
// 高亮后的标题和描述
const highlightedTitle = computed(() => {
if (!searchKeyword.value.trim()) return props.task.title
return highlightMatch(props.task.title, searchKeyword.value)
})
const highlightedDescription = computed(() => {
if (!searchKeyword.value.trim() || !props.task.description) return props.task.description
return highlightMatch(props.task.description, searchKeyword.value)
})
async function handleToggle() {
if (isToggling.value) return
// 如果当前任务未完成,先播放动画
if (!props.task.is_completed) {
isAnimating.value = true
showStars.value = true
// 等待动画播放
await new Promise(r => setTimeout(r, 500))
isAnimating.value = false
// 延迟隐藏星星,让动画播放完毕
setTimeout(() => {
showStars.value = false
}, 600)
}
isToggling.value = true
try {
const result = await taskStore.toggleTask(props.task.id)
if (result && result.is_completed) {
ElMessage.success({
message: '太棒了!任务完成啦~ ✨',
duration: 2000
})
}
} finally {
isToggling.value = false
}
}
function handleEdit() {
uiStore.openTaskDialog(props.task)
}
async function handleDelete() {
try {
await ElMessageBox.confirm(
'确定要删除这个任务吗?删除后可就找不回来了呢~',
'确认删除',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
}
)
isDeleting.value = true
const success = await taskStore.deleteTask(props.task.id)
if (success) {
ElMessage.success('任务已删除')
}
} catch {
// 用户取消
} finally {
isDeleting.value = false
}
}
</script>
<template>
<div
class="task-card card"
:class="{
completed: task.is_completed,
'task-complete': isAnimating
}"
>
<!-- 星星飞散效果 -->
<div
v-if="showStars"
v-for="(star, index) in starPositions"
:key="index"
class="star-burst"
:style="{
left: star.x + '%',
top: star.y + '%',
animationDelay: star.delay + 'ms'
}"
/>
<div class="card-header">
<el-checkbox
:model-value="task.is_completed"
:loading="isToggling"
size="large"
class="task-checkbox"
@change="handleToggle"
/>
<div class="task-priority" :style="{ background: priority?.color || 'var(--priority-q4)' }">
{{ priority?.label || 'Q4 不重要不紧急' }}
</div>
</div>
<div class="card-body">
<h3
class="task-title"
:class="{ 'line-through': task.is_completed }"
v-html="highlightedTitle"
/>
<p
v-if="task.description"
class="task-description"
v-html="highlightedDescription"
/>
<div class="task-meta">
<span
v-if="task.category"
class="task-category"
:style="{ background: categoryColor }"
>
{{ task.category.name }}
</span>
<span
v-if="formattedDueDate"
class="task-due"
:class="formattedDueDate.class"
>
<el-icon><Calendar /></el-icon>
{{ formattedDueDate.text }}
</span>
</div>
</div>
<div class="card-footer">
<div class="task-actions">
<el-button
text
size="small"
class="action-btn edit-btn"
@click="handleEdit"
>
<el-icon><Edit /></el-icon>
</el-button>
<el-button
text
size="small"
class="action-btn delete-btn"
:loading="isDeleting"
@click="handleDelete"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.task-card {
position: relative;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
transition: all var(--transition-normal);
cursor: default;
overflow: hidden;
&:hover {
transform: translateY(-4px);
.task-actions {
opacity: 1;
}
}
&.completed {
opacity: 0.7;
background: var(--background-dark);
.task-title {
text-decoration: line-through;
color: var(--text-secondary);
}
}
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.task-checkbox {
:deep(.el-checkbox__inner) {
border-radius: 50%;
border-color: var(--primary);
}
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: var(--success);
border-color: var(--success);
}
}
.task-priority {
font-size: 11px;
font-weight: 600;
padding: 4px 10px;
border-radius: 12px;
color: white;
text-transform: uppercase;
}
.card-body {
flex: 1;
}
.task-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.line-through {
text-decoration: line-through;
}
}
.task-description {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.task-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.task-category {
font-size: 11px;
padding: 4px 10px;
border-radius: 10px;
color: white;
}
.task-due {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-secondary);
padding: 4px 8px;
background: var(--background-dark);
border-radius: 8px;
&.overdue {
background: rgba(255, 107, 107, 0.15);
color: var(--danger);
}
&.today {
background: rgba(255, 179, 71, 0.15);
color: var(--warning);
}
&.tomorrow {
background: rgba(152, 216, 200, 0.15);
color: var(--success);
}
}
.card-footer {
display: flex;
justify-content: flex-end;
}
.task-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity var(--transition-fast);
}
.action-btn {
color: var(--text-secondary);
&:hover {
color: var(--primary);
}
&.delete-btn:hover {
color: var(--danger);
}
}
// 搜索高亮样式
:deep(.search-highlight) {
background: linear-gradient(135deg, rgba(255, 183, 197, 0.4) 0%, rgba(255, 183, 197, 0.6) 100%);
color: var(--primary-dark);
padding: 0 2px;
border-radius: 3px;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,330 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useTaskStore } from '@/stores/useTaskStore'
import { useCategoryStore } from '@/stores/useCategoryStore'
import { useTagStore } from '@/stores/useTagStore'
import { useUIStore } from '@/stores/useUIStore'
import type { TaskFormData } from '@/api/types'
import { ElMessage } from 'element-plus'
const taskStore = useTaskStore()
const categoryStore = useCategoryStore()
const tagStore = useTagStore()
const uiStore = useUIStore()
const form = ref<TaskFormData>({
title: '',
description: '',
priority: 'q4',
due_date: '',
category_id: undefined
})
const formRef = ref()
const loading = ref(false)
const rules = {
title: [
{ required: true, message: '请输入任务标题~', trigger: 'blur' },
{ min: 1, max: 200, message: '标题长度在 1 到 200 个字符~', trigger: 'blur' }
]
}
const isEdit = computed(() => !!uiStore.editingTask)
const dialogTitle = computed(() => isEdit.value ? '编辑任务' : '新建任务')
watch(() => uiStore.taskDialogVisible, (visible) => {
if (visible) {
if (uiStore.editingTask) {
const task = uiStore.editingTask
form.value = {
title: task.title,
description: task.description || '',
priority: task.priority,
due_date: task.due_date || '',
category_id: task.category_id,
tag_ids: task.tags?.map(t => t.id)
}
} else {
form.value = {
title: '',
description: '',
priority: 'q4',
due_date: '',
category_id: undefined
}
}
}
})
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
loading.value = true
try {
if (isEdit.value && uiStore.editingTask) {
await taskStore.updateTask(uiStore.editingTask.id, form.value)
ElMessage.success('任务已更新~')
} else {
await taskStore.createTask(form.value)
ElMessage.success('任务创建成功~')
}
uiStore.closeTaskDialog()
} finally {
loading.value = false
}
})
}
function handleClose() {
uiStore.closeTaskDialog()
}
</script>
<template>
<el-dialog
v-model="uiStore.taskDialogVisible"
:title="dialogTitle"
width="500px"
:close-on-click-modal="false"
class="task-dialog"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="task-form"
>
<el-form-item label="任务标题" prop="title">
<el-input
v-model="form.title"
placeholder="请输入任务标题~"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="任务描述">
<el-input
v-model="form.description"
type="textarea"
placeholder="添加一些描述吧~(可选)"
:rows="3"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="优先级">
<div class="quadrant-selector">
<el-radio-group
v-model="form.priority"
class="priority-group"
:class="`${form.priority}-selected`"
>
<el-radio-button value="q1">
<div class="priority-option q1">
<span class="priority-label">Q1</span>
<span class="priority-desc">重要紧急</span>
</div>
</el-radio-button>
<el-radio-button value="q2">
<div class="priority-option q2">
<span class="priority-label">Q2</span>
<span class="priority-desc">重要不紧急</span>
</div>
</el-radio-button>
<el-radio-button value="q3">
<div class="priority-option q3">
<span class="priority-label">Q3</span>
<span class="priority-desc">不重要紧急</span>
</div>
</el-radio-button>
<el-radio-button value="q4">
<div class="priority-option q4">
<span class="priority-label">Q4</span>
<span class="priority-desc">不重要不紧急</span>
</div>
</el-radio-button>
</el-radio-group>
</div>
</el-form-item>
<el-form-item label="截止日期">
<el-date-picker
v-model="form.due_date"
type="datetime"
placeholder="选择截止日期~(可选)"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DDTHH:mm:ss"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="分类">
<el-select
v-model="form.category_id"
placeholder="选择分类~(可选)"
clearable
style="width: 100%"
>
<el-option
v-for="category in categoryStore.categories"
:key="category.id"
:label="category.name"
:value="category.id"
>
<span class="category-option">
<span class="category-dot" :style="{ background: category.color }"></span>
{{ category.name }}
</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="标签">
<el-select
v-model="form.tag_ids"
multiple
placeholder="选择标签~(可选)"
clearable
style="width: 100%"
>
<el-option
v-for="tag in tagStore.tags"
:key="tag.id"
:label="tag.name"
:value="tag.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
:loading="loading"
@click="handleSubmit"
>
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.task-form {
padding: 8px 0;
}
.quadrant-selector {
width: 100%;
}
.priority-group {
width: 100%;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
:deep(.el-radio-button) {
.el-radio-button__inner {
width: 100%;
border-radius: var(--radius-md) !important;
border: 2px solid var(--secondary) !important;
background: var(--background);
padding: 12px;
transition: all 0.2s ease;
}
&:hover .el-radio-button__inner {
border-color: var(--primary) !important;
}
&.is-active {
.el-radio-button__inner {
background: white !important;
box-shadow: var(--shadow-md);
transform: scale(1.02);
}
}
}
}
// 每个象限选中时的特殊边框颜色
.priority-group.q1-selected :deep(.el-radio-button.is-active .el-radio-button__inner) {
border-color: var(--priority-q1) !important;
}
.priority-group.q2-selected :deep(.el-radio-button.is-active .el-radio-button__inner) {
border-color: var(--priority-q2) !important;
}
.priority-group.q3-selected :deep(.el-radio-button.is-active .el-radio-button__inner) {
border-color: var(--priority-q3) !important;
}
.priority-group.q4-selected :deep(.el-radio-button.is-active .el-radio-button__inner) {
border-color: var(--priority-q4) !important;
}
.priority-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
.priority-label {
font-size: 16px;
font-weight: 700;
}
.priority-desc {
font-size: 11px;
color: var(--text-secondary);
}
&.q1 { color: var(--priority-q1); }
&.q2 { color: var(--priority-q2); }
&.q3 { color: var(--priority-q3); }
&.q4 { color: var(--priority-q4); }
}
// 选中状态下的样式增强
.priority-group :deep(.el-radio-button.is-active) {
.priority-label {
transform: scale(1.1);
}
.priority-desc {
color: var(--text-primary) !important;
font-weight: 500;
}
}
.category-option {
display: flex;
align-items: center;
gap: 8px;
}
.category-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

21
WebUI/src/main.ts Normal file
View File

@@ -0,0 +1,21 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import '@/styles/main.scss'
import App from './App.vue'
import router from './router'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(ElementPlus)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')

78
WebUI/src/router/index.ts Normal file
View File

@@ -0,0 +1,78 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { useUserSettingsStore } from '@/stores/useUserSettingsStore'
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/tasks'
},
{
path: '/tasks',
name: 'tasks',
component: () => import('@/views/TaskListView.vue'),
meta: { title: '待办列表', view: 'list' }
},
{
path: '/calendar',
name: 'calendar',
component: () => import('@/views/CalendarPage.vue'),
meta: { title: '日历视图', view: 'calendar' }
},
{
path: '/quadrant',
name: 'quadrant',
component: () => import('@/views/QuadrantPage.vue'),
meta: { title: '四象限', view: 'quadrant' }
},
{
path: '/profile',
name: 'profile',
component: () => import('@/views/ProfileView.vue'),
meta: { title: '个人信息', view: 'profile' }
},
{
path: '/habits',
name: 'habits',
component: () => import('@/views/HabitPage.vue'),
meta: { title: '习惯打卡', view: 'habits' }
},
{
path: '/anniversaries',
name: 'anniversaries',
component: () => import('@/views/AnniversaryPage.vue'),
meta: { title: '纪念日', view: 'anniversaries' }
},
{
path: '/assets',
name: 'assets',
component: () => import('@/views/AssetPage.vue'),
meta: { title: '资产总览', view: 'assets' }
},
{
path: '/settings',
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
meta: { title: '偏好设置', view: 'settings' }
}
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(_to, _from, savedPosition) {
if (savedPosition) {
return savedPosition
}
return { top: 0 }
}
})
router.beforeEach((to) => {
const page = (to.meta.title as string) || ''
const userStore = useUserSettingsStore()
const siteName = userStore.siteName || '爱莉希雅待办'
document.title = page ? `${page} - ${siteName}` : siteName
})
export default router

View File

@@ -0,0 +1,208 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { accountApi } from '@/api/accounts'
import type {
FinancialAccount, AccountFormData, BalanceUpdateData,
AccountHistoryRecord, DebtInstallment, DebtInstallmentFormData
} from '@/api/types'
export const useAccountStore = defineStore('account', () => {
const accounts = ref<FinancialAccount[]>([])
const installments = ref<DebtInstallment[]>([])
const loading = ref(false)
const savingsAccounts = computed(() =>
accounts.value.filter(a => a.account_type === 'savings' && a.is_active)
)
const debtAccounts = computed(() =>
accounts.value.filter(a => a.account_type === 'debt' && a.is_active)
)
const totalSavings = computed(() =>
savingsAccounts.value.reduce((sum, a) => sum + a.balance, 0)
)
const totalDebt = computed(() =>
debtAccounts.value.reduce((sum, a) => sum + a.balance, 0)
)
const netAssets = computed(() => totalSavings.value - totalDebt.value)
const activeInstallments = computed(() =>
installments.value.filter(i => !i.is_completed && i.days_until_payment !== null)
)
const upcomingPayments = computed(() =>
activeInstallments.value
.filter(i => i.days_until_payment! >= 0)
.sort((a, b) => a.days_until_payment! - b.days_until_payment!)
)
// ============ 账户操作 ============
async function fetchAccounts() {
loading.value = true
try {
accounts.value = await accountApi.getAccounts()
} catch (error) {
console.error('获取账户列表失败:', error)
} finally {
loading.value = false
}
}
async function createAccount(data: AccountFormData): Promise<FinancialAccount | null> {
try {
const account = await accountApi.createAccount(data)
accounts.value.push(account)
return account
} catch (error) {
console.error('创建账户失败:', error)
return null
}
}
async function updateAccount(id: number, data: Partial<AccountFormData>): Promise<FinancialAccount | null> {
try {
const updated = await accountApi.updateAccount(id, data)
const index = accounts.value.findIndex(a => a.id === id)
if (index !== -1) {
accounts.value[index] = updated
}
return updated
} catch (error) {
console.error('更新账户失败:', error)
return null
}
}
async function deleteAccount(id: number): Promise<boolean> {
try {
await accountApi.deleteAccount(id)
accounts.value = accounts.value.filter(a => a.id !== id)
return true
} catch (error) {
console.error('删除账户失败:', error)
return false
}
}
async function updateBalance(id: number, data: BalanceUpdateData): Promise<FinancialAccount | null> {
try {
const updated = await accountApi.updateBalance(id, data)
const index = accounts.value.findIndex(a => a.id === id)
if (index !== -1) {
accounts.value[index] = updated
}
return updated
} catch (error) {
console.error('更新余额失败:', error)
return null
}
}
async function fetchHistory(id: number, page = 1, pageSize = 20): Promise<AccountHistoryResponse> {
try {
return await accountApi.getHistory(id, { page, page_size: pageSize })
} catch (error) {
console.error('获取变更历史失败:', error)
return { total: 0, page: 1, page_size: pageSize, records: [] }
}
}
// ============ 分期计划操作 ============
async function fetchInstallments() {
try {
installments.value = await accountApi.getInstallments()
} catch (error) {
console.error('获取分期计划失败:', error)
}
}
async function createInstallment(data: DebtInstallmentFormData): Promise<DebtInstallment | null> {
try {
const inst = await accountApi.createInstallment(data)
installments.value.push(inst)
installments.value.sort((a, b) => {
const aActive = !a.is_completed && a.days_until_payment !== null ? 0 : 1
const bActive = !b.is_completed && b.days_until_payment !== null ? 0 : 1
if (aActive !== bActive) return aActive - bActive
return (a.days_until_payment ?? 9999) - (b.days_until_payment ?? 9999)
})
return inst
} catch (error) {
console.error('创建分期计划失败:', error)
return null
}
}
async function updateInstallment(id: number, data: Partial<DebtInstallmentFormData>): Promise<DebtInstallment | null> {
try {
const updated = await accountApi.updateInstallment(id, data)
const index = installments.value.findIndex(i => i.id === id)
if (index !== -1) {
installments.value[index] = updated
}
return updated
} catch (error) {
console.error('更新分期计划失败:', error)
return null
}
}
async function deleteInstallment(id: number): Promise<boolean> {
try {
await accountApi.deleteInstallment(id)
installments.value = installments.value.filter(i => i.id !== id)
return true
} catch (error) {
console.error('删除分期计划失败:', error)
return false
}
}
async function payInstallment(id: number): Promise<DebtInstallment | null> {
try {
const updated = await accountApi.payInstallment(id)
const index = installments.value.findIndex(i => i.id === id)
if (index !== -1) {
installments.value[index] = updated
}
return updated
} catch (error) {
console.error('标记还款失败:', error)
return null
}
}
async function init() {
await Promise.all([fetchAccounts(), fetchInstallments()])
}
return {
accounts,
installments,
loading,
savingsAccounts,
debtAccounts,
totalSavings,
totalDebt,
netAssets,
activeInstallments,
upcomingPayments,
fetchAccounts,
createAccount,
updateAccount,
deleteAccount,
updateBalance,
fetchHistory,
fetchInstallments,
createInstallment,
updateInstallment,
deleteInstallment,
payInstallment,
init,
}
})

View File

@@ -0,0 +1,180 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { anniversaryApi } from '@/api/anniversaries'
import type { Anniversary, AnniversaryFormData, AnniversaryCategory, AnniversaryCategoryFormData } from '@/api/types'
export const useAnniversaryStore = defineStore('anniversary', () => {
const anniversaries = ref<Anniversary[]>([])
const categories = ref<AnniversaryCategory[]>([])
const loading = ref(false)
const activeCategoryId = ref<number | null>(null)
const filteredAnniversaries = computed(() => {
if (activeCategoryId.value === null) {
return anniversaries.value
}
return anniversaries.value.filter(a => a.category_id === activeCategoryId.value)
})
const upcomingAnniversaries = computed(() =>
filteredAnniversaries.value.filter(a => a.days_until !== null && a.days_until! >= 0)
)
const pastAnniversaries = computed(() =>
filteredAnniversaries.value.filter(a => a.days_until === null || a.days_until! < 0)
)
const todayAnniversaries = computed(() =>
filteredAnniversaries.value.filter(a => a.days_until === 0)
)
const remindAnniversaries = computed(() =>
filteredAnniversaries.value.filter(a =>
a.days_until !== null && a.days_until! >= 0 && a.days_until! <= a.remind_days_before
)
)
// ============ 纪念日操作 ============
async function fetchAnniversaries() {
loading.value = true
try {
const params = activeCategoryId.value !== null
? { category_id: activeCategoryId.value }
: undefined
anniversaries.value = await anniversaryApi.getAnniversaries(params)
} catch (error) {
console.error('获取纪念日列表失败:', error)
} finally {
loading.value = false
}
}
async function createAnniversary(data: AnniversaryFormData): Promise<Anniversary | null> {
try {
const newAnniversary = await anniversaryApi.createAnniversary(data)
anniversaries.value.unshift(newAnniversary)
reSort()
return newAnniversary
} catch (error) {
console.error('创建纪念日失败:', error)
return null
}
}
async function updateAnniversary(id: number, data: Partial<AnniversaryFormData>): Promise<Anniversary | null> {
try {
const updated = await anniversaryApi.updateAnniversary(id, data)
const index = anniversaries.value.findIndex(a => a.id === id)
if (index !== -1) {
anniversaries.value[index] = updated
}
reSort()
return updated
} catch (error) {
console.error('更新纪念日失败:', error)
return null
}
}
async function deleteAnniversary(id: number): Promise<boolean> {
try {
await anniversaryApi.deleteAnniversary(id)
anniversaries.value = anniversaries.value.filter(a => a.id !== id)
return true
} catch (error) {
console.error('删除纪念日失败:', error)
return false
}
}
// ============ 分类操作 ============
async function fetchCategories() {
try {
categories.value = await anniversaryApi.getCategories()
} catch (error) {
console.error('获取纪念日分类失败:', error)
}
}
async function createCategory(data: AnniversaryCategoryFormData): Promise<AnniversaryCategory | null> {
try {
const newCat = await anniversaryApi.createCategory(data)
categories.value.push(newCat)
categories.value.sort((a, b) => a.sort_order - b.sort_order)
return newCat
} catch (error) {
console.error('创建纪念日分类失败:', error)
return null
}
}
async function updateCategory(id: number, data: Partial<AnniversaryCategoryFormData>): Promise<AnniversaryCategory | null> {
try {
const updated = await anniversaryApi.updateCategory(id, data)
const index = categories.value.findIndex(c => c.id === id)
if (index !== -1) {
categories.value[index] = updated
}
categories.value.sort((a, b) => a.sort_order - b.sort_order)
return updated
} catch (error) {
console.error('更新纪念日分类失败:', error)
return null
}
}
async function deleteCategory(id: number): Promise<boolean> {
try {
await anniversaryApi.deleteCategory(id)
categories.value = categories.value.filter(c => c.id !== id)
if (activeCategoryId.value === id) {
activeCategoryId.value = null
}
return true
} catch (error) {
console.error('删除纪念日分类失败:', error)
return false
}
}
function setFilter(categoryId: number | null) {
activeCategoryId.value = categoryId
}
function reSort() {
anniversaries.value.sort((a, b) => {
const aUpcoming = a.days_until !== null && a.days_until >= 0 ? 0 : 1
const bUpcoming = b.days_until !== null && b.days_until >= 0 ? 0 : 1
if (aUpcoming !== bUpcoming) return aUpcoming - bUpcoming
return (a.days_until ?? 9999) - (b.days_until ?? 9999)
})
}
async function init() {
await Promise.all([fetchAnniversaries(), fetchCategories()])
}
return {
anniversaries,
categories,
loading,
activeCategoryId,
filteredAnniversaries,
upcomingAnniversaries,
pastAnniversaries,
todayAnniversaries,
remindAnniversaries,
fetchAnniversaries,
createAnniversary,
updateAnniversary,
deleteAnniversary,
fetchCategories,
createCategory,
updateCategory,
deleteCategory,
setFilter,
init,
}
})

View File

@@ -0,0 +1,65 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { categoryApi, type CategoryResponse } from '@/api/categories'
import type { CategoryFormData } from '@/api/types'
export const useCategoryStore = defineStore('category', () => {
const categories = ref<CategoryResponse[]>([])
const loading = ref(false)
async function fetchCategories() {
loading.value = true
try {
categories.value = await categoryApi.getCategories()
} catch (error) {
console.error('获取分类列表失败:', error)
} finally {
loading.value = false
}
}
async function createCategory(data: CategoryFormData) {
try {
const newCategory = await categoryApi.createCategory(data)
categories.value.push(newCategory)
return newCategory
} catch (error) {
console.error('创建分类失败:', error)
return null
}
}
async function updateCategory(id: number, data: CategoryFormData) {
try {
const updatedCategory = await categoryApi.updateCategory(id, data)
const index = categories.value.findIndex((c: CategoryResponse) => c.id === id)
if (index !== -1) {
categories.value[index] = updatedCategory
}
return updatedCategory
} catch (error) {
console.error('更新分类失败:', error)
return null
}
}
async function deleteCategory(id: number) {
try {
await categoryApi.deleteCategory(id)
categories.value = categories.value.filter((c: CategoryResponse) => c.id !== id)
return true
} catch (error) {
console.error('删除分类失败:', error)
return false
}
}
return {
categories,
loading,
fetchCategories,
createCategory,
updateCategory,
deleteCategory
}
})

View File

@@ -0,0 +1,227 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { habitApi, habitGroupApi, type HabitResponse, type HabitGroupResponse, type HabitStatsResponse } from '@/api/habits'
import type { HabitFormData, HabitGroupFormData } from '@/api/types'
export const useHabitStore = defineStore('habit', () => {
const habits = ref<HabitResponse[]>([])
const groups = ref<HabitGroupResponse[]>([])
const statsMap = ref<Record<number, HabitStatsResponse>>({})
const loading = ref(false)
// 按分组组织习惯
const groupedHabits = computed(() => {
const map = new Map<number, { group: HabitGroupResponse | null; habits: HabitResponse[] }>()
// 先添加有分组的
for (const group of groups.value) {
map.set(group.id, { group, habits: [] })
}
for (const habit of habits.value) {
if (habit.group_id && map.has(habit.group_id)) {
map.get(habit.group_id)!.habits.push(habit)
} else {
// 未分组
if (!map.has(0)) {
map.set(0, { group: null, habits: [] })
}
map.get(0)!.habits.push(habit)
}
}
return Array.from(map.values())
})
// 今日统计
const todaySummary = computed(() => {
const todayHabits = habits.value.filter(h => !h.is_archived)
const total = todayHabits.length
let completed = 0
let maxStreak = 0
for (const habit of todayHabits) {
const stats = statsMap.value[habit.id]
if (stats?.today_completed) completed++
if (stats && stats.current_streak > maxStreak) maxStreak = stats.current_streak
}
return { total, completed, maxStreak }
})
async function fetchHabits(includeArchived = false) {
loading.value = true
try {
habits.value = await habitApi.getHabits({ include_archived: includeArchived })
} catch (error) {
console.error('获取习惯列表失败:', error)
} finally {
loading.value = false
}
}
async function fetchGroups() {
try {
groups.value = await habitGroupApi.getGroups()
} catch (error) {
console.error('获取习惯分组失败:', error)
}
}
async function fetchStats(habitId: number) {
try {
statsMap.value[habitId] = await habitApi.getStats(habitId)
} catch (error) {
console.error('获取习惯统计失败:', error)
}
}
async function fetchAllStats() {
for (const habit of habits.value) {
await fetchStats(habit.id)
}
}
async function createHabit(data: HabitFormData) {
try {
const newHabit = await habitApi.createHabit(data)
habits.value.unshift(newHabit)
await fetchStats(newHabit.id)
return newHabit
} catch (error) {
console.error('创建习惯失败:', error)
return null
}
}
async function updateHabit(id: number, data: Partial<HabitFormData>) {
try {
const updated = await habitApi.updateHabit(id, data)
const index = habits.value.findIndex(h => h.id === id)
if (index !== -1) habits.value[index] = updated
return updated
} catch (error) {
console.error('更新习惯失败:', error)
return null
}
}
async function deleteHabit(id: number) {
try {
await habitApi.deleteHabit(id)
habits.value = habits.value.filter(h => h.id !== id)
delete statsMap.value[id]
return true
} catch (error) {
console.error('删除习惯失败:', error)
return false
}
}
async function toggleArchive(id: number) {
try {
const updated = await habitApi.toggleArchive(id)
const index = habits.value.findIndex(h => h.id === id)
if (index !== -1) habits.value[index] = updated
return updated
} catch (error) {
console.error('切换归档状态失败:', error)
return null
}
}
async function checkin(habitId: number, count?: number) {
try {
const result = await habitApi.checkin(habitId, count)
await fetchStats(habitId)
return result
} catch (error) {
console.error('打卡失败:', error)
return null
}
}
async function cancelCheckin(habitId: number, count: number = 1) {
try {
await habitApi.cancelCheckin(habitId, count)
await fetchStats(habitId)
return true
} catch (error) {
console.error('取消打卡失败:', error)
return false
}
}
async function createGroup(data: HabitGroupFormData) {
try {
const newGroup = await habitGroupApi.createGroup(data)
groups.value.push(newGroup)
return newGroup
} catch (error) {
console.error('创建分组失败:', error)
return null
}
}
async function updateGroup(id: number, data: Partial<HabitGroupFormData>) {
try {
const updated = await habitGroupApi.updateGroup(id, data)
const index = groups.value.findIndex(g => g.id === id)
if (index !== -1) groups.value[index] = updated
// 同步更新 habits 中的 group 引用
for (const habit of habits.value) {
if (habit.group_id === id) habit.group = updated
}
return updated
} catch (error) {
console.error('更新分组失败:', error)
return null
}
}
async function deleteGroup(id: number) {
try {
await habitGroupApi.deleteGroup(id)
groups.value = groups.value.filter(g => g.id !== id)
// 清空关联习惯的 group_id
for (const habit of habits.value) {
if (habit.group_id === id) {
habit.group_id = undefined
habit.group = undefined
}
}
return true
} catch (error) {
console.error('删除分组失败:', error)
return false
}
}
async function init() {
await Promise.all([fetchGroups(), fetchHabits()])
await fetchAllStats()
}
return {
habits,
groups,
statsMap,
loading,
groupedHabits,
todaySummary,
fetchHabits,
fetchGroups,
fetchStats,
fetchAllStats,
createHabit,
updateHabit,
deleteHabit,
toggleArchive,
checkin,
cancelCheckin,
createGroup,
updateGroup,
deleteGroup,
init
}
})

View File

@@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { tagApi, type TagResponse } from '@/api/tags'
import type { TagFormData } from '@/api/types'
export const useTagStore = defineStore('tag', () => {
const tags = ref<TagResponse[]>([])
const loading = ref(false)
async function fetchTags() {
loading.value = true
try {
tags.value = await tagApi.getTags()
} catch (error) {
console.error('获取标签列表失败:', error)
} finally {
loading.value = false
}
}
async function createTag(data: TagFormData) {
try {
const newTag = await tagApi.createTag(data)
tags.value.push(newTag)
return newTag
} catch (error) {
console.error('创建标签失败:', error)
return null
}
}
async function deleteTag(id: number) {
try {
await tagApi.deleteTag(id)
tags.value = tags.value.filter((t: TagResponse) => t.id !== id)
return true
} catch (error) {
console.error('删除标签失败:', error)
return false
}
}
return {
tags,
loading,
fetchTags,
createTag,
deleteTag
}
})

View File

@@ -0,0 +1,180 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { taskApi, type TaskResponse } from '@/api/tasks'
import type { TaskFormData, TaskFilters } from '@/api/types'
import { matchWithPinyin } from '@/utils/pinyin'
import { formatDate } from '@/utils/date'
export const useTaskStore = defineStore('task', () => {
const allTasks = ref<TaskResponse[]>([])
const loading = ref(false)
const filters = ref<TaskFilters>({
status: 'all',
sort_by: 'priority',
sort_order: 'desc'
})
// 所有任务
const tasks = computed(() => {
let result = [...allTasks.value]
// 搜索过滤(支持拼音)
if (filters.value.search?.trim()) {
const keyword = filters.value.search.trim()
result = result.filter(t =>
matchWithPinyin(t.title, keyword) ||
(t.description && matchWithPinyin(t.description, keyword)) ||
t.tags?.some(tag => matchWithPinyin(tag.name, keyword))
)
}
// 状态筛选
if (filters.value.status === 'active') {
result = result.filter(t => !t.is_completed)
} else if (filters.value.status === 'completed') {
result = result.filter(t => t.is_completed)
}
// 分类筛选
if (filters.value.category_id) {
result = result.filter(t => t.category_id === filters.value.category_id)
}
// 排序
if (filters.value.sort_by) {
result.sort((a, b) => {
let comparison = 0
if (filters.value.sort_by === 'priority') {
const priorityOrder: Record<string, number> = { q1: 4, q2: 3, q3: 2, q4: 1 }
const aOrder = priorityOrder[a.priority] || 0
const bOrder = priorityOrder[b.priority] || 0
comparison = aOrder - bOrder
} else if (filters.value.sort_by === 'due_date') {
if (!a.due_date && !b.due_date) comparison = 0
else if (!a.due_date) comparison = 1
else if (!b.due_date) comparison = -1
else comparison = new Date(a.due_date).getTime() - new Date(b.due_date).getTime()
} else {
comparison = new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
}
return filters.value.sort_order === 'asc' ? -comparison : comparison
})
}
return result
})
// 计算进行中和已完成的任务数量(基于所有任务)
const activeTasks = computed(() => allTasks.value.filter(t => !t.is_completed))
const completedTasks = computed(() => allTasks.value.filter(t => t.is_completed))
const totalTasks = computed(() => allTasks.value)
// 按日期分组的任务(用于月视图)
const tasksByDate = computed(() => {
const grouped = new Map<string, TaskResponse[]>()
const today = new Date()
const todayStr = formatDate(today)
allTasks.value.forEach(task => {
let dateKey: string
if (task.due_date) {
dateKey = formatDate(new Date(task.due_date))
} else {
// 无截止日期的任务显示在当天
dateKey = todayStr
}
if (!grouped.has(dateKey)) {
grouped.set(dateKey, [])
}
grouped.get(dateKey)!.push(task)
})
return grouped
})
async function fetchTasks() {
loading.value = true
try {
// 一次获取所有任务,在前端进行筛选和排序
allTasks.value = await taskApi.getTasks({ status: 'all' })
} catch (error) {
console.error('获取任务列表失败:', error)
} finally {
loading.value = false
}
}
async function createTask(data: TaskFormData) {
try {
const newTask = await taskApi.createTask(data)
allTasks.value.unshift(newTask)
return newTask
} catch (error) {
console.error('创建任务失败:', error)
return null
}
}
async function updateTask(id: number, data: TaskFormData) {
try {
const updatedTask = await taskApi.updateTask(id, data)
const index = allTasks.value.findIndex((t: TaskResponse) => t.id === id)
if (index !== -1) {
allTasks.value[index] = updatedTask
}
return updatedTask
} catch (error) {
console.error('更新任务失败:', error)
return null
}
}
async function deleteTask(id: number) {
try {
await taskApi.deleteTask(id)
allTasks.value = allTasks.value.filter((t: TaskResponse) => t.id !== id)
return true
} catch (error) {
console.error('删除任务失败:', error)
return false
}
}
async function toggleTask(id: number) {
try {
const updatedTask = await taskApi.toggleTask(id)
const index = allTasks.value.findIndex((t: TaskResponse) => t.id === id)
if (index !== -1) {
allTasks.value[index] = updatedTask
}
return updatedTask
} catch (error) {
console.error('切换任务状态失败:', error)
return null
}
}
function setFilters(newFilters: TaskFilters) {
filters.value = { ...filters.value, ...newFilters }
// 筛选现在在前端完成,不需要重新请求
}
return {
tasks,
loading,
filters,
activeTasks,
completedTasks,
totalTasks: allTasks,
tasksByDate,
fetchTasks,
createTask,
updateTask,
deleteTask,
toggleTask,
setFilters
}
})

View File

@@ -0,0 +1,72 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import type { Task, Category } from '@/api/types'
export const useUIStore = defineStore('ui', () => {
const router = useRouter()
const taskDialogVisible = ref(false)
const editingTask = ref<Task | null>(null)
const categoryDialogVisible = ref(false)
const editingCategory = ref<Category | null>(null)
const sidebarCollapsed = ref(false)
const globalLoading = ref(false)
const currentView = ref<'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries' | 'assets'>('list')
const calendarMode = ref<'week' | 'monthly'>('monthly')
function openTaskDialog(task?: Task) {
editingTask.value = task || null
taskDialogVisible.value = true
}
function closeTaskDialog() {
taskDialogVisible.value = false
editingTask.value = null
}
function openCategoryDialog(category?: Category) {
editingCategory.value = category || null
categoryDialogVisible.value = true
}
function closeCategoryDialog() {
categoryDialogVisible.value = false
editingCategory.value = null
}
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
function setLoading(loading: boolean) {
globalLoading.value = loading
}
function setCurrentView(view: 'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries' | 'assets') {
currentView.value = view
}
function setCalendarMode(mode: 'week' | 'monthly') {
calendarMode.value = mode
}
return {
taskDialogVisible,
editingTask,
categoryDialogVisible,
editingCategory,
sidebarCollapsed,
globalLoading,
currentView,
calendarMode,
openTaskDialog,
closeTaskDialog,
openCategoryDialog,
closeCategoryDialog,
toggleSidebar,
setLoading,
setCurrentView,
setCalendarMode
}
})

View File

@@ -0,0 +1,74 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { UserSettings, UserSettingsUpdate } from '@/api/types'
import { getUserSettings, updateUserSettings as apiUpdateSettings } from '@/api/userSettings'
export const useUserSettingsStore = defineStore('userSettings', () => {
const settings = ref<UserSettings | null>(null)
const loading = ref(false)
async function fetchSettings() {
loading.value = true
try {
settings.value = await getUserSettings()
} finally {
loading.value = false
}
}
async function updateSettings(data: UserSettingsUpdate) {
loading.value = true
try {
settings.value = await apiUpdateSettings(data)
} finally {
loading.value = false
}
}
const nickname = ref('爱莉希雅')
const avatar = ref('')
const signature = ref('')
const birthday = ref('')
const email = ref('')
const siteName = ref('爱莉希雅待办')
const defaultView = ref('list')
const defaultSortBy = ref('priority')
const defaultSortOrder = ref('desc')
function syncFromSettings(s: UserSettings) {
nickname.value = s.nickname || ''
avatar.value = s.avatar || ''
signature.value = s.signature || ''
birthday.value = s.birthday || ''
email.value = s.email || ''
siteName.value = s.site_name || '爱莉希雅待办'
defaultView.value = s.default_view || 'list'
defaultSortBy.value = s.default_sort_by || 'priority'
defaultSortOrder.value = s.default_sort_order || 'desc'
}
async function fetchAndSync() {
await fetchSettings()
if (settings.value) {
syncFromSettings(settings.value)
}
}
return {
settings,
loading,
fetchSettings,
updateSettings,
fetchAndSync,
nickname,
avatar,
signature,
birthday,
email,
siteName,
defaultView,
defaultSortBy,
defaultSortOrder,
syncFromSettings
}
})

View File

@@ -0,0 +1,33 @@
// 爱莉希雅主题配色方案
$primary: #FFB7C5;
$secondary: #FFC0CB;
$accent: #C8A2C8;
$background: #FFF5F7;
$background-dark: #F8F0F5;
$text-primary: #8B4557;
$text-secondary: #A86A7A;
$success: #98D8C8;
$warning: #FFB347;
$danger: #FF6B6B;
// 四象限优先级颜色
$priority-q1: #FF6B6B; // 重要紧急 - 紧急红
$priority-q2: #FFB347; // 重要不紧急 - 警示橙
$priority-q3: #98D8C8; // 不重要紧急 - 平和绿
$priority-q4: #C8A2C8; // 不重要不紧急 - 淡雅紫
// 圆角
$radius-sm: 8px;
$radius-md: 12px;
$radius-lg: 16px;
$radius-xl: 24px;
// 阴影
$shadow-sm: 0 2px 8px rgba(255, 183, 197, 0.15);
$shadow-md: 0 4px 16px rgba(255, 183, 197, 0.2);
$shadow-lg: 0 8px 32px rgba(255, 183, 197, 0.25);
// 过渡
$transition-fast: 150ms ease;
$transition-normal: 300ms ease;
$transition-slow: 500ms ease;

340
WebUI/src/styles/main.scss Normal file
View File

@@ -0,0 +1,340 @@
// 爱莉希雅主题全局样式
$primary: #FFB7C5;
$secondary: #FFC0CB;
$accent: #C8A2C8;
$background: #FFF5F7;
$background-dark: #F8F0F5;
$text-primary: #8B4557;
$text-secondary: #A86A7A;
$success: #98D8C8;
$warning: #FFB347;
$danger: #FF6B6B;
$priority-q1: #FF6B6B;
$priority-q2: #FFB347;
$priority-q3: #98D8C8;
$priority-q4: #C8A2C8;
$radius-sm: 8px;
$radius-md: 12px;
$radius-lg: 16px;
$radius-xl: 24px;
$shadow-sm: 0 2px 8px rgba(255, 183, 197, 0.15);
$shadow-md: 0 4px 16px rgba(255, 183, 197, 0.2);
$shadow-lg: 0 8px 32px rgba(255, 183, 197, 0.25);
$transition-fast: 150ms ease;
$transition-normal: 300ms ease;
$transition-slow: 500ms ease;
:root {
--primary: #{$primary};
--secondary: #{$secondary};
--accent: #{$accent};
--background: #{$background};
--background-dark: #{$background-dark};
--text-primary: #{$text-primary};
--text-secondary: #{$text-secondary};
--success: #{$success};
--warning: #{$warning};
--danger: #{$danger};
--priority-q1: #{$priority-q1};
--priority-q2: #{$priority-q2};
--priority-q3: #{$priority-q3};
--priority-q4: #{$priority-q4};
--radius-sm: #{$radius-sm};
--radius-md: #{$radius-md};
--radius-lg: #{$radius-lg};
--radius-xl: #{$radius-xl};
--shadow-sm: #{$shadow-sm};
--shadow-md: #{$shadow-md};
--shadow-lg: #{$shadow-lg};
--transition-fast: #{$transition-fast};
--transition-normal: #{$transition-normal};
--transition-slow: #{$transition-slow};
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', sans-serif;
font-size: 14px;
color: var(--text-primary);
background: var(--background);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
min-height: 100vh;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 3px;
&:hover {
background: var(--accent);
}
}
.page-enter-active,
.page-leave-active {
transition: opacity $transition-normal, transform $transition-normal;
}
.page-enter-from {
opacity: 0;
transform: translateY(10px);
}
.page-leave-to {
opacity: 0;
transform: translateY(-10px);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in-up {
animation: fadeInUp $transition-normal ease forwards;
}
@keyframes completeTask {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
.task-complete {
animation: completeTask 0.3s ease;
}
@keyframes starBurst {
0% {
opacity: 1;
transform: scale(0) rotate(0deg);
}
50% {
opacity: 1;
transform: scale(1) rotate(180deg);
}
100% {
opacity: 0;
transform: scale(0) rotate(360deg);
}
}
.star-burst {
position: absolute;
width: 20px;
height: 20px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23FFB7C5'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E") no-repeat center;
animation: starBurst 0.6s ease forwards;
}
.btn-glow {
transition: box-shadow $transition-fast, transform $transition-fast;
&:hover {
box-shadow: 0 0 20px rgba(255, 183, 197, 0.5);
}
&:active {
transform: scale(0.95);
}
}
// Element Plus 按钮主题覆盖(粉色主题)
.el-button--primary {
--el-button-bg-color: var(--primary);
--el-button-border-color: var(--primary);
--el-button-hover-bg-color: #ffaabb;
--el-button-hover-border-color: #ffaabb;
--el-button-hover-text-color: white;
--el-button-active-bg-color: var(--accent);
--el-button-active-border-color: var(--accent);
--el-button-active-text-color: white;
--el-button-text-color: white;
--el-button-disabled-bg-color: #ffd6e0;
--el-button-disabled-border-color: #ffd6e0;
--el-button-disabled-text-color: white;
}
// 默认按钮 hover 也不变蓝
.el-button--default {
--el-button-hover-bg-color: rgba(255, 183, 197, 0.1);
--el-button-hover-border-color: var(--primary);
--el-button-hover-text-color: var(--text-primary);
}
// text 类型按钮 hover 变粉色
.el-button--default:not(.el-button--primary):not(.is-disabled):hover {
color: var(--text-primary) !important;
border-color: var(--primary) !important;
background-color: rgba(255, 183, 197, 0.08) !important;
}
// link 类型按钮
.el-button.is-link:not(.is-disabled):hover {
color: var(--text-primary) !important;
}
.el-button--primary.is-link:not(.is-disabled):hover {
color: var(--text-primary) !important;
}
// Switch 开关颜色
.el-switch {
--el-switch-on-color: #{$primary};
}
.el-switch.is-checked .el-switch__core {
background-color: #{$primary} !important;
border-color: #{$primary} !important;
}
.el-switch.is-checked .el-switch__action {
left: calc(100% - 20px);
color: white;
}
// 单选框颜色
.el-radio-button__original-radio:checked + .el-radio-button__inner {
background-color: var(--primary) !important;
border-color: var(--primary) !important;
box-shadow: -1px 0 0 0 var(--primary) !important;
}
.el-radio.is-checked .el-radio__inner {
background-color: var(--primary) !important;
border-color: var(--primary) !important;
}
.el-radio.is-checked .el-radio__label {
color: var(--text-primary) !important;
}
.el-radio__inner:hover {
border-color: var(--primary) !important;
}
// Message box 确认按钮
.el-message-box__btns .el-button--primary {
background-color: var(--primary);
border-color: var(--primary);
}
.el-message-box__btns .el-button--primary:hover {
background-color: #ffaabb;
border-color: #ffaabb;
}
.el-input__wrapper {
border-radius: var(--radius-md) !important;
box-shadow: none !important;
border: 1px solid var(--secondary) !important;
&:hover {
border-color: var(--primary) !important;
}
&.is-focus {
border-color: var(--primary) !important;
box-shadow: 0 0 0 2px rgba(255, 183, 197, 0.2) !important;
}
}
.el-select .el-input__wrapper {
border-radius: var(--radius-md) !important;
}
// Element Plus 下拉菜单选中项样式
.el-select-dropdown__item.is-selected {
color: var(--primary) !important;
}
.el-select-dropdown__item.hover,
.el-select-dropdown__item:hover {
background-color: var(--background) !important;
}
.el-dialog {
border-radius: var(--radius-lg) !important;
.el-dialog__header {
border-bottom: 1px solid var(--background-dark);
padding-bottom: 16px;
}
.el-dialog__title {
color: var(--text-primary);
font-weight: 600;
}
}
.el-message {
border-radius: var(--radius-md) !important;
}
.card {
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: box-shadow $transition-normal, transform $transition-normal;
&:hover {
box-shadow: var(--shadow-md);
}
}
.decoration-star {
position: absolute;
width: 16px;
height: 16px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23FFB7C5'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E") no-repeat center;
opacity: 0.6;
animation: twinkle 2s ease-in-out infinite;
}
@keyframes twinkle {
0%, 100% {
opacity: 0.6;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
@media (max-width: 768px) {
html, body {
font-size: 13px;
}
}

113
WebUI/src/utils/date.ts Normal file
View File

@@ -0,0 +1,113 @@
/**
* 日期工具函数
*/
/**
* 格式化日期为 YYYY-MM-DD 格式
* @param date 日期对象
* @returns 格式化后的日期字符串
*/
export function formatDate(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
/**
* 格式化时间为 HH:mm 格式
* @param date 日期对象
* @returns 格式化后的时间字符串
*/
export function formatTime(date: Date): string {
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
/**
* 获取某一周的第一天(周日)
* @param date 日期对象
* @returns 该周第一天的日期对象
*/
export function getWeekStart(date: Date): Date {
const d = new Date(date)
const day = d.getDay()
d.setDate(d.getDate() - day)
d.setHours(0, 0, 0, 0)
return d
}
/**
* 获取某一周的最后一天(周六)
* @param date 日期对象
* @returns 该周最后一天的日期对象
*/
export function getWeekEnd(date: Date): Date {
const d = new Date(date)
const day = d.getDay()
d.setDate(d.getDate() + (6 - day))
d.setHours(23, 59, 59, 999)
return d
}
/**
* 获取一周的所有日期
* @param date 参考日期
* @returns 7天的日期数组周日到周六
*/
export function getWeekDays(date: Date): Date[] {
const weekStart = getWeekStart(date)
const days: Date[] = []
for (let i = 0; i < 7; i++) {
const d = new Date(weekStart)
d.setDate(weekStart.getDate() + i)
days.push(d)
}
return days
}
/**
* 获取一年中的第几周
* @param date 日期对象
* @returns 周数1-53
*/
export function getWeekNumber(date: Date): number {
const d = new Date(date)
d.setHours(0, 0, 0, 0)
// 设置为周四ISO周定义
d.setDate(d.getDate() + 4 - (d.getDay() || 7))
// 获取年初
const yearStart = new Date(d.getFullYear(), 0, 1)
// 计算周数
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)
}
/**
* 判断两个日期是否是同一天
* @param date1 日期1
* @param date2 日期2
* @returns 是否是同一天
*/
export function isSameDay(date1: Date, date2: Date): boolean {
return formatDate(date1) === formatDate(date2)
}
/**
* 判断日期是否是今天
* @param date 日期对象
* @returns 是否是今天
*/
export function isToday(date: Date): boolean {
return isSameDay(date, new Date())
}
/**
* 获取星期几的中文名称
* @param dayOfWeek 星期几0-60为周日
* @returns 中文名称
*/
export function getWeekDayName(dayOfWeek: number): string {
const names = ['日', '一', '二', '三', '四', '五', '六']
return names[dayOfWeek] ?? ''
}

177
WebUI/src/utils/pinyin.ts Normal file
View File

@@ -0,0 +1,177 @@
import { pinyin } from 'pinyin-pro'
/**
* 将中文文本转换为拼音首字母(小写)
* @param text 中文文本
* @returns 拼音首字母字符串
*/
export function toPinyinInitials(text: string): string {
return pinyin(text, { pattern: 'first', toneType: 'none' })
.replace(/\s/g, '')
.toLowerCase()
}
/**
* 将中文文本转换为完整拼音(小写)
* @param text 中文文本
* @returns 完整拼音字符串
*/
export function toPinyinFull(text: string): string {
return pinyin(text, { pattern: 'pinyin', toneType: 'none' })
.replace(/\s/g, '')
.toLowerCase()
}
/**
* 检查搜索词是否匹配目标文本(支持拼音匹配)
* 匹配规则:
* 1. 直接包含搜索词
* 2. 拼音首字母匹配
* 3. 完整拼音匹配
* @param target 目标文本
* @param keyword 搜索词
* @returns 是否匹配
*/
export function matchWithPinyin(target: string, keyword: string): boolean {
const targetLower = target.toLowerCase()
const keywordLower = keyword.toLowerCase()
// 直接匹配
if (targetLower.includes(keywordLower)) {
return true
}
// 拼音首字母匹配
const initials = toPinyinInitials(target)
if (initials.includes(keywordLower)) {
return true
}
// 完整拼音匹配
const fullPinyin = toPinyinFull(target)
if (fullPinyin.includes(keywordLower)) {
return true
}
return false
}
/**
* 获取匹配的位置信息
* @param target 目标文本
* @param keyword 搜索词
* @returns 匹配的字符索引数组,如果没有匹配则返回空数组
*/
export function getMatchIndices(target: string, keyword: string): number[] {
const targetLower = target.toLowerCase()
const keywordLower = keyword.toLowerCase()
const indices: number[] = []
// 如果是直接文本匹配
let startPos = targetLower.indexOf(keywordLower)
if (startPos !== -1) {
for (let i = startPos; i < startPos + keyword.length; i++) {
indices.push(i)
}
return indices
}
// 拼音匹配 - 需要找出哪些字符被匹配
const keywordChars = keywordLower.split('')
for (let i = 0; i < target.length; i++) {
const char = target[i]
if (!char) continue
const charInitial = toPinyinInitials(char)
const charFullPinyin = toPinyinFull(char)
// 检查是否有任何剩余的搜索关键词可以匹配这个字符
for (let j = 0; j < keywordChars.length; j++) {
const remaining = keywordChars.slice(j).join('')
// 首字母匹配
if (charInitial.length > 0 && remaining.startsWith(charInitial)) {
indices.push(i)
keywordChars.splice(j, charInitial.length)
break
}
// 完整拼音匹配
if (charFullPinyin.length > 0 && remaining.startsWith(charFullPinyin)) {
indices.push(i)
keywordChars.splice(j, charFullPinyin.length)
break
}
}
}
return indices
}
/**
* 将文本中的特殊字符转义为 HTML 实体,防止 XSS
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
/**
* 高亮显示匹配的文本
* @param text 原始文本
* @param keyword 搜索词
* @returns 包含高亮标记的 HTML 字符串
*/
export function highlightMatch(text: string, keyword: string): string {
if (!keyword.trim()) {
return escapeHtml(text)
}
const indices = getMatchIndices(text, keyword)
if (indices.length === 0) {
return escapeHtml(text)
}
let result = ''
let inHighlight = false
let highlightStart = -1
const indicesSet = new Set(indices)
// 找出连续的高亮区间
const ranges: Array<{ start: number; end: number }> = []
let currentRange: { start: number; end: number } | null = null
for (let i = 0; i < text.length; i++) {
if (indicesSet.has(i)) {
if (currentRange === null) {
currentRange = { start: i, end: i }
} else {
currentRange.end = i
}
} else {
if (currentRange !== null) {
ranges.push(currentRange)
currentRange = null
}
}
}
if (currentRange !== null) {
ranges.push(currentRange)
}
// 构建结果字符串(先转义再包裹高亮标签)
let lastIndex = 0
for (const range of ranges) {
result += escapeHtml(text.slice(lastIndex, range.start))
result += `<mark class="search-highlight">${escapeHtml(text.slice(range.start, range.end + 1))}</mark>`
lastIndex = range.end + 1
}
result += escapeHtml(text.slice(lastIndex))
return result
}

View File

@@ -0,0 +1,27 @@
import type { QuadrantPriority } from '@/api/types'
const priorityColors: Record<string, string> = {
q1: 'var(--priority-q1)',
q2: 'var(--priority-q2)',
q3: 'var(--priority-q3)',
q4: 'var(--priority-q4)'
}
const defaultPriorityColor = 'var(--priority-q4)'
export function getPriorityColor(priority: string): string {
return priorityColors[priority] ?? defaultPriorityColor
}
const priorityConfig: Record<string, { label: string; color: string }> = {
q1: { label: 'Q1 重要紧急', color: 'var(--priority-q1)' },
q2: { label: 'Q2 重要不紧急', color: 'var(--priority-q2)' },
q3: { label: 'Q3 不重要紧急', color: 'var(--priority-q3)' },
q4: { label: 'Q4 不重要不紧急', color: 'var(--priority-q4)' }
}
const defaultPriorityConfig = { label: 'Q4 不重要不紧急', color: 'var(--priority-q4)' }
export function getPriorityConfig(priority: string) {
return priorityConfig[priority] || defaultPriorityConfig
}

View File

@@ -0,0 +1,905 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useAnniversaryStore } from '@/stores/useAnniversaryStore'
import type { Anniversary, AnniversaryCategory } from '@/api/types'
import { ElMessage, ElMessageBox } from 'element-plus'
import AnniversaryDialog from '@/components/AnniversaryDialog.vue'
import AnniversaryCategoryDialog from '@/components/AnniversaryCategoryDialog.vue'
const store = useAnniversaryStore()
const showAnniversaryDialog = ref(false)
const showCategoryDialog = ref(false)
const editingAnniversary = ref<Anniversary | null>(null)
const editingCategory = ref<AnniversaryCategory | null>(null)
const showCategoryManage = ref(false)
onMounted(async () => {
await store.init()
})
watch(() => store.activeCategoryId, () => {
store.fetchAnniversaries()
})
// ============ 格式化工具 ============
function formatDate(dateStr: string): string {
const d = new Date(dateStr)
return `${d.getMonth() + 1}${d.getDate()}`
}
function formatNextDate(dateStr: string | null): string {
if (!dateStr) return ''
const d = new Date(dateStr)
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`
}
function getCountdownText(item: Anniversary): string {
if (item.days_until === null) return '无日期'
if (item.days_until === 0) return '就是今天'
if (item.days_until > 0) return `还有 ${item.days_until}`
return `已过 ${Math.abs(item.days_until)}`
}
function getCountdownType(item: Anniversary): 'today' | 'upcoming' | 'past' | 'remind' {
if (item.days_until === 0) return 'today'
if (item.days_until === null) return 'past'
if (item.days_until > item.remind_days_before) return 'upcoming'
if (item.days_until > 0) return 'remind'
return 'past'
}
function getYearCountText(item: Anniversary): string {
if (item.year_count === null || item.year_count === undefined) return ''
if (item.year_count === 0) return '今年'
return `${item.year_count}`
}
// ============ 分类筛选 ============
const filterTabs = computed(() => {
const tabs: { id: number | null; name: string; color?: string }[] = [
{ id: null, name: '全部' }
]
for (const cat of store.categories) {
tabs.push({ id: cat.id, name: cat.name, color: cat.color })
}
return tabs
})
function setFilter(categoryId: number | null) {
store.setFilter(categoryId)
}
// ============ 纪念日操作 ============
function openCreate() {
editingAnniversary.value = null
showAnniversaryDialog.value = true
}
function openEdit(item: Anniversary) {
editingAnniversary.value = item
showAnniversaryDialog.value = true
}
async function handleDelete(item: Anniversary) {
try {
await ElMessageBox.confirm(
`确定要删除「${item.title}」吗?`,
'确认删除',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
)
const success = await store.deleteAnniversary(item.id)
if (success) ElMessage.success('删除成功~')
} catch {
// 用户取消
}
}
// ============ 分类管理 ============
function openCategoryDialog(cat?: AnniversaryCategory) {
editingCategory.value = cat || null
showCategoryDialog.value = true
}
async function handleDeleteCategory(cat: AnniversaryCategory) {
try {
await ElMessageBox.confirm(
`确定要删除分类「${cat.name}」吗?该分类下的纪念日将变为未分类。`,
'确认删除',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
)
const success = await store.deleteCategory(cat.id)
if (success) ElMessage.success('分类删除成功~')
} catch {
// 用户取消
}
}
// ============ 概览数据 ============
const totalCount = computed(() => store.anniversaries.length)
const upcomingCount = computed(() => store.upcomingAnniversaries.length)
const todayCount = computed(() => store.todayAnniversaries.length)
</script>
<template>
<div class="anniversary-page">
<div class="anniversary-container">
<!-- 概览统计 -->
<div class="overview-card">
<div class="overview-item">
<div class="overview-icon overview-icon--total">
<el-icon :size="22"><Calendar /></el-icon>
</div>
<div class="overview-info">
<span class="overview-value">{{ totalCount }}</span>
<span class="overview-label">全部纪念日</span>
</div>
</div>
<div class="overview-divider"></div>
<div class="overview-item">
<div class="overview-icon overview-icon--today">
<el-icon :size="22"><Star /></el-icon>
</div>
<div class="overview-info">
<span class="overview-value">{{ todayCount }}</span>
<span class="overview-label">今天是</span>
</div>
</div>
<div class="overview-divider"></div>
<div class="overview-item">
<div class="overview-icon overview-icon--upcoming">
<el-icon :size="22"><Timer /></el-icon>
</div>
<div class="overview-info">
<span class="overview-value">{{ upcomingCount }}</span>
<span class="overview-label">即将到来</span>
</div>
</div>
</div>
<!-- 操作栏 -->
<div class="toolbar">
<div class="toolbar-top">
<div class="toolbar-left">
<button class="action-btn action-btn--outline" @click="showCategoryManage = !showCategoryManage">
<el-icon :size="16"><FolderAdd /></el-icon>
<span>管理分类</span>
</button>
</div>
<div class="toolbar-right">
<button class="action-btn action-btn--primary" @click="openCreate">
<el-icon :size="16"><Plus /></el-icon>
<span>新建纪念日</span>
</button>
</div>
</div>
<!-- 分类筛选标签 -->
<div v-if="filterTabs.length > 1" class="group-tabs">
<button
v-for="tab in filterTabs"
:key="tab.id ?? 'all'"
class="group-tab"
:class="{ active: store.activeCategoryId === tab.id }"
@click="setFilter(tab.id)"
>
<span
v-if="tab.color && tab.id !== null"
class="tab-dot"
:style="{ background: tab.color }"
/>
{{ tab.name }}
</button>
</div>
</div>
<!-- 分类管理面板 -->
<Transition name="slide-down">
<div v-if="showCategoryManage" class="category-manage-panel">
<div class="panel-header">
<span class="panel-title">分类管理</span>
<button class="action-btn action-btn--primary action-btn--sm" @click="openCategoryDialog()">
<el-icon :size="14"><Plus /></el-icon>
<span>新建分类</span>
</button>
</div>
<div v-if="store.categories.length === 0" class="panel-empty">
还没有分类点击新建分类创建吧~
</div>
<div v-else class="category-list">
<div
v-for="cat in store.categories"
:key="cat.id"
class="category-item"
>
<div class="cat-left">
<span class="cat-dot" :style="{ background: cat.color }"></span>
<span class="cat-name">{{ cat.name }}</span>
</div>
<div class="cat-actions">
<el-button text size="small" @click="openCategoryDialog(cat)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button text size="small" @click="handleDeleteCategory(cat)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</Transition>
<!-- 加载状态 -->
<div v-if="store.loading" class="loading-state">
<el-icon class="loading-icon is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<!-- 空状态 -->
<div v-else-if="store.filteredAnniversaries.length === 0" class="empty-state">
<div class="empty-icon">
<el-icon :size="64" color="#C8A2C8"><Calendar /></el-icon>
</div>
<h3>还没有纪念日呢</h3>
<p>点击新建纪念日记录重要的日子吧~</p>
<button class="action-btn action-btn--primary" @click="openCreate">
<el-icon :size="16"><Plus /></el-icon>
<span>新建纪念日</span>
</button>
</div>
<!-- 纪念日卡片列表 -->
<div v-else class="anniversary-list">
<!-- 即将到来 -->
<div v-if="store.upcomingAnniversaries.length > 0" class="list-section">
<div class="section-header">
<el-icon :size="16" color="#98D8C8"><Clock /></el-icon>
<span>即将到来</span>
<span class="section-count">{{ store.upcomingAnniversaries.length }}</span>
</div>
<div class="cards-grid">
<div
v-for="item in store.upcomingAnniversaries"
:key="item.id"
class="anniversary-card"
:class="[`card--${getCountdownType(item)}`, { 'card--today': item.days_until === 0 }]"
>
<div class="card-color-bar" :style="{ background: item.category?.color || '#FFB7C5' }"></div>
<div class="card-body">
<div class="card-top">
<div class="card-title-row">
<h4 class="card-title">{{ item.title }}</h4>
<div v-if="item.category" class="card-category-tag" :style="{ background: item.category.color + '20', color: item.category.color }">
{{ item.category.name }}
</div>
</div>
<div class="card-meta-row">
<span v-if="getYearCountText(item)" class="year-badge">{{ getYearCountText(item) }}</span>
</div>
</div>
<div class="card-bottom">
<div class="card-date">
<el-icon :size="14"><Calendar /></el-icon>
<span>{{ formatDate(item.date) }}</span>
</div>
<div class="card-countdown" :class="`countdown--${getCountdownType(item)}`">
<span>{{ getCountdownText(item) }}</span>
</div>
</div>
<div v-if="item.description" class="card-desc">{{ item.description }}</div>
<div class="card-actions">
<el-button text size="small" @click="openEdit(item)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button text size="small" @click="handleDelete(item)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 已过去 -->
<div v-if="store.pastAnniversaries.length > 0" class="list-section">
<div class="section-header">
<el-icon :size="16" color="#C8A2C8"><Finished /></el-icon>
<span>已过去</span>
<span class="section-count">{{ store.pastAnniversaries.length }}</span>
</div>
<div class="cards-grid">
<div
v-for="item in store.pastAnniversaries"
:key="item.id"
class="anniversary-card card--past"
>
<div class="card-color-bar" :style="{ background: item.category?.color || '#C8A2C8' }"></div>
<div class="card-body">
<div class="card-top">
<div class="card-title-row">
<h4 class="card-title">{{ item.title }}</h4>
<div v-if="item.category" class="card-category-tag" :style="{ background: item.category.color + '20', color: item.category.color }">
{{ item.category.name }}
</div>
</div>
<div class="card-meta-row">
<span v-if="getYearCountText(item)" class="year-badge">{{ getYearCountText(item) }}</span>
</div>
</div>
<div class="card-bottom">
<div class="card-date">
<el-icon :size="14"><Calendar /></el-icon>
<span>{{ formatDate(item.date) }}</span>
</div>
<div class="card-countdown countdown--past">
<span>{{ getCountdownText(item) }}</span>
</div>
</div>
<div v-if="item.description" class="card-desc">{{ item.description }}</div>
<div class="card-actions">
<el-button text size="small" @click="openEdit(item)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button text size="small" @click="handleDelete(item)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 弹窗 -->
<AnniversaryDialog
:visible="showAnniversaryDialog"
:edit-anniversary="editingAnniversary"
@update:visible="showAnniversaryDialog = $event"
/>
<AnniversaryCategoryDialog
:visible="showCategoryDialog"
:edit-category="editingCategory"
@update:visible="showCategoryDialog = $event"
/>
</div>
</template>
<style scoped lang="scss">
.anniversary-page {
min-height: calc(100vh - 60px);
padding: 24px;
display: flex;
justify-content: center;
}
.anniversary-container {
width: 100%;
max-width: 1200px;
display: flex;
flex-direction: column;
gap: 20px;
}
// ============ 概览统计 ============
.overview-card {
display: flex;
align-items: center;
justify-content: space-around;
padding: 24px 32px;
background: white;
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
animation: fadeInUp 0.4s ease;
}
.overview-item {
display: flex;
align-items: center;
gap: 14px;
.overview-icon {
width: 46px;
height: 46px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
&--total {
background: rgba(255, 183, 197, 0.2);
color: #FFB7C5;
}
&--today {
background: rgba(255, 179, 71, 0.2);
color: #FFB347;
}
&--upcoming {
background: rgba(152, 216, 200, 0.2);
color: #98D8C8;
}
}
.overview-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.overview-value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.overview-label {
font-size: 12px;
color: var(--text-secondary);
}
}
.overview-divider {
width: 1px;
height: 40px;
background: rgba(255, 183, 197, 0.2);
}
// ============ 工具栏 ============
.toolbar {
display: flex;
flex-direction: column;
gap: 12px;
.toolbar-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
outline: none;
&--primary {
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
color: white;
box-shadow: 0 2px 8px rgba(255, 183, 197, 0.4);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(255, 183, 197, 0.5);
}
}
&--outline {
background: white;
color: var(--text-primary);
border: 1px solid rgba(255, 183, 197, 0.3);
&:hover {
border-color: var(--primary);
color: var(--primary);
background: rgba(255, 183, 197, 0.05);
}
}
&--sm {
padding: 6px 12px;
font-size: 13px;
}
}
// ============ 分类筛选标签 ============
.group-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding: 4px 0;
}
.group-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
background: white;
border: 1px solid rgba(255, 183, 197, 0.15);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: var(--primary);
border-color: var(--primary);
}
&.active {
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
border-color: transparent;
box-shadow: 0 2px 8px rgba(255, 183, 197, 0.3);
}
.tab-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
}
// ============ 分类管理面板 ============
.category-manage-panel {
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 20px;
animation: fadeInUp 0.3s ease;
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.panel-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
}
.panel-empty {
text-align: center;
padding: 20px;
color: var(--text-secondary);
font-size: 14px;
}
}
.category-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.category-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: var(--radius-sm);
transition: background 0.15s;
&:hover {
background: rgba(255, 183, 197, 0.06);
}
.cat-left {
display: flex;
align-items: center;
gap: 10px;
.cat-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.cat-name {
font-size: 14px;
color: var(--text-primary);
}
}
.cat-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s;
}
&:hover .cat-actions {
opacity: 1;
}
}
// ============ 加载 & 空状态 ============
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 0;
color: var(--text-secondary);
.loading-icon {
font-size: 32px;
color: var(--primary);
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 60px 0;
animation: fadeInUp 0.4s ease;
.empty-icon {
opacity: 0.6;
}
h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
p {
color: var(--text-secondary);
font-size: 14px;
margin: 0;
}
}
// ============ 纪念日列表 ============
.list-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
padding: 4px 0;
.section-count {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
background: rgba(255, 183, 197, 0.15);
padding: 2px 8px;
border-radius: 10px;
}
}
.cards-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.anniversary-card {
position: relative;
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
transition: all 0.2s ease;
display: flex;
&:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
.card-actions {
opacity: 1;
}
}
&.card--today {
border: 2px solid #FFB347;
background: linear-gradient(135deg, rgba(255, 179, 71, 0.05) 0%, rgba(255, 183, 197, 0.05) 100%);
}
&.card--remind {
border: 1px solid rgba(255, 179, 71, 0.4);
}
&.card--past {
opacity: 0.75;
&:hover {
opacity: 1;
}
}
}
.card-color-bar {
width: 4px;
flex-shrink: 0;
}
.card-body {
flex: 1;
padding: 14px 12px;
min-width: 0;
display: flex;
flex-direction: column;
}
.card-top {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 10px;
}
.card-title-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.card-category-tag {
font-size: 10px;
font-weight: 500;
padding: 1px 6px;
border-radius: 8px;
white-space: nowrap;
flex-shrink: 0;
}
.year-badge {
font-size: 11px;
font-weight: 500;
color: var(--accent);
background: rgba(200, 162, 200, 0.15);
padding: 1px 8px;
border-radius: 8px;
white-space: nowrap;
align-self: flex-start;
}
.card-meta-row {
display: flex;
align-items: center;
gap: 6px;
}
.card-bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.card-date {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--text-secondary);
}
.card-countdown {
font-size: 13px;
font-weight: 600;
padding: 2px 10px;
border-radius: 12px;
white-space: nowrap;
&.countdown--today {
color: #FF8C00;
background: rgba(255, 179, 71, 0.15);
}
&.countdown--remind {
color: #FF6B6B;
background: rgba(255, 107, 107, 0.1);
}
&.countdown--upcoming {
color: var(--primary);
background: rgba(255, 183, 197, 0.12);
}
&.countdown--past {
color: var(--text-secondary);
background: rgba(200, 162, 200, 0.1);
}
}
.card-desc {
font-size: 11px;
color: var(--text-secondary);
margin-top: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-actions {
display: flex;
justify-content: flex-end;
gap: 0;
padding-top: 8px;
margin-top: auto;
border-top: 1px dashed rgba(255, 183, 197, 0.15);
opacity: 0;
transition: opacity 0.15s;
}
// ============ 动画 ============
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.slide-down-enter-active,
.slide-down-leave-active {
transition: all 0.3s ease;
overflow: hidden;
}
.slide-down-enter-from,
.slide-down-leave-to {
opacity: 0;
transform: translateY(-10px);
max-height: 0;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import CalendarView from '@/views/CalendarView.vue'
</script>
<template>
<div class="calendar-page">
<CalendarView />
</div>
</template>
<style scoped lang="scss">
.calendar-page {
padding: 24px 32px;
height: calc(100vh - 60px);
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useUIStore } from '@/stores/useUIStore'
import WeeklyView from './WeeklyView.vue'
import MonthlyView from './MonthlyView.vue'
const uiStore = useUIStore()
// 当前日历模式
const calendarMode = computed(() => uiStore.calendarMode)
// 视图选项
const viewOptions = [
{ label: '周', value: 'week' as const },
{ label: '月', value: 'monthly' as const }
]
// 切换视图模式
function handleModeChange(value: 'week' | 'monthly') {
uiStore.setCalendarMode(value)
}
</script>
<template>
<div class="calendar-view">
<!-- 分段控制器 -->
<div class="view-switcher">
<div class="segmented-control">
<button
v-for="option in viewOptions"
:key="option.value"
class="segment-btn"
:class="{ active: calendarMode === option.value }"
@click="handleModeChange(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<!-- 视图内容 -->
<div class="calendar-content">
<Transition name="view-fade" mode="out-in">
<WeeklyView v-if="calendarMode === 'week'" />
<MonthlyView v-else />
</Transition>
</div>
</div>
</template>
<style scoped lang="scss">
.calendar-view {
height: 100%;
display: flex;
flex-direction: column;
}
.view-switcher {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
padding: 0 4px;
}
.segmented-control {
display: flex;
background: var(--background);
padding: 4px;
border-radius: var(--radius-lg);
}
.segment-btn {
padding: 8px 24px;
border: none;
background: transparent;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
color: var(--text-primary);
}
&.active {
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
box-shadow: 0 2px 8px rgba(255, 183, 197, 0.4);
}
}
.calendar-content {
flex: 1;
overflow: hidden;
}
// 视图切换动画
.view-fade-enter-active,
.view-fade-leave-active {
transition: opacity 0.2s ease;
}
.view-fade-enter-from,
.view-fade-leave-to {
opacity: 0;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,708 @@
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { ArrowLeft, ArrowRight, Calendar } from '@element-plus/icons-vue'
import { useTaskStore } from '@/stores/useTaskStore'
import { useUIStore } from '@/stores/useUIStore'
import { formatDate } from '@/utils/date'
import { getPriorityColor } from '@/utils/priority'
import type { Task } from '@/api/types'
const taskStore = useTaskStore()
const uiStore = useUIStore()
const currentDate = ref(new Date())
const selectedDate = ref<string | null>(null)
const isTransitioning = ref(false)
// 星期标题
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
// 当前显示的年月
const currentYear = computed(() => currentDate.value.getFullYear())
const currentMonth = computed(() => currentDate.value.getMonth())
// 月份标题
const monthTitle = computed(() => {
return `${currentYear.value}${currentMonth.value + 1}`
})
// 获取当月日历数据
const calendarDays = computed(() => {
const year = currentYear.value
const month = currentMonth.value
// 当月第一天
const firstDay = new Date(year, month, 1)
// 当月最后一天
const lastDay = new Date(year, month + 1, 0)
// 当月天数
const daysInMonth = lastDay.getDate()
// 当月第一天是星期几0-6, 0是周日
const startWeekday = firstDay.getDay()
const days: Array<{
date: Date
dateStr: string
isCurrentMonth: boolean
isToday: boolean
tasks: Task[]
}> = []
// 上个月的补充天数
const prevMonth = new Date(year, month, 0)
const prevMonthDays = prevMonth.getDate()
for (let i = startWeekday - 1; i >= 0; i--) {
const date = new Date(year, month - 1, prevMonthDays - i)
const dateStr = formatDate(date)
days.push({
date,
dateStr,
isCurrentMonth: false,
isToday: false,
tasks: taskStore.tasksByDate.get(dateStr) || []
})
}
// 当月的天数
const today = new Date()
const todayStr = formatDate(today)
for (let i = 1; i <= daysInMonth; i++) {
const date = new Date(year, month, i)
const dateStr = formatDate(date)
days.push({
date,
dateStr,
isCurrentMonth: true,
isToday: dateStr === todayStr,
tasks: taskStore.tasksByDate.get(dateStr) || []
})
}
// 下个月的补充天数补齐到42天保证6行
const remainingDays = 42 - days.length
for (let i = 1; i <= remainingDays; i++) {
const date = new Date(year, month + 1, i)
const dateStr = formatDate(date)
days.push({
date,
dateStr,
isCurrentMonth: false,
isToday: false,
tasks: taskStore.tasksByDate.get(dateStr) || []
})
}
return days
})
// 整页滑动动画:克隆旧网格 → 更新数据 → 新网格从偏移滑入 → 旧快照淡出
async function slideAnimate(updateFn: () => void, direction: 'up' | 'down') {
if (isTransitioning.value) return
isTransitioning.value = true
const container = document.querySelector('.monthly-view .calendar-container') as HTMLElement
const grid = document.querySelector('.monthly-view .calendar-grid') as HTMLElement
if (!container || !grid) { isTransitioning.value = false; return }
// 快照旧网格
const snapshot = grid.cloneNode(true) as HTMLElement
snapshot.setAttribute('data-snapshot', 'true')
snapshot.style.position = 'absolute'
snapshot.style.top = '0'
snapshot.style.left = '0'
snapshot.style.width = '100%'
snapshot.style.height = '100%'
snapshot.style.zIndex = '1'
snapshot.style.transition = 'none'
snapshot.style.pointerEvents = 'none'
container.style.position = 'relative'
// 把快照插到 grid 后面grid 在上层z-index 更高)
grid.style.position = 'relative'
grid.style.zIndex = '2'
container.appendChild(snapshot)
const gridHeight = grid.offsetHeight
// 设置新 grid 的起始偏移
const startY = direction === 'down' ? gridHeight : -gridHeight
grid.style.transition = 'none'
grid.style.transform = `translateY(${startY}px)`
// 更新数据
updateFn()
await nextTick()
// 触发重排
void container.offsetHeight
// 新 grid 滑入
grid.style.transition = 'transform 0.8s cubic-bezier(0.25, 1, 0.5, 1)'
grid.style.transform = 'translateY(0)'
// 旧快照同步滑出
snapshot.style.transition = 'transform 0.8s cubic-bezier(0.25, 1, 0.5, 1), opacity 0.5s ease'
snapshot.style.transform = `translateY(${-startY}px)`
setTimeout(() => {
snapshot.style.opacity = '0'
}, 300)
setTimeout(() => {
container.removeChild(snapshot)
grid.style.transition = ''
grid.style.transform = ''
grid.style.zIndex = ''
grid.style.position = ''
container.style.position = ''
isTransitioning.value = false
}, 900)
}
// 上一个月
function prevMonth() {
slideAnimate(() => {
currentDate.value = new Date(currentYear.value, currentMonth.value - 1, 1)
}, 'up')
}
// 下一个月
function nextMonth() {
slideAnimate(() => {
currentDate.value = new Date(currentYear.value, currentMonth.value + 1, 1)
}, 'down')
}
// 回到今天
function goToday() {
const now = new Date()
const targetYear = now.getFullYear()
const targetMonth = now.getMonth()
if (targetYear === currentYear.value && targetMonth === currentMonth.value) return
const oldYear = currentYear.value
const oldMonth = currentMonth.value
const diff = (targetYear * 12 + targetMonth) - (oldYear * 12 + oldMonth)
slideAnimate(() => {
currentDate.value = now
}, diff < 0 ? 'up' : 'down')
}
// 点击日期
function handleDateClick(dateStr: string) {
selectedDate.value = selectedDate.value === dateStr ? null : dateStr
}
// 打开任务详情/编辑
function handleTaskClick(task: Task, event: Event) {
event.stopPropagation()
uiStore.openTaskDialog(task)
}
// 获取任务完成状态样式
function getTaskClass(task: Task): string {
return task.is_completed ? 'completed' : ''
}
</script>
<template>
<div class="monthly-view">
<!-- 月份导航 -->
<div class="month-nav">
<div class="nav-left">
<h2 class="month-title">{{ monthTitle }}</h2>
<span class="task-summary">
{{ taskStore.totalTasks.length }} 个任务
</span>
</div>
<div class="nav-right">
<el-button-group class="mode-switch">
<el-button
class="mode-btn"
:type="uiStore.calendarMode === 'week' ? 'primary' : 'default'"
@click="uiStore.setCalendarMode('week')"
>
</el-button>
<el-button
class="mode-btn"
:type="uiStore.calendarMode === 'monthly' ? 'primary' : 'default'"
@click="uiStore.setCalendarMode('monthly')"
>
</el-button>
</el-button-group>
<el-button class="nav-btn" @click="goToday">
<el-icon><Calendar /></el-icon>
今天
</el-button>
<el-button-group class="nav-group">
<el-button class="nav-btn" @click="prevMonth">
<el-icon><ArrowLeft /></el-icon>
</el-button>
<el-button class="nav-btn" @click="nextMonth">
<el-icon><ArrowRight /></el-icon>
</el-button>
</el-button-group>
</div>
</div>
<!-- 日历网格 -->
<div class="calendar-container">
<!-- 星期标题 -->
<div class="weekdays">
<div v-for="day in weekDays" :key="day" class="weekday">{{ day }}</div>
</div>
<!-- 日期格子 -->
<div class="calendar-grid">
<div
v-for="(day, index) in calendarDays"
:key="day.dateStr"
:data-date="day.dateStr"
class="day-cell"
:class="{
'other-month': !day.isCurrentMonth,
'is-today': day.isToday,
'selected': selectedDate === day.dateStr,
'has-tasks': day.tasks.length > 0
}"
@click="handleDateClick(day.dateStr)"
>
<div class="day-header">
<span class="day-number">{{ day.date.getDate() }}</span>
<span v-if="day.tasks.length > 0" class="task-count">
{{ day.tasks.length }}
</span>
</div>
<!-- 任务列表 -->
<div v-if="day.tasks.length > 0" class="task-list">
<div
v-for="task in day.tasks.slice(0, 2)"
:key="task.id"
class="mini-task"
:class="getTaskClass(task)"
@click="handleTaskClick(task, $event)"
>
<span
class="priority-dot"
:style="{ background: getPriorityColor(task.priority) }"
></span>
<span class="task-title">{{ task.title }}</span>
</div>
<div v-if="day.tasks.length > 2" class="more-tasks">
+{{ day.tasks.length - 2 }} 更多
</div>
</div>
</div>
</div>
</div>
<!-- 选中日期的任务详情面板 -->
<Transition name="slide">
<div
v-if="selectedDate"
class="task-detail-panel"
>
<div class="panel-header">
<h3>{{ selectedDate }} 的任务</h3>
<el-button text @click="selectedDate = null">
<el-icon><Close /></el-icon>
</el-button>
</div>
<div class="panel-content">
<div
v-for="task in taskStore.tasksByDate.get(selectedDate) || []"
:key="task.id"
class="detail-task-item"
@click="handleTaskClick(task, $event)"
>
<span
class="priority-dot"
:style="{ background: getPriorityColor(task.priority) }"
></span>
<div class="task-info">
<span class="task-title" :class="{ completed: task.is_completed }">
{{ task.title }}
</span>
<span v-if="task.category" class="task-category">
{{ task.category.name }}
</span>
</div>
</div>
<div
v-if="(taskStore.tasksByDate.get(selectedDate) || []).length === 0"
class="no-tasks"
>
这一天没有任务呢~
</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped lang="scss">
.monthly-view {
height: 100%;
display: flex;
flex-direction: column;
}
.month-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding: 0 4px;
}
.nav-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.month-title {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
}
.task-summary {
font-size: 14px;
color: var(--text-secondary);
}
.nav-right {
display: flex;
align-items: center;
gap: 12px;
}
.mode-switch {
.mode-btn {
padding: 8px 16px;
font-size: 13px;
&:first-child {
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
&:last-child {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
}
}
.nav-btn {
border-radius: var(--radius-md);
border-color: var(--secondary);
color: var(--text-primary);
&:hover {
border-color: var(--primary);
color: var(--primary);
}
}
.nav-group {
.nav-btn {
border-radius: 0;
&:first-child {
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
&:last-child {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
}
}
.calendar-container {
flex: 1;
display: flex;
flex-direction: column;
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
background: var(--background-dark);
border-bottom: 1px solid var(--secondary);
}
.weekday {
padding: 8px;
text-align: center;
font-weight: 600;
color: var(--text-secondary);
font-size: 12px;
}
.calendar-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(6, 1fr);
}
.day-cell {
padding: 4px 6px;
border-right: 1px solid var(--background-dark);
border-bottom: 1px solid var(--background-dark);
cursor: pointer;
transition: background 0.35s ease, opacity 0.35s ease;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
&:nth-child(7n) {
border-right: none;
}
&:hover {
background: var(--background);
}
.day-number {
transition: color 0.35s ease, opacity 0.35s ease;
}
.task-list,
.task-count {
transition: opacity 0.35s ease;
}
&.other-month {
background: var(--background);
.day-number {
color: var(--text-secondary);
opacity: 0.4;
}
.task-list,
.task-count {
opacity: 0.35;
}
}
&.is-today {
background: rgba(255, 183, 197, 0.08);
.day-number {
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
color: white;
border-radius: 50%;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
}
}
&.selected {
background: rgba(255, 183, 197, 0.15);
outline: 2px solid var(--primary);
outline-offset: -2px;
.day-number {
font-weight: 700;
color: var(--primary);
}
}
}
.day-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 3px;
}
.day-number {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
}
.task-count {
font-size: 10px;
padding: 1px 5px;
background: var(--primary);
color: white;
border-radius: 10px;
}
.task-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
}
.mini-task {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 4px;
background: var(--background);
border-radius: var(--radius-sm);
font-size: 11px;
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
background: var(--background-dark);
transform: translateX(2px);
}
&.completed {
opacity: 0.6;
.task-title {
text-decoration: line-through;
}
}
.task-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
}
}
.priority-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.more-tasks {
font-size: 11px;
color: var(--text-secondary);
text-align: center;
padding: 2px;
}
// 任务详情面板
.task-detail-panel {
position: absolute;
right: 24px;
top: 80px;
width: 280px;
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
z-index: 10;
overflow: hidden;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--background);
border-bottom: 1px solid var(--background-dark);
h3 {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
}
.panel-content {
max-height: 300px;
overflow-y: auto;
padding: 8px;
}
.detail-task-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
background: var(--background);
}
.task-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.task-title {
font-size: 14px;
color: var(--text-primary);
&.completed {
text-decoration: line-through;
color: var(--text-secondary);
}
}
.task-category {
font-size: 11px;
color: var(--text-secondary);
}
}
.no-tasks {
text-align: center;
padding: 24px;
color: var(--text-secondary);
font-size: 14px;
}
// 滑入动画
.slide-enter-active,
.slide-leave-active {
transition: all var(--transition-normal);
}
.slide-enter-from,
.slide-leave-to {
opacity: 0;
transform: translateX(20px);
}
@media (max-width: 768px) {
.day-cell {
min-height: 60px;
padding: 4px;
}
.mini-task {
font-size: 10px;
padding: 2px 4px;
}
.month-nav {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
}
</style>

View File

@@ -0,0 +1,376 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import type { UploadProps } from 'element-plus'
import { useUserSettingsStore } from '@/stores/useUserSettingsStore'
const userStore = useUserSettingsStore()
const form = ref({
nickname: '',
avatar: '',
signature: '',
birthday: '',
email: '',
site_name: ''
})
const saving = ref(false)
onMounted(() => {
form.value.nickname = userStore.nickname
form.value.avatar = userStore.avatar
form.value.signature = userStore.signature
form.value.birthday = userStore.birthday
form.value.email = userStore.email
form.value.site_name = userStore.siteName
})
const displayAvatar = computed(() => {
const name = form.value.nickname || '爱莉希雅'
return name.charAt(0)
})
function beforeAvatarUpload(file: File) {
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
ElMessage.error('只能上传图片文件哦~')
return false
}
if (!isLt2M) {
ElMessage.error('图片大小不能超过 2MB 呢~')
return false
}
return true
}
const handleAvatarChange: UploadProps['onChange'] = (uploadFile) => {
const file = uploadFile.raw
if (!file) return
if (!beforeAvatarUpload(file)) return
const reader = new FileReader()
reader.onload = (e) => {
form.value.avatar = e.target?.result as string
}
reader.readAsDataURL(file)
}
function clearAvatar() {
form.value.avatar = ''
}
async function handleSave() {
saving.value = true
try {
await userStore.updateSettings({
nickname: form.value.nickname,
avatar: form.value.avatar,
signature: form.value.signature,
birthday: form.value.birthday || undefined,
email: form.value.email || undefined,
site_name: form.value.site_name || undefined
})
userStore.syncFromSettings(userStore.settings!)
ElMessage.success('个人信息保存成功~')
} catch {
ElMessage.error('保存失败了呢,请稍后再试~')
} finally {
saving.value = false
}
}
</script>
<template>
<div class="profile-page">
<div class="profile-card">
<div class="card-header">
<h2 class="page-title">个人信息</h2>
<p class="page-subtitle">管理你的个人资料和展示信息</p>
</div>
<div class="card-body">
<!-- 头像区域 -->
<div class="avatar-section">
<div class="avatar-wrapper">
<el-avatar
v-if="form.avatar"
:size="88"
:src="form.avatar"
class="profile-avatar"
/>
<el-avatar
v-else
:size="88"
class="profile-avatar default-avatar"
>
{{ displayAvatar }}
</el-avatar>
<el-upload
:show-file-list="false"
:auto-upload="false"
accept="image/*"
:on-change="handleAvatarChange"
>
<div class="avatar-overlay">
<el-icon :size="20"><Camera /></el-icon>
</div>
</el-upload>
</div>
<p v-if="form.signature" class="profile-signature">{{ form.signature }}</p>
<el-button
v-if="form.avatar"
text
type="danger"
size="small"
@click="clearAvatar"
>
移除头像
</el-button>
</div>
<!-- 表单区域 -->
<el-form
:model="form"
label-position="top"
class="profile-form"
>
<el-form-item label="网站名称">
<el-input
v-model="form.site_name"
placeholder="给你的待办应用取个名字~"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="昵称">
<el-input
v-model="form.nickname"
placeholder="给自己取个名字吧~"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="个性签名">
<el-input
v-model="form.signature"
type="textarea"
placeholder="写点什么来介绍自己吧..."
maxlength="200"
show-word-limit
:autosize="{ minRows: 2, maxRows: 4 }"
/>
</el-form-item>
<div class="form-row">
<el-form-item label="生日" class="form-item-half">
<el-date-picker
v-model="form.birthday"
type="date"
placeholder="选择你的生日"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
:teleported="false"
/>
</el-form-item>
<el-form-item label="邮箱" class="form-item-half">
<el-input
v-model="form.email"
placeholder="your@email.com"
type="email"
/>
</el-form-item>
</div>
<div class="form-actions">
<el-button
type="primary"
:loading="saving"
@click="handleSave"
class="save-btn"
>
保存信息
</el-button>
</div>
</el-form>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.profile-page {
min-height: calc(100vh - 60px);
padding: 32px;
display: flex;
justify-content: center;
align-items: flex-start;
}
.profile-card {
width: 100%;
max-width: 580px;
background: white;
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
overflow: hidden;
animation: fadeInUp 0.4s ease;
}
.card-header {
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
padding: 32px;
color: white;
.page-title {
font-size: 24px;
font-weight: 600;
margin: 0 0 8px;
}
.page-subtitle {
font-size: 14px;
opacity: 0.85;
margin: 0;
}
}
.card-body {
padding: 32px;
}
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32px;
}
.avatar-wrapper {
position: relative;
cursor: pointer;
margin-bottom: 8px;
.profile-avatar {
border: 3px solid var(--primary);
box-shadow: var(--shadow-md);
}
.default-avatar {
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
color: white;
font-size: 32px;
font-weight: 600;
}
.avatar-overlay {
position: absolute;
inset: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
color: white;
opacity: 0;
transition: opacity 0.2s ease;
}
&:hover .avatar-overlay {
opacity: 1;
}
}
.profile-signature {
font-size: 13px;
color: var(--text-secondary);
margin: 0 0 8px;
text-align: center;
max-width: 240px;
}
.profile-form {
:deep(.el-form-item__label) {
font-weight: 500;
color: var(--text-primary);
}
:deep(.el-input__wrapper),
:deep(.el-textarea__inner) {
border-radius: var(--radius-md);
}
:deep(.el-date-editor) {
.el-input__wrapper {
border-radius: var(--radius-md);
}
}
}
.form-row {
display: flex;
gap: 16px;
.form-item-half {
flex: 1;
}
}
.form-actions {
display: flex;
justify-content: flex-end;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid rgba(255, 183, 197, 0.2);
.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);
}
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.profile-page {
padding: 16px;
}
.card-header {
padding: 24px;
}
.card-body {
padding: 24px;
}
.form-row {
flex-direction: column;
gap: 0;
}
}
</style>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import QuadrantView from '@/views/QuadrantView.vue'
</script>
<template>
<div class="quadrant-page">
<QuadrantView />
</div>
</template>
<style scoped lang="scss">
.quadrant-page {
padding: 24px 32px;
height: calc(100vh - 60px);
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,362 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTaskStore } from '@/stores/useTaskStore'
import QuadrantTaskCard from '@/components/QuadrantTaskCard.vue'
import type { TaskResponse } from '@/api/tasks'
const taskStore = useTaskStore()
type QuadrantKey = 'q1' | 'q2' | 'q3' | 'q4'
interface QuadrantInfo {
title: string
subtitle: string
description: string
color: string
bgColor: string
isUrgent?: boolean
}
const quadrantConfig: Record<QuadrantKey, QuadrantInfo> = {
q1: {
title: 'Q1 重要紧急',
subtitle: '立即执行',
description: '需要优先处理的紧急任务',
color: 'var(--priority-q1)',
bgColor: 'rgba(255, 107, 107, 0.08)',
isUrgent: true
},
q2: {
title: 'Q2 重要不紧急',
subtitle: '规划执行',
description: '对长期目标有帮助的任务',
color: 'var(--priority-q2)',
bgColor: 'rgba(255, 179, 71, 0.08)'
},
q3: {
title: 'Q3 不重要紧急',
subtitle: '委托他人',
description: '可以考虑委托或快速处理',
color: 'var(--priority-q3)',
bgColor: 'rgba(152, 216, 200, 0.08)'
},
q4: {
title: 'Q4 不重要不紧急',
subtitle: '适当考虑',
description: '可以稍后处理或删除',
color: 'var(--priority-q4)',
bgColor: 'rgba(200, 162, 200, 0.08)'
}
}
const quadrantOrder: QuadrantKey[] = ['q2', 'q1', 'q4', 'q3']
function sortByDueDate(a: TaskResponse, b: TaskResponse): number {
if (!a.due_date && !b.due_date) return 0
if (!a.due_date) return 1
if (!b.due_date) return -1
return new Date(a.due_date).getTime() - new Date(b.due_date).getTime()
}
const tasksByPriority = computed(() => {
const grouped: Record<QuadrantKey, TaskResponse[]> = { q1: [], q2: [], q3: [], q4: [] }
for (const t of taskStore.totalTasks as TaskResponse[]) {
if (!t.is_completed && grouped[t.priority as QuadrantKey]) {
grouped[t.priority as QuadrantKey].push(t)
}
}
for (const key of quadrantOrder) {
grouped[key].sort(sortByDueDate)
}
return grouped
})
const totalActiveTasks = computed(() => {
return tasksByPriority.value.q1.length +
tasksByPriority.value.q2.length +
tasksByPriority.value.q3.length +
tasksByPriority.value.q4.length
})
</script>
<template>
<div class="quadrant-view">
<div class="quadrant-header">
<h2 class="quadrant-title">
四象限视图
<span class="task-count">{{ totalActiveTasks }} 项待办</span>
</h2>
<p class="quadrant-subtitle">按照重要紧急程度管理你的任务</p>
</div>
<div v-if="taskStore.loading" class="loading-state">
<el-skeleton :rows="8" animated />
</div>
<div v-else-if="totalActiveTasks === 0" class="empty-state">
<div class="empty-icon"></div>
<p class="empty-text">还没有待办任务呢~</p>
<p class="empty-hint">点击右下角的按钮创建一个新任务吧</p>
</div>
<div v-else class="quadrant-grid">
<div
v-for="key in quadrantOrder"
:key="key"
class="quadrant"
:class="key"
:style="{ '--quadrant-color': quadrantConfig[key].color, '--quadrant-bg': quadrantConfig[key].bgColor }"
>
<div class="quadrant-header-inner">
<div class="quadrant-label" :class="{ urgent: quadrantConfig[key].isUrgent }">
{{ quadrantConfig[key].subtitle }}
</div>
<h3 class="quadrant-title-inner">{{ quadrantConfig[key].title }}</h3>
<p class="quadrant-desc">{{ quadrantConfig[key].description }}</p>
<span class="quadrant-count">{{ tasksByPriority[key].length }}</span>
</div>
<div class="quadrant-content">
<TransitionGroup name="task-list" tag="div" class="task-list">
<QuadrantTaskCard
v-for="task in tasksByPriority[key]"
:key="task.id"
:task="task"
/>
</TransitionGroup>
<div v-if="tasksByPriority[key].length === 0" class="quadrant-empty">
<span class="quadrant-empty-text">暂无任务</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.quadrant-view {
height: 100%;
display: flex;
flex-direction: column;
}
.quadrant-header {
margin-bottom: 24px;
}
.quadrant-title {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 12px;
.task-count {
font-size: 14px;
font-weight: 500;
padding: 4px 12px;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
color: white;
border-radius: 20px;
}
}
.quadrant-subtitle {
font-size: 14px;
color: var(--text-secondary);
margin-top: 4px;
}
.quadrant-grid {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 16px;
min-height: 0;
}
.quadrant {
background: var(--quadrant-bg);
border-radius: var(--radius-lg);
border: 2px solid transparent;
display: flex;
flex-direction: column;
overflow: hidden;
transition: all 0.3s ease;
&:hover {
border-color: var(--quadrant-color);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
}
&.q1 .quadrant-label.urgent {
animation: pulse 2s infinite;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}
.quadrant-header-inner {
padding: 16px 20px;
position: relative;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
flex-shrink: 0;
}
.quadrant-label {
display: inline-block;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
background: var(--quadrant-color);
color: white;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.quadrant-title-inner {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.quadrant-desc {
font-size: 12px;
color: var(--text-secondary);
}
.quadrant-count {
position: absolute;
top: 16px;
right: 16px;
font-size: 28px;
font-weight: 700;
color: var(--quadrant-color);
opacity: 0.6;
}
.quadrant-content {
flex: 1;
overflow-y: auto;
padding: 12px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 3px;
&:hover {
background: rgba(0, 0, 0, 0.2);
}
}
}
.quadrant-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 24px 0;
}
.quadrant-empty-text {
font-size: 12px;
color: var(--text-secondary);
opacity: 0.5;
}
.task-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.task-list-enter-active,
.task-list-leave-active {
transition: all 0.3s ease;
}
.task-list-enter-from {
opacity: 0;
transform: translateX(-20px);
}
.task-list-leave-to {
opacity: 0;
transform: translateX(20px);
}
.loading-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
.empty-icon {
font-size: 64px;
color: var(--primary);
margin-bottom: 16px;
animation: twinkle 2s ease-in-out infinite;
}
.empty-text {
font-size: 18px;
color: var(--text-primary);
margin-bottom: 8px;
}
.empty-hint {
font-size: 14px;
color: var(--text-secondary);
}
}
@keyframes twinkle {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.1);
}
}
@media (max-width: 900px) {
.quadrant-grid {
grid-template-columns: 1fr;
grid-template-rows: auto;
}
.quadrant {
max-height: 300px;
}
}
</style>

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>

View File

@@ -0,0 +1,283 @@
<script setup lang="ts">
import { computed, ref, watch, onUnmounted } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { useTaskStore } from '@/stores/useTaskStore'
import { matchWithPinyin, highlightMatch } from '@/utils/pinyin'
import TaskCard from '@/components/TaskCard.vue'
const taskStore = useTaskStore()
const statusText = computed(() => {
switch (taskStore.filters.status) {
case 'active': return '进行中'
case 'completed': return '已完成'
default: return '全部任务'
}
})
// 筛选条件的唯一标识,用于强制重新渲染列表
const filterKey = computed(() => {
const { status, category_id, sort_by, sort_order, search } = taskStore.filters
return `${status}-${category_id || 'all'}-${sort_by}-${sort_order}-${search || ''}`
})
// 搜索框
const searchInput = ref(taskStore.filters.search || '')
// 使用防抖更新搜索条件
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(searchInput, (value) => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
taskStore.setFilters({ search: value })
}, 300)
})
// 组件卸载时清理防抖计时器
onUnmounted(() => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
})
// 清空搜索
const clearSearch = () => {
searchInput.value = ''
}
// 生成搜索建议
const searchSuggestions = computed(() => {
const suggestions: string[] = []
const addedSet = new Set<string>()
taskStore.activeTasks.forEach(task => {
// 添加标题
if (task.title && !addedSet.has(task.title.toLowerCase())) {
suggestions.push(task.title)
addedSet.add(task.title.toLowerCase())
}
// 添加标签
task.tags?.forEach(tag => {
if (!addedSet.has(tag.name.toLowerCase())) {
suggestions.push(tag.name)
addedSet.add(tag.name.toLowerCase())
}
})
})
return suggestions
})
// 查询建议
const querySearch = (queryString: string, cb: (results: { value: string }[]) => void) => {
const results = queryString
? searchSuggestions.value
.filter(item => matchWithPinyin(item, queryString))
.slice(0, 8)
.map(item => ({ value: item }))
: searchSuggestions.value
.slice(0, 8)
.map(item => ({ value: item }))
cb(results)
}
// 选中建议
const handleSelect = (item: { value: string }) => {
searchInput.value = item.value
taskStore.setFilters({ search: item.value })
}
// 高亮建议中的匹配文本
const suggestionHighlight = (item: { value: string }) => {
return highlightMatch(item.value, searchInput.value)
}
</script>
<template>
<div class="task-list">
<div class="list-header">
<h2 class="list-title">
{{ statusText }}
<span class="task-count">{{ taskStore.tasks.length }}</span>
</h2>
</div>
<!-- 搜索框 -->
<div class="search-box">
<el-autocomplete
v-model="searchInput"
:fetch-suggestions="querySearch"
placeholder="搜索任务标题、描述或标签..."
clearable
class="search-input"
popper-class="search-suggestions"
@clear="clearSearch"
@select="handleSelect"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
<template #default="{ item }">
<span class="suggestion-item" v-html="suggestionHighlight(item)" />
</template>
</el-autocomplete>
</div>
<div v-if="taskStore.loading" class="loading-state">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="taskStore.tasks.length === 0" class="empty-state">
<div class="empty-icon"></div>
<p class="empty-text">
{{ searchInput ? '没有找到匹配的任务呢~' : '还没有任务呢~' }}
</p>
<p v-if="!searchInput" class="empty-hint">点击右下角的按钮创建一个新任务吧</p>
<p v-else class="empty-hint">试试换个关键词搜索吧</p>
</div>
<div v-else :key="filterKey" class="task-grid">
<TaskCard
v-for="(task, index) in taskStore.tasks"
:key="task.id"
:task="task"
:style="{ animationDelay: `${index * 50}ms` }"
class="task-item"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.task-list {
max-width: 900px;
margin: 0 auto;
}
.list-header {
margin-bottom: 16px;
}
.list-title {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 12px;
.task-count {
font-size: 14px;
font-weight: 500;
padding: 4px 12px;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
color: white;
border-radius: 20px;
}
}
.search-box {
margin-bottom: 24px;
.search-input {
width: 100%;
:deep(.el-input__wrapper) {
border-radius: 16px;
box-shadow: var(--shadow-sm);
border: 1px solid transparent;
transition: all 0.2s ease;
padding: 8px 16px;
min-height: 48px;
&:hover {
border-color: var(--primary-light);
}
&.is-focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(255, 183, 197, 0.2);
}
}
:deep(.el-input__inner) {
font-size: 16px;
}
:deep(.el-input__prefix) {
color: var(--text-secondary);
font-size: 18px;
}
:deep(.el-input__clear) {
font-size: 16px;
}
}
}
.suggestion-item {
font-size: 14px;
:deep(.search-highlight) {
background: linear-gradient(135deg, rgba(255, 183, 197, 0.4) 0%, rgba(255, 183, 197, 0.6) 100%);
color: var(--primary-dark);
padding: 0 2px;
border-radius: 3px;
font-weight: 500;
}
}
.task-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.task-item {
animation: fadeInUp 0.3s ease forwards;
opacity: 0;
}
.loading-state {
padding: 20px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
.empty-icon {
font-size: 64px;
color: var(--primary);
margin-bottom: 16px;
animation: twinkle 2s ease-in-out infinite;
}
.empty-text {
font-size: 18px;
color: var(--text-primary);
margin-bottom: 8px;
}
.empty-hint {
font-size: 14px;
color: var(--text-secondary);
}
}
// 列表项入场动画(筛选切换时整体淡入)
.task-grid {
animation: filterFadeIn 0.2s ease;
}
@keyframes filterFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import CategorySidebar from '@/components/CategorySidebar.vue'
import TaskList from '@/views/TaskList.vue'
import { useUIStore } from '@/stores/useUIStore'
const uiStore = useUIStore()
</script>
<template>
<div class="task-list-layout">
<CategorySidebar />
<main class="main-content" :class="{ 'sidebar-collapsed': uiStore.sidebarCollapsed }">
<Transition name="view-fade" mode="out-in">
<TaskList />
</Transition>
</main>
</div>
</template>
<style scoped lang="scss">
.task-list-layout {
display: flex;
min-height: calc(100vh - 60px);
}
.main-content {
flex: 1;
padding: 24px;
margin-left: 240px;
will-change: margin-left;
transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.sidebar-collapsed {
margin-left: 0;
}
@media (max-width: 768px) {
margin-left: 0;
padding: 16px;
}
}
.view-fade-enter-active,
.view-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.view-fade-enter-from {
opacity: 0;
transform: translateY(10px);
}
.view-fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>

View File

@@ -0,0 +1,447 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ArrowLeft, ArrowRight, Calendar } from '@element-plus/icons-vue'
import { useTaskStore } from '@/stores/useTaskStore'
import { useUIStore } from '@/stores/useUIStore'
import {
formatDate,
getWeekDays,
getWeekNumber,
isToday
} from '@/utils/date'
import { getPriorityColor } from '@/utils/priority'
import type { Task } from '@/api/types'
const taskStore = useTaskStore()
const uiStore = useUIStore()
const currentDate = ref(new Date())
// 星期标题
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
// 时间刻度0-23小时
const hours = Array.from({ length: 24 }, (_, i) => i)
// 当前显示的周数据
const weekData = computed(() => {
const days = getWeekDays(currentDate.value)
return days.map((date, index) => ({
date,
dateStr: formatDate(date),
dayOfWeek: index,
dayName: weekDays[index] ?? '',
dayNumber: date.getDate(),
month: date.getMonth() + 1,
isToday: isToday(date),
tasks: taskStore.tasksByDate.get(formatDate(date)) || []
}))
})
// 周标题
const weekTitle = computed(() => {
const year = currentDate.value.getFullYear()
const weekNum = getWeekNumber(currentDate.value)
return `${year}年 第${weekNum}`
})
// 上一周
function prevWeek() {
const d = new Date(currentDate.value)
d.setDate(d.getDate() - 7)
currentDate.value = d
}
// 下一周
function nextWeek() {
const d = new Date(currentDate.value)
d.setDate(d.getDate() + 7)
currentDate.value = d
}
// 回到本周
function goToday() {
currentDate.value = new Date()
}
// 获取任务在时间轴上的位置(小时)
function getTaskHour(task: Task): number {
if (!task.due_date) return -1
const date = new Date(task.due_date)
return date.getHours()
}
// 获取某天某小时的任务列表
function getTasksForHour(dayIndex: number, hour: number): Task[] {
const day = weekData.value[dayIndex]
if (!day) return []
return day.tasks.filter(task => {
const taskHour = getTaskHour(task)
return taskHour === hour
})
}
// 格式化小时显示
function formatHour(hour: number): string {
return `${String(hour).padStart(2, '0')}:00`
}
// 打开任务详情/编辑
function handleTaskClick(task: Task, event: Event) {
event.stopPropagation()
uiStore.openTaskDialog(task)
}
</script>
<template>
<div class="weekly-view">
<!-- 周导航 -->
<div class="week-nav">
<div class="nav-left">
<h2 class="week-title">{{ weekTitle }}</h2>
<span class="task-summary">
{{ taskStore.totalTasks.length }} 个任务
</span>
</div>
<div class="nav-right">
<el-button-group class="mode-switch">
<el-button
class="mode-btn"
:type="uiStore.calendarMode === 'week' ? 'primary' : 'default'"
@click="uiStore.setCalendarMode('week')"
>
</el-button>
<el-button
class="mode-btn"
:type="uiStore.calendarMode === 'monthly' ? 'primary' : 'default'"
@click="uiStore.setCalendarMode('monthly')"
>
</el-button>
</el-button-group>
<el-button class="nav-btn" @click="goToday">
<el-icon><Calendar /></el-icon>
本周
</el-button>
<el-button-group class="nav-group">
<el-button class="nav-btn" @click="prevWeek">
<el-icon><ArrowLeft /></el-icon>
</el-button>
<el-button class="nav-btn" @click="nextWeek">
<el-icon><ArrowRight /></el-icon>
</el-button>
</el-button-group>
</div>
</div>
<!-- 时间轴容器 -->
<div class="timeline-container">
<!-- 日期头部 -->
<div class="timeline-header">
<div class="time-label"></div>
<div
v-for="day in weekData"
:key="day.dateStr"
class="day-header"
:class="{ 'is-today': day.isToday }"
>
<span class="day-name">{{ day.dayName }}</span>
<span class="day-number">{{ day.month }}/{{ day.dayNumber }}</span>
<span v-if="day.tasks.length > 0" class="task-badge">
{{ day.tasks.length }}
</span>
</div>
</div>
<!-- 时间轴内容 -->
<div class="timeline-body">
<!-- 左侧时间刻度 -->
<div class="time-column">
<div v-for="hour in hours" :key="hour" class="time-cell">
{{ formatHour(hour) }}
</div>
</div>
<!-- 每天的任务列 -->
<div
v-for="(day, dayIndex) in weekData"
:key="day.dateStr"
class="day-column"
:class="{ 'is-today': day.isToday }"
>
<div v-for="hour in hours" :key="hour" class="hour-cell">
<!-- 该小时的任务 -->
<div
v-for="task in getTasksForHour(dayIndex, hour)"
:key="task.id"
class="task-block"
:class="{ completed: task.is_completed }"
@click="handleTaskClick(task, $event)"
>
<span
class="priority-indicator"
:style="{ background: getPriorityColor(task.priority) }"
></span>
<span class="task-title">{{ task.title }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.weekly-view {
height: 100%;
display: flex;
flex-direction: column;
}
.week-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 0 4px;
}
.nav-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.week-title {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
}
.task-summary {
font-size: 14px;
color: var(--text-secondary);
}
.nav-right {
display: flex;
align-items: center;
gap: 12px;
}
.mode-switch {
.mode-btn {
border-radius: 0;
&:first-child {
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
&:last-child {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
}
}
.nav-btn {
border-radius: var(--radius-md);
border-color: var(--secondary);
color: var(--text-primary);
&:hover {
border-color: var(--primary);
color: var(--primary);
}
}
.nav-group {
.nav-btn {
border-radius: 0;
&:first-child {
border-radius: var(--radius-md) 0 0 var(--radius-md);
}
&:last-child {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
}
}
.timeline-container {
flex: 1;
display: flex;
flex-direction: column;
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.timeline-header {
display: grid;
grid-template-columns: 60px repeat(7, 1fr);
background: var(--background-dark);
border-bottom: 1px solid var(--secondary);
}
.time-label {
padding: 12px 8px;
}
.day-header {
padding: 12px 8px;
text-align: center;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
&.is-today {
background: rgba(255, 183, 197, 0.2);
.day-name,
.day-number {
color: var(--primary);
font-weight: 600;
}
}
}
.day-name {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.day-number {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.task-badge {
position: absolute;
top: 6px;
right: 6px;
font-size: 10px;
padding: 2px 6px;
background: var(--primary);
color: white;
border-radius: 10px;
min-width: 18px;
text-align: center;
}
.timeline-body {
flex: 1;
display: grid;
grid-template-columns: 60px repeat(7, 1fr);
overflow-y: auto;
}
.time-column {
background: var(--background);
border-right: 1px solid var(--secondary);
}
.time-cell {
height: 48px;
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: 4px 8px;
font-size: 11px;
color: var(--text-secondary);
border-bottom: 1px solid var(--background-dark);
}
.day-column {
border-right: 1px solid var(--background-dark);
position: relative;
&:last-child {
border-right: none;
}
&.is-today {
background: rgba(255, 183, 197, 0.05);
}
}
.hour-cell {
height: 48px;
border-bottom: 1px solid var(--background-dark);
padding: 2px;
position: relative;
}
.task-block {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--background);
border-radius: var(--radius-sm);
border-left: 3px solid var(--primary);
cursor: pointer;
transition: all var(--transition-fast);
margin-bottom: 2px;
&:hover {
background: var(--background-dark);
transform: translateX(2px);
}
&.completed {
opacity: 0.6;
.task-title {
text-decoration: line-through;
}
}
}
.priority-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.task-title {
font-size: 12px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
@media (max-width: 768px) {
.week-nav {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.time-cell {
font-size: 10px;
padding: 2px 4px;
}
.hour-cell {
height: 40px;
}
.task-block {
padding: 2px 4px;
.task-title {
font-size: 10px;
}
}
}
</style>

19
WebUI/tsconfig.app.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
WebUI/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
WebUI/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

32
WebUI/vite.config.ts Normal file
View File

@@ -0,0 +1,32 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:23994',
changeOrigin: true
}
}
},
build: {
rollupOptions: {
output: {
manualChunks: {
'element-plus': ['element-plus', '@element-plus/icons-vue'],
'vendor': ['vue', 'vue-router', 'pinia', 'axios']
}
}
}
},
cacheDir: 'node_modules/.vite'
})

1
api/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# 爱莉希雅待办事项后端

29
api/app/config.py Normal file
View File

@@ -0,0 +1,29 @@
# 硬编码配置
import os
# api 目录的绝对路径(基于本文件位置计算,不依赖工作目录)
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 数据库配置
DATABASE_PATH = os.path.join(_BASE_DIR, "data", "todo.db")
DATABASE_URL = f"sqlite:///{DATABASE_PATH}"
# WebUI 配置
WEBUI_PATH = os.path.join(_BASE_DIR, "webui")
# CORS 配置
CORS_ORIGINS = [
"http://localhost:5173",
"http://localhost:23994",
]
# 日志配置
LOG_LEVEL = "INFO"
LOG_DIR = os.path.join(_BASE_DIR, "logs")
# 分页配置
DEFAULT_PAGE_SIZE = 20
# 服务配置
HOST = "0.0.0.0"
PORT = 23994

101
api/app/database.py Normal file
View File

@@ -0,0 +1,101 @@
from sqlalchemy import create_engine, inspect, text, String, Integer, Text, Boolean, Float, DateTime, Date
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
from app.config import DATABASE_PATH, DATABASE_URL
# 确保 data 目录存在
os.makedirs(os.path.dirname(DATABASE_PATH) if os.path.dirname(DATABASE_PATH) else ".", exist_ok=True)
# 创建引擎
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False}
)
# 创建会话工厂
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 创建基类
Base = declarative_base()
def get_db():
"""获取数据库会话"""
db = SessionLocal()
try:
yield db
finally:
db.close()
# SQLAlchemy 类型到 SQLite 类型名的映射
_TYPE_MAP = {
String: "VARCHAR",
Integer: "INTEGER",
Text: "TEXT",
Boolean: "BOOLEAN",
Float: "REAL",
DateTime: "DATETIME",
Date: "DATE",
}
def _col_type_str(col_type) -> str:
"""将 SQLAlchemy 列类型转为 SQLite 类型字符串"""
if col_type.__class__ in _TYPE_MAP:
base = _TYPE_MAP[col_type.__class__]
else:
base = str(col_type).split("(")[0].strip()
length = getattr(col_type, "length", None)
if length:
return f"{base}({length})"
return base
def init_db():
"""初始化数据库表,自动补充新增的列"""
# 导入所有模型,确保 Base.metadata 包含全部表定义
from app.models import ( # noqa: F401
task, category, tag, user_settings, habit, anniversary, account,
)
Base.metadata.create_all(bind=engine)
# 通用自动迁移:对比 ORM 模型与实际表结构补充缺失的列SQLite 兼容)
inspector = inspect(engine)
table_names = set(inspector.get_table_names())
with engine.begin() as conn:
for table_cls in Base.metadata.sorted_tables:
table_name = table_cls.name
if table_name not in table_names:
continue
existing_cols = {c["name"] for c in inspector.get_columns(table_name)}
for col in table_cls.columns:
if col.name in existing_cols:
continue
# 跳过无服务端默认值且不可为空的列(容易出错)
if col.nullable is False and col.server_default is None and col.default is None:
continue
sqlite_type = _col_type_str(col.type)
ddl = f"ALTER TABLE {table_name} ADD COLUMN {col.name} {sqlite_type}"
# 为可空列或已有默认值的列附加 DEFAULT
if col.server_default is not None:
ddl += f" DEFAULT {col.server_default.arg}"
elif col.default is not None and col.nullable:
default_val = col.default.arg
if isinstance(default_val, str):
ddl += f" DEFAULT '{default_val}'"
elif isinstance(default_val, bool):
ddl += f" DEFAULT {1 if default_val else 0}"
else:
ddl += f" DEFAULT {default_val}"
conn.execute(text(ddl))

144
api/app/main.py Normal file
View File

@@ -0,0 +1,144 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse
import os
import time
import json
from app.config import CORS_ORIGINS, WEBUI_PATH, HOST, PORT
from app.database import init_db
from app.routers import api_router
from app.utils.logger import logger
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
# 启动时
logger.info("应用启动中...")
init_db()
logger.info("数据库初始化完成")
yield
# 关闭时
logger.info("应用关闭中...")
# 创建 FastAPI 应用
app = FastAPI(
title="爱莉希雅待办事项 API",
description="Elysia ToDo - 个人信息管理应用",
version="1.0.0",
lifespan=lifespan,
)
# 配置 CORS
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 请求日志中间件
@app.middleware("http")
async def log_requests(request: Request, call_next):
start_time = time.time()
# 记录请求信息
request_method = request.method
request_path = request.url.path
query_params = dict(request.query_params) if request.query_params else None
# 构建日志信息
log_parts = [f"请求开始 -> {request_method} {request_path}"]
if query_params:
log_parts.append(f"Query参数: {json.dumps(query_params, ensure_ascii=False)}")
# 尝试读取请求体(仅对有 body 的方法)
body_info = None
if request.method in ["POST", "PUT", "PATCH"]:
try:
body_bytes = await request.body()
if body_bytes:
try:
body_json = json.loads(body_bytes)
body_info = json.dumps(body_json, ensure_ascii=False)
except json.JSONDecodeError:
body_info = body_bytes.decode('utf-8', errors='replace')[:200]
log_parts.append(f"Body: {body_info}")
except Exception:
pass
logger.info(" | ".join(log_parts))
# 执行请求
response = await call_next(request)
# 计算耗时
process_time = (time.time() - start_time) * 1000
# 记录响应信息
logger.info(
f"请求完成 <- {request_method} {request_path} | "
f"状态码: {response.status_code} | 耗时: {process_time:.2f}ms"
)
return response
# 全局异常处理器
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
# 使用 exc_info=True 记录完整堆栈信息
logger.error(
f"全局异常: {request.method} {request.url.path} | 错误: {str(exc)}",
exc_info=True
)
return JSONResponse(
status_code=500,
content={
"success": False,
"message": "服务器内部错误",
"error_code": "INTERNAL_ERROR"
}
)
# 注册路由
app.include_router(api_router)
# 健康检查(必须在 static mount 之前注册,否则会被静态文件拦截)
@app.get("/health")
async def health_check():
"""健康检查"""
return {"status": "ok", "message": "服务运行正常"}
# SPA 静态文件回退路由(支持前端 History 模式路由)
if os.path.exists(WEBUI_PATH):
@app.get("/")
async def serve_index():
return FileResponse(os.path.join(WEBUI_PATH, "index.html"))
@app.get("/{full_path:path}")
async def spa_fallback(request: Request, full_path: str):
"""SPA 回退:先尝试提供真实文件,找不到则返回 index.html"""
file_path = os.path.join(WEBUI_PATH, full_path)
if os.path.isfile(file_path):
return FileResponse(file_path)
return FileResponse(os.path.join(WEBUI_PATH, "index.html"))
logger.info(f"SPA 静态文件服务已配置: {WEBUI_PATH}")
else:
logger.warning(f"WebUI 目录不存在: {WEBUI_PATH}")
if __name__ == "__main__":
import uvicorn
logger.info(f"启动服务: {HOST}:{PORT}")
uvicorn.run(app, host=HOST, port=PORT)

View File

@@ -0,0 +1,14 @@
from app.models.task import Task
from app.models.category import Category
from app.models.tag import Tag, task_tags
from app.models.user_settings import UserSettings
from app.models.habit import HabitGroup, Habit, HabitCheckin
from app.models.anniversary import AnniversaryCategory, Anniversary
from app.models.account import FinancialAccount, AccountHistory, DebtInstallment
__all__ = [
"Task", "Category", "Tag", "task_tags", "UserSettings",
"HabitGroup", "Habit", "HabitCheckin",
"AnniversaryCategory", "Anniversary",
"FinancialAccount", "AccountHistory", "DebtInstallment",
]

61
api/app/models/account.py Normal file
View File

@@ -0,0 +1,61 @@
from sqlalchemy import Column, Integer, String, Text, Boolean, Float, DateTime, ForeignKey, Date
from sqlalchemy.orm import relationship
from app.database import Base
from app.utils.datetime import utcnow
class FinancialAccount(Base):
"""财务账户模型"""
__tablename__ = "financial_accounts"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
account_type = Column(String(20), nullable=False, default="savings") # savings / debt
balance = Column(Float, default=0.0)
icon = Column(String(50), default="wallet")
color = Column(String(20), default="#FFB7C5")
sort_order = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
# 关联关系
history_records = relationship("AccountHistory", back_populates="account", cascade="all, delete-orphan")
debt_installments = relationship("DebtInstallment", back_populates="account", cascade="all, delete-orphan")
class AccountHistory(Base):
"""余额变更历史"""
__tablename__ = "account_history"
id = Column(Integer, primary_key=True, index=True)
account_id = Column(Integer, ForeignKey("financial_accounts.id"), nullable=False)
change_amount = Column(Float, nullable=False)
balance_before = Column(Float, nullable=False)
balance_after = Column(Float, nullable=False)
note = Column(String(200), nullable=True)
created_at = Column(DateTime, default=utcnow)
# 关联关系
account = relationship("FinancialAccount", back_populates="history_records")
class DebtInstallment(Base):
"""分期还款计划"""
__tablename__ = "debt_installments"
id = Column(Integer, primary_key=True, index=True)
account_id = Column(Integer, ForeignKey("financial_accounts.id"), nullable=False)
total_amount = Column(Float, nullable=False)
total_periods = Column(Integer, nullable=False)
current_period = Column(Integer, nullable=False, default=1) # 1-based, 指向下一期待还
payment_day = Column(Integer, nullable=False) # 每月还款日 1-31
payment_amount = Column(Float, nullable=False)
start_date = Column(Date, nullable=False)
is_completed = Column(Boolean, default=False)
created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
# 关联关系
account = relationship("FinancialAccount", back_populates="debt_installments")

View File

@@ -0,0 +1,37 @@
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Date
from sqlalchemy.orm import relationship
from app.database import Base
from app.utils.datetime import utcnow
class AnniversaryCategory(Base):
"""纪念日分类模型"""
__tablename__ = "anniversary_categories"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), nullable=False)
icon = Column(String(50), default="calendar")
color = Column(String(20), default="#FFB7C5")
sort_order = Column(Integer, default=0)
# 关联关系
anniversaries = relationship("Anniversary", back_populates="category")
class Anniversary(Base):
"""纪念日模型"""
__tablename__ = "anniversaries"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(200), nullable=False)
date = Column(Date, nullable=False) # 月-日,年份部分可选
year = Column(Integer, nullable=True) # 年份,用于计算第 N 个周年
category_id = Column(Integer, ForeignKey("anniversary_categories.id"), nullable=True)
description = Column(Text, nullable=True)
is_recurring = Column(Boolean, default=True)
remind_days_before = Column(Integer, default=3)
created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
# 关联关系
category = relationship("AnniversaryCategory", back_populates="anniversaries")

View File

@@ -0,0 +1,16 @@
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship
from app.database import Base
class Category(Base):
"""分类模型"""
__tablename__ = "categories"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
color = Column(String(20), default="#FFB7C5") # 默认樱花粉
icon = Column(String(50), default="folder") # 默认图标
# 关联关系
tasks = relationship("Task", back_populates="category")

55
api/app/models/habit.py Normal file
View File

@@ -0,0 +1,55 @@
from sqlalchemy import Column, Integer, String, Text, Boolean, Date, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship
from app.database import Base
from app.utils.datetime import utcnow
class HabitGroup(Base):
"""习惯分组模型"""
__tablename__ = "habit_groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
color = Column(String(20), default="#FFB7C5")
icon = Column(String(50), default="flag")
sort_order = Column(Integer, default=0)
# 关联关系
habits = relationship("Habit", back_populates="group", order_by="Habit.created_at")
class Habit(Base):
"""习惯模型"""
__tablename__ = "habits"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False)
description = Column(Text, nullable=True)
group_id = Column(Integer, ForeignKey("habit_groups.id"), nullable=True)
target_count = Column(Integer, default=1)
frequency = Column(String(20), default="daily") # daily / weekly
active_days = Column(String(100), nullable=True) # JSON 数组, 如 [0,2,4] 表示周一三五
is_archived = Column(Boolean, default=False)
created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
# 关联关系
group = relationship("HabitGroup", back_populates="habits")
checkins = relationship("HabitCheckin", back_populates="habit", cascade="all, delete-orphan")
class HabitCheckin(Base):
"""习惯打卡记录模型"""
__tablename__ = "habit_checkins"
__table_args__ = (
UniqueConstraint("habit_id", "checkin_date", name="uq_habit_checkin_date"),
)
id = Column(Integer, primary_key=True, index=True)
habit_id = Column(Integer, ForeignKey("habits.id"), nullable=False)
checkin_date = Column(Date, nullable=False)
count = Column(Integer, default=0)
created_at = Column(DateTime, default=utcnow)
# 关联关系
habit = relationship("Habit", back_populates="checkins")

23
api/app/models/tag.py Normal file
View File

@@ -0,0 +1,23 @@
from sqlalchemy import Column, Integer, String, Table, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
# 任务-标签关联表(多对多)
task_tags = Table(
"task_tags",
Base.metadata,
Column("task_id", Integer, ForeignKey("tasks.id"), primary_key=True),
Column("tag_id", Integer, ForeignKey("tags.id"), primary_key=True)
)
class Tag(Base):
"""标签模型"""
__tablename__ = "tags"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), nullable=False, unique=True)
# 关联关系
tasks = relationship("Task", secondary=task_tags, back_populates="tags")

23
api/app/models/task.py Normal file
View File

@@ -0,0 +1,23 @@
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
from app.utils.datetime import utcnow
class Task(Base):
"""任务模型"""
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(200), nullable=False)
description = Column(Text, nullable=True)
priority = Column(String(20), default="q4") # q1(重要紧急), q2(重要不紧急), q3(不重要紧急), q4(不重要不紧急)
due_date = Column(DateTime, nullable=True)
is_completed = Column(Boolean, default=False)
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
# 关联关系
category = relationship("Category", back_populates="tasks")
tags = relationship("Tag", secondary="task_tags", back_populates="tasks")

View File

@@ -0,0 +1,36 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, Date
from datetime import datetime, timezone, date
from app.database import Base
def utcnow():
"""统一获取 UTC 时间的工厂函数"""
return datetime.now(timezone.utc)
class UserSettings(Base):
"""用户设置模型(单例,始终只有一条记录 id=1"""
__tablename__ = "user_settings"
id = Column(Integer, primary_key=True, default=1)
# 个人信息
nickname = Column(String(50), default="爱莉希雅")
avatar = Column(Text, nullable=True)
signature = Column(String(200), nullable=True)
birthday = Column(Date, nullable=True)
email = Column(String(100), nullable=True)
# 应用信息
site_name = Column(String(50), default="爱莉希雅待办")
# 应用偏好
theme = Column(String(20), default="pink")
language = Column(String(10), default="zh-CN")
default_view = Column(String(20), default="list")
default_sort_by = Column(String(20), default="created_at")
default_sort_order = Column(String(10), default="desc")
# 时间戳
created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)

View File

@@ -0,0 +1,14 @@
from fastapi import APIRouter
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, accounts
# 创建主路由
api_router = APIRouter()
# 注册子路由
api_router.include_router(tasks.router)
api_router.include_router(categories.router)
api_router.include_router(tags.router)
api_router.include_router(user_settings.router)
api_router.include_router(habits.router)
api_router.include_router(anniversaries.router)
api_router.include_router(accounts.router)

498
api/app/routers/accounts.py Normal file
View File

@@ -0,0 +1,498 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional, List
from datetime import date
from calendar import monthrange
from app.database import get_db
from app.models.account import FinancialAccount, AccountHistory, DebtInstallment
from app.schemas.account import (
AccountCreate, AccountUpdate, AccountResponse, BalanceUpdateRequest,
AccountHistoryResponse, AccountListItemResponse,
DebtInstallmentCreate, DebtInstallmentUpdate, DebtInstallmentResponse,
)
from app.schemas.common import DeleteResponse
from app.utils.crud import get_or_404
from app.utils.datetime import utcnow
from app.utils.logger import logger
router = APIRouter(prefix="/api", tags=["资产"])
def compute_installment_info(installment: DebtInstallment, today: date) -> dict:
"""计算分期计划的下次还款日期、距今天数、剩余期数"""
if installment.is_completed:
return {
"next_payment_date": None,
"days_until_payment": None,
"remaining_periods": 0,
}
remaining = installment.total_periods - installment.current_period + 1
if remaining <= 0:
return {
"next_payment_date": None,
"days_until_payment": None,
"remaining_periods": 0,
}
# 根据 start_date 和 payment_day 计算下一还款日期
payment_day = installment.payment_day
start_year = installment.start_date.year
start_month = installment.start_date.month
# 计算当前应还的期数对应的月份
period_index = installment.current_period - 1
next_month_year = start_year * 12 + (start_month - 1) + period_index
next_year = next_month_year // 12
next_month = next_month_year % 12 + 1
# 处理 payment_day 超出当月天数的情况
max_day = monthrange(next_year, next_month)[1]
actual_day = min(payment_day, max_day)
next_payment_date = date(next_year, next_month, actual_day)
# 如果计算出的日期在 start_date 之前(边界情况),使用 start_date
if next_payment_date < installment.start_date:
next_payment_date = installment.start_date
days_until = (next_payment_date - today).days
return {
"next_payment_date": next_payment_date,
"days_until_payment": days_until,
"remaining_periods": remaining,
}
# ============ 财务账户 API ============
@router.get("/accounts", response_model=List[AccountListItemResponse])
def get_accounts(db: Session = Depends(get_db)):
"""获取所有账户列表"""
try:
accounts = db.query(FinancialAccount).order_by(
FinancialAccount.sort_order.asc(),
FinancialAccount.id.asc()
).all()
result = []
for acc in accounts:
data = {
"id": acc.id,
"name": acc.name,
"account_type": acc.account_type,
"balance": acc.balance,
"icon": acc.icon,
"color": acc.color,
"sort_order": acc.sort_order,
"is_active": acc.is_active,
"description": acc.description,
"created_at": acc.created_at,
"updated_at": acc.updated_at,
}
# 附加分期计划摘要(欠款账户)
if acc.account_type == "debt":
installments = db.query(DebtInstallment).filter(
DebtInstallment.account_id == acc.id,
DebtInstallment.is_completed == False
).all()
today = date.today()
active_installments = []
for inst in installments:
info = compute_installment_info(inst, today)
active_installments.append(info)
data["installments"] = active_installments
else:
data["installments"] = []
result.append(data)
logger.info(f"获取账户列表成功,总数: {len(result)}")
return result
except Exception as e:
logger.error(f"获取账户列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取账户列表失败")
@router.post("/accounts", response_model=AccountResponse, status_code=201)
def create_account(data: AccountCreate, db: Session = Depends(get_db)):
"""创建账户"""
try:
account = FinancialAccount(
name=data.name,
account_type=data.account_type,
balance=data.balance,
icon=data.icon,
color=data.color,
sort_order=data.sort_order,
is_active=data.is_active,
description=data.description,
)
db.add(account)
db.commit()
db.refresh(account)
logger.info(f"创建账户成功: id={account.id}, name={account.name}")
return account
except Exception as e:
db.rollback()
logger.error(f"创建账户失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建账户失败")
@router.get("/accounts/{account_id}", response_model=AccountResponse)
def get_account(account_id: int, db: Session = Depends(get_db)):
"""获取单个账户"""
try:
account = get_or_404(db, FinancialAccount, account_id, "账户")
return account
except HTTPException:
raise
except Exception as e:
logger.error(f"获取账户失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取账户失败")
@router.put("/accounts/{account_id}", response_model=AccountResponse)
def update_account(account_id: int, data: AccountUpdate, db: Session = Depends(get_db)):
"""更新账户基本信息"""
try:
account = get_or_404(db, FinancialAccount, account_id, "账户")
update_data = data.model_dump(exclude_unset=True)
# 不允许通过此接口修改余额(使用专门的余额更新接口)
if "balance" in update_data:
del update_data["balance"]
for field, value in update_data.items():
setattr(account, field, value)
account.updated_at = utcnow()
db.commit()
db.refresh(account)
logger.info(f"更新账户成功: id={account_id}")
return account
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新账户失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新账户失败")
@router.delete("/accounts/{account_id}")
def delete_account(account_id: int, db: Session = Depends(get_db)):
"""删除账户(级联删除历史记录和分期计划)"""
try:
account = get_or_404(db, FinancialAccount, account_id, "账户")
db.delete(account)
db.commit()
logger.info(f"删除账户成功: id={account_id}")
return DeleteResponse(message="账户删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除账户失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除账户失败")
@router.post("/accounts/{account_id}/balance", response_model=AccountResponse)
def update_balance(account_id: int, data: BalanceUpdateRequest, db: Session = Depends(get_db)):
"""更新账户余额(自动记录变更历史)"""
try:
account = get_or_404(db, FinancialAccount, account_id, "账户")
old_balance = account.balance
new_balance = data.new_balance
change_amount = round(new_balance - old_balance, 2)
# 创建历史记录
history = AccountHistory(
account_id=account_id,
change_amount=change_amount,
balance_before=old_balance,
balance_after=new_balance,
note=data.note,
)
db.add(history)
# 更新余额
account.balance = new_balance
account.updated_at = utcnow()
db.commit()
db.refresh(account)
logger.info(f"更新余额成功: account_id={account_id}, {old_balance} -> {new_balance}")
return account
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新余额失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新余额失败")
@router.get("/accounts/{account_id}/history")
def get_account_history(
account_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db)):
"""获取账户变更历史"""
try:
account = get_or_404(db, FinancialAccount, account_id, "账户")
total = db.query(AccountHistory).filter(
AccountHistory.account_id == account_id
).count()
records = db.query(AccountHistory).filter(
AccountHistory.account_id == account_id
).order_by(
AccountHistory.created_at.desc()
).offset((page - 1) * page_size).limit(page_size).all()
result = {
"total": total,
"page": page,
"page_size": page_size,
"records": [
{
"id": r.id,
"account_id": r.account_id,
"change_amount": r.change_amount,
"balance_before": r.balance_before,
"balance_after": r.balance_after,
"note": r.note,
"created_at": r.created_at,
}
for r in records
]
}
logger.info(f"获取账户历史成功: account_id={account_id}, total={total}")
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"获取账户历史失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取账户历史失败")
# ============ 分期还款计划 API ============
@router.get("/debt-installments", response_model=List[DebtInstallmentResponse])
def get_installments(db: Session = Depends(get_db)):
"""获取所有分期计划(含下次还款计算)"""
try:
installments = db.query(DebtInstallment).order_by(
DebtInstallment.is_completed.asc(),
DebtInstallment.next_payment_date.asc() if hasattr(DebtInstallment, 'next_payment_date') else DebtInstallment.id.asc()
).all()
today = date.today()
result = []
for inst in installments:
info = compute_installment_info(inst, today)
data = {
"id": inst.id,
"account_id": inst.account_id,
"total_amount": inst.total_amount,
"total_periods": inst.total_periods,
"current_period": inst.current_period,
"payment_day": inst.payment_day,
"payment_amount": inst.payment_amount,
"start_date": inst.start_date,
"is_completed": inst.is_completed,
"created_at": inst.created_at,
"updated_at": inst.updated_at,
**info,
}
if inst.account:
data["account_name"] = inst.account.name
data["account_icon"] = inst.account.icon
data["account_color"] = inst.account.color
result.append(data)
# 排序:未完成且临近的排前面
result.sort(key=lambda x: (
0 if not x["is_completed"] and x["days_until_payment"] is not None else 1,
0 if not x["is_completed"] else 1,
x["days_until_payment"] if x["days_until_payment"] is not None else 9999,
))
logger.info(f"获取分期计划列表成功,总数: {len(result)}")
return result
except Exception as e:
logger.error(f"获取分期计划列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取分期计划列表失败")
@router.post("/debt-installments", response_model=DebtInstallmentResponse, status_code=201)
def create_installment(data: DebtInstallmentCreate, db: Session = Depends(get_db)):
"""创建分期计划"""
try:
# 验证关联账户存在且为欠款类型
account = get_or_404(db, FinancialAccount, data.account_id, "账户")
if account.account_type != "debt":
raise HTTPException(status_code=400, detail="分期计划只能关联欠款类型的账户")
installment = DebtInstallment(
account_id=data.account_id,
total_amount=data.total_amount,
total_periods=data.total_periods,
current_period=data.current_period,
payment_day=data.payment_day,
payment_amount=data.payment_amount,
start_date=data.start_date,
is_completed=data.is_completed,
)
db.add(installment)
db.commit()
db.refresh(installment)
# 返回含计算字段的响应
today = date.today()
info = compute_installment_info(installment, today)
logger.info(f"创建分期计划成功: id={installment.id}")
return DebtInstallmentResponse(
id=installment.id,
account_id=installment.account_id,
total_amount=installment.total_amount,
total_periods=installment.total_periods,
current_period=installment.current_period,
payment_day=installment.payment_day,
payment_amount=installment.payment_amount,
start_date=installment.start_date,
is_completed=installment.is_completed,
created_at=installment.created_at,
updated_at=installment.updated_at,
**info,
account_name=account.name,
account_icon=account.icon,
account_color=account.color,
)
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"创建分期计划失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建分期计划失败")
@router.put("/debt-installments/{installment_id}", response_model=DebtInstallmentResponse)
def update_installment(installment_id: int, data: DebtInstallmentUpdate, db: Session = Depends(get_db)):
"""更新分期计划"""
try:
installment = get_or_404(db, DebtInstallment, installment_id, "分期计划")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(installment, field, value)
installment.updated_at = utcnow()
db.commit()
db.refresh(installment)
today = date.today()
info = compute_installment_info(installment, today)
result = DebtInstallmentResponse(
id=installment.id,
account_id=installment.account_id,
total_amount=installment.total_amount,
total_periods=installment.total_periods,
current_period=installment.current_period,
payment_day=installment.payment_day,
payment_amount=installment.payment_amount,
start_date=installment.start_date,
is_completed=installment.is_completed,
created_at=installment.created_at,
updated_at=installment.updated_at,
**info,
)
if installment.account:
result.account_name = installment.account.name
result.account_icon = installment.account.icon
result.account_color = installment.account.color
logger.info(f"更新分期计划成功: id={installment_id}")
return result
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新分期计划失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新分期计划失败")
@router.delete("/debt-installments/{installment_id}")
def delete_installment(installment_id: int, db: Session = Depends(get_db)):
"""删除分期计划"""
try:
installment = get_or_404(db, DebtInstallment, installment_id, "分期计划")
db.delete(installment)
db.commit()
logger.info(f"删除分期计划成功: id={installment_id}")
return DeleteResponse(message="分期计划删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除分期计划失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除分期计划失败")
@router.patch("/debt-installments/{installment_id}/pay")
def pay_installment(installment_id: int, db: Session = Depends(get_db)):
"""标记已还一期"""
try:
installment = get_or_404(db, DebtInstallment, installment_id, "分期计划")
if installment.is_completed:
raise HTTPException(status_code=400, detail="该分期计划已全部还清")
installment.current_period += 1
# 检查是否已全部还清
if installment.current_period > installment.total_periods:
installment.is_completed = True
installment.current_period = installment.total_periods
installment.updated_at = utcnow()
db.commit()
db.refresh(installment)
today = date.today()
info = compute_installment_info(installment, today)
result = DebtInstallmentResponse(
id=installment.id,
account_id=installment.account_id,
total_amount=installment.total_amount,
total_periods=installment.total_periods,
current_period=installment.current_period,
payment_day=installment.payment_day,
payment_amount=installment.payment_amount,
start_date=installment.start_date,
is_completed=installment.is_completed,
created_at=installment.created_at,
updated_at=installment.updated_at,
**info,
)
if installment.account:
result.account_name = installment.account.name
result.account_icon = installment.account.icon
result.account_color = installment.account.color
logger.info(f"分期还款成功: id={installment_id}, current_period={installment.current_period}")
return result
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"分期还款失败: {str(e)}")
raise HTTPException(status_code=500, detail="分期还款失败")

View File

@@ -0,0 +1,284 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional, List
from datetime import date
from app.database import get_db
from app.models.anniversary import Anniversary, AnniversaryCategory
from app.schemas.anniversary import (
AnniversaryCreate, AnniversaryUpdate, AnniversaryResponse,
AnniversaryCategoryCreate, AnniversaryCategoryUpdate, AnniversaryCategoryResponse,
)
from app.schemas.common import DeleteResponse
from app.utils.crud import get_or_404
from app.utils.datetime import utcnow
from app.utils.logger import logger
router = APIRouter(prefix="/api", tags=["纪念日"])
def compute_next_info(anniversary: Anniversary, today: date) -> tuple:
"""计算纪念日的下一次日期、距今天数、周年数"""
month, day = anniversary.date.month, anniversary.date.day
if anniversary.is_recurring:
# 计算今年和明年的日期
this_year = today.year
next_date = date(this_year, month, day)
if next_date < today:
next_date = date(this_year + 1, month, day)
days_until = (next_date - today).days
year_count = None
if anniversary.year:
year_count = next_date.year - anniversary.year
return next_date, days_until, year_count
else:
# 非重复:使用原始日期(加上年份)
if anniversary.year:
target = date(anniversary.year, month, day)
if target < today:
return None, None, None
days_until = (target - today).days
return target, days_until, 0
else:
# 无年份的日期按今年算
target = date(today.year, month, day)
if target < today:
return None, None, None
days_until = (target - today).days
return target, days_until, None
def enrich_anniversary(anniversary: Anniversary, today: date) -> dict:
"""将 SQLAlchemy 模型转换为响应字典,附加计算字段"""
data = {
"id": anniversary.id,
"title": anniversary.title,
"date": anniversary.date,
"year": anniversary.year,
"category_id": anniversary.category_id,
"description": anniversary.description,
"is_recurring": anniversary.is_recurring,
"remind_days_before": anniversary.remind_days_before,
"created_at": anniversary.created_at,
"updated_at": anniversary.updated_at,
}
next_date, days_until, year_count = compute_next_info(anniversary, today)
data["next_date"] = next_date
data["days_until"] = days_until
data["year_count"] = year_count
if anniversary.category:
data["category"] = {
"id": anniversary.category.id,
"name": anniversary.category.name,
"icon": anniversary.category.icon,
"color": anniversary.category.color,
"sort_order": anniversary.category.sort_order,
}
else:
data["category"] = None
return data
# ============ 纪念日分类 API ============
@router.get("/anniversary-categories", response_model=List[AnniversaryCategoryResponse])
def get_categories(db: Session = Depends(get_db)):
"""获取纪念日分类列表"""
try:
categories = db.query(AnniversaryCategory).order_by(
AnniversaryCategory.sort_order.asc(),
AnniversaryCategory.id.asc()
).all()
logger.info(f"获取纪念日分类列表成功,总数: {len(categories)}")
return categories
except Exception as e:
logger.error(f"获取纪念日分类列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取纪念日分类列表失败")
@router.post("/anniversary-categories", response_model=AnniversaryCategoryResponse, status_code=201)
def create_category(data: AnniversaryCategoryCreate, db: Session = Depends(get_db)):
"""创建纪念日分类"""
try:
db_category = AnniversaryCategory(
name=data.name,
icon=data.icon,
color=data.color,
sort_order=data.sort_order,
)
db.add(db_category)
db.commit()
db.refresh(db_category)
logger.info(f"创建纪念日分类成功: id={db_category.id}, name={db_category.name}")
return db_category
except Exception as e:
db.rollback()
logger.error(f"创建纪念日分类失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建纪念日分类失败")
@router.put("/anniversary-categories/{category_id}", response_model=AnniversaryCategoryResponse)
def update_category(category_id: int, data: AnniversaryCategoryUpdate, db: Session = Depends(get_db)):
"""更新纪念日分类"""
try:
category = get_or_404(db, AnniversaryCategory, category_id, "纪念日分类")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(category, field, value)
db.commit()
db.refresh(category)
logger.info(f"更新纪念日分类成功: id={category_id}")
return category
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新纪念日分类失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新纪念日分类失败")
@router.delete("/anniversary-categories/{category_id}")
def delete_category(category_id: int, db: Session = Depends(get_db)):
"""删除纪念日分类"""
try:
category = get_or_404(db, AnniversaryCategory, category_id, "纪念日分类")
# 删除分类时,将其下纪念日的 category_id 设为 NULL
anniversaries = db.query(Anniversary).filter(
Anniversary.category_id == category_id
).all()
for a in anniversaries:
a.category_id = None
db.delete(category)
db.commit()
logger.info(f"删除纪念日分类成功: id={category_id}")
return DeleteResponse(message="纪念日分类删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除纪念日分类失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除纪念日分类失败")
# ============ 纪念日 API ============
@router.get("/anniversaries", response_model=List[AnniversaryResponse])
def get_anniversaries(
category_id: Optional[int] = Query(None, description="分类ID筛选"),
db: Session = Depends(get_db)
):
"""获取纪念日列表(包含计算字段 next_date, days_until, year_count"""
try:
query = db.query(Anniversary)
if category_id is not None:
query = query.filter(Anniversary.category_id == category_id)
anniversaries = query.order_by(
Anniversary.date.asc(),
Anniversary.title.asc()
).all()
today = date.today()
result = [enrich_anniversary(a, today) for a in anniversaries]
# 排序:即将到来的排前面,同天数的按日期排
result.sort(key=lambda x: (
0 if (x["days_until"] is not None and x["days_until"] >= 0) else 1,
x["days_until"] if x["days_until"] is not None else 9999,
))
logger.info(f"获取纪念日列表成功,总数: {len(result)}")
return result
except Exception as e:
logger.error(f"获取纪念日列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取纪念日列表失败")
@router.post("/anniversaries", response_model=AnniversaryResponse, status_code=201)
def create_anniversary(data: AnniversaryCreate, db: Session = Depends(get_db)):
"""创建纪念日"""
try:
db_anniversary = Anniversary(
title=data.title,
date=data.date,
year=data.year,
category_id=data.category_id,
description=data.description,
is_recurring=data.is_recurring,
remind_days_before=data.remind_days_before,
)
db.add(db_anniversary)
db.commit()
db.refresh(db_anniversary)
today = date.today()
next_date, days_until, year_count = compute_next_info(db_anniversary, today)
logger.info(f"创建纪念日成功: id={db_anniversary.id}, title={db_anniversary.title}")
return db_anniversary
except Exception as e:
db.rollback()
logger.error(f"创建纪念日失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建纪念日失败")
@router.get("/anniversaries/{anniversary_id}", response_model=AnniversaryResponse)
def get_anniversary(anniversary_id: int, db: Session = Depends(get_db)):
"""获取单个纪念日"""
try:
anniversary = get_or_404(db, Anniversary, anniversary_id, "纪念日")
return anniversary
except HTTPException:
raise
except Exception as e:
logger.error(f"获取纪念日失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取纪念日失败")
@router.put("/anniversaries/{anniversary_id}", response_model=AnniversaryResponse)
def update_anniversary(anniversary_id: int, data: AnniversaryUpdate, db: Session = Depends(get_db)):
"""更新纪念日"""
try:
anniversary = get_or_404(db, Anniversary, anniversary_id, "纪念日")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if value is not None or field in data.clearable_fields:
setattr(anniversary, field, value)
anniversary.updated_at = utcnow()
db.commit()
db.refresh(anniversary)
logger.info(f"更新纪念日成功: id={anniversary_id}")
return anniversary
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新纪念日失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新纪念日失败")
@router.delete("/anniversaries/{anniversary_id}")
def delete_anniversary(anniversary_id: int, db: Session = Depends(get_db)):
"""删除纪念日"""
try:
anniversary = get_or_404(db, Anniversary, anniversary_id, "纪念日")
db.delete(anniversary)
db.commit()
logger.info(f"删除纪念日成功: id={anniversary_id}")
return DeleteResponse(message="纪念日删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除纪念日失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除纪念日失败")

View File

@@ -0,0 +1,102 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List
from app.database import get_db
from app.models import Category
from app.schemas import CategoryCreate, CategoryUpdate, CategoryResponse
from app.schemas.common import DeleteResponse
from app.utils.crud import get_or_404
from app.utils.logger import logger
router = APIRouter(prefix="/api/categories", tags=["分类"])
@router.get("", response_model=List[CategoryResponse])
def get_categories(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100),
db: Session = Depends(get_db)
):
"""获取分类列表"""
try:
categories = db.query(Category).offset(skip).limit(limit).all()
logger.info(f"获取分类列表成功,总数: {len(categories)}")
return categories
except Exception as e:
logger.error(f"获取分类列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取分类列表失败")
@router.post("", response_model=CategoryResponse, status_code=201)
def create_category(category_data: CategoryCreate, db: Session = Depends(get_db)):
"""创建分类"""
try:
db_category = Category(
name=category_data.name,
color=category_data.color,
icon=category_data.icon,
)
db.add(db_category)
db.commit()
db.refresh(db_category)
logger.info(f"创建分类成功: id={db_category.id}, name={db_category.name}")
return db_category
except Exception as e:
db.rollback()
logger.error(f"创建分类失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建分类失败")
@router.put("/{category_id}", response_model=CategoryResponse)
def update_category(category_id: int, category_data: CategoryUpdate, db: Session = Depends(get_db)):
"""更新分类"""
try:
category = get_or_404(db, Category, category_id, "分类")
update_data = category_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if value is not None:
setattr(category, field, value)
db.commit()
db.refresh(category)
logger.info(f"更新分类成功: id={category_id}")
return category
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新分类失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新分类失败")
@router.delete("/{category_id}")
def delete_category(category_id: int, db: Session = Depends(get_db)):
"""删除分类"""
try:
category = get_or_404(db, Category, category_id, "分类")
# 检查是否有任务关联
if category.tasks:
logger.warning(f"分类下有关联任务,无法删除: id={category_id}")
raise HTTPException(status_code=400, detail="该分类下有关联任务,无法删除")
db.delete(category)
db.commit()
logger.info(f"删除分类成功: id={category_id}")
return DeleteResponse(message="分类删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除分类失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除分类失败")

368
api/app/routers/habits.py Normal file
View File

@@ -0,0 +1,368 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, distinct
from typing import Optional, List
from datetime import date, timedelta
from app.database import get_db
from app.models.habit import Habit, HabitGroup, HabitCheckin
from app.schemas.habit import (
HabitGroupCreate, HabitGroupUpdate, HabitGroupResponse,
HabitCreate, HabitUpdate, HabitResponse,
CheckinCreate, CheckinResponse, HabitStatsResponse,
)
from app.schemas.common import DeleteResponse
from app.utils.crud import get_or_404
from app.utils.datetime import utcnow, today
from app.utils.logger import logger
router = APIRouter(tags=["习惯"])
# ============ 习惯分组 CRUD ============
habit_group_router = APIRouter(prefix="/api/habit-groups", tags=["习惯分组"])
@habit_group_router.get("", response_model=List[HabitGroupResponse])
def get_habit_groups(db: Session = Depends(get_db)):
"""获取所有习惯分组"""
try:
groups = db.query(HabitGroup).order_by(HabitGroup.sort_order, HabitGroup.id).all()
return groups
except Exception as e:
logger.error(f"获取习惯分组失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取习惯分组失败")
@habit_group_router.post("", response_model=HabitGroupResponse, status_code=201)
def create_habit_group(data: HabitGroupCreate, db: Session = Depends(get_db)):
"""创建习惯分组"""
try:
group = HabitGroup(**data.model_dump())
db.add(group)
db.commit()
db.refresh(group)
logger.info(f"创建习惯分组成功: id={group.id}, name={group.name}")
return group
except Exception as e:
db.rollback()
logger.error(f"创建习惯分组失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建习惯分组失败")
@habit_group_router.put("/{group_id}", response_model=HabitGroupResponse)
def update_habit_group(group_id: int, data: HabitGroupUpdate, db: Session = Depends(get_db)):
"""更新习惯分组"""
try:
group = get_or_404(db, HabitGroup, group_id, "习惯分组")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(group, field, value)
db.commit()
db.refresh(group)
logger.info(f"更新习惯分组成功: id={group_id}")
return group
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新习惯分组失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新习惯分组失败")
@habit_group_router.delete("/{group_id}")
def delete_habit_group(group_id: int, db: Session = Depends(get_db)):
"""删除习惯分组(习惯的 group_id 会被置空)"""
try:
group = get_or_404(db, HabitGroup, group_id, "习惯分组")
# 将该分组下所有习惯的 group_id 置空
db.query(Habit).filter(Habit.group_id == group_id).update({Habit.group_id: None})
db.delete(group)
db.commit()
logger.info(f"删除习惯分组成功: id={group_id}")
return DeleteResponse(message="习惯分组删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除习惯分组失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除习惯分组失败")
# ============ 习惯 CRUD ============
habit_router = APIRouter(prefix="/api/habits", tags=["习惯"])
@habit_router.get("", response_model=List[HabitResponse])
def get_habits(
include_archived: bool = Query(False, description="是否包含已归档的习惯"),
db: Session = Depends(get_db),
):
"""获取所有习惯"""
try:
query = db.query(Habit)
if not include_archived:
query = query.filter(Habit.is_archived == False)
habits = query.order_by(Habit.created_at.desc()).all()
return habits
except Exception as e:
logger.error(f"获取习惯列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取习惯列表失败")
@habit_router.post("", response_model=HabitResponse, status_code=201)
def create_habit(data: HabitCreate, db: Session = Depends(get_db)):
"""创建习惯"""
try:
habit = Habit(**data.model_dump())
db.add(habit)
db.commit()
db.refresh(habit)
logger.info(f"创建习惯成功: id={habit.id}, name={habit.name}")
return habit
except Exception as e:
db.rollback()
logger.error(f"创建习惯失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建习惯失败")
@habit_router.put("/{habit_id}", response_model=HabitResponse)
def update_habit(habit_id: int, data: HabitUpdate, db: Session = Depends(get_db)):
"""更新习惯"""
try:
habit = get_or_404(db, Habit, habit_id, "习惯")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if value is not None or field in data.clearable_fields:
setattr(habit, field, value)
habit.updated_at = utcnow()
db.commit()
db.refresh(habit)
logger.info(f"更新习惯成功: id={habit_id}")
return habit
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新习惯失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新习惯失败")
@habit_router.delete("/{habit_id}")
def delete_habit(habit_id: int, db: Session = Depends(get_db)):
"""删除习惯(级联删除打卡记录)"""
try:
habit = get_or_404(db, Habit, habit_id, "习惯")
db.delete(habit)
db.commit()
logger.info(f"删除习惯成功: id={habit_id}")
return DeleteResponse(message="习惯删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除习惯失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除习惯失败")
@habit_router.patch("/{habit_id}/archive", response_model=HabitResponse)
def toggle_archive_habit(habit_id: int, db: Session = Depends(get_db)):
"""切换习惯归档状态"""
try:
habit = get_or_404(db, Habit, habit_id, "习惯")
habit.is_archived = not habit.is_archived
habit.updated_at = utcnow()
db.commit()
db.refresh(habit)
logger.info(f"切换习惯归档状态成功: id={habit_id}, is_archived={habit.is_archived}")
return habit
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"切换习惯归档状态失败: {str(e)}")
raise HTTPException(status_code=500, detail="切换习惯归档状态失败")
# ============ 打卡 ============
checkin_router = APIRouter(prefix="/api/habits/{habit_id}/checkins", tags=["习惯打卡"])
@checkin_router.get("", response_model=List[CheckinResponse])
def get_checkins(
habit_id: int,
from_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
to_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
db: Session = Depends(get_db),
):
"""获取习惯打卡记录"""
try:
get_or_404(db, Habit, habit_id, "习惯")
query = db.query(HabitCheckin).filter(HabitCheckin.habit_id == habit_id)
if from_date:
query = query.filter(HabitCheckin.checkin_date >= date.fromisoformat(from_date))
if to_date:
query = query.filter(HabitCheckin.checkin_date <= date.fromisoformat(to_date))
checkins = query.order_by(HabitCheckin.checkin_date.desc()).all()
return checkins
except HTTPException:
raise
except Exception as e:
logger.error(f"获取打卡记录失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取打卡记录失败")
@checkin_router.post("", response_model=CheckinResponse)
def create_checkin(
habit_id: int,
data: Optional[CheckinCreate] = None,
db: Session = Depends(get_db),
):
"""打卡(当天 count 累加)"""
try:
habit = get_or_404(db, Habit, habit_id, "习惯")
today_date = today()
add_count = data.count if data else 1
# 查找今日已有记录
checkin = db.query(HabitCheckin).filter(
HabitCheckin.habit_id == habit_id,
HabitCheckin.checkin_date == today_date,
).first()
if checkin:
checkin.count += add_count
else:
checkin = HabitCheckin(
habit_id=habit_id,
checkin_date=today_date,
count=add_count,
)
db.add(checkin)
db.commit()
db.refresh(checkin)
logger.info(f"打卡成功: habit_id={habit_id}, date={today_date}, count={checkin.count}")
return checkin
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"打卡失败: {str(e)}")
raise HTTPException(status_code=500, detail="打卡失败")
@checkin_router.delete("")
def cancel_checkin(
habit_id: int,
count: int = Query(1, ge=1, description="取消的打卡次数"),
db: Session = Depends(get_db),
):
"""取消今日打卡count-1为0时删除记录"""
try:
habit = get_or_404(db, Habit, habit_id, "习惯")
today_date = today()
checkin = db.query(HabitCheckin).filter(
HabitCheckin.habit_id == habit_id,
HabitCheckin.checkin_date == today_date,
).first()
if not checkin:
return DeleteResponse(message="今日无打卡记录")
checkin.count = max(0, checkin.count - count)
if checkin.count <= 0:
db.delete(checkin)
db.commit()
logger.info(f"取消打卡: habit_id={habit_id}, date={today_date}")
return DeleteResponse(message="取消打卡成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"取消打卡失败: {str(e)}")
raise HTTPException(status_code=500, detail="取消打卡失败")
@checkin_router.get("/stats", response_model=HabitStatsResponse)
def get_habit_stats(habit_id: int, db: Session = Depends(get_db)):
"""获取习惯统计数据"""
try:
habit = get_or_404(db, Habit, habit_id, "习惯")
today_date = today()
# 今日打卡
today_checkin = db.query(HabitCheckin).filter(
HabitCheckin.habit_id == habit_id,
HabitCheckin.checkin_date == today_date,
).first()
today_count = today_checkin.count if today_checkin else 0
today_completed = today_count >= habit.target_count
# 所有完成打卡的日期count >= target_count
completed_dates = [
row[0]
for row in db.query(HabitCheckin.checkin_date).filter(
HabitCheckin.habit_id == habit_id,
HabitCheckin.count >= habit.target_count,
).order_by(HabitCheckin.checkin_date).all()
]
total_days = len(completed_dates)
# 计算连续天数(从今天往回推算)
current_streak = 0
check_date = today_date
# 如果今天还没完成,从昨天开始算
if not today_completed:
check_date = check_date - timedelta(days=1)
while True:
if check_date in completed_dates:
current_streak += 1
check_date -= timedelta(days=1)
else:
break
# 计算最长连续天数
longest_streak = 0
streak = 0
prev_date = None
for d in completed_dates:
if prev_date is None or d == prev_date + timedelta(days=1):
streak += 1
else:
streak = 1
longest_streak = max(longest_streak, streak)
prev_date = d
return HabitStatsResponse(
total_days=total_days,
current_streak=current_streak,
longest_streak=longest_streak,
today_count=today_count,
today_completed=today_completed,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"获取习惯统计失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取习惯统计失败")
# 将子路由组合到主路由
router.include_router(habit_group_router)
router.include_router(habit_router)
router.include_router(checkin_router)

74
api/app/routers/tags.py Normal file
View File

@@ -0,0 +1,74 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from typing import List
from app.database import get_db
from app.models import Tag
from app.schemas import TagCreate, TagResponse
from app.schemas.common import DeleteResponse
from app.utils.crud import get_or_404
from app.utils.logger import logger
router = APIRouter(prefix="/api/tags", tags=["标签"])
@router.get("", response_model=List[TagResponse])
def get_tags(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100),
db: Session = Depends(get_db)
):
"""获取标签列表"""
try:
tags = db.query(Tag).offset(skip).limit(limit).all()
logger.info(f"获取标签列表成功,总数: {len(tags)}")
return tags
except Exception as e:
logger.error(f"获取标签列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取标签列表失败")
@router.post("", response_model=TagResponse, status_code=201)
def create_tag(tag_data: TagCreate, db: Session = Depends(get_db)):
"""创建标签"""
try:
db_tag = Tag(name=tag_data.name)
db.add(db_tag)
db.commit()
db.refresh(db_tag)
logger.info(f"创建标签成功: id={db_tag.id}, name={db_tag.name}")
return db_tag
except IntegrityError:
db.rollback()
logger.warning(f"标签名称已存在: name={tag_data.name}")
raise HTTPException(status_code=400, detail="标签名称已存在")
except Exception as e:
db.rollback()
logger.error(f"创建标签失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建标签失败")
@router.delete("/{tag_id}")
def delete_tag(tag_id: int, db: Session = Depends(get_db)):
"""删除标签"""
try:
tag = get_or_404(db, Tag, tag_id, "标签")
db.delete(tag)
db.commit()
logger.info(f"删除标签成功: id={tag_id}")
return DeleteResponse(message="标签删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除标签失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除标签失败")

185
api/app/routers/tasks.py Normal file
View File

@@ -0,0 +1,185 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional, List
from app.database import get_db
from app.models import Task, Tag
from app.schemas import TaskCreate, TaskUpdate, TaskResponse
from app.schemas.common import DeleteResponse
from app.utils.crud import get_or_404
from app.utils.datetime import utcnow
from app.utils.logger import logger
router = APIRouter(prefix="/api/tasks", tags=["任务"])
@router.get("", response_model=List[TaskResponse])
def get_tasks(
status: Optional[str] = Query(None, description="筛选状态: all/in_progress/completed"),
category_id: Optional[int] = Query(None, description="分类ID"),
priority: Optional[str] = Query(None, description="优先级: q1/q2/q3/q4"),
sort_by: Optional[str] = Query("created_at", description="排序字段: created_at/priority/due_date"),
sort_order: Optional[str] = Query("desc", description="排序方向: asc/desc"),
db: Session = Depends(get_db)
) -> List[TaskResponse]:
"""获取任务列表(支持筛选和排序)"""
try:
query = db.query(Task)
# 状态筛选
if status == "in_progress":
query = query.filter(Task.is_completed == False)
elif status == "completed":
query = query.filter(Task.is_completed == True)
# 分类筛选
if category_id is not None:
query = query.filter(Task.category_id == category_id)
# 优先级筛选
if priority:
query = query.filter(Task.priority == priority)
# 排序
if sort_by == "priority":
order_col = Task.priority
elif sort_by == "due_date":
order_col = Task.due_date
else:
order_col = Task.created_at
if sort_order == "asc":
query = query.order_by(order_col.asc().nullslast())
else:
query = query.order_by(order_col.desc().nullslast())
tasks = query.all()
logger.info(f"获取任务列表成功,总数: {len(tasks)}")
return tasks
except Exception as e:
logger.error(f"获取任务列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取任务列表失败")
@router.post("", response_model=TaskResponse, status_code=201)
def create_task(task_data: TaskCreate, db: Session = Depends(get_db)):
"""创建任务"""
try:
# 创建任务对象
db_task = Task(
title=task_data.title,
description=task_data.description,
priority=task_data.priority,
due_date=task_data.due_date,
category_id=task_data.category_id,
)
# 添加标签
if task_data.tag_ids:
tags = db.query(Tag).filter(Tag.id.in_(task_data.tag_ids)).all()
db_task.tags = tags
db.add(db_task)
db.commit()
db.refresh(db_task)
logger.info(f"创建任务成功: id={db_task.id}, title={db_task.title}")
return db_task
except Exception as e:
db.rollback()
logger.error(f"创建任务失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建任务失败")
@router.get("/{task_id}", response_model=TaskResponse)
def get_task(task_id: int, db: Session = Depends(get_db)):
"""获取单个任务"""
try:
task = get_or_404(db, Task, task_id, "任务")
return task
except HTTPException:
raise
except Exception as e:
logger.error(f"获取任务失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取任务失败")
@router.put("/{task_id}", response_model=TaskResponse)
def update_task(task_id: int, task_data: TaskUpdate, db: Session = Depends(get_db)):
"""更新任务"""
try:
task = get_or_404(db, Task, task_id, "任务")
# exclude_unset=True 保证:前端没传的字段不会出现在 dict 中,不会意外清空
# 前端显式传了 null 的字段会出现在 dict 中,允许清空可空字段
update_data = task_data.model_dump(exclude_unset=True)
tag_ids = update_data.pop("tag_ids", None)
for field, value in update_data.items():
# 非 clearable 字段(如 title只有非 None 值才更新
# clearable 字段description, due_date, category_id允许设为 None
if value is not None or field in task_data.clearable_fields:
setattr(task, field, value)
# 更新标签
if tag_ids is not None:
tags = db.query(Tag).filter(Tag.id.in_(tag_ids)).all()
task.tags = tags
task.updated_at = utcnow()
db.commit()
db.refresh(task)
logger.info(f"更新任务成功: id={task_id}")
return task
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新任务失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新任务失败")
@router.delete("/{task_id}")
def delete_task(task_id: int, db: Session = Depends(get_db)):
"""删除任务"""
try:
task = get_or_404(db, Task, task_id, "任务")
db.delete(task)
db.commit()
logger.info(f"删除任务成功: id={task_id}")
return DeleteResponse(message="任务删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除任务失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除任务失败")
@router.patch("/{task_id}/toggle", response_model=TaskResponse)
def toggle_task(task_id: int, db: Session = Depends(get_db)):
"""切换任务完成状态"""
try:
task = get_or_404(db, Task, task_id, "任务")
task.is_completed = not task.is_completed
task.updated_at = utcnow()
db.commit()
db.refresh(task)
logger.info(f"切换任务状态成功: id={task_id}, is_completed={task.is_completed}")
return task
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"切换任务状态失败: {str(e)}")
raise HTTPException(status_code=500, detail="切换任务状态失败")

View File

@@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user_settings import UserSettings
from app.schemas.user_settings import UserSettingsUpdate, UserSettingsResponse
from app.utils.datetime import utcnow
from app.utils.logger import logger
router = APIRouter(prefix="/api/user-settings", tags=["用户设置"])
@router.get("", response_model=UserSettingsResponse)
def get_user_settings(db: Session = Depends(get_db)):
"""获取用户设置(单例模式)"""
try:
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
if not settings:
# 首次访问时自动创建默认设置
settings = UserSettings(id=1)
db.add(settings)
db.commit()
db.refresh(settings)
logger.info("自动创建默认用户设置")
return settings
except Exception as e:
logger.error(f"获取用户设置失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取用户设置失败")
@router.put("", response_model=UserSettingsResponse)
def update_user_settings(
data: UserSettingsUpdate,
db: Session = Depends(get_db)
):
"""更新用户设置upsert 单条记录)"""
try:
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
if not settings:
settings = UserSettings(id=1)
db.add(settings)
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(settings, field, value)
settings.updated_at = utcnow()
db.commit()
db.refresh(settings)
logger.info("更新用户设置成功")
return settings
except Exception as e:
db.rollback()
logger.error(f"更新用户设置失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新用户设置失败")

View File

@@ -0,0 +1,44 @@
from app.schemas.task import (
TaskBase,
TaskCreate,
TaskUpdate,
TaskResponse,
)
from app.schemas.category import (
CategoryBase,
CategoryCreate,
CategoryUpdate,
CategoryResponse,
)
from app.schemas.tag import (
TagBase,
TagCreate,
TagResponse,
)
from app.schemas.common import (
DeleteResponse,
PaginatedResponse,
)
from app.schemas.user_settings import (
UserSettingsUpdate,
UserSettingsResponse,
)
from app.schemas.habit import (
HabitGroupCreate,
HabitGroupUpdate,
HabitGroupResponse,
HabitCreate,
HabitUpdate,
HabitResponse,
CheckinCreate,
CheckinResponse,
HabitStatsResponse,
)
from app.schemas.anniversary import (
AnniversaryCategoryCreate,
AnniversaryCategoryUpdate,
AnniversaryCategoryResponse,
AnniversaryCreate,
AnniversaryUpdate,
AnniversaryResponse,
)

133
api/app/schemas/account.py Normal file
View File

@@ -0,0 +1,133 @@
from pydantic import BaseModel, Field
from datetime import date, datetime
from typing import Optional, List
# ============ 财务账户 Schema ============
class AccountBase(BaseModel):
"""账户基础模型"""
name: str = Field(..., min_length=1, max_length=100)
account_type: str = Field(default="savings", pattern="^(savings|debt)$")
balance: float = Field(default=0.0)
icon: str = Field(default="wallet", max_length=50)
color: str = Field(default="#FFB7C5", max_length=20)
sort_order: int = Field(default=0)
is_active: bool = Field(default=True)
description: Optional[str] = None
class AccountCreate(AccountBase):
"""创建账户请求模型"""
pass
class AccountUpdate(BaseModel):
"""更新账户请求模型"""
name: Optional[str] = Field(None, max_length=100)
account_type: Optional[str] = Field(None, pattern="^(savings|debt)$")
balance: Optional[float] = None
icon: Optional[str] = Field(None, max_length=50)
color: Optional[str] = Field(None, max_length=20)
sort_order: Optional[int] = None
is_active: Optional[bool] = None
description: Optional[str] = None
class AccountResponse(AccountBase):
"""账户响应模型"""
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class AccountListItemResponse(BaseModel):
"""账户列表项响应模型(含分期摘要)"""
id: int
name: str
account_type: str
balance: float
icon: str
color: str
sort_order: int
is_active: bool
description: Optional[str] = None
created_at: datetime
updated_at: datetime
installments: List[dict] = []
class BalanceUpdateRequest(BaseModel):
"""更新余额请求模型"""
new_balance: float
note: Optional[str] = Field(None, max_length=200)
# ============ 账户变更历史 Schema ============
class AccountHistoryResponse(BaseModel):
"""变更历史响应模型"""
id: int
account_id: int
change_amount: float
balance_before: float
balance_after: float
note: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
# ============ 分期还款计划 Schema ============
class DebtInstallmentBase(BaseModel):
"""分期计划基础模型"""
account_id: int
total_amount: float
total_periods: int = Field(..., ge=1)
current_period: int = Field(default=1, ge=1)
payment_day: int = Field(..., ge=1, le=31)
payment_amount: float = Field(..., gt=0)
start_date: date
is_completed: bool = Field(default=False)
class DebtInstallmentCreate(DebtInstallmentBase):
"""创建分期计划请求模型"""
pass
class DebtInstallmentUpdate(BaseModel):
"""更新分期计划请求模型"""
account_id: Optional[int] = None
total_amount: Optional[float] = None
total_periods: Optional[int] = Field(None, ge=1)
current_period: Optional[int] = Field(None, ge=1)
payment_day: Optional[int] = Field(None, ge=1, le=31)
payment_amount: Optional[float] = Field(None, gt=0)
start_date: Optional[date] = None
is_completed: Optional[bool] = None
class DebtInstallmentResponse(DebtInstallmentBase):
"""分期计划响应模型(含计算字段)"""
id: int
created_at: datetime
updated_at: datetime
# 计算字段
next_payment_date: Optional[date] = None
days_until_payment: Optional[int] = None
remaining_periods: Optional[int] = None
# 关联账户信息
account_name: Optional[str] = None
account_icon: Optional[str] = None
account_color: Optional[str] = None
class Config:
from_attributes = True

View File

@@ -0,0 +1,122 @@
from pydantic import BaseModel, Field, field_validator
from datetime import date, datetime, timezone
from typing import Optional, List
def parse_date(value):
"""解析日期字符串"""
if value is None or value == '':
return None
if isinstance(value, date):
return value
if isinstance(value, datetime):
return value.date()
formats = [
'%Y-%m-%d',
'%Y-%m-%dT%H:%M:%S',
'%Y-%m-%d %H:%M:%S',
'%Y-%m-%dT%H:%M:%S.%f',
]
for fmt in formats:
try:
return datetime.strptime(value, fmt).date()
except ValueError:
continue
try:
return date.fromisoformat(value)
except ValueError:
raise ValueError(f"无法解析日期: {value}")
# ============ 纪念日分类 Schema ============
class AnniversaryCategoryBase(BaseModel):
"""纪念日分类基础模型"""
name: str = Field(..., max_length=50)
icon: str = Field(default="calendar", max_length=50)
color: str = Field(default="#FFB7C5", max_length=20)
sort_order: int = Field(default=0)
class AnniversaryCategoryCreate(AnniversaryCategoryBase):
"""创建纪念日分类请求模型"""
pass
class AnniversaryCategoryUpdate(BaseModel):
"""更新纪念日分类请求模型"""
name: Optional[str] = Field(None, max_length=50)
icon: Optional[str] = Field(None, max_length=50)
color: Optional[str] = Field(None, max_length=20)
sort_order: Optional[int] = None
class AnniversaryCategoryResponse(AnniversaryCategoryBase):
"""纪念日分类响应模型"""
id: int
class Config:
from_attributes = True
# ============ 纪念日 Schema ============
class AnniversaryBase(BaseModel):
"""纪念日基础模型"""
title: str = Field(..., max_length=200)
date: date
year: Optional[int] = None
category_id: Optional[int] = None
description: Optional[str] = None
is_recurring: bool = Field(default=True)
remind_days_before: int = Field(default=3)
@field_validator('date', mode='before')
@classmethod
def parse_anniversary_date(cls, v):
result = parse_date(v)
if result is None:
raise ValueError("纪念日日期不能为空")
return result
class AnniversaryCreate(AnniversaryBase):
"""创建纪念日请求模型"""
pass
class AnniversaryUpdate(BaseModel):
"""更新纪念日请求模型"""
title: Optional[str] = Field(None, max_length=200)
date: Optional[date] = None
year: Optional[int] = None
category_id: Optional[int] = None
description: Optional[str] = None
is_recurring: Optional[bool] = None
remind_days_before: Optional[int] = None
@field_validator('date', mode='before')
@classmethod
def parse_anniversary_date(cls, v):
if v is None:
return None
return parse_date(v)
@property
def clearable_fields(self) -> set:
"""允许被显式清空(设为 None的字段集合"""
return {'description', 'category_id', 'year'}
class AnniversaryResponse(AnniversaryBase):
"""纪念日响应模型"""
id: int
created_at: datetime
updated_at: datetime
category: Optional[AnniversaryCategoryResponse] = None
next_date: Optional[date] = None
days_until: Optional[int] = None
year_count: Optional[int] = None
class Config:
from_attributes = True

View File

@@ -0,0 +1,28 @@
from pydantic import BaseModel, Field
class CategoryBase(BaseModel):
"""分类基础模型"""
name: str = Field(..., max_length=100)
color: str = Field(default="#FFB7C5", max_length=20)
icon: str = Field(default="folder", max_length=50)
class CategoryCreate(CategoryBase):
"""创建分类请求模型"""
pass
class CategoryUpdate(BaseModel):
"""更新分类请求模型"""
name: str = Field(None, max_length=100)
color: str = Field(None, max_length=20)
icon: str = Field(None, max_length=50)
class CategoryResponse(CategoryBase):
"""分类响应模型"""
id: int
class Config:
from_attributes = True

39
api/app/schemas/common.py Normal file
View File

@@ -0,0 +1,39 @@
"""
通用响应模型
"""
from typing import Generic, TypeVar, List, Optional
from pydantic import BaseModel, Field
T = TypeVar("T")
class DeleteResponse(BaseModel):
"""删除成功响应"""
success: bool = Field(default=True, description="操作是否成功")
message: str = Field(description="响应消息")
class Config:
json_schema_extra = {
"example": {
"success": True,
"message": "删除成功"
}
}
class PaginatedResponse(BaseModel, Generic[T]):
"""分页列表响应"""
items: List[T] = Field(description="数据列表")
total: int = Field(description="总记录数")
skip: int = Field(description="跳过的记录数")
limit: int = Field(description="返回的记录数")
class Config:
json_schema_extra = {
"example": {
"items": [],
"total": 0,
"skip": 0,
"limit": 20
}
}

105
api/app/schemas/habit.py Normal file
View File

@@ -0,0 +1,105 @@
from pydantic import BaseModel, Field, field_validator
from datetime import datetime, date
from typing import Optional, List
# ============ 习惯分组 Schemas ============
class HabitGroupBase(BaseModel):
"""习惯分组基础模型"""
name: str = Field(..., max_length=100)
color: str = Field(default="#FFB7C5", max_length=20)
icon: str = Field(default="flag", max_length=50)
sort_order: int = Field(default=0)
class HabitGroupCreate(HabitGroupBase):
"""创建习惯分组请求模型"""
pass
class HabitGroupUpdate(BaseModel):
"""更新习惯分组请求模型"""
name: Optional[str] = Field(None, max_length=100)
color: Optional[str] = Field(None, max_length=20)
icon: Optional[str] = Field(None, max_length=50)
sort_order: Optional[int] = None
class HabitGroupResponse(HabitGroupBase):
"""习惯分组响应模型"""
id: int
class Config:
from_attributes = True
# ============ 习惯 Schemas ============
class HabitBase(BaseModel):
"""习惯基础模型"""
name: str = Field(..., max_length=200)
description: Optional[str] = None
group_id: Optional[int] = None
target_count: int = Field(default=1, ge=1)
frequency: str = Field(default="daily", pattern="^(daily|weekly)$")
active_days: Optional[str] = None
class HabitCreate(HabitBase):
"""创建习惯请求模型"""
pass
class HabitUpdate(BaseModel):
"""更新习惯请求模型"""
name: Optional[str] = Field(None, max_length=200)
description: Optional[str] = None
group_id: Optional[int] = None
target_count: Optional[int] = Field(None, ge=1)
frequency: Optional[str] = Field(None, pattern="^(daily|weekly)$")
active_days: Optional[str] = None
@property
def clearable_fields(self) -> set:
return {"description", "group_id", "active_days"}
class HabitResponse(HabitBase):
"""习惯响应模型"""
id: int
is_archived: bool
created_at: datetime
updated_at: datetime
group: Optional[HabitGroupResponse] = None
class Config:
from_attributes = True
# ============ 打卡 Schemas ============
class CheckinCreate(BaseModel):
"""打卡请求模型"""
count: Optional[int] = Field(default=1, ge=1)
class CheckinResponse(BaseModel):
"""打卡记录响应模型"""
id: int
habit_id: int
checkin_date: date
count: int
created_at: datetime
class Config:
from_attributes = True
class HabitStatsResponse(BaseModel):
"""习惯统计响应模型"""
total_days: int = 0
current_streak: int = 0
longest_streak: int = 0
today_count: int = 0
today_completed: bool = False

19
api/app/schemas/tag.py Normal file
View File

@@ -0,0 +1,19 @@
from pydantic import BaseModel, Field
class TagBase(BaseModel):
"""标签基础模型"""
name: str = Field(..., max_length=50)
class TagCreate(TagBase):
"""创建标签请求模型"""
pass
class TagResponse(TagBase):
"""标签响应模型"""
id: int
class Config:
from_attributes = True

85
api/app/schemas/task.py Normal file
View File

@@ -0,0 +1,85 @@
from pydantic import BaseModel, Field, field_validator
from datetime import datetime
from typing import Optional, List
from app.schemas.category import CategoryResponse
from app.schemas.tag import TagResponse
def parse_datetime(value):
"""解析日期时间字符串"""
if value is None or value == '':
return None
if isinstance(value, datetime):
return value
# 尝试多种格式
formats = [
'%Y-%m-%dT%H:%M:%S',
'%Y-%m-%d %H:%M:%S',
'%Y-%m-%dT%H:%M:%S.%f',
]
for fmt in formats:
try:
return datetime.strptime(value, fmt)
except ValueError:
continue
# 最后尝试 ISO 格式
return datetime.fromisoformat(value.replace('Z', '+00:00'))
class TaskBase(BaseModel):
"""任务基础模型"""
title: str = Field(..., max_length=200)
description: Optional[str] = None
priority: str = Field(default="q4", pattern="^(q1|q2|q3|q4)$")
due_date: Optional[datetime] = None
category_id: Optional[int] = None
@field_validator('due_date', mode='before')
@classmethod
def parse_due_date(cls, v):
return parse_datetime(v)
class TaskCreate(TaskBase):
"""创建任务请求模型"""
tag_ids: Optional[List[int]] = []
class TaskUpdate(BaseModel):
"""更新任务请求模型
通过 exclude_unset=True 区分"前端没传""前端传了 null"
- 前端没传某个字段 -> model_dump 结果中不包含该 key -> 不修改
- 前端传了 null -> model_dump 结果中包含 key: None -> 视为"清空"
"""
title: Optional[str] = Field(None, max_length=200)
description: Optional[str] = None
priority: Optional[str] = Field(None, pattern="^(q1|q2|q3|q4)$")
due_date: Optional[datetime] = None
is_completed: Optional[bool] = None
category_id: Optional[int] = None
tag_ids: Optional[List[int]] = None
@field_validator('due_date', mode='before')
@classmethod
def parse_due_date(cls, v):
return parse_datetime(v)
@property
def clearable_fields(self) -> set:
"""允许被显式清空(设为 None的字段集合"""
return {'description', 'due_date', 'category_id'}
class TaskResponse(TaskBase):
"""任务响应模型"""
id: int
is_completed: bool
created_at: datetime
updated_at: datetime
category: Optional[CategoryResponse] = None
tags: List[TagResponse] = []
class Config:
from_attributes = True

View File

@@ -0,0 +1,39 @@
from pydantic import BaseModel, Field
from datetime import datetime, date
from typing import Optional
class UserSettingsUpdate(BaseModel):
"""更新用户设置请求模型"""
nickname: Optional[str] = Field(None, max_length=50)
avatar: Optional[str] = None
signature: Optional[str] = Field(None, max_length=200)
birthday: Optional[date] = None
email: Optional[str] = Field(None, max_length=100)
site_name: Optional[str] = Field(None, max_length=50)
theme: Optional[str] = Field(None, max_length=20)
language: Optional[str] = Field(None, max_length=10)
default_view: Optional[str] = Field(None, max_length=20)
default_sort_by: Optional[str] = Field(None, max_length=20)
default_sort_order: Optional[str] = Field(None, max_length=10)
class UserSettingsResponse(BaseModel):
"""用户设置响应模型"""
id: int
nickname: str
avatar: Optional[str] = None
signature: Optional[str] = None
birthday: Optional[date] = None
email: Optional[str] = None
site_name: str
theme: str
language: str
default_view: str
default_sort_by: str
default_sort_order: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,5 @@
"""
Utils 工具模块
"""
from app.utils.crud import get_or_404
from app.utils.logger import logger

34
api/app/utils/crud.py Normal file
View File

@@ -0,0 +1,34 @@
"""
通用 CRUD 工具函数
"""
from typing import Type, TypeVar
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.database import Base
from app.utils.logger import logger
ModelType = TypeVar("ModelType", bound=Base)
def get_or_404(
db: Session,
model: Type[ModelType],
item_id: int,
name: str = "资源"
) -> ModelType:
"""
获取实体,不存在时抛出 404 异常
Args:
db: 数据库会话
model: 模型类
item_id: 实体 ID
name: 实体名称(用于错误信息)
"""
item = db.query(model).filter(model.id == item_id).first()
if not item:
logger.warning(f"{name}不存在: id={item_id}")
raise HTTPException(status_code=404, detail=f"{name}不存在")
return item

11
api/app/utils/datetime.py Normal file
View File

@@ -0,0 +1,11 @@
from datetime import datetime, timezone, date
def utcnow() -> datetime:
"""统一获取 UTC 时间的工具函数"""
return datetime.now(timezone.utc)
def today() -> date:
"""获取当天日期"""
return date.today()

48
api/app/utils/logger.py Normal file
View File

@@ -0,0 +1,48 @@
import logging
import os
from datetime import datetime
from logging.handlers import TimedRotatingFileHandler
from app.config import LOG_LEVEL, LOG_DIR
# 确保日志目录存在
os.makedirs(LOG_DIR, exist_ok=True)
def setup_logger(name: str) -> logging.Logger:
"""设置日志记录器"""
logger = logging.getLogger(name)
logger.setLevel(getattr(logging, LOG_LEVEL))
# 避免重复添加处理器
if logger.handlers:
return logger
# 日志格式
formatter = logging.Formatter(
"[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
# 控制台处理器
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# 文件处理器(按日期分割)
log_file = os.path.join(LOG_DIR, "app.log")
file_handler = TimedRotatingFileHandler(
log_file,
when="midnight",
interval=1,
backupCount=30,
encoding="utf-8"
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger
# 创建日志记录器
logger = setup_logger("app")

16
api/webui/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title>
<script type="module" crossorigin src="/assets/index-1IMBzsQJ.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-CwFI-VDq.js">
<link rel="modulepreload" crossorigin href="/assets/element-plus-CAICPA8-.js">
<link rel="stylesheet" crossorigin href="/assets/index-D3zzYHQC.css">
</head>
<body>
<div id="app"></div>
</body>

Some files were not shown because too many files have changed in this diff Show More