From 3c0386602170c665bf92619e03bf6558cfb7b93c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A5=80=E6=A2=A6?= <3501646051@qq.com> Date: Sun, 17 May 2026 11:21:41 +0800 Subject: [PATCH] feat: add JWT authentication and AGENTS.md --- AGENTS.md | 81 ++ API_DOCS.md | 1580 ---------------------------- README.md | 2 +- WebUI/src/App.vue | 71 +- WebUI/src/api/auth.ts | 19 + WebUI/src/api/request.ts | 14 +- WebUI/src/components/AppHeader.vue | 11 + WebUI/src/router/index.ts | 17 +- WebUI/src/stores/useAuthStore.ts | 49 + WebUI/src/views/LoginView.vue | 157 +++ api/app/config.py | 4 + api/app/main.py | 23 + api/app/models/user_settings.py | 3 + api/app/routers/__init__.py | 5 +- api/app/routers/auth.py | 51 + api/app/schemas/auth.py | 15 + api/app/utils/auth.py | 51 + api/webui/index.html | 30 +- requirements.txt | 3 + 19 files changed, 554 insertions(+), 1632 deletions(-) create mode 100644 AGENTS.md delete mode 100644 API_DOCS.md create mode 100644 WebUI/src/api/auth.ts create mode 100644 WebUI/src/stores/useAuthStore.ts create mode 100644 WebUI/src/views/LoginView.vue create mode 100644 api/app/routers/auth.py create mode 100644 api/app/schemas/auth.py create mode 100644 api/app/utils/auth.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..74b57d6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,81 @@ +# 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 `. + +### 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 ` 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. diff --git a/API_DOCS.md b/API_DOCS.md deleted file mode 100644 index 67626f3..0000000 --- a/API_DOCS.md +++ /dev/null @@ -1,1580 +0,0 @@ -# Elysia ToDo - 接口文档 - -> 爱莉希雅待办事项系统 RESTful API 接口文档 -> 基础地址:`http://localhost:23994` -> 认证方式:无(所有接口均为公开访问) - ---- - -## 目录 - -- [1. 健康检查](#1-健康检查) -- [2. 任务管理 (Tasks)](#2-任务管理-tasks) -- [3. 分类管理 (Categories)](#3-分类管理-categories) -- [4. 标签管理 (Tags)](#4-标签管理-tags) -- [5. 习惯组管理 (Habit Groups)](#5-习惯组管理-habit-groups) -- [6. 习惯管理 (Habits)](#6-习惯管理-habits) -- [7. 习惯打卡 (Habit Check-ins)](#7-习惯打卡-habit-check-ins) -- [8. 纪念日分类 (Anniversary Categories)](#8-纪念日分类-anniversary-categories) -- [9. 纪念日管理 (Anniversaries)](#9-纪念日管理-anniversaries) -- [10. 财务账户 (Accounts)](#10-财务账户-accounts) -- [11. 分期还款 (Debt Installments)](#11-分期还款-debt-installments) -- [12. 用户设置 (User Settings)](#12-用户设置-user-settings) -- [附录:通用响应格式](#附录通用响应格式) -- [附录:数据模型关系图](#附录数据模型关系图) - ---- - -## 1. 健康检查 - -### `GET /health` - -服务健康检查接口,用于确认服务是否正常运行。 - -**请求参数:** 无 - -**响应示例:** - -```json -{ - "status": "ok", - "message": "Elysia ToDo API is running!" -} -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| status | string | 服务状态,`"ok"` 表示正常 | -| message | string | 服务描述信息 | - ---- - -## 2. 任务管理 (Tasks) - -### `GET /api/tasks` - -获取任务列表,支持按状态、分类、优先级筛选和排序。 - -**查询参数:** - -| 参数 | 类型 | 必填 | 默认值 | 说明 | -|------|------|------|--------|------| -| status | string | 否 | `"all"` | 任务状态:`all`(全部)、`in_progress`(进行中)、`completed`(已完成) | -| category_id | integer | 否 | - | 按分类 ID 筛选 | -| priority | string | 否 | - | 按优先级筛选:`q1`(重要且紧急)、`q2`(重要不紧急)、`q3`(不重要但紧急)、`q4`(不重要不紧急) | -| sort_by | string | 否 | `"created_at"` | 排序字段:`created_at`、`priority`、`due_date` | -| sort_order | string | 否 | `"desc"` | 排序方向:`asc`(升序)、`desc`(降序) | - -**响应示例:** - -```json -[ - { - "id": 1, - "title": "完成接口文档", - "description": "为项目编写完整的 API 接口文档", - "priority": "q1", - "due_date": "2026-03-15T00:00:00", - "is_completed": false, - "category_id": 2, - "created_at": "2026-03-14T10:00:00", - "updated_at": "2026-03-14T10:00:00", - "category": { - "id": 2, - "name": "工作", - "color": "#FF6B6B", - "icon": "briefcase" - }, - "tags": [ - { - "id": 1, - "name": "重要" - } - ] - } -] -``` - ---- - -### `POST /api/tasks` - -创建新任务。 - -**请求体:** - -| 字段 | 类型 | 必填 | 默认值 | 说明 | -|------|------|------|--------|------| -| title | string | **是** | - | 任务标题,最长 200 字符 | -| description | string | 否 | null | 任务描述 | -| priority | string | 否 | `"q4"` | 优先级:`q1`、`q2`、`q3`、`q4` | -| due_date | string | 否 | null | 截止日期,ISO 8601 格式 | -| category_id | integer | 否 | null | 所属分类 ID | -| tag_ids | integer[] | 否 | [] | 关联标签 ID 列表 | - -**请求示例:** - -```json -{ - "title": "完成接口文档", - "description": "为项目编写完整的 API 接口文档", - "priority": "q1", - "due_date": "2026-03-15T00:00:00", - "category_id": 2, - "tag_ids": [1, 3] -} -``` - -**响应:** HTTP `201 Created`,返回 `TaskResponse` 对象(格式同上)。 - ---- - -### `GET /api/tasks/{task_id}` - -获取单个任务详情。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| task_id | integer | 任务 ID | - -**响应:** 返回 `TaskResponse` 对象。 - -**错误响应:** - -| 状态码 | 说明 | -|--------|------| -| 404 | 任务不存在 | - ---- - -### `PUT /api/tasks/{task_id}` - -更新任务信息(部分更新,只提交需要修改的字段)。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| task_id | integer | 任务 ID | - -**请求体(所有字段均为可选):** - -| 字段 | 类型 | 说明 | -|------|------|------| -| title | string | 任务标题 | -| description | string | 任务描述 | -| priority | string | 优先级:`q1`、`q2`、`q3`、`q4` | -| due_date | string | 截止日期 | -| is_completed | boolean | 是否完成 | -| category_id | integer | 所属分类 ID | -| tag_ids | integer[] | 关联标签 ID 列表 | - -**响应:** 返回更新后的 `TaskResponse` 对象。 - ---- - -### `DELETE /api/tasks/{task_id}` - -删除任务。关联的标签关系将自动解除。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| task_id | integer | 任务 ID | - -**响应示例:** - -```json -{ - "success": true, - "message": "任务已删除" -} -``` - ---- - -### `PATCH /api/tasks/{task_id}/toggle` - -切换任务的完成状态(已完成 ↔ 未完成)。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| task_id | integer | 任务 ID | - -**响应:** 返回更新后的 `TaskResponse` 对象,其中 `is_completed` 字段已被取反。 - ---- - -## 3. 分类管理 (Categories) - -### `GET /api/categories` - -获取分类列表,支持分页。 - -**查询参数:** - -| 参数 | 类型 | 必填 | 默认值 | 说明 | -|------|------|------|--------|------| -| skip | integer | 否 | 0 | 跳过的记录数 | -| limit | integer | 否 | 100 | 每页记录数,最大 100 | - -**响应示例:** - -```json -[ - { - "id": 1, - "name": "工作", - "color": "#FF6B6B", - "icon": "briefcase" - }, - { - "id": 2, - "name": "学习", - "color": "#4ECDC4", - "icon": "book" - } -] -``` - ---- - -### `POST /api/categories` - -创建新分类。 - -**请求体:** - -| 字段 | 类型 | 必填 | 默认值 | 说明 | -|------|------|------|--------|------| -| name | string | **是** | - | 分类名称,最长 100 字符 | -| color | string | 否 | `"#FFB7C5"` | 颜色值(十六进制) | -| icon | string | 否 | `"folder"` | 图标标识 | - -**响应:** HTTP `201 Created`,返回 `CategoryResponse` 对象。 - ---- - -### `PUT /api/categories/{category_id}` - -更新分类信息。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| category_id | integer | 分类 ID | - -**请求体(所有字段可选):** - -| 字段 | 类型 | 说明 | -|------|------|------| -| name | string | 分类名称 | -| color | string | 颜色值 | -| icon | string | 图标标识 | - -**响应:** 返回更新后的 `CategoryResponse` 对象。 - ---- - -### `DELETE /api/categories/{category_id}` - -删除分类。如果该分类下有关联的任务,将返回 400 错误。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| category_id | integer | 分类 ID | - -**成功响应:** - -```json -{ - "success": true, - "message": "分类已删除" -} -``` - -**错误响应:** - -| 状态码 | 说明 | -|--------|------| -| 400 | 该分类下存在关联任务,无法删除 | - ---- - -## 4. 标签管理 (Tags) - -### `GET /api/tags` - -获取标签列表。 - -**查询参数:** - -| 参数 | 类型 | 必填 | 默认值 | 说明 | -|------|------|------|--------|------| -| skip | integer | 否 | 0 | 跳过的记录数 | -| limit | integer | 否 | 100 | 每页记录数 | - -**响应示例:** - -```json -[ - { "id": 1, "name": "重要" }, - { "id": 2, "name": "紧急" }, - { "id": 3, "name": "待审核" } -] -``` - ---- - -### `POST /api/tags` - -创建新标签。 - -**请求体:** - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| name | string | **是** | 标签名称,最长 50 字符,全局唯一 | - -**请求示例:** - -```json -{ - "name": "重要" -} -``` - -**响应:** HTTP `201 Created`,返回 `TagResponse` 对象。 - -**错误响应:** - -| 状态码 | 说明 | -|--------|------| -| 400 | 标签名称已存在 | - -> **注意:** 标签不支持更新操作,如需修改请先删除再重新创建。 - ---- - -### `DELETE /api/tags/{tag_id}` - -删除标签,关联的任务-标签关系将自动解除。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| tag_id | integer | 标签 ID | - -**响应示例:** - -```json -{ - "success": true, - "message": "标签已删除" -} -``` - ---- - -## 5. 习惯组管理 (Habit Groups) - -习惯组用于对习惯进行分组管理,类似于文件夹的概念。 - -### `GET /api/habit-groups` - -获取所有习惯组列表。 - -**响应示例:** - -```json -[ - { - "id": 1, - "name": "健康", - "color": "#FF6B6B", - "icon": "heart", - "sort_order": 0 - }, - { - "id": 2, - "name": "学习", - "color": "#4ECDC4", - "icon": "book", - "sort_order": 1 - } -] -``` - ---- - -### `POST /api/habit-groups` - -创建新的习惯组。 - -**请求体:** - -| 字段 | 类型 | 必填 | 默认值 | 说明 | -|------|------|------|--------|------| -| name | string | **是** | - | 组名称,最长 100 字符 | -| color | string | 否 | `"#FFB7C5"` | 颜色值 | -| icon | string | 否 | `"flag"` | 图标标识 | -| sort_order | integer | 否 | 0 | 排序顺序 | - -**响应:** HTTP `201 Created`,返回 `HabitGroupResponse` 对象。 - ---- - -### `PUT /api/habit-groups/{group_id}` - -更新习惯组信息。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| group_id | integer | 习惯组 ID | - -**请求体(所有字段可选):** - -| 字段 | 类型 | 说明 | -|------|------|------| -| name | string | 组名称 | -| color | string | 颜色值 | -| icon | string | 图标标识 | -| sort_order | integer | 排序顺序 | - -**响应:** 返回更新后的 `HabitGroupResponse` 对象。 - ---- - -### `DELETE /api/habit-groups/{group_id}` - -删除习惯组。组内的习惯不会被删除,但其 `group_id` 将被设为 `null`。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| group_id | integer | 习惯组 ID | - -**响应示例:** - -```json -{ - "success": true, - "message": "习惯组已删除" -} -``` - ---- - -## 6. 习惯管理 (Habits) - -### `GET /api/habits` - -获取习惯列表。 - -**查询参数:** - -| 参数 | 类型 | 必填 | 默认值 | 说明 | -|------|------|------|--------|------| -| include_archived | boolean | 否 | false | 是否包含已归档的习惯 | - -**响应示例:** - -```json -[ - { - "id": 1, - "name": "每日阅读", - "description": "至少阅读 30 分钟", - "group_id": 2, - "target_count": 1, - "frequency": "daily", - "active_days": "[0,1,2,3,4,5,6]", - "is_archived": false, - "created_at": "2026-03-01T00:00:00", - "updated_at": "2026-03-14T00:00:00", - "group": { - "id": 2, - "name": "学习", - "color": "#4ECDC4", - "icon": "book", - "sort_order": 1 - } - } -] -``` - -**HabitResponse 字段说明:** - -| 字段 | 类型 | 说明 | -|------|------|------| -| id | integer | 习惯 ID | -| name | string | 习惯名称 | -| description | string\|null | 习惯描述 | -| group_id | integer\|null | 所属习惯组 ID | -| target_count | integer | 每日/每周目标次数(≥1) | -| frequency | string | 频率:`daily`(每日)、`weekly`(每周) | -| active_days | string\|null | 活跃日期(JSON 数组),如 `"[0,2,4]"` 表示周一/三/五(0=周一,6=周日) | -| is_archived | boolean | 是否已归档 | -| created_at | datetime | 创建时间 | -| updated_at | datetime | 更新时间 | -| group | object\|null | 关联的习惯组信息 | - ---- - -### `POST /api/habits` - -创建新习惯。 - -**请求体:** - -| 字段 | 类型 | 必填 | 默认值 | 说明 | -|------|------|------|--------|------| -| name | string | **是** | - | 习惯名称,最长 200 字符 | -| description | string | 否 | null | 习惯描述 | -| group_id | integer | 否 | null | 所属习惯组 ID | -| target_count | integer | 否 | 1 | 目标次数(≥1) | -| frequency | string | 否 | `"daily"` | 频率:`daily`、`weekly` | -| active_days | string | 否 | null | 活跃日期(JSON 数组字符串) | - -**响应:** HTTP `201 Created`,返回 `HabitResponse` 对象。 - ---- - -### `PUT /api/habits/{habit_id}` - -更新习惯信息。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| habit_id | integer | 习惯 ID | - -**请求体(所有字段可选):** - -| 字段 | 类型 | 说明 | -|------|------|------| -| name | string | 习惯名称 | -| description | string | 习惯描述 | -| group_id | integer | 所属习惯组 ID | -| target_count | integer | 目标次数 | -| frequency | string | 频率 | -| active_days | string | 活跃日期 | - -**响应:** 返回更新后的 `HabitResponse` 对象。 - ---- - -### `DELETE /api/habits/{habit_id}` - -删除习惯,关联的打卡记录将一并被级联删除。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| habit_id | integer | 习惯 ID | - -**响应示例:** - -```json -{ - "success": true, - "message": "习惯已删除" -} -``` - ---- - -### `PATCH /api/habits/{habit_id}/archive` - -切换习惯的归档状态。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| habit_id | integer | 习惯 ID | - -**响应:** 返回更新后的 `HabitResponse` 对象,`is_archived` 字段已被取反。 - ---- - -## 7. 习惯打卡 (Habit Check-ins) - -### `GET /api/habits/{habit_id}/checkins` - -获取习惯的打卡记录。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| habit_id | integer | 习惯 ID | - -**查询参数:** - -| 参数 | 类型 | 必填 | 默认值 | 说明 | -|------|------|------|--------|------| -| from_date | string | 否 | - | 起始日期(YYYY-MM-DD) | -| to_date | string | 否 | - | 结束日期(YYYY-MM-DD) | - -**响应示例:** - -```json -[ - { - "id": 1, - "habit_id": 1, - "checkin_date": "2026-03-14", - "count": 2, - "created_at": "2026-03-14T08:00:00" - } -] -``` - ---- - -### `POST /api/habits/{habit_id}/checkins` - -进行打卡。如果当天已有打卡记录,将累加 `count`;否则创建新记录。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| habit_id | integer | 习惯 ID | - -**请求体:** - -| 字段 | 类型 | 必填 | 默认值 | 说明 | -|------|------|------|--------|------| -| count | integer | 否 | 1 | 本次打卡次数 | - -**响应:** 返回 `CheckinResponse` 对象。 - ---- - -### `DELETE /api/habits/{habit_id}/checkins` - -取消打卡(减少当天打卡次数)。如果次数减至 0,将删除该天的打卡记录。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| habit_id | integer | 习惯 ID | - -**查询参数:** - -| 参数 | 类型 | 必填 | 默认值 | 说明 | -|------|------|------|--------|------| -| count | integer | 否 | 1 | 要减少的次数(≥1) | - -**响应示例:** - -```json -{ - "success": true, - "message": "打卡已取消" -} -``` - ---- - -### `GET /api/habits/{habit_id}/checkins/stats` - -获取习惯的统计数据。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| habit_id | integer | 习惯 ID | - -**响应示例:** - -```json -{ - "total_days": 42, - "current_streak": 7, - "longest_streak": 15, - "today_count": 1, - "today_completed": true -} -``` - -**HabitStatsResponse 字段说明:** - -| 字段 | 类型 | 说明 | -|------|------|------| -| total_days | integer | 总打卡天数 | -| current_streak | integer | 当前连续打卡天数 | -| longest_streak | integer | 最长连续打卡天数 | -| today_count | integer | 今日已打卡次数 | -| today_completed | boolean | 今日目标是否已完成 | - ---- - -## 8. 纪念日分类 (Anniversary Categories) - -纪念日分类用于对纪念日进行分组管理。 - -### `GET /api/anniversary-categories` - -获取所有纪念日分类列表。 - -**响应示例:** - -```json -[ - { - "id": 1, - "name": "生日", - "icon": "cake", - "color": "#FF6B6B", - "sort_order": 0 - }, - { - "id": 2, - "name": "节日", - "icon": "star", - "color": "#FFD93D", - "sort_order": 1 - } -] -``` - ---- - -### `POST /api/anniversary-categories` - -创建新的纪念日分类。 - -**请求体:** - -| 字段 | 类型 | 必填 | 默认值 | 说明 | -|------|------|------|--------|------| -| name | string | **是** | - | 分类名称,最长 50 字符 | -| icon | string | 否 | `"calendar"` | 图标标识 | -| color | string | 否 | `"#FFB7C5"` | 颜色值 | -| sort_order | integer | 否 | 0 | 排序顺序 | - -**响应:** HTTP `201 Created`,返回 `AnniversaryCategoryResponse` 对象。 - ---- - -### `PUT /api/anniversary-categories/{category_id}` - -更新纪念日分类信息。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| category_id | integer | 纪念日分类 ID | - -**请求体(所有字段可选):** - -| 字段 | 类型 | 说明 | -|------|------|------| -| name | string | 分类名称 | -| icon | string | 图标标识 | -| color | string | 颜色值 | -| sort_order | integer | 排序顺序 | - -**响应:** 返回更新后的 `AnniversaryCategoryResponse` 对象。 - ---- - -### `DELETE /api/anniversary-categories/{category_id}` - -删除纪念日分类。分类下的纪念日不会被删除,但其 `category_id` 将被设为 `null`。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| category_id | integer | 纪念日分类 ID | - -**响应示例:** - -```json -{ - "success": true, - "message": "纪念日分类已删除" -} -``` - ---- - -## 9. 纪念日管理 (Anniversaries) - -### `GET /api/anniversaries` - -获取纪念日列表,包含计算出的下次日期、剩余天数和第 N 次纪念日信息。结果按即将到来的日期排序。 - -**查询参数:** - -| 参数 | 类型 | 必填 | 默认值 | 说明 | -|------|------|------|--------|------| -| category_id | integer | 否 | - | 按分类 ID 筛选 | - -**响应示例:** - -```json -[ - { - "id": 1, - "title": "爱莉希雅生日", - "date": "2020-03-14", - "year": 2020, - "category_id": 1, - "description": "最重要的日子!", - "is_recurring": true, - "remind_days_before": 3, - "created_at": "2026-01-01T00:00:00", - "updated_at": "2026-01-01T00:00:00", - "category": { - "id": 1, - "name": "生日", - "icon": "cake", - "color": "#FF6B6B", - "sort_order": 0 - }, - "next_date": "2026-03-14", - "days_until": 0, - "year_count": 6 - } -] -``` - -**计算字段说明:** - -| 字段 | 类型 | 说明 | -|------|------|------| -| next_date | date\|null | 下一次纪念日日期 | -| days_until | integer\|null | 距离下一次纪念日的天数 | -| year_count | integer\|null | 第几次纪念日(从原始年份起算) | - ---- - -### `POST /api/anniversaries` - -创建新的纪念日。 - -**请求体:** - -| 字段 | 类型 | 必填 | 默认值 | 说明 | -|------|------|------|--------|------| -| title | string | **是** | - | 纪念日标题,最长 200 字符 | -| date | string | **是** | - | 日期(YYYY-MM-DD) | -| year | integer | 否 | null | 原始年份,用于计算第 N 次 | -| category_id | integer | 否 | null | 所属分类 ID | -| description | string | 否 | null | 描述信息 | -| is_recurring | boolean | 否 | true | 是否每年重复 | -| remind_days_before | integer | 否 | 3 | 提前几天提醒 | - -**响应:** HTTP `201 Created`,返回 `AnniversaryResponse` 对象。 - ---- - -### `GET /api/anniversaries/{anniversary_id}` - -获取单个纪念日详情。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| anniversary_id | integer | 纪念日 ID | - -**响应:** 返回 `AnniversaryResponse` 对象(含计算字段)。 - ---- - -### `PUT /api/anniversaries/{anniversary_id}` - -更新纪念日信息。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| anniversary_id | integer | 纪念日 ID | - -**请求体(所有字段可选):** - -| 字段 | 类型 | 说明 | -|------|------|------| -| title | string | 纪念日标题 | -| date | string | 日期 | -| year | integer | 原始年份 | -| category_id | integer | 所属分类 ID | -| description | string | 描述信息 | -| is_recurring | boolean | 是否每年重复 | -| remind_days_before | integer | 提前几天提醒 | - -**响应:** 返回更新后的 `AnniversaryResponse` 对象。 - ---- - -### `DELETE /api/anniversaries/{anniversary_id}` - -删除纪念日。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| anniversary_id | integer | 纪念日 ID | - -**响应示例:** - -```json -{ - "success": true, - "message": "纪念日已删除" -} -``` - ---- - -## 10. 财务账户 (Accounts) - -财务账户支持储蓄账户和债务账户两种类型。GET 列表接口会返回每个账户的分期还款汇总信息。 - -### `GET /api/accounts` - -获取所有财务账户列表。对于债务类型账户,`installments` 字段会包含分期还款计划摘要。 - -**响应示例:** - -```json -[ - { - "id": 1, - "name": "招商银行储蓄卡", - "account_type": "savings", - "balance": 15000.00, - "icon": "bank", - "color": "#FF6B6B", - "sort_order": 0, - "is_active": true, - "description": "日常储蓄", - "created_at": "2026-01-01T00:00:00", - "updated_at": "2026-03-14T00:00:00", - "installments": [] - }, - { - "id": 2, - "name": "信用卡", - "account_type": "debt", - "balance": -5000.00, - "icon": "credit-card", - "color": "#4ECDC4", - "sort_order": 1, - "is_active": true, - "description": null, - "created_at": "2026-01-01T00:00:00", - "updated_at": "2026-03-14T00:00:00", - "installments": [ - { - "id": 1, - "total_amount": 6000.0, - "remaining_periods": 5, - "next_payment_date": "2026-04-15", - "days_until_payment": 32 - } - ] - } -] -``` - ---- - -### `POST /api/accounts` - -创建新的财务账户。 - -**请求体:** - -| 字段 | 类型 | 必填 | 默认值 | 说明 | -|------|------|------|--------|------| -| name | string | **是** | - | 账户名称,最长 100 字符 | -| account_type | string | 否 | `"savings"` | 账户类型:`savings`(储蓄)、`debt`(债务) | -| balance | number | 否 | 0.0 | 初始余额 | -| icon | string | 否 | `"wallet"` | 图标标识 | -| color | string | 否 | `"#FFB7C5"` | 颜色值 | -| sort_order | integer | 否 | 0 | 排序顺序 | -| is_active | boolean | 否 | true | 是否启用 | -| description | string | 否 | null | 描述信息 | - -**响应:** HTTP `201 Created`,返回 `AccountResponse` 对象。 - ---- - -### `GET /api/accounts/{account_id}` - -获取单个账户详情。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| account_id | integer | 账户 ID | - -**响应:** 返回 `AccountResponse` 对象。 - ---- - -### `PUT /api/accounts/{account_id}` - -更新账户信息。 - -> **注意:** 此接口不支持直接修改余额,余额请通过 `POST /api/accounts/{id}/balance` 修改。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| account_id | integer | 账户 ID | - -**请求体(所有字段可选,**不包含 balance**):** - -| 字段 | 类型 | 说明 | -|------|------|------| -| name | string | 账户名称 | -| account_type | string | 账户类型 | -| icon | string | 图标标识 | -| color | string | 颜色值 | -| sort_order | integer | 排序顺序 | -| is_active | boolean | 是否启用 | -| description | string | 描述信息 | - -**响应:** 返回更新后的 `AccountResponse` 对象。 - ---- - -### `DELETE /api/accounts/{account_id}` - -删除账户,关联的历史记录和分期还款计划将一并被级联删除。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| account_id | integer | 账户 ID | - -**响应示例:** - -```json -{ - "success": true, - "message": "账户已删除" -} -``` - ---- - -### `POST /api/accounts/{account_id}/balance` - -更新账户余额,自动创建余额变动历史记录。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| account_id | integer | 账户 ID | - -**请求体:** - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| new_balance | number | **是** | 新的余额值 | -| note | string | 否 | 变动备注,最长 200 字符 | - -**请求示例:** - -```json -{ - "new_balance": 16000.00, - "note": "工资到账" -} -``` - -**响应:** 返回更新后的 `AccountResponse` 对象。同时会自动在 `account_history` 表中创建一条记录。 - ---- - -### `GET /api/accounts/{account_id}/history` - -获取账户的余额变动历史,支持分页。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| account_id | integer | 账户 ID | - -**查询参数:** - -| 参数 | 类型 | 必填 | 默认值 | 说明 | -|------|------|------|--------|------| -| page | integer | 否 | 1 | 页码(从 1 开始) | -| page_size | integer | 否 | 20 | 每页记录数,最大 100 | - -**响应示例:** - -```json -{ - "total": 15, - "page": 1, - "page_size": 20, - "records": [ - { - "id": 15, - "account_id": 1, - "change_amount": 1000.0, - "balance_before": 15000.0, - "balance_after": 16000.0, - "note": "工资到账", - "created_at": "2026-03-14T10:00:00" - } - ] -} -``` - -**AccountHistoryResponse 字段说明:** - -| 字段 | 类型 | 说明 | -|------|------|------| -| id | integer | 记录 ID | -| account_id | integer | 账户 ID | -| change_amount | number | 变动金额(正数=收入,负数=支出) | -| balance_before | number | 变动前余额 | -| balance_after | number | 变动后余额 | -| note | string\|null | 变动备注 | -| created_at | datetime | 记录时间 | - ---- - -## 11. 分期还款 (Debt Installments) - -分期还款计划仅与 `account_type` 为 `debt` 的账户关联。GET 列表接口会返回计算出的下次还款日期、剩余天数和剩余期数等字段。 - -### `GET /api/debt-installments` - -获取所有分期还款计划列表,包含计算字段,按紧急程度排序。 - -**响应示例:** - -```json -[ - { - "id": 1, - "account_id": 2, - "total_amount": 6000.0, - "total_periods": 12, - "current_period": 8, - "payment_day": 15, - "payment_amount": 500.0, - "start_date": "2025-09-15", - "is_completed": false, - "created_at": "2025-09-01T00:00:00", - "updated_at": "2026-03-01T00:00:00", - "next_payment_date": "2026-04-15", - "days_until_payment": 32, - "remaining_periods": 5, - "account_name": "信用卡", - "account_icon": "credit-card", - "account_color": "#4ECDC4" - } -] -``` - -**计算字段说明:** - -| 字段 | 类型 | 说明 | -|------|------|------| -| next_payment_date | date\|null | 下一次还款日期 | -| days_until_payment | integer\|null | 距离下次还款的天数 | -| remaining_periods | integer\|null | 剩余还款期数 | -| account_name | string\|null | 关联账户名称 | -| account_icon | string\|null | 关联账户图标 | -| account_color | string\|null | 关联账户颜色 | - ---- - -### `POST /api/debt-installments` - -创建新的分期还款计划。 - -**请求体:** - -| 字段 | 类型 | 必填 | 默认值 | 说明 | -|------|------|------|--------|------| -| account_id | integer | **是** | - | 关联的债务账户 ID | -| total_amount | number | **是** | - | 总金额 | -| total_periods | integer | **是** | - | 总期数 | -| current_period | integer | 否 | 1 | 当前期数(从 1 开始,指向下一期未还) | -| payment_day | integer | **是** | - | 每月还款日(1-31) | -| payment_amount | number | **是** | - | 每期还款金额 | -| start_date | string | **是** | - | 起始日期(YYYY-MM-DD) | -| is_completed | boolean | 否 | false | 是否已完成 | - -**响应:** HTTP `201 Created`,返回 `DebtInstallmentResponse` 对象。 - -**错误响应:** - -| 状态码 | 说明 | -|--------|------| -| 400 | 关联账户不是债务类型(`account_type` 不为 `debt`) | - ---- - -### `PUT /api/debt-installments/{installment_id}` - -更新分期还款计划。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| installment_id | integer | 分期计划 ID | - -**请求体(所有字段可选):** - -| 字段 | 类型 | 说明 | -|------|------|------| -| account_id | integer | 关联账户 ID | -| total_amount | number | 总金额 | -| total_periods | integer | 总期数 | -| current_period | integer | 当前期数 | -| payment_day | integer | 每月还款日 | -| payment_amount | number | 每期还款金额 | -| start_date | string | 起始日期 | -| is_completed | boolean | 是否已完成 | - -**响应:** 返回更新后的 `DebtInstallmentResponse` 对象。 - ---- - -### `DELETE /api/debt-installments/{installment_id}` - -删除分期还款计划。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| installment_id | integer | 分期计划 ID | - -**响应示例:** - -```json -{ - "success": true, - "message": "分期计划已删除" -} -``` - ---- - -### `PATCH /api/debt-installments/{installment_id}/pay` - -标记一期为已还款。`current_period` 递增 1,如果所有期数都已还完,自动将 `is_completed` 设为 `true`。 - -**路径参数:** - -| 参数 | 类型 | 说明 | -|------|------|------| -| installment_id | integer | 分期计划 ID | - -**响应:** 返回更新后的 `DebtInstallmentResponse` 对象。 - -**错误响应:** - -| 状态码 | 说明 | -|--------|------| -| 400 | 分期计划已全部还完 | - ---- - -## 12. 用户设置 (User Settings) - -用户设置为单例模式,全局只有一条记录(`id=1`)。首次访问时自动创建默认设置。 - -### `GET /api/user-settings` - -获取用户设置。如果不存在,自动创建默认设置并返回。 - -**响应示例:** - -```json -{ - "id": 1, - "nickname": "爱莉希雅", - "avatar": null, - "signature": "美好的一天从微笑开始~", - "birthday": "2020-03-14", - "email": null, - "site_name": "爱莉希雅待办", - "theme": "pink", - "language": "zh-CN", - "default_view": "list", - "default_sort_by": "created_at", - "default_sort_order": "desc", - "created_at": "2026-01-01T00:00:00", - "updated_at": "2026-03-14T00:00:00" -} -``` - -**字段说明:** - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| id | integer | 1 | 固定为 1 | -| nickname | string | `"爱莉希雅"` | 用户昵称 | -| avatar | string\|null | null | 头像 URL | -| signature | string\|null | null | 个性签名 | -| birthday | date\|null | null | 生日 | -| email | string\|null | null | 邮箱 | -| site_name | string | `"爱莉希雅待办"` | 站点名称 | -| theme | string | `"pink"` | 主题 | -| language | string | `"zh-CN"` | 语言 | -| default_view | string | `"list"` | 默认视图 | -| default_sort_by | string | `"created_at"` | 默认排序字段 | -| default_sort_order | string | `"desc"` | 默认排序方向 | - ---- - -### `PUT /api/user-settings` - -更新用户设置(upsert 模式,不存在则创建)。 - -**请求体(所有字段可选):** - -| 字段 | 类型 | 说明 | -|------|------|------| -| nickname | string | 用户昵称 | -| avatar | string | 头像 URL | -| signature | string | 个性签名 | -| birthday | string | 生日(YYYY-MM-DD) | -| email | string | 邮箱 | -| site_name | string | 站点名称 | -| theme | string | 主题 | -| language | string | 语言 | -| default_view | string | 默认视图 | -| default_sort_by | string | 默认排序字段 | -| default_sort_order | string | 默认排序方向 | - -**响应:** 返回更新后的 `UserSettingsResponse` 对象。 - ---- - -## 附录:通用响应格式 - -### 成功响应 - -大部分接口返回具体数据对象或对象列表。部分接口(如删除操作)返回以下格式: - -```json -{ - "success": true, - "message": "操作成功提示" -} -``` - -### 错误响应 - -| 状态码 | 说明 | -|--------|------| -| 400 | 请求参数错误 / 业务逻辑错误(如重复名称、关联约束等) | -| 404 | 资源不存在 | -| 422 | 请求体验证失败(Pydantic 校验错误) | -| 500 | 服务器内部错误 | - -**422 错误响应示例:** - -```json -{ - "detail": [ - { - "loc": ["body", "title"], - "msg": "field required", - "type": "value_error.missing" - } - ] -} -``` - -**500 错误响应示例:** - -```json -{ - "success": false, - "message": "服务器内部错误", - "error_code": "INTERNAL_ERROR" -} -``` - ---- - -## 附录:数据模型关系图 - -``` -┌──────────────┐ ┌──────────────┐ ┌──────────────┐ -│ categories │ 1───N │ tasks │ N───M │ tags │ -│──────────────│ │──────────────│ │──────────────│ -│ id │ │ id │ │ id │ -│ name │ │ title │ │ name (unique)│ -│ color │ │ description │ └──────────────┘ -│ icon │ │ priority │ -└──────────────┘ │ due_date │ ┌──────────────┐ - │ is_completed │ │ task_tags │ - │ category_id ─┤ │──────────────│ - │ created_at │ │ task_id (PK) │ - │ updated_at │ │ tag_id (PK) │ - └──────────────┘ └──────────────┘ - -┌──────────────┐ 1───N ┌──────────────┐ 1───N ┌────────────────┐ -│habit_groups │ │ habits │ │ habit_checkins │ -│──────────────│ │──────────────│ │────────────────│ -│ id │ │ id │ │ id │ -│ name │ │ name │ │ habit_id (FK) │ -│ color │ │ description │ │ checkin_date │ -│ icon │ │ group_id ────│ │ count │ -│ sort_order │ │ target_count │ │ created_at │ -└──────────────┘ │ frequency │ └────────────────┘ - │ active_days │ - │ is_archived │ - └──────────────┘ - -┌────────────────────┐ 1───N ┌────────────────┐ -│anniversary_ │ │ anniversaries │ -│categories │ │────────────────│ -│────────────────────│ │ id │ -│ id │ │ title │ -│ name │ │ date │ -│ icon │ │ year │ -│ color │ │ category_id ───│ -│ sort_order │ │ description │ -└────────────────────┘ │ is_recurring │ - │ remind_days_ │ - │ before │ - └────────────────┘ - -┌──────────────────┐ 1───N ┌────────────────┐ -│financial_accounts│ │account_history │ -│──────────────────│ │────────────────│ -│ id │ │ id │ -│ name │ │ account_id ────│ -│ account_type │ │ change_amount │ -│ balance │ │ balance_before │ -│ icon │ │ balance_after │ -│ color │ │ note │ -│ sort_order │ │ created_at │ -│ is_active │ └────────────────┘ -│ description │ -└──────────────────┘ - │ 1───N ┌────────────────┐ - │debt_installments │ - │────────────────│ - │ id │ - │ account_id ────│ - │ total_amount │ - │ total_periods │ - │ current_period │ - │ payment_day │ - │ payment_amount │ - │ start_date │ - │ is_completed │ - └────────────────┘ - -┌──────────────────┐ -│ user_settings │ (单例, id=1) -│──────────────────│ -│ id │ -│ nickname │ -│ avatar │ -│ signature │ -│ birthday │ -│ email │ -│ site_name │ -│ theme │ -│ language │ -│ default_view │ -│ default_sort_by │ -│ default_sort_order│ -└──────────────────┘ -``` - ---- - -## 接口总览(共 50 个接口) - -| # | 方法 | 路径 | 功能 | -|---|------|------|------| -| 1 | GET | `/health` | 健康检查 | -| 2 | GET | `/api/tasks` | 获取任务列表 | -| 3 | POST | `/api/tasks` | 创建任务 | -| 4 | GET | `/api/tasks/{id}` | 获取任务详情 | -| 5 | PUT | `/api/tasks/{id}` | 更新任务 | -| 6 | DELETE | `/api/tasks/{id}` | 删除任务 | -| 7 | PATCH | `/api/tasks/{id}/toggle` | 切换任务完成状态 | -| 8 | GET | `/api/categories` | 获取分类列表 | -| 9 | POST | `/api/categories` | 创建分类 | -| 10 | PUT | `/api/categories/{id}` | 更新分类 | -| 11 | DELETE | `/api/categories/{id}` | 删除分类 | -| 12 | GET | `/api/tags` | 获取标签列表 | -| 13 | POST | `/api/tags` | 创建标签 | -| 14 | DELETE | `/api/tags/{id}` | 删除标签 | -| 15 | GET | `/api/habit-groups` | 获取习惯组列表 | -| 16 | POST | `/api/habit-groups` | 创建习惯组 | -| 17 | PUT | `/api/habit-groups/{id}` | 更新习惯组 | -| 18 | DELETE | `/api/habit-groups/{id}` | 删除习惯组 | -| 19 | GET | `/api/habits` | 获取习惯列表 | -| 20 | POST | `/api/habits` | 创建习惯 | -| 21 | PUT | `/api/habits/{id}` | 更新习惯 | -| 22 | DELETE | `/api/habits/{id}` | 删除习惯 | -| 23 | PATCH | `/api/habits/{id}/archive` | 切换习惯归档状态 | -| 24 | GET | `/api/habits/{id}/checkins` | 获取打卡记录 | -| 25 | POST | `/api/habits/{id}/checkins` | 打卡 | -| 26 | DELETE | `/api/habits/{id}/checkins` | 取消打卡 | -| 27 | GET | `/api/habits/{id}/checkins/stats` | 获取习惯统计 | -| 28 | GET | `/api/anniversary-categories` | 获取纪念日分类列表 | -| 29 | POST | `/api/anniversary-categories` | 创建纪念日分类 | -| 30 | PUT | `/api/anniversary-categories/{id}` | 更新纪念日分类 | -| 31 | DELETE | `/api/anniversary-categories/{id}` | 删除纪念日分类 | -| 32 | GET | `/api/anniversaries` | 获取纪念日列表 | -| 33 | POST | `/api/anniversaries` | 创建纪念日 | -| 34 | GET | `/api/anniversaries/{id}` | 获取纪念日详情 | -| 35 | PUT | `/api/anniversaries/{id}` | 更新纪念日 | -| 36 | DELETE | `/api/anniversaries/{id}` | 删除纪念日 | -| 37 | GET | `/api/accounts` | 获取账户列表 | -| 38 | POST | `/api/accounts` | 创建账户 | -| 39 | GET | `/api/accounts/{id}` | 获取账户详情 | -| 40 | PUT | `/api/accounts/{id}` | 更新账户 | -| 41 | DELETE | `/api/accounts/{id}` | 删除账户 | -| 42 | POST | `/api/accounts/{id}/balance` | 更新余额 | -| 43 | GET | `/api/accounts/{id}/history` | 获取余额变动历史 | -| 44 | GET | `/api/debt-installments` | 获取分期还款列表 | -| 45 | POST | `/api/debt-installments` | 创建分期还款计划 | -| 46 | PUT | `/api/debt-installments/{id}` | 更新分期还款计划 | -| 47 | DELETE | `/api/debt-installments/{id}` | 删除分期还款计划 | -| 48 | PATCH | `/api/debt-installments/{id}/pay` | 标记一期为已还 | -| 49 | GET | `/api/user-settings` | 获取用户设置 | -| 50 | PUT | `/api/user-settings` | 更新用户设置 | diff --git a/README.md b/README.md index c9f076f..e22501f 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ npm run dev ## API 概览 -所有接口均以 `/api` 为前缀,详细的请求/响应格式参见 [API_DOCS.md](./API_DOCS.md) 或访问 `/docs` 查看 Swagger 文档。 +所有接口均以 `/api` 为前缀,启动后端后访问 `/docs` 查看 Swagger 文档。 | 模块 | 前缀 | 说明 | |------|------|------| diff --git a/WebUI/src/App.vue b/WebUI/src/App.vue index b1c1538..a96e3fb 100644 --- a/WebUI/src/App.vue +++ b/WebUI/src/App.vue @@ -6,6 +6,7 @@ import { useCategoryStore } from '@/stores/useCategoryStore' import { useTagStore } from '@/stores/useTagStore' import { useUIStore } from '@/stores/useUIStore' import { useUserSettingsStore } from '@/stores/useUserSettingsStore' +import { useAuthStore } from '@/stores/useAuthStore' import zhCn from 'element-plus/es/locale/lang/zh-cn' import AppHeader from '@/components/AppHeader.vue' import TaskDialog from '@/components/TaskDialog.vue' @@ -18,6 +19,7 @@ const categoryStore = useCategoryStore() const tagStore = useTagStore() const uiStore = useUIStore() const userSettingsStore = useUserSettingsStore() +const authStore = useAuthStore() // 路由变化时同步 currentView watch(() => route.meta.view, (view) => { @@ -33,6 +35,8 @@ watch(() => userSettingsStore.siteName, (name) => { }) onMounted(async () => { + if (!authStore.isLoggedIn) return + await userSettingsStore.fetchAndSync() // 根据用户设置初始化默认排序 @@ -63,41 +67,46 @@ onMounted(async () => { diff --git a/WebUI/src/api/auth.ts b/WebUI/src/api/auth.ts new file mode 100644 index 0000000..9c278a9 --- /dev/null +++ b/WebUI/src/api/auth.ts @@ -0,0 +1,19 @@ +import { post } from './request' + +export interface LoginResponse { + access_token: string + token_type: string +} + +export interface ChangePasswordData { + old_password: string + new_password: string +} + +export function login(password: string): Promise { + return post('/auth/login', { password }) +} + +export function changePassword(data: ChangePasswordData): Promise<{ message: string }> { + return post<{ message: string }>('/auth/change-password', data) +} diff --git a/WebUI/src/api/request.ts b/WebUI/src/api/request.ts index f4c184b..e28eaed 100644 --- a/WebUI/src/api/request.ts +++ b/WebUI/src/api/request.ts @@ -1,6 +1,8 @@ import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios' import { ElMessage } from 'element-plus' +const TOKEN_KEY = 'elysia_auth_token' + const instance: AxiosInstance = axios.create({ baseURL: '/api', timeout: 10000, @@ -9,6 +11,14 @@ const instance: AxiosInstance = axios.create({ } }) +instance.interceptors.request.use((config) => { + const token = localStorage.getItem(TOKEN_KEY) + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + instance.interceptors.response.use( (response: AxiosResponse) => { return response @@ -28,7 +38,9 @@ instance.interceptors.response.use( break case 401: message = '登录状态已失效~' - break + localStorage.removeItem(TOKEN_KEY) + window.location.href = '/login' + return Promise.reject(error) case 403: message = '没有权限访问呢~' break diff --git a/WebUI/src/components/AppHeader.vue b/WebUI/src/components/AppHeader.vue index 60218b1..610fd8c 100644 --- a/WebUI/src/components/AppHeader.vue +++ b/WebUI/src/components/AppHeader.vue @@ -3,9 +3,11 @@ import { computed } from 'vue' import { useRouter, useRoute } from 'vue-router' import { useUIStore } from '@/stores/useUIStore' import { useUserSettingsStore } from '@/stores/useUserSettingsStore' +import { useAuthStore } from '@/stores/useAuthStore' const uiStore = useUIStore() const userSettingsStore = useUserSettingsStore() +const authStore = useAuthStore() const router = useRouter() const route = useRoute() @@ -21,6 +23,11 @@ function setView(view: string) { } function handleCommand(command: string) { + if (command === 'logout') { + authStore.logout() + router.push('/login') + return + } router.push(`/${command}`) } @@ -125,6 +132,10 @@ const currentRouteName = computed(() => route.name as string) 偏好设置 + + + 退出登录 + diff --git a/WebUI/src/router/index.ts b/WebUI/src/router/index.ts index ec0966f..7cbbb54 100644 --- a/WebUI/src/router/index.ts +++ b/WebUI/src/router/index.ts @@ -3,6 +3,12 @@ import type { RouteRecordRaw } from 'vue-router' import { useUserSettingsStore } from '@/stores/useUserSettingsStore' const routes: RouteRecordRaw[] = [ + { + path: '/login', + name: 'login', + component: () => import('@/views/LoginView.vue'), + meta: { title: '登录', noAuth: true } + }, { path: '/', redirect: '/tasks' @@ -68,11 +74,20 @@ const router = createRouter({ } }) -router.beforeEach((to) => { +const TOKEN_KEY = 'elysia_auth_token' + +router.beforeEach((to, from) => { const page = (to.meta.title as string) || '' const userStore = useUserSettingsStore() const siteName = userStore.siteName || '爱莉希雅待办' document.title = page ? `${page} - ${siteName}` : siteName + + if (to.meta.noAuth) return + + const token = localStorage.getItem(TOKEN_KEY) + if (!token) { + return { path: '/login', query: { redirect: to.fullPath } } + } }) export default router diff --git a/WebUI/src/stores/useAuthStore.ts b/WebUI/src/stores/useAuthStore.ts new file mode 100644 index 0000000..c1c31cd --- /dev/null +++ b/WebUI/src/stores/useAuthStore.ts @@ -0,0 +1,49 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { login as apiLogin } 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', () => { + const token = ref(getStoredToken()) + const loading = ref(false) + const error = ref('') + + const isLoggedIn = computed(() => !!token.value) + + async function login(password: string): Promise { + loading.value = true + error.value = '' + try { + const res = await apiLogin(password) + token.value = res.access_token + setStoredToken(res.access_token) + return true + } catch (e: any) { + error.value = e?.response?.data?.detail || '登录失败' + return false + } finally { + loading.value = false + } + } + + function logout() { + token.value = '' + error.value = '' + clearStoredToken() + } + + return { token, loading, error, isLoggedIn, login, logout } +}) diff --git a/WebUI/src/views/LoginView.vue b/WebUI/src/views/LoginView.vue new file mode 100644 index 0000000..9ece2a1 --- /dev/null +++ b/WebUI/src/views/LoginView.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/api/app/config.py b/api/app/config.py index f4e8f69..a56bb97 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -27,3 +27,7 @@ DEFAULT_PAGE_SIZE = 20 # 服务配置 HOST = "0.0.0.0" PORT = 23994 + +# JWT 认证配置 +JWT_SECRET = "elysia-todo-secret-key-change-in-production" +ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24小时 diff --git a/api/app/main.py b/api/app/main.py index 213782d..f953fcd 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -10,6 +10,7 @@ from app.config import CORS_ORIGINS, WEBUI_PATH, HOST, PORT from app.database import init_db from app.routers import api_router from app.utils.logger import logger +from app.utils.auth import decode_access_token @asynccontextmanager @@ -89,6 +90,28 @@ async def log_requests(request: Request, call_next): return response +# 认证中间件(保护所有 /api/* 路由,除了 /api/auth/* 和 /health) +@app.middleware("http") +async def auth_middleware(request: Request, call_next): + path = request.url.path + + # 不拦截:健康检查、静态文件、auth 路由 + if path == "/health" or not path.startswith("/api/") or path.startswith("/api/auth/"): + return await call_next(request) + + auth_header = request.headers.get("Authorization", "") + token = auth_header.replace("Bearer ", "") + if not token: + return JSONResponse(status_code=401, content={"detail": "未登录"}) + + try: + decode_access_token(token) + except Exception: + return JSONResponse(status_code=401, content={"detail": "登录已过期,请重新登录"}) + + return await call_next(request) + + # 全局异常处理器 @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): diff --git a/api/app/models/user_settings.py b/api/app/models/user_settings.py index e947729..9705ef4 100644 --- a/api/app/models/user_settings.py +++ b/api/app/models/user_settings.py @@ -31,6 +31,9 @@ class UserSettings(Base): default_sort_by = Column(String(20), default="created_at") default_sort_order = Column(String(10), default="desc") + # 认证 + password_hash = Column(String(255), default="") + # 时间戳 created_at = Column(DateTime, default=utcnow) updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) diff --git a/api/app/routers/__init__.py b/api/app/routers/__init__.py index 6a3a713..af58899 100644 --- a/api/app/routers/__init__.py +++ b/api/app/routers/__init__.py @@ -1,10 +1,9 @@ from fastapi import APIRouter -from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, accounts +from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, accounts, auth -# 创建主路由 api_router = APIRouter() -# 注册子路由 +api_router.include_router(auth.router) api_router.include_router(tasks.router) api_router.include_router(categories.router) api_router.include_router(tags.router) diff --git a/api/app/routers/auth.py b/api/app/routers/auth.py new file mode 100644 index 0000000..a14c9cc --- /dev/null +++ b/api/app/routers/auth.py @@ -0,0 +1,51 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.user_settings import UserSettings +from app.schemas.auth import LoginRequest, TokenResponse, ChangePasswordRequest +from app.utils.auth import ( + hash_password, verify_password, create_access_token, + get_current_user, set_default_password +) + +router = APIRouter(prefix="/api/auth", tags=["认证"]) + + +@router.post("/login", response_model=TokenResponse) +def login(data: LoginRequest, db: Session = Depends(get_db)): + settings = db.query(UserSettings).filter(UserSettings.id == 1).first() + if not settings: + settings = UserSettings(id=1) + db.add(settings) + db.commit() + db.refresh(settings) + + set_default_password(db, settings) + + if not verify_password(data.password, settings.password_hash): + raise HTTPException(status_code=401, detail="密码错误") + + token = create_access_token({"sub": str(settings.id)}) + return TokenResponse(access_token=token) + + +@router.post("/change-password") +def change_password( + data: ChangePasswordRequest, + request: Request, + db: Session = Depends(get_db) +): + get_current_user(request) + + settings = db.query(UserSettings).filter(UserSettings.id == 1).first() + if not settings: + raise HTTPException(status_code=500, detail="用户设置不存在") + + if not verify_password(data.old_password, settings.password_hash): + raise HTTPException(status_code=400, detail="原密码错误") + + settings.password_hash = hash_password(data.new_password) + db.commit() + + return {"message": "密码修改成功"} diff --git a/api/app/schemas/auth.py b/api/app/schemas/auth.py new file mode 100644 index 0000000..3c3c068 --- /dev/null +++ b/api/app/schemas/auth.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, Field + + +class LoginRequest(BaseModel): + password: str = Field(..., min_length=1, max_length=100) + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +class ChangePasswordRequest(BaseModel): + old_password: str = Field(..., min_length=1, max_length=100) + new_password: str = Field(..., min_length=1, max_length=100) diff --git a/api/app/utils/auth.py b/api/app/utils/auth.py new file mode 100644 index 0000000..4b18a78 --- /dev/null +++ b/api/app/utils/auth.py @@ -0,0 +1,51 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import Request, HTTPException +from sqlalchemy.orm import Session + +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" + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, JWT_SECRET, algorithm=ALGORITHM) + + +def decode_access_token(token: str) -> dict: + return jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) + + +def get_current_user(request: Request) -> dict: + auth_header = request.headers.get("Authorization", "") + token = auth_header.replace("Bearer ", "") + if not token: + raise HTTPException(status_code=401, detail="未登录") + try: + payload = decode_access_token(token) + return payload + except JWTError: + 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() diff --git a/api/webui/index.html b/api/webui/index.html index 55c27fd..f98cb96 100644 --- a/api/webui/index.html +++ b/api/webui/index.html @@ -1,16 +1,16 @@ - - - - - - - webui - + + + + + + + webui + - - - - -
- - + + + + +
+ + diff --git a/requirements.txt b/requirements.txt index 4e3bb51..55e994e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,6 @@ sqlalchemy==2.0.25 pydantic==2.5.3 pydantic-settings==2.1.0 python-multipart==0.0.6 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.2.1