Compare commits

...

16 Commits

Author SHA1 Message Date
bee9658b2d docs: consolidate project documentation 2026-06-08 16:09:28 +08:00
祀梦
4ce7de48c4 feat: add cumulative checkin tracking mode for goals
Goals can now choose between milestone-based progress (existing) and
cumulative checkin-based progress (new). Cumulative mode supports
cross-unit conversion (e.g. kcal → g fat) via a configurable
conversion rate. New GoalCheckin model stores daily inputs; progress
auto-recalculates on every checkin C/U/D. Backup import/export covers
the new table. Frontend GoalDialog, GoalDetailPage and GoalPage cards
adapt to show cumulative progress or milestone progress based on
track_type.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 23:00:24 +08:00
祀梦
4ee1e39454 feat: add certificate management module with image upload
- Add Certificate + CertificateCategory models with full CRUD API
- Image upload via base64 data URL stored in Text column
- Certificate fields: title, issuer, issue_date, expiry_date, image, description
- Frontend: card grid with category sidebar filter, create/edit dialog
- Include certificates in data backup/export
- Fix hasPhaseParent optimization in GoalDetailPage

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:25:58 +08:00
祀梦
5048de4fa1 feat: add data backup/import, goal step ordering, and PostgreSQL migration
- Add GET /api/backup/export and POST /api/backup/import endpoints for full data backup
- Add drag-and-drop reorder for goal steps with PUT /api/goals/{id}/steps/reorder
- Auto-assign sort_order on step creation (preserves creation order)
- Fix duplicate milestone rendering in goal detail page
- Add category management button in goal dialog
- Migrate database default from SQLite to PostgreSQL
- Fix router guard redirect loop for logged-in users on setup/login pages
- Fix ALTER TABLE ADD COLUMN crash on callable defaults (uuid.uuid4)
- Add auth status rate limiter and token version caching
- Update CLAUDE.md to reflect current architecture

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:02:18 +08:00
祀梦
0ab719500b feat: add WebDAV sync support and startup/shutdown scripts
Backend:
- Add uuid, sync_version, is_deleted fields to all syncable models
- Add SyncSettings model for WebDAV configuration (AES-256-GCM encrypted passwords)
- Add crypto.py: AES-256-GCM encryption derived from JWT_SECRET via PBKDF2
- Add sync_lock.py: thread-level sync lock with 503 middleware for write blocking
- Add webdav.py: WebDAV client using requests (PUT/GET/MKCOL/DELETE)
- Add sync_service.py: push/pull/bidirectional merge with LWW conflict resolution
- Add sync router with 8 endpoints: config, test, push, pull, sync, status, remote delete
- Add UUID backfill for existing records in init_db()
- Add SQLAlchemy before_update event to auto-increment sync_version
- Register sync middleware to block writes during sync (503)

Frontend:
- Add sync API client (WebUI/src/api/sync.ts)
- Add useSyncStore with config, test, push/pull/sync operations
- Add WebDAV config + sync UI in SettingsView
- Add 503 status code handling in axios interceptor
- Add uuid field to all TypeScript type definitions

Scripts:
- Add scripts/start.bat and scripts/stop.bat for project management

Design doc: docs/plan/webdav-sync-design.md
2026-05-17 21:18:54 +08:00
祀梦
944d20dcc7 fix: 5 auth flow bugs in setup/login routing
1. App.vue: exclude setup page from main layout (header/FAB)
2. request.ts: exempt /auth/setup from 401 hard redirect to /login
3. LoginView: redirect to /setup when backend says password not set
4. SetupView: add missing router.replace after successful setup
5. router guard: only call checkSetup after checkAuth fails, not on every navigation
2026-05-17 19:51:57 +08:00
祀梦
f838840bda feat: add onboarding setup flow with nickname and password
Replace default auto-generated password with a first-run setup page that
lets users choose their own nickname and password. The /auth/setup endpoint
now accepts an optional nickname field (also sets site_name). Remove
set_default_password() since setup is now mandatory before login.
2026-05-17 19:45:36 +08:00
祀梦
bfdf0c9987 fix: replace passlib with native bcrypt (Python 3.13 compatibility)
passlib 1.7.4 has a known bug with bcrypt 4.x on Python 3.13 where
detect_wrap_bug passes an over-72-byte hash as a password, causing
ValueError on every login attempt.

Switched to bcrypt.hashpw/checkpw directly, removing the passlib
dependency entirely.

Also fixed 401 page reload on /auth/login endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:12:10 +08:00
祀梦
5af8cb5486 feat: add goal management module (long-term goals with phases, milestones, reviews)
Backend:
- Goal model: title, description, status (active/paused/completed/abandoned),
  progress (auto-computed from milestones), target_date, category, color, icon
- GoalStep model: unified phase/milestone with parent nesting
- GoalReview model: periodic reflection with rating
- goal_tasks M2M: link existing tasks to goals
- /api/goals CRUD + steps CRUD + reviews + task linking + status toggle
- Progress auto-calculated from milestone completion ratio

Frontend:
- GoalPage: card grid with progress bars, status filter
- GoalDetailPage: step tree (phases > milestones), reviews, linked tasks
- GoalDialog: create/edit form with color/icon picker
- Goal navigation in AppHeader
- useGoalStore: full Pinia store for all goal operations

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:34:39 +08:00
祀梦
0bca9e6654 fix: prevent full page reload on login failure (401 on /auth/login)
The axios 401 interceptor was redirecting to /login for every 401 response,
including failed login attempts. Now it skips the redirect when the request
is to /auth/login, letting the LoginView handle the error gracefully.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:01:04 +08:00
祀梦
9d4d869d57 fix: harden authentication system (JWT, cookies, rate limiting, password policy)
- Replace hardcoded JWT secret with randomly generated key persisted to file
- Replace hardcoded default password with random password shown in logs
- Migrate token storage from localStorage to HttpOnly SameSite=strict cookie
- Add IP-based login rate limiter (5 attempts / 15 min, 429 on lockout)
- Add token_version for JWT revocation on password change
- Add password strength validation (min 6 chars, 3+ unique characters)
- Inject decoded user payload into request.state.user in auth middleware
- Add /api/auth/me and /api/auth/logout endpoints
- Narrow auth middleware exception handling (JWTError only, not all Exception)
- Update updated_at timestamp on password change
- Remove localStorage token management from frontend (axios, router, store)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:54:45 +08:00
祀梦
1047bcece9 chore: remove AGENTS.md from tracking, gitignore Claude doc files
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:43:04 +08:00
祀梦
58559064d2 chore: update built frontend artifacts (asset features removed) 2026-05-17 13:06:00 +08:00
祀梦
e3f73048a7 refactor: remove all asset/account functionality (models, schemas, routers, store, views, components, tests, docs) 2026-05-17 12:59:52 +08:00
祀梦
9c5ef36fe8 fix: path traversal via URL-encoded ../, Feb 29 leap year crash, missing response_model, dead code, duplicate utcnow 2026-05-17 12:36:45 +08:00
祀梦
5f23b8ef5b fix: computed fields missing in anniversary endpoints + missing account_id validation in installment update 2026-05-17 12:00:54 +08:00
80 changed files with 6493 additions and 4414 deletions

4
.gitignore vendored
View File

@@ -72,3 +72,7 @@ check_*.py
# Environment variables # Environment variables
.env .env
.env.* .env.*
# Claude Code
CLAUDE.md
AGENTS.md

View File

@@ -1,81 +0,0 @@
# AGENTS.md
## Project overview
- Full-stack todo app: **Vue 3 + Element Plus** (WebUI/) + **FastAPI + SQLAlchemy** (api/)
- **Python 3.10+, Node 18+**; SQLite database
- Single `master` branch, no CI/CD
## Quick commands
```powershell
# Install Python deps (required before first run)
pip install -r requirements.txt
# Full-stack one-shot (builds frontend, then starts backend on :23994)
python main.py
# Frontend dev only (hot-reload on :5173, proxies /api → :23994)
cd WebUI; npm run dev
# Backend only
cd api; uvicorn app.main:app --host 0.0.0.0 --port 23994
# Type-check frontend (noEmit; uses project references tsconfig)
cd WebUI; npm run typecheck
# Run the one hand-written test (backend must be running on :23994)
python tests/test_accounts.py
```
**Build order matters:** `python main.py` automatically compiles WebUI (`npm install` + `npm run build`), copies `WebUI/dist/``api/webui/`, then starts uvicorn. It checks timestamps to skip rebuilds when frontend is unchanged.
**Import path:** `main.py` injects `api/` into `sys.path` (no `os.chdir`). Backend imports resolve relative to `api/` — e.g. `from app.config import ...`, not `from api.app.config import ...`.
**Port conflict:** `main.py` will auto-kill any process already listening on port 23994 before starting. If you have another instance running, it will be terminated without warning.
## Architecture
### Backend (`api/app/`)
- **Config is hardcoded** in `api/app/config.py` — no `.env`, no `os.getenv()`. Port `23994`, CORS origins `["http://localhost:5173", "http://localhost:23994"]`. Note: `pydantic-settings` is in `requirements.txt` but unused.
- **SQLite path** is computed relative to `api/` via `__file__` — safe regardless of cwd. `connect_args={"check_same_thread": False}` for FastAPI async compatibility.
- **`database.py:init_db()`** auto-creates tables on startup (`create_all`) and auto-adds missing columns via `ALTER TABLE ADD COLUMN` (no Alembic). Columns that are non-nullable with no default are skipped.
- **UserSettings is a singleton**: always id=1, auto-created on first `GET`.
- **Account balance changes** auto-create `AccountHistory` records in `update_balance()`.
- **Habit checkins for the same day** accumulate count (not new rows), enforced by a `(habit_id, checkin_date)` unique constraint.
- **Anniversaries / DebtInstallments** have computed fields (`next_date`, `days_until`, `year_count` / `remaining_periods`) calculated at request time, not stored in DB.
- **`task_tags` M2M table** is defined in `models/tag.py` (not `models/task.py`).
- **Update schemas** use `clearable_fields` + `exclude_unset=True` to distinguish "field not sent" from "field sent as null".
- **JWT authentication** — mandatory for all `/api/*` routes except `/api/auth/*` and `/health`. Default password: `elysia`. Login via `POST /api/auth/login`. Token key in localStorage: `elysia_auth_token`. Frontend axios interceptor auto-attaches `Authorization: Bearer <token>`.
### Frontend (`WebUI/`)
- Vue Router uses `createWebHistory()` (HTML5 history mode) — **requires the backend SPA fallback** (`/{full_path:path}``index.html`).
- Vite dev proxy forwards `/api``http://localhost:23994`.
- `@` alias maps to `src/`.
- Global styles in SCSS (`src/styles/`).
- 8 Pinia stores; Element Plus icons registered globally in `main.ts`.
- Element Plus uses Chinese locale (`zh-cn`).
### Route registration order matters
The `/health` endpoint must be registered **before** the `/{full_path:path}` SPA fallback catch-all, otherwise `/health` requests return `index.html`. This is enforced in `api/app/main.py:114` — do not reorder these registrations.
### Docker
- `Dockerfile` copies pre-built `api/webui/` — you must build frontend before `docker build`.
- Docker CMD is an inline `python -c` one-liner that injects `api/` into `sys.path` and starts uvicorn. No separate entrypoint script.
- `docker-compose.yml` mounts `api/data/` and `api/logs/` for persistence; `api/webui/` is read-only.
## Testing quirks
- Only one test file: `tests/test_accounts.py`**hand-written, no framework** (not pytest). It counts pass/fail manually and uses `requests` directly against `localhost:23994`.
- Backend must be running before executing tests.
- **The test permanently mutates the database** — Section 15 deliberately leaves test data in place for UI display. Run it on a disposable database copy or reset manually if you need a clean state.
- **The test sends no auth headers** and will fail with 401 if JWT auth is enforced. You must first `POST /api/auth/login` with `{"password": "elysia"}` to get a token, then include `Authorization: Bearer <token>` in requests, or temporarily comment out the auth middleware for testing.
- No test coverage for tasks, habits, anniversaries, or tags.
## What's missing (agents should not assume)
- No linter, formatter, pre-commit hooks, or CI/CD
- No `.env` or environment variable loading
- No database migrations framework (Alembic)
## Additional notes
- Swagger UI at `/docs` when backend is running — the live, auto-generated API reference.
- `python-multipart` in `requirements.txt` is required for FastAPI to parse form data (not optional).
- `sass` is a runtime `dependency` (not `devDependency`) in `package.json` — unusual, but intentional.

View File

@@ -15,8 +15,8 @@ COPY api/ ./api/
# 拷贝编译后的前端产物 # 拷贝编译后的前端产物
COPY api/webui/ ./api/webui/ COPY api/webui/ ./api/webui/
# 创建数据和日志目录 # 创建日志目录
RUN mkdir -p api/data api/logs RUN mkdir -p api/logs
EXPOSE 23994 EXPOSE 23994

298
README.md
View File

@@ -1,173 +1,215 @@
# Elysia ToDo - 爱莉希雅待办事项 # Elysia ToDo - 爱莉希雅待办事项
一款全栈个人信息管理应用,待办任务、习惯打卡、纪念日提醒、资产总览于一体 Elysia ToDo 是一个全栈个人信息管理应用,当前代码包含待办任务、习惯打卡、纪念日、长期目标、证书管理、数据备份导入、WebDAV 同步和单用户密码登录能力
## 功能概览 ## 功能概览
### 任务管理 ### 任务管理
- **待办列表** — 创建、编辑、删除任务,支持分类、标签、优先级
- **四象限视图** — 基于艾森豪威尔矩阵(重要/紧急)的四象限优先级模型 - 待办列表:创建、编辑、删除、完成切换、分类、标签、截止时间和优先级
- **日历视图** — 按月/周/日查看任务排布 - 四象限视图:使用 `q1``q4` 表示重要/紧急优先级。
- **拼音搜索** — 支持中文拼音快速检索任务和分类 - 日历视图:按日历方式查看任务排布。
- 拼音搜索:前端使用 `pinyin-pro` 支持中文拼音检索。
### 习惯打卡 ### 习惯打卡
- 习惯分组管理(学习、运动、生活等)
- 每日打卡记录,支持周期配置与休息日
- 周视图打卡进度展示,一目了然
### 纪念日管理 - 习惯分组管理,支持颜色、图标和排序。
- 自定义纪念日分类 - 习惯支持每日/每周频率、目标次数、指定活跃日期和归档状态。
- 支持农历/公历日期 - 打卡记录按日期保存,可用于周视图/月视图展示。
- 倒计时提醒,不错过重要日子
### 资产总览 ### 纪念日
- 财务账户管理(现金、银行卡、电子钱包等)
- 收支记录与历史查询
- 分期还款跟踪
- 资产汇总统计
### 系统功能 - 纪念日分类管理。
- 偏好设置(站点名称、默认视图等) - 纪念日支持日期、年份、循环标记、提前提醒天数和说明。
- 可折叠侧边栏 - 前端提供倒计时、分类筛选和编辑弹窗。
- 响应式布局
- SPA 单页应用History 路由模式 ### 目标管理
- 长期目标支持 `milestone` 里程碑模式和 `cumulative` 累计打卡模式。
- 目标可设置状态、目标值、输入单位、换算率、目标日期、分类、颜色、图标和排序。
- 目标详情支持阶段/里程碑、复盘、关联任务和目标打卡记录。
### 证书管理
- 证书分类管理。
- 证书支持图片、颁发机构、颁发日期、到期日期、说明和排序。
### 系统与数据
- 首次设置密码、登录、退出、修改密码,使用 Cookie 中的 JWT 做会话认证。
- 用户偏好设置:昵称、头像、签名、生日、邮箱、站点名称、主题、默认视图和默认排序。
- 数据备份导出和导入:通过 `/api/backup/export``/api/backup/import` 处理 JSON 备份。
- WebDAV 同步:支持配置、测试连接、推送、拉取、双向同步、状态查询和清空远端同步数据。
- SPA 单页应用,后端静态服务支持 History 路由回退。
## 技术栈 ## 技术栈
| 层级 | 技术 | | 层级 | 当前实现 |
|------|------| |------|----------|
| 前端框架 | Vue 3 + TypeScript | | 前端框架 | Vue 3 + TypeScript |
| UI 组件库 | Element Plus | | UI 组件库 | Element Plus |
| 状态管理 | Pinia | | 状态管理 | Pinia |
| 路由 | Vue Router 4 | | 路由 | Vue Router 4 |
| 构建工具 | Vite | | 构建工具 | Vite 7 |
| HTTP 客户端 | Axios |
| 后端框架 | FastAPI | | 后端框架 | FastAPI |
| ORM | SQLAlchemy | | ORM | SQLAlchemy 2 |
| 数据库 | SQLite | | 数据库 | PostgreSQL默认由 `DATABASE_URL` 指定 |
| ASGI 服务器 | Uvicorn | | ASGI 服务器 | Uvicorn |
| 密码与令牌 | bcrypt + python-jose |
## 项目结构 ## 项目结构
``` ```text
ToDoList/ ToDoList/
├── main.py # 启动入口(编译前端 + 启动后端 ├── main.py # 编译前端、复制静态资源、启动后端
├── requirements.txt # Python 依赖 ├── requirements.txt # Python 依赖锁定
├── .gitignore ├── Dockerfile # 后端镜像,依赖预构建的 api/webui
├── api/ # 后端 ├── docker-compose.yml # PostgreSQL + 应用服务
│ └── app/ ├── scripts/
├── config.py # 配置端口、路径、CORS 等) ├── start.bat # Windows CMD 启动脚本
├── database.py # 数据库引擎与会话管理 └── stop.bat # Windows CMD 停止脚本
│ ├── main.py # FastAPI 应用(路由、中间件、静态文件) ├── api/
├── models/ # SQLAlchemy 数据模型 ├── app/
├── schemas/ # Pydantic 请求/响应模型 ├── config.py # 数据库、端口、CORS、日志和 JWT 配置
├── routers/ # API 路由 ├── database.py # SQLAlchemy 引擎、会话、建表和轻量迁移
── utils/ # 工具函数CRUD、日志、日期 ── main.py # FastAPI 应用、中间件、路由和静态文件
├── WebUI/ # 前端 ├── models/ # SQLAlchemy 数据模型
│ ├── package.json │ ├── schemas/ # Pydantic 请求/响应模型
│ ├── vite.config.ts │ ├── routers/ # API 路由
│ └── src/ │ └── utils/ # 日志、认证、同步、WebDAV、加密等工具
├── api/ # Axios 接口封装 └── webui/ # 已构建前端静态资源,启动时由 main.py 覆盖
│ ├── components/ # 通用组件 └── WebUI/
├── views/ # 页面视图 ├── package.json
├── stores/ # Pinia 状态管理 ├── package-lock.json
├── router/ # 路由配置 ├── vite.config.ts
── styles/ # 全局样式 (SCSS) ── src/
── utils/ # 前端工具(拼音、优先级、日期) ── api/ # Axios 接口封装
└── tests/ # 测试 ├── components/ # 复用组件和弹窗
├── router/ # 路由和认证守卫
├── stores/ # Pinia 状态
├── styles/ # 全局 SCSS
├── utils/ # 日期、拼音、优先级工具
└── views/ # 页面视图
``` ```
## 快速开始 当前仓库只保留根目录 `README.md` 作为统一文档入口;没有 `tests/` 目录,也没有独立的测试脚本。
### 环境要求 ## 环境要求
- Python 3.10+ - Windows CMD 是默认运行环境。
- Node.js 18+ - Python 3.10+。当前本机检查到的解释器是 Python 3.11.0。
- npm - Node.js 满足 Vite 7 要求:`^20.19.0``>=22.12.0`
- npm。Windows 下优先使用 `npm.cmd`,避免 PowerShell shim 路径问题。
- PostgreSQL。默认连接由 `api/app/config.py` 中的 `DATABASE_URL` 读取,推荐通过环境变量覆盖。
### 安装与运行 ## 启动与停止
```bash 官方启动入口是脚本,不建议把 `python``npm``uvicorn` 作为日常手工启动方式。
# 1. 克隆项目
git clone <your-repo-url>
cd ToDoList
# 2. 安装 Python 依赖 ```cmd
pip install -r requirements.txt scripts\start.bat
```
# 3. 一键启动(自动编译前端 + 启动后端) `scripts\start.bat` 会执行以下动作:
python main.py
1. 切换到项目根目录。
2. 检查 Python 和项目文件。
3. 首次运行时安装 Python 依赖。
4. 检查并释放 `23994` 端口。
5. 运行 `main.py`
6. `main.py` 会按需执行前端依赖安装、`npm.cmd run build`、复制 `WebUI/dist``api/webui`,再启动 FastAPI。
停止服务:
```cmd
scripts\stop.bat
``` ```
启动后访问: 启动后访问:
- 前端页面http://localhost:23994 - 前端页面http://localhost:23994
- API 文档http://localhost:23994/docs - API 文档http://localhost:23994/docs
- 健康检查http://localhost:23994/health
### 前端开发模式
如果需要前后端分离开发(热更新):
```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` 为前缀,启动后端后访问 `/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` 中可以修改: 主要配置位于 `api/app/config.py`
| 配置项 | 默认值 | 说明 | | 配置项 | 当前默认值 | 说明 |
|--------|--------|------| |--------|------------|------|
| HOST | `0.0.0.0` | 监听地址 | | `DATABASE_URL` | 从环境变量读取,否则使用内置 PostgreSQL 连接 | SQLAlchemy 数据库连接串 |
| PORT | `23994` | 服务端口 | | `WEBUI_PATH` | `api/webui` | 后端挂载的前端静态资源目录 |
| DATABASE_PATH | `api/data/todo.db` | SQLite 数据库路径 | | `CORS_ORIGINS` | `http://localhost:5173``http://localhost:23994` | 允许的跨域来源 |
| WEBUI_PATH | `api/webui` | 前端静态文件目录 | | `LOG_LEVEL` | `INFO` | 应用日志级别 |
| CORS_ORIGINS | `localhost:5173, 23994` | 允许的跨域来源 | | `LOG_DIR` | `api/logs` | 文件日志目录 |
| `HOST` | `0.0.0.0` | 服务监听地址 |
| `PORT` | `23994` | 服务端口 |
| `JWT_SECRET` | 首次启动写入 `api/data/.jwt_secret` | JWT 和 WebDAV 密码加密派生密钥 |
数据库文件和日志文件会在首次运行时自动创建,无需手动初始化。 运行时目录:
## 部署 - `api/logs/app.log`:应用日志,按天轮转。
- `api/data/.jwt_secret`:本机 JWT 密钥文件。
- `api/data/backups/`WebDAV 拉取前的本地 JSON 快照。
项目支持在 Windows 和 Linux 上运行。`main.py` 会自动处理平台差异npm 命令、端口占用检测等)。 ## API 概览
对于生产环境部署,建议: `/health` 外,业务接口均在 `/api` 下。大多数 `/api/*` 接口需要登录 Cookie。
- 使用 `gunicorn` + `uvicorn worker` 替代直接运行
- 配置反向代理Nginx | 模块 | 路径 | 说明 |
- 数据库可替换为 PostgreSQL 或 MySQL修改 `config.py` 中的 `DATABASE_URL` |------|------|------|
| 认证 | `/api/auth/status``/api/auth/setup``/api/auth/login``/api/auth/logout``/api/auth/me``/api/auth/change-password` | 首次设置、登录状态、登录退出和改密 |
| 任务 | `/api/tasks``/api/tasks/{task_id}/toggle` | 任务 CRUD 和完成切换 |
| 分类 | `/api/categories` | 任务分类 CRUD |
| 标签 | `/api/tags` | 标签列表、创建、删除 |
| 习惯分组 | `/api/habit-groups` | 习惯分组 CRUD |
| 习惯 | `/api/habits``/api/habits/{habit_id}/checkins` | 习惯 CRUD、归档和打卡 |
| 纪念日 | `/api/anniversary-categories``/api/anniversaries` | 纪念日分类和纪念日 CRUD |
| 目标 | `/api/goals` | 目标、步骤、复盘、关联任务和目标打卡 |
| 证书 | `/api/certificate-categories``/api/certificates` | 证书分类和证书 CRUD |
| 用户设置 | `/api/user-settings` | 用户偏好获取和更新 |
| 备份 | `/api/backup/export``/api/backup/import` | JSON 备份导出和覆盖导入 |
| WebDAV 同步 | `/api/sync/config``/api/sync/test``/api/sync/push``/api/sync/pull``/api/sync/sync``/api/sync/status``/api/sync/remote` | 同步配置和操作 |
| 健康检查 | `/health` | 服务状态 |
## 数据模型关系
```text
Category 1 -> N Task
Task N <-> N Tag
HabitGroup 1 -> N Habit
Habit 1 -> N HabitCheckin
AnniversaryCategory 1 -> N Anniversary
Category 1 -> N Goal
Goal 1 -> N GoalStep
GoalStep 1 -> N GoalStep
Goal 1 -> N GoalReview
Goal 1 -> N GoalCheckin
Goal N <-> N Task
CertificateCategory 1 -> N Certificate
UserSettings 1 -> 1
SyncSettings 1 -> 1
```
可同步模型使用 `uuid``sync_version``is_deleted` 支持 WebDAV 同步和软删除传播。
## Docker 部署
`docker-compose.yml` 包含两个服务:
- `db`PostgreSQL容器名 `elysia-todo-db`
- `elysia-todo`:应用服务,容器名 `elysia-todo`,对外暴露 `23994`
注意:`Dockerfile` 只复制 `api/` 和已经存在的 `api/webui/`,不会在镜像构建时编译 `WebUI/`。构建镜像前需要先通过启动流程或构建流程生成前端产物并复制到 `api/webui`
## 当前已知差异
- `requirements.txt` 当前未显式列出 `requests`,但 `api/app/utils/webdav.py` 会导入并使用它。干净环境如果报 `ModuleNotFoundError: No module named 'requests'`,需要先补齐依赖配置。
- 连接串、容器数据库密码等敏感配置当前存在于仓库配置文件中。对外发布或多人协作前,建议改为环境变量和 `.env.example`
- 仓库当前没有测试目录和测试脚本。新增功能时应先补 `scripts/` 下的验证脚本,再通过脚本执行验证。

View File

@@ -1,5 +0,0 @@
# 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).

View File

@@ -24,7 +24,7 @@ const authStore = useAuthStore()
// 路由变化时同步 currentView // 路由变化时同步 currentView
watch(() => route.meta.view, (view) => { watch(() => route.meta.view, (view) => {
if (view) { if (view) {
uiStore.setCurrentView(view as 'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries' | 'assets') uiStore.setCurrentView(view as 'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries')
} }
}, { immediate: true }) }, { immediate: true })
@@ -67,7 +67,7 @@ onMounted(async () => {
<template> <template>
<el-config-provider :locale="zhCn"> <el-config-provider :locale="zhCn">
<div class="app-container"> <div class="app-container">
<template v-if="route.name !== 'login'"> <template v-if="route.name !== 'login' && route.name !== 'setup'">
<div class="decoration-star" style="top: 20%; right: 8%; animation-delay: 0.5s;"></div> <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: 60%; left: 3%; animation-delay: 1s;"></div>
<div class="decoration-star" style="top: 80%; right: 5%; animation-delay: 1.5s;"></div> <div class="decoration-star" style="top: 80%; right: 5%; animation-delay: 1.5s;"></div>
@@ -89,7 +89,7 @@ onMounted(async () => {
</main> </main>
</div> </div>
<div v-if="uiStore.currentView !== 'settings' && uiStore.currentView !== 'profile' && uiStore.currentView !== 'habits' && uiStore.currentView !== 'anniversaries' && uiStore.currentView !== 'assets'" class="fab-container"> <div v-if="uiStore.currentView !== 'settings' && uiStore.currentView !== 'profile' && uiStore.currentView !== 'habits' && uiStore.currentView !== 'anniversaries'" class="fab-container">
<el-button <el-button
type="primary" type="primary"
circle circle

View File

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

View File

@@ -1,8 +1,7 @@
import { post } from './request' import { post, get } from './request'
export interface LoginResponse { export interface LoginResponse {
access_token: string message: string
token_type: string
} }
export interface ChangePasswordData { export interface ChangePasswordData {
@@ -10,10 +9,37 @@ export interface ChangePasswordData {
new_password: string new_password: string
} }
export interface AuthStatusResponse {
authenticated: boolean
user_id: string
}
export interface SetupStatusResponse {
has_password: boolean
}
export function login(password: string): Promise<LoginResponse> { export function login(password: string): Promise<LoginResponse> {
return post<LoginResponse>('/auth/login', { password }) return post<LoginResponse>('/auth/login', { password })
} }
export function logout(): Promise<{ message: string }> {
return post<{ message: string }>('/auth/logout')
}
export function checkAuth(): Promise<AuthStatusResponse> {
return get<AuthStatusResponse>('/auth/me')
}
export function changePassword(data: ChangePasswordData): Promise<{ message: string }> { export function changePassword(data: ChangePasswordData): Promise<{ message: string }> {
return post<{ message: string }>('/auth/change-password', data) return post<{ message: string }>('/auth/change-password', data)
} }
export function checkSetupStatus(): Promise<SetupStatusResponse> {
return get<SetupStatusResponse>('/auth/status')
}
export function setupPassword(password: string, nickname?: string): Promise<{ message: string }> {
const data: Record<string, string> = { password }
if (nickname) data.nickname = nickname
return post<{ message: string }>('/auth/setup', data)
}

13
WebUI/src/api/backup.ts Normal file
View File

@@ -0,0 +1,13 @@
import request from './request'
export function exportBackup(): Promise<Blob> {
return request.get('/backup/export', { responseType: 'blob' })
}
export function importBackup(file: File): Promise<{ message: string; count: number }> {
const form = new FormData()
form.append('file', file)
return request.post('/backup/import', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
}

View File

@@ -0,0 +1,46 @@
import { get, post, put, del } from './request'
import type {
Certificate, CertificateFormData,
CertificateCategory, CertificateCategoryFormData,
} from './types'
// ============ Categories ============
export function getCategories(): Promise<CertificateCategory[]> {
return get<CertificateCategory[]>('/certificate-categories')
}
export function createCategory(data: CertificateCategoryFormData): Promise<CertificateCategory> {
return post<CertificateCategory>('/certificate-categories', data)
}
export function updateCategory(id: number, data: Partial<CertificateCategoryFormData>): Promise<CertificateCategory> {
return put<CertificateCategory>(`/certificate-categories/${id}`, data)
}
export function deleteCategory(id: number): Promise<{ message: string }> {
return del<{ message: string }>(`/certificate-categories/${id}`)
}
// ============ Certificates ============
export function getCertificates(categoryId?: number): Promise<Certificate[]> {
const params = categoryId ? `?category_id=${categoryId}` : ''
return get<Certificate[]>(`/certificates${params}`)
}
export function getCertificate(id: number): Promise<Certificate> {
return get<Certificate>(`/certificates/${id}`)
}
export function createCertificate(data: CertificateFormData): Promise<Certificate> {
return post<Certificate>('/certificates', data)
}
export function updateCertificate(id: number, data: Partial<CertificateFormData>): Promise<Certificate> {
return put<Certificate>(`/certificates/${id}`, data)
}
export function deleteCertificate(id: number): Promise<{ message: string }> {
return del<{ message: string }>(`/certificates/${id}`)
}

94
WebUI/src/api/goals.ts Normal file
View File

@@ -0,0 +1,94 @@
import { get, post, put, del, patch } from './request'
import type {
Goal, GoalDetail, GoalFormData,
GoalStep, GoalStepFormData,
GoalReview, GoalReviewFormData,
GoalCheckin, GoalCheckinFormData,
} from './types'
// ============ Goals ============
export function getGoals(status?: string): Promise<Goal[]> {
const params = status ? `?status=${status}` : ''
return get<Goal[]>(`/goals${params}`)
}
export function getGoal(id: number): Promise<GoalDetail> {
return get<GoalDetail>(`/goals/${id}`)
}
export function createGoal(data: GoalFormData): Promise<GoalDetail> {
return post<GoalDetail>('/goals', data)
}
export function updateGoal(id: number, data: Partial<GoalFormData>): Promise<GoalDetail> {
return put<GoalDetail>(`/goals/${id}`, data)
}
export function deleteGoal(id: number): Promise<{ message: string }> {
return del<{ message: string }>(`/goals/${id}`)
}
export function updateGoalStatus(id: number, status: string): Promise<GoalDetail> {
return patch<GoalDetail>(`/goals/${id}/status`, { status })
}
// ============ Steps ============
export function createStep(goalId: number, data: GoalStepFormData): Promise<GoalStep> {
return post<GoalStep>(`/goals/${goalId}/steps`, data)
}
export function updateStep(goalId: number, stepId: number, data: Partial<GoalStepFormData>): Promise<GoalStep> {
return put<GoalStep>(`/goals/${goalId}/steps/${stepId}`, data)
}
export function deleteStep(goalId: number, stepId: number): Promise<{ message: string }> {
return del<{ message: string }>(`/goals/${goalId}/steps/${stepId}`)
}
export function toggleStep(goalId: number, stepId: number): Promise<GoalStep> {
return patch<GoalStep>(`/goals/${goalId}/steps/${stepId}/toggle`)
}
export function reorderSteps(goalId: number, items: { id: number; sort_order: number }[]): Promise<{ message: string }> {
return put<{ message: string }>(`/goals/${goalId}/steps/reorder`, { items })
}
// ============ Reviews ============
export function createReview(goalId: number, data: GoalReviewFormData): Promise<GoalReview> {
return post<GoalReview>(`/goals/${goalId}/reviews`, data)
}
export function deleteReview(goalId: number, reviewId: number): Promise<{ message: string }> {
return del<{ message: string }>(`/goals/${goalId}/reviews/${reviewId}`)
}
// ============ Task Linking ============
export function linkTask(goalId: number, taskId: number): Promise<{ message: string }> {
return post<{ message: string }>(`/goals/${goalId}/tasks/${taskId}`)
}
export function unlinkTask(goalId: number, taskId: number): Promise<{ message: string }> {
return del<{ message: string }>(`/goals/${goalId}/tasks/${taskId}`)
}
// ============ Checkins (累计打卡) ============
export function getCheckins(goalId: number): Promise<GoalCheckin[]> {
return get<GoalCheckin[]>(`/goals/${goalId}/checkins`)
}
export function createCheckin(goalId: number, data: GoalCheckinFormData): Promise<GoalCheckin> {
return post<GoalCheckin>(`/goals/${goalId}/checkins`, data)
}
export function updateCheckin(goalId: number, checkinId: number, data: Partial<GoalCheckinFormData>): Promise<GoalCheckin> {
return put<GoalCheckin>(`/goals/${goalId}/checkins/${checkinId}`, data)
}
export function deleteCheckin(goalId: number, checkinId: number): Promise<{ message: string }> {
return del<{ message: string }>(`/goals/${goalId}/checkins/${checkinId}`)
}

View File

@@ -1,24 +1,15 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios' import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
const TOKEN_KEY = 'elysia_auth_token'
const instance: AxiosInstance = axios.create({ const instance: AxiosInstance = axios.create({
baseURL: '/api', baseURL: '/api',
timeout: 10000, timeout: 10000,
withCredentials: true,
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}) })
instance.interceptors.request.use((config) => {
const token = localStorage.getItem(TOKEN_KEY)
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
instance.interceptors.response.use( instance.interceptors.response.use(
(response: AxiosResponse) => { (response: AxiosResponse) => {
return response return response
@@ -37,8 +28,10 @@ instance.interceptors.response.use(
message = data?.detail || '请求参数有误,请检查一下~' message = data?.detail || '请求参数有误,请检查一下~'
break break
case 401: case 401:
if (error.config?.url?.includes('/auth/login') || error.config?.url?.includes('/auth/setup')) {
break
}
message = '登录状态已失效~' message = '登录状态已失效~'
localStorage.removeItem(TOKEN_KEY)
window.location.href = '/login' window.location.href = '/login'
return Promise.reject(error) return Promise.reject(error)
case 403: case 403:
@@ -47,6 +40,12 @@ instance.interceptors.response.use(
case 404: case 404:
message = '请求的资源不存在~' message = '请求的资源不存在~'
break break
case 429:
message = data?.detail || '请求过于频繁,请稍后再试~'
break
case 503:
message = data?.detail || '正在同步数据,请稍后再试~'
break
case 500: case 500:
message = '服务器内部错误~' message = '服务器内部错误~'
break break

52
WebUI/src/api/sync.ts Normal file
View File

@@ -0,0 +1,52 @@
import { get, put, post, del } from './request'
export interface SyncConfig {
webdav_url: string | null
webdav_username: string | null
webdav_password: string | null
webdav_path: string
sync_enabled: boolean
auto_sync: boolean
auto_sync_interval: number
last_sync_at: string | null
last_sync_version: number
}
export interface SyncConfigUpdate {
webdav_url?: string | null
webdav_username?: string | null
webdav_password?: string | null
webdav_path?: string
auto_sync?: boolean
auto_sync_interval?: number
}
export interface SyncStatus {
syncing: boolean
last_sync_at: string | null
last_sync_version: number
sync_enabled: boolean
}
export interface SyncResult {
success: boolean
message: string
}
export const syncApi = {
getConfig: () => get<SyncConfig>('/sync/config'),
updateConfig: (data: SyncConfigUpdate) => put<SyncConfig>('/sync/config', data),
testConnection: () => post<SyncResult>('/sync/test'),
push: () => post<SyncResult>('/sync/push'),
pull: () => post<SyncResult>('/sync/pull'),
sync: () => post<SyncResult>('/sync/sync'),
getStatus: () => get<SyncStatus>('/sync/status'),
clearRemote: () => del<SyncResult>('/sync/remote'),
}

View File

@@ -2,6 +2,7 @@ export type QuadrantPriority = 'q1' | 'q2' | 'q3' | 'q4'
export interface Task { export interface Task {
id: number id: number
uuid?: string
title: string title: string
description?: string description?: string
priority: QuadrantPriority priority: QuadrantPriority
@@ -16,6 +17,7 @@ export interface Task {
export interface Category { export interface Category {
id: number id: number
uuid?: string
name: string name: string
color: string color: string
icon: string icon: string
@@ -23,6 +25,7 @@ export interface Category {
export interface Tag { export interface Tag {
id: number id: number
uuid?: string
name: string name: string
} }
@@ -88,6 +91,7 @@ export interface UserSettingsUpdate {
export interface HabitGroup { export interface HabitGroup {
id: number id: number
uuid?: string
name: string name: string
color: string color: string
icon: string icon: string
@@ -105,6 +109,7 @@ export type HabitFrequency = 'daily' | 'weekly'
export interface Habit { export interface Habit {
id: number id: number
uuid?: string
name: string name: string
description?: string description?: string
group_id?: number group_id?: number
@@ -128,6 +133,7 @@ export interface HabitFormData {
export interface HabitCheckin { export interface HabitCheckin {
id: number id: number
uuid?: string
habit_id: number habit_id: number
checkin_date: string checkin_date: string
count: number count: number
@@ -146,6 +152,7 @@ export interface HabitStats {
export interface AnniversaryCategory { export interface AnniversaryCategory {
id: number id: number
uuid?: string
name: string name: string
icon: string icon: string
color: string color: string
@@ -161,6 +168,7 @@ export interface AnniversaryCategoryFormData {
export interface Anniversary { export interface Anniversary {
id: number id: number
uuid?: string
title: string title: string
date: string date: string
year?: number | null year?: number | null
@@ -186,93 +194,158 @@ export interface AnniversaryFormData {
remind_days_before: number remind_days_before: number
} }
// ============ 资产账户相关 ============
export type AccountType = 'savings' | 'debt' // ============ 目标相关 ============
export interface FinancialAccount { export type GoalStatus = 'active' | 'paused' | 'completed' | 'abandoned'
export type StepType = 'phase' | 'milestone'
export type StepStatus = 'pending' | 'in_progress' | 'completed'
export type TrackType = 'milestone' | 'cumulative'
export interface Goal {
id: number id: number
uuid?: string
title: string
description?: string | null
status: GoalStatus
track_type: TrackType
progress: number
target_value?: number | null
target_unit?: string | null
input_unit?: string | null
conversion_rate: number
current_value: number
target_date?: string | null
completed_at?: string | null
category_id?: number | null
category?: Category | null
color: string
icon: string
sort_order: number
created_at: string
updated_at: string
total_steps: number
completed_steps: number
}
export interface GoalDetail extends Goal {
steps: GoalStep[]
reviews: GoalReview[]
checkins: GoalCheckin[]
tasks: Task[]
}
export interface GoalStep {
id: number
uuid?: string
goal_id: number
parent_id?: number | null
title: string
step_type: StepType
status: StepStatus
target_date?: string | null
reached_at?: string | null
sort_order: number
created_at: string
children: GoalStep[]
}
export interface GoalReview {
id: number
uuid?: string
goal_id: number
content: string
rating?: number | null
created_at: string
}
export interface GoalFormData {
title: string
description?: string | null
status: GoalStatus
track_type: TrackType
target_value?: number | null
target_unit?: string | null
input_unit?: string | null
conversion_rate: number
target_date?: string | null
category_id?: number | null
color: string
icon: string
sort_order: number
}
export interface GoalStepFormData {
title: string
step_type: StepType
status: StepStatus
target_date?: string | null
parent_id?: number | null
sort_order: number
}
export interface GoalReviewFormData {
content: string
rating?: number | null
}
export interface GoalCheckin {
id: number
uuid?: string
goal_id: number
value: number
note?: string | null
checkin_date: string
created_at: string
}
export interface GoalCheckinFormData {
value: number
note?: string | null
checkin_date: string
}
// ============ 证书相关 ============
export interface CertificateCategory {
id: number
uuid?: string
name: string name: string
account_type: AccountType
balance: number
icon: string icon: string
color: string color: string
sort_order: number sort_order: number
is_active: boolean
description?: string | null
created_at: string
updated_at: string
installments?: InstallmentInfo[]
} }
export interface AccountFormData { export interface CertificateCategoryFormData {
name: string name: string
account_type: AccountType
balance: number
icon: string icon: string
color: string color: string
sort_order: number sort_order?: number
is_active: boolean }
export interface Certificate {
id: number
uuid?: string
title: string
category_id?: number | null
image?: string | null
issuer?: string | null
issue_date?: string | null
expiry_date?: string | null
description?: string | null description?: string | null
} sort_order: number
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 created_at: string
updated_at: string updated_at: string
next_payment_date: string | null category?: CertificateCategory | 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 { export interface CertificateFormData {
account_id: number title: string
total_amount: number category_id?: number | null
total_periods: number image?: string | null
current_period: number issuer?: string | null
payment_day: number issue_date?: string | null
payment_amount: number expiry_date?: string | null
start_date: string description?: string | null
is_completed: boolean sort_order?: number
} }

View File

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

View File

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

View File

@@ -22,9 +22,9 @@ function setView(view: string) {
router.push(`/${view === 'list' ? 'tasks' : view}`) router.push(`/${view === 'list' ? 'tasks' : view}`)
} }
function handleCommand(command: string) { async function handleCommand(command: string) {
if (command === 'logout') { if (command === 'logout') {
authStore.logout() await authStore.logout()
router.push('/login') router.push('/login')
return return
} }
@@ -97,11 +97,19 @@ const currentRouteName = computed(() => route.name as string)
</button> </button>
<button <button
class="nav-item" class="nav-item"
:class="{ active: currentRouteName === 'assets' }" :class="{ active: currentRouteName === 'goals' || currentRouteName === 'goalDetail' }"
@click="router.push('/assets')" @click="router.push('/goals')"
> >
<el-icon><Wallet /></el-icon> <el-icon><Aim /></el-icon>
<span>资产</span> <span>目标</span>
</button>
<button
class="nav-item"
:class="{ active: currentRouteName === 'certificates' }"
@click="router.push('/certificates')"
>
<el-icon><Medal /></el-icon>
<span>证书</span>
</button> </button>
</nav> </nav>

View File

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

View File

@@ -0,0 +1,195 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useCertificateStore } from '@/stores/useCertificateStore'
import type { Certificate, CertificateFormData } from '@/api/types'
const props = defineProps<{
visible: boolean
editingCert?: Certificate | null
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const store = useCertificateStore()
const form = ref<CertificateFormData>({
title: '',
category_id: null,
image: null,
issuer: null,
issue_date: null,
expiry_date: null,
description: null,
})
const isEditing = ref(false)
const saving = ref(false)
watch(() => props.visible, (val) => {
if (val) {
store.fetchCategories()
if (props.editingCert) {
isEditing.value = true
form.value = {
title: props.editingCert.title,
category_id: props.editingCert.category_id ?? null,
image: props.editingCert.image ?? null,
issuer: props.editingCert.issuer ?? null,
issue_date: props.editingCert.issue_date ?? null,
expiry_date: props.editingCert.expiry_date ?? null,
description: props.editingCert.description ?? null,
}
} else {
isEditing.value = false
form.value = {
title: '',
category_id: null,
image: null,
issuer: null,
issue_date: null,
expiry_date: null,
description: null,
}
}
}
})
function handleImageUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
form.value.image = reader.result as string
}
reader.readAsDataURL(file)
}
function removeImage() {
form.value.image = null
}
async function handleSave() {
if (!form.value.title.trim()) return
saving.value = true
try {
if (isEditing.value && props.editingCert) {
await store.updateCertificate(props.editingCert.id, form.value)
} else {
await store.createCertificate(form.value)
}
emit('saved')
} finally {
saving.value = false
}
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="isEditing ? '编辑证书' : '添加证书'"
width="520px"
@close="emit('close')"
destroy-on-close
>
<el-form :model="form" label-position="top">
<el-form-item label="证书名称" required>
<el-input v-model="form.title" maxlength="200" placeholder="例如CET-6 英语六级证书" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="分类">
<el-select v-model="form.category_id" style="width:100%" clearable placeholder="选择分类">
<el-option v-for="cat in store.categories" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="颁发机构">
<el-input v-model="form.issuer" maxlength="200" placeholder="例如:教育部考试中心" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="获取日期">
<el-date-picker v-model="form.issue_date" type="date" placeholder="选择日期" style="width:100%" value-format="YYYY-MM-DD" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="有效期至">
<el-date-picker v-model="form.expiry_date" type="date" placeholder="留空表示永久有效" style="width:100%" value-format="YYYY-MM-DD" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="证书图片">
<div class="image-upload">
<div v-if="form.image" class="image-preview">
<img :src="form.image" alt="证书预览" />
<el-button text type="danger" size="small" class="remove-img-btn" @click="removeImage">移除图片</el-button>
</div>
<label v-else class="upload-area">
<input type="file" accept="image/*" style="display:none" @change="handleImageUpload" />
<el-icon :size="32"><Plus /></el-icon>
<span>上传证书图片</span>
</label>
</div>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="补充说明..." />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="emit('close')">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ isEditing ? '保存' : '添加' }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.image-upload {
width: 100%;
}
.upload-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
height: 120px;
border: 2px dashed #ddd;
border-radius: 8px;
cursor: pointer;
color: #999;
transition: all 0.2s;
&:hover { border-color: var(--primary); color: var(--primary); }
}
.image-preview {
position: relative;
display: inline-block;
img {
max-width: 100%;
max-height: 200px;
border-radius: 8px;
border: 1px solid #eee;
}
.remove-img-btn {
position: absolute;
top: 4px;
right: 4px;
background: rgba(255,255,255,0.9);
}
}
</style>

View File

@@ -0,0 +1,284 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useGoalStore } from '@/stores/useGoalStore'
import { useCategoryStore } from '@/stores/useCategoryStore'
import { useUIStore } from '@/stores/useUIStore'
import type { Goal, GoalFormData } from '@/api/types'
const props = defineProps<{
visible: boolean
editingGoal?: Goal | null
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const goalStore = useGoalStore()
const categoryStore = useCategoryStore()
const uiStore = useUIStore()
const form = ref<GoalFormData>({
title: '',
description: null,
status: 'active',
track_type: 'milestone',
target_value: null,
target_unit: null,
input_unit: null,
conversion_rate: 1.0,
target_date: null,
category_id: null,
color: '#FFB7C5',
icon: 'flag',
sort_order: 0,
})
const isEditing = ref(false)
const saving = ref(false)
watch(() => props.visible, (val) => {
if (val) {
categoryStore.fetchCategories()
if (props.editingGoal) {
isEditing.value = true
form.value = {
title: props.editingGoal.title,
description: props.editingGoal.description ?? null,
status: props.editingGoal.status,
track_type: props.editingGoal.track_type ?? 'milestone',
target_value: props.editingGoal.target_value ?? null,
target_unit: props.editingGoal.target_unit ?? null,
input_unit: props.editingGoal.input_unit ?? null,
conversion_rate: props.editingGoal.conversion_rate ?? 1.0,
target_date: props.editingGoal.target_date ?? null,
category_id: props.editingGoal.category_id ?? null,
color: props.editingGoal.color,
icon: props.editingGoal.icon,
sort_order: props.editingGoal.sort_order,
}
} else {
isEditing.value = false
form.value = {
title: '',
description: null,
status: 'active',
track_type: 'milestone',
target_value: null,
target_unit: null,
input_unit: null,
conversion_rate: 1.0,
target_date: null,
category_id: null,
color: '#FFB7C5',
icon: 'flag',
sort_order: 0,
}
}
}
})
async function handleSave() {
if (!form.value.title.trim()) return
saving.value = true
try {
if (isEditing.value && props.editingGoal) {
await goalStore.updateGoal(props.editingGoal.id, form.value)
} else {
await goalStore.createGoal(form.value)
}
emit('saved')
} finally {
saving.value = false
}
}
const colorOptions = [
'#FFB7C5', '#FF6B81', '#FFA502', '#7BED9F', '#70A1FF', '#5352ED', '#A29BFE', '#FF7979',
]
const iconOptions = ['flag', 'star', 'trophy', 'aim', 'medal', 'magic', 'sunny', 'moon']
</script>
<template>
<el-dialog
:model-value="visible"
:title="isEditing ? '编辑目标' : '新建目标'"
width="520px"
@close="emit('close')"
destroy-on-close
>
<el-form :model="form" label-position="top">
<el-form-item label="目标名称" required>
<el-input v-model="form.title" maxlength="200" placeholder="例如:学好 Rust 开发" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="描述一下这个目标..." />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="状态">
<el-select v-model="form.status" style="width:100%">
<el-option label="进行中" value="active" />
<el-option label="暂停" value="paused" />
<el-option label="已完成" value="completed" />
<el-option label="已放弃" value="abandoned" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标日期">
<el-date-picker v-model="form.target_date" type="date" placeholder="选择日期" style="width:100%" value-format="YYYY-MM-DD" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="追踪方式">
<el-radio-group v-model="form.track_type">
<el-radio value="milestone">里程碑模式</el-radio>
<el-radio value="cumulative">累计打卡模式</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="form.track_type === 'cumulative'">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="目标值">
<el-input-number v-model="form.target_value" :min="0" :precision="1" placeholder="如 600" style="width:100%" controls-position="right" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标单位">
<el-input v-model="form.target_unit" maxlength="20" placeholder="如 g、kg、次" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="打卡单位">
<el-input v-model="form.input_unit" maxlength="20" placeholder="如 kcal、次、km" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="换算率">
<el-input-number v-model="form.conversion_rate" :min="0.01" :precision="2" :step="0.1" style="width:100%" controls-position="right" />
<div class="field-hint">多少打卡单位 = 1 目标单位</div>
</el-form-item>
</el-col>
</el-row>
</template>
<el-row :gutter="16">
<el-col :span="16">
<el-form-item label="分类">
<div class="category-row">
<el-select v-model="form.category_id" style="flex:1" clearable placeholder="无分类">
<el-option v-for="cat in categoryStore.categories" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
<el-button text size="small" class="manage-cat-btn" @click="uiStore.openCategoryDialog()">
<el-icon><Setting /></el-icon>
</el-button>
</div>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="颜色">
<div class="color-picker">
<button
v-for="c in colorOptions" :key="c"
class="color-dot"
:class="{ active: form.color === c }"
:style="{ background: c }"
@click.prevent="form.color = c"
/>
</div>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="图标">
<div class="icon-picker">
<button
v-for="icon in iconOptions" :key="icon"
class="icon-btn"
:class="{ active: form.icon === icon }"
@click.prevent="form.icon = icon"
>
<el-icon :size="20"><component :is="icon" /></el-icon>
</button>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="emit('close')">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ isEditing ? '保存' : '创建' }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.category-row {
display: flex;
align-items: center;
gap: 6px;
}
.manage-cat-btn {
flex-shrink: 0;
color: #999;
&:hover { color: var(--primary); }
}
.field-hint {
font-size: 11px;
color: #bbb;
margin-top: 2px;
}
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
padding-top: 4px;
.color-dot {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.15s;
&:hover { transform: scale(1.15); }
&.active { border-color: #333; transform: scale(1.1); }
}
}
.icon-picker {
display: flex;
gap: 8px;
.icon-btn {
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid #ddd;
background: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
color: #666;
&:hover { border-color: var(--primary); color: var(--primary); }
&.active { background: var(--primary); color: white; border-color: var(--primary); }
}
}
</style>

View File

@@ -1,272 +0,0 @@
<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'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
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"
:locale="zhCn"
placeholder="选择首次还款日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
:clearable="false"
/>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.form-content {
padding: 8px 0;
}
.form-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
}
.form-hint {
font-size: 12px;
color: var(--text-secondary);
}
}
.form-row {
display: flex;
gap: 16px;
.form-col {
flex: 1;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import { useUserSettingsStore } from '@/stores/useUserSettingsStore' import { useUserSettingsStore } from '@/stores/useUserSettingsStore'
import { useAuthStore } from '@/stores/useAuthStore'
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
@@ -9,6 +10,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/LoginView.vue'), component: () => import('@/views/LoginView.vue'),
meta: { title: '登录', noAuth: true } meta: { title: '登录', noAuth: true }
}, },
{
path: '/setup',
name: 'setup',
component: () => import('@/views/SetupView.vue'),
meta: { title: '首次设置', noAuth: true }
},
{ {
path: '/', path: '/',
redirect: '/tasks' redirect: '/tasks'
@@ -49,17 +56,29 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/AnniversaryPage.vue'), component: () => import('@/views/AnniversaryPage.vue'),
meta: { title: '纪念日', view: 'anniversaries' } meta: { title: '纪念日', view: 'anniversaries' }
}, },
{
path: '/assets',
name: 'assets',
component: () => import('@/views/AssetPage.vue'),
meta: { title: '资产总览', view: 'assets' }
},
{ {
path: '/settings', path: '/settings',
name: 'settings', name: 'settings',
component: () => import('@/views/SettingsView.vue'), component: () => import('@/views/SettingsView.vue'),
meta: { title: '偏好设置', view: 'settings' } meta: { title: '偏好设置', view: 'settings' }
},
{
path: '/goals',
name: 'goals',
component: () => import('@/views/GoalPage.vue'),
meta: { title: '目标管理', view: 'goals' }
},
{
path: '/goals/:id',
name: 'goalDetail',
component: () => import('@/views/GoalDetailPage.vue'),
meta: { title: '目标详情', view: 'goals' }
},
{
path: '/certificates',
name: 'certificates',
component: () => import('@/views/CertificatePage.vue'),
meta: { title: '证书管理', view: 'certificates' }
} }
] ]
@@ -74,18 +93,46 @@ const router = createRouter({
} }
}) })
const TOKEN_KEY = 'elysia_auth_token' router.beforeEach(async (to, from) => {
router.beforeEach((to, from) => {
const page = (to.meta.title as string) || '' const page = (to.meta.title as string) || ''
const userStore = useUserSettingsStore() const userStore = useUserSettingsStore()
const siteName = userStore.siteName || '爱莉希雅待办' const siteName = userStore.siteName || '爱莉希雅待办'
document.title = page ? `${page} - ${siteName}` : siteName document.title = page ? `${page} - ${siteName}` : siteName
// 已登录用户访问 setup/login 页面时重定向到主页
if ((to.path === '/setup' || to.path === '/login')) {
const authStore = useAuthStore()
if (authStore.checked && authStore.isLoggedIn && !authStore.needSetup) {
return { path: '/' }
}
if (authStore.checked && authStore.needSetup && to.path === '/login') {
return { path: '/setup' }
}
return
}
if (to.meta.noAuth) return if (to.meta.noAuth) return
const token = localStorage.getItem(TOKEN_KEY) const authStore = useAuthStore()
if (!token) {
// 已知状态直接判断
if (authStore.needSetup) {
return { path: '/setup', query: { redirect: to.fullPath } }
}
if (authStore.checked && !authStore.isLoggedIn) {
return { path: '/login', query: { redirect: to.fullPath } }
}
// 未验证:先检查认证状态
if (!authStore.checked) {
const ok = await authStore.checkAuth()
if (ok) return
// 未认证:检查是否需要先设置密码
const needSetup = await authStore.checkSetup()
if (needSetup) {
return { path: '/setup', query: { redirect: to.fullPath } }
}
return { path: '/login', query: { redirect: to.fullPath } } return { path: '/login', query: { redirect: to.fullPath } }
} }
}) })

View File

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

View File

@@ -1,35 +1,48 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref } from 'vue'
import { login as apiLogin } from '@/api/auth' import { login as apiLogin, logout as apiLogout, checkAuth as apiCheckAuth, checkSetupStatus, setupPassword as apiSetupPassword } from '@/api/auth'
const TOKEN_KEY = 'elysia_auth_token'
function getStoredToken(): string {
return localStorage.getItem(TOKEN_KEY) || ''
}
function setStoredToken(token: string) {
localStorage.setItem(TOKEN_KEY, token)
}
function clearStoredToken() {
localStorage.removeItem(TOKEN_KEY)
}
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const token = ref<string>(getStoredToken()) const isLoggedIn = ref(false)
const checked = ref(false)
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
const needSetup = ref(false)
const setupChecked = ref(false)
const isLoggedIn = computed(() => !!token.value) async function checkAuth(): Promise<boolean> {
try {
const res = await apiCheckAuth()
isLoggedIn.value = res.authenticated
checked.value = true
return isLoggedIn.value
} catch {
isLoggedIn.value = false
checked.value = true
return false
}
}
async function checkSetup(): Promise<boolean> {
try {
const res = await checkSetupStatus()
needSetup.value = !res.has_password
setupChecked.value = true
return needSetup.value
} catch {
needSetup.value = false
setupChecked.value = true
return false
}
}
async function login(password: string): Promise<boolean> { async function login(password: string): Promise<boolean> {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
const res = await apiLogin(password) await apiLogin(password)
token.value = res.access_token isLoggedIn.value = true
setStoredToken(res.access_token) checked.value = true
return true return true
} catch (e: any) { } catch (e: any) {
error.value = e?.response?.data?.detail || '登录失败' error.value = e?.response?.data?.detail || '登录失败'
@@ -39,11 +52,32 @@ export const useAuthStore = defineStore('auth', () => {
} }
} }
function logout() { async function setupPassword(password: string, nickname?: string): Promise<boolean> {
token.value = '' loading.value = true
error.value = '' error.value = ''
clearStoredToken() try {
await apiSetupPassword(password, nickname)
isLoggedIn.value = true
checked.value = true
needSetup.value = false
return true
} catch (e: any) {
error.value = e?.response?.data?.detail || '设置失败'
return false
} finally {
loading.value = false
}
} }
return { token, loading, error, isLoggedIn, login, logout } async function logout() {
try {
await apiLogout()
} finally {
isLoggedIn.value = false
checked.value = true
error.value = ''
}
}
return { isLoggedIn, checked, loading, error, needSetup, setupChecked, login, logout, checkAuth, checkSetup, setupPassword }
}) })

View File

@@ -0,0 +1,105 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Certificate, CertificateCategory, CertificateFormData, CertificateCategoryFormData } from '@/api/types'
import * as certApi from '@/api/certificates'
export const useCertificateStore = defineStore('certificate', () => {
const certificates = ref<Certificate[]>([])
const categories = ref<CertificateCategory[]>([])
const loading = ref(false)
const error = ref('')
async function fetchCertificates(categoryId?: number) {
loading.value = true
error.value = ''
try {
certificates.value = await certApi.getCertificates(categoryId)
} catch (e: any) {
error.value = e?.response?.data?.detail || '获取证书列表失败'
} finally {
loading.value = false
}
}
async function fetchCategories() {
try {
categories.value = await certApi.getCategories()
} catch (e: any) {
error.value = e?.response?.data?.detail || '获取证书分类失败'
}
}
async function createCertificate(data: CertificateFormData): Promise<Certificate | null> {
try {
const cert = await certApi.createCertificate(data)
await fetchCertificates()
return cert
} catch (e: any) {
error.value = e?.response?.data?.detail || '创建证书失败'
return null
}
}
async function updateCertificate(id: number, data: Partial<CertificateFormData>): Promise<Certificate | null> {
try {
const cert = await certApi.updateCertificate(id, data)
await fetchCertificates()
return cert
} catch (e: any) {
error.value = e?.response?.data?.detail || '更新证书失败'
return null
}
}
async function deleteCertificate(id: number): Promise<boolean> {
try {
await certApi.deleteCertificate(id)
await fetchCertificates()
return true
} catch (e: any) {
error.value = e?.response?.data?.detail || '删除证书失败'
return false
}
}
async function createCategory(data: CertificateCategoryFormData): Promise<CertificateCategory | null> {
try {
const cat = await certApi.createCategory(data)
categories.value.push(cat)
return cat
} catch (e: any) {
error.value = e?.response?.data?.detail || '创建证书分类失败'
return null
}
}
async function updateCategory(id: number, data: Partial<CertificateCategoryFormData>): Promise<CertificateCategory | null> {
try {
const cat = await certApi.updateCategory(id, data)
const idx = categories.value.findIndex(c => c.id === id)
if (idx !== -1) categories.value[idx] = cat
return cat
} catch (e: any) {
error.value = e?.response?.data?.detail || '更新证书分类失败'
return null
}
}
async function deleteCategory(id: number): Promise<boolean> {
try {
await certApi.deleteCategory(id)
categories.value = categories.value.filter(c => c.id !== id)
return true
} catch (e: any) {
error.value = e?.response?.data?.detail || '删除证书分类失败'
return false
}
}
return {
certificates, categories, loading, error,
fetchCertificates, fetchCategories,
createCertificate, updateCertificate, deleteCertificate,
createCategory, updateCategory, deleteCategory,
}
})

View File

@@ -0,0 +1,261 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Goal, GoalDetail, GoalStep, GoalReview, GoalCheckin, GoalFormData, GoalStepFormData, GoalReviewFormData, GoalCheckinFormData } from '@/api/types'
import * as goalApi from '@/api/goals'
export const useGoalStore = defineStore('goal', () => {
const goals = ref<Goal[]>([])
const currentGoal = ref<GoalDetail | null>(null)
const loading = ref(false)
const error = ref('')
const activeGoals = computed(() => goals.value.filter(g => g.status === 'active'))
const pausedGoals = computed(() => goals.value.filter(g => g.status === 'paused'))
const completedGoals = computed(() => goals.value.filter(g => g.status === 'completed'))
async function fetchGoals(status?: string) {
loading.value = true
error.value = ''
try {
goals.value = await goalApi.getGoals(status)
} catch (e: any) {
error.value = e?.response?.data?.detail || '获取目标列表失败'
} finally {
loading.value = false
}
}
async function fetchGoal(id: number) {
loading.value = true
error.value = ''
try {
currentGoal.value = await goalApi.getGoal(id)
} catch (e: any) {
error.value = e?.response?.data?.detail || '获取目标详情失败'
} finally {
loading.value = false
}
}
async function createGoal(data: GoalFormData): Promise<GoalDetail | null> {
loading.value = true
error.value = ''
try {
const goal = await goalApi.createGoal(data)
await fetchGoals()
return goal
} catch (e: any) {
error.value = e?.response?.data?.detail || '创建目标失败'
return null
} finally {
loading.value = false
}
}
async function updateGoal(id: number, data: Partial<GoalFormData>): Promise<GoalDetail | null> {
loading.value = true
error.value = ''
try {
const goal = await goalApi.updateGoal(id, data)
if (currentGoal.value?.id === id) {
currentGoal.value = { ...currentGoal.value, ...goal }
}
await fetchGoals()
return goal
} catch (e: any) {
error.value = e?.response?.data?.detail || '更新目标失败'
return null
} finally {
loading.value = false
}
}
async function deleteGoal(id: number): Promise<boolean> {
loading.value = true
error.value = ''
try {
await goalApi.deleteGoal(id)
if (currentGoal.value?.id === id) {
currentGoal.value = null
}
await fetchGoals()
return true
} catch (e: any) {
error.value = e?.response?.data?.detail || '删除目标失败'
return false
} finally {
loading.value = false
}
}
async function updateGoalStatus(id: number, status: string) {
try {
const goal = await goalApi.updateGoalStatus(id, status)
if (currentGoal.value?.id === id) {
currentGoal.value = { ...currentGoal.value, ...goal }
}
await fetchGoals()
} catch (e: any) {
error.value = e?.response?.data?.detail || '更新状态失败'
}
}
// ============ Steps ============
async function createStep(goalId: number, data: GoalStepFormData): Promise<GoalStep | null> {
try {
const step = await goalApi.createStep(goalId, data)
if (currentGoal.value?.id === goalId) {
await fetchGoal(goalId)
}
await fetchGoals()
return step
} catch (e: any) {
error.value = e?.response?.data?.detail || '添加步骤失败'
return null
}
}
async function updateStep(goalId: number, stepId: number, data: Partial<GoalStepFormData>) {
try {
await goalApi.updateStep(goalId, stepId, data)
if (currentGoal.value?.id === goalId) {
await fetchGoal(goalId)
}
await fetchGoals()
} catch (e: any) {
error.value = e?.response?.data?.detail || '更新步骤失败'
}
}
async function deleteStep(goalId: number, stepId: number) {
try {
await goalApi.deleteStep(goalId, stepId)
if (currentGoal.value?.id === goalId) {
await fetchGoal(goalId)
}
await fetchGoals()
} catch (e: any) {
error.value = e?.response?.data?.detail || '删除步骤失败'
}
}
async function toggleStep(goalId: number, stepId: number) {
try {
await goalApi.toggleStep(goalId, stepId)
if (currentGoal.value?.id === goalId) {
await fetchGoal(goalId)
}
await fetchGoals()
} catch (e: any) {
error.value = e?.response?.data?.detail || '切换步骤状态失败'
}
}
// ============ Reviews ============
async function createReview(goalId: number, data: GoalReviewFormData) {
try {
await goalApi.createReview(goalId, data)
if (currentGoal.value?.id === goalId) {
await fetchGoal(goalId)
}
} catch (e: any) {
error.value = e?.response?.data?.detail || '创建复盘失败'
}
}
async function deleteReview(goalId: number, reviewId: number) {
try {
await goalApi.deleteReview(goalId, reviewId)
if (currentGoal.value?.id === goalId) {
await fetchGoal(goalId)
}
} catch (e: any) {
error.value = e?.response?.data?.detail || '删除复盘失败'
}
}
// ============ Task Linking ============
async function linkTask(goalId: number, taskId: number) {
try {
await goalApi.linkTask(goalId, taskId)
if (currentGoal.value?.id === goalId) {
await fetchGoal(goalId)
}
} catch (e: any) {
error.value = e?.response?.data?.detail || '关联任务失败'
}
}
async function unlinkTask(goalId: number, taskId: number) {
try {
await goalApi.unlinkTask(goalId, taskId)
if (currentGoal.value?.id === goalId) {
await fetchGoal(goalId)
}
} catch (e: any) {
error.value = e?.response?.data?.detail || '取消关联失败'
}
}
// ============ Checkins (累计打卡) ============
async function fetchCheckins(goalId: number): Promise<GoalCheckin[]> {
try {
return await goalApi.getCheckins(goalId)
} catch (e: any) {
error.value = e?.response?.data?.detail || '获取打卡记录失败'
return []
}
}
async function createCheckin(goalId: number, data: GoalCheckinFormData): Promise<GoalCheckin | null> {
try {
const checkin = await goalApi.createCheckin(goalId, data)
if (currentGoal.value?.id === goalId) {
await fetchGoal(goalId)
}
await fetchGoals()
return checkin
} catch (e: any) {
error.value = e?.response?.data?.detail || '创建打卡记录失败'
return null
}
}
async function updateCheckin(goalId: number, checkinId: number, data: Partial<GoalCheckinFormData>) {
try {
await goalApi.updateCheckin(goalId, checkinId, data)
if (currentGoal.value?.id === goalId) {
await fetchGoal(goalId)
}
await fetchGoals()
} catch (e: any) {
error.value = e?.response?.data?.detail || '更新打卡记录失败'
}
}
async function deleteCheckin(goalId: number, checkinId: number) {
try {
await goalApi.deleteCheckin(goalId, checkinId)
if (currentGoal.value?.id === goalId) {
await fetchGoal(goalId)
}
await fetchGoals()
} catch (e: any) {
error.value = e?.response?.data?.detail || '删除打卡记录失败'
}
}
return {
goals, currentGoal, loading, error,
activeGoals, pausedGoals, completedGoals,
fetchGoals, fetchGoal, createGoal, updateGoal, deleteGoal, updateGoalStatus,
createStep, updateStep, deleteStep, toggleStep,
createReview, deleteReview,
linkTask, unlinkTask,
fetchCheckins, createCheckin, updateCheckin, deleteCheckin,
}
})

View File

@@ -0,0 +1,147 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { SyncConfig, SyncConfigUpdate, SyncStatus } from '@/api/sync'
import { syncApi } from '@/api/sync'
import { ElMessage, ElMessageBox } from 'element-plus'
export type SyncDirection = 'push' | 'pull' | 'sync'
export const useSyncStore = defineStore('sync', () => {
const config = ref<SyncConfig | null>(null)
const status = ref<SyncStatus | null>(null)
const loading = ref(false)
const syncing = ref(false)
const syncMessage = ref('')
const isConfigured = computed(() => !!config.value?.webdav_url)
async function fetchConfig() {
loading.value = true
try {
config.value = await syncApi.getConfig()
} finally {
loading.value = false
}
}
async function saveConfig(data: SyncConfigUpdate) {
loading.value = true
try {
config.value = await syncApi.updateConfig(data)
ElMessage.success('保存成功')
} catch {
ElMessage.error('保存失败')
} finally {
loading.value = false
}
}
async function testConnection() {
loading.value = true
try {
const result = await syncApi.testConnection()
if (result.success) {
ElMessage.success(result.message)
} else {
ElMessage.error(result.message)
}
return result
} catch {
ElMessage.error('连接测试失败')
return { success: false as const, message: '连接测试失败' }
} finally {
loading.value = false
}
}
async function fetchStatus() {
try {
status.value = await syncApi.getStatus()
syncing.value = status.value.syncing
} catch {
// 静默失败
}
}
async function startSync(direction: SyncDirection) {
if (syncing.value) {
ElMessage.warning('正在同步中,请稍后再试')
return
}
const directionLabel = direction === 'push' ? '推送' : direction === 'pull' ? '拉取' : '双向合并'
if (direction === 'push' || direction === 'pull') {
try {
await ElMessageBox.confirm(
`${direction === 'push' ? '推送' : '拉取'}操作会覆盖${direction === 'push' ? '远端' : '本地'}数据,确定继续吗?`,
'警告',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
}
syncing.value = true
syncMessage.value = `正在${directionLabel}...`
try {
let result
switch (direction) {
case 'push':
result = await syncApi.push()
break
case 'pull':
result = await syncApi.pull()
break
case 'sync':
result = await syncApi.sync()
break
}
if (result.success) {
ElMessage.success(result.message)
} else {
ElMessage.error(result.message)
}
} catch {
ElMessage.error(`${directionLabel}失败`)
} finally {
syncing.value = false
syncMessage.value = ''
await fetchStatus()
}
}
async function clearRemote() {
try {
await ElMessageBox.confirm(
'确定要清空远端所有同步数据吗?此操作不可恢复!',
'危险操作',
{ confirmButtonText: '确定清空', cancelButtonText: '取消', type: 'error' }
)
const result = await syncApi.clearRemote()
if (result.success) {
ElMessage.success(result.message)
} else {
ElMessage.error(result.message)
}
} catch {
// 取消操作
}
}
return {
config,
status,
loading,
syncing,
syncMessage,
isConfigured,
fetchConfig,
saveConfig,
testConnection,
fetchStatus,
startSync,
clearRemote,
}
})

View File

@@ -12,7 +12,7 @@ export const useUIStore = defineStore('ui', () => {
const editingCategory = ref<Category | null>(null) const editingCategory = ref<Category | null>(null)
const sidebarCollapsed = ref(false) const sidebarCollapsed = ref(false)
const globalLoading = ref(false) const globalLoading = ref(false)
const currentView = ref<'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries' | 'assets'>('list') const currentView = ref<'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries'>('list')
const calendarMode = ref<'week' | 'monthly'>('monthly') const calendarMode = ref<'week' | 'monthly'>('monthly')
function openTaskDialog(task?: Task) { function openTaskDialog(task?: Task) {
@@ -43,7 +43,7 @@ export const useUIStore = defineStore('ui', () => {
globalLoading.value = loading globalLoading.value = loading
} }
function setCurrentView(view: 'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries' | 'assets') { function setCurrentView(view: 'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries') {
currentView.value = view currentView.value = view
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,314 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { Plus, Edit, Delete, Medal } from '@element-plus/icons-vue'
import { useCertificateStore } from '@/stores/useCertificateStore'
import CertificateDialog from '@/components/CertificateDialog.vue'
import type { Certificate, CertificateCategory } from '@/api/types'
import { ElMessageBox, ElMessage } from 'element-plus'
const store = useCertificateStore()
const dialogVisible = ref(false)
const editingCert = ref<Certificate | null>(null)
const selectedCategoryId = ref<number | undefined>()
const catDialogVisible = ref(false)
const editingCat = ref<CertificateCategory | null>(null)
const catForm = ref({ name: '', color: '#FFB7C5', icon: 'medal' })
onMounted(async () => {
await store.fetchCategories()
await store.fetchCertificates()
})
function openCreate() {
editingCert.value = null
dialogVisible.value = true
}
function openEdit(cert: Certificate) {
editingCert.value = cert
dialogVisible.value = true
}
async function handleDelete(cert: Certificate) {
try {
await ElMessageBox.confirm(`确定要删除证书「${cert.title}」吗?`, '删除确认', {
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消',
})
await store.deleteCertificate(cert.id)
ElMessage.success('证书已删除')
} catch {}
}
function onSaved() {
dialogVisible.value = false
}
function filterByCategory(catId?: number) {
selectedCategoryId.value = catId
store.fetchCertificates(catId)
}
function openCatDialog(cat?: CertificateCategory) {
editingCat.value = cat || null
if (cat) {
catForm.value = { name: cat.name, color: cat.color, icon: cat.icon }
} else {
catForm.value = { name: '', color: '#FFB7C5', icon: 'medal' }
}
catDialogVisible.value = true
}
async function handleCatSave() {
if (!catForm.value.name.trim()) return
if (editingCat.value) {
await store.updateCategory(editingCat.value.id, catForm.value)
} else {
await store.createCategory(catForm.value)
}
catDialogVisible.value = false
}
async function handleCatDelete() {
if (!editingCat.value) return
try {
await ElMessageBox.confirm(`确定要删除分类「${editingCat.value.name}」吗?`, '删除确认', {
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消',
})
await store.deleteCategory(editingCat.value.id)
catDialogVisible.value = false
} catch {}
}
const isExpired = (cert: Certificate): boolean => {
if (!cert.expiry_date) return false
return new Date(cert.expiry_date) < new Date()
}
</script>
<template>
<div class="cert-page">
<div class="page-header">
<h2>我的证书</h2>
<el-button type="primary" :icon="Plus" @click="openCreate">添加证书</el-button>
</div>
<div class="cert-layout">
<aside class="cert-sidebar">
<div class="sidebar-section">
<div class="section-header">
<h3>分类</h3>
<el-button text size="small" @click="openCatDialog()">
<el-icon><Plus /></el-icon>
</el-button>
</div>
<ul class="cat-list">
<li
:class="{ active: selectedCategoryId === undefined }"
@click="filterByCategory()"
>全部</li>
<li
v-for="cat in store.categories" :key="cat.id"
:class="{ active: selectedCategoryId === cat.id }"
@click="filterByCategory(cat.id)"
>
<span class="cat-dot" :style="{ background: cat.color }"></span>
{{ cat.name }}
<span class="cat-actions">
<el-button text size="small" @click.stop="openCatDialog(cat)"><el-icon><Edit /></el-icon></el-button>
</span>
</li>
</ul>
</div>
</aside>
<div class="cert-grid">
<div v-if="store.loading" class="loading-state">加载中...</div>
<div v-else-if="store.certificates.length === 0" class="empty-state">
<el-icon :size="64" color="#ccc"><Medallion /></el-icon>
<p>还没有证书</p>
<el-button type="primary" @click="openCreate">添加第一个证书</el-button>
</div>
<div v-for="cert in store.certificates" :key="cert.id" class="cert-card" :class="{ expired: isExpired(cert) }">
<div class="card-image" @click="openEdit(cert)">
<img v-if="cert.image" :src="cert.image" alt="" />
<el-icon v-else :size="48" color="#ddd"><Medallion /></el-icon>
</div>
<div class="card-body">
<h4 class="card-title">{{ cert.title }}</h4>
<div class="card-meta">
<span v-if="cert.issuer" class="meta-item">
<el-icon :size="14"><OfficeBuilding /></el-icon>
{{ cert.issuer }}
</span>
<span v-if="cert.issue_date" class="meta-item">
<el-icon :size="14"><Calendar /></el-icon>
{{ cert.issue_date }}
</span>
</div>
<div class="card-footer">
<el-tag v-if="cert.expiry_date" :type="isExpired(cert) ? 'danger' : 'success'" size="small">
{{ isExpired(cert) ? '已过期' : '有效期至 ' + cert.expiry_date }}
</el-tag>
<el-tag v-else type="info" size="small">永久有效</el-tag>
<span v-if="cert.category" class="cat-badge" :style="{ color: cert.category.color }">
{{ cert.category.name }}
</span>
<div class="card-actions">
<el-button text size="small" @click="openEdit(cert)"><el-icon><Edit /></el-icon></el-button>
<el-button text size="small" type="danger" @click="handleDelete(cert)"><el-icon><Delete /></el-icon></el-button>
</div>
</div>
</div>
</div>
</div>
</div>
<CertificateDialog :visible="dialogVisible" :editing-cert="editingCert" @close="dialogVisible = false" @saved="onSaved" />
<!-- Category Dialog -->
<el-dialog v-model="catDialogVisible" :title="editingCat ? '编辑分类' : '新建分类'" width="400px" destroy-on-close>
<el-form :model="catForm" label-position="top">
<el-form-item label="分类名称" required>
<el-input v-model="catForm.name" maxlength="50" placeholder="例如:比赛证书" />
</el-form-item>
<el-form-item label="颜色">
<div class="color-row">
<button v-for="c in ['#FFB7C5','#FF6B81','#FFA502','#7BED9F','#70A1FF','#5352ED','#A29BFE','#FF7979']" :key="c"
class="color-dot" :class="{ active: catForm.color === c }" :style="{ background: c }" @click.prevent="catForm.color = c" />
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button v-if="editingCat" type="danger" text @click="handleCatDelete">删除分类</el-button>
<el-button @click="catDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleCatSave">{{ editingCat ? '保存' : '创建' }}</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.cert-page {
padding: 24px;
max-width: 1100px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 { margin: 0; font-size: 22px; }
}
.cert-layout {
display: flex;
gap: 24px;
}
.cert-sidebar {
width: 180px;
flex-shrink: 0;
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
h3 { margin: 0; font-size: 13px; color: #999; text-transform: uppercase; }
}
.cat-list {
list-style: none;
padding: 0;
li {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
color: #666;
transition: all 0.15s;
&:hover { background: #f5f5f5; }
&.active { background: #fef0f5; color: var(--primary); font-weight: 500; }
.cat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.cat-actions { margin-left: auto; opacity: 0; }
&:hover .cat-actions { opacity: 1; }
}
}
}
.cert-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
align-content: start;
}
.cert-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
transition: transform 0.15s, box-shadow 0.15s;
&.expired { opacity: 0.7; }
&:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.card-image {
height: 160px;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
cursor: pointer;
overflow: hidden;
img { width: 100%; height: 100%; object-fit: cover; }
}
.card-body {
padding: 14px;
.card-title { margin: 0 0 8px; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-meta {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
.meta-item { font-size: 12px; color: #999; display: flex; align-items: center; gap: 4px; }
}
.card-footer {
display: flex;
align-items: center;
gap: 8px;
.cat-badge { font-size: 12px; margin-left: auto; }
.card-actions { display: flex; gap: 2px; opacity: 0; }
}
&:hover .card-actions { opacity: 1; }
}
}
.loading-state, .empty-state {
text-align: center;
padding: 80px 0;
color: #999;
grid-column: 1 / -1;
}
.color-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
.color-dot {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
&:hover { transform: scale(1.15); }
&.active { border-color: #333; }
}
}
</style>

View File

@@ -0,0 +1,775 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Plus, Delete } from '@element-plus/icons-vue'
import { useGoalStore } from '@/stores/useGoalStore'
import { useTaskStore } from '@/stores/useTaskStore'
import { reorderSteps } from '@/api/goals'
import GoalDialog from '@/components/GoalDialog.vue'
import type { Goal, GoalStep, GoalDetail } from '@/api/types'
import { ElMessageBox, ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const goalStore = useGoalStore()
const taskStore = useTaskStore()
const goalId = computed(() => Number(route.params.id))
const stepFormVisible = ref(false)
const stepParentId = ref<number | null>(null)
const stepTitle = ref('')
const stepType = ref<'phase' | 'milestone'>('milestone')
const reviewFormVisible = ref(false)
const reviewContent = ref('')
const reviewRating = ref<number | null>(null)
const checkinFormVisible = ref(false)
const checkinValue = ref<number>(0)
const checkinNote = ref('')
const checkinDate = ref(new Date().toISOString().slice(0, 10))
const editDialogVisible = ref(false)
const linkTaskVisible = ref(false)
const selectedTaskId = ref<number | null>(null)
onMounted(async () => {
await goalStore.fetchGoal(goalId.value)
await taskStore.fetchTasks()
})
function goBack() {
router.push('/goals')
}
const stepLabel: Record<string, string> = {
pending: '待开始',
in_progress: '进行中',
completed: '已完成',
}
function hasPhaseParent(step: GoalStep): boolean {
if (!step.parent_id) return false
return goalStore.currentGoal?.steps.some(
(s: GoalStep) => s.id === step.parent_id && s.step_type === 'phase'
) ?? false
}
const phaseParentIds = computed(() => {
if (!goalStore.currentGoal) return new Set<number>()
return new Set(
goalStore.currentGoal.steps
.filter((s: GoalStep) => s.step_type === 'phase')
.map((s: GoalStep) => s.id)
)
})
const stepColor: Record<string, string> = {
pending: '#909399',
in_progress: '#E6A23C',
completed: '#67C23A',
}
async function handleToggleStep(stepId: number) {
await goalStore.toggleStep(goalId.value, stepId)
}
async function handleDeleteStep(stepId: number) {
try {
await ElMessageBox.confirm('确定删除此步骤吗?', '删除确认', { type: 'warning' })
await goalStore.deleteStep(goalId.value, stepId)
} catch {}
}
async function handleAddStep() {
if (!stepTitle.value.trim()) return
await goalStore.createStep(goalId.value, {
title: stepTitle.value,
step_type: stepType.value,
status: 'pending',
parent_id: stepParentId.value,
})
stepTitle.value = ''
stepParentId.value = null
stepFormVisible.value = false
}
async function handleAddReview() {
if (!reviewContent.value.trim()) return
await goalStore.createReview(goalId.value, {
content: reviewContent.value,
rating: reviewRating.value,
})
reviewContent.value = ''
reviewRating.value = null
reviewFormVisible.value = false
}
async function handleDeleteReview(reviewId: number) {
try {
await ElMessageBox.confirm('确定删除此复盘记录吗?', '删除确认', { type: 'warning' })
await goalStore.deleteReview(goalId.value, reviewId)
} catch {}
}
// ============ Checkin handlers ============
async function handleAddCheckin() {
if (checkinValue.value <= 0) return
await goalStore.createCheckin(goalId.value, {
value: checkinValue.value,
note: checkinNote.value || null,
checkin_date: checkinDate.value,
})
checkinValue.value = 0
checkinNote.value = ''
checkinDate.value = new Date().toISOString().slice(0, 10)
checkinFormVisible.value = false
}
async function handleDeleteCheckin(checkinId: number) {
try {
await ElMessageBox.confirm('确定删除此打卡记录吗?', '删除确认', { type: 'warning' })
await goalStore.deleteCheckin(goalId.value, checkinId)
} catch {}
}
const currentGoalCheckins = computed(() => {
return goalStore.currentGoal?.checkins ?? []
})
function formatCheckinProgress(goal: GoalDetail): string {
if (!goal.target_value) return ''
const rate = goal.conversion_rate || 1
const progressInTarget = goal.current_value / rate
return `${progressInTarget.toFixed(1)} / ${goal.target_value} ${goal.target_unit || ''}`
}
async function handleLinkTask() {
if (!selectedTaskId.value) return
await goalStore.linkTask(goalId.value, selectedTaskId.value)
selectedTaskId.value = null
linkTaskVisible.value = false
}
async function handleUnlinkTask(taskId: number) {
await goalStore.unlinkTask(goalId.value, taskId)
}
const editableGoal = computed(() => goalStore.currentGoal ? {
id: goalStore.currentGoal.id,
title: goalStore.currentGoal.title,
description: goalStore.currentGoal.description ?? null,
status: goalStore.currentGoal.status,
track_type: goalStore.currentGoal.track_type ?? 'milestone',
target_value: goalStore.currentGoal.target_value ?? null,
target_unit: goalStore.currentGoal.target_unit ?? null,
input_unit: goalStore.currentGoal.input_unit ?? null,
conversion_rate: goalStore.currentGoal.conversion_rate ?? 1.0,
target_date: goalStore.currentGoal.target_date ?? null,
category_id: goalStore.currentGoal.category_id ?? null,
color: goalStore.currentGoal.color,
icon: goalStore.currentGoal.icon,
sort_order: goalStore.currentGoal.sort_order,
} as Goal : null)
const unlinkedTasks = computed(() => {
if (!goalStore.currentGoal) return []
const linkedIds = new Set(goalStore.currentGoal.tasks.map(t => t.id))
return taskStore.activeTasks.filter(t => !linkedIds.has(t.id))
})
// ============ Drag & Drop ============
interface DragItem {
id: number
step_type: string
parent_id: number | null
}
const dragItem = ref<DragItem | null>(null)
const dragOverId = ref<number | null>(null)
function onDragStart(step: GoalStep) {
dragItem.value = { id: step.id, step_type: step.step_type, parent_id: step.parent_id }
}
function onDragOver(e: DragEvent, step: GoalStep) {
e.preventDefault()
if (dragItem.value && dragItem.value.id !== step.id) {
dragOverId.value = step.id
}
}
function onDragLeave() {
dragOverId.value = null
}
async function onDrop(targetStep: GoalStep) {
dragOverId.value = null
if (!dragItem.value || dragItem.value.id === targetStep.id) return
if (!goalStore.currentGoal) return
const src = dragItem.value
const currentGoal = goalStore.currentGoal
// Only allow reorder within the same parent scope
if (src.parent_id !== targetStep.parent_id) {
dragItem.value = null
return
}
const siblingSteps = currentGoal.steps.filter(
(s: GoalStep) => s.parent_id === targetStep.parent_id && s.step_type === targetStep.step_type
)
const children = targetStep.parent_id
? (currentGoal.steps.find((s: GoalStep) => s.id === targetStep.parent_id)?.children || [])
: []
// Build ordered list: remove dragged item, insert at target position
const ordered = siblingSteps.filter((s: GoalStep) => s.id !== src.id)
const targetIdx = ordered.findIndex((s: GoalStep) => s.id === targetStep.id)
ordered.splice(targetIdx, 0, currentGoal.steps.find((s: GoalStep) => s.id === src.id)!)
// Assign new sort_order values
const items = ordered.map((s: GoalStep, i: number) => ({ id: s.id, sort_order: i }))
// Optimistic local update
for (const item of items) {
const step = currentGoal.steps.find((s: GoalStep) => s.id === item.id)
if (step) (step as any).sort_order = item.sort_order
}
// Persist to backend
try {
await reorderSteps(currentGoal.id, items)
} catch {
ElMessage.error('排序更新失败')
await goalStore.fetchGoal(currentGoal.id)
}
dragItem.value = null
}
function onDragEnd() {
dragItem.value = null
dragOverId.value = null
}
const statusLabel: Record<string, string> = {
active: '进行中', paused: '已暂停', completed: '已完成', abandoned: '已放弃',
}
</script>
<template>
<div class="goal-detail" v-if="goalStore.currentGoal">
<div class="detail-header">
<el-button text :icon="ArrowLeft" @click="goBack">返回</el-button>
<div class="header-actions">
<el-button @click="editDialogVisible = true">编辑目标</el-button>
</div>
</div>
<div class="goal-hero" :style="{ borderColor: goalStore.currentGoal.color }">
<div class="hero-left">
<el-icon :size="36" :color="goalStore.currentGoal.color">
<component :is="goalStore.currentGoal.icon" />
</el-icon>
<div>
<h1>{{ goalStore.currentGoal.title }}</h1>
<p v-if="goalStore.currentGoal.description" class="hero-desc">{{ goalStore.currentGoal.description }}</p>
</div>
</div>
<div class="hero-right">
<div class="big-progress">{{ goalStore.currentGoal.progress }}%</div>
<el-progress
:percentage="goalStore.currentGoal.progress"
:color="goalStore.currentGoal.color"
:stroke-width="10"
:show-text="false"
style="width:140px"
/>
</div>
</div>
<div class="detail-meta">
<el-tag>{{ statusLabel[goalStore.currentGoal.status] || goalStore.currentGoal.status }}</el-tag>
<span v-if="goalStore.currentGoal.track_type === 'cumulative' && goalStore.currentGoal.target_value">
{{ formatCheckinProgress(goalStore.currentGoal) }}
</span>
<span v-if="goalStore.currentGoal.target_date">目标日期: {{ goalStore.currentGoal.target_date }}</span>
<span v-if="goalStore.currentGoal.category">
<el-icon :size="14"><Folder /></el-icon>
{{ goalStore.currentGoal.category.name }}
</span>
<span v-if="goalStore.currentGoal.track_type !== 'cumulative'">{{ goalStore.currentGoal.completed_steps }}/{{ goalStore.currentGoal.total_steps }} 里程碑完成</span>
</div>
<div class="detail-body">
<!-- 累计打卡模式 -->
<section v-if="goalStore.currentGoal.track_type === 'cumulative'" class="checkins-section">
<div class="section-header">
<h3>每日打卡</h3>
<el-button size="small" :icon="Plus" @click="checkinFormVisible = true">记录打卡</el-button>
</div>
<div v-if="checkinFormVisible" class="checkin-form">
<el-row :gutter="12">
<el-col :span="8">
<el-form-item :label="`数值 (${goalStore.currentGoal.input_unit || '次'})`">
<el-input-number v-model="checkinValue" :min="0.01" :precision="1" style="width:100%" controls-position="right" @keyup.enter="handleAddCheckin" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="日期">
<el-date-picker v-model="checkinDate" type="date" style="width:100%" value-format="YYYY-MM-DD" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="备注">
<el-input v-model="checkinNote" placeholder="可选" maxlength="200" @keyup.enter="handleAddCheckin" />
</el-form-item>
</el-col>
</el-row>
<div class="checkin-form-actions">
<el-button size="small" type="primary" @click="handleAddCheckin" :disabled="checkinValue <= 0">提交</el-button>
<el-button size="small" @click="checkinFormVisible = false">取消</el-button>
</div>
</div>
<div v-if="goalStore.currentGoal.target_value" class="checkin-summary">
<div class="summary-item">
<span class="summary-label">累计</span>
<span class="summary-value">{{ goalStore.currentGoal.current_value.toFixed(1) }} {{ goalStore.currentGoal.input_unit || '' }}</span>
</div>
<div class="summary-item">
<span class="summary-label">折算</span>
<span class="summary-value">{{ (goalStore.currentGoal.current_value / (goalStore.currentGoal.conversion_rate || 1)).toFixed(1) }} {{ goalStore.currentGoal.target_unit || '' }}</span>
</div>
<div class="summary-item">
<span class="summary-label">目标</span>
<span class="summary-value">{{ goalStore.currentGoal.target_value }} {{ goalStore.currentGoal.target_unit || '' }}</span>
</div>
</div>
<div v-if="currentGoalCheckins.length === 0" class="empty-hint">还没有打卡记录点击上方按钮开始记录</div>
<div v-for="checkin in currentGoalCheckins" :key="checkin.id" class="checkin-row">
<div class="checkin-left">
<span class="checkin-value">+{{ checkin.value }} {{ goalStore.currentGoal.input_unit || '' }}</span>
<span v-if="checkin.note" class="checkin-note">{{ checkin.note }}</span>
</div>
<div class="checkin-right">
<span class="checkin-date">{{ checkin.checkin_date }}</span>
<el-button text size="small" type="danger" :icon="Delete" @click="handleDeleteCheckin(checkin.id)" />
</div>
</div>
</section>
<!-- 里程碑模式阶段 & 里程碑 -->
<section v-if="goalStore.currentGoal.track_type !== 'cumulative'" class="steps-section">
<div class="section-header">
<h3>阶段 & 里程碑</h3>
<el-button size="small" :icon="Plus" @click="stepFormVisible = true; stepType = 'milestone'">添加里程碑</el-button>
<el-button size="small" :icon="Plus" @click="stepFormVisible = true; stepType = 'phase'">添加阶段</el-button>
</div>
<div v-if="stepFormVisible" class="step-form">
<el-input v-model="stepTitle" placeholder="步骤名称" size="small" style="flex:1" @keyup.enter="handleAddStep" />
<el-select v-model="stepType" size="small" style="width:100px">
<el-option label="里程碑" value="milestone" />
<el-option label="阶段" value="phase" />
</el-select>
<el-select v-if="stepType === 'milestone'" v-model="stepParentId" size="small" style="width:140px" clearable placeholder="所属阶段(可选)">
<el-option
v-for="s in goalStore.currentGoal.steps.filter((s: GoalStep) => s.step_type === 'phase')"
:key="s.id" :label="s.title" :value="s.id"
/>
</el-select>
<el-button size="small" type="primary" @click="handleAddStep">添加</el-button>
<el-button size="small" @click="stepFormVisible = false">取消</el-button>
</div>
<div v-if="goalStore.currentGoal.steps.length === 0" class="empty-hint">还没有阶段或里程碑点击上方按钮添加</div>
<div v-for="step in goalStore.currentGoal.steps" :key="step.id" class="step-tree">
<!-- Phase -->
<div v-if="step.step_type === 'phase'" class="phase-node">
<div
class="step-row"
:class="[step.status, { 'drag-over': dragOverId === step.id, 'dragging': dragItem?.id === step.id }]"
draggable="true"
@dragstart="onDragStart(step)"
@dragover="onDragOver($event, step)"
@dragleave="onDragLeave"
@drop="onDrop(step)"
@dragend="onDragEnd"
>
<el-button
text
class="toggle-btn"
:style="{ color: stepColor[step.status] }"
@click="handleToggleStep(step.id)"
>
<el-icon :size="18">
<CircleCheck v-if="step.status === 'completed'" />
<Loading v-else-if="step.status === 'in_progress'" />
<CirclePlus v-else />
</el-icon>
</el-button>
<span class="step-title">{{ step.title }}</span>
<el-tag size="small" :type="step.status === 'completed' ? 'success' : step.status === 'in_progress' ? 'warning' : 'info'">
{{ stepLabel[step.status] }}
</el-tag>
<span v-if="step.target_date" class="step-date">{{ step.target_date }}</span>
<el-button text size="small" type="danger" :icon="Delete" @click="handleDeleteStep(step.id)" />
</div>
<!-- child milestones -->
<div v-for="child in step.children" :key="child.id" class="child-milestone">
<div
class="step-row"
:class="[child.status, { 'drag-over': dragOverId === child.id, 'dragging': dragItem?.id === child.id }]"
draggable="true"
@dragstart="onDragStart(child)"
@dragover="onDragOver($event, child)"
@dragleave="onDragLeave"
@drop="onDrop(child)"
@dragend="onDragEnd"
>
<el-button
text
class="toggle-btn"
:style="{ color: stepColor[child.status] }"
@click="handleToggleStep(child.id)"
>
<el-icon :size="16">
<CircleCheck v-if="child.status === 'completed'" />
<Loading v-else-if="child.status === 'in_progress'" />
<CirclePlus v-else />
</el-icon>
</el-button>
<span class="step-title">{{ child.title }}</span>
<el-tag size="small" :type="child.status === 'completed' ? 'success' : child.status === 'in_progress' ? 'warning' : 'info'">
{{ stepLabel[child.status] }}
</el-tag>
<span v-if="child.target_date" class="step-date">{{ child.target_date }}</span>
<el-button text size="small" type="danger" :icon="Delete" @click="handleDeleteStep(child.id)" />
</div>
</div>
</div>
<!-- Standalone milestone (no parent phase) -->
<div
v-else-if="!hasPhaseParent(step)"
class="step-row standalone"
:class="[step.status, { 'drag-over': dragOverId === step.id, 'dragging': dragItem?.id === step.id }]"
draggable="true"
@dragstart="onDragStart(step)"
@dragover="onDragOver($event, step)"
@dragleave="onDragLeave"
@drop="onDrop(step)"
@dragend="onDragEnd"
>
<el-button
text
class="toggle-btn"
:style="{ color: stepColor[step.status] }"
@click="handleToggleStep(step.id)"
>
<el-icon :size="18">
<CircleCheck v-if="step.status === 'completed'" />
<Loading v-else-if="step.status === 'in_progress'" />
<CirclePlus v-else />
</el-icon>
</el-button>
<span class="step-title">{{ step.title }}</span>
<el-tag size="small" type="info" effect="plain">里程碑</el-tag>
<el-tag size="small" :type="step.status === 'completed' ? 'success' : step.status === 'in_progress' ? 'warning' : 'info'">
{{ stepLabel[step.status] }}
</el-tag>
<span v-if="step.target_date" class="step-date">{{ step.target_date }}</span>
<el-button text size="small" type="danger" :icon="Delete" @click="handleDeleteStep(step.id)" />
</div>
</div>
</section>
<!-- 关联任务 -->
<section class="tasks-section">
<div class="section-header">
<h3>关联任务</h3>
<el-button size="small" :icon="Plus" @click="linkTaskVisible = true">关联任务</el-button>
</div>
<div v-if="linkTaskVisible" class="link-form">
<el-select v-model="selectedTaskId" placeholder="选择要关联的任务" size="small" style="flex:1" filterable>
<el-option v-for="t in unlinkedTasks" :key="t.id" :label="t.title" :value="t.id" />
</el-select>
<el-button size="small" type="primary" @click="handleLinkTask" :disabled="!selectedTaskId">关联</el-button>
<el-button size="small" @click="linkTaskVisible = false">取消</el-button>
</div>
<div v-if="goalStore.currentGoal.tasks.length === 0" class="empty-hint">暂无关联任务</div>
<div v-for="task in goalStore.currentGoal.tasks" :key="task.id" class="linked-task">
<span class="task-title">{{ task.title }}</span>
<span v-if="task.is_completed" class="done-badge">已完成</span>
<span v-else class="pending-badge">进行中</span>
<el-button text size="small" type="danger" @click="handleUnlinkTask(task.id)">取消关联</el-button>
</div>
</section>
<!-- 复盘记录 -->
<section class="reviews-section">
<div class="section-header">
<h3>复盘记录</h3>
<el-button size="small" :icon="Plus" @click="reviewFormVisible = true">添加复盘</el-button>
</div>
<div v-if="reviewFormVisible" class="review-form">
<el-input
v-model="reviewContent"
type="textarea"
:rows="3"
placeholder="记录本次复盘的思考和收获..."
/>
<div class="review-form-actions">
<span>自评:</span>
<el-rate v-model="reviewRating" :max="5" show-score />
<el-button size="small" type="primary" @click="handleAddReview" :disabled="!reviewContent.trim()">提交</el-button>
<el-button size="small" @click="reviewFormVisible = false">取消</el-button>
</div>
</div>
<div v-if="goalStore.currentGoal.reviews.length === 0" class="empty-hint">暂无复盘记录</div>
<div v-for="review in goalStore.currentGoal.reviews" :key="review.id" class="review-card">
<div class="review-meta">
<span class="review-date">{{ review.created_at.slice(0, 10) }}</span>
<el-rate v-if="review.rating" :model-value="review.rating" :max="5" disabled show-score size="small" />
<el-button text size="small" type="danger" :icon="Delete" @click="handleDeleteReview(review.id)" />
</div>
<p class="review-content">{{ review.content }}</p>
</div>
</section>
</div>
<GoalDialog
:visible="editDialogVisible"
:editing-goal="editableGoal"
@close="editDialogVisible = false"
@saved="editDialogVisible = false"
/>
</div>
<div v-else-if="goalStore.loading" class="loading-state">
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
</div>
</template>
<style scoped lang="scss">
.goal-detail {
max-width: 900px;
margin: 0 auto;
padding: 24px;
}
.detail-header {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
.goal-hero {
display: flex;
justify-content: space-between;
align-items: center;
background: white;
border: 1px solid #eee;
border-left: 5px solid #FFB7C5;
border-radius: 12px;
padding: 24px;
margin-bottom: 16px;
.hero-left {
display: flex;
align-items: center;
gap: 16px;
h1 { margin: 0; font-size: 22px; }
.hero-desc { margin: 6px 0 0; color: #999; font-size: 14px; }
}
.hero-right {
text-align: center;
.big-progress { font-size: 32px; font-weight: 800; color: #333; margin-bottom: 8px; }
}
}
.detail-meta {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 28px;
font-size: 13px;
color: #999;
}
.detail-body {
display: flex;
flex-direction: column;
gap: 28px;
}
section {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
}
.section-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
h3 { margin: 0; font-size: 16px; flex:1; }
}
.empty-hint {
color: #ccc;
font-size: 13px;
padding: 16px 0;
text-align: center;
}
// Steps
.step-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 4px;
border-radius: 6px;
transition: background 0.15s, opacity 0.15s;
cursor: grab;
&:hover { background: #fafafa; }
&:active { cursor: grabbing; }
&.dragging { opacity: 0.4; }
&.drag-over { background: #e6f7ff; border: 1px dashed #69b1ff; }
.toggle-btn { padding: 4px; flex-shrink: 0; cursor: pointer; }
.step-title { flex: 1; font-size: 14px; }
.step-date { font-size: 12px; color: #bbb; }
}
.phase-node {
margin-bottom: 8px;
.phase-node { padding-left: 32px; }
}
.child-milestone {
padding-left: 32px;
}
.standalone {
padding: 8px 4px;
}
.step-form {
display: flex;
gap: 8px;
margin-bottom: 12px;
align-items: center;
}
// Reviews
.review-form {
margin-bottom: 16px;
.review-form-actions {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
}
.review-card {
border-bottom: 1px solid #f0f0f0;
padding: 12px 0;
&:last-child { border-bottom: none; }
.review-meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
.review-date { font-size: 12px; color: #999; }
}
.review-content { font-size: 14px; line-height: 1.6; color: #555; margin: 0; }
}
// Tasks
.link-form {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.linked-task {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 4px;
.task-title { flex: 1; font-size: 14px; }
.done-badge { font-size: 12px; color: #67C23A; }
.pending-badge { font-size: 12px; color: #E6A23C; }
}
.loading-state { text-align: center; padding: 80px; }
// Checkins (累计打卡)
.checkin-form {
margin-bottom: 16px;
padding: 12px;
background: #fafafa;
border-radius: 8px;
.checkin-form-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
}
.checkin-summary {
display: flex;
gap: 24px;
padding: 12px 16px;
margin-bottom: 16px;
background: linear-gradient(135deg, #f5f7fa, #e8ecf1);
border-radius: 8px;
.summary-item {
display: flex;
flex-direction: column;
.summary-label { font-size: 12px; color: #999; }
.summary-value { font-size: 16px; font-weight: 700; color: #333; }
}
}
.checkin-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 4px;
border-bottom: 1px solid #f0f0f0;
&:last-child { border-bottom: none; }
.checkin-left {
display: flex;
align-items: center;
gap: 12px;
.checkin-value { font-size: 16px; font-weight: 600; color: #67C23A; }
.checkin-note { font-size: 13px; color: #999; }
}
.checkin-right {
display: flex;
align-items: center;
gap: 8px;
.checkin-date { font-size: 12px; color: #bbb; }
}
}
</style>

View File

@@ -0,0 +1,249 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Plus } from '@element-plus/icons-vue'
import { useGoalStore } from '@/stores/useGoalStore'
import GoalDialog from '@/components/GoalDialog.vue'
import type { Goal } from '@/api/types'
import { ElMessageBox } from 'element-plus'
const router = useRouter()
const goalStore = useGoalStore()
const dialogVisible = ref(false)
const editingGoal = ref<Goal | null>(null)
const statusFilter = ref<string>('')
onMounted(async () => {
await goalStore.fetchGoals()
})
function openCreate() {
editingGoal.value = null
dialogVisible.value = true
}
function openEdit(goal: Goal) {
editingGoal.value = goal
dialogVisible.value = true
}
async function handleDelete(goal: Goal) {
try {
await ElMessageBox.confirm(`确定要删除目标「${goal.title}」吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
await goalStore.deleteGoal(goal.id)
} catch {}
}
function goDetail(id: number) {
router.push(`/goals/${id}`)
}
function onSaved() {
dialogVisible.value = false
}
const filteredGoals = ref<Goal[]>([])
function applyFilter() {
goalStore.fetchGoals(statusFilter.value || undefined)
}
const statusLabel: Record<string, string> = {
active: '进行中',
paused: '已暂停',
completed: '已完成',
abandoned: '已放弃',
}
const statusColor: Record<string, string> = {
active: '#67C23A',
paused: '#E6A23C',
completed: '#409EFF',
abandoned: '#909399',
}
</script>
<template>
<div class="goal-page">
<div class="page-header">
<h2>目标管理</h2>
<div class="header-actions">
<el-select v-model="statusFilter" placeholder="全部状态" clearable style="width:140px" @change="applyFilter">
<el-option label="进行中" value="active" />
<el-option label="已暂停" value="paused" />
<el-option label="已完成" value="completed" />
<el-option label="已放弃" value="abandoned" />
</el-select>
<el-button type="primary" :icon="Plus" @click="openCreate">新建目标</el-button>
</div>
</div>
<div v-if="goalStore.loading" class="loading-state">
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
</div>
<div v-else-if="goalStore.goals.length === 0" class="empty-state">
<el-icon :size="64" color="#ccc"><Flag /></el-icon>
<p>还没有目标</p>
<p class="hint">设定一个长期目标拆解成阶段和里程碑来追踪进度</p>
<el-button type="primary" @click="openCreate">创建第一个目标</el-button>
</div>
<div v-else class="goal-grid">
<div
v-for="goal in goalStore.goals"
:key="goal.id"
class="goal-card"
:style="{ borderLeftColor: goal.color }"
@click="goDetail(goal.id)"
>
<div class="card-top">
<el-icon :size="24" :color="goal.color"><component :is="goal.icon" /></el-icon>
<div class="card-actions" @click.stop>
<el-button text size="small" @click="openEdit(goal)">编辑</el-button>
<el-button text size="small" type="danger" @click="handleDelete(goal)">删除</el-button>
</div>
</div>
<h3 class="card-title">{{ goal.title }}</h3>
<p v-if="goal.description" class="card-desc">{{ goal.description }}</p>
<div class="progress-section">
<div class="progress-info">
<span class="progress-text">{{ goal.progress }}%</span>
<span v-if="goal.track_type === 'cumulative' && goal.target_value" class="progress-steps">
{{ (goal.current_value / (goal.conversion_rate || 1)).toFixed(1) }}/{{ goal.target_value }} {{ goal.target_unit || '' }}
</span>
<span v-else class="progress-steps">{{ goal.completed_steps }}/{{ goal.total_steps }} 里程碑</span>
</div>
<el-progress
:percentage="goal.progress"
:color="goal.color"
:stroke-width="8"
:show-text="false"
/>
</div>
<div class="card-footer">
<el-tag :color="statusColor[goal.status]" effect="dark" size="small">
{{ statusLabel[goal.status] || goal.status }}
</el-tag>
<span v-if="goal.target_date" class="target-date">目标日期: {{ goal.target_date }}</span>
<span v-if="goal.category" class="category-badge">
<el-icon :size="14"><Folder /></el-icon>
{{ goal.category.name }}
</span>
</div>
</div>
</div>
<GoalDialog :visible="dialogVisible" :editing-goal="editingGoal" @close="dialogVisible = false" @saved="onSaved" />
</div>
</template>
<style scoped lang="scss">
.goal-page {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 { margin: 0; font-size: 22px; }
.header-actions { display: flex; gap: 12px; }
}
.loading-state, .empty-state {
text-align: center;
padding: 80px 0;
color: #999;
.hint { font-size: 13px; color: #bbb; margin-top: 8px; }
}
.goal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 20px;
}
.goal-card {
background: white;
border-radius: 12px;
padding: 20px;
border-left: 4px solid #FFB7C5;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
}
}
.card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.card-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.15s; }
}
.goal-card:hover .card-actions { opacity: 1; }
.card-title {
font-size: 17px;
font-weight: 600;
margin: 0 0 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-desc {
font-size: 13px;
color: #999;
margin: 0 0 16px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.progress-section {
margin-bottom: 14px;
.progress-info {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
font-size: 13px;
.progress-text { font-weight: 700; color: #333; }
.progress-steps { color: #999; }
}
}
.card-footer {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: #999;
.category-badge {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
}
</style>

View File

@@ -17,7 +17,7 @@ const error = ref('')
const redirect = (route.query.redirect as string) || '/' const redirect = (route.query.redirect as string) || '/'
async function handleLogin() { async function handleLogin() {
if (!password.value) return if (!password.value || loading.value) return
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
@@ -26,7 +26,11 @@ async function handleLogin() {
await userSettingsStore.fetchAndSync() await userSettingsStore.fetchAndSync()
router.replace(redirect) router.replace(redirect)
} else { } else {
error.value = authStore.error || '密码错误' const msg = authStore.error || '密码错误'
error.value = msg
if (msg.includes('请先设置密码')) {
router.replace({ path: '/setup', query: route.query })
}
} }
} finally { } finally {
loading.value = false loading.value = false

View File

@@ -6,7 +6,10 @@ import { useTaskStore } from '@/stores/useTaskStore'
import { useCategoryStore } from '@/stores/useCategoryStore' import { useCategoryStore } from '@/stores/useCategoryStore'
import { useTagStore } from '@/stores/useTagStore' import { useTagStore } from '@/stores/useTagStore'
import { useHabitStore } from '@/stores/useHabitStore' import { useHabitStore } from '@/stores/useHabitStore'
import { get, post, del } from '@/api/request' import { useGoalStore } from '@/stores/useGoalStore'
import { useAnniversaryStore } from '@/stores/useAnniversaryStore'
import { useSyncStore, type SyncDirection } from '@/stores/useSyncStore'
import { exportBackup, importBackup } from '@/api/backup'
import type { Task, Category, Tag, HabitGroup, Habit } from '@/api/types' import type { Task, Category, Tag, HabitGroup, Habit } from '@/api/types'
const userStore = useUserSettingsStore() const userStore = useUserSettingsStore()
@@ -14,9 +17,13 @@ const taskStore = useTaskStore()
const categoryStore = useCategoryStore() const categoryStore = useCategoryStore()
const tagStore = useTagStore() const tagStore = useTagStore()
const habitStore = useHabitStore() const habitStore = useHabitStore()
const goalStore = useGoalStore()
const anniversaryStore = useAnniversaryStore()
const syncStore = useSyncStore()
const saving = ref(false) const saving = ref(false)
const exporting = ref(false) const exporting = ref(false)
const importing = ref(false)
const viewOptions = [ const viewOptions = [
{ label: '列表', value: 'list' }, { label: '列表', value: 'list' },
@@ -42,13 +49,48 @@ const prefs = ref({
default_sort_order: 'desc' default_sort_order: 'desc'
}) })
const webdavConfig = ref({
webdav_url: '',
webdav_username: '',
webdav_password: '',
webdav_path: '/elysia-todo/',
auto_sync: false,
auto_sync_interval: 300
})
const syncDirection = ref<SyncDirection>('sync')
let pollTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => { onMounted(() => {
prefs.value.site_name = userStore.siteName || '爱莉希雅待办' prefs.value.site_name = userStore.siteName || '爱莉希雅待办'
prefs.value.default_view = userStore.defaultView || 'list' prefs.value.default_view = userStore.defaultView || 'list'
prefs.value.default_sort_by = userStore.defaultSortBy || 'priority' prefs.value.default_sort_by = userStore.defaultSortBy || 'priority'
prefs.value.default_sort_order = userStore.defaultSortOrder || 'desc' prefs.value.default_sort_order = userStore.defaultSortOrder || 'desc'
syncStore.fetchConfig().then(() => {
if (syncStore.config) {
webdavConfig.value.webdav_url = syncStore.config.webdav_url || ''
webdavConfig.value.webdav_username = syncStore.config.webdav_username || ''
webdavConfig.value.webdav_password = ''
webdavConfig.value.webdav_path = syncStore.config.webdav_path || '/elysia-todo/'
webdavConfig.value.auto_sync = syncStore.config.auto_sync || false
webdavConfig.value.auto_sync_interval = syncStore.config.auto_sync_interval || 300
}
}) })
syncStore.fetchStatus()
pollTimer = setInterval(() => {
syncStore.fetchStatus()
}, 5000)
})
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
async function handleSave() { async function handleSave() {
saving.value = true saving.value = true
try { try {
@@ -60,7 +102,6 @@ async function handleSave() {
}) })
userStore.syncFromSettings(userStore.settings!) userStore.syncFromSettings(userStore.settings!)
// 保存排序后立即应用
taskStore.setFilters({ taskStore.setFilters({
sort_by: prefs.value.default_sort_by as 'priority' | 'due_date' | 'created_at', sort_by: prefs.value.default_sort_by as 'priority' | 'due_date' | 'created_at',
sort_order: prefs.value.default_sort_order as 'asc' | 'desc' sort_order: prefs.value.default_sort_order as 'asc' | 'desc'
@@ -74,35 +115,39 @@ async function handleSave() {
} }
} }
async function saveWebdavConfig() {
const data: Record<string, unknown> = {
webdav_url: webdavConfig.value.webdav_url || null,
webdav_username: webdavConfig.value.webdav_username || null,
webdav_path: webdavConfig.value.webdav_path || '/elysia-todo/',
auto_sync: webdavConfig.value.auto_sync,
auto_sync_interval: webdavConfig.value.auto_sync_interval,
}
if (webdavConfig.value.webdav_password) {
data.webdav_password = webdavConfig.value.webdav_password
}
await syncStore.saveConfig(data)
}
async function testWebdavConnection() {
await syncStore.testConnection()
}
async function startSync() {
await syncStore.startSync(syncDirection.value)
}
async function exportData() { async function exportData() {
exporting.value = true exporting.value = true
try { try {
const [tasks, categories, tags, habitGroups, habits] = await Promise.all([ const blob = await exportBackup()
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 url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = `todo-backup-${new Date().toISOString().slice(0, 10)}.json` const now = new Date().toISOString().slice(0, 19).replace(/:/g, '-')
a.download = `elysia-backup-${now}.json`
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
ElMessage.success('数据导出成功~') ElMessage.success('数据导出成功~')
} catch { } catch {
ElMessage.error('导出失败了呢~') ElMessage.error('导出失败了呢~')
@@ -111,169 +156,47 @@ async function exportData() {
} }
} }
const importFileRef = ref<HTMLInputElement>()
function importData() { function importData() {
const input = document.createElement('input') importFileRef.value?.click()
input.type = 'file' }
input.accept = '.json'
input.onchange = async (e) => { async function handleImportFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0] const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return if (!file) return
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
'导入数据会覆盖现有的所有任务、分类、标签习惯数据,确定要继续吗?', '导入数据会覆盖当前的所有数据(包括任务、分类、标签习惯、纪念日、目标等),确定要继续吗?',
'确认导入', '确认导入',
{ confirmButtonText: '确定导入', cancelButtonText: '取消', type: 'warning' } { confirmButtonText: '确定导入', cancelButtonText: '取消', type: 'warning' }
) )
const text = await file.text() importing.value = true
const data = JSON.parse(text) const result = await importBackup(file)
ElMessage.success(`数据导入成功~ 共导入 ${result.count} 条记录`)
if (!data.tasks || !Array.isArray(data.tasks)) { // 刷新所有 store
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([ await Promise.all([
taskStore.fetchTasks(), taskStore.fetchTasks(),
categoryStore.fetchCategories(), categoryStore.fetchCategories(),
tagStore.fetchTags() tagStore.fetchTags(),
habitStore.init(),
goalStore.fetchGoals(),
anniversaryStore.fetchCategories(),
anniversaryStore.fetchAnniversaries(),
]) ])
if (data.habits || data.habitGroups) { } catch (err: any) {
await habitStore.init() if (err?.toString?.() !== 'cancel') {
ElMessage.error(err?.response?.data?.detail || '导入失败了呢~')
} }
} finally {
ElMessage.success('数据导入成功~') importing.value = false
} catch (err) { // 重置 file input允许重新选择同一文件
if ((err as { toString?: () => string })?.toString?.() !== 'cancel') { if (importFileRef.value) importFileRef.value.value = ''
ElMessage.error('导入失败了呢~')
} }
} }
}
input.click()
}
async function clearCompleted() { async function clearCompleted() {
try { try {
@@ -400,7 +323,7 @@ async function clearCompleted() {
<div class="data-action-item"> <div class="data-action-item">
<div class="action-info"> <div class="action-info">
<span class="action-title">导出数据</span> <span class="action-title">导出数据</span>
<span class="action-desc">将所有任务分类标签和习惯导出为 JSON 文件</span> <span class="action-desc">将所有数据任务目标习惯纪念日分类标签导出为 JSON 文件</span>
</div> </div>
<el-button <el-button
:loading="exporting" :loading="exporting"
@@ -415,9 +338,17 @@ async function clearCompleted() {
<div class="data-action-item"> <div class="data-action-item">
<div class="action-info"> <div class="action-info">
<span class="action-title">导入数据</span> <span class="action-title">导入数据</span>
<span class="action-desc warning"> JSON 文件恢复数据会覆盖有数据</span> <span class="action-desc warning"> JSON 备份文件恢复全部数据会覆盖当前所有数据</span>
</div> </div>
<input
ref="importFileRef"
type="file"
accept=".json"
style="display:none"
@change="handleImportFile"
/>
<el-button <el-button
:loading="importing"
@click="importData" @click="importData"
class="action-btn" class="action-btn"
> >
@@ -444,6 +375,166 @@ async function clearCompleted() {
</div> </div>
</div> </div>
</div> </div>
<!-- 数据同步 -->
<div class="settings-card">
<div class="card-header">
<div class="card-icon sync-icon">
<el-icon :size="24"><Connection /></el-icon>
</div>
<div>
<h3 class="card-title">数据同步</h3>
<p class="card-subtitle">通过 WebDAV 同步你的数据</p>
</div>
</div>
<div class="card-body">
<div class="setting-item">
<div class="setting-label">
<span class="label-text">服务器地址</span>
<span class="label-desc">Alist WebDAV 地址</span>
</div>
<el-input
v-model="webdavConfig.webdav_url"
placeholder="https://alist.example.com/dav"
style="width: 260px"
/>
</div>
<div class="setting-item">
<div class="setting-label">
<span class="label-text">用户名</span>
<span class="label-desc">WebDAV 登录用户名</span>
</div>
<el-input
v-model="webdavConfig.webdav_username"
placeholder="用户名"
style="width: 200px"
/>
</div>
<div class="setting-item">
<div class="setting-label">
<span class="label-text">密码</span>
<span class="label-desc">WebDAV 登录密码</span>
</div>
<div style="display: flex; gap: 8px; align-items: center;">
<el-input
v-model="webdavConfig.webdav_password"
type="password"
show-password
:placeholder="syncStore.config?.webdav_password ? '已保存(留空不变更)' : '输入密码'"
style="width: 200px"
/>
<el-button @click="testWebdavConnection" :loading="syncStore.loading" size="default">
测试连接
</el-button>
</div>
</div>
<div class="setting-item">
<div class="setting-label">
<span class="label-text">远端路径</span>
<span class="label-desc">WebDAV 上的存储路径</span>
</div>
<el-input
v-model="webdavConfig.webdav_path"
placeholder="/elysia-todo/"
style="width: 200px"
/>
</div>
<div class="form-actions">
<el-button
type="primary"
:loading="syncStore.loading"
@click="saveWebdavConfig"
class="save-btn"
>
保存配置
</el-button>
</div>
</div>
</div>
<!-- 同步操作 -->
<div class="settings-card" v-if="syncStore.isConfigured">
<div class="card-header">
<div class="card-icon sync-icon">
<el-icon :size="24"><Refresh /></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-radio-group v-model="syncDirection">
<el-radio-button value="sync">双向合并</el-radio-button>
<el-radio-button value="push">推送</el-radio-button>
<el-radio-button value="pull">拉取</el-radio-button>
</el-radio-group>
</div>
<div class="sync-info" v-if="syncStore.status">
<span class="info-item">
<el-icon><Clock /></el-icon>
上次同步: {{ syncStore.status.last_sync_at ? new Date(syncStore.status.last_sync_at).toLocaleString() : '从未同步' }}
</span>
<span class="info-item" v-if="syncStore.status.last_sync_version > 0">
版本: v{{ syncStore.status.last_sync_version }}
</span>
</div>
<div class="sync-overlay-hint" v-if="syncStore.syncing">
<el-icon class="sync-spin" :size="20"><Loading /></el-icon>
<span>{{ syncStore.syncMessage || '正在同步...' }}</span>
</div>
<div class="data-actions">
<div class="data-action-item">
<div class="action-info">
<span class="action-title">执行同步</span>
<span class="action-desc" v-if="syncDirection === 'push'">将本地数据推送到远端远端数据将被覆盖</span>
<span class="action-desc warning" v-else-if="syncDirection === 'pull'">从远端拉取数据本地数据将被覆盖</span>
<span class="action-desc" v-else>合并本地和远端数据以最新版本为准</span>
</div>
<el-button
type="primary"
:loading="syncStore.syncing"
:disabled="syncStore.syncing"
@click="startSync"
class="action-btn"
>
<el-icon><Refresh /></el-icon>
{{ syncStore.syncing ? syncStore.syncMessage : '开始同步' }}
</el-button>
</div>
<div class="data-action-item">
<div class="action-info">
<span class="action-title">清空远端数据</span>
<span class="action-desc danger">删除 WebDAV 上的所有同步数据不可恢复</span>
</div>
<el-button
type="danger"
plain
@click="syncStore.clearRemote()"
class="action-btn"
>
<el-icon><Delete /></el-icon>
清空远端
</el-button>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -628,6 +719,46 @@ async function clearCompleted() {
} }
} }
.sync-icon {
background: linear-gradient(135deg, rgba(100, 200, 255, 0.2) 0%, rgba(150, 150, 255, 0.2) 100%) !important;
color: #6495ed !important;
}
.sync-info {
display: flex;
gap: 16px;
padding: 12px 0;
font-size: 13px;
color: var(--text-secondary);
.info-item {
display: flex;
align-items: center;
gap: 4px;
}
}
.sync-overlay-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
margin: 8px 0;
background: linear-gradient(135deg, rgba(100, 200, 255, 0.08) 0%, rgba(150, 150, 255, 0.08) 100%);
border-radius: var(--radius-md);
font-size: 14px;
color: #6495ed;
.sync-spin {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 768px) { @media (max-width: 768px) {
.settings-page { .settings-page {
padding: 16px; padding: 16px;

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { User, Lock, Check } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/useAuthStore'
import { useUserSettingsStore } from '@/stores/useUserSettingsStore'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const userSettingsStore = useUserSettingsStore()
const nickname = ref('')
const password = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const error = ref('')
const redirect = (route.query.redirect as string) || '/'
function validate(): string | null {
if (!password.value) return '请输入密码'
if (password.value.length < 6) return '密码长度至少6位'
if (new Set(password.value).size < 3) return '密码不能过于简单需包含至少3种不同字符'
if (password.value !== confirmPassword.value) return '两次输入的密码不一致'
return null
}
async function handleSetup() {
if (loading.value) return
const msg = validate()
if (msg) {
error.value = msg
return
}
loading.value = true
error.value = ''
try {
const name = nickname.value.trim() || undefined
const ok = await authStore.setupPassword(password.value, name)
if (ok) {
await userSettingsStore.fetchAndSync()
router.replace(redirect)
} else {
error.value = authStore.error || '设置失败,请重试'
}
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-wrapper">
<div class="decoration-star" style="top: 10%; right: 15%; animation-delay: 0s;"></div>
<div class="decoration-star" style="top: 70%; left: 10%; animation-delay: 1s;"></div>
<div class="decoration-star" style="top: 30%; left: 20%; animation-delay: 2s;"></div>
<div class="login-card">
<div class="login-header">
<div class="logo-icon"></div>
<h1 class="site-name">欢迎到来</h1>
<p class="subtitle">首次使用请设置你的账户信息~</p>
</div>
<el-form @submit.prevent="handleSetup" class="login-form">
<el-input
v-model="nickname"
placeholder="你的昵称(不填默认为爱莉希雅)"
size="large"
:prefix-icon="User"
@keyup.enter="handleSetup"
/>
<el-input
v-model="password"
type="password"
placeholder="请设置密码至少6位"
size="large"
show-password
:prefix-icon="Lock"
@keyup.enter="handleSetup"
/>
<el-input
v-model="confirmPassword"
type="password"
placeholder="再次输入密码"
size="large"
show-password
:prefix-icon="Check"
@keyup.enter="handleSetup"
/>
<p v-if="error" class="error-msg">{{ error }}</p>
<el-button
type="primary"
size="large"
:loading="loading"
class="login-btn"
@click="handleSetup"
>
开始使用
</el-button>
</el-form>
</div>
</div>
</template>
<style scoped lang="scss">
.login-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #fce4ec 0%, #f8bbd0 30%, #f48fb1 60%, #fce4ec 100%);
position: relative;
overflow: hidden;
}
.login-card {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(16px);
border-radius: 24px;
padding: 48px 40px;
width: 400px;
max-width: 90vw;
box-shadow: 0 8px 32px rgba(255, 183, 197, 0.3);
z-index: 1;
}
.login-header {
text-align: center;
margin-bottom: 32px;
.logo-icon {
font-size: 48px;
color: var(--primary);
animation: twinkle 2s ease-in-out infinite;
margin-bottom: 12px;
}
.site-name {
font-size: 24px;
font-weight: 700;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0 0 8px;
}
.subtitle {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
}
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.error-msg {
color: #f56c6c;
font-size: 13px;
margin: 0;
text-align: center;
}
.login-btn {
width: 100%;
height: 44px;
font-size: 16px;
font-weight: 600;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
border: none;
border-radius: 12px;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(255, 183, 197, 0.5);
}
}
@keyframes twinkle {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.05); }
}
</style>

View File

@@ -1,12 +1,18 @@
# 硬编码配置 # 硬编码配置
import os import os
import secrets
import logging
_logger = logging.getLogger("app.config")
# api 目录的绝对路径(基于本文件位置计算,不依赖工作目录) # api 目录的绝对路径(基于本文件位置计算,不依赖工作目录)
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 数据库配置 # 数据库配置
DATABASE_PATH = os.path.join(_BASE_DIR, "data", "todo.db") DATABASE_URL = os.getenv(
DATABASE_URL = f"sqlite:///{DATABASE_PATH}" "DATABASE_URL",
"postgresql://ToDoList:53N2PTSjMBPDy6zY@192.168.1.86:5432/ToDoList",
)
# WebUI 配置 # WebUI 配置
WEBUI_PATH = os.path.join(_BASE_DIR, "webui") WEBUI_PATH = os.path.join(_BASE_DIR, "webui")
@@ -28,6 +34,21 @@ DEFAULT_PAGE_SIZE = 20
HOST = "0.0.0.0" HOST = "0.0.0.0"
PORT = 23994 PORT = 23994
# JWT 认证配置
JWT_SECRET = "elysia-todo-secret-key-change-in-production" # JWT 密钥(首次启动随机生成,持久化到文件)
def _load_jwt_secret() -> str:
secret_file = os.path.join(_BASE_DIR, "data", ".jwt_secret")
if os.path.exists(secret_file):
with open(secret_file) as f:
return f.read().strip()
secret = secrets.token_hex(32)
os.makedirs(os.path.dirname(secret_file), exist_ok=True)
with open(secret_file, "w") as f:
f.write(secret)
_logger.warning("已生成新的 JWT 密钥")
return secret
JWT_SECRET = _load_jwt_secret()
ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24小时 ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24小时
ACCESS_TOKEN_EXPIRE_SECONDS = ACCESS_TOKEN_EXPIRE_MINUTES * 60

View File

@@ -1,23 +1,18 @@
from sqlalchemy import create_engine, inspect, text, String, Integer, Text, Boolean, Float, DateTime, Date from sqlalchemy import create_engine, inspect, text, String, Integer, Text, Boolean, Float, DateTime, Date, event
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy.orm import sessionmaker
import os
from app.config import DATABASE_PATH, DATABASE_URL from app.config import DATABASE_URL
# 确保 data 目录存在
os.makedirs(os.path.dirname(DATABASE_PATH) if os.path.dirname(DATABASE_PATH) else ".", exist_ok=True)
# 创建引擎
engine = create_engine( engine = create_engine(
DATABASE_URL, DATABASE_URL,
connect_args={"check_same_thread": False} pool_size=10,
max_overflow=20,
pool_recycle=3600,
pool_pre_ping=True,
) )
# 创建会话工厂
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 创建基类
Base = declarative_base() Base = declarative_base()
@@ -30,20 +25,18 @@ def get_db():
db.close() db.close()
# SQLAlchemy 类型到 SQLite 类型名的映射
_TYPE_MAP = { _TYPE_MAP = {
String: "VARCHAR", String: "VARCHAR",
Integer: "INTEGER", Integer: "INTEGER",
Text: "TEXT", Text: "TEXT",
Boolean: "BOOLEAN", Boolean: "BOOLEAN",
Float: "REAL", Float: "DOUBLE PRECISION",
DateTime: "DATETIME", DateTime: "TIMESTAMP",
Date: "DATE", Date: "DATE",
} }
def _col_type_str(col_type) -> str: def _col_type_str(col_type) -> str:
"""将 SQLAlchemy 列类型转为 SQLite 类型字符串"""
if col_type.__class__ in _TYPE_MAP: if col_type.__class__ in _TYPE_MAP:
base = _TYPE_MAP[col_type.__class__] base = _TYPE_MAP[col_type.__class__]
else: else:
@@ -56,14 +49,13 @@ def _col_type_str(col_type) -> str:
def init_db(): def init_db():
"""初始化数据库表,自动补充新增的列""" """初始化数据库表,自动补充新增的列,并为缺少 uuid 的记录回填"""
# 导入所有模型,确保 Base.metadata 包含全部表定义 from app.utils.logger import logger # 避免循环导入
from app.models import ( # noqa: F401 from app.models import ( # noqa: F401
task, category, tag, user_settings, habit, anniversary, account, task, category, tag, user_settings, habit, anniversary, goal, sync_settings,
) )
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# 通用自动迁移:对比 ORM 模型与实际表结构补充缺失的列SQLite 兼容)
inspector = inspect(engine) inspector = inspect(engine)
table_names = set(inspector.get_table_names()) table_names = set(inspector.get_table_names())
@@ -78,24 +70,79 @@ def init_db():
for col in table_cls.columns: for col in table_cls.columns:
if col.name in existing_cols: if col.name in existing_cols:
continue continue
# 跳过无服务端默认值且不可为空的列(容易出错)
if col.nullable is False and col.server_default is None and col.default is None: if col.nullable is False and col.server_default is None and col.default is None:
continue continue
sqlite_type = _col_type_str(col.type) col_type_str = _col_type_str(col.type)
col_name = col.name
ddl = f"ALTER TABLE {table_name} ADD COLUMN {col.name} {sqlite_type}"
# 为可空列或已有默认值的列附加 DEFAULT
if col.server_default is not None: if col.server_default is not None:
ddl = f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type_str}"
ddl += f" DEFAULT {col.server_default.arg}" ddl += f" DEFAULT {col.server_default.arg}"
elif col.default is not None and col.nullable: if not col.nullable:
ddl += " NOT NULL"
elif col.default is not None:
default_val = col.default.arg default_val = col.default.arg
if isinstance(default_val, str): ddl = f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type_str}"
ddl += f" DEFAULT '{default_val}'" if callable(default_val):
# callable 类型的默认值(如 uuid.uuid4无法写入 SQL DEFAULT
# 后续的 UUID 回填逻辑会处理已有记录
pass
elif isinstance(default_val, bool): elif isinstance(default_val, bool):
ddl += f" DEFAULT {1 if default_val else 0}" ddl += f" DEFAULT {'TRUE' if default_val else 'FALSE'}"
elif isinstance(default_val, str):
ddl += f" DEFAULT '{default_val}'"
else: else:
ddl += f" DEFAULT {default_val}" ddl += f" DEFAULT {default_val}"
if not col.nullable:
ddl += " NOT NULL"
else:
ddl = f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type_str}"
conn.execute(text(ddl)) conn.execute(text(ddl))
# 为缺少 uuid 的已有记录回填 UUID4
import uuid
db_session = SessionLocal()
try:
from app.models import Task, Category, Tag, HabitGroup, Habit, HabitCheckin
from app.models import AnniversaryCategory, Anniversary, Goal, GoalStep, GoalReview, SyncSettings
for model_cls in [Task, Category, Tag, HabitGroup, Habit, HabitCheckin,
AnniversaryCategory, Anniversary, Goal, GoalStep, GoalReview]:
if hasattr(model_cls, 'uuid'):
null_uuid_records = db_session.query(model_cls).filter(
(model_cls.uuid == None) | (model_cls.uuid == '') # noqa: E711
).all()
for record in null_uuid_records:
record.uuid = str(uuid.uuid4())
if null_uuid_records:
logger.info(f"{len(null_uuid_records)}{model_cls.__name__} 记录回填了 uuid")
db_session.commit()
except Exception as e:
logger.warning(f"UUID 回填时出现异常(可忽略): {e}")
db_session.rollback()
finally:
db_session.close()
# 注册 sync_version 自增事件监听
_register_sync_version_listeners()
def _bump_sync_version(mapper, connection, target):
"""before_update 事件:自动递增 sync_version同步模式中跳过"""
from app.utils.sync_lock import is_sync_mode
if not is_sync_mode() and hasattr(target, 'sync_version'):
target.sync_version = (target.sync_version or 0) + 1
def _register_sync_version_listeners():
"""为所有可同步模型注册 before_update 事件监听"""
from app.models import (
Task, Category, Tag, HabitGroup, Habit, HabitCheckin,
AnniversaryCategory, Anniversary, Goal, GoalStep, GoalReview,
)
for model_cls in [Task, Category, Tag, HabitGroup, Habit, HabitCheckin,
AnniversaryCategory, Anniversary, Goal, GoalStep, GoalReview]:
if hasattr(model_cls, 'sync_version'):
event.listen(model_cls, 'before_update', _bump_sync_version)

View File

@@ -7,10 +7,13 @@ import time
import json import json
from app.config import CORS_ORIGINS, WEBUI_PATH, HOST, PORT from app.config import CORS_ORIGINS, WEBUI_PATH, HOST, PORT
from app.database import init_db from app.database import init_db, SessionLocal
from app.models.user_settings import UserSettings
from app.routers import api_router from app.routers import api_router
from app.utils.logger import logger from app.utils.logger import logger
from app.utils.auth import decode_access_token from app.utils.auth import decode_access_token, get_cached_token_version, set_cached_token_version
from app.utils.sync_lock import is_syncing
from jose import JWTError
@asynccontextmanager @asynccontextmanager
@@ -90,25 +93,55 @@ async def log_requests(request: Request, call_next):
return response return response
# 认证中间件(保护所有 /api/* 路由,除了 /api/auth/* 和 /health # 认证中间件(保护 /api/*,仅放行 /health 和 /api/auth/login、/api/auth/logout
@app.middleware("http") @app.middleware("http")
async def auth_middleware(request: Request, call_next): async def auth_middleware(request: Request, call_next):
path = request.url.path path = request.url.path
# 不拦截健康检查、静态文件、auth 路由 # 不拦截:健康检查、静态文件、公开的 auth 端点
if path == "/health" or not path.startswith("/api/") or path.startswith("/api/auth/"): public_paths = {"/health", "/api/auth/login", "/api/auth/logout", "/api/auth/status", "/api/auth/setup"}
if path in public_paths or not path.startswith("/api/"):
return await call_next(request) return await call_next(request)
auth_header = request.headers.get("Authorization", "") token = request.cookies.get("access_token", "")
token = auth_header.replace("Bearer ", "")
if not token: if not token:
return JSONResponse(status_code=401, content={"detail": "未登录"}) return JSONResponse(status_code=401, content={"detail": "未登录"})
try: try:
decode_access_token(token) payload = decode_access_token(token)
except Exception: except JWTError:
return JSONResponse(status_code=401, content={"detail": "登录已过期,请重新登录"}) return JSONResponse(status_code=401, content={"detail": "登录已过期,请重新登录"})
user_id = payload.get("sub", "")
token_tv = payload.get("tv")
if token_tv is not None and user_id:
cached_tv = get_cached_token_version(user_id)
if cached_tv is None:
db = SessionLocal()
try:
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
cached_tv = settings.token_version if settings else 0
set_cached_token_version(user_id, cached_tv)
finally:
db.close()
if token_tv != cached_tv:
return JSONResponse(status_code=401, content={"detail": "密码已修改,请重新登录"})
request.state.user = payload
return await call_next(request)
# 同步锁中间件(同步期间禁止写操作)
@app.middleware("http")
async def sync_lock_middleware(request: Request, call_next):
path = request.url.path
if is_syncing() and request.method in ("POST", "PUT", "PATCH", "DELETE"):
if not path.startswith("/api/sync"):
return JSONResponse(status_code=503, content={"detail": "正在同步数据,请稍后再试"})
return await call_next(request) return await call_next(request)
@@ -151,9 +184,12 @@ if os.path.exists(WEBUI_PATH):
@app.get("/{full_path:path}") @app.get("/{full_path:path}")
async def spa_fallback(request: Request, full_path: str): async def spa_fallback(request: Request, full_path: str):
"""SPA 回退:先尝试提供真实文件,找不到则返回 index.html""" """SPA 回退:先尝试提供真实文件,找不到则返回 index.html"""
file_path = os.path.join(WEBUI_PATH, full_path) # 规范化路径并防止路径穿越攻击
if os.path.isfile(file_path): safe_path = os.path.normpath(os.path.join(WEBUI_PATH, full_path))
return FileResponse(file_path) if not safe_path.startswith(os.path.normpath(WEBUI_PATH)):
return FileResponse(os.path.join(WEBUI_PATH, "index.html"))
if os.path.isfile(safe_path):
return FileResponse(safe_path)
return FileResponse(os.path.join(WEBUI_PATH, "index.html")) return FileResponse(os.path.join(WEBUI_PATH, "index.html"))
logger.info(f"SPA 静态文件服务已配置: {WEBUI_PATH}") logger.info(f"SPA 静态文件服务已配置: {WEBUI_PATH}")

View File

@@ -4,11 +4,15 @@ from app.models.tag import Tag, task_tags
from app.models.user_settings import UserSettings from app.models.user_settings import UserSettings
from app.models.habit import HabitGroup, Habit, HabitCheckin from app.models.habit import HabitGroup, Habit, HabitCheckin
from app.models.anniversary import AnniversaryCategory, Anniversary from app.models.anniversary import AnniversaryCategory, Anniversary
from app.models.account import FinancialAccount, AccountHistory, DebtInstallment from app.models.goal import Goal, GoalStep, GoalReview, GoalCheckin, goal_tasks
from app.models.sync_settings import SyncSettings
from app.models.certificate import Certificate, CertificateCategory
__all__ = [ __all__ = [
"Task", "Category", "Tag", "task_tags", "UserSettings", "Task", "Category", "Tag", "task_tags", "UserSettings",
"HabitGroup", "Habit", "HabitCheckin", "HabitGroup", "Habit", "HabitCheckin",
"AnniversaryCategory", "Anniversary", "AnniversaryCategory", "Anniversary",
"FinancialAccount", "AccountHistory", "DebtInstallment", "Goal", "GoalStep", "GoalReview", "GoalCheckin", "goal_tasks",
"SyncSettings",
"Certificate", "CertificateCategory",
] ]

View File

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

View File

@@ -1,3 +1,4 @@
import uuid as _uuid
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Date from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Date
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
@@ -9,10 +10,13 @@ class AnniversaryCategory(Base):
__tablename__ = "anniversary_categories" __tablename__ = "anniversary_categories"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
name = Column(String(50), nullable=False) name = Column(String(50), nullable=False)
icon = Column(String(50), default="calendar") icon = Column(String(50), default="calendar")
color = Column(String(20), default="#FFB7C5") color = Column(String(20), default="#FFB7C5")
sort_order = Column(Integer, default=0) sort_order = Column(Integer, default=0)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
# 关联关系 # 关联关系
anniversaries = relationship("Anniversary", back_populates="category") anniversaries = relationship("Anniversary", back_populates="category")
@@ -23,6 +27,7 @@ class Anniversary(Base):
__tablename__ = "anniversaries" __tablename__ = "anniversaries"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
title = Column(String(200), nullable=False) title = Column(String(200), nullable=False)
date = Column(Date, nullable=False) # 月-日,年份部分可选 date = Column(Date, nullable=False) # 月-日,年份部分可选
year = Column(Integer, nullable=True) # 年份,用于计算第 N 个周年 year = Column(Integer, nullable=True) # 年份,用于计算第 N 个周年
@@ -30,6 +35,8 @@ class Anniversary(Base):
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
is_recurring = Column(Boolean, default=True) is_recurring = Column(Boolean, default=True)
remind_days_before = Column(Integer, default=3) remind_days_before = Column(Integer, default=3)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
created_at = Column(DateTime, default=utcnow) created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)

View File

@@ -1,4 +1,5 @@
from sqlalchemy import Column, Integer, String import uuid as _uuid
from sqlalchemy import Column, Integer, String, Boolean
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
@@ -8,9 +9,12 @@ class Category(Base):
__tablename__ = "categories" __tablename__ = "categories"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
name = Column(String(100), nullable=False) name = Column(String(100), nullable=False)
color = Column(String(20), default="#FFB7C5") # 默认樱花粉 color = Column(String(20), default="#FFB7C5") # 默认樱花粉
icon = Column(String(50), default="folder") # 默认图标 icon = Column(String(50), default="folder") # 默认图标
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
# 关联关系 # 关联关系
tasks = relationship("Task", back_populates="category") tasks = relationship("Task", back_populates="category")

View File

@@ -0,0 +1,43 @@
import uuid as _uuid
from sqlalchemy import Column, Integer, String, Text, Date, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
from app.utils.datetime import utcnow
class CertificateCategory(Base):
"""证书分类模型"""
__tablename__ = "certificate_categories"
id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
name = Column(String(50), nullable=False)
icon = Column(String(50), default="medal")
color = Column(String(20), default="#FFB7C5")
sort_order = Column(Integer, default=0)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
certificates = relationship("Certificate", back_populates="category")
class Certificate(Base):
"""证书模型"""
__tablename__ = "certificates"
id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
title = Column(String(200), nullable=False)
category_id = Column(Integer, ForeignKey("certificate_categories.id"), nullable=True)
image = Column(Text, nullable=True) # base64 data URL
issuer = Column(String(200), nullable=True) # 来源/颁发机构
issue_date = Column(Date, nullable=True)
expiry_date = Column(Date, nullable=True) # null = 永久有效
description = Column(Text, nullable=True)
sort_order = Column(Integer, default=0)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
category = relationship("CertificateCategory", back_populates="certificates")

119
api/app/models/goal.py Normal file
View File

@@ -0,0 +1,119 @@
import uuid as _uuid
from sqlalchemy import Column, Integer, String, Text, Date, DateTime, Boolean, Float, ForeignKey, Table, desc
from sqlalchemy.orm import relationship
from app.database import Base
from app.utils.datetime import utcnow
# 目标-任务关联表(多对多)
goal_tasks = Table(
"goal_tasks",
Base.metadata,
Column("goal_id", Integer, ForeignKey("goals.id"), primary_key=True),
Column("task_id", Integer, ForeignKey("tasks.id"), primary_key=True),
)
class Goal(Base):
"""长期目标模型"""
__tablename__ = "goals"
id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
title = Column(String(200), nullable=False)
description = Column(Text, nullable=True)
status = Column(String(20), default="active") # active/paused/completed/abandoned
track_type = Column(String(20), default="milestone") # "milestone" | "cumulative"
progress = Column(Integer, default=0) # 0-100里程碑模式从里程碑自动计算累计模式从打卡汇总计算
target_value = Column(Float, nullable=True) # 累计模式:目标值(目标单位)
target_unit = Column(String(20), nullable=True) # 累计模式:目标单位,如 "g"、"kg"
input_unit = Column(String(20), nullable=True) # 累计模式:打卡输入单位,如 "kcal"、"次"
conversion_rate = Column(Float, default=1.0) # 累计模式:换算率(多少输入单位 = 1 目标单位)
current_value = Column(Float, default=0) # 累计模式:累计打卡值(输入单位)
target_date = Column(Date, nullable=True)
completed_at = Column(DateTime, nullable=True)
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
color = Column(String(20), default="#FFB7C5")
icon = Column(String(50), default="flag")
sort_order = Column(Integer, default=0)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
# 关联关系
category = relationship("Category")
steps = relationship(
"GoalStep", back_populates="goal",
cascade="all, delete-orphan",
order_by="GoalStep.sort_order",
)
reviews = relationship(
"GoalReview", back_populates="goal",
cascade="all, delete-orphan",
order_by=lambda: desc(GoalReview.created_at),
)
checkins = relationship(
"GoalCheckin", back_populates="goal",
cascade="all, delete-orphan",
order_by=lambda: desc(GoalCheckin.checkin_date),
)
tasks = relationship("Task", secondary=goal_tasks, back_populates="goals")
class GoalStep(Base):
"""目标阶段/里程碑模型step_type 区分类型)"""
__tablename__ = "goal_steps"
id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False)
parent_id = Column(Integer, ForeignKey("goal_steps.id"), nullable=True)
title = Column(String(200), nullable=False)
step_type = Column(String(20), nullable=False) # "phase" | "milestone"
status = Column(String(20), default="pending") # pending/in_progress/completed
target_date = Column(Date, nullable=True)
reached_at = Column(DateTime, nullable=True)
sort_order = Column(Integer, default=0)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
created_at = Column(DateTime, default=utcnow)
# 关联关系
goal = relationship("Goal", back_populates="steps")
parent = relationship("GoalStep", remote_side=[id], backref="children")
class GoalReview(Base):
"""目标复盘记录模型"""
__tablename__ = "goal_reviews"
id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False)
content = Column(Text, nullable=False)
rating = Column(Integer, nullable=True) # 1-5 自评
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
created_at = Column(DateTime, default=utcnow)
# 关联关系
goal = relationship("Goal", back_populates="reviews")
class GoalCheckin(Base):
"""目标累计打卡记录模型"""
__tablename__ = "goal_checkins"
id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False)
value = Column(Float, nullable=False)
note = Column(Text, nullable=True)
checkin_date = Column(Date, nullable=False)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
created_at = Column(DateTime, default=utcnow)
# 关联关系
goal = relationship("Goal", back_populates="checkins")

View File

@@ -1,3 +1,4 @@
import uuid as _uuid
from sqlalchemy import Column, Integer, String, Text, Boolean, Date, DateTime, ForeignKey, UniqueConstraint from sqlalchemy import Column, Integer, String, Text, Boolean, Date, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
@@ -9,10 +10,13 @@ class HabitGroup(Base):
__tablename__ = "habit_groups" __tablename__ = "habit_groups"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
name = Column(String(100), nullable=False) name = Column(String(100), nullable=False)
color = Column(String(20), default="#FFB7C5") color = Column(String(20), default="#FFB7C5")
icon = Column(String(50), default="flag") icon = Column(String(50), default="flag")
sort_order = Column(Integer, default=0) sort_order = Column(Integer, default=0)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
# 关联关系 # 关联关系
habits = relationship("Habit", back_populates="group", order_by="Habit.created_at") habits = relationship("Habit", back_populates="group", order_by="Habit.created_at")
@@ -23,6 +27,7 @@ class Habit(Base):
__tablename__ = "habits" __tablename__ = "habits"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
name = Column(String(200), nullable=False) name = Column(String(200), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
group_id = Column(Integer, ForeignKey("habit_groups.id"), nullable=True) group_id = Column(Integer, ForeignKey("habit_groups.id"), nullable=True)
@@ -30,6 +35,8 @@ class Habit(Base):
frequency = Column(String(20), default="daily") # daily / weekly frequency = Column(String(20), default="daily") # daily / weekly
active_days = Column(String(100), nullable=True) # JSON 数组, 如 [0,2,4] 表示周一三五 active_days = Column(String(100), nullable=True) # JSON 数组, 如 [0,2,4] 表示周一三五
is_archived = Column(Boolean, default=False) is_archived = Column(Boolean, default=False)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
created_at = Column(DateTime, default=utcnow) created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
@@ -46,9 +53,12 @@ class HabitCheckin(Base):
) )
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
habit_id = Column(Integer, ForeignKey("habits.id"), nullable=False) habit_id = Column(Integer, ForeignKey("habits.id"), nullable=False)
checkin_date = Column(Date, nullable=False) checkin_date = Column(Date, nullable=False)
count = Column(Integer, default=0) count = Column(Integer, default=0)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
created_at = Column(DateTime, default=utcnow) created_at = Column(DateTime, default=utcnow)
# 关联关系 # 关联关系

View File

@@ -0,0 +1,28 @@
import uuid as _uuid
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from app.database import Base
from app.utils.datetime import utcnow
class SyncSettings(Base):
"""同步设置模型(单例,始终只有一条记录 id=1"""
__tablename__ = "sync_settings"
id = Column(Integer, primary_key=True, default=1)
# WebDAV 连接配置
webdav_url = Column(String(500), nullable=True)
webdav_username = Column(String(200), nullable=True)
webdav_password = Column(String(500), nullable=True) # AES-256-GCM 加密存储
webdav_path = Column(String(200), default="/elysia-todo/")
# 同步状态
sync_enabled = Column(Boolean, default=False)
last_sync_at = Column(DateTime, nullable=True)
last_sync_version = Column(Integer, default=0)
auto_sync = Column(Boolean, default=False)
auto_sync_interval = Column(Integer, default=300) # 秒
# 时间戳
created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)

View File

@@ -1,4 +1,5 @@
from sqlalchemy import Column, Integer, String, Table, ForeignKey import uuid as _uuid
from sqlalchemy import Column, Integer, String, Boolean, Table, ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
@@ -17,7 +18,10 @@ class Tag(Base):
__tablename__ = "tags" __tablename__ = "tags"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
name = Column(String(50), nullable=False, unique=True) name = Column(String(50), nullable=False, unique=True)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
# 关联关系 # 关联关系
tasks = relationship("Task", secondary=task_tags, back_populates="tags") tasks = relationship("Task", secondary=task_tags, back_populates="tags")

View File

@@ -1,3 +1,4 @@
import uuid as _uuid
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
@@ -9,15 +10,19 @@ class Task(Base):
__tablename__ = "tasks" __tablename__ = "tasks"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
title = Column(String(200), nullable=False) title = Column(String(200), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
priority = Column(String(20), default="q4") # q1(重要紧急), q2(重要不紧急), q3(不重要紧急), q4(不重要不紧急) priority = Column(String(20), default="q4") # q1(重要紧急), q2(重要不紧急), q3(不重要紧急), q4(不重要不紧急)
due_date = Column(DateTime, nullable=True) due_date = Column(DateTime, nullable=True)
is_completed = Column(Boolean, default=False) is_completed = Column(Boolean, default=False)
is_deleted = Column(Boolean, default=False)
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True) category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
sync_version = Column(Integer, default=1)
created_at = Column(DateTime, default=utcnow) created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
# 关联关系 # 关联关系
category = relationship("Category", back_populates="tasks") category = relationship("Category", back_populates="tasks")
tags = relationship("Tag", secondary="task_tags", back_populates="tasks") tags = relationship("Tag", secondary="task_tags", back_populates="tasks")
goals = relationship("Goal", secondary="goal_tasks", back_populates="tasks")

View File

@@ -1,12 +1,6 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, Date from sqlalchemy import Column, Integer, String, Text, DateTime, Date
from datetime import datetime, timezone, date
from app.database import Base from app.database import Base
from app.utils.datetime import utcnow
def utcnow():
"""统一获取 UTC 时间的工厂函数"""
return datetime.now(timezone.utc)
class UserSettings(Base): class UserSettings(Base):
"""用户设置模型(单例,始终只有一条记录 id=1""" """用户设置模型(单例,始终只有一条记录 id=1"""
@@ -33,6 +27,7 @@ class UserSettings(Base):
# 认证 # 认证
password_hash = Column(String(255), default="") password_hash = Column(String(255), default="")
token_version = Column(Integer, default=0)
# 时间戳 # 时间戳
created_at = Column(DateTime, default=utcnow) created_at = Column(DateTime, default=utcnow)

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, accounts, auth from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth, goals, sync, backup, certificates
api_router = APIRouter() api_router = APIRouter()
@@ -10,4 +10,7 @@ api_router.include_router(tags.router)
api_router.include_router(user_settings.router) api_router.include_router(user_settings.router)
api_router.include_router(habits.router) api_router.include_router(habits.router)
api_router.include_router(anniversaries.router) api_router.include_router(anniversaries.router)
api_router.include_router(accounts.router) api_router.include_router(goals.router)
api_router.include_router(sync.router)
api_router.include_router(backup.router)
api_router.include_router(certificates.router)

View File

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

View File

@@ -17,6 +17,16 @@ from app.utils.logger import logger
router = APIRouter(prefix="/api", tags=["纪念日"]) router = APIRouter(prefix="/api", tags=["纪念日"])
def _safe_date(year: int, month: int, day: int) -> date:
"""安全构造日期,对闰年 2 月 29 日回退到 2 月 28 日"""
try:
return date(year, month, day)
except ValueError:
if month == 2 and day == 29:
return date(year, 2, 28)
raise
def compute_next_info(anniversary: Anniversary, today: date) -> tuple: def compute_next_info(anniversary: Anniversary, today: date) -> tuple:
"""计算纪念日的下一次日期、距今天数、周年数""" """计算纪念日的下一次日期、距今天数、周年数"""
month, day = anniversary.date.month, anniversary.date.day month, day = anniversary.date.month, anniversary.date.day
@@ -24,9 +34,9 @@ def compute_next_info(anniversary: Anniversary, today: date) -> tuple:
if anniversary.is_recurring: if anniversary.is_recurring:
# 计算今年和明年的日期 # 计算今年和明年的日期
this_year = today.year this_year = today.year
next_date = date(this_year, month, day) next_date = _safe_date(this_year, month, day)
if next_date < today: if next_date < today:
next_date = date(this_year + 1, month, day) next_date = _safe_date(this_year + 1, month, day)
days_until = (next_date - today).days days_until = (next_date - today).days
@@ -38,14 +48,14 @@ def compute_next_info(anniversary: Anniversary, today: date) -> tuple:
else: else:
# 非重复:使用原始日期(加上年份) # 非重复:使用原始日期(加上年份)
if anniversary.year: if anniversary.year:
target = date(anniversary.year, month, day) target = _safe_date(anniversary.year, month, day)
if target < today: if target < today:
return None, None, None return None, None, None
days_until = (target - today).days days_until = (target - today).days
return target, days_until, 0 return target, days_until, 0
else: else:
# 无年份的日期按今年算 # 无年份的日期按今年算
target = date(today.year, month, day) target = _safe_date(today.year, month, day)
if target < today: if target < today:
return None, None, None return None, None, None
days_until = (target - today).days days_until = (target - today).days
@@ -219,10 +229,10 @@ def create_anniversary(data: AnniversaryCreate, db: Session = Depends(get_db)):
db.refresh(db_anniversary) db.refresh(db_anniversary)
today = date.today() today = date.today()
next_date, days_until, year_count = compute_next_info(db_anniversary, today) result = enrich_anniversary(db_anniversary, today)
logger.info(f"创建纪念日成功: id={db_anniversary.id}, title={db_anniversary.title}") logger.info(f"创建纪念日成功: id={db_anniversary.id}, title={db_anniversary.title}")
return db_anniversary return result
except Exception as e: except Exception as e:
db.rollback() db.rollback()
logger.error(f"创建纪念日失败: {str(e)}") logger.error(f"创建纪念日失败: {str(e)}")
@@ -234,7 +244,9 @@ def get_anniversary(anniversary_id: int, db: Session = Depends(get_db)):
"""获取单个纪念日""" """获取单个纪念日"""
try: try:
anniversary = get_or_404(db, Anniversary, anniversary_id, "纪念日") anniversary = get_or_404(db, Anniversary, anniversary_id, "纪念日")
return anniversary today = date.today()
result = enrich_anniversary(anniversary, today)
return result
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:

View File

@@ -1,33 +1,157 @@
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import time
from app.database import get_db from app.database import get_db
from app.models.user_settings import UserSettings from app.models.user_settings import UserSettings
from app.schemas.auth import LoginRequest, TokenResponse, ChangePasswordRequest from app.schemas.auth import LoginRequest, LoginResponse, ChangePasswordRequest, AuthStatusResponse, SetupPasswordRequest, AuthSetupStatusResponse
from app.utils.auth import ( from app.utils.auth import (
hash_password, verify_password, create_access_token, hash_password, verify_password, create_access_token,
get_current_user, set_default_password get_current_user, set_cached_token_version,
) )
from app.utils.datetime import utcnow
from app.utils.rate_limiter import login_limiter
from app.config import ACCESS_TOKEN_EXPIRE_SECONDS
router = APIRouter(prefix="/api/auth", tags=["认证"]) router = APIRouter(prefix="/api/auth", tags=["认证"])
@router.post("/login", response_model=TokenResponse) def _get_client_ip(request: Request) -> str:
def login(data: LoginRequest, db: Session = Depends(get_db)): forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def _get_or_create_settings(db: Session) -> UserSettings:
settings = db.query(UserSettings).filter(UserSettings.id == 1).first() settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
if not settings: if not settings:
settings = UserSettings(id=1) settings = UserSettings(id=1)
db.add(settings) db.add(settings)
db.commit() db.commit()
db.refresh(settings) db.refresh(settings)
return settings
set_default_password(db, settings)
class _StatusLimiter:
MAX_REQUESTS = 30
WINDOW_SECONDS = 60
def __init__(self):
self._requests: dict[str, list[float]] = {}
def check(self, ip: str) -> bool:
now = time.time()
times = [t for t in self._requests.get(ip, []) if now - t < self.WINDOW_SECONDS]
self._requests[ip] = times
if len(times) >= self.MAX_REQUESTS:
return False
times.append(now)
return True
_status_limiter = _StatusLimiter()
@router.get("/status", response_model=AuthSetupStatusResponse)
def auth_status(request: Request, db: Session = Depends(get_db)):
"""检查系统密码是否已设置"""
ip = _get_client_ip(request)
if not _status_limiter.check(ip):
raise HTTPException(status_code=429, detail="请求过于频繁")
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
has_password = bool(settings and settings.password_hash)
return AuthSetupStatusResponse(has_password=has_password)
@router.post("/setup")
def setup_password(data: SetupPasswordRequest, db: Session = Depends(get_db)):
"""首次设置密码(仅在无密码时可用),设置成功后自动登录"""
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
if settings and settings.password_hash:
raise HTTPException(status_code=400, detail="密码已设置,请使用登录接口")
if not settings:
settings = UserSettings(id=1)
db.add(settings)
settings.password_hash = hash_password(data.password)
settings.token_version = 1
if data.nickname and data.nickname.strip():
nick = data.nickname.strip()
settings.nickname = nick
settings.site_name = f"{nick}待办"
settings.updated_at = utcnow()
db.commit()
token = create_access_token({
"sub": str(settings.id),
"tv": settings.token_version,
})
response = JSONResponse(content={"message": "密码设置成功"})
response.set_cookie(
key="access_token",
value=token,
httponly=True,
samesite="strict",
max_age=ACCESS_TOKEN_EXPIRE_SECONDS,
path="/",
)
return response
@router.post("/login", response_model=LoginResponse)
def login(data: LoginRequest, request: Request, db: Session = Depends(get_db)):
ip = _get_client_ip(request)
allowed, retry_after = login_limiter.check(ip)
if not allowed:
return JSONResponse(
status_code=429,
content={
"detail": f"登录尝试过于频繁,请 {retry_after // 60 + 1} 分钟后再试",
"retry_after": retry_after,
},
headers={"Retry-After": str(retry_after)},
)
settings = _get_or_create_settings(db)
if not settings.password_hash:
raise HTTPException(status_code=400, detail="请先设置密码")
if not verify_password(data.password, settings.password_hash): if not verify_password(data.password, settings.password_hash):
login_limiter.record_failure(ip)
raise HTTPException(status_code=401, detail="密码错误") raise HTTPException(status_code=401, detail="密码错误")
token = create_access_token({"sub": str(settings.id)}) login_limiter.reset(ip)
return TokenResponse(access_token=token)
token = create_access_token({
"sub": str(settings.id),
"tv": settings.token_version,
})
response = JSONResponse(content={"message": "登录成功"})
response.set_cookie(
key="access_token",
value=token,
httponly=True,
samesite="strict",
max_age=ACCESS_TOKEN_EXPIRE_SECONDS,
path="/",
)
return response
@router.post("/logout")
def logout():
response = JSONResponse(content={"message": "已退出登录"})
response.delete_cookie("access_token", path="/")
return response
@router.get("/me", response_model=AuthStatusResponse)
def me(request: Request):
user = get_current_user(request)
return AuthStatusResponse(authenticated=True, user_id=user.get("sub", ""))
@router.post("/change-password") @router.post("/change-password")
@@ -36,7 +160,7 @@ def change_password(
request: Request, request: Request,
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
get_current_user(request) user = get_current_user(request)
settings = db.query(UserSettings).filter(UserSettings.id == 1).first() settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
if not settings: if not settings:
@@ -46,6 +170,9 @@ def change_password(
raise HTTPException(status_code=400, detail="原密码错误") raise HTTPException(status_code=400, detail="原密码错误")
settings.password_hash = hash_password(data.new_password) settings.password_hash = hash_password(data.new_password)
settings.token_version = (settings.token_version or 0) + 1
set_cached_token_version(str(settings.id), settings.token_version)
settings.updated_at = utcnow()
db.commit() db.commit()
return {"message": "密码修改成功"} return {"message": "密码修改成功"}

161
api/app/routers/backup.py Normal file
View File

@@ -0,0 +1,161 @@
"""数据备份导入导出路由"""
import json
from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import text
from io import BytesIO
from app.database import get_db, Base, engine
from app.utils.logger import logger
from app.utils.datetime import utcnow
router = APIRouter(prefix="/api/backup", tags=["备份"])
# 导出顺序:按依赖关系(无 FK 的先导出)
EXPORT_TABLES = [
"categories", "tags", "user_settings", "sync_settings",
"habit_groups", "anniversary_categories", "certificate_categories",
"goals", "tasks", "habits", "anniversaries", "certificates",
"goal_steps", "goal_reviews", "goal_checkins", "habit_checkins",
"task_tags", "goal_tasks",
]
# 导入时的清表顺序:子表先删(避免 FK 约束报错)
TRUNCATE_ORDER = [
"task_tags", "goal_tasks",
"habit_checkins", "goal_checkins",
"goal_reviews", "goal_steps",
"tasks", "habits", "anniversaries", "certificates",
"goals", "categories", "tags",
"habit_groups", "anniversary_categories", "certificate_categories",
"user_settings", "sync_settings",
]
# 导入时的插入顺序:父表先插
INSERT_ORDER = [
"categories", "tags", "user_settings", "sync_settings",
"habit_groups", "anniversary_categories", "certificate_categories",
"goals", "tasks", "habits", "anniversaries", "certificates",
"goal_steps", "goal_reviews", "goal_checkins", "habit_checkins",
"task_tags", "goal_tasks",
]
def _serialize_value(val):
"""将 Python 对象转为 JSON 可序列化的值"""
if val is None:
return None
if isinstance(val, (datetime, date)):
return val.isoformat()
if isinstance(val, bytes):
return val.decode("utf-8", errors="replace")
return val
@router.get("/export")
def export_data(db: Session = Depends(get_db)):
"""导出所有数据为 JSON 备份文件"""
try:
data: dict[str, list[dict]] = {}
for table_name in EXPORT_TABLES:
rows = []
try:
result = db.execute(text(f"SELECT * FROM {table_name}"))
columns = list(result.keys())
for row in result:
rows.append({
col: _serialize_value(getattr(row, col))
for col in columns
})
except Exception:
# 表可能不存在
rows = []
data[table_name] = rows
backup = {
"metadata": {
"version": 1,
"exported_at": utcnow().isoformat(),
},
"data": data,
}
json_bytes = json.dumps(backup, ensure_ascii=False, indent=2).encode("utf-8")
filename = f"elysia-backup-{utcnow().strftime('%Y%m%d-%H%M%S')}.json"
logger.info(f"数据导出成功,共 {sum(len(v) for v in data.values())} 条记录")
return StreamingResponse(
BytesIO(json_bytes),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
except Exception as e:
logger.error(f"导出数据失败: {str(e)}")
raise HTTPException(status_code=500, detail="导出数据失败")
@router.post("/import")
async def import_data(
file: UploadFile = File(...),
db: Session = Depends(get_db),
):
"""导入备份数据(覆盖当前所有数据)"""
if not file.filename or not file.filename.endswith(".json"):
raise HTTPException(status_code=400, detail="请上传 JSON 格式的备份文件")
try:
content = await file.read()
backup = json.loads(content.decode("utf-8"))
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="备份文件格式不正确")
except Exception as e:
logger.error(f"读取备份文件失败: {str(e)}")
raise HTTPException(status_code=400, detail="读取备份文件失败")
payload = backup.get("data")
if not payload:
raise HTTPException(status_code=400, detail="备份文件内容为空")
# 验证必要表存在
for table_name in INSERT_ORDER:
if table_name not in payload:
raise HTTPException(status_code=400, detail=f"备份文件缺少表: {table_name}")
imported_count = 0
try:
# 1. 按序清空所有表
for table_name in TRUNCATE_ORDER:
try:
db.execute(text(f"DELETE FROM {table_name}"))
except Exception:
pass # 表可能不存在
db.flush()
# 2. 按序插入数据
for table_name in INSERT_ORDER:
rows = payload.get(table_name, [])
if not rows:
continue
columns = list(rows[0].keys())
col_str = ", ".join(columns)
placeholders = ", ".join([f":{c}" for c in columns])
for row_data in rows:
db.execute(
text(f"INSERT INTO {table_name} ({col_str}) VALUES ({placeholders})"),
{c: row_data[c] for c in columns},
)
imported_count += 1
db.commit()
logger.info(f"数据导入成功,共 {imported_count} 条记录")
return {"message": "数据导入成功", "count": imported_count}
except Exception as e:
db.rollback()
logger.error(f"导入数据失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"导入数据失败: {str(e)}")

View File

@@ -0,0 +1,167 @@
"""证书路由"""
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.certificate import Certificate, CertificateCategory
from app.schemas.certificate import (
CertificateCreate, CertificateUpdate,
CertificateListResponse, CertificateDetailResponse,
CertificateCategoryCreate, CertificateCategoryUpdate, CertificateCategoryResponse,
)
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=["证书"])
# ============ 证书分类 API ============
@router.get("/certificate-categories", response_model=List[CertificateCategoryResponse])
def get_categories(db: Session = Depends(get_db)):
try:
categories = db.query(CertificateCategory).order_by(
CertificateCategory.sort_order.asc(),
CertificateCategory.id.asc()
).all()
return categories
except Exception as e:
logger.error(f"获取证书分类列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取证书分类列表失败")
@router.post("/certificate-categories", response_model=CertificateCategoryResponse, status_code=201)
def create_category(data: CertificateCategoryCreate, db: Session = Depends(get_db)):
try:
cat = CertificateCategory(**data.model_dump())
db.add(cat)
db.commit()
db.refresh(cat)
logger.info(f"创建证书分类成功: id={cat.id}, name={cat.name}")
return cat
except Exception as e:
db.rollback()
logger.error(f"创建证书分类失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建证书分类失败")
@router.put("/certificate-categories/{category_id}", response_model=CertificateCategoryResponse)
def update_category(category_id: int, data: CertificateCategoryUpdate, db: Session = Depends(get_db)):
try:
cat = get_or_404(db, CertificateCategory, category_id, "证书分类")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(cat, field, value)
db.commit()
db.refresh(cat)
logger.info(f"更新证书分类成功: id={category_id}")
return cat
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新证书分类失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新证书分类失败")
@router.delete("/certificate-categories/{category_id}")
def delete_category(category_id: int, db: Session = Depends(get_db)):
try:
cat = get_or_404(db, CertificateCategory, category_id, "证书分类")
certs = db.query(Certificate).filter(Certificate.category_id == category_id).all()
for c in certs:
c.category_id = None
db.delete(cat)
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("/certificates", response_model=List[CertificateListResponse])
def get_certificates(
category_id: Optional[int] = Query(None),
db: Session = Depends(get_db),
):
try:
query = db.query(Certificate)
if category_id is not None:
query = query.filter(Certificate.category_id == category_id)
certs = query.order_by(Certificate.sort_order.asc(), Certificate.created_at.desc()).all()
return certs
except Exception as e:
logger.error(f"获取证书列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取证书列表失败")
@router.post("/certificates", response_model=CertificateDetailResponse, status_code=201)
def create_certificate(data: CertificateCreate, db: Session = Depends(get_db)):
try:
cert = Certificate(**data.model_dump())
db.add(cert)
db.commit()
db.refresh(cert)
logger.info(f"创建证书成功: id={cert.id}, title={cert.title}")
return cert
except Exception as e:
db.rollback()
logger.error(f"创建证书失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建证书失败")
@router.get("/certificates/{cert_id}", response_model=CertificateDetailResponse)
def get_certificate(cert_id: int, db: Session = Depends(get_db)):
try:
return get_or_404(db, Certificate, cert_id, "证书")
except HTTPException:
raise
except Exception as e:
logger.error(f"获取证书失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取证书失败")
@router.put("/certificates/{cert_id}", response_model=CertificateDetailResponse)
def update_certificate(cert_id: int, data: CertificateUpdate, db: Session = Depends(get_db)):
try:
cert = get_or_404(db, Certificate, cert_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(cert, field, value)
cert.updated_at = utcnow()
db.commit()
db.refresh(cert)
logger.info(f"更新证书成功: id={cert_id}")
return cert
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新证书失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新证书失败")
@router.delete("/certificates/{cert_id}")
def delete_certificate(cert_id: int, db: Session = Depends(get_db)):
try:
cert = get_or_404(db, Certificate, cert_id, "证书")
db.delete(cert)
db.commit()
logger.info(f"删除证书成功: id={cert_id}")
return DeleteResponse(message="证书删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除证书失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除证书失败")

556
api/app/routers/goals.py Normal file
View File

@@ -0,0 +1,556 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List
from app.database import get_db
from app.models.goal import Goal, GoalStep, GoalReview, GoalCheckin
from app.models.task import Task
from app.schemas.goal import (
GoalCreate, GoalUpdate, GoalListResponse, GoalDetailResponse, GoalStatusUpdate,
GoalStepCreate, GoalStepUpdate, GoalStepResponse,
GoalReviewCreate, GoalReviewResponse,
GoalCheckinCreate, GoalCheckinUpdate, GoalCheckinResponse,
ReorderRequest,
)
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(prefix="/api/goals", tags=["目标"])
def recalc_progress(db: Session, goal_id: int):
"""根据追踪类型重新计算目标进度。里程碑模式按步骤完成比例;累计模式按打卡值/换算率/目标值。"""
goal = db.query(Goal).filter(Goal.id == goal_id).first()
if not goal:
return 0
if goal.track_type == "cumulative" and goal.target_value and goal.target_value > 0:
total = db.query(func.coalesce(func.sum(GoalCheckin.value), 0)).filter(
GoalCheckin.goal_id == goal_id,
).scalar()
goal.current_value = float(total)
progress_in_target = goal.current_value / goal.conversion_rate if goal.conversion_rate else goal.current_value
progress = int(progress_in_target / goal.target_value * 100)
return min(progress, 100)
total = db.query(GoalStep).filter(
GoalStep.goal_id == goal_id,
GoalStep.step_type == "milestone",
).count()
if total == 0:
return 0
completed = db.query(GoalStep).filter(
GoalStep.goal_id == goal_id,
GoalStep.step_type == "milestone",
GoalStep.status == "completed",
).count()
return int(completed / total * 100)
def build_step_tree(steps: list[GoalStep]) -> list[dict]:
"""将扁平的 step 列表转为树形结构phase 包含子 milestone按 sort_order 排序"""
step_map = {}
roots = []
for s in sorted(steps, key=lambda x: (x.sort_order or 0)):
step_map[s.id] = {
"id": s.id,
"goal_id": s.goal_id,
"parent_id": s.parent_id,
"title": s.title,
"step_type": s.step_type,
"status": s.status,
"target_date": s.target_date,
"reached_at": s.reached_at,
"sort_order": s.sort_order,
"created_at": s.created_at,
"children": [],
}
for s in sorted(steps, key=lambda x: (x.sort_order or 0)):
node = step_map[s.id]
if s.parent_id and s.parent_id in step_map:
step_map[s.parent_id]["children"].append(node)
else:
roots.append(node)
return roots
# ============ Goals CRUD ============
@router.get("", response_model=List[GoalListResponse])
def get_goals(
status: str | None = None,
db: Session = Depends(get_db),
):
"""获取所有目标"""
try:
query = db.query(Goal)
if status:
query = query.filter(Goal.status == status)
goals = query.order_by(Goal.sort_order, Goal.created_at.desc()).all()
result = []
for g in goals:
total = db.query(GoalStep).filter(
GoalStep.goal_id == g.id,
GoalStep.step_type == "milestone",
).count()
completed = db.query(GoalStep).filter(
GoalStep.goal_id == g.id,
GoalStep.step_type == "milestone",
GoalStep.status == "completed",
).count()
g.total_steps = total
g.completed_steps = completed
# 累计模式重新计算进度和当前值
if g.track_type == "cumulative":
g.current_value = float(db.query(func.coalesce(func.sum(GoalCheckin.value), 0)).filter(
GoalCheckin.goal_id == g.id,
).scalar())
g.progress = recalc_progress(db, g.id)
result.append(g)
return result
except Exception as e:
logger.error(f"获取目标列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取目标列表失败")
@router.post("", response_model=GoalDetailResponse, status_code=201)
def create_goal(data: GoalCreate, db: Session = Depends(get_db)):
"""创建目标"""
try:
goal = Goal(**data.model_dump())
db.add(goal)
db.commit()
db.refresh(goal)
logger.info(f"创建目标成功: id={goal.id}, title={goal.title}")
return goal
except Exception as e:
db.rollback()
logger.error(f"创建目标失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建目标失败")
@router.get("/{goal_id}", response_model=GoalDetailResponse)
def get_goal(goal_id: int, db: Session = Depends(get_db)):
"""获取目标详情(含 steps 树、reviews、关联 tasks、checkins"""
try:
goal = get_or_404(db, Goal, goal_id, "目标")
goal.total_steps = db.query(GoalStep).filter(
GoalStep.goal_id == goal_id,
GoalStep.step_type == "milestone",
).count()
goal.completed_steps = db.query(GoalStep).filter(
GoalStep.goal_id == goal_id,
GoalStep.step_type == "milestone",
GoalStep.status == "completed",
).count()
if goal.track_type == "cumulative":
goal.current_value = float(db.query(func.coalesce(func.sum(GoalCheckin.value), 0)).filter(
GoalCheckin.goal_id == goal_id,
).scalar())
goal.progress = recalc_progress(db, goal_id)
return goal
except HTTPException:
raise
except Exception as e:
logger.error(f"获取目标详情失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取目标详情失败")
@router.put("/{goal_id}", response_model=GoalDetailResponse)
def update_goal(goal_id: int, data: GoalUpdate, db: Session = Depends(get_db)):
"""更新目标"""
try:
goal = get_or_404(db, Goal, goal_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(goal, field, value)
goal.updated_at = utcnow()
db.commit()
db.refresh(goal)
logger.info(f"更新目标成功: id={goal_id}")
return goal
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新目标失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新目标失败")
@router.delete("/{goal_id}")
def delete_goal(goal_id: int, db: Session = Depends(get_db)):
"""删除目标(级联删除 steps + reviews"""
try:
goal = get_or_404(db, Goal, goal_id, "目标")
db.delete(goal)
db.commit()
logger.info(f"删除目标成功: id={goal_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("/{goal_id}/status", response_model=GoalDetailResponse)
def update_goal_status(goal_id: int, data: GoalStatusUpdate, db: Session = Depends(get_db)):
"""更新目标状态"""
try:
goal = get_or_404(db, Goal, goal_id, "目标")
goal.status = data.status
if data.status == "completed":
goal.completed_at = utcnow()
goal.progress = 100
else:
goal.completed_at = None
goal.updated_at = utcnow()
db.commit()
db.refresh(goal)
logger.info(f"更新目标状态成功: id={goal_id}, status={data.status}")
return goal
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新目标状态失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新目标状态失败")
# ============ Steps ============
@router.post("/{goal_id}/steps", response_model=GoalStepResponse, status_code=201)
def create_step(goal_id: int, data: GoalStepCreate, db: Session = Depends(get_db)):
"""添加阶段/里程碑"""
try:
get_or_404(db, Goal, goal_id, "目标")
# 自动分配 sort_order同类步骤中取最大值 + 1
max_sort = db.query(GoalStep).filter(
GoalStep.goal_id == goal_id,
GoalStep.step_type == data.step_type,
).order_by(GoalStep.sort_order.desc()).first()
next_sort = (max_sort.sort_order + 1) if max_sort and max_sort.sort_order is not None else 0
step = GoalStep(goal_id=goal_id, sort_order=next_sort, **data.model_dump())
db.add(step)
db.commit()
db.refresh(step)
# 重算进度
goal = db.query(Goal).filter(Goal.id == goal_id).first()
if goal:
goal.progress = recalc_progress(db, goal_id)
goal.updated_at = utcnow()
db.commit()
logger.info(f"添加{data.step_type}成功: id={step.id}, goal_id={goal_id}")
return step
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"添加步骤失败: {str(e)}")
raise HTTPException(status_code=500, detail="添加步骤失败")
# ============ Reorder ============
@router.put("/{goal_id}/steps/reorder")
def reorder_steps(goal_id: int, data: ReorderRequest, db: Session = Depends(get_db)):
"""批量更新步骤排序"""
try:
get_or_404(db, Goal, goal_id, "目标")
for item in data.items:
step = db.query(GoalStep).filter(
GoalStep.id == item.id,
GoalStep.goal_id == goal_id,
).first()
if step:
step.sort_order = item.sort_order
db.commit()
logger.info(f"步骤排序更新成功: goal_id={goal_id}, count={len(data.items)}")
return {"message": "排序更新成功"}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新步骤排序失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新排序失败")
@router.put("/{goal_id}/steps/{step_id}", response_model=GoalStepResponse)
def update_step(goal_id: int, step_id: int, data: GoalStepUpdate, db: Session = Depends(get_db)):
"""更新阶段/里程碑"""
try:
get_or_404(db, Goal, goal_id, "目标")
step = get_or_404(db, GoalStep, step_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(step, field, value)
db.commit()
db.refresh(step)
# 重算进度
goal = db.query(Goal).filter(Goal.id == goal_id).first()
if goal:
goal.progress = recalc_progress(db, goal_id)
goal.updated_at = utcnow()
db.commit()
logger.info(f"更新步骤成功: id={step_id}")
return step
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新步骤失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新步骤失败")
@router.delete("/{goal_id}/steps/{step_id}")
def delete_step(goal_id: int, step_id: int, db: Session = Depends(get_db)):
"""删除阶段/里程碑(级联删除子 step"""
try:
get_or_404(db, Goal, goal_id, "目标")
step = get_or_404(db, GoalStep, step_id, "步骤")
db.delete(step)
db.commit()
# 重算进度
goal = db.query(Goal).filter(Goal.id == goal_id).first()
if goal:
goal.progress = recalc_progress(db, goal_id)
goal.updated_at = utcnow()
db.commit()
logger.info(f"删除步骤成功: id={step_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("/{goal_id}/steps/{step_id}/toggle", response_model=GoalStepResponse)
def toggle_step(goal_id: int, step_id: int, db: Session = Depends(get_db)):
"""切换步骤状态 (pending → in_progress → completed → pending)"""
try:
get_or_404(db, Goal, goal_id, "目标")
step = get_or_404(db, GoalStep, step_id, "步骤")
cycle = {"pending": "in_progress", "in_progress": "completed", "completed": "pending"}
step.status = cycle.get(step.status, "pending")
if step.status == "completed":
step.reached_at = utcnow()
else:
step.reached_at = None
db.commit()
db.refresh(step)
# 重算进度
goal = db.query(Goal).filter(Goal.id == goal_id).first()
if goal:
goal.progress = recalc_progress(db, goal_id)
goal.updated_at = utcnow()
db.commit()
logger.info(f"切换步骤状态成功: id={step_id}, status={step.status}")
return step
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"切换步骤状态失败: {str(e)}")
raise HTTPException(status_code=500, detail="切换步骤状态失败")
# ============ Reviews ============
@router.post("/{goal_id}/reviews", response_model=GoalReviewResponse, status_code=201)
def create_review(goal_id: int, data: GoalReviewCreate, db: Session = Depends(get_db)):
"""创建复盘记录"""
try:
get_or_404(db, Goal, goal_id, "目标")
review = GoalReview(goal_id=goal_id, **data.model_dump())
db.add(review)
db.commit()
db.refresh(review)
logger.info(f"创建复盘成功: goal_id={goal_id}")
return review
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"创建复盘失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建复盘失败")
@router.delete("/{goal_id}/reviews/{review_id}")
def delete_review(goal_id: int, review_id: int, db: Session = Depends(get_db)):
"""删除复盘记录"""
try:
get_or_404(db, Goal, goal_id, "目标")
review = get_or_404(db, GoalReview, review_id, "复盘记录")
db.delete(review)
db.commit()
logger.info(f"删除复盘成功: id={review_id}")
return DeleteResponse(message="复盘记录删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除复盘失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除复盘失败")
# ============ Task Linking ============
@router.post("/{goal_id}/tasks/{task_id}")
def link_task(goal_id: int, task_id: int, db: Session = Depends(get_db)):
"""关联任务到目标"""
try:
goal = get_or_404(db, Goal, goal_id, "目标")
task = get_or_404(db, Task, task_id, "任务")
if task not in goal.tasks:
goal.tasks.append(task)
db.commit()
logger.info(f"关联任务成功: goal_id={goal_id}, task_id={task_id}")
return {"message": "关联成功"}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"关联任务失败: {str(e)}")
raise HTTPException(status_code=500, detail="关联任务失败")
@router.delete("/{goal_id}/tasks/{task_id}")
def unlink_task(goal_id: int, task_id: int, db: Session = Depends(get_db)):
"""取消关联任务"""
try:
goal = get_or_404(db, Goal, goal_id, "目标")
task = get_or_404(db, Task, task_id, "任务")
if task in goal.tasks:
goal.tasks.remove(task)
db.commit()
logger.info(f"取消关联任务成功: goal_id={goal_id}, task_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="取消关联任务失败")
# ============ Checkins (累计打卡) ============
@router.get("/{goal_id}/checkins", response_model=List[GoalCheckinResponse])
def get_checkins(goal_id: int, db: Session = Depends(get_db)):
"""获取目标的打卡记录列表"""
try:
get_or_404(db, Goal, goal_id, "目标")
checkins = db.query(GoalCheckin).filter(
GoalCheckin.goal_id == goal_id,
).order_by(GoalCheckin.checkin_date.desc(), GoalCheckin.created_at.desc()).all()
return checkins
except HTTPException:
raise
except Exception as e:
logger.error(f"获取打卡记录失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取打卡记录失败")
@router.post("/{goal_id}/checkins", response_model=GoalCheckinResponse, status_code=201)
def create_checkin(goal_id: int, data: GoalCheckinCreate, db: Session = Depends(get_db)):
"""创建打卡记录并重算进度"""
try:
get_or_404(db, Goal, goal_id, "目标")
checkin = GoalCheckin(goal_id=goal_id, **data.model_dump())
db.add(checkin)
db.commit()
db.refresh(checkin)
goal = db.query(Goal).filter(Goal.id == goal_id).first()
if goal:
goal.progress = recalc_progress(db, goal_id)
goal.updated_at = utcnow()
db.commit()
logger.info(f"创建打卡记录成功: id={checkin.id}, goal_id={goal_id}, value={checkin.value}")
return checkin
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"创建打卡记录失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建打卡记录失败")
@router.put("/{goal_id}/checkins/{checkin_id}", response_model=GoalCheckinResponse)
def update_checkin(goal_id: int, checkin_id: int, data: GoalCheckinUpdate, db: Session = Depends(get_db)):
"""修改打卡记录并重算进度"""
try:
get_or_404(db, Goal, goal_id, "目标")
checkin = get_or_404(db, GoalCheckin, checkin_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(checkin, field, value)
db.commit()
db.refresh(checkin)
goal = db.query(Goal).filter(Goal.id == goal_id).first()
if goal:
goal.progress = recalc_progress(db, goal_id)
goal.updated_at = utcnow()
db.commit()
logger.info(f"更新打卡记录成功: id={checkin_id}")
return checkin
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新打卡记录失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新打卡记录失败")
@router.delete("/{goal_id}/checkins/{checkin_id}")
def delete_checkin(goal_id: int, checkin_id: int, db: Session = Depends(get_db)):
"""删除打卡记录并重算进度"""
try:
get_or_404(db, Goal, goal_id, "目标")
checkin = get_or_404(db, GoalCheckin, checkin_id, "打卡记录")
db.delete(checkin)
db.commit()
goal = db.query(Goal).filter(Goal.id == goal_id).first()
if goal:
goal.progress = recalc_progress(db, goal_id)
goal.updated_at = utcnow()
db.commit()
logger.info(f"删除打卡记录成功: id={checkin_id}")
return DeleteResponse(message="打卡记录删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除打卡记录失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除打卡记录失败")

152
api/app/routers/sync.py Normal file
View File

@@ -0,0 +1,152 @@
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import SyncSettings
from app.schemas.sync import (
SyncConfigUpdate, SyncConfigResponse,
SyncStatusResponse, SyncTestResponse, SyncOperationResponse,
)
from app.utils.crypto import encrypt, decrypt
from app.utils.webdav import WebDAVClient
from app.utils.sync_service import push_to_remote, pull_from_remote, bidirectional_sync
from app.utils.sync_lock import is_syncing
router = APIRouter(prefix="/api/sync", tags=["同步"])
def _get_or_create_settings(db: Session) -> SyncSettings:
settings = db.query(SyncSettings).filter(SyncSettings.id == 1).first()
if not settings:
settings = SyncSettings(id=1)
db.add(settings)
db.commit()
db.refresh(settings)
return settings
@router.get("/config", response_model=SyncConfigResponse)
def get_sync_config(request: Request, db: Session = Depends(get_db)):
settings = _get_or_create_settings(db)
response = SyncConfigResponse(
webdav_url=settings.webdav_url,
webdav_username=settings.webdav_username,
webdav_password="***" if settings.webdav_password else None,
webdav_path=settings.webdav_path or "/elysia-todo/",
sync_enabled=settings.sync_enabled,
auto_sync=settings.auto_sync,
auto_sync_interval=settings.auto_sync_interval or 300,
last_sync_at=settings.last_sync_at,
last_sync_version=settings.last_sync_version or 0,
)
return response
@router.put("/config", response_model=SyncConfigResponse)
def update_sync_config(config: SyncConfigUpdate, request: Request, db: Session = Depends(get_db)):
settings = _get_or_create_settings(db)
if config.webdav_url is not None:
settings.webdav_url = config.webdav_url
if config.webdav_username is not None:
settings.webdav_username = config.webdav_username
if config.webdav_password is not None and config.webdav_password != "***":
settings.webdav_password = encrypt(config.webdav_password)
if config.webdav_path is not None:
settings.webdav_path = config.webdav_path
if config.auto_sync is not None:
settings.auto_sync = config.auto_sync
if config.auto_sync_interval is not None:
settings.auto_sync_interval = config.auto_sync_interval
db.commit()
db.refresh(settings)
return SyncConfigResponse(
webdav_url=settings.webdav_url,
webdav_username=settings.webdav_username,
webdav_password="***" if settings.webdav_password else None,
webdav_path=settings.webdav_path or "/elysia-todo/",
sync_enabled=settings.sync_enabled,
auto_sync=settings.auto_sync,
auto_sync_interval=settings.auto_sync_interval or 300,
last_sync_at=settings.last_sync_at,
last_sync_version=settings.last_sync_version or 0,
)
@router.post("/test", response_model=SyncTestResponse)
def test_connection(request: Request, db: Session = Depends(get_db)):
settings = _get_or_create_settings(db)
if not settings.webdav_url:
return SyncTestResponse(success=False, message="未配置 WebDAV 地址")
password = decrypt(settings.webdav_password) if settings.webdav_password else ""
if settings.webdav_password and password is None:
return SyncTestResponse(success=False, message="WebDAV 密码解密失败,请重新配置")
client = WebDAVClient(
url=settings.webdav_url,
username=settings.webdav_username or "",
password=password or "",
path=settings.webdav_path or "/elysia-todo/",
)
success, message = client.test_connection()
return SyncTestResponse(success=success, message=message)
@router.post("/push", response_model=SyncOperationResponse)
def sync_push(request: Request, db: Session = Depends(get_db)):
result = push_to_remote(db)
return SyncOperationResponse(**result)
@router.post("/pull", response_model=SyncOperationResponse)
def sync_pull(request: Request, db: Session = Depends(get_db)):
result = pull_from_remote(db)
return SyncOperationResponse(**result)
@router.post("/sync", response_model=SyncOperationResponse)
def sync_bidirectional(request: Request, db: Session = Depends(get_db)):
result = bidirectional_sync(db)
return SyncOperationResponse(**result)
@router.get("/status", response_model=SyncStatusResponse)
def get_sync_status(request: Request, db: Session = Depends(get_db)):
settings = _get_or_create_settings(db)
return SyncStatusResponse(
syncing=is_syncing(),
last_sync_at=settings.last_sync_at,
last_sync_version=settings.last_sync_version or 0,
sync_enabled=settings.sync_enabled,
)
@router.delete("/remote", response_model=SyncOperationResponse)
def clear_remote(request: Request, db: Session = Depends(get_db)):
from app.utils.webdav import WebDAVClient
from app.utils.crypto import decrypt
settings = _get_or_create_settings(db)
if not settings.webdav_url:
return SyncOperationResponse(success=False, message="未配置 WebDAV 地址")
password = decrypt(settings.webdav_password) if settings.webdav_password else ""
if settings.webdav_password and password is None:
return SyncOperationResponse(success=False, message="WebDAV 密码解密失败,请重新配置")
client = WebDAVClient(
url=settings.webdav_url,
username=settings.webdav_username or "",
password=password or "",
path=settings.webdav_path or "/elysia-todo/",
)
success = client.clear_remote()
if success:
return SyncOperationResponse(success=True, message="远端数据已清空")
return SyncOperationResponse(success=False, message="清空远端数据失败")

View File

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

View File

@@ -54,6 +54,7 @@ class AnniversaryCategoryUpdate(BaseModel):
class AnniversaryCategoryResponse(AnniversaryCategoryBase): class AnniversaryCategoryResponse(AnniversaryCategoryBase):
"""纪念日分类响应模型""" """纪念日分类响应模型"""
id: int id: int
uuid: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -111,6 +112,7 @@ class AnniversaryUpdate(BaseModel):
class AnniversaryResponse(AnniversaryBase): class AnniversaryResponse(AnniversaryBase):
"""纪念日响应模型""" """纪念日响应模型"""
id: int id: int
uuid: Optional[str] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
category: Optional[AnniversaryCategoryResponse] = None category: Optional[AnniversaryCategoryResponse] = None

View File

@@ -1,15 +1,48 @@
from pydantic import BaseModel, Field from typing import Optional
from pydantic import BaseModel, Field, field_validator
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
password: str = Field(..., min_length=1, max_length=100) password: str = Field(..., min_length=1, max_length=100)
class TokenResponse(BaseModel): class LoginResponse(BaseModel):
access_token: str message: str = "登录成功"
token_type: str = "bearer"
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
old_password: str = Field(..., min_length=1, max_length=100) old_password: str = Field(..., min_length=1, max_length=100)
new_password: str = Field(..., min_length=1, max_length=100) new_password: str = Field(..., min_length=6, max_length=100)
@field_validator("new_password")
@classmethod
def validate_password_strength(cls, v: str) -> str:
if len(v) < 6:
raise ValueError("密码长度至少6位")
if len(set(v)) < 3:
raise ValueError("密码不能过于简单需包含至少3种不同字符")
return v
class AuthStatusResponse(BaseModel):
authenticated: bool
user_id: str
class SetupPasswordRequest(BaseModel):
password: str = Field(..., min_length=6, max_length=100)
nickname: Optional[str] = Field(None, min_length=1, max_length=50)
@field_validator("password")
@classmethod
def validate_password_strength(cls, v: str) -> str:
if len(v) < 6:
raise ValueError("密码长度至少6位")
if len(set(v)) < 3:
raise ValueError("密码不能过于简单需包含至少3种不同字符")
return v
class AuthSetupStatusResponse(BaseModel):
has_password: bool

14
api/app/schemas/backup.py Normal file
View File

@@ -0,0 +1,14 @@
"""数据备份导入导出 Schema"""
from pydantic import BaseModel
from datetime import datetime, date
from typing import Optional, Any
class BackupMetadata(BaseModel):
version: int = 1
exported_at: datetime
class BackupPayload(BaseModel):
metadata: BackupMetadata
data: dict[str, list[dict[str, Any]]]

View File

@@ -1,4 +1,5 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional
class CategoryBase(BaseModel): class CategoryBase(BaseModel):
@@ -23,6 +24,7 @@ class CategoryUpdate(BaseModel):
class CategoryResponse(CategoryBase): class CategoryResponse(CategoryBase):
"""分类响应模型""" """分类响应模型"""
id: int id: int
uuid: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -0,0 +1,79 @@
"""证书 Schema"""
from pydantic import BaseModel, Field, field_validator
from datetime import date, datetime
from typing import Optional, List
# ============ 证书分类 Schema ============
class CertificateCategoryBase(BaseModel):
name: str = Field(..., max_length=50)
icon: str = Field(default="medal", max_length=50)
color: str = Field(default="#FFB7C5", max_length=20)
sort_order: int = Field(default=0)
class CertificateCategoryCreate(CertificateCategoryBase):
pass
class CertificateCategoryUpdate(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 CertificateCategoryResponse(CertificateCategoryBase):
id: int
uuid: Optional[str] = None
class Config:
from_attributes = True
# ============ 证书 Schema ============
class CertificateBase(BaseModel):
title: str = Field(..., max_length=200)
category_id: Optional[int] = None
image: Optional[str] = None
issuer: Optional[str] = Field(None, max_length=200)
issue_date: Optional[date] = None
expiry_date: Optional[date] = None
description: Optional[str] = None
sort_order: int = Field(default=0)
class CertificateCreate(CertificateBase):
pass
class CertificateUpdate(BaseModel):
title: Optional[str] = Field(None, max_length=200)
category_id: Optional[int] = None
image: Optional[str] = None
issuer: Optional[str] = Field(None, max_length=200)
issue_date: Optional[date] = None
expiry_date: Optional[date] = None
description: Optional[str] = None
sort_order: Optional[int] = None
@property
def clearable_fields(self) -> set:
return {"description", "category_id", "image", "issuer", "issue_date", "expiry_date"}
class CertificateListResponse(CertificateBase):
id: int
uuid: Optional[str] = None
created_at: datetime
updated_at: datetime
category: Optional[CertificateCategoryResponse] = None
class Config:
from_attributes = True
class CertificateDetailResponse(CertificateListResponse):
pass

177
api/app/schemas/goal.py Normal file
View File

@@ -0,0 +1,177 @@
from pydantic import BaseModel, Field, field_validator
from datetime import datetime, date
from typing import Optional, List
from app.schemas.category import CategoryResponse
from app.schemas.task import TaskResponse
# ============ GoalCheckin Schemas ============
class GoalCheckinCreate(BaseModel):
value: float = Field(..., gt=0)
note: Optional[str] = None
checkin_date: date
class GoalCheckinUpdate(BaseModel):
value: Optional[float] = Field(None, gt=0)
note: Optional[str] = None
checkin_date: Optional[date] = None
@property
def clearable_fields(self) -> set:
return {"note"}
class GoalCheckinResponse(BaseModel):
id: int
uuid: Optional[str] = None
goal_id: int
value: float
note: Optional[str] = None
checkin_date: date
created_at: datetime
class Config:
from_attributes = True
# ============ GoalStep Schemas ============
class GoalStepBase(BaseModel):
title: str = Field(..., max_length=200)
step_type: str = Field(..., pattern="^(phase|milestone)$")
status: str = Field(default="pending", pattern="^(pending|in_progress|completed)$")
target_date: Optional[date] = None
parent_id: Optional[int] = None
sort_order: int = Field(default=0)
class GoalStepCreate(GoalStepBase):
pass
class GoalStepUpdate(BaseModel):
title: Optional[str] = Field(None, max_length=200)
step_type: Optional[str] = Field(None, pattern="^(phase|milestone)$")
status: Optional[str] = Field(None, pattern="^(pending|in_progress|completed)$")
target_date: Optional[date] = None
parent_id: Optional[int] = None
sort_order: Optional[int] = None
@property
def clearable_fields(self) -> set:
return {"target_date", "parent_id"}
class GoalStepResponse(GoalStepBase):
id: int
uuid: Optional[str] = None
goal_id: int
reached_at: Optional[datetime] = None
created_at: datetime
children: List["GoalStepResponse"] = []
class Config:
from_attributes = True
# ============ GoalReview Schemas ============
class GoalReviewCreate(BaseModel):
content: str = Field(..., min_length=1)
rating: Optional[int] = Field(None, ge=1, le=5)
class GoalReviewResponse(BaseModel):
id: int
uuid: Optional[str] = None
goal_id: int
content: str
rating: Optional[int] = None
created_at: datetime
class Config:
from_attributes = True
# ============ Goal Schemas ============
class GoalBase(BaseModel):
title: str = Field(..., max_length=200)
description: Optional[str] = None
status: str = Field(default="active", pattern="^(active|paused|completed|abandoned)$")
track_type: str = Field(default="milestone", pattern="^(milestone|cumulative)$")
target_value: Optional[float] = None
target_unit: Optional[str] = Field(None, max_length=20)
input_unit: Optional[str] = Field(None, max_length=20)
conversion_rate: float = Field(default=1.0)
target_date: Optional[date] = None
category_id: Optional[int] = None
color: str = Field(default="#FFB7C5", max_length=20)
icon: str = Field(default="flag", max_length=50)
sort_order: int = Field(default=0)
class GoalCreate(GoalBase):
pass
class GoalUpdate(BaseModel):
title: Optional[str] = Field(None, max_length=200)
description: Optional[str] = None
status: Optional[str] = Field(None, pattern="^(active|paused|completed|abandoned)$")
track_type: Optional[str] = Field(None, pattern="^(milestone|cumulative)$")
target_value: Optional[float] = None
target_unit: Optional[str] = Field(None, max_length=20)
input_unit: Optional[str] = Field(None, max_length=20)
conversion_rate: Optional[float] = None
target_date: Optional[date] = None
category_id: Optional[int] = None
color: Optional[str] = Field(None, max_length=20)
icon: Optional[str] = Field(None, max_length=50)
sort_order: Optional[int] = None
@property
def clearable_fields(self) -> set:
return {"description", "target_date", "category_id",
"target_value", "target_unit", "input_unit"}
class GoalListResponse(GoalBase):
id: int
uuid: Optional[str] = None
progress: int
current_value: float = 0
completed_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
category: Optional[CategoryResponse] = None
total_steps: int = 0
completed_steps: int = 0
class Config:
from_attributes = True
class GoalDetailResponse(GoalListResponse):
steps: List[GoalStepResponse] = []
reviews: List[GoalReviewResponse] = []
checkins: List[GoalCheckinResponse] = []
tasks: List[TaskResponse] = []
class GoalStatusUpdate(BaseModel):
status: str = Field(..., pattern="^(active|paused|completed|abandoned)$")
# ============ Reorder Schema ============
class ReorderItem(BaseModel):
id: int
sort_order: int
class ReorderRequest(BaseModel):
items: list[ReorderItem]

View File

@@ -29,6 +29,7 @@ class HabitGroupUpdate(BaseModel):
class HabitGroupResponse(HabitGroupBase): class HabitGroupResponse(HabitGroupBase):
"""习惯分组响应模型""" """习惯分组响应模型"""
id: int id: int
uuid: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -68,6 +69,7 @@ class HabitUpdate(BaseModel):
class HabitResponse(HabitBase): class HabitResponse(HabitBase):
"""习惯响应模型""" """习惯响应模型"""
id: int id: int
uuid: Optional[str] = None
is_archived: bool is_archived: bool
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -87,6 +89,7 @@ class CheckinCreate(BaseModel):
class CheckinResponse(BaseModel): class CheckinResponse(BaseModel):
"""打卡记录响应模型""" """打卡记录响应模型"""
id: int id: int
uuid: Optional[str] = None
habit_id: int habit_id: int
checkin_date: date checkin_date: date
count: int count: int

50
api/app/schemas/sync.py Normal file
View File

@@ -0,0 +1,50 @@
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class SyncConfigBase(BaseModel):
webdav_url: Optional[str] = None
webdav_username: Optional[str] = None
webdav_password: Optional[str] = None
webdav_path: str = "/elysia-todo/"
auto_sync: bool = False
auto_sync_interval: int = Field(default=300, ge=60)
class SyncConfigUpdate(SyncConfigBase):
webdav_url: Optional[str] = None
webdav_username: Optional[str] = None
webdav_password: Optional[str] = None
class SyncConfigResponse(BaseModel):
webdav_url: Optional[str] = None
webdav_username: Optional[str] = None
webdav_password: Optional[str] = None
webdav_path: str = "/elysia-todo/"
sync_enabled: bool = False
auto_sync: bool = False
auto_sync_interval: int = 300
last_sync_at: Optional[datetime] = None
last_sync_version: int = 0
class Config:
from_attributes = True
class SyncStatusResponse(BaseModel):
syncing: bool
last_sync_at: Optional[datetime] = None
last_sync_version: int = 0
sync_enabled: bool = False
class SyncTestResponse(BaseModel):
success: bool
message: str
class SyncOperationResponse(BaseModel):
success: bool
message: str

View File

@@ -1,4 +1,5 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional
class TagBase(BaseModel): class TagBase(BaseModel):
@@ -14,6 +15,7 @@ class TagCreate(TagBase):
class TagResponse(TagBase): class TagResponse(TagBase):
"""标签响应模型""" """标签响应模型"""
id: int id: int
uuid: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -75,6 +75,7 @@ class TaskUpdate(BaseModel):
class TaskResponse(TaskBase): class TaskResponse(TaskBase):
"""任务响应模型""" """任务响应模型"""
id: int id: int
uuid: Optional[str] = None
is_completed: bool is_completed: bool
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@@ -1,25 +1,31 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
import bcrypt
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Request, HTTPException from fastapi import Request, HTTPException
from sqlalchemy.orm import Session
from app.config import JWT_SECRET, ACCESS_TOKEN_EXPIRE_MINUTES from app.config import JWT_SECRET, ACCESS_TOKEN_EXPIRE_MINUTES
from app.database import get_db
from app.models.user_settings import UserSettings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256" ALGORITHM = "HS256"
_token_version_cache: dict[str, int] = {}
def get_cached_token_version(user_id: str) -> int | None:
return _token_version_cache.get(user_id)
def set_cached_token_version(user_id: str, version: int):
_token_version_cache[user_id] = version
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
return pwd_context.hash(password) return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password) return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
@@ -34,8 +40,9 @@ def decode_access_token(token: str) -> dict:
def get_current_user(request: Request) -> dict: def get_current_user(request: Request) -> dict:
auth_header = request.headers.get("Authorization", "") if hasattr(request.state, "user") and request.state.user:
token = auth_header.replace("Bearer ", "") return request.state.user
token = request.cookies.get("access_token", "")
if not token: if not token:
raise HTTPException(status_code=401, detail="未登录") raise HTTPException(status_code=401, detail="未登录")
try: try:
@@ -43,9 +50,3 @@ def get_current_user(request: Request) -> dict:
return payload return payload
except JWTError: except JWTError:
raise HTTPException(status_code=401, detail="登录已过期,请重新登录") raise HTTPException(status_code=401, detail="登录已过期,请重新登录")
def set_default_password(db: Session, settings: UserSettings):
if not settings.password_hash:
settings.password_hash = hash_password("elysia")
db.commit()

53
api/app/utils/crypto.py Normal file
View File

@@ -0,0 +1,53 @@
"""
AES-256-GCM 加解密工具
密钥从 JWT_SECRET 派生,用于加密 WebDAV 密码等敏感信息
"""
import base64
import os
import hashlib
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from app.config import JWT_SECRET
_SALT = b"elysia-todo-sync-v1"
_NONCE_SIZE = 12 # AES-GCM 标准 nonce 长度
def _derive_key() -> bytes:
"""从 JWT_SECRET 派生 256-bit AES 密钥"""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=_SALT,
iterations=480000,
)
return kdf.derive(JWT_SECRET.encode("utf-8"))
def encrypt(plaintext: str) -> str:
"""AES-256-GCM 加密,返回 base64(iv + ciphertext + tag)"""
if not plaintext:
return ""
key = _derive_key()
nonce = os.urandom(_NONCE_SIZE)
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
return base64.b64encode(nonce + ciphertext).decode("ascii")
def decrypt(encrypted: str) -> str | None:
"""AES-256-GCM 解密,解密失败返回 None"""
if not encrypted:
return None
try:
key = _derive_key()
raw = base64.b64decode(encrypted)
nonce = raw[:_NONCE_SIZE]
ciphertext = raw[_NONCE_SIZE:]
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
return plaintext.decode("utf-8")
except Exception:
return None

View File

@@ -0,0 +1,59 @@
"""
登录频率限制器(内存实现,单用户场景适用)
"""
import time
class LoginRateLimiter:
"""IP 级别的登录频率限制"""
MAX_ATTEMPTS = 5
WINDOW_SECONDS = 900 # 统计窗口15 分钟
LOCKOUT_SECONDS = 900 # 锁定时间15 分钟
def __init__(self):
self._attempts: dict[str, list[float]] = {}
self._lockout: dict[str, float] = {}
def _cleanup(self):
"""清除过期的记录"""
now = time.time()
self._attempts = {
ip: [t for t in times if now - t < self.WINDOW_SECONDS]
for ip, times in self._attempts.items()
}
self._attempts = {ip: times for ip, times in self._attempts.items() if times}
self._lockout = {
ip: until for ip, until in self._lockout.items()
if now < until
}
def check(self, ip: str) -> tuple[bool, int | None]:
"""返回 (是否允许, 剩余等待秒数)"""
self._cleanup()
now = time.time()
if ip in self._lockout:
remaining = int(self._lockout[ip] - now)
if remaining > 0:
return False, remaining
del self._lockout[ip]
return True, None
def record_failure(self, ip: str):
"""记录一次失败"""
now = time.time()
self._attempts.setdefault(ip, []).append(now)
if len(self._attempts[ip]) >= self.MAX_ATTEMPTS:
self._lockout[ip] = now + self.LOCKOUT_SECONDS
def reset(self, ip: str):
"""登录成功后重置计数"""
self._attempts.pop(ip, None)
self._lockout.pop(ip, None)
login_limiter = LoginRateLimiter()

View File

@@ -0,0 +1,37 @@
"""
全局同步锁与同步模式标记
同步期间禁止所有写操作,前端显示同步遮罩
"""
import threading
_sync_lock = threading.Lock()
_sync_in_progress = False
_sync_mode = threading.local()
def acquire_sync_lock() -> bool:
"""非阻塞获取同步锁,成功返回 True"""
acquired = _sync_lock.acquire(blocking=False)
if acquired:
global _sync_in_progress
_sync_in_progress = True
_sync_mode.active = True
return acquired
def release_sync_lock():
"""释放同步锁"""
global _sync_in_progress
_sync_in_progress = False
_sync_mode.active = False
_sync_lock.release()
def is_syncing() -> bool:
"""检查是否正在同步"""
return _sync_in_progress
def is_sync_mode() -> bool:
"""检查当前线程是否在同步模式中(跳过 sync_version 自增)"""
return getattr(_sync_mode, 'active', False)

View File

@@ -0,0 +1,567 @@
"""
同步核心服务
处理 push / pull / bidirectional merge 逻辑
"""
from datetime import datetime, date as date_type
import json
import os
from sqlalchemy.orm import Session
from app.database import SessionLocal
from app.models import (
Task, Category, Tag, task_tags, UserSettings,
HabitGroup, Habit, HabitCheckin,
AnniversaryCategory, Anniversary,
Goal, GoalStep, GoalReview, goal_tasks,
SyncSettings,
)
from app.utils.crypto import encrypt, decrypt
from app.utils.webdav import WebDAVClient
from app.utils.sync_lock import acquire_sync_lock, release_sync_lock
from app.utils.logger import logger
from app.utils.datetime import utcnow
SYNC_COLLECTIONS = [
("categories", Category),
("tags", Tag),
("tasks", Task),
("habit_groups", HabitGroup),
("habits", Habit),
("habit_checkins", HabitCheckin),
("anniversary_categories", AnniversaryCategory),
("anniversaries", Anniversary),
("goals", Goal),
("goal_steps", GoalStep),
("goal_reviews", GoalReview),
]
ASSOCIATION_COLLECTIONS = [
("task_tags", task_tags, "tasks", "tags"),
("goal_tasks", goal_tasks, "goals", "tasks"),
]
USER_SETTINGS_SYNC_FIELDS = [
"nickname", "avatar", "signature", "birthday", "email",
"site_name", "theme", "language", "default_view",
"default_sort_by", "default_sort_order",
]
MODEL_MAP = {
"tasks": Task,
"categories": Category,
"tags": Tag,
"habit_groups": HabitGroup,
"habits": Habit,
"anniversary_categories": AnniversaryCategory,
"anniversaries": Anniversary,
"goals": Goal,
"goal_steps": GoalStep,
"goal_reviews": GoalReview,
"user_settings": UserSettings,
}
def _get_sync_settings(db: Session) -> SyncSettings:
settings = db.query(SyncSettings).filter(SyncSettings.id == 1).first()
if not settings:
settings = SyncSettings(id=1)
db.add(settings)
db.commit()
db.refresh(settings)
return settings
def _create_webdav_client(settings: SyncSettings) -> WebDAVClient | None:
if not settings.webdav_url:
return None
password = decrypt(settings.webdav_password) if settings.webdav_password else ""
if settings.webdav_password and password is None:
logger.error("WebDAV 密码解密失败,可能 JWT_SECRET 已更改")
return None
return WebDAVClient(
url=settings.webdav_url,
username=settings.webdav_username or "",
password=password,
path=settings.webdav_path or "/elysia-todo/",
)
def _serialize_model(obj, model_class) -> dict:
result = {}
for col in model_class.__table__.columns:
val = getattr(obj, col.name, None)
if isinstance(val, datetime):
val = val.isoformat() if val else None
elif isinstance(val, date_type):
val = val.isoformat() if val else None
result[col.name] = val
return result
def _serialize_association(row, left_model, right_model, db: Session) -> dict | None:
left_id, right_id = row[0], row[1]
left_obj = db.query(left_model).filter(left_model.id == left_id).first()
right_obj = db.query(right_model).filter(right_model.id == right_id).first()
if not left_obj or not right_obj or not left_obj.uuid or not right_obj.uuid:
return None
return {
f"{left_model.__tablename__}_uuid": left_obj.uuid,
f"{right_model.__tablename__}_uuid": right_obj.uuid,
}
def _convert_value(val, col_type_name: str):
if val is None:
return None
if isinstance(val, (datetime, date_type)):
return val
if not isinstance(val, str):
return val
if "DateTime" in col_type_name:
try:
return datetime.fromisoformat(val.replace("Z", "+00:00"))
except (ValueError, TypeError):
pass
elif "Date" in col_type_name and "Time" not in col_type_name:
try:
return date_type.fromisoformat(val)
except (ValueError, TypeError):
pass
return val
def _item_to_model_kwargs(item: dict, model_class) -> dict:
"""将远程 JSON 对象转换为可用于创建模型的 kwargs保留 uuid 和 sync_version"""
kwargs = {}
for col in model_class.__table__.columns:
if col.name not in item:
continue
val = item[col.name]
if col.name == "id":
continue
col_type_name = type(col.type).__name__
val = _convert_value(val, col_type_name)
kwargs[col.name] = val
return kwargs
def _backup_local(db: Session) -> str:
timestamp = utcnow().strftime("%Y-%m-%dT%H-%M-%S")
backup_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data", "backups", timestamp
)
os.makedirs(backup_dir, exist_ok=True)
for coll_name, model_class in SYNC_COLLECTIONS:
rows = db.query(model_class).filter(model_class.is_deleted == False).all()
items = [_serialize_model(r, model_class) for r in rows]
filepath = os.path.join(backup_dir, f"{coll_name}.json")
with open(filepath, "w", encoding="utf-8") as f:
json.dump({"collection": coll_name, "items": items}, f, ensure_ascii=False, indent=2, default=str)
logger.info(f"本地数据已备份到: {backup_dir}")
return backup_dir
def push_to_remote(db: Session) -> dict:
settings = _get_sync_settings(db)
client = _create_webdav_client(settings)
if not client:
return {"success": False, "message": "WebDAV 未配置或密码解密失败"}
if not acquire_sync_lock():
return {"success": False, "message": "同步正在进行中"}
try:
client.ensure_dirs()
timestamp = utcnow().strftime("%Y-%m-%dT%H-%M-%S")
client.backup_remote(timestamp)
for coll_name, model_class in SYNC_COLLECTIONS:
rows = db.query(model_class).all()
items = [_serialize_model(r, model_class) for r in rows]
data = {
"version": 1,
"collection": coll_name,
"updated_at": utcnow().isoformat(),
"items": items,
}
if not client.upload_json(f"{coll_name}.json", data):
return {"success": False, "message": f"上传 {coll_name} 失败"}
for assoc_name, assoc_table, left_name, right_name in ASSOCIATION_COLLECTIONS:
left_model = MODEL_MAP.get(left_name)
right_model = MODEL_MAP.get(right_name)
if not left_model or not right_model:
continue
rows = db.execute(assoc_table.select()).fetchall()
items = []
for row in rows:
item = _serialize_association(row, left_model, right_model, db)
if item:
items.append(item)
client.upload_json(f"{assoc_name}.json", {
"version": 1,
"collection": assoc_name,
"updated_at": utcnow().isoformat(),
"items": items,
})
user_settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
if user_settings:
pref_data = {}
for field in USER_SETTINGS_SYNC_FIELDS:
val = getattr(user_settings, field, None)
if isinstance(val, (datetime, date_type)):
val = val.isoformat() if val else None
pref_data[field] = val
client.upload_json("user_settings.json", {
"version": 1,
"collection": "user_settings",
"updated_at": utcnow().isoformat(),
"items": [pref_data],
})
manifest = {
"version": 1,
"last_sync_at": utcnow().isoformat(),
"collections": {},
}
for coll_name, model_class in SYNC_COLLECTIONS:
count = db.query(model_class).filter(model_class.is_deleted == False).count()
manifest["collections"][coll_name] = {
"count": count,
"updated_at": utcnow().isoformat(),
}
client.upload_json("manifest.json", manifest)
settings.last_sync_at = utcnow()
settings.last_sync_version = (settings.last_sync_version or 0) + 1
settings.sync_enabled = True
db.commit()
return {"success": True, "message": "推送成功"}
except Exception as e:
logger.error(f"推送失败: {e}", exc_info=True)
db.rollback()
return {"success": False, "message": f"推送失败: {str(e)}"}
finally:
release_sync_lock()
def pull_from_remote(db: Session) -> dict:
settings = _get_sync_settings(db)
client = _create_webdav_client(settings)
if not client:
return {"success": False, "message": "WebDAV 未配置或密码解密失败"}
if not acquire_sync_lock():
return {"success": False, "message": "同步正在进行中"}
try:
_backup_local(db)
for coll_name, model_class in SYNC_COLLECTIONS:
remote_data = client.download_json(f"{coll_name}.json")
if remote_data is None:
continue
db.query(model_class).delete()
db.commit()
for item in remote_data.get("items", []):
kwargs = _item_to_model_kwargs(item, model_class)
is_deleted = kwargs.pop("is_deleted", False)
obj = model_class(**kwargs)
obj.is_deleted = bool(is_deleted)
db.add(obj)
db.commit()
for assoc_name, assoc_table, left_name, right_name in ASSOCIATION_COLLECTIONS:
remote_data = client.download_json(f"{assoc_name}.json")
if remote_data is None:
continue
db.execute(assoc_table.delete())
db.commit()
left_model = MODEL_MAP.get(left_name)
right_model = MODEL_MAP.get(right_name)
if not left_model or not right_model:
continue
for item in remote_data.get("items", []):
left_uuid = item.get(f"{left_name}_uuid")
right_uuid = item.get(f"{right_name}_uuid")
if not left_uuid or not right_uuid:
continue
left_obj = db.query(left_model).filter(left_model.uuid == left_uuid).first()
right_obj = db.query(right_model).filter(right_model.uuid == right_uuid).first()
if left_obj and right_obj:
db.execute(assoc_table.insert().values(
left_id=left_obj.id, right_id=right_obj.id
))
db.commit()
remote_prefs = client.download_json("user_settings.json")
if remote_prefs and remote_prefs.get("items"):
pref = remote_prefs["items"][0]
user_settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
if user_settings:
for field in USER_SETTINGS_SYNC_FIELDS:
if field in pref and pref[field] is not None:
val = pref[field]
if isinstance(val, str) and field == "birthday":
try:
val = date_type.fromisoformat(val)
except (ValueError, TypeError):
pass
setattr(user_settings, field, val)
db.commit()
settings.last_sync_at = utcnow()
settings.last_sync_version = (settings.last_sync_version or 0) + 1
settings.sync_enabled = True
db.commit()
return {"success": True, "message": "拉取成功"}
except Exception as e:
logger.error(f"拉取失败: {e}", exc_info=True)
db.rollback()
return {"success": False, "message": f"拉取失败: {str(e)}"}
finally:
release_sync_lock()
def bidirectional_sync(db: Session) -> dict:
settings = _get_sync_settings(db)
client = _create_webdav_client(settings)
if not client:
return {"success": False, "message": "WebDAV 未配置或密码解密失败"}
if not acquire_sync_lock():
return {"success": False, "message": "同步正在进行中"}
try:
client.ensure_dirs()
stats = {"pushed": 0, "pulled": 0, "merged": 0, "deleted": 0}
for coll_name, model_class in SYNC_COLLECTIONS:
remote_data = client.download_json(f"{coll_name}.json")
remote_by_uuid = {}
remote_deleted_uuids = set()
if remote_data:
for item in remote_data.get("items", []):
uuid_val = item.get("uuid")
if not uuid_val:
continue
if item.get("is_deleted"):
remote_deleted_uuids.add(uuid_val)
else:
remote_by_uuid[uuid_val] = item
local_objs = db.query(model_class).all()
local_by_uuid = {}
local_deleted_uuids = set()
for obj in local_objs:
if obj.uuid:
if obj.is_deleted:
local_deleted_uuids.add(obj.uuid)
local_by_uuid[obj.uuid] = obj
all_uuids = set(remote_by_uuid.keys()) | set(local_by_uuid.keys()) | remote_deleted_uuids | local_deleted_uuids
for uuid_val in all_uuids:
remote_item = remote_by_uuid.get(uuid_val)
local_obj = local_by_uuid.get(uuid_val)
remote_deleted = uuid_val in remote_deleted_uuids and uuid_val not in remote_by_uuid
local_deleted = uuid_val in local_deleted_uuids and uuid_val not in local_by_uuid
# 两边都删除了 → 什么都不做
if remote_deleted and local_deleted:
continue
# 远端删除了,本地还在 → 删除本地
if remote_deleted and local_obj:
local_obj.is_deleted = True
local_obj.sync_version = (local_obj.sync_version or 0) + 1
stats["deleted"] += 1
continue
# 本地删除了,远端还在 → 删除远端标记(本地占优在这里意味着:远端也应标记删除)
# 这里简单处理:如果本地标记了删除但远端还活着,以远端为准拉取回来
if local_deleted and not remote_deleted and not local_obj and remote_item:
kwargs = _item_to_model_kwargs(remote_item, model_class)
kwargs.pop("is_deleted", None)
new_obj = model_class(**kwargs)
db.add(new_obj)
stats["pulled"] += 1
continue
# 仅远端有 → 拉取到本地
if remote_item and not local_obj:
kwargs = _item_to_model_kwargs(remote_item, model_class)
kwargs.pop("is_deleted", None)
new_obj = model_class(**kwargs)
db.add(new_obj)
stats["pulled"] += 1
continue
# 仅本地有 → 会在最后统一上传时推送到远端
if not remote_item and local_obj and not local_deleted:
stats["pushed"] += 1
continue
# 两边都有 → LWW 合并
if remote_item and local_obj:
remote_ver = remote_item.get("sync_version", 1) or 1
local_ver = local_obj.sync_version or 1
if remote_ver > local_ver:
kwargs = _item_to_model_kwargs(remote_item, model_class)
kwargs.pop("is_deleted", None)
kwargs.pop("sync_version", None)
for key, val in kwargs.items():
if val is not None or key in getattr(local_obj, '__clearable_fields__', set()):
setattr(local_obj, key, val)
local_obj.sync_version = remote_ver
stats["merged"] += 1
elif local_ver > remote_ver:
local_obj.sync_version = local_ver
stats["pushed"] += 1
else:
# 版本相同,以远端为准
kwargs = _item_to_model_kwargs(remote_item, model_class)
kwargs.pop("is_deleted", None)
kwargs.pop("sync_version", None)
for key, val in kwargs.items():
setattr(local_obj, key, val)
local_obj.sync_version = local_ver + 1
stats["merged"] += 1
db.commit()
# 合并关联表
for assoc_name, assoc_table, left_name, right_name in ASSOCIATION_COLLECTIONS:
remote_data = client.download_json(f"{assoc_name}.json")
if remote_data is None:
continue
left_model = MODEL_MAP.get(left_name)
right_model = MODEL_MAP.get(right_name)
if not left_model or not right_model:
continue
remote_pairs = set()
for item in remote_data.get("items", []):
left_uuid = item.get(f"{left_name}_uuid")
right_uuid = item.get(f"{right_name}_uuid")
if left_uuid and right_uuid:
remote_pairs.add((left_uuid, right_uuid))
local_pairs = set()
rows = db.execute(assoc_table.select()).fetchall()
for row in rows:
left_id, right_id = row[0], row[1]
left_obj = db.query(left_model).filter(left_model.id == left_id).first()
right_obj = db.query(right_model).filter(right_model.id == right_id).first()
if left_obj and right_obj and left_obj.uuid and right_obj.uuid:
local_pairs.add((left_obj.uuid, right_obj.uuid))
merged_pairs = local_pairs | remote_pairs
db.execute(assoc_table.delete())
for left_uuid, right_uuid in merged_pairs:
left_obj = db.query(left_model).filter(left_model.uuid == left_uuid).first()
right_obj = db.query(right_model).filter(right_model.uuid == right_uuid).first()
if left_obj and right_obj:
db.execute(assoc_table.insert().values(left_id=left_obj.id, right_id=right_obj.id))
db.commit()
# 合并 user_settings
remote_prefs = client.download_json("user_settings.json")
if remote_prefs and remote_prefs.get("items"):
pref = remote_prefs["items"][0]
user_settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
if user_settings:
for field in USER_SETTINGS_SYNC_FIELDS:
if field in pref and pref[field] is not None:
val = pref[field]
if isinstance(val, str) and field == "birthday":
try:
val = date_type.fromisoformat(val)
except (ValueError, TypeError):
pass
setattr(user_settings, field, val)
db.commit()
# 统一上传合并后的数据到远端
_upload_all_to_remote(db, client)
settings.last_sync_at = utcnow()
settings.last_sync_version = (settings.last_sync_version or 0) + 1
settings.sync_enabled = True
db.commit()
return {"success": True, "message": f"同步完成: 推送 {stats['pushed']}, 拉取 {stats['pulled']}, 合并 {stats['merged']}, 删除 {stats['deleted']}"}
except Exception as e:
logger.error(f"双向同步失败: {e}", exc_info=True)
db.rollback()
return {"success": False, "message": f"同步失败: {str(e)}"}
finally:
release_sync_lock()
def _upload_all_to_remote(db: Session, client: WebDAVClient):
"""将本地所有数据上传到远端"""
for coll_name, model_class in SYNC_COLLECTIONS:
items = [_serialize_model(obj, model_class) for obj in db.query(model_class).all()]
client.upload_json(f"{coll_name}.json", {
"version": 1,
"collection": coll_name,
"updated_at": utcnow().isoformat(),
"items": items,
})
for assoc_name, assoc_table, left_name, right_name in ASSOCIATION_COLLECTIONS:
left_model = MODEL_MAP.get(left_name)
right_model = MODEL_MAP.get(right_name)
if not left_model or not right_model:
continue
rows = db.execute(assoc_table.select()).fetchall()
items = [_serialize_association(row, left_model, right_model, db) for row in rows]
items = [i for i in items if i is not None]
client.upload_json(f"{assoc_name}.json", {
"version": 1,
"collection": assoc_name,
"updated_at": utcnow().isoformat(),
"items": items,
})
user_settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
if user_settings:
pref_data = {}
for field in USER_SETTINGS_SYNC_FIELDS:
val = getattr(user_settings, field, None)
if isinstance(val, (datetime, date_type)):
val = val.isoformat() if val else None
pref_data[field] = val
client.upload_json("user_settings.json", {
"version": 1,
"collection": "user_settings",
"updated_at": utcnow().isoformat(),
"items": [pref_data],
})
manifest = {
"version": 1,
"last_sync_at": utcnow().isoformat(),
"collections": {},
}
for coll_name, model_class in SYNC_COLLECTIONS:
count = db.query(model_class).filter(model_class.is_deleted == False).count()
manifest["collections"][coll_name] = {
"count": count,
"updated_at": utcnow().isoformat(),
}
client.upload_json("manifest.json", manifest)

150
api/app/utils/webdav.py Normal file
View File

@@ -0,0 +1,150 @@
"""
WebDAV 客户端工具
基于 requests 实现,兼容 Alist 等 WebDAV 服务
"""
import json
from datetime import datetime
from typing import Any
import requests
from requests.auth import HTTPBasicAuth
from app.utils.logger import logger
class WebDAVClient:
"""WebDAV 客户端,用于与 Alist 等 WebDAV 服务交互"""
def __init__(self, url: str, username: str, password: str, path: str = "/elysia-todo/"):
self.base_url = url.rstrip("/")
self.username = username
self.password = username # Alist 使用用户名作为密码
self.auth = HTTPBasicAuth(username, self.password)
self.path = path if path.startswith("/") else f"/{path}"
self._session = requests.Session()
self._session.auth = self.auth
self._session.timeout = 30
self._session.headers.update({"Content-Type": "application/json"})
@property
def _data_url(self) -> str:
return f"{self.base_url}{self.path}data/"
@property
def _backups_url(self) -> str:
return f"{self.base_url}{self.path}backups/"
def _url(self, filename: str) -> str:
return f"{self._data_url}{filename}"
def _manifest_url(self) -> str:
return f"{self.base_url}{self.path}manifest.json"
def test_connection(self) -> tuple[bool, str]:
"""测试 WebDAV 连接,返回 (成功, 消息)"""
try:
resp = self._session.request("PROPFIND", f"{self.base_url}{self.path}", headers={"Depth": "0"})
if resp.status_code in (200, 207, 404):
return True, "连接成功"
return False, f"连接失败: HTTP {resp.status_code}"
except requests.ConnectionError:
return False, "连接失败: 无法连接到服务器"
except requests.Timeout:
return False, "连接超时"
except Exception as e:
return False, f"连接失败: {str(e)}"
def ensure_dirs(self) -> bool:
"""确保远端目录结构存在"""
try:
for path in [self.path, f"{self.path}data/", f"{self.path}backups/"]:
url = f"{self.base_url}{path}"
self._session.request("PROPFIND", url, headers={"Depth": "0"})
resp = self._session.request("MKCOL", url)
if resp.status_code in (200, 201, 405, 301):
pass
return True
except Exception as e:
logger.error(f"创建远端目录失败: {e}")
return False
def upload_json(self, filename: str, data: Any) -> bool:
"""上传 JSON 数据到 WebDAV"""
try:
url = self._url(filename) if filename != "manifest.json" else self._manifest_url()
content = json.dumps(data, ensure_ascii=False, indent=2, default=str).encode("utf-8")
headers = {"Content-Type": "application/json"}
resp = self._session.put(url, data=content, headers=headers)
if resp.status_code in (200, 201, 204):
return True
logger.error(f"上传 {filename} 失败: HTTP {resp.status_code}")
return False
except Exception as e:
logger.error(f"上传 {filename} 异常: {e}")
return False
def download_json(self, filename: str) -> Any | None:
"""从 WebDAV 下载 JSON 数据"""
try:
url = self._url(filename) if filename != "manifest.json" else self._manifest_url()
resp = self._session.get(url)
if resp.status_code == 200:
return resp.json()
if resp.status_code == 404:
return None
logger.error(f"下载 {filename} 失败: HTTP {resp.status_code}")
return None
except Exception as e:
logger.error(f"下载 {filename} 异常: {e}")
return None
def delete_file(self, filename: str) -> bool:
"""删除 WebDAV 上的文件"""
try:
url = self._url(filename) if filename != "manifest.json" else self._manifest_url()
resp = self._session.delete(url)
return resp.status_code in (200, 204, 404)
except Exception as e:
logger.error(f"删除 {filename} 异常: {e}")
return False
def backup_remote(self, timestamp: str) -> bool:
"""备份远端数据到 backups/{timestamp}/"""
try:
backup_path = f"{self.path}backups/{timestamp}/data/"
backup_url = f"{self.base_url}{backup_path}"
self._session.request("MKCOL", f"{self.base_url}{self.path}backups/")
self._session.request("MKCOL", f"{self.base_url}{self.path}backups/{timestamp}/")
self._session.request("MKCOL", backup_url)
for filename in [
"manifest.json", "user_settings.json", "categories.json",
"tasks.json", "tags.json", "task_tags.json",
"habit_groups.json", "habits.json", "habit_checkins.json",
"anniversary_categories.json", "anniversaries.json",
"goals.json", "goal_steps.json", "goal_reviews.json", "goal_tasks.json",
]:
data = self.download_json(filename)
if data is not None:
src_url = self._url(filename) if filename != "manifest.json" else self._manifest_url()
dst_url = f"{backup_url}{filename}" if filename != "manifest.json" else f"{self.base_url}{backup_path}../manifest.json"
content = json.dumps(data, ensure_ascii=False, indent=2, default=str).encode("utf-8")
self._session.put(dst_url, data=content, headers={"Content-Type": "application/json"})
return True
except Exception as e:
logger.error(f"备份远端数据失败: {e}")
return False
def clear_remote(self) -> bool:
"""清空远端数据目录"""
filenames = [
"manifest.json", "user_settings.json", "categories.json",
"tasks.json", "tags.json", "task_tags.json",
"habit_groups.json", "habits.json", "habit_checkins.json",
"anniversary_categories.json", "anniversaries.json",
"goals.json", "goal_steps.json", "goal_reviews.json", "goal_tasks.json",
]
for f in filenames:
self.delete_file(f)
return True

View File

@@ -5,10 +5,10 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title> <title>webui</title>
<script type="module" crossorigin src="/assets/index-BDYzX3N-.js"></script> <script type="module" crossorigin src="/assets/index-BX5HkU7A.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-CwFI-VDq.js"> <link rel="modulepreload" crossorigin href="/assets/vendor-CwFI-VDq.js">
<link rel="modulepreload" crossorigin href="/assets/element-plus-DsX44Q4d.js"> <link rel="modulepreload" crossorigin href="/assets/element-plus-CljBHM1G.js">
<link rel="stylesheet" crossorigin href="/assets/index-DOz3B-pr.css"> <link rel="stylesheet" crossorigin href="/assets/index-O358hdvS.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -1,14 +1,30 @@
services: services:
db:
image: postgres:18-alpine
container_name: elysia-todo-db
restart: unless-stopped
environment:
POSTGRES_USER: ToDoList
POSTGRES_PASSWORD: 53N2PTSjMBPDy6zY
POSTGRES_DB: ToDoList
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
elysia-todo: elysia-todo:
build: . build: .
container_name: elysia-todo container_name: elysia-todo
restart: unless-stopped restart: unless-stopped
ports: ports:
- "23994:23994" - "23994:23994"
environment:
DATABASE_URL: "postgresql://ToDoList:53N2PTSjMBPDy6zY@db:5432/ToDoList"
depends_on:
- db
volumes: volumes:
# 挂载前端编译产物,本地修改后可立即生效
- ./api/webui:/app/api/webui:ro - ./api/webui:/app/api/webui:ro
# 挂载数据库文件,持久化数据
- ./api/data:/app/api/data
# 挂载日志目录,方便查看日志
- ./api/logs:/app/api/logs - ./api/logs:/app/api/logs
volumes:
pgdata:

View File

@@ -5,5 +5,5 @@ pydantic==2.5.3
pydantic-settings==2.1.0 pydantic-settings==2.1.0
python-multipart==0.0.6 python-multipart==0.0.6
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4 bcrypt==4.0.1
bcrypt==4.2.1 psycopg2-binary==2.9.11

53
scripts/start.bat Normal file
View File

@@ -0,0 +1,53 @@
@echo off
chcp 65001 >nul 2>&1
title 爱莉希雅待办事项
echo ====================================================
echo 爱莉希雅待办事项 - 启动脚本
echo ====================================================
echo.
:: 项目根目录(脚本所在目录的上级)
set "PROJECT_ROOT=%~dp0.."
cd /d "%PROJECT_ROOT%"
:: 检查 Python
where python >nul 2>&1
if errorlevel 1 (
echo [错误] 未找到 Python请确保已安装并添加到 PATH
pause
exit /b 1
)
:: 检查依赖
if not exist "api\app\__init__.py" (
echo [错误] 未找到项目文件,请确认在项目根目录运行
pause
exit /b 1
)
:: 安装 Python 依赖(如需)
if not exist "api\__pycache__" (
echo [信息] 首次运行,安装 Python 依赖...
pip install -r requirements.txt -q
if errorlevel 1 (
echo [错误] Python 依赖安装失败
pause
exit /b 1
)
)
:: 检查端口占用
echo [信息] 检查端口 23994 占用情况...
for /f "tokens=5" %%a in ('netstat -ano -p TCP ^| findstr ":23994.*LISTENING"') do (
echo [警告] 端口 23994 已被进程 %%a 占用,正在尝试终止...
taskkill /PID %%a /F >nul 2>&1
timeout /t 2 /nobreak >nul
)
:: 启动项目
echo [信息] 正在启动项目...
echo [信息] 访问地址: http://localhost:23994
echo.
python main.py
pause

42
scripts/stop.bat Normal file
View File

@@ -0,0 +1,42 @@
@echo off
chcp 65001 >nul 2>&1
title 爱莉希雅待办事项 - 停止
echo ====================================================
echo 爱莉希雅待办事项 - 停止脚本
echo ====================================================
echo.
set "PORT=23994"
set "FOUND=0"
:: 查找并终止占用端口的进程
for /f "tokens=5" %%a in ('netstat -ano -p TCP ^| findstr ":%PORT%.*LISTENING"') do (
echo [信息] 发现进程 %%a 占用端口 %PORT%
taskkill /PID %%a /F >nul 2>&1
if errorlevel 1 (
echo [错误] 终止进程 %%a 失败,请尝试手动终止
) else (
echo [成功] 进程 %%a 已终止
set "FOUND=1"
)
)
if "%FOUND%"=="0" (
echo [信息] 端口 %PORT% 未被占用,无需停止
) else (
echo.
echo [信息] 等待端口释放...
timeout /t 2 /nobreak >nul
:: 二次确认
for /f "tokens=5" %%a in ('netstat -ano -p TCP ^| findstr ":%PORT%.*LISTENING"') do (
echo [警告] 端口 %PORT% 仍被进程 %%a 占用,再次尝试终止...
taskkill /PID %%a /F >nul 2>&1
timeout /t 1 /nobreak >nul
)
echo [成功] 项目已停止
)
echo.
pause

View File

@@ -1,618 +0,0 @@
"""
资产总览功能 - 全面测试脚本
测试覆盖:账户 CRUD、余额更新、变更历史、分期计划 CRUD、还款操作
"""
import requests
import sys
from datetime import date
BASE_URL = "http://localhost:23994/api"
passed = 0
failed = 0
errors = []
def test(name, condition, detail=""):
global passed, failed
if condition:
passed += 1
print(f" [PASS] {name}")
else:
failed += 1
errors.append(name)
print(f" [FAIL] {name} {detail}")
def section(title):
print(f"\n{'='*60}")
print(f" {title}")
print(f"{'='*60}")
# ============================================================
section("1. 账户 CRUD 测试")
# ============================================================
# 1.1 创建存款账户 - 微信
print("\n--- 创建存款账户 ---")
r = requests.post(f"{BASE_URL}/accounts", json={
"name": "微信",
"account_type": "savings",
"balance": 5800.50,
"icon": "wechat",
"color": "#67C23A",
"is_active": True,
"description": "日常零钱"
})
test("创建微信账户", r.status_code == 201, f"status={r.status_code}")
wechat = r.json()
test("微信账户ID存在", wechat.get("id") is not None)
test("微信余额正确", wechat.get("balance") == 5800.50)
test("微信类型正确", wechat.get("account_type") == "savings")
wechat_id = wechat["id"]
# 1.2 创建存款账户 - 支付宝
r = requests.post(f"{BASE_URL}/accounts", json={
"name": "支付宝",
"account_type": "savings",
"balance": 12300.00,
"icon": "alipay",
"color": "#1677FF",
"is_active": True,
"description": "工资卡"
})
test("创建支付宝账户", r.status_code == 201, f"status={r.status_code}")
alipay = r.json()
alipay_id = alipay["id"]
# 1.3 创建存款账户 - 银行卡
r = requests.post(f"{BASE_URL}/accounts", json={
"name": "招商银行",
"account_type": "savings",
"balance": 45600.00,
"icon": "bank",
"color": "#FF6B6B",
"is_active": True
})
test("创建招商银行账户", r.status_code == 201, f"status={r.status_code}")
bank = r.json()
bank_id = bank["id"]
# 1.4 创建欠款账户 - 花呗
r = requests.post(f"{BASE_URL}/accounts", json={
"name": "花呗",
"account_type": "debt",
"balance": 3000.00,
"icon": "credit-card",
"color": "#FFB347",
"is_active": True,
"description": "分3期每月12号还"
})
test("创建花呗账户", r.status_code == 201, f"status={r.status_code}")
huabei = r.json()
huabei_id = huabei["id"]
# 1.5 创建欠款账户 - 白条
r = requests.post(f"{BASE_URL}/accounts", json={
"name": "白条",
"account_type": "debt",
"balance": 2000.00,
"icon": "ticket",
"color": "#E6A23C",
"is_active": True,
"description": "分6期每月15号还"
})
test("创建白条账户", r.status_code == 201, f"status={r.status_code}")
baitiao = r.json()
baitiao_id = baitiao["id"]
# 1.6 创建一个已禁用的账户
r = requests.post(f"{BASE_URL}/accounts", json={
"name": "已注销信用卡",
"account_type": "debt",
"balance": 0,
"icon": "credit-card",
"color": "#909399",
"is_active": False,
"description": "测试禁用状态"
})
test("创建已禁用账户", r.status_code == 201)
disabled_id = r.json()["id"]
# ============================================================
section("2. 获取账户列表测试")
# ============================================================
print("\n--- 获取所有账户 ---")
r = requests.get(f"{BASE_URL}/accounts")
test("获取账户列表 200", r.status_code == 200, f"status={r.status_code}")
accounts = r.json()
test("账户总数正确", len(accounts) >= 5, f"实际数量: {len(accounts)}")
savings = [a for a in accounts if a["account_type"] == "savings" and a["is_active"]]
debt = [a for a in accounts if a["account_type"] == "debt" and a["is_active"]]
test("活跃存款账户数量", len(savings) == 3, f"实际: {len(savings)}")
test("活跃欠款账户数量", len(debt) == 2, f"实际: {len(debt)}")
# 2.1 获取单个账户
print("\n--- 获取单个账户 ---")
r = requests.get(f"{BASE_URL}/accounts/{wechat_id}")
test("获取单个微信账户 200", r.status_code == 200)
test("账户名称正确", r.json().get("name") == "微信")
# 2.2 获取不存在的账户
r = requests.get(f"{BASE_URL}/accounts/99999")
test("获取不存在账户 404", r.status_code == 404, f"status={r.status_code}")
# ============================================================
section("3. 更新账户测试")
# ============================================================
print("\n--- 更新账户信息 ---")
r = requests.put(f"{BASE_URL}/accounts/{wechat_id}", json={
"description": "日常零钱+红包"
})
test("更新微信描述 200", r.status_code == 200, f"status={r.status_code}")
test("描述更新成功", r.json().get("description") == "日常零钱+红包")
# 3.1 确认 balance 字段被忽略
r = requests.put(f"{BASE_URL}/accounts/{wechat_id}", json={
"balance": 99999 # 应该被忽略
})
test("更新忽略 balance", r.json().get("balance") == 5800.50,
f"余额不应被修改, 实际: {r.json().get('balance')}")
# ============================================================
section("4. 余额更新与变更历史测试")
# ============================================================
print("\n--- 更新微信余额 ---")
r = requests.post(f"{BASE_URL}/accounts/{wechat_id}/balance", json={
"new_balance": 6800.50,
"note": "收到红包"
})
test("更新微信余额 200", r.status_code == 200, f"status={r.status_code}")
test("微信余额更新成功", r.json().get("balance") == 6800.50)
print("\n--- 再次更新微信余额 ---")
r = requests.post(f"{BASE_URL}/accounts/{wechat_id}/balance", json={
"new_balance": 5300.50,
"note": "日常消费"
})
test("微信消费后余额", r.json().get("balance") == 5300.50)
print("\n--- 更新支付宝余额 ---")
r = requests.post(f"{BASE_URL}/accounts/{alipay_id}/balance", json={
"new_balance": 15300.00,
"note": "工资到账"
})
test("支付宝工资到账 200", r.status_code == 200)
test("支付宝余额正确", r.json().get("balance") == 15300.00)
print("\n--- 更新招商银行余额 ---")
r = requests.post(f"{BASE_URL}/accounts/{bank_id}/balance", json={
"new_balance": 40000.00,
"note": "取现"
})
test("招商银行取现 200", r.status_code == 200)
print("\n--- 更新花呗余额 ---")
r = requests.post(f"{BASE_URL}/accounts/{huabei_id}/balance", json={
"new_balance": 2500.00,
"note": "提前还款500"
})
test("花呗提前还款 200", r.status_code == 200)
test("花呗余额更新", r.json().get("balance") == 2500.00)
# ============================================================
section("5. 变更历史测试")
# ============================================================
print("\n--- 查看微信变更历史 ---")
r = requests.get(f"{BASE_URL}/accounts/{wechat_id}/history", params={
"page": 1,
"page_size": 10
})
test("获取微信历史 200", r.status_code == 200, f"status={r.status_code}")
history = r.json()
test("历史总数正确", history.get("total") == 2, f"实际: {history.get('total')}")
test("分页参数正确", history.get("page") == 1 and history.get("page_size") == 10)
if history.get("records"):
first_record = history["records"][0]
test("最新记录是消费", first_record.get("change_amount") == -1500.0,
f"实际: {first_record.get('change_amount')}")
test("消费前余额正确", first_record.get("balance_before") == 6800.50)
test("消费后余额正确", first_record.get("balance_after") == 5300.50)
test("消费备注正确", first_record.get("note") == "日常消费")
test("记录有创建时间", first_record.get("created_at") is not None)
print("\n--- 查看支付宝变更历史 ---")
r = requests.get(f"{BASE_URL}/accounts/{alipay_id}/history")
history_alipay = r.json()
test("支付宝历史 200", r.status_code == 200)
test("支付宝历史有记录", history_alipay.get("total") == 1)
print("\n--- 查看从未变更的账户历史 ---")
r = requests.get(f"{BASE_URL}/accounts/{baitiao_id}/history")
test("白条历史 200", r.status_code == 200)
test("白条无变更记录", r.json().get("total") == 0)
# ============================================================
section("6. 分期还款计划 CRUD 测试")
# ============================================================
# 6.1 为花呗创建分期计划3期每月12号每期1000
print("\n--- 创建花呗分期计划 ---")
r = requests.post(f"{BASE_URL}/debt-installments", json={
"account_id": huabei_id,
"total_amount": 3000.00,
"total_periods": 3,
"current_period": 1,
"payment_day": 12,
"payment_amount": 1000.00,
"start_date": "2026-03-12",
"is_completed": False
})
test("创建花呗分期 201", r.status_code == 201, f"status={r.status_code}")
huabei_inst = r.json()
test("花呗分期ID存在", huabei_inst.get("id") is not None)
test("花呗下次还款日期已计算", huabei_inst.get("next_payment_date") is not None)
test("花呗距今天数已计算", huabei_inst.get("days_until_payment") is not None)
test("花呗剩余期数", huabei_inst.get("remaining_periods") == 3)
test("花呗账户名称关联", huabei_inst.get("account_name") == "花呗")
huabei_inst_id = huabei_inst["id"]
# 6.2 为白条创建分期计划6期每月15号每期333.33
print("\n--- 创建白条分期计划 ---")
r = requests.post(f"{BASE_URL}/debt-installments", json={
"account_id": baitiao_id,
"total_amount": 2000.00,
"total_periods": 6,
"current_period": 3,
"payment_day": 15,
"payment_amount": 333.33,
"start_date": "2026-01-15",
"is_completed": False
})
test("创建白条分期 201", r.status_code == 201, f"status={r.status_code}")
baitiao_inst = r.json()
test("白条分期第3期", baitiao_inst.get("current_period") == 3)
test("白条剩余期数", baitiao_inst.get("remaining_periods") == 4)
baitiao_inst_id = baitiao_inst["id"]
# 6.3 验证不能给存款账户创建分期
print("\n--- 验证存款账户不能创建分期 ---")
r = requests.post(f"{BASE_URL}/debt-installments", json={
"account_id": wechat_id,
"total_amount": 1000,
"total_periods": 1,
"current_period": 1,
"payment_day": 1,
"payment_amount": 1000,
"start_date": "2026-04-01",
"is_completed": False
})
test("存款账户不能分期 400", r.status_code == 400, f"status={r.status_code}")
# 6.4 验证不存在的账户
r = requests.post(f"{BASE_URL}/debt-installments", json={
"account_id": 99999,
"total_amount": 1000,
"total_periods": 1,
"current_period": 1,
"payment_day": 1,
"payment_amount": 1000,
"start_date": "2026-04-01",
"is_completed": False
})
test("不存在账户不能分期 404", r.status_code == 404, f"status={r.status_code}")
# ============================================================
section("7. 获取分期计划列表测试")
# ============================================================
print("\n--- 获取所有分期计划 ---")
r = requests.get(f"{BASE_URL}/debt-installments")
test("获取分期列表 200", r.status_code == 200, f"status={r.status_code}")
installments = r.json()
test("分期计划总数 >= 2", len(installments) >= 2, f"实际: {len(installments)}")
# 验证排序:未完成的排前面,临近的排前面
if len(installments) >= 2:
first_active = next((i for i in installments if not i["is_completed"]), None)
test("列表第一个是未完成的", first_active is not None)
if first_active:
test("第一个有还款日期", first_active.get("next_payment_date") is not None)
test("第一个有距今天数", first_active.get("days_until_payment") is not None)
# 验证每个计划都有计算字段
for inst in installments:
test(f"分期#{inst['id']}有计算字段",
inst.get("next_payment_date") is not None and
inst.get("days_until_payment") is not None and
inst.get("remaining_periods") is not None,
f"id={inst['id']}")
# ============================================================
section("8. 更新分期计划测试")
# ============================================================
print("\n--- 更新花呗分期计划 ---")
r = requests.put(f"{BASE_URL}/debt-installments/{huabei_inst_id}", json={
"total_amount": 3500.00,
"payment_amount": 1166.67
})
test("更新花呗分期 200", r.status_code == 200, f"status={r.status_code}")
updated = r.json()
test("花呗总额更新", updated.get("total_amount") == 3500.00)
test("花呗每期金额更新", updated.get("payment_amount") == 1166.67)
# 恢复
requests.put(f"{BASE_URL}/debt-installments/{huabei_inst_id}", json={
"total_amount": 3000.00,
"payment_amount": 1000.00
})
# ============================================================
section("9. 标记还款测试(核心流程)")
# ============================================================
print("\n--- 花呗还款第1期 ---")
r = requests.patch(f"{BASE_URL}/debt-installments/{huabei_inst_id}/pay")
test("花呗还款第1期 200", r.status_code == 200, f"status={r.status_code}")
paid = r.json()
test("当前期数变为2", paid.get("current_period") == 2, f"实际: {paid.get('current_period')}")
test("未完成", paid.get("is_completed") == False)
test("剩余期数变为2", paid.get("remaining_periods") == 2)
print("\n--- 花呗还款第2期 ---")
r = requests.patch(f"{BASE_URL}/debt-installments/{huabei_inst_id}/pay")
test("花呗还款第2期 200", r.status_code == 200)
paid = r.json()
test("当前期数变为3", paid.get("current_period") == 3)
test("剩余期数变为1", paid.get("remaining_periods") == 1)
print("\n--- 花呗还款第3期最后一期---")
r = requests.patch(f"{BASE_URL}/debt-installments/{huabei_inst_id}/pay")
test("花呗还款第3期 200", r.status_code == 200)
paid = r.json()
test("标记为已完成", paid.get("is_completed") == True)
test("当前期数等于总期数", paid.get("current_period") == 3)
test("剩余期数为0", paid.get("remaining_periods") == 0)
print("\n--- 已完成的分期不能继续还款 ---")
r = requests.patch(f"{BASE_URL}/debt-installments/{huabei_inst_id}/pay")
test("已完成分期拒绝还款 400", r.status_code == 400, f"status={r.status_code}")
# ============================================================
section("10. 删除操作测试")
# ============================================================
# 10.1 删除分期计划
print("\n--- 删除白条分期计划 ---")
r = requests.delete(f"{BASE_URL}/debt-installments/{baitiao_inst_id}")
test("删除白条分期 200", r.status_code == 200, f"status={r.status_code}")
# 验证删除后列表中没有了
r = requests.get(f"{BASE_URL}/debt-installments")
ids = [i["id"] for i in r.json()]
test("白条分期已从列表移除", baitiao_inst_id not in ids)
# 10.2 删除已禁用账户
print("\n--- 删除已禁用账户 ---")
r = requests.delete(f"{BASE_URL}/accounts/{disabled_id}")
test("删除已禁用账户 200", r.status_code == 200)
# 10.3 验证删除后
r = requests.get(f"{BASE_URL}/accounts")
ids = [a["id"] for a in r.json()]
test("已禁用账户已移除", disabled_id not in ids)
# 10.4 重新创建一个花呗分期用来测试级联删除
print("\n--- 测试级联删除 ---")
r = requests.post(f"{BASE_URL}/debt-installments", json={
"account_id": huabei_id,
"total_amount": 1000.00,
"total_periods": 1,
"current_period": 1,
"payment_day": 25,
"payment_amount": 1000.00,
"start_date": "2026-03-25",
"is_completed": False
})
cascade_inst_id = r.json()["id"]
r = requests.delete(f"{BASE_URL}/accounts/{huabei_id}")
test("级联删除花呗账户 200", r.status_code == 200)
# 验证分期计划也被删除了
r = requests.get(f"{BASE_URL}/debt-installments")
ids = [i["id"] for i in r.json()]
test("花呗关联分期也被删除", cascade_inst_id not in ids)
# 验证历史记录也被删除
r = requests.get(f"{BASE_URL}/accounts/{huabei_id}/history")
test("花呗账户删除后历史不可访问", r.status_code == 404, f"status={r.status_code}")
# ============================================================
section("11. 边界条件测试")
# ============================================================
print("\n--- 余额更新到0 ---")
r = requests.post(f"{BASE_URL}/accounts/{baitiao_id}/balance", json={
"new_balance": 0,
"note": "还清"
})
test("余额归零 200", r.status_code == 200)
test("余额为0", r.json().get("balance") == 0)
print("\n--- 余额更新到大额 ---")
r = requests.post(f"{BASE_URL}/accounts/{baitiao_id}/balance", json={
"new_balance": 999999.99,
"note": "测试大额"
})
test("大额余额 200", r.status_code == 200)
test("大额余额正确", r.json().get("balance") == 999999.99)
# 恢复
requests.post(f"{BASE_URL}/accounts/{baitiao_id}/balance", json={
"new_balance": 2000.00,
"note": "恢复"
})
print("\n--- 参数校验:缺失必填字段 ---")
r = requests.post(f"{BASE_URL}/accounts", json={
"name": "",
})
test("空名称被拒绝 422", r.status_code == 422, f"status={r.status_code}")
print("\n--- 参数校验:无效类型 ---")
r = requests.post(f"{BASE_URL}/accounts", json={
"name": "测试账户",
"account_type": "invalid_type",
})
test("无效类型被拒绝 422", r.status_code == 422, f"status={r.status_code}")
print("\n--- 分期参数校验 ---")
r = requests.post(f"{BASE_URL}/debt-installments", json={
"account_id": baitiao_id,
"total_amount": 0,
"total_periods": 1,
"current_period": 1,
"payment_day": 1,
"payment_amount": 0,
"start_date": "2026-04-01",
"is_completed": False
})
test("零金额分期被拒绝 422", r.status_code == 422, f"status={r.status_code}")
# ============================================================
section("12. 账户列表的分期信息附加测试")
# ============================================================
print("\n--- 获取白条账户(验证分期信息附加) ---")
# 先给白条创建一个分期
r = requests.post(f"{BASE_URL}/debt-installments", json={
"account_id": baitiao_id,
"total_amount": 2000.00,
"total_periods": 6,
"current_period": 2,
"payment_day": 15,
"payment_amount": 333.33,
"start_date": "2026-01-15",
"is_completed": False
})
test("白条分期重建 201", r.status_code == 201)
r = requests.get(f"{BASE_URL}/accounts")
accounts = r.json()
baitiao_acc = next((a for a in accounts if a["id"] == baitiao_id), None)
test("白条账户有分期附加信息", baitiao_acc is not None and baitiao_acc.get("installments") is not None)
# 存款账户不应有分期信息
wechat_acc = next((a for a in accounts if a["id"] == wechat_id), None)
if wechat_acc:
test("微信账户有分期字段(空列表)", wechat_acc.get("installments") is not None)
test("微信分期列表为空", len(wechat_acc.get("installments", [])) == 0)
# ============================================================
section("13. 历史分页测试")
# ============================================================
print("\n--- 历史分页 ---")
# 先给招商银行做多次变更
for i, (bal, note) in enumerate([
(45000, "存入"),
(42000, "消费"),
(50000, "转入"),
(48000, "理财"),
(52000, "收益"),
]):
requests.post(f"{BASE_URL}/accounts/{bank_id}/balance", json={
"new_balance": bal,
"note": note
})
r = requests.get(f"{BASE_URL}/accounts/{bank_id}/history", params={"page": 1, "page_size": 3})
test("历史分页第1页 200", r.status_code == 200)
page1 = r.json()
test("每页3条", len(page1.get("records", [])) == 3, f"实际: {len(page1.get('records', []))}")
test("总记录6条", page1.get("total") == 6, f"实际: {page1.get('total')}")
r = requests.get(f"{BASE_URL}/accounts/{bank_id}/history", params={"page": 2, "page_size": 3})
test("历史分页第2页 200", r.status_code == 200)
page2 = r.json()
test("第2页3条", len(page2.get("records", [])) == 3, f"实际: {len(page2.get('records', []))}")
# 验证第1页和第2页没有重复
page1_ids = {r["id"] for r in page1["records"]}
page2_ids = {r["id"] for r in page2["records"]}
test("两页记录不重复", len(page1_ids & page2_ids) == 0)
# ============================================================
section("14. 花呗还款日期计算验证")
# ============================================================
print("\n--- 验证还款日期计算 ---")
today = date.today()
r = requests.get(f"{BASE_URL}/debt-installments")
for inst in r.json():
if inst["is_completed"]:
continue
next_date_str = inst.get("next_payment_date")
days_until = inst.get("days_until_payment")
if next_date_str:
next_date = date.fromisoformat(next_date_str)
expected_days = (next_date - today).days
test(f"分期#{inst['id']}天数计算正确", days_until == expected_days,
f"计算: {days_until}, 预期: {expected_days}")
# ============================================================
section("15. 保留测试数据(跳过清理)")
# ============================================================
print("\n--- 保留测试数据供页面展示 ---")
r = requests.get(f"{BASE_URL}/accounts")
remaining_accounts = len(r.json())
test("账户数据已保留", remaining_accounts > 0, f"保留: {remaining_accounts} 个账户")
r = requests.get(f"{BASE_URL}/debt-installments")
remaining_installs = len(r.json())
test("分期数据已保留", remaining_installs >= 0, f"保留: {remaining_installs} 个分期")
# ============================================================
# 最终报告
# ============================================================
print(f"\n{'='*60}")
print(f" 测试报告")
print(f"{'='*60}")
print(f" 通过: {passed}")
print(f" 失败: {failed}")
print(f" 总计: {passed + failed}")
if errors:
print(f"\n 失败项:")
for e in errors:
print(f" - {e}")
print(f"{'='*60}")
if failed > 0:
sys.exit(1)
else:
print("\n 全部测试通过!")
sys.exit(0)