release: Elysia ToDo v1.0.0
鍏ㄦ爤涓汉淇℃伅绠$悊搴旂敤锛岄泦鎴愬緟鍔炰换鍔°€佷範鎯墦鍗°€佺邯蹇垫棩鎻愰啋銆佽祫浜ф€昏鍔熻兘銆 Made-with: Cursor
This commit is contained in:
74
.gitignore
vendored
Normal file
74
.gitignore
vendored
Normal 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
1580
API_DOCS.md
Normal file
File diff suppressed because it is too large
Load Diff
173
README.md
Normal file
173
README.md
Normal 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
24
WebUI/.gitignore
vendored
Normal 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
5
WebUI/README.md
Normal 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
13
WebUI/index.html
Normal 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
2499
WebUI/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
WebUI/package.json
Normal file
30
WebUI/package.json
Normal 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
1
WebUI/public/vite.svg
Normal 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
154
WebUI/src/App.vue
Normal 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
64
WebUI/src/api/accounts.ts
Normal 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`)
|
||||||
|
},
|
||||||
|
}
|
||||||
49
WebUI/src/api/anniversaries.ts
Normal file
49
WebUI/src/api/anniversaries.ts
Normal 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}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
29
WebUI/src/api/categories.ts
Normal file
29
WebUI/src/api/categories.ts
Normal 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
78
WebUI/src/api/habits.ts
Normal 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
72
WebUI/src/api/request.ts
Normal 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
23
WebUI/src/api/tags.ts
Normal 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
44
WebUI/src/api/tasks.ts
Normal 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
278
WebUI/src/api/types.ts
Normal 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
|
||||||
|
}
|
||||||
10
WebUI/src/api/userSettings.ts
Normal file
10
WebUI/src/api/userSettings.ts
Normal 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
1
WebUI/src/assets/vue.svg
Normal 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 |
361
WebUI/src/components/AccountDialog.vue
Normal file
361
WebUI/src/components/AccountDialog.vue
Normal 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>
|
||||||
235
WebUI/src/components/AccountHistoryDialog.vue
Normal file
235
WebUI/src/components/AccountHistoryDialog.vue
Normal 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>
|
||||||
192
WebUI/src/components/AnniversaryCategoryDialog.vue
Normal file
192
WebUI/src/components/AnniversaryCategoryDialog.vue
Normal 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>
|
||||||
257
WebUI/src/components/AnniversaryDialog.vue
Normal file
257
WebUI/src/components/AnniversaryDialog.vue
Normal 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>
|
||||||
271
WebUI/src/components/AppHeader.vue
Normal file
271
WebUI/src/components/AppHeader.vue
Normal 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>
|
||||||
185
WebUI/src/components/BalanceDialog.vue
Normal file
185
WebUI/src/components/BalanceDialog.vue
Normal 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>
|
||||||
227
WebUI/src/components/CategoryDialog.vue
Normal file
227
WebUI/src/components/CategoryDialog.vue
Normal 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>
|
||||||
698
WebUI/src/components/CategorySidebar.vue
Normal file
698
WebUI/src/components/CategorySidebar.vue
Normal 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>
|
||||||
330
WebUI/src/components/HabitDialog.vue
Normal file
330
WebUI/src/components/HabitDialog.vue
Normal 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>
|
||||||
468
WebUI/src/components/HabitGroupDialog.vue
Normal file
468
WebUI/src/components/HabitGroupDialog.vue
Normal 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>
|
||||||
270
WebUI/src/components/InstallmentDialog.vue
Normal file
270
WebUI/src/components/InstallmentDialog.vue
Normal 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>
|
||||||
376
WebUI/src/components/QuadrantTaskCard.vue
Normal file
376
WebUI/src/components/QuadrantTaskCard.vue
Normal 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>
|
||||||
368
WebUI/src/components/TaskCard.vue
Normal file
368
WebUI/src/components/TaskCard.vue
Normal 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>
|
||||||
330
WebUI/src/components/TaskDialog.vue
Normal file
330
WebUI/src/components/TaskDialog.vue
Normal 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
21
WebUI/src/main.ts
Normal 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
78
WebUI/src/router/index.ts
Normal 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
|
||||||
208
WebUI/src/stores/useAccountStore.ts
Normal file
208
WebUI/src/stores/useAccountStore.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
180
WebUI/src/stores/useAnniversaryStore.ts
Normal file
180
WebUI/src/stores/useAnniversaryStore.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
65
WebUI/src/stores/useCategoryStore.ts
Normal file
65
WebUI/src/stores/useCategoryStore.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
227
WebUI/src/stores/useHabitStore.ts
Normal file
227
WebUI/src/stores/useHabitStore.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
50
WebUI/src/stores/useTagStore.ts
Normal file
50
WebUI/src/stores/useTagStore.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
180
WebUI/src/stores/useTaskStore.ts
Normal file
180
WebUI/src/stores/useTaskStore.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
72
WebUI/src/stores/useUIStore.ts
Normal file
72
WebUI/src/stores/useUIStore.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
74
WebUI/src/stores/useUserSettingsStore.ts
Normal file
74
WebUI/src/stores/useUserSettingsStore.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
33
WebUI/src/styles/_variables.scss
Normal file
33
WebUI/src/styles/_variables.scss
Normal 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
340
WebUI/src/styles/main.scss
Normal 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
113
WebUI/src/utils/date.ts
Normal 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-6,0为周日)
|
||||||
|
* @returns 中文名称
|
||||||
|
*/
|
||||||
|
export function getWeekDayName(dayOfWeek: number): string {
|
||||||
|
const names = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
|
return names[dayOfWeek] ?? ''
|
||||||
|
}
|
||||||
177
WebUI/src/utils/pinyin.ts
Normal file
177
WebUI/src/utils/pinyin.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 高亮显示匹配的文本
|
||||||
|
* @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
|
||||||
|
}
|
||||||
27
WebUI/src/utils/priority.ts
Normal file
27
WebUI/src/utils/priority.ts
Normal 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
|
||||||
|
}
|
||||||
905
WebUI/src/views/AnniversaryPage.vue
Normal file
905
WebUI/src/views/AnniversaryPage.vue
Normal 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>
|
||||||
1134
WebUI/src/views/AssetPage.vue
Normal file
1134
WebUI/src/views/AssetPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
17
WebUI/src/views/CalendarPage.vue
Normal file
17
WebUI/src/views/CalendarPage.vue
Normal 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>
|
||||||
109
WebUI/src/views/CalendarView.vue
Normal file
109
WebUI/src/views/CalendarView.vue
Normal 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>
|
||||||
1251
WebUI/src/views/HabitPage.vue
Normal file
1251
WebUI/src/views/HabitPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
708
WebUI/src/views/MonthlyView.vue
Normal file
708
WebUI/src/views/MonthlyView.vue
Normal 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>
|
||||||
376
WebUI/src/views/ProfileView.vue
Normal file
376
WebUI/src/views/ProfileView.vue
Normal 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>
|
||||||
17
WebUI/src/views/QuadrantPage.vue
Normal file
17
WebUI/src/views/QuadrantPage.vue
Normal 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>
|
||||||
362
WebUI/src/views/QuadrantView.vue
Normal file
362
WebUI/src/views/QuadrantView.vue
Normal 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>
|
||||||
669
WebUI/src/views/SettingsView.vue
Normal file
669
WebUI/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,669 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { useUserSettingsStore } from '@/stores/useUserSettingsStore'
|
||||||
|
import { useTaskStore } from '@/stores/useTaskStore'
|
||||||
|
import { useCategoryStore } from '@/stores/useCategoryStore'
|
||||||
|
import { useTagStore } from '@/stores/useTagStore'
|
||||||
|
import { useHabitStore } from '@/stores/useHabitStore'
|
||||||
|
import { get, post, del } from '@/api/request'
|
||||||
|
import type { Task, Category, Tag, HabitGroup, Habit } from '@/api/types'
|
||||||
|
|
||||||
|
const userStore = useUserSettingsStore()
|
||||||
|
const taskStore = useTaskStore()
|
||||||
|
const categoryStore = useCategoryStore()
|
||||||
|
const tagStore = useTagStore()
|
||||||
|
const habitStore = useHabitStore()
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const exporting = ref(false)
|
||||||
|
|
||||||
|
const viewOptions = [
|
||||||
|
{ label: '列表', value: 'list' },
|
||||||
|
{ label: '日历', value: 'calendar' },
|
||||||
|
{ label: '四象限', value: 'quadrant' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const sortByOptions = [
|
||||||
|
{ label: '创建时间', value: 'created_at' },
|
||||||
|
{ label: '截止日期', value: 'due_date' },
|
||||||
|
{ label: '优先级', value: 'priority' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const sortOrderOptions = [
|
||||||
|
{ label: '降序 (新到旧)', value: 'desc' },
|
||||||
|
{ label: '升序 (旧到新)', value: 'asc' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const prefs = ref({
|
||||||
|
site_name: '爱莉希雅待办',
|
||||||
|
default_view: 'list',
|
||||||
|
default_sort_by: 'priority',
|
||||||
|
default_sort_order: 'desc'
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
prefs.value.site_name = userStore.siteName || '爱莉希雅待办'
|
||||||
|
prefs.value.default_view = userStore.defaultView || 'list'
|
||||||
|
prefs.value.default_sort_by = userStore.defaultSortBy || 'priority'
|
||||||
|
prefs.value.default_sort_order = userStore.defaultSortOrder || 'desc'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await userStore.updateSettings({
|
||||||
|
site_name: prefs.value.site_name,
|
||||||
|
default_view: prefs.value.default_view,
|
||||||
|
default_sort_by: prefs.value.default_sort_by,
|
||||||
|
default_sort_order: prefs.value.default_sort_order
|
||||||
|
})
|
||||||
|
userStore.syncFromSettings(userStore.settings!)
|
||||||
|
|
||||||
|
// 保存排序后立即应用
|
||||||
|
taskStore.setFilters({
|
||||||
|
sort_by: prefs.value.default_sort_by as 'priority' | 'due_date' | 'created_at',
|
||||||
|
sort_order: prefs.value.default_sort_order as 'asc' | 'desc'
|
||||||
|
})
|
||||||
|
|
||||||
|
ElMessage.success('偏好设置已保存~')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('保存失败了呢,请稍后再试~')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportData() {
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
const [tasks, categories, tags, habitGroups, habits] = await Promise.all([
|
||||||
|
get<Task[]>('/tasks'),
|
||||||
|
get<Category[]>('/categories'),
|
||||||
|
get<Tag[]>('/tags'),
|
||||||
|
get<HabitGroup[]>('/habit-groups'),
|
||||||
|
get<Habit[]>('/habits', { params: { include_archived: true } })
|
||||||
|
])
|
||||||
|
|
||||||
|
const exportObj = {
|
||||||
|
version: 2,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
tasks,
|
||||||
|
categories,
|
||||||
|
tags,
|
||||||
|
habitGroups,
|
||||||
|
habits
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(exportObj, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `todo-backup-${new Date().toISOString().slice(0, 10)}.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
ElMessage.success('数据导出成功~')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('导出失败了呢~')
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function importData() {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = '.json'
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'导入数据会覆盖现有的所有任务、分类、标签和习惯数据,确定要继续吗?',
|
||||||
|
'确认导入',
|
||||||
|
{ confirmButtonText: '确定导入', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const text = await file.text()
|
||||||
|
const data = JSON.parse(text)
|
||||||
|
|
||||||
|
if (!data.tasks || !Array.isArray(data.tasks)) {
|
||||||
|
ElMessage.error('数据格式不正确呢~')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先删除所有现有数据
|
||||||
|
const allTasks = await get<Task[]>('/tasks')
|
||||||
|
for (const t of allTasks) {
|
||||||
|
await del(`/tasks/${t.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allCategories = await get<Category[]>('/categories')
|
||||||
|
for (const c of allCategories) {
|
||||||
|
await del(`/categories/${c.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTags = await get<Tag[]>('/tags')
|
||||||
|
for (const t of allTags) {
|
||||||
|
await del(`/tags/${t.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除习惯数据(如果有的话)
|
||||||
|
if (data.habits && Array.isArray(data.habits)) {
|
||||||
|
const allHabits = await get<Habit[]>('/habits', { params: { include_archived: true } })
|
||||||
|
for (const h of allHabits) {
|
||||||
|
await del(`/habits/${h.id}`)
|
||||||
|
}
|
||||||
|
const allGroups = await get<HabitGroup[]>('/habit-groups')
|
||||||
|
for (const g of allGroups) {
|
||||||
|
await del(`/habit-groups/${g.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新导入
|
||||||
|
if (data.categories && Array.isArray(data.categories)) {
|
||||||
|
for (const cat of data.categories) {
|
||||||
|
await post('/categories', { name: cat.name, color: cat.color, icon: cat.icon })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.tags && Array.isArray(data.tags)) {
|
||||||
|
for (const tag of data.tags) {
|
||||||
|
await post('/tags', { name: tag.name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.tasks && Array.isArray(data.tasks)) {
|
||||||
|
// 建立新旧ID到名称的映射
|
||||||
|
const oldCatMap = new Map<number, string>()
|
||||||
|
const oldTagMap = new Map<number, string>()
|
||||||
|
if (data.categories) {
|
||||||
|
data.categories.forEach((c: Category) => oldCatMap.set(c.id, c.name))
|
||||||
|
}
|
||||||
|
if (data.tags) {
|
||||||
|
data.tags.forEach((t: Tag) => oldTagMap.set(t.id, t.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取新建后的分类和标签
|
||||||
|
const newCategories = await get<Category[]>('/categories')
|
||||||
|
const newTags = await get<Tag[]>('/tags')
|
||||||
|
const catNameToId = new Map(newCategories.map(c => [c.name, c.id]))
|
||||||
|
const tagNameToId = new Map(newTags.map(t => [t.name, t.id]))
|
||||||
|
|
||||||
|
for (const task of data.tasks) {
|
||||||
|
const taskData: Record<string, unknown> = {
|
||||||
|
title: task.title,
|
||||||
|
description: task.description || null,
|
||||||
|
priority: task.priority,
|
||||||
|
due_date: task.due_date || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.category_id && oldCatMap.has(task.category_id)) {
|
||||||
|
const catName = oldCatMap.get(task.category_id)
|
||||||
|
if (catName && catNameToId.has(catName)) {
|
||||||
|
taskData.category_id = catNameToId.get(catName)!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagIds: number[] = []
|
||||||
|
if (task.tags && Array.isArray(task.tags)) {
|
||||||
|
for (const tag of task.tags) {
|
||||||
|
if (tagNameToId.has(tag.name)) {
|
||||||
|
tagIds.push(tagNameToId.get(tag.name)!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
taskData.tag_ids = tagIds
|
||||||
|
|
||||||
|
await post('/tasks', taskData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入习惯数据
|
||||||
|
if (data.habitGroups && Array.isArray(data.habitGroups)) {
|
||||||
|
for (const grp of data.habitGroups) {
|
||||||
|
await post('/habit-groups', { name: grp.name, color: grp.color, icon: grp.icon, sort_order: grp.sort_order })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.habits && Array.isArray(data.habits)) {
|
||||||
|
const oldGroupMap = new Map<number, string>()
|
||||||
|
if (data.habitGroups) {
|
||||||
|
data.habitGroups.forEach((g: HabitGroup) => oldGroupMap.set(g.id, g.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
const newGroups = await get<HabitGroup[]>('/habit-groups')
|
||||||
|
const groupNameToId = new Map(newGroups.map(g => [g.name, g.id]))
|
||||||
|
|
||||||
|
for (const habit of data.habits) {
|
||||||
|
const habitData: Record<string, unknown> = {
|
||||||
|
name: habit.name,
|
||||||
|
description: habit.description || null,
|
||||||
|
target_count: habit.target_count || 1,
|
||||||
|
frequency: habit.frequency || 'daily',
|
||||||
|
active_days: habit.active_days || null,
|
||||||
|
is_archived: habit.is_archived || false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (habit.group_id && oldGroupMap.has(habit.group_id)) {
|
||||||
|
const grpName = oldGroupMap.get(habit.group_id)
|
||||||
|
if (grpName && groupNameToId.has(grpName)) {
|
||||||
|
habitData.group_id = groupNameToId.get(grpName)!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await post('/habits', habitData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新数据
|
||||||
|
await Promise.all([
|
||||||
|
taskStore.fetchTasks(),
|
||||||
|
categoryStore.fetchCategories(),
|
||||||
|
tagStore.fetchTags()
|
||||||
|
])
|
||||||
|
if (data.habits || data.habitGroups) {
|
||||||
|
await habitStore.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.success('数据导入成功~')
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { toString?: () => string })?.toString?.() !== 'cancel') {
|
||||||
|
ElMessage.error('导入失败了呢~')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCompleted() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'确定要清除所有已完成的任务吗?这个操作不可撤销哦~',
|
||||||
|
'确认清除',
|
||||||
|
{ confirmButtonText: '确定清除', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const completed = taskStore.completedTasks
|
||||||
|
for (const task of completed) {
|
||||||
|
await del(`/tasks/${task.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await taskStore.fetchTasks()
|
||||||
|
ElMessage.success(`已清除 ${completed.length} 个已完成的任务~`)
|
||||||
|
} catch {
|
||||||
|
// 用户取消
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="settings-page">
|
||||||
|
<div class="settings-container">
|
||||||
|
<!-- 应用偏好 -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-icon">
|
||||||
|
<el-icon :size="24"><Brush /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title">应用偏好</h3>
|
||||||
|
<p class="card-subtitle">自定义你的使用体验</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-label">
|
||||||
|
<span class="label-text">网站名称</span>
|
||||||
|
<span class="label-desc">显示在浏览器标签页和顶部导航栏</span>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-model="prefs.site_name"
|
||||||
|
placeholder="爱莉希雅待办"
|
||||||
|
maxlength="50"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-label">
|
||||||
|
<span class="label-text">默认视图</span>
|
||||||
|
<span class="label-desc">打开应用时首先显示的页面</span>
|
||||||
|
</div>
|
||||||
|
<el-select v-model="prefs.default_view" style="width: 180px">
|
||||||
|
<el-option
|
||||||
|
v-for="opt in viewOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-label">
|
||||||
|
<span class="label-text">默认排序方式</span>
|
||||||
|
<span class="label-desc">任务列表的默认排序依据</span>
|
||||||
|
</div>
|
||||||
|
<el-select v-model="prefs.default_sort_by" style="width: 180px">
|
||||||
|
<el-option
|
||||||
|
v-for="opt in sortByOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-label">
|
||||||
|
<span class="label-text">默认排序顺序</span>
|
||||||
|
<span class="label-desc">列表的默认排列方向</span>
|
||||||
|
</div>
|
||||||
|
<el-select v-model="prefs.default_sort_order" style="width: 180px">
|
||||||
|
<el-option
|
||||||
|
v-for="opt in sortOrderOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="saving"
|
||||||
|
@click="handleSave"
|
||||||
|
class="save-btn"
|
||||||
|
>
|
||||||
|
保存偏好
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 数据管理 -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-icon">
|
||||||
|
<el-icon :size="24"><FolderOpened /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title">数据管理</h3>
|
||||||
|
<p class="card-subtitle">备份、恢复和清理你的数据</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="data-actions">
|
||||||
|
<div class="data-action-item">
|
||||||
|
<div class="action-info">
|
||||||
|
<span class="action-title">导出数据</span>
|
||||||
|
<span class="action-desc">将所有任务、分类、标签和习惯导出为 JSON 文件</span>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
:loading="exporting"
|
||||||
|
@click="exportData"
|
||||||
|
class="action-btn"
|
||||||
|
>
|
||||||
|
<el-icon><Download /></el-icon>
|
||||||
|
导出
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-action-item">
|
||||||
|
<div class="action-info">
|
||||||
|
<span class="action-title">导入数据</span>
|
||||||
|
<span class="action-desc warning">从 JSON 文件恢复数据(会覆盖现有数据)</span>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
@click="importData"
|
||||||
|
class="action-btn"
|
||||||
|
>
|
||||||
|
<el-icon><Upload /></el-icon>
|
||||||
|
导入
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-action-item">
|
||||||
|
<div class="action-info">
|
||||||
|
<span class="action-title">清除已完成任务</span>
|
||||||
|
<span class="action-desc danger">删除所有标记为已完成的任务(不可撤销)</span>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
@click="clearCompleted"
|
||||||
|
class="action-btn"
|
||||||
|
>
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
清除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.settings-page {
|
||||||
|
min-height: calc(100vh - 60px);
|
||||||
|
padding: 32px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fadeInUp 0.4s ease;
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
animation-delay: 0.1s;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px 28px;
|
||||||
|
border-bottom: 1px solid rgba(255, 183, 197, 0.15);
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 183, 197, 0.2) 0%, rgba(200, 162, 200, 0.2) 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 24px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 183, 197, 0.08);
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 24px;
|
||||||
|
|
||||||
|
.label-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgba(255, 183, 197, 0.15);
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
min-width: 120px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||||
|
border: none;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 183, 197, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-action-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 183, 197, 0.08);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 24px;
|
||||||
|
|
||||||
|
.action-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
&.warning {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
min-width: 100px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settings-page {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header,
|
||||||
|
.card-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-select) {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-action-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.action-info {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
283
WebUI/src/views/TaskList.vue
Normal file
283
WebUI/src/views/TaskList.vue
Normal 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>
|
||||||
57
WebUI/src/views/TaskListView.vue
Normal file
57
WebUI/src/views/TaskListView.vue
Normal 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>
|
||||||
447
WebUI/src/views/WeeklyView.vue
Normal file
447
WebUI/src/views/WeeklyView.vue
Normal 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
19
WebUI/tsconfig.app.json
Normal 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
7
WebUI/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
WebUI/tsconfig.node.json
Normal file
26
WebUI/tsconfig.node.json
Normal 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
32
WebUI/vite.config.ts
Normal 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
1
api/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# 爱莉希雅待办事项后端
|
||||||
29
api/app/config.py
Normal file
29
api/app/config.py
Normal 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
101
api/app/database.py
Normal 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
144
api/app/main.py
Normal 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)
|
||||||
14
api/app/models/__init__.py
Normal file
14
api/app/models/__init__.py
Normal 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
61
api/app/models/account.py
Normal 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")
|
||||||
37
api/app/models/anniversary.py
Normal file
37
api/app/models/anniversary.py
Normal 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")
|
||||||
16
api/app/models/category.py
Normal file
16
api/app/models/category.py
Normal 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
55
api/app/models/habit.py
Normal 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
23
api/app/models/tag.py
Normal 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
23
api/app/models/task.py
Normal 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")
|
||||||
36
api/app/models/user_settings.py
Normal file
36
api/app/models/user_settings.py
Normal 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)
|
||||||
14
api/app/routers/__init__.py
Normal file
14
api/app/routers/__init__.py
Normal 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
498
api/app/routers/accounts.py
Normal 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="分期还款失败")
|
||||||
284
api/app/routers/anniversaries.py
Normal file
284
api/app/routers/anniversaries.py
Normal 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="删除纪念日失败")
|
||||||
102
api/app/routers/categories.py
Normal file
102
api/app/routers/categories.py
Normal 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
368
api/app/routers/habits.py
Normal 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
74
api/app/routers/tags.py
Normal 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
185
api/app/routers/tasks.py
Normal 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="切换任务状态失败")
|
||||||
57
api/app/routers/user_settings.py
Normal file
57
api/app/routers/user_settings.py
Normal 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="更新用户设置失败")
|
||||||
44
api/app/schemas/__init__.py
Normal file
44
api/app/schemas/__init__.py
Normal 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
133
api/app/schemas/account.py
Normal 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
|
||||||
122
api/app/schemas/anniversary.py
Normal file
122
api/app/schemas/anniversary.py
Normal 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
|
||||||
28
api/app/schemas/category.py
Normal file
28
api/app/schemas/category.py
Normal 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
39
api/app/schemas/common.py
Normal 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
105
api/app/schemas/habit.py
Normal 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
19
api/app/schemas/tag.py
Normal 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
85
api/app/schemas/task.py
Normal 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
|
||||||
39
api/app/schemas/user_settings.py
Normal file
39
api/app/schemas/user_settings.py
Normal 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
|
||||||
5
api/app/utils/__init__.py
Normal file
5
api/app/utils/__init__.py
Normal 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
34
api/app/utils/crud.py
Normal 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
11
api/app/utils/datetime.py
Normal 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
48
api/app/utils/logger.py
Normal 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
16
api/webui/index.html
Normal 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
Reference in New Issue
Block a user