commit 2979197b1c2cf49b02b9de067f657cd78e7c5797 Author: 祀梦 <3501646051@qq.com> Date: Sat Mar 14 22:21:26 2026 +0800 release: Elysia ToDo v1.0.0 鍏ㄦ爤涓汉淇℃伅绠$悊搴旂敤锛岄泦鎴愬緟鍔炰换鍔°€佷範鎯墦鍗°€佺邯蹇垫棩鎻愰啋銆佽祫浜ф€昏鍔熻兘銆 Made-with: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8dbea86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,74 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +ENV/ +env/ +.venv + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +logs/ +*.log + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Node +node_modules/ + +# Frontend build output +api/webui/assets/ +WebUI/dist/ + +# API runtime data +api/data/ +api/logs/ + +# Test cache +.pytest_cache/ +.coverage +htmlcov/ + +# Temporary files +*.tmp +*.bak +*.swp +todo_check_temp.* +check_*.py + +# Environment variables +.env +.env.* diff --git a/API_DOCS.md b/API_DOCS.md new file mode 100644 index 0000000..67626f3 --- /dev/null +++ b/API_DOCS.md @@ -0,0 +1,1580 @@ +# 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 new file mode 100644 index 0000000..c9f076f --- /dev/null +++ b/README.md @@ -0,0 +1,173 @@ +# Elysia ToDo - 爱莉希雅待办事项 + +一款全栈个人信息管理应用,集待办任务、习惯打卡、纪念日提醒、资产总览于一体。 + +## 功能概览 + +### 任务管理 +- **待办列表** — 创建、编辑、删除任务,支持分类、标签、优先级 +- **四象限视图** — 基于艾森豪威尔矩阵(重要/紧急)的四象限优先级模型 +- **日历视图** — 按月/周/日查看任务排布 +- **拼音搜索** — 支持中文拼音快速检索任务和分类 + +### 习惯打卡 +- 习惯分组管理(学习、运动、生活等) +- 每日打卡记录,支持周期配置与休息日 +- 周视图打卡进度展示,一目了然 + +### 纪念日管理 +- 自定义纪念日分类 +- 支持农历/公历日期 +- 倒计时提醒,不错过重要日子 + +### 资产总览 +- 财务账户管理(现金、银行卡、电子钱包等) +- 收支记录与历史查询 +- 分期还款跟踪 +- 资产汇总统计 + +### 系统功能 +- 偏好设置(站点名称、默认视图等) +- 可折叠侧边栏 +- 响应式布局 +- SPA 单页应用,History 路由模式 + +## 技术栈 + +| 层级 | 技术 | +|------|------| +| 前端框架 | Vue 3 + TypeScript | +| UI 组件库 | Element Plus | +| 状态管理 | Pinia | +| 路由 | Vue Router 4 | +| 构建工具 | Vite | +| 后端框架 | FastAPI | +| ORM | SQLAlchemy | +| 数据库 | SQLite | +| ASGI 服务器 | Uvicorn | + +## 项目结构 + +``` +ToDoList/ +├── main.py # 启动入口(编译前端 + 启动后端) +├── requirements.txt # Python 依赖 +├── .gitignore +├── api/ # 后端 +│ └── app/ +│ ├── config.py # 配置(端口、路径、CORS 等) +│ ├── database.py # 数据库引擎与会话管理 +│ ├── main.py # FastAPI 应用(路由、中间件、静态文件) +│ ├── models/ # SQLAlchemy 数据模型 +│ ├── schemas/ # Pydantic 请求/响应模型 +│ ├── routers/ # API 路由 +│ └── utils/ # 工具函数(CRUD、日志、日期) +├── WebUI/ # 前端 +│ ├── package.json +│ ├── vite.config.ts +│ └── src/ +│ ├── api/ # Axios 接口封装 +│ ├── components/ # 通用组件 +│ ├── views/ # 页面视图 +│ ├── stores/ # Pinia 状态管理 +│ ├── router/ # 路由配置 +│ ├── styles/ # 全局样式 (SCSS) +│ └── utils/ # 前端工具(拼音、优先级、日期) +└── tests/ # 测试 +``` + +## 快速开始 + +### 环境要求 + +- Python 3.10+ +- Node.js 18+ +- npm + +### 安装与运行 + +```bash +# 1. 克隆项目 +git clone +cd ToDoList + +# 2. 安装 Python 依赖 +pip install -r requirements.txt + +# 3. 一键启动(自动编译前端 + 启动后端) +python main.py +``` + +启动后访问: +- 前端页面:http://localhost:23994 +- API 文档:http://localhost:23994/docs + +### 前端开发模式 + +如果需要前后端分离开发(热更新): + +```bash +# 终端 1 — 启动后端 +cd api +uvicorn app.main:app --host 0.0.0.0 --port 23994 + +# 终端 2 — 启动前端开发服务器 +cd WebUI +npm install +npm run dev +``` + +前端开发服务器运行在 http://localhost:5173,已配置 API 代理到后端。 + +## API 概览 + +所有接口均以 `/api` 为前缀,详细的请求/响应格式参见 [API_DOCS.md](./API_DOCS.md) 或访问 `/docs` 查看 Swagger 文档。 + +| 模块 | 前缀 | 说明 | +|------|------|------| +| 任务 | `/api/tasks` | 任务 CRUD、状态切换、批量操作 | +| 分类 | `/api/categories` | 分类 CRUD | +| 标签 | `/api/tags` | 标签 CRUD | +| 习惯 | `/api/habits` | 习惯、习惯组、打卡记录 | +| 纪念日 | `/api/anniversaries` | 纪念日、纪念日分类 | +| 资产 | `/api/accounts` | 账户、交易记录、分期还款 | +| 设置 | `/api/user-settings` | 用户偏好设置 | +| 健康检查 | `/health` | 服务状态检查 | + +## 数据模型关系 + +``` +Category ──< Task >── Tag + │ + HabitGroup ──< Habit ──< HabitCheckin + +AnniversaryCategory ──< Anniversary + +FinancialAccount ──< AccountHistory + ──< DebtInstallment + +UserSettings (单例) +``` + +## 配置说明 + +在 `api/app/config.py` 中可以修改: + +| 配置项 | 默认值 | 说明 | +|--------|--------|------| +| HOST | `0.0.0.0` | 监听地址 | +| PORT | `23994` | 服务端口 | +| DATABASE_PATH | `api/data/todo.db` | SQLite 数据库路径 | +| WEBUI_PATH | `api/webui` | 前端静态文件目录 | +| CORS_ORIGINS | `localhost:5173, 23994` | 允许的跨域来源 | + +数据库文件和日志文件会在首次运行时自动创建,无需手动初始化。 + +## 部署 + +项目支持在 Windows 和 Linux 上运行。`main.py` 会自动处理平台差异(npm 命令、端口占用检测等)。 + +对于生产环境部署,建议: +- 使用 `gunicorn` + `uvicorn worker` 替代直接运行 +- 配置反向代理(Nginx) +- 数据库可替换为 PostgreSQL 或 MySQL(修改 `config.py` 中的 `DATABASE_URL`) diff --git a/WebUI/.gitignore b/WebUI/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/WebUI/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/WebUI/README.md b/WebUI/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/WebUI/README.md @@ -0,0 +1,5 @@ +# Vue 3 + TypeScript + Vite + +This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + diff --git a/WebUI/package-lock.json b/WebUI/package-lock.json new file mode 100644 index 0000000..a75e1e3 --- /dev/null +++ b/WebUI/package-lock.json @@ -0,0 +1,2499 @@ +{ + "name": "webui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "webui", + "version": "0.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.13.6", + "element-plus": "^2.13.5", + "pinia": "^3.0.4", + "pinyin-pro": "^3.28.0", + "sass": "^1.98.0", + "vue": "^3.5.25", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@vitejs/plugin-vue": "^6.0.2", + "@vue/tsconfig": "^0.8.1", + "typescript": "~5.9.3", + "vite": "^7.3.1", + "vue-tsc": "^3.1.5" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz", + "integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "3.2.5", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.2.5.tgz", + "integrity": "sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.8.1", + "resolved": "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.8.1.tgz", + "integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.13.5", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.5.tgz", + "integrity": "sha512-dmY24fhSREfZN/PuUt0YZigMso7wWzl+B5o+YKNN15kQIn/0hzamsPU+ebj9SES0IbUqsLX1wkrzYmzU8VrVOQ==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pinyin-pro": { + "version": "3.28.0", + "resolved": "https://registry.npmmirror.com/pinyin-pro/-/pinyin-pro-3.28.0.tgz", + "integrity": "sha512-mMRty6RisoyYNphJrTo3pnvp3w8OMZBrXm9YSWkxhAfxKj1KZk2y8T2PDIZlDDRsvZ0No+Hz6FI4sZpA6Ey25g==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sass": { + "version": "1.98.0", + "resolved": "https://registry.npmmirror.com/sass/-/sass-1.98.0.tgz", + "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==", + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "3.2.5", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.2.5.tgz", + "integrity": "sha512-/htfTCMluQ+P2FISGAooul8kO4JMheOTCbCy4M6dYnYYjqLe3BExZudAua6MSIKSFYQtFOYAll7XobYwcpokGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.2.5" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/WebUI/package.json b/WebUI/package.json new file mode 100644 index 0000000..099ac7b --- /dev/null +++ b/WebUI/package.json @@ -0,0 +1,30 @@ +{ + "name": "webui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "vue-tsc -b --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.13.6", + "element-plus": "^2.13.5", + "pinia": "^3.0.4", + "pinyin-pro": "^3.28.0", + "sass": "^1.98.0", + "vue": "^3.5.25", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@vitejs/plugin-vue": "^6.0.2", + "@vue/tsconfig": "^0.8.1", + "typescript": "~5.9.3", + "vite": "^7.3.1", + "vue-tsc": "^3.1.5" + } +} diff --git a/WebUI/public/vite.svg b/WebUI/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/WebUI/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/WebUI/src/App.vue b/WebUI/src/App.vue new file mode 100644 index 0000000..5439fbd --- /dev/null +++ b/WebUI/src/App.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/WebUI/src/api/accounts.ts b/WebUI/src/api/accounts.ts new file mode 100644 index 0000000..d7c1872 --- /dev/null +++ b/WebUI/src/api/accounts.ts @@ -0,0 +1,64 @@ +import { get, post, put, del, patch } from './request' +import type { + FinancialAccount, AccountFormData, BalanceUpdateData, + AccountHistoryResponse, DebtInstallment, DebtInstallmentFormData +} from './types' + +export interface GetAccountHistoryParams { + page?: number + page_size?: number +} + +export const accountApi = { + // ============ 账户 CRUD ============ + getAccounts(): Promise { + return get('/accounts') + }, + + getAccount(id: number): Promise { + return get(`/accounts/${id}`) + }, + + createAccount(data: AccountFormData): Promise { + return post('/accounts', data) + }, + + updateAccount(id: number, data: Partial): Promise { + return put(`/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 { + return post(`/accounts/${id}/balance`, data) + }, + + // ============ 变更历史 ============ + getHistory(id: number, params?: GetAccountHistoryParams): Promise { + return get(`/accounts/${id}/history`, { params }) + }, + + // ============ 分期计划 ============ + getInstallments(): Promise { + return get('/debt-installments') + }, + + createInstallment(data: DebtInstallmentFormData): Promise { + return post('/debt-installments', data) + }, + + updateInstallment(id: number, data: Partial): Promise { + return put(`/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 { + return patch(`/debt-installments/${id}/pay`) + }, +} diff --git a/WebUI/src/api/anniversaries.ts b/WebUI/src/api/anniversaries.ts new file mode 100644 index 0000000..77ede12 --- /dev/null +++ b/WebUI/src/api/anniversaries.ts @@ -0,0 +1,49 @@ +import { get, post, put, del } from './request' +import type { Anniversary, AnniversaryFormData, AnniversaryCategory, AnniversaryCategoryFormData } from './types' + +export type AnniversaryResponse = Anniversary +export type AnniversaryCategoryResponse = AnniversaryCategory + +export interface GetAnniversariesParams { + category_id?: number +} + +export const anniversaryApi = { + // ============ 纪念日 ============ + getAnniversaries(params?: GetAnniversariesParams): Promise { + return get('/anniversaries', { params }) + }, + + getAnniversary(id: number): Promise { + return get(`/anniversaries/${id}`) + }, + + createAnniversary(data: AnniversaryFormData): Promise { + return post('/anniversaries', data) + }, + + updateAnniversary(id: number, data: Partial): Promise { + return put(`/anniversaries/${id}`, data) + }, + + deleteAnniversary(id: number): Promise<{ success: boolean; message?: string }> { + return del<{ success: boolean; message?: string }>(`/anniversaries/${id}`) + }, + + // ============ 纪念日分类 ============ + getCategories(): Promise { + return get('/anniversary-categories') + }, + + createCategory(data: AnniversaryCategoryFormData): Promise { + return post('/anniversary-categories', data) + }, + + updateCategory(id: number, data: Partial): Promise { + return put(`/anniversary-categories/${id}`, data) + }, + + deleteCategory(id: number): Promise<{ success: boolean; message?: string }> { + return del<{ success: boolean; message?: string }>(`/anniversary-categories/${id}`) + }, +} diff --git a/WebUI/src/api/categories.ts b/WebUI/src/api/categories.ts new file mode 100644 index 0000000..c796de6 --- /dev/null +++ b/WebUI/src/api/categories.ts @@ -0,0 +1,29 @@ +import { get, post, put, del, patch } from './request' +import type { CategoryFormData } from './types' + +export interface CategoryResponse { + id: number + name: string + color: string + icon: string +} + +export type { CategoryFormData } + +export const categoryApi = { + getCategories(): Promise { + return get('/categories') + }, + + createCategory(data: CategoryFormData): Promise { + return post('/categories', data) + }, + + updateCategory(id: number, data: CategoryFormData): Promise { + return put(`/categories/${id}`, data) + }, + + deleteCategory(id: number): Promise<{ success: boolean; message?: string }> { + return del<{ success: boolean; message?: string }>(`/categories/${id}`) + } +} diff --git a/WebUI/src/api/habits.ts b/WebUI/src/api/habits.ts new file mode 100644 index 0000000..d7b8193 --- /dev/null +++ b/WebUI/src/api/habits.ts @@ -0,0 +1,78 @@ +import { get, post, put, del, patch } from './request' +import type { + Habit, HabitFormData, HabitGroup, HabitGroupFormData, + HabitCheckin, HabitStats +} from './types' + +export type HabitResponse = Habit +export type HabitGroupResponse = HabitGroup +export type HabitCheckinResponse = HabitCheckin +export type HabitStatsResponse = HabitStats + +export const habitGroupApi = { + getGroups(): Promise { + return get('/habit-groups') + }, + + createGroup(data: HabitGroupFormData): Promise { + return post('/habit-groups', data) + }, + + updateGroup(id: number, data: Partial): Promise { + return put(`/habit-groups/${id}`, data) + }, + + deleteGroup(id: number): Promise<{ success: boolean; message?: string }> { + return del<{ success: boolean; message?: string }>(`/habit-groups/${id}`) + } +} + +export interface GetHabitsParams { + include_archived?: boolean +} + +export const habitApi = { + getHabits(params?: GetHabitsParams): Promise { + return get('/habits', { params }) + }, + + getHabit(id: number): Promise { + return get(`/habits/${id}`) + }, + + createHabit(data: HabitFormData): Promise { + return post('/habits', data) + }, + + updateHabit(id: number, data: Partial): Promise { + return put(`/habits/${id}`, data) + }, + + deleteHabit(id: number): Promise<{ success: boolean; message?: string }> { + return del<{ success: boolean; message?: string }>(`/habits/${id}`) + }, + + toggleArchive(id: number): Promise { + return patch(`/habits/${id}/archive`, {}) + }, + + getCheckins(habitId: number, fromDate?: string, toDate?: string): Promise { + const params: Record = {} + if (fromDate) params.from_date = fromDate + if (toDate) params.to_date = toDate + return get(`/habits/${habitId}/checkins`, { params }) + }, + + checkin(habitId: number, count?: number): Promise { + const data = count !== undefined ? { count } : {} + return post(`/habits/${habitId}/checkins`, data) + }, + + cancelCheckin(habitId: number, count: number = 1): Promise<{ success: boolean; message?: string }> { + return del<{ success: boolean; message?: string }>(`/habits/${habitId}/checkins`, { params: { count } }) + }, + + getStats(habitId: number): Promise { + return get(`/habits/${habitId}/checkins/stats`) + } +} diff --git a/WebUI/src/api/request.ts b/WebUI/src/api/request.ts new file mode 100644 index 0000000..f4c184b --- /dev/null +++ b/WebUI/src/api/request.ts @@ -0,0 +1,72 @@ +import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios' +import { ElMessage } from 'element-plus' + +const instance: AxiosInstance = axios.create({ + baseURL: '/api', + timeout: 10000, + headers: { + 'Content-Type': 'application/json' + } +}) + +instance.interceptors.response.use( + (response: AxiosResponse) => { + return response + }, + (error) => { + let message = '服务器开小差了,请稍后再试~' + if (error.response) { + const data = error.response.data + if (data?.detail) { + message = Array.isArray(data.detail) + ? data.detail.map((d: any) => d.msg || d.loc?.join('.')).join('; ') + : data.detail + } + switch (error.response.status) { + case 400: + message = data?.detail || '请求参数有误,请检查一下~' + break + case 401: + message = '登录状态已失效~' + break + case 403: + message = '没有权限访问呢~' + break + case 404: + message = '请求的资源不存在~' + break + case 500: + message = '服务器内部错误~' + break + } + } + ElMessage.error({ + message, + duration: 3000, + showClose: true + }) + return Promise.reject(error) + } +) + +export default instance + +export function get(url: string, config?: AxiosRequestConfig): Promise { + return instance.get(url, config).then((res) => res.data) +} + +export function post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { + return instance.post(url, data, config).then((res) => res.data) +} + +export function put(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { + return instance.put(url, data, config).then((res) => res.data) +} + +export function del(url: string, config?: AxiosRequestConfig): Promise { + return instance.delete(url, config).then((res) => res.data) +} + +export function patch(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { + return instance.patch(url, data, config).then((res) => res.data) +} diff --git a/WebUI/src/api/tags.ts b/WebUI/src/api/tags.ts new file mode 100644 index 0000000..1daa576 --- /dev/null +++ b/WebUI/src/api/tags.ts @@ -0,0 +1,23 @@ +import { get, post, del } from './request' +import type { TagFormData } from './types' + +export interface TagResponse { + id: number + name: string +} + +export type { TagFormData } + +export const tagApi = { + getTags(): Promise { + return get('/tags') + }, + + createTag(data: TagFormData): Promise { + return post('/tags', data) + }, + + deleteTag(id: number): Promise<{ success: boolean; message?: string }> { + return del<{ success: boolean; message?: string }>(`/tags/${id}`) + } +} diff --git a/WebUI/src/api/tasks.ts b/WebUI/src/api/tasks.ts new file mode 100644 index 0000000..723fe5d --- /dev/null +++ b/WebUI/src/api/tasks.ts @@ -0,0 +1,44 @@ +import { get, post, put, del, patch } from './request' +import type { TaskFormData } from './types' + +export type TaskResponse = import('./types').Task +export type CategoryResponse = import('./types').Category +export type TagResponse = import('./types').Tag + +export type { TaskFormData } + +export interface GetTasksParams { + status?: string + category_id?: number + priority?: string + sort_by?: string + sort_order?: string + skip?: number + limit?: number +} + +export const taskApi = { + getTasks(params?: GetTasksParams): Promise { + return get('/tasks', { params }) + }, + + getTask(id: number): Promise { + return get(`/tasks/${id}`) + }, + + createTask(data: TaskFormData): Promise { + return post('/tasks', data) + }, + + updateTask(id: number, data: TaskFormData): Promise { + return put(`/tasks/${id}`, data) + }, + + deleteTask(id: number): Promise<{ success: boolean; message?: string }> { + return del<{ success: boolean; message?: string }>(`/tasks/${id}`) + }, + + toggleTask(id: number): Promise { + return patch(`/tasks/${id}/toggle`, {}) + } +} diff --git a/WebUI/src/api/types.ts b/WebUI/src/api/types.ts new file mode 100644 index 0000000..41bb05d --- /dev/null +++ b/WebUI/src/api/types.ts @@ -0,0 +1,278 @@ +export type QuadrantPriority = 'q1' | 'q2' | 'q3' | 'q4' + +export interface Task { + id: number + title: string + description?: string + priority: QuadrantPriority + due_date?: string + is_completed: boolean + category_id?: number + category?: Category + tags?: Tag[] + created_at: string + updated_at: string +} + +export interface Category { + id: number + name: string + color: string + icon: string +} + +export interface Tag { + id: number + name: string +} + +export interface TaskFormData { + title: string + description?: string + priority: QuadrantPriority + due_date?: string + category_id?: number + tag_ids?: number[] +} + +export interface CategoryFormData { + name: string + color: string + icon: string +} + +export interface TagFormData { + name: string +} + +export interface TaskFilters { + status?: 'all' | 'active' | 'completed' + category_id?: number + sort_by?: 'priority' | 'due_date' | 'created_at' + sort_order?: 'asc' | 'desc' + search?: string +} + +export interface UserSettings { + id: number + nickname: string + avatar?: string + signature?: string + birthday?: string + email?: string + site_name: string + theme: string + language: string + default_view: string + default_sort_by: string + default_sort_order: string + created_at: string + updated_at: string +} + +export interface UserSettingsUpdate { + nickname?: string + avatar?: string + signature?: string + birthday?: string + email?: string + site_name?: string + theme?: string + language?: string + default_view?: string + default_sort_by?: string + default_sort_order?: string +} + +// ============ 习惯相关 ============ + +export interface HabitGroup { + id: number + name: string + color: string + icon: string + sort_order: number +} + +export interface HabitGroupFormData { + name: string + color: string + icon: string + sort_order?: number +} + +export type HabitFrequency = 'daily' | 'weekly' + +export interface Habit { + id: number + name: string + description?: string + group_id?: number + target_count: number + frequency: HabitFrequency + active_days?: string + is_archived: boolean + created_at: string + updated_at: string + group?: HabitGroup +} + +export interface HabitFormData { + name: string + description?: string + group_id?: number | null + target_count: number + frequency: HabitFrequency + active_days?: string | null +} + +export interface HabitCheckin { + id: number + habit_id: number + checkin_date: string + count: number + created_at: string +} + +export interface HabitStats { + total_days: number + current_streak: number + longest_streak: number + today_count: number + today_completed: boolean +} + +// ============ 纪念日相关 ============ + +export interface AnniversaryCategory { + id: number + name: string + icon: string + color: string + sort_order: number +} + +export interface AnniversaryCategoryFormData { + name: string + icon: string + color: string + sort_order?: number +} + +export interface Anniversary { + id: number + title: string + date: string + year?: number | null + category_id?: number | null + description?: string | null + is_recurring: boolean + remind_days_before: number + created_at: string + updated_at: string + category?: AnniversaryCategory | null + next_date?: string | null + days_until?: number | null + year_count?: number | null +} + +export interface AnniversaryFormData { + title: string + date: string + year?: number | null + category_id?: number | null + description?: string | null + is_recurring: boolean + remind_days_before: number +} + +// ============ 资产账户相关 ============ + +export type AccountType = 'savings' | 'debt' + +export interface FinancialAccount { + id: number + name: string + account_type: AccountType + balance: number + icon: string + color: string + sort_order: number + is_active: boolean + description?: string | null + created_at: string + updated_at: string + installments?: InstallmentInfo[] +} + +export interface AccountFormData { + name: string + account_type: AccountType + balance: number + icon: string + color: string + sort_order: number + is_active: boolean + description?: string | null +} + +export interface BalanceUpdateData { + new_balance: number + note?: string | null +} + +export interface InstallmentInfo { + next_payment_date: string | null + days_until_payment: number | null + remaining_periods: number +} + +export interface AccountHistoryRecord { + id: number + account_id: number + change_amount: number + balance_before: number + balance_after: number + note?: string | null + created_at: string +} + +export interface AccountHistoryResponse { + total: number + page: number + page_size: number + records: AccountHistoryRecord[] +} + +// ============ 分期还款计划相关 ============ + +export interface DebtInstallment { + id: number + account_id: number + total_amount: number + total_periods: number + current_period: number + payment_day: number + payment_amount: number + start_date: string + is_completed: boolean + created_at: string + updated_at: string + next_payment_date: string | null + days_until_payment: number | null + remaining_periods: number | null + account_name: string | null + account_icon: string | null + account_color: string | null +} + +export interface DebtInstallmentFormData { + account_id: number + total_amount: number + total_periods: number + current_period: number + payment_day: number + payment_amount: number + start_date: string + is_completed: boolean +} diff --git a/WebUI/src/api/userSettings.ts b/WebUI/src/api/userSettings.ts new file mode 100644 index 0000000..2d9c56d --- /dev/null +++ b/WebUI/src/api/userSettings.ts @@ -0,0 +1,10 @@ +import { get, put } from './request' +import type { UserSettings, UserSettingsUpdate } from './types' + +export function getUserSettings(): Promise { + return get('/user-settings') +} + +export function updateUserSettings(data: UserSettingsUpdate): Promise { + return put('/user-settings', data) +} diff --git a/WebUI/src/assets/vue.svg b/WebUI/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/WebUI/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/WebUI/src/components/AccountDialog.vue b/WebUI/src/components/AccountDialog.vue new file mode 100644 index 0000000..e881306 --- /dev/null +++ b/WebUI/src/components/AccountDialog.vue @@ -0,0 +1,361 @@ + + + + + diff --git a/WebUI/src/components/AccountHistoryDialog.vue b/WebUI/src/components/AccountHistoryDialog.vue new file mode 100644 index 0000000..cf7b3ef --- /dev/null +++ b/WebUI/src/components/AccountHistoryDialog.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/WebUI/src/components/AnniversaryCategoryDialog.vue b/WebUI/src/components/AnniversaryCategoryDialog.vue new file mode 100644 index 0000000..a9b1867 --- /dev/null +++ b/WebUI/src/components/AnniversaryCategoryDialog.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/WebUI/src/components/AnniversaryDialog.vue b/WebUI/src/components/AnniversaryDialog.vue new file mode 100644 index 0000000..ef6306f --- /dev/null +++ b/WebUI/src/components/AnniversaryDialog.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/WebUI/src/components/AppHeader.vue b/WebUI/src/components/AppHeader.vue new file mode 100644 index 0000000..60218b1 --- /dev/null +++ b/WebUI/src/components/AppHeader.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/WebUI/src/components/BalanceDialog.vue b/WebUI/src/components/BalanceDialog.vue new file mode 100644 index 0000000..3bfb6dc --- /dev/null +++ b/WebUI/src/components/BalanceDialog.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/WebUI/src/components/CategoryDialog.vue b/WebUI/src/components/CategoryDialog.vue new file mode 100644 index 0000000..4104fff --- /dev/null +++ b/WebUI/src/components/CategoryDialog.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/WebUI/src/components/CategorySidebar.vue b/WebUI/src/components/CategorySidebar.vue new file mode 100644 index 0000000..079df53 --- /dev/null +++ b/WebUI/src/components/CategorySidebar.vue @@ -0,0 +1,698 @@ + + + + + diff --git a/WebUI/src/components/HabitDialog.vue b/WebUI/src/components/HabitDialog.vue new file mode 100644 index 0000000..602b110 --- /dev/null +++ b/WebUI/src/components/HabitDialog.vue @@ -0,0 +1,330 @@ + + + + + diff --git a/WebUI/src/components/HabitGroupDialog.vue b/WebUI/src/components/HabitGroupDialog.vue new file mode 100644 index 0000000..d4b459d --- /dev/null +++ b/WebUI/src/components/HabitGroupDialog.vue @@ -0,0 +1,468 @@ + + + + + diff --git a/WebUI/src/components/InstallmentDialog.vue b/WebUI/src/components/InstallmentDialog.vue new file mode 100644 index 0000000..266ab8e --- /dev/null +++ b/WebUI/src/components/InstallmentDialog.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/WebUI/src/components/QuadrantTaskCard.vue b/WebUI/src/components/QuadrantTaskCard.vue new file mode 100644 index 0000000..b3efda3 --- /dev/null +++ b/WebUI/src/components/QuadrantTaskCard.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/WebUI/src/components/TaskCard.vue b/WebUI/src/components/TaskCard.vue new file mode 100644 index 0000000..074fdc3 --- /dev/null +++ b/WebUI/src/components/TaskCard.vue @@ -0,0 +1,368 @@ + + + + + diff --git a/WebUI/src/components/TaskDialog.vue b/WebUI/src/components/TaskDialog.vue new file mode 100644 index 0000000..e3701ce --- /dev/null +++ b/WebUI/src/components/TaskDialog.vue @@ -0,0 +1,330 @@ + + + + + diff --git a/WebUI/src/main.ts b/WebUI/src/main.ts new file mode 100644 index 0000000..8d24d65 --- /dev/null +++ b/WebUI/src/main.ts @@ -0,0 +1,21 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import 'element-plus/dist/index.css' +import '@/styles/main.scss' +import App from './App.vue' +import router from './router' + +const app = createApp(App) +const pinia = createPinia() + +app.use(pinia) +app.use(router) +app.use(ElementPlus) + +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.mount('#app') diff --git a/WebUI/src/router/index.ts b/WebUI/src/router/index.ts new file mode 100644 index 0000000..ec0966f --- /dev/null +++ b/WebUI/src/router/index.ts @@ -0,0 +1,78 @@ +import { createRouter, createWebHistory } from 'vue-router' +import type { RouteRecordRaw } from 'vue-router' +import { useUserSettingsStore } from '@/stores/useUserSettingsStore' + +const routes: RouteRecordRaw[] = [ + { + path: '/', + redirect: '/tasks' + }, + { + path: '/tasks', + name: 'tasks', + component: () => import('@/views/TaskListView.vue'), + meta: { title: '待办列表', view: 'list' } + }, + { + path: '/calendar', + name: 'calendar', + component: () => import('@/views/CalendarPage.vue'), + meta: { title: '日历视图', view: 'calendar' } + }, + { + path: '/quadrant', + name: 'quadrant', + component: () => import('@/views/QuadrantPage.vue'), + meta: { title: '四象限', view: 'quadrant' } + }, + { + path: '/profile', + name: 'profile', + component: () => import('@/views/ProfileView.vue'), + meta: { title: '个人信息', view: 'profile' } + }, + { + path: '/habits', + name: 'habits', + component: () => import('@/views/HabitPage.vue'), + meta: { title: '习惯打卡', view: 'habits' } + }, + { + path: '/anniversaries', + name: 'anniversaries', + component: () => import('@/views/AnniversaryPage.vue'), + meta: { title: '纪念日', view: 'anniversaries' } + }, + { + path: '/assets', + name: 'assets', + component: () => import('@/views/AssetPage.vue'), + meta: { title: '资产总览', view: 'assets' } + }, + { + path: '/settings', + name: 'settings', + component: () => import('@/views/SettingsView.vue'), + meta: { title: '偏好设置', view: 'settings' } + } +] + +const router = createRouter({ + history: createWebHistory(), + routes, + scrollBehavior(_to, _from, savedPosition) { + if (savedPosition) { + return savedPosition + } + return { top: 0 } + } +}) + +router.beforeEach((to) => { + const page = (to.meta.title as string) || '' + const userStore = useUserSettingsStore() + const siteName = userStore.siteName || '爱莉希雅待办' + document.title = page ? `${page} - ${siteName}` : siteName +}) + +export default router diff --git a/WebUI/src/stores/useAccountStore.ts b/WebUI/src/stores/useAccountStore.ts new file mode 100644 index 0000000..daf9427 --- /dev/null +++ b/WebUI/src/stores/useAccountStore.ts @@ -0,0 +1,208 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { accountApi } from '@/api/accounts' +import type { + FinancialAccount, AccountFormData, BalanceUpdateData, + AccountHistoryRecord, DebtInstallment, DebtInstallmentFormData +} from '@/api/types' + +export const useAccountStore = defineStore('account', () => { + const accounts = ref([]) + const installments = ref([]) + 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 { + 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): Promise { + 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 { + 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 { + 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 { + 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 { + 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): Promise { + 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 { + 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 { + 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, + } +}) diff --git a/WebUI/src/stores/useAnniversaryStore.ts b/WebUI/src/stores/useAnniversaryStore.ts new file mode 100644 index 0000000..58bf2c0 --- /dev/null +++ b/WebUI/src/stores/useAnniversaryStore.ts @@ -0,0 +1,180 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { anniversaryApi } from '@/api/anniversaries' +import type { Anniversary, AnniversaryFormData, AnniversaryCategory, AnniversaryCategoryFormData } from '@/api/types' + +export const useAnniversaryStore = defineStore('anniversary', () => { + const anniversaries = ref([]) + const categories = ref([]) + const loading = ref(false) + const activeCategoryId = ref(null) + + const filteredAnniversaries = computed(() => { + if (activeCategoryId.value === null) { + return anniversaries.value + } + return anniversaries.value.filter(a => a.category_id === activeCategoryId.value) + }) + + const upcomingAnniversaries = computed(() => + filteredAnniversaries.value.filter(a => a.days_until !== null && a.days_until! >= 0) + ) + + const pastAnniversaries = computed(() => + filteredAnniversaries.value.filter(a => a.days_until === null || a.days_until! < 0) + ) + + const todayAnniversaries = computed(() => + filteredAnniversaries.value.filter(a => a.days_until === 0) + ) + + const remindAnniversaries = computed(() => + filteredAnniversaries.value.filter(a => + a.days_until !== null && a.days_until! >= 0 && a.days_until! <= a.remind_days_before + ) + ) + + // ============ 纪念日操作 ============ + + async function fetchAnniversaries() { + loading.value = true + try { + const params = activeCategoryId.value !== null + ? { category_id: activeCategoryId.value } + : undefined + anniversaries.value = await anniversaryApi.getAnniversaries(params) + } catch (error) { + console.error('获取纪念日列表失败:', error) + } finally { + loading.value = false + } + } + + async function createAnniversary(data: AnniversaryFormData): Promise { + try { + const newAnniversary = await anniversaryApi.createAnniversary(data) + anniversaries.value.unshift(newAnniversary) + reSort() + return newAnniversary + } catch (error) { + console.error('创建纪念日失败:', error) + return null + } + } + + async function updateAnniversary(id: number, data: Partial): Promise { + try { + const updated = await anniversaryApi.updateAnniversary(id, data) + const index = anniversaries.value.findIndex(a => a.id === id) + if (index !== -1) { + anniversaries.value[index] = updated + } + reSort() + return updated + } catch (error) { + console.error('更新纪念日失败:', error) + return null + } + } + + async function deleteAnniversary(id: number): Promise { + try { + await anniversaryApi.deleteAnniversary(id) + anniversaries.value = anniversaries.value.filter(a => a.id !== id) + return true + } catch (error) { + console.error('删除纪念日失败:', error) + return false + } + } + + // ============ 分类操作 ============ + + async function fetchCategories() { + try { + categories.value = await anniversaryApi.getCategories() + } catch (error) { + console.error('获取纪念日分类失败:', error) + } + } + + async function createCategory(data: AnniversaryCategoryFormData): Promise { + try { + const newCat = await anniversaryApi.createCategory(data) + categories.value.push(newCat) + categories.value.sort((a, b) => a.sort_order - b.sort_order) + return newCat + } catch (error) { + console.error('创建纪念日分类失败:', error) + return null + } + } + + async function updateCategory(id: number, data: Partial): Promise { + try { + const updated = await anniversaryApi.updateCategory(id, data) + const index = categories.value.findIndex(c => c.id === id) + if (index !== -1) { + categories.value[index] = updated + } + categories.value.sort((a, b) => a.sort_order - b.sort_order) + return updated + } catch (error) { + console.error('更新纪念日分类失败:', error) + return null + } + } + + async function deleteCategory(id: number): Promise { + try { + await anniversaryApi.deleteCategory(id) + categories.value = categories.value.filter(c => c.id !== id) + if (activeCategoryId.value === id) { + activeCategoryId.value = null + } + return true + } catch (error) { + console.error('删除纪念日分类失败:', error) + return false + } + } + + function setFilter(categoryId: number | null) { + activeCategoryId.value = categoryId + } + + function reSort() { + anniversaries.value.sort((a, b) => { + const aUpcoming = a.days_until !== null && a.days_until >= 0 ? 0 : 1 + const bUpcoming = b.days_until !== null && b.days_until >= 0 ? 0 : 1 + if (aUpcoming !== bUpcoming) return aUpcoming - bUpcoming + return (a.days_until ?? 9999) - (b.days_until ?? 9999) + }) + } + + async function init() { + await Promise.all([fetchAnniversaries(), fetchCategories()]) + } + + return { + anniversaries, + categories, + loading, + activeCategoryId, + filteredAnniversaries, + upcomingAnniversaries, + pastAnniversaries, + todayAnniversaries, + remindAnniversaries, + fetchAnniversaries, + createAnniversary, + updateAnniversary, + deleteAnniversary, + fetchCategories, + createCategory, + updateCategory, + deleteCategory, + setFilter, + init, + } +}) diff --git a/WebUI/src/stores/useCategoryStore.ts b/WebUI/src/stores/useCategoryStore.ts new file mode 100644 index 0000000..678baa2 --- /dev/null +++ b/WebUI/src/stores/useCategoryStore.ts @@ -0,0 +1,65 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { categoryApi, type CategoryResponse } from '@/api/categories' +import type { CategoryFormData } from '@/api/types' + +export const useCategoryStore = defineStore('category', () => { + const categories = ref([]) + const loading = ref(false) + + async function fetchCategories() { + loading.value = true + try { + categories.value = await categoryApi.getCategories() + } catch (error) { + console.error('获取分类列表失败:', error) + } finally { + loading.value = false + } + } + + async function createCategory(data: CategoryFormData) { + try { + const newCategory = await categoryApi.createCategory(data) + categories.value.push(newCategory) + return newCategory + } catch (error) { + console.error('创建分类失败:', error) + return null + } + } + + async function updateCategory(id: number, data: CategoryFormData) { + try { + const updatedCategory = await categoryApi.updateCategory(id, data) + const index = categories.value.findIndex((c: CategoryResponse) => c.id === id) + if (index !== -1) { + categories.value[index] = updatedCategory + } + return updatedCategory + } catch (error) { + console.error('更新分类失败:', error) + return null + } + } + + async function deleteCategory(id: number) { + try { + await categoryApi.deleteCategory(id) + categories.value = categories.value.filter((c: CategoryResponse) => c.id !== id) + return true + } catch (error) { + console.error('删除分类失败:', error) + return false + } + } + + return { + categories, + loading, + fetchCategories, + createCategory, + updateCategory, + deleteCategory + } +}) diff --git a/WebUI/src/stores/useHabitStore.ts b/WebUI/src/stores/useHabitStore.ts new file mode 100644 index 0000000..3f5c7e7 --- /dev/null +++ b/WebUI/src/stores/useHabitStore.ts @@ -0,0 +1,227 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { habitApi, habitGroupApi, type HabitResponse, type HabitGroupResponse, type HabitStatsResponse } from '@/api/habits' +import type { HabitFormData, HabitGroupFormData } from '@/api/types' + +export const useHabitStore = defineStore('habit', () => { + const habits = ref([]) + const groups = ref([]) + const statsMap = ref>({}) + const loading = ref(false) + + // 按分组组织习惯 + const groupedHabits = computed(() => { + const map = new Map() + + // 先添加有分组的 + for (const group of groups.value) { + map.set(group.id, { group, habits: [] }) + } + + for (const habit of habits.value) { + if (habit.group_id && map.has(habit.group_id)) { + map.get(habit.group_id)!.habits.push(habit) + } else { + // 未分组 + if (!map.has(0)) { + map.set(0, { group: null, habits: [] }) + } + map.get(0)!.habits.push(habit) + } + } + + return Array.from(map.values()) + }) + + // 今日统计 + const todaySummary = computed(() => { + const todayHabits = habits.value.filter(h => !h.is_archived) + const total = todayHabits.length + let completed = 0 + let maxStreak = 0 + + for (const habit of todayHabits) { + const stats = statsMap.value[habit.id] + if (stats?.today_completed) completed++ + if (stats && stats.current_streak > maxStreak) maxStreak = stats.current_streak + } + + return { total, completed, maxStreak } + }) + + async function fetchHabits(includeArchived = false) { + loading.value = true + try { + habits.value = await habitApi.getHabits({ include_archived: includeArchived }) + } catch (error) { + console.error('获取习惯列表失败:', error) + } finally { + loading.value = false + } + } + + async function fetchGroups() { + try { + groups.value = await habitGroupApi.getGroups() + } catch (error) { + console.error('获取习惯分组失败:', error) + } + } + + async function fetchStats(habitId: number) { + try { + statsMap.value[habitId] = await habitApi.getStats(habitId) + } catch (error) { + console.error('获取习惯统计失败:', error) + } + } + + async function fetchAllStats() { + for (const habit of habits.value) { + await fetchStats(habit.id) + } + } + + async function createHabit(data: HabitFormData) { + try { + const newHabit = await habitApi.createHabit(data) + habits.value.unshift(newHabit) + await fetchStats(newHabit.id) + return newHabit + } catch (error) { + console.error('创建习惯失败:', error) + return null + } + } + + async function updateHabit(id: number, data: Partial) { + try { + const updated = await habitApi.updateHabit(id, data) + const index = habits.value.findIndex(h => h.id === id) + if (index !== -1) habits.value[index] = updated + return updated + } catch (error) { + console.error('更新习惯失败:', error) + return null + } + } + + async function deleteHabit(id: number) { + try { + await habitApi.deleteHabit(id) + habits.value = habits.value.filter(h => h.id !== id) + delete statsMap.value[id] + return true + } catch (error) { + console.error('删除习惯失败:', error) + return false + } + } + + async function toggleArchive(id: number) { + try { + const updated = await habitApi.toggleArchive(id) + const index = habits.value.findIndex(h => h.id === id) + if (index !== -1) habits.value[index] = updated + return updated + } catch (error) { + console.error('切换归档状态失败:', error) + return null + } + } + + async function checkin(habitId: number, count?: number) { + try { + const result = await habitApi.checkin(habitId, count) + await fetchStats(habitId) + return result + } catch (error) { + console.error('打卡失败:', error) + return null + } + } + + async function cancelCheckin(habitId: number, count: number = 1) { + try { + await habitApi.cancelCheckin(habitId, count) + await fetchStats(habitId) + return true + } catch (error) { + console.error('取消打卡失败:', error) + return false + } + } + + async function createGroup(data: HabitGroupFormData) { + try { + const newGroup = await habitGroupApi.createGroup(data) + groups.value.push(newGroup) + return newGroup + } catch (error) { + console.error('创建分组失败:', error) + return null + } + } + + async function updateGroup(id: number, data: Partial) { + try { + const updated = await habitGroupApi.updateGroup(id, data) + const index = groups.value.findIndex(g => g.id === id) + if (index !== -1) groups.value[index] = updated + // 同步更新 habits 中的 group 引用 + for (const habit of habits.value) { + if (habit.group_id === id) habit.group = updated + } + return updated + } catch (error) { + console.error('更新分组失败:', error) + return null + } + } + + async function deleteGroup(id: number) { + try { + await habitGroupApi.deleteGroup(id) + groups.value = groups.value.filter(g => g.id !== id) + // 清空关联习惯的 group_id + for (const habit of habits.value) { + if (habit.group_id === id) { + habit.group_id = undefined + habit.group = undefined + } + } + return true + } catch (error) { + console.error('删除分组失败:', error) + return false + } + } + + async function init() { + await Promise.all([fetchGroups(), fetchHabits()]) + await fetchAllStats() + } + + return { + habits, + groups, + statsMap, + loading, + groupedHabits, + todaySummary, + fetchHabits, + fetchGroups, + fetchStats, + fetchAllStats, + createHabit, + updateHabit, + deleteHabit, + toggleArchive, + checkin, + cancelCheckin, + createGroup, + updateGroup, + deleteGroup, + init + } +}) diff --git a/WebUI/src/stores/useTagStore.ts b/WebUI/src/stores/useTagStore.ts new file mode 100644 index 0000000..6fed191 --- /dev/null +++ b/WebUI/src/stores/useTagStore.ts @@ -0,0 +1,50 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { tagApi, type TagResponse } from '@/api/tags' +import type { TagFormData } from '@/api/types' + +export const useTagStore = defineStore('tag', () => { + const tags = ref([]) + const loading = ref(false) + + async function fetchTags() { + loading.value = true + try { + tags.value = await tagApi.getTags() + } catch (error) { + console.error('获取标签列表失败:', error) + } finally { + loading.value = false + } + } + + async function createTag(data: TagFormData) { + try { + const newTag = await tagApi.createTag(data) + tags.value.push(newTag) + return newTag + } catch (error) { + console.error('创建标签失败:', error) + return null + } + } + + async function deleteTag(id: number) { + try { + await tagApi.deleteTag(id) + tags.value = tags.value.filter((t: TagResponse) => t.id !== id) + return true + } catch (error) { + console.error('删除标签失败:', error) + return false + } + } + + return { + tags, + loading, + fetchTags, + createTag, + deleteTag + } +}) diff --git a/WebUI/src/stores/useTaskStore.ts b/WebUI/src/stores/useTaskStore.ts new file mode 100644 index 0000000..791028a --- /dev/null +++ b/WebUI/src/stores/useTaskStore.ts @@ -0,0 +1,180 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { taskApi, type TaskResponse } from '@/api/tasks' +import type { TaskFormData, TaskFilters } from '@/api/types' +import { matchWithPinyin } from '@/utils/pinyin' +import { formatDate } from '@/utils/date' + +export const useTaskStore = defineStore('task', () => { + const allTasks = ref([]) + const loading = ref(false) + const filters = ref({ + status: 'all', + sort_by: 'priority', + sort_order: 'desc' + }) + + // 所有任务 + const tasks = computed(() => { + let result = [...allTasks.value] + + // 搜索过滤(支持拼音) + if (filters.value.search?.trim()) { + const keyword = filters.value.search.trim() + result = result.filter(t => + matchWithPinyin(t.title, keyword) || + (t.description && matchWithPinyin(t.description, keyword)) || + t.tags?.some(tag => matchWithPinyin(tag.name, keyword)) + ) + } + + // 状态筛选 + if (filters.value.status === 'active') { + result = result.filter(t => !t.is_completed) + } else if (filters.value.status === 'completed') { + result = result.filter(t => t.is_completed) + } + + // 分类筛选 + if (filters.value.category_id) { + result = result.filter(t => t.category_id === filters.value.category_id) + } + + // 排序 + if (filters.value.sort_by) { + result.sort((a, b) => { + let comparison = 0 + + if (filters.value.sort_by === 'priority') { + const priorityOrder: Record = { q1: 4, q2: 3, q3: 2, q4: 1 } + const aOrder = priorityOrder[a.priority] || 0 + const bOrder = priorityOrder[b.priority] || 0 + comparison = aOrder - bOrder + } else if (filters.value.sort_by === 'due_date') { + if (!a.due_date && !b.due_date) comparison = 0 + else if (!a.due_date) comparison = 1 + else if (!b.due_date) comparison = -1 + else comparison = new Date(a.due_date).getTime() - new Date(b.due_date).getTime() + } else { + comparison = new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + } + + return filters.value.sort_order === 'asc' ? -comparison : comparison + }) + } + + return result + }) + + // 计算进行中和已完成的任务数量(基于所有任务) + const activeTasks = computed(() => allTasks.value.filter(t => !t.is_completed)) + const completedTasks = computed(() => allTasks.value.filter(t => t.is_completed)) + const totalTasks = computed(() => allTasks.value) + + // 按日期分组的任务(用于月视图) + const tasksByDate = computed(() => { + const grouped = new Map() + const today = new Date() + const todayStr = formatDate(today) + + allTasks.value.forEach(task => { + let dateKey: string + if (task.due_date) { + dateKey = formatDate(new Date(task.due_date)) + } else { + // 无截止日期的任务显示在当天 + dateKey = todayStr + } + + if (!grouped.has(dateKey)) { + grouped.set(dateKey, []) + } + grouped.get(dateKey)!.push(task) + }) + + return grouped + }) + + async function fetchTasks() { + loading.value = true + try { + // 一次获取所有任务,在前端进行筛选和排序 + allTasks.value = await taskApi.getTasks({ status: 'all' }) + } catch (error) { + console.error('获取任务列表失败:', error) + } finally { + loading.value = false + } + } + + async function createTask(data: TaskFormData) { + try { + const newTask = await taskApi.createTask(data) + allTasks.value.unshift(newTask) + return newTask + } catch (error) { + console.error('创建任务失败:', error) + return null + } + } + + async function updateTask(id: number, data: TaskFormData) { + try { + const updatedTask = await taskApi.updateTask(id, data) + const index = allTasks.value.findIndex((t: TaskResponse) => t.id === id) + if (index !== -1) { + allTasks.value[index] = updatedTask + } + return updatedTask + } catch (error) { + console.error('更新任务失败:', error) + return null + } + } + + async function deleteTask(id: number) { + try { + await taskApi.deleteTask(id) + allTasks.value = allTasks.value.filter((t: TaskResponse) => t.id !== id) + return true + } catch (error) { + console.error('删除任务失败:', error) + return false + } + } + + async function toggleTask(id: number) { + try { + const updatedTask = await taskApi.toggleTask(id) + const index = allTasks.value.findIndex((t: TaskResponse) => t.id === id) + if (index !== -1) { + allTasks.value[index] = updatedTask + } + return updatedTask + } catch (error) { + console.error('切换任务状态失败:', error) + return null + } + } + + function setFilters(newFilters: TaskFilters) { + filters.value = { ...filters.value, ...newFilters } + // 筛选现在在前端完成,不需要重新请求 + } + + return { + tasks, + loading, + filters, + activeTasks, + completedTasks, + totalTasks: allTasks, + tasksByDate, + fetchTasks, + createTask, + updateTask, + deleteTask, + toggleTask, + setFilters + } +}) diff --git a/WebUI/src/stores/useUIStore.ts b/WebUI/src/stores/useUIStore.ts new file mode 100644 index 0000000..b6f9f66 --- /dev/null +++ b/WebUI/src/stores/useUIStore.ts @@ -0,0 +1,72 @@ +import { defineStore } from 'pinia' +import { ref, watch } from 'vue' +import { useRouter } from 'vue-router' +import type { Task, Category } from '@/api/types' + +export const useUIStore = defineStore('ui', () => { + const router = useRouter() + + const taskDialogVisible = ref(false) + const editingTask = ref(null) + const categoryDialogVisible = ref(false) + const editingCategory = ref(null) + const sidebarCollapsed = ref(false) + const globalLoading = ref(false) + const currentView = ref<'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries' | 'assets'>('list') + const calendarMode = ref<'week' | 'monthly'>('monthly') + + function openTaskDialog(task?: Task) { + editingTask.value = task || null + taskDialogVisible.value = true + } + + function closeTaskDialog() { + taskDialogVisible.value = false + editingTask.value = null + } + + function openCategoryDialog(category?: Category) { + editingCategory.value = category || null + categoryDialogVisible.value = true + } + + function closeCategoryDialog() { + categoryDialogVisible.value = false + editingCategory.value = null + } + + function toggleSidebar() { + sidebarCollapsed.value = !sidebarCollapsed.value + } + + function setLoading(loading: boolean) { + globalLoading.value = loading + } + + function setCurrentView(view: 'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries' | 'assets') { + currentView.value = view + } + + function setCalendarMode(mode: 'week' | 'monthly') { + calendarMode.value = mode + } + + return { + taskDialogVisible, + editingTask, + categoryDialogVisible, + editingCategory, + sidebarCollapsed, + globalLoading, + currentView, + calendarMode, + openTaskDialog, + closeTaskDialog, + openCategoryDialog, + closeCategoryDialog, + toggleSidebar, + setLoading, + setCurrentView, + setCalendarMode + } +}) diff --git a/WebUI/src/stores/useUserSettingsStore.ts b/WebUI/src/stores/useUserSettingsStore.ts new file mode 100644 index 0000000..9faa2c1 --- /dev/null +++ b/WebUI/src/stores/useUserSettingsStore.ts @@ -0,0 +1,74 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { UserSettings, UserSettingsUpdate } from '@/api/types' +import { getUserSettings, updateUserSettings as apiUpdateSettings } from '@/api/userSettings' + +export const useUserSettingsStore = defineStore('userSettings', () => { + const settings = ref(null) + const loading = ref(false) + + async function fetchSettings() { + loading.value = true + try { + settings.value = await getUserSettings() + } finally { + loading.value = false + } + } + + async function updateSettings(data: UserSettingsUpdate) { + loading.value = true + try { + settings.value = await apiUpdateSettings(data) + } finally { + loading.value = false + } + } + + const nickname = ref('爱莉希雅') + const avatar = ref('') + const signature = ref('') + const birthday = ref('') + const email = ref('') + const siteName = ref('爱莉希雅待办') + const defaultView = ref('list') + const defaultSortBy = ref('priority') + const defaultSortOrder = ref('desc') + + function syncFromSettings(s: UserSettings) { + nickname.value = s.nickname || '' + avatar.value = s.avatar || '' + signature.value = s.signature || '' + birthday.value = s.birthday || '' + email.value = s.email || '' + siteName.value = s.site_name || '爱莉希雅待办' + defaultView.value = s.default_view || 'list' + defaultSortBy.value = s.default_sort_by || 'priority' + defaultSortOrder.value = s.default_sort_order || 'desc' + } + + async function fetchAndSync() { + await fetchSettings() + if (settings.value) { + syncFromSettings(settings.value) + } + } + + return { + settings, + loading, + fetchSettings, + updateSettings, + fetchAndSync, + nickname, + avatar, + signature, + birthday, + email, + siteName, + defaultView, + defaultSortBy, + defaultSortOrder, + syncFromSettings + } +}) diff --git a/WebUI/src/styles/_variables.scss b/WebUI/src/styles/_variables.scss new file mode 100644 index 0000000..1acf077 --- /dev/null +++ b/WebUI/src/styles/_variables.scss @@ -0,0 +1,33 @@ +// 爱莉希雅主题配色方案 +$primary: #FFB7C5; +$secondary: #FFC0CB; +$accent: #C8A2C8; +$background: #FFF5F7; +$background-dark: #F8F0F5; +$text-primary: #8B4557; +$text-secondary: #A86A7A; +$success: #98D8C8; +$warning: #FFB347; +$danger: #FF6B6B; + +// 四象限优先级颜色 +$priority-q1: #FF6B6B; // 重要紧急 - 紧急红 +$priority-q2: #FFB347; // 重要不紧急 - 警示橙 +$priority-q3: #98D8C8; // 不重要紧急 - 平和绿 +$priority-q4: #C8A2C8; // 不重要不紧急 - 淡雅紫 + +// 圆角 +$radius-sm: 8px; +$radius-md: 12px; +$radius-lg: 16px; +$radius-xl: 24px; + +// 阴影 +$shadow-sm: 0 2px 8px rgba(255, 183, 197, 0.15); +$shadow-md: 0 4px 16px rgba(255, 183, 197, 0.2); +$shadow-lg: 0 8px 32px rgba(255, 183, 197, 0.25); + +// 过渡 +$transition-fast: 150ms ease; +$transition-normal: 300ms ease; +$transition-slow: 500ms ease; diff --git a/WebUI/src/styles/main.scss b/WebUI/src/styles/main.scss new file mode 100644 index 0000000..3c41547 --- /dev/null +++ b/WebUI/src/styles/main.scss @@ -0,0 +1,340 @@ +// 爱莉希雅主题全局样式 +$primary: #FFB7C5; +$secondary: #FFC0CB; +$accent: #C8A2C8; +$background: #FFF5F7; +$background-dark: #F8F0F5; +$text-primary: #8B4557; +$text-secondary: #A86A7A; +$success: #98D8C8; +$warning: #FFB347; +$danger: #FF6B6B; +$priority-q1: #FF6B6B; +$priority-q2: #FFB347; +$priority-q3: #98D8C8; +$priority-q4: #C8A2C8; +$radius-sm: 8px; +$radius-md: 12px; +$radius-lg: 16px; +$radius-xl: 24px; +$shadow-sm: 0 2px 8px rgba(255, 183, 197, 0.15); +$shadow-md: 0 4px 16px rgba(255, 183, 197, 0.2); +$shadow-lg: 0 8px 32px rgba(255, 183, 197, 0.25); +$transition-fast: 150ms ease; +$transition-normal: 300ms ease; +$transition-slow: 500ms ease; + +:root { + --primary: #{$primary}; + --secondary: #{$secondary}; + --accent: #{$accent}; + --background: #{$background}; + --background-dark: #{$background-dark}; + --text-primary: #{$text-primary}; + --text-secondary: #{$text-secondary}; + --success: #{$success}; + --warning: #{$warning}; + --danger: #{$danger}; + --priority-q1: #{$priority-q1}; + --priority-q2: #{$priority-q2}; + --priority-q3: #{$priority-q3}; + --priority-q4: #{$priority-q4}; + --radius-sm: #{$radius-sm}; + --radius-md: #{$radius-md}; + --radius-lg: #{$radius-lg}; + --radius-xl: #{$radius-xl}; + --shadow-sm: #{$shadow-sm}; + --shadow-md: #{$shadow-md}; + --shadow-lg: #{$shadow-lg}; + --transition-fast: #{$transition-fast}; + --transition-normal: #{$transition-normal}; + --transition-slow: #{$transition-slow}; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', sans-serif; + font-size: 14px; + color: var(--text-primary); + background: var(--background); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#app { + min-height: 100vh; +} + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--primary); + border-radius: 3px; + + &:hover { + background: var(--accent); + } +} + +.page-enter-active, +.page-leave-active { + transition: opacity $transition-normal, transform $transition-normal; +} + +.page-enter-from { + opacity: 0; + transform: translateY(10px); +} + +.page-leave-to { + opacity: 0; + transform: translateY(-10px); +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.fade-in-up { + animation: fadeInUp $transition-normal ease forwards; +} + +@keyframes completeTask { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } + 100% { + transform: scale(1); + } +} + +.task-complete { + animation: completeTask 0.3s ease; +} + +@keyframes starBurst { + 0% { + opacity: 1; + transform: scale(0) rotate(0deg); + } + 50% { + opacity: 1; + transform: scale(1) rotate(180deg); + } + 100% { + opacity: 0; + transform: scale(0) rotate(360deg); + } +} + +.star-burst { + position: absolute; + width: 20px; + height: 20px; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23FFB7C5'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E") no-repeat center; + animation: starBurst 0.6s ease forwards; +} + +.btn-glow { + transition: box-shadow $transition-fast, transform $transition-fast; + + &:hover { + box-shadow: 0 0 20px rgba(255, 183, 197, 0.5); + } + + &:active { + transform: scale(0.95); + } +} + +// Element Plus 按钮主题覆盖(粉色主题) +.el-button--primary { + --el-button-bg-color: var(--primary); + --el-button-border-color: var(--primary); + --el-button-hover-bg-color: #ffaabb; + --el-button-hover-border-color: #ffaabb; + --el-button-hover-text-color: white; + --el-button-active-bg-color: var(--accent); + --el-button-active-border-color: var(--accent); + --el-button-active-text-color: white; + --el-button-text-color: white; + --el-button-disabled-bg-color: #ffd6e0; + --el-button-disabled-border-color: #ffd6e0; + --el-button-disabled-text-color: white; +} + +// 默认按钮 hover 也不变蓝 +.el-button--default { + --el-button-hover-bg-color: rgba(255, 183, 197, 0.1); + --el-button-hover-border-color: var(--primary); + --el-button-hover-text-color: var(--text-primary); +} + +// text 类型按钮 hover 变粉色 +.el-button--default:not(.el-button--primary):not(.is-disabled):hover { + color: var(--text-primary) !important; + border-color: var(--primary) !important; + background-color: rgba(255, 183, 197, 0.08) !important; +} + +// link 类型按钮 +.el-button.is-link:not(.is-disabled):hover { + color: var(--text-primary) !important; +} + +.el-button--primary.is-link:not(.is-disabled):hover { + color: var(--text-primary) !important; +} + +// Switch 开关颜色 +.el-switch { + --el-switch-on-color: #{$primary}; +} + +.el-switch.is-checked .el-switch__core { + background-color: #{$primary} !important; + border-color: #{$primary} !important; +} + +.el-switch.is-checked .el-switch__action { + left: calc(100% - 20px); + color: white; +} + +// 单选框颜色 +.el-radio-button__original-radio:checked + .el-radio-button__inner { + background-color: var(--primary) !important; + border-color: var(--primary) !important; + box-shadow: -1px 0 0 0 var(--primary) !important; +} + +.el-radio.is-checked .el-radio__inner { + background-color: var(--primary) !important; + border-color: var(--primary) !important; +} + +.el-radio.is-checked .el-radio__label { + color: var(--text-primary) !important; +} + +.el-radio__inner:hover { + border-color: var(--primary) !important; +} + +// Message box 确认按钮 +.el-message-box__btns .el-button--primary { + background-color: var(--primary); + border-color: var(--primary); +} + +.el-message-box__btns .el-button--primary:hover { + background-color: #ffaabb; + border-color: #ffaabb; +} + +.el-input__wrapper { + border-radius: var(--radius-md) !important; + box-shadow: none !important; + border: 1px solid var(--secondary) !important; + + &:hover { + border-color: var(--primary) !important; + } + + &.is-focus { + border-color: var(--primary) !important; + box-shadow: 0 0 0 2px rgba(255, 183, 197, 0.2) !important; + } +} + +.el-select .el-input__wrapper { + border-radius: var(--radius-md) !important; +} + +// Element Plus 下拉菜单选中项样式 +.el-select-dropdown__item.is-selected { + color: var(--primary) !important; +} + +.el-select-dropdown__item.hover, +.el-select-dropdown__item:hover { + background-color: var(--background) !important; +} + +.el-dialog { + border-radius: var(--radius-lg) !important; + + .el-dialog__header { + border-bottom: 1px solid var(--background-dark); + padding-bottom: 16px; + } + + .el-dialog__title { + color: var(--text-primary); + font-weight: 600; + } +} + +.el-message { + border-radius: var(--radius-md) !important; +} + +.card { + background: white; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + transition: box-shadow $transition-normal, transform $transition-normal; + + &:hover { + box-shadow: var(--shadow-md); + } +} + +.decoration-star { + position: absolute; + width: 16px; + height: 16px; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23FFB7C5'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E") no-repeat center; + opacity: 0.6; + animation: twinkle 2s ease-in-out infinite; +} + +@keyframes twinkle { + 0%, 100% { + opacity: 0.6; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.2); + } +} + +@media (max-width: 768px) { + html, body { + font-size: 13px; + } +} diff --git a/WebUI/src/utils/date.ts b/WebUI/src/utils/date.ts new file mode 100644 index 0000000..c80af83 --- /dev/null +++ b/WebUI/src/utils/date.ts @@ -0,0 +1,113 @@ +/** + * 日期工具函数 + */ + +/** + * 格式化日期为 YYYY-MM-DD 格式 + * @param date 日期对象 + * @returns 格式化后的日期字符串 + */ +export function formatDate(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +/** + * 格式化时间为 HH:mm 格式 + * @param date 日期对象 + * @returns 格式化后的时间字符串 + */ +export function formatTime(date: Date): string { + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${hours}:${minutes}` +} + +/** + * 获取某一周的第一天(周日) + * @param date 日期对象 + * @returns 该周第一天的日期对象 + */ +export function getWeekStart(date: Date): Date { + const d = new Date(date) + const day = d.getDay() + d.setDate(d.getDate() - day) + d.setHours(0, 0, 0, 0) + return d +} + +/** + * 获取某一周的最后一天(周六) + * @param date 日期对象 + * @returns 该周最后一天的日期对象 + */ +export function getWeekEnd(date: Date): Date { + const d = new Date(date) + const day = d.getDay() + d.setDate(d.getDate() + (6 - day)) + d.setHours(23, 59, 59, 999) + return d +} + +/** + * 获取一周的所有日期 + * @param date 参考日期 + * @returns 7天的日期数组(周日到周六) + */ +export function getWeekDays(date: Date): Date[] { + const weekStart = getWeekStart(date) + const days: Date[] = [] + for (let i = 0; i < 7; i++) { + const d = new Date(weekStart) + d.setDate(weekStart.getDate() + i) + days.push(d) + } + return days +} + +/** + * 获取一年中的第几周 + * @param date 日期对象 + * @returns 周数(1-53) + */ +export function getWeekNumber(date: Date): number { + const d = new Date(date) + d.setHours(0, 0, 0, 0) + // 设置为周四(ISO周定义) + d.setDate(d.getDate() + 4 - (d.getDay() || 7)) + // 获取年初 + const yearStart = new Date(d.getFullYear(), 0, 1) + // 计算周数 + return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7) +} + +/** + * 判断两个日期是否是同一天 + * @param date1 日期1 + * @param date2 日期2 + * @returns 是否是同一天 + */ +export function isSameDay(date1: Date, date2: Date): boolean { + return formatDate(date1) === formatDate(date2) +} + +/** + * 判断日期是否是今天 + * @param date 日期对象 + * @returns 是否是今天 + */ +export function isToday(date: Date): boolean { + return isSameDay(date, new Date()) +} + +/** + * 获取星期几的中文名称 + * @param dayOfWeek 星期几(0-6,0为周日) + * @returns 中文名称 + */ +export function getWeekDayName(dayOfWeek: number): string { + const names = ['日', '一', '二', '三', '四', '五', '六'] + return names[dayOfWeek] ?? '' +} diff --git a/WebUI/src/utils/pinyin.ts b/WebUI/src/utils/pinyin.ts new file mode 100644 index 0000000..9921402 --- /dev/null +++ b/WebUI/src/utils/pinyin.ts @@ -0,0 +1,177 @@ +import { pinyin } from 'pinyin-pro' + +/** + * 将中文文本转换为拼音首字母(小写) + * @param text 中文文本 + * @returns 拼音首字母字符串 + */ +export function toPinyinInitials(text: string): string { + return pinyin(text, { pattern: 'first', toneType: 'none' }) + .replace(/\s/g, '') + .toLowerCase() +} + +/** + * 将中文文本转换为完整拼音(小写) + * @param text 中文文本 + * @returns 完整拼音字符串 + */ +export function toPinyinFull(text: string): string { + return pinyin(text, { pattern: 'pinyin', toneType: 'none' }) + .replace(/\s/g, '') + .toLowerCase() +} + +/** + * 检查搜索词是否匹配目标文本(支持拼音匹配) + * 匹配规则: + * 1. 直接包含搜索词 + * 2. 拼音首字母匹配 + * 3. 完整拼音匹配 + * @param target 目标文本 + * @param keyword 搜索词 + * @returns 是否匹配 + */ +export function matchWithPinyin(target: string, keyword: string): boolean { + const targetLower = target.toLowerCase() + const keywordLower = keyword.toLowerCase() + + // 直接匹配 + if (targetLower.includes(keywordLower)) { + return true + } + + // 拼音首字母匹配 + const initials = toPinyinInitials(target) + if (initials.includes(keywordLower)) { + return true + } + + // 完整拼音匹配 + const fullPinyin = toPinyinFull(target) + if (fullPinyin.includes(keywordLower)) { + return true + } + + return false +} + +/** + * 获取匹配的位置信息 + * @param target 目标文本 + * @param keyword 搜索词 + * @returns 匹配的字符索引数组,如果没有匹配则返回空数组 + */ +export function getMatchIndices(target: string, keyword: string): number[] { + const targetLower = target.toLowerCase() + const keywordLower = keyword.toLowerCase() + const indices: number[] = [] + + // 如果是直接文本匹配 + let startPos = targetLower.indexOf(keywordLower) + if (startPos !== -1) { + for (let i = startPos; i < startPos + keyword.length; i++) { + indices.push(i) + } + return indices + } + + // 拼音匹配 - 需要找出哪些字符被匹配 + const keywordChars = keywordLower.split('') + + for (let i = 0; i < target.length; i++) { + const char = target[i] + if (!char) continue + const charInitial = toPinyinInitials(char) + const charFullPinyin = toPinyinFull(char) + + // 检查是否有任何剩余的搜索关键词可以匹配这个字符 + for (let j = 0; j < keywordChars.length; j++) { + const remaining = keywordChars.slice(j).join('') + + // 首字母匹配 + if (charInitial.length > 0 && remaining.startsWith(charInitial)) { + indices.push(i) + keywordChars.splice(j, charInitial.length) + break + } + + // 完整拼音匹配 + if (charFullPinyin.length > 0 && remaining.startsWith(charFullPinyin)) { + indices.push(i) + keywordChars.splice(j, charFullPinyin.length) + break + } + } + } + + return indices +} + +/** + * 将文本中的特殊字符转义为 HTML 实体,防止 XSS + */ +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** + * 高亮显示匹配的文本 + * @param text 原始文本 + * @param keyword 搜索词 + * @returns 包含高亮标记的 HTML 字符串 + */ +export function highlightMatch(text: string, keyword: string): string { + if (!keyword.trim()) { + return escapeHtml(text) + } + + const indices = getMatchIndices(text, keyword) + if (indices.length === 0) { + return escapeHtml(text) + } + + let result = '' + let inHighlight = false + let highlightStart = -1 + + const indicesSet = new Set(indices) + + // 找出连续的高亮区间 + const ranges: Array<{ start: number; end: number }> = [] + let currentRange: { start: number; end: number } | null = null + + for (let i = 0; i < text.length; i++) { + if (indicesSet.has(i)) { + if (currentRange === null) { + currentRange = { start: i, end: i } + } else { + currentRange.end = i + } + } else { + if (currentRange !== null) { + ranges.push(currentRange) + currentRange = null + } + } + } + if (currentRange !== null) { + ranges.push(currentRange) + } + + // 构建结果字符串(先转义再包裹高亮标签) + let lastIndex = 0 + for (const range of ranges) { + result += escapeHtml(text.slice(lastIndex, range.start)) + result += `${escapeHtml(text.slice(range.start, range.end + 1))}` + lastIndex = range.end + 1 + } + result += escapeHtml(text.slice(lastIndex)) + + return result +} diff --git a/WebUI/src/utils/priority.ts b/WebUI/src/utils/priority.ts new file mode 100644 index 0000000..adbf639 --- /dev/null +++ b/WebUI/src/utils/priority.ts @@ -0,0 +1,27 @@ +import type { QuadrantPriority } from '@/api/types' + +const priorityColors: Record = { + q1: 'var(--priority-q1)', + q2: 'var(--priority-q2)', + q3: 'var(--priority-q3)', + q4: 'var(--priority-q4)' +} + +const defaultPriorityColor = 'var(--priority-q4)' + +export function getPriorityColor(priority: string): string { + return priorityColors[priority] ?? defaultPriorityColor +} + +const priorityConfig: Record = { + q1: { label: 'Q1 重要紧急', color: 'var(--priority-q1)' }, + q2: { label: 'Q2 重要不紧急', color: 'var(--priority-q2)' }, + q3: { label: 'Q3 不重要紧急', color: 'var(--priority-q3)' }, + q4: { label: 'Q4 不重要不紧急', color: 'var(--priority-q4)' } +} + +const defaultPriorityConfig = { label: 'Q4 不重要不紧急', color: 'var(--priority-q4)' } + +export function getPriorityConfig(priority: string) { + return priorityConfig[priority] || defaultPriorityConfig +} diff --git a/WebUI/src/views/AnniversaryPage.vue b/WebUI/src/views/AnniversaryPage.vue new file mode 100644 index 0000000..f482b51 --- /dev/null +++ b/WebUI/src/views/AnniversaryPage.vue @@ -0,0 +1,905 @@ + + + + + diff --git a/WebUI/src/views/AssetPage.vue b/WebUI/src/views/AssetPage.vue new file mode 100644 index 0000000..dfd394c --- /dev/null +++ b/WebUI/src/views/AssetPage.vue @@ -0,0 +1,1134 @@ + + + + + diff --git a/WebUI/src/views/CalendarPage.vue b/WebUI/src/views/CalendarPage.vue new file mode 100644 index 0000000..eabf6fa --- /dev/null +++ b/WebUI/src/views/CalendarPage.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/WebUI/src/views/CalendarView.vue b/WebUI/src/views/CalendarView.vue new file mode 100644 index 0000000..baf2483 --- /dev/null +++ b/WebUI/src/views/CalendarView.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/WebUI/src/views/HabitPage.vue b/WebUI/src/views/HabitPage.vue new file mode 100644 index 0000000..6657829 --- /dev/null +++ b/WebUI/src/views/HabitPage.vue @@ -0,0 +1,1251 @@ + + + + + diff --git a/WebUI/src/views/MonthlyView.vue b/WebUI/src/views/MonthlyView.vue new file mode 100644 index 0000000..3896037 --- /dev/null +++ b/WebUI/src/views/MonthlyView.vue @@ -0,0 +1,708 @@ + + + + + diff --git a/WebUI/src/views/ProfileView.vue b/WebUI/src/views/ProfileView.vue new file mode 100644 index 0000000..e9284c4 --- /dev/null +++ b/WebUI/src/views/ProfileView.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/WebUI/src/views/QuadrantPage.vue b/WebUI/src/views/QuadrantPage.vue new file mode 100644 index 0000000..d5f94e7 --- /dev/null +++ b/WebUI/src/views/QuadrantPage.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/WebUI/src/views/QuadrantView.vue b/WebUI/src/views/QuadrantView.vue new file mode 100644 index 0000000..7dd0e8a --- /dev/null +++ b/WebUI/src/views/QuadrantView.vue @@ -0,0 +1,362 @@ + + + + + diff --git a/WebUI/src/views/SettingsView.vue b/WebUI/src/views/SettingsView.vue new file mode 100644 index 0000000..1a75934 --- /dev/null +++ b/WebUI/src/views/SettingsView.vue @@ -0,0 +1,669 @@ + + + + + diff --git a/WebUI/src/views/TaskList.vue b/WebUI/src/views/TaskList.vue new file mode 100644 index 0000000..9c2ef5b --- /dev/null +++ b/WebUI/src/views/TaskList.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/WebUI/src/views/TaskListView.vue b/WebUI/src/views/TaskListView.vue new file mode 100644 index 0000000..340596e --- /dev/null +++ b/WebUI/src/views/TaskListView.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/WebUI/src/views/WeeklyView.vue b/WebUI/src/views/WeeklyView.vue new file mode 100644 index 0000000..8beebcc --- /dev/null +++ b/WebUI/src/views/WeeklyView.vue @@ -0,0 +1,447 @@ + + + + + diff --git a/WebUI/tsconfig.app.json b/WebUI/tsconfig.app.json new file mode 100644 index 0000000..a3f3289 --- /dev/null +++ b/WebUI/tsconfig.app.json @@ -0,0 +1,19 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/WebUI/tsconfig.json b/WebUI/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/WebUI/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/WebUI/tsconfig.node.json b/WebUI/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/WebUI/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/WebUI/vite.config.ts b/WebUI/vite.config.ts new file mode 100644 index 0000000..8b771f8 --- /dev/null +++ b/WebUI/vite.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:23994', + changeOrigin: true + } + } + }, + build: { + rollupOptions: { + output: { + manualChunks: { + 'element-plus': ['element-plus', '@element-plus/icons-vue'], + 'vendor': ['vue', 'vue-router', 'pinia', 'axios'] + } + } + } + }, + cacheDir: 'node_modules/.vite' +}) diff --git a/api/app/__init__.py b/api/app/__init__.py new file mode 100644 index 0000000..1869bc8 --- /dev/null +++ b/api/app/__init__.py @@ -0,0 +1 @@ +# 爱莉希雅待办事项后端 diff --git a/api/app/config.py b/api/app/config.py new file mode 100644 index 0000000..f4e8f69 --- /dev/null +++ b/api/app/config.py @@ -0,0 +1,29 @@ +# 硬编码配置 +import os + +# api 目录的绝对路径(基于本文件位置计算,不依赖工作目录) +_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# 数据库配置 +DATABASE_PATH = os.path.join(_BASE_DIR, "data", "todo.db") +DATABASE_URL = f"sqlite:///{DATABASE_PATH}" + +# WebUI 配置 +WEBUI_PATH = os.path.join(_BASE_DIR, "webui") + +# CORS 配置 +CORS_ORIGINS = [ + "http://localhost:5173", + "http://localhost:23994", +] + +# 日志配置 +LOG_LEVEL = "INFO" +LOG_DIR = os.path.join(_BASE_DIR, "logs") + +# 分页配置 +DEFAULT_PAGE_SIZE = 20 + +# 服务配置 +HOST = "0.0.0.0" +PORT = 23994 diff --git a/api/app/database.py b/api/app/database.py new file mode 100644 index 0000000..1a3ecd5 --- /dev/null +++ b/api/app/database.py @@ -0,0 +1,101 @@ +from sqlalchemy import create_engine, inspect, text, String, Integer, Text, Boolean, Float, DateTime, Date +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +import os + +from app.config import DATABASE_PATH, DATABASE_URL + +# 确保 data 目录存在 +os.makedirs(os.path.dirname(DATABASE_PATH) if os.path.dirname(DATABASE_PATH) else ".", exist_ok=True) + +# 创建引擎 +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False} +) + +# 创建会话工厂 +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# 创建基类 +Base = declarative_base() + + +def get_db(): + """获取数据库会话""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +# SQLAlchemy 类型到 SQLite 类型名的映射 +_TYPE_MAP = { + String: "VARCHAR", + Integer: "INTEGER", + Text: "TEXT", + Boolean: "BOOLEAN", + Float: "REAL", + DateTime: "DATETIME", + Date: "DATE", +} + + +def _col_type_str(col_type) -> str: + """将 SQLAlchemy 列类型转为 SQLite 类型字符串""" + if col_type.__class__ in _TYPE_MAP: + base = _TYPE_MAP[col_type.__class__] + else: + base = str(col_type).split("(")[0].strip() + + length = getattr(col_type, "length", None) + if length: + return f"{base}({length})" + return base + + +def init_db(): + """初始化数据库表,自动补充新增的列""" + # 导入所有模型,确保 Base.metadata 包含全部表定义 + from app.models import ( # noqa: F401 + task, category, tag, user_settings, habit, anniversary, account, + ) + Base.metadata.create_all(bind=engine) + + # 通用自动迁移:对比 ORM 模型与实际表结构,补充缺失的列(SQLite 兼容) + inspector = inspect(engine) + table_names = set(inspector.get_table_names()) + + with engine.begin() as conn: + for table_cls in Base.metadata.sorted_tables: + table_name = table_cls.name + if table_name not in table_names: + continue + + existing_cols = {c["name"] for c in inspector.get_columns(table_name)} + + for col in table_cls.columns: + if col.name in existing_cols: + continue + # 跳过无服务端默认值且不可为空的列(容易出错) + if col.nullable is False and col.server_default is None and col.default is None: + continue + + sqlite_type = _col_type_str(col.type) + + ddl = f"ALTER TABLE {table_name} ADD COLUMN {col.name} {sqlite_type}" + + # 为可空列或已有默认值的列附加 DEFAULT + if col.server_default is not None: + ddl += f" DEFAULT {col.server_default.arg}" + elif col.default is not None and col.nullable: + default_val = col.default.arg + if isinstance(default_val, str): + ddl += f" DEFAULT '{default_val}'" + elif isinstance(default_val, bool): + ddl += f" DEFAULT {1 if default_val else 0}" + else: + ddl += f" DEFAULT {default_val}" + + conn.execute(text(ddl)) diff --git a/api/app/main.py b/api/app/main.py new file mode 100644 index 0000000..213782d --- /dev/null +++ b/api/app/main.py @@ -0,0 +1,144 @@ +from contextlib import asynccontextmanager +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, FileResponse +import os +import time +import json + +from app.config import CORS_ORIGINS, WEBUI_PATH, HOST, PORT +from app.database import init_db +from app.routers import api_router +from app.utils.logger import logger + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期管理""" + # 启动时 + logger.info("应用启动中...") + init_db() + logger.info("数据库初始化完成") + yield + # 关闭时 + logger.info("应用关闭中...") + + +# 创建 FastAPI 应用 +app = FastAPI( + title="爱莉希雅待办事项 API", + description="Elysia ToDo - 个人信息管理应用", + version="1.0.0", + lifespan=lifespan, +) + +# 配置 CORS +app.add_middleware( + CORSMiddleware, + allow_origins=CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# 请求日志中间件 +@app.middleware("http") +async def log_requests(request: Request, call_next): + start_time = time.time() + + # 记录请求信息 + request_method = request.method + request_path = request.url.path + query_params = dict(request.query_params) if request.query_params else None + + # 构建日志信息 + log_parts = [f"请求开始 -> {request_method} {request_path}"] + if query_params: + log_parts.append(f"Query参数: {json.dumps(query_params, ensure_ascii=False)}") + + # 尝试读取请求体(仅对有 body 的方法) + body_info = None + if request.method in ["POST", "PUT", "PATCH"]: + try: + body_bytes = await request.body() + if body_bytes: + try: + body_json = json.loads(body_bytes) + body_info = json.dumps(body_json, ensure_ascii=False) + except json.JSONDecodeError: + body_info = body_bytes.decode('utf-8', errors='replace')[:200] + log_parts.append(f"Body: {body_info}") + except Exception: + pass + + logger.info(" | ".join(log_parts)) + + # 执行请求 + response = await call_next(request) + + # 计算耗时 + process_time = (time.time() - start_time) * 1000 + + # 记录响应信息 + logger.info( + f"请求完成 <- {request_method} {request_path} | " + f"状态码: {response.status_code} | 耗时: {process_time:.2f}ms" + ) + + return response + + +# 全局异常处理器 +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + # 使用 exc_info=True 记录完整堆栈信息 + logger.error( + f"全局异常: {request.method} {request.url.path} | 错误: {str(exc)}", + exc_info=True + ) + return JSONResponse( + status_code=500, + content={ + "success": False, + "message": "服务器内部错误", + "error_code": "INTERNAL_ERROR" + } + ) + + +# 注册路由 +app.include_router(api_router) + + +# 健康检查(必须在 static mount 之前注册,否则会被静态文件拦截) +@app.get("/health") +async def health_check(): + """健康检查""" + return {"status": "ok", "message": "服务运行正常"} + + +# SPA 静态文件回退路由(支持前端 History 模式路由) +if os.path.exists(WEBUI_PATH): + + @app.get("/") + async def serve_index(): + return FileResponse(os.path.join(WEBUI_PATH, "index.html")) + + @app.get("/{full_path:path}") + async def spa_fallback(request: Request, full_path: str): + """SPA 回退:先尝试提供真实文件,找不到则返回 index.html""" + file_path = os.path.join(WEBUI_PATH, full_path) + if os.path.isfile(file_path): + return FileResponse(file_path) + return FileResponse(os.path.join(WEBUI_PATH, "index.html")) + + logger.info(f"SPA 静态文件服务已配置: {WEBUI_PATH}") +else: + logger.warning(f"WebUI 目录不存在: {WEBUI_PATH}") + + +if __name__ == "__main__": + import uvicorn + logger.info(f"启动服务: {HOST}:{PORT}") + uvicorn.run(app, host=HOST, port=PORT) diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py new file mode 100644 index 0000000..547c229 --- /dev/null +++ b/api/app/models/__init__.py @@ -0,0 +1,14 @@ +from app.models.task import Task +from app.models.category import Category +from app.models.tag import Tag, task_tags +from app.models.user_settings import UserSettings +from app.models.habit import HabitGroup, Habit, HabitCheckin +from app.models.anniversary import AnniversaryCategory, Anniversary +from app.models.account import FinancialAccount, AccountHistory, DebtInstallment + +__all__ = [ + "Task", "Category", "Tag", "task_tags", "UserSettings", + "HabitGroup", "Habit", "HabitCheckin", + "AnniversaryCategory", "Anniversary", + "FinancialAccount", "AccountHistory", "DebtInstallment", +] diff --git a/api/app/models/account.py b/api/app/models/account.py new file mode 100644 index 0000000..f91290e --- /dev/null +++ b/api/app/models/account.py @@ -0,0 +1,61 @@ +from sqlalchemy import Column, Integer, String, Text, Boolean, Float, DateTime, ForeignKey, Date +from sqlalchemy.orm import relationship +from app.database import Base +from app.utils.datetime import utcnow + + +class FinancialAccount(Base): + """财务账户模型""" + __tablename__ = "financial_accounts" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + account_type = Column(String(20), nullable=False, default="savings") # savings / debt + balance = Column(Float, default=0.0) + icon = Column(String(50), default="wallet") + color = Column(String(20), default="#FFB7C5") + sort_order = Column(Integer, default=0) + is_active = Column(Boolean, default=True) + description = Column(Text, nullable=True) + created_at = Column(DateTime, default=utcnow) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) + + # 关联关系 + history_records = relationship("AccountHistory", back_populates="account", cascade="all, delete-orphan") + debt_installments = relationship("DebtInstallment", back_populates="account", cascade="all, delete-orphan") + + +class AccountHistory(Base): + """余额变更历史""" + __tablename__ = "account_history" + + id = Column(Integer, primary_key=True, index=True) + account_id = Column(Integer, ForeignKey("financial_accounts.id"), nullable=False) + change_amount = Column(Float, nullable=False) + balance_before = Column(Float, nullable=False) + balance_after = Column(Float, nullable=False) + note = Column(String(200), nullable=True) + created_at = Column(DateTime, default=utcnow) + + # 关联关系 + account = relationship("FinancialAccount", back_populates="history_records") + + +class DebtInstallment(Base): + """分期还款计划""" + __tablename__ = "debt_installments" + + id = Column(Integer, primary_key=True, index=True) + account_id = Column(Integer, ForeignKey("financial_accounts.id"), nullable=False) + total_amount = Column(Float, nullable=False) + total_periods = Column(Integer, nullable=False) + current_period = Column(Integer, nullable=False, default=1) # 1-based, 指向下一期待还 + payment_day = Column(Integer, nullable=False) # 每月还款日 1-31 + payment_amount = Column(Float, nullable=False) + start_date = Column(Date, nullable=False) + is_completed = Column(Boolean, default=False) + created_at = Column(DateTime, default=utcnow) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) + + # 关联关系 + account = relationship("FinancialAccount", back_populates="debt_installments") diff --git a/api/app/models/anniversary.py b/api/app/models/anniversary.py new file mode 100644 index 0000000..78abff5 --- /dev/null +++ b/api/app/models/anniversary.py @@ -0,0 +1,37 @@ +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Date +from sqlalchemy.orm import relationship +from app.database import Base +from app.utils.datetime import utcnow + + +class AnniversaryCategory(Base): + """纪念日分类模型""" + __tablename__ = "anniversary_categories" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(50), nullable=False) + icon = Column(String(50), default="calendar") + color = Column(String(20), default="#FFB7C5") + sort_order = Column(Integer, default=0) + + # 关联关系 + anniversaries = relationship("Anniversary", back_populates="category") + + +class Anniversary(Base): + """纪念日模型""" + __tablename__ = "anniversaries" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(200), nullable=False) + date = Column(Date, nullable=False) # 月-日,年份部分可选 + year = Column(Integer, nullable=True) # 年份,用于计算第 N 个周年 + category_id = Column(Integer, ForeignKey("anniversary_categories.id"), nullable=True) + description = Column(Text, nullable=True) + is_recurring = Column(Boolean, default=True) + remind_days_before = Column(Integer, default=3) + created_at = Column(DateTime, default=utcnow) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) + + # 关联关系 + category = relationship("AnniversaryCategory", back_populates="anniversaries") diff --git a/api/app/models/category.py b/api/app/models/category.py new file mode 100644 index 0000000..83b3b1d --- /dev/null +++ b/api/app/models/category.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, Integer, String +from sqlalchemy.orm import relationship +from app.database import Base + + +class Category(Base): + """分类模型""" + __tablename__ = "categories" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + color = Column(String(20), default="#FFB7C5") # 默认樱花粉 + icon = Column(String(50), default="folder") # 默认图标 + + # 关联关系 + tasks = relationship("Task", back_populates="category") diff --git a/api/app/models/habit.py b/api/app/models/habit.py new file mode 100644 index 0000000..5917eb5 --- /dev/null +++ b/api/app/models/habit.py @@ -0,0 +1,55 @@ +from sqlalchemy import Column, Integer, String, Text, Boolean, Date, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.orm import relationship +from app.database import Base +from app.utils.datetime import utcnow + + +class HabitGroup(Base): + """习惯分组模型""" + __tablename__ = "habit_groups" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + color = Column(String(20), default="#FFB7C5") + icon = Column(String(50), default="flag") + sort_order = Column(Integer, default=0) + + # 关联关系 + habits = relationship("Habit", back_populates="group", order_by="Habit.created_at") + + +class Habit(Base): + """习惯模型""" + __tablename__ = "habits" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(200), nullable=False) + description = Column(Text, nullable=True) + group_id = Column(Integer, ForeignKey("habit_groups.id"), nullable=True) + target_count = Column(Integer, default=1) + frequency = Column(String(20), default="daily") # daily / weekly + active_days = Column(String(100), nullable=True) # JSON 数组, 如 [0,2,4] 表示周一三五 + is_archived = Column(Boolean, default=False) + created_at = Column(DateTime, default=utcnow) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) + + # 关联关系 + group = relationship("HabitGroup", back_populates="habits") + checkins = relationship("HabitCheckin", back_populates="habit", cascade="all, delete-orphan") + + +class HabitCheckin(Base): + """习惯打卡记录模型""" + __tablename__ = "habit_checkins" + __table_args__ = ( + UniqueConstraint("habit_id", "checkin_date", name="uq_habit_checkin_date"), + ) + + id = Column(Integer, primary_key=True, index=True) + habit_id = Column(Integer, ForeignKey("habits.id"), nullable=False) + checkin_date = Column(Date, nullable=False) + count = Column(Integer, default=0) + created_at = Column(DateTime, default=utcnow) + + # 关联关系 + habit = relationship("Habit", back_populates="checkins") diff --git a/api/app/models/tag.py b/api/app/models/tag.py new file mode 100644 index 0000000..1a18c26 --- /dev/null +++ b/api/app/models/tag.py @@ -0,0 +1,23 @@ +from sqlalchemy import Column, Integer, String, Table, ForeignKey +from sqlalchemy.orm import relationship +from app.database import Base + + +# 任务-标签关联表(多对多) +task_tags = Table( + "task_tags", + Base.metadata, + Column("task_id", Integer, ForeignKey("tasks.id"), primary_key=True), + Column("tag_id", Integer, ForeignKey("tags.id"), primary_key=True) +) + + +class Tag(Base): + """标签模型""" + __tablename__ = "tags" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(50), nullable=False, unique=True) + + # 关联关系 + tasks = relationship("Task", secondary=task_tags, back_populates="tags") diff --git a/api/app/models/task.py b/api/app/models/task.py new file mode 100644 index 0000000..cc82431 --- /dev/null +++ b/api/app/models/task.py @@ -0,0 +1,23 @@ +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from app.database import Base +from app.utils.datetime import utcnow + + +class Task(Base): + """任务模型""" + __tablename__ = "tasks" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(200), nullable=False) + description = Column(Text, nullable=True) + priority = Column(String(20), default="q4") # q1(重要紧急), q2(重要不紧急), q3(不重要紧急), q4(不重要不紧急) + due_date = Column(DateTime, nullable=True) + is_completed = Column(Boolean, default=False) + category_id = Column(Integer, ForeignKey("categories.id"), nullable=True) + created_at = Column(DateTime, default=utcnow) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) + + # 关联关系 + category = relationship("Category", back_populates="tasks") + tags = relationship("Tag", secondary="task_tags", back_populates="tasks") diff --git a/api/app/models/user_settings.py b/api/app/models/user_settings.py new file mode 100644 index 0000000..e947729 --- /dev/null +++ b/api/app/models/user_settings.py @@ -0,0 +1,36 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, Date +from datetime import datetime, timezone, date +from app.database import Base + + +def utcnow(): + """统一获取 UTC 时间的工厂函数""" + return datetime.now(timezone.utc) + + +class UserSettings(Base): + """用户设置模型(单例,始终只有一条记录 id=1)""" + __tablename__ = "user_settings" + + id = Column(Integer, primary_key=True, default=1) + + # 个人信息 + nickname = Column(String(50), default="爱莉希雅") + avatar = Column(Text, nullable=True) + signature = Column(String(200), nullable=True) + birthday = Column(Date, nullable=True) + email = Column(String(100), nullable=True) + + # 应用信息 + site_name = Column(String(50), default="爱莉希雅待办") + + # 应用偏好 + theme = Column(String(20), default="pink") + language = Column(String(10), default="zh-CN") + default_view = Column(String(20), default="list") + default_sort_by = Column(String(20), default="created_at") + default_sort_order = Column(String(10), default="desc") + + # 时间戳 + created_at = Column(DateTime, default=utcnow) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) diff --git a/api/app/routers/__init__.py b/api/app/routers/__init__.py new file mode 100644 index 0000000..6a3a713 --- /dev/null +++ b/api/app/routers/__init__.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter +from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, accounts + +# 创建主路由 +api_router = APIRouter() + +# 注册子路由 +api_router.include_router(tasks.router) +api_router.include_router(categories.router) +api_router.include_router(tags.router) +api_router.include_router(user_settings.router) +api_router.include_router(habits.router) +api_router.include_router(anniversaries.router) +api_router.include_router(accounts.router) diff --git a/api/app/routers/accounts.py b/api/app/routers/accounts.py new file mode 100644 index 0000000..d379231 --- /dev/null +++ b/api/app/routers/accounts.py @@ -0,0 +1,498 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from typing import Optional, List +from datetime import date +from calendar import monthrange + +from app.database import get_db +from app.models.account import FinancialAccount, AccountHistory, DebtInstallment +from app.schemas.account import ( + AccountCreate, AccountUpdate, AccountResponse, BalanceUpdateRequest, + AccountHistoryResponse, AccountListItemResponse, + DebtInstallmentCreate, DebtInstallmentUpdate, DebtInstallmentResponse, +) +from app.schemas.common import DeleteResponse +from app.utils.crud import get_or_404 +from app.utils.datetime import utcnow +from app.utils.logger import logger + +router = APIRouter(prefix="/api", tags=["资产"]) + + +def compute_installment_info(installment: DebtInstallment, today: date) -> dict: + """计算分期计划的下次还款日期、距今天数、剩余期数""" + if installment.is_completed: + return { + "next_payment_date": None, + "days_until_payment": None, + "remaining_periods": 0, + } + + remaining = installment.total_periods - installment.current_period + 1 + if remaining <= 0: + return { + "next_payment_date": None, + "days_until_payment": None, + "remaining_periods": 0, + } + + # 根据 start_date 和 payment_day 计算下一还款日期 + payment_day = installment.payment_day + start_year = installment.start_date.year + start_month = installment.start_date.month + + # 计算当前应还的期数对应的月份 + period_index = installment.current_period - 1 + next_month_year = start_year * 12 + (start_month - 1) + period_index + next_year = next_month_year // 12 + next_month = next_month_year % 12 + 1 + + # 处理 payment_day 超出当月天数的情况 + max_day = monthrange(next_year, next_month)[1] + actual_day = min(payment_day, max_day) + next_payment_date = date(next_year, next_month, actual_day) + + # 如果计算出的日期在 start_date 之前(边界情况),使用 start_date + if next_payment_date < installment.start_date: + next_payment_date = installment.start_date + + days_until = (next_payment_date - today).days + + return { + "next_payment_date": next_payment_date, + "days_until_payment": days_until, + "remaining_periods": remaining, + } + + +# ============ 财务账户 API ============ + +@router.get("/accounts", response_model=List[AccountListItemResponse]) +def get_accounts(db: Session = Depends(get_db)): + """获取所有账户列表""" + try: + accounts = db.query(FinancialAccount).order_by( + FinancialAccount.sort_order.asc(), + FinancialAccount.id.asc() + ).all() + + result = [] + for acc in accounts: + data = { + "id": acc.id, + "name": acc.name, + "account_type": acc.account_type, + "balance": acc.balance, + "icon": acc.icon, + "color": acc.color, + "sort_order": acc.sort_order, + "is_active": acc.is_active, + "description": acc.description, + "created_at": acc.created_at, + "updated_at": acc.updated_at, + } + + # 附加分期计划摘要(欠款账户) + if acc.account_type == "debt": + installments = db.query(DebtInstallment).filter( + DebtInstallment.account_id == acc.id, + DebtInstallment.is_completed == False + ).all() + today = date.today() + active_installments = [] + for inst in installments: + info = compute_installment_info(inst, today) + active_installments.append(info) + data["installments"] = active_installments + else: + data["installments"] = [] + + result.append(data) + + logger.info(f"获取账户列表成功,总数: {len(result)}") + return result + except Exception as e: + logger.error(f"获取账户列表失败: {str(e)}") + raise HTTPException(status_code=500, detail="获取账户列表失败") + + +@router.post("/accounts", response_model=AccountResponse, status_code=201) +def create_account(data: AccountCreate, db: Session = Depends(get_db)): + """创建账户""" + try: + account = FinancialAccount( + name=data.name, + account_type=data.account_type, + balance=data.balance, + icon=data.icon, + color=data.color, + sort_order=data.sort_order, + is_active=data.is_active, + description=data.description, + ) + db.add(account) + db.commit() + db.refresh(account) + logger.info(f"创建账户成功: id={account.id}, name={account.name}") + return account + except Exception as e: + db.rollback() + logger.error(f"创建账户失败: {str(e)}") + raise HTTPException(status_code=500, detail="创建账户失败") + + +@router.get("/accounts/{account_id}", response_model=AccountResponse) +def get_account(account_id: int, db: Session = Depends(get_db)): + """获取单个账户""" + try: + account = get_or_404(db, FinancialAccount, account_id, "账户") + return account + except HTTPException: + raise + except Exception as e: + logger.error(f"获取账户失败: {str(e)}") + raise HTTPException(status_code=500, detail="获取账户失败") + + +@router.put("/accounts/{account_id}", response_model=AccountResponse) +def update_account(account_id: int, data: AccountUpdate, db: Session = Depends(get_db)): + """更新账户基本信息""" + try: + account = get_or_404(db, FinancialAccount, account_id, "账户") + update_data = data.model_dump(exclude_unset=True) + + # 不允许通过此接口修改余额(使用专门的余额更新接口) + if "balance" in update_data: + del update_data["balance"] + + for field, value in update_data.items(): + setattr(account, field, value) + + account.updated_at = utcnow() + db.commit() + db.refresh(account) + logger.info(f"更新账户成功: id={account_id}") + return account + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"更新账户失败: {str(e)}") + raise HTTPException(status_code=500, detail="更新账户失败") + + +@router.delete("/accounts/{account_id}") +def delete_account(account_id: int, db: Session = Depends(get_db)): + """删除账户(级联删除历史记录和分期计划)""" + try: + account = get_or_404(db, FinancialAccount, account_id, "账户") + db.delete(account) + db.commit() + logger.info(f"删除账户成功: id={account_id}") + return DeleteResponse(message="账户删除成功") + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"删除账户失败: {str(e)}") + raise HTTPException(status_code=500, detail="删除账户失败") + + +@router.post("/accounts/{account_id}/balance", response_model=AccountResponse) +def update_balance(account_id: int, data: BalanceUpdateRequest, db: Session = Depends(get_db)): + """更新账户余额(自动记录变更历史)""" + try: + account = get_or_404(db, FinancialAccount, account_id, "账户") + old_balance = account.balance + new_balance = data.new_balance + change_amount = round(new_balance - old_balance, 2) + + # 创建历史记录 + history = AccountHistory( + account_id=account_id, + change_amount=change_amount, + balance_before=old_balance, + balance_after=new_balance, + note=data.note, + ) + db.add(history) + + # 更新余额 + account.balance = new_balance + account.updated_at = utcnow() + db.commit() + db.refresh(account) + logger.info(f"更新余额成功: account_id={account_id}, {old_balance} -> {new_balance}") + return account + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"更新余额失败: {str(e)}") + raise HTTPException(status_code=500, detail="更新余额失败") + + +@router.get("/accounts/{account_id}/history") +def get_account_history( + account_id: int, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db)): + """获取账户变更历史""" + try: + account = get_or_404(db, FinancialAccount, account_id, "账户") + + total = db.query(AccountHistory).filter( + AccountHistory.account_id == account_id + ).count() + + records = db.query(AccountHistory).filter( + AccountHistory.account_id == account_id + ).order_by( + AccountHistory.created_at.desc() + ).offset((page - 1) * page_size).limit(page_size).all() + + result = { + "total": total, + "page": page, + "page_size": page_size, + "records": [ + { + "id": r.id, + "account_id": r.account_id, + "change_amount": r.change_amount, + "balance_before": r.balance_before, + "balance_after": r.balance_after, + "note": r.note, + "created_at": r.created_at, + } + for r in records + ] + } + + logger.info(f"获取账户历史成功: account_id={account_id}, total={total}") + return result + except HTTPException: + raise + except Exception as e: + logger.error(f"获取账户历史失败: {str(e)}") + raise HTTPException(status_code=500, detail="获取账户历史失败") + + +# ============ 分期还款计划 API ============ + +@router.get("/debt-installments", response_model=List[DebtInstallmentResponse]) +def get_installments(db: Session = Depends(get_db)): + """获取所有分期计划(含下次还款计算)""" + try: + installments = db.query(DebtInstallment).order_by( + DebtInstallment.is_completed.asc(), + DebtInstallment.next_payment_date.asc() if hasattr(DebtInstallment, 'next_payment_date') else DebtInstallment.id.asc() + ).all() + + today = date.today() + result = [] + for inst in installments: + info = compute_installment_info(inst, today) + data = { + "id": inst.id, + "account_id": inst.account_id, + "total_amount": inst.total_amount, + "total_periods": inst.total_periods, + "current_period": inst.current_period, + "payment_day": inst.payment_day, + "payment_amount": inst.payment_amount, + "start_date": inst.start_date, + "is_completed": inst.is_completed, + "created_at": inst.created_at, + "updated_at": inst.updated_at, + **info, + } + + if inst.account: + data["account_name"] = inst.account.name + data["account_icon"] = inst.account.icon + data["account_color"] = inst.account.color + + result.append(data) + + # 排序:未完成且临近的排前面 + result.sort(key=lambda x: ( + 0 if not x["is_completed"] and x["days_until_payment"] is not None else 1, + 0 if not x["is_completed"] else 1, + x["days_until_payment"] if x["days_until_payment"] is not None else 9999, + )) + + logger.info(f"获取分期计划列表成功,总数: {len(result)}") + return result + except Exception as e: + logger.error(f"获取分期计划列表失败: {str(e)}") + raise HTTPException(status_code=500, detail="获取分期计划列表失败") + + +@router.post("/debt-installments", response_model=DebtInstallmentResponse, status_code=201) +def create_installment(data: DebtInstallmentCreate, db: Session = Depends(get_db)): + """创建分期计划""" + try: + # 验证关联账户存在且为欠款类型 + account = get_or_404(db, FinancialAccount, data.account_id, "账户") + if account.account_type != "debt": + raise HTTPException(status_code=400, detail="分期计划只能关联欠款类型的账户") + + installment = DebtInstallment( + account_id=data.account_id, + total_amount=data.total_amount, + total_periods=data.total_periods, + current_period=data.current_period, + payment_day=data.payment_day, + payment_amount=data.payment_amount, + start_date=data.start_date, + is_completed=data.is_completed, + ) + db.add(installment) + db.commit() + db.refresh(installment) + + # 返回含计算字段的响应 + today = date.today() + info = compute_installment_info(installment, today) + + logger.info(f"创建分期计划成功: id={installment.id}") + return DebtInstallmentResponse( + id=installment.id, + account_id=installment.account_id, + total_amount=installment.total_amount, + total_periods=installment.total_periods, + current_period=installment.current_period, + payment_day=installment.payment_day, + payment_amount=installment.payment_amount, + start_date=installment.start_date, + is_completed=installment.is_completed, + created_at=installment.created_at, + updated_at=installment.updated_at, + **info, + account_name=account.name, + account_icon=account.icon, + account_color=account.color, + ) + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"创建分期计划失败: {str(e)}") + raise HTTPException(status_code=500, detail="创建分期计划失败") + + +@router.put("/debt-installments/{installment_id}", response_model=DebtInstallmentResponse) +def update_installment(installment_id: int, data: DebtInstallmentUpdate, db: Session = Depends(get_db)): + """更新分期计划""" + try: + installment = get_or_404(db, DebtInstallment, installment_id, "分期计划") + update_data = data.model_dump(exclude_unset=True) + + for field, value in update_data.items(): + setattr(installment, field, value) + + installment.updated_at = utcnow() + db.commit() + db.refresh(installment) + + today = date.today() + info = compute_installment_info(installment, today) + + result = DebtInstallmentResponse( + id=installment.id, + account_id=installment.account_id, + total_amount=installment.total_amount, + total_periods=installment.total_periods, + current_period=installment.current_period, + payment_day=installment.payment_day, + payment_amount=installment.payment_amount, + start_date=installment.start_date, + is_completed=installment.is_completed, + created_at=installment.created_at, + updated_at=installment.updated_at, + **info, + ) + if installment.account: + result.account_name = installment.account.name + result.account_icon = installment.account.icon + result.account_color = installment.account.color + + logger.info(f"更新分期计划成功: id={installment_id}") + return result + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"更新分期计划失败: {str(e)}") + raise HTTPException(status_code=500, detail="更新分期计划失败") + + +@router.delete("/debt-installments/{installment_id}") +def delete_installment(installment_id: int, db: Session = Depends(get_db)): + """删除分期计划""" + try: + installment = get_or_404(db, DebtInstallment, installment_id, "分期计划") + db.delete(installment) + db.commit() + logger.info(f"删除分期计划成功: id={installment_id}") + return DeleteResponse(message="分期计划删除成功") + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"删除分期计划失败: {str(e)}") + raise HTTPException(status_code=500, detail="删除分期计划失败") + + +@router.patch("/debt-installments/{installment_id}/pay") +def pay_installment(installment_id: int, db: Session = Depends(get_db)): + """标记已还一期""" + try: + installment = get_or_404(db, DebtInstallment, installment_id, "分期计划") + + if installment.is_completed: + raise HTTPException(status_code=400, detail="该分期计划已全部还清") + + installment.current_period += 1 + + # 检查是否已全部还清 + if installment.current_period > installment.total_periods: + installment.is_completed = True + installment.current_period = installment.total_periods + + installment.updated_at = utcnow() + db.commit() + db.refresh(installment) + + today = date.today() + info = compute_installment_info(installment, today) + + result = DebtInstallmentResponse( + id=installment.id, + account_id=installment.account_id, + total_amount=installment.total_amount, + total_periods=installment.total_periods, + current_period=installment.current_period, + payment_day=installment.payment_day, + payment_amount=installment.payment_amount, + start_date=installment.start_date, + is_completed=installment.is_completed, + created_at=installment.created_at, + updated_at=installment.updated_at, + **info, + ) + if installment.account: + result.account_name = installment.account.name + result.account_icon = installment.account.icon + result.account_color = installment.account.color + + logger.info(f"分期还款成功: id={installment_id}, current_period={installment.current_period}") + return result + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"分期还款失败: {str(e)}") + raise HTTPException(status_code=500, detail="分期还款失败") diff --git a/api/app/routers/anniversaries.py b/api/app/routers/anniversaries.py new file mode 100644 index 0000000..aad8f99 --- /dev/null +++ b/api/app/routers/anniversaries.py @@ -0,0 +1,284 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from typing import Optional, List +from datetime import date + +from app.database import get_db +from app.models.anniversary import Anniversary, AnniversaryCategory +from app.schemas.anniversary import ( + AnniversaryCreate, AnniversaryUpdate, AnniversaryResponse, + AnniversaryCategoryCreate, AnniversaryCategoryUpdate, AnniversaryCategoryResponse, +) +from app.schemas.common import DeleteResponse +from app.utils.crud import get_or_404 +from app.utils.datetime import utcnow +from app.utils.logger import logger + +router = APIRouter(prefix="/api", tags=["纪念日"]) + + +def compute_next_info(anniversary: Anniversary, today: date) -> tuple: + """计算纪念日的下一次日期、距今天数、周年数""" + month, day = anniversary.date.month, anniversary.date.day + + if anniversary.is_recurring: + # 计算今年和明年的日期 + this_year = today.year + next_date = date(this_year, month, day) + if next_date < today: + next_date = date(this_year + 1, month, day) + + days_until = (next_date - today).days + + year_count = None + if anniversary.year: + year_count = next_date.year - anniversary.year + + return next_date, days_until, year_count + else: + # 非重复:使用原始日期(加上年份) + if anniversary.year: + target = date(anniversary.year, month, day) + if target < today: + return None, None, None + days_until = (target - today).days + return target, days_until, 0 + else: + # 无年份的日期按今年算 + target = date(today.year, month, day) + if target < today: + return None, None, None + days_until = (target - today).days + return target, days_until, None + + +def enrich_anniversary(anniversary: Anniversary, today: date) -> dict: + """将 SQLAlchemy 模型转换为响应字典,附加计算字段""" + data = { + "id": anniversary.id, + "title": anniversary.title, + "date": anniversary.date, + "year": anniversary.year, + "category_id": anniversary.category_id, + "description": anniversary.description, + "is_recurring": anniversary.is_recurring, + "remind_days_before": anniversary.remind_days_before, + "created_at": anniversary.created_at, + "updated_at": anniversary.updated_at, + } + + next_date, days_until, year_count = compute_next_info(anniversary, today) + data["next_date"] = next_date + data["days_until"] = days_until + data["year_count"] = year_count + + if anniversary.category: + data["category"] = { + "id": anniversary.category.id, + "name": anniversary.category.name, + "icon": anniversary.category.icon, + "color": anniversary.category.color, + "sort_order": anniversary.category.sort_order, + } + else: + data["category"] = None + + return data + + +# ============ 纪念日分类 API ============ + +@router.get("/anniversary-categories", response_model=List[AnniversaryCategoryResponse]) +def get_categories(db: Session = Depends(get_db)): + """获取纪念日分类列表""" + try: + categories = db.query(AnniversaryCategory).order_by( + AnniversaryCategory.sort_order.asc(), + AnniversaryCategory.id.asc() + ).all() + logger.info(f"获取纪念日分类列表成功,总数: {len(categories)}") + return categories + except Exception as e: + logger.error(f"获取纪念日分类列表失败: {str(e)}") + raise HTTPException(status_code=500, detail="获取纪念日分类列表失败") + + +@router.post("/anniversary-categories", response_model=AnniversaryCategoryResponse, status_code=201) +def create_category(data: AnniversaryCategoryCreate, db: Session = Depends(get_db)): + """创建纪念日分类""" + try: + db_category = AnniversaryCategory( + name=data.name, + icon=data.icon, + color=data.color, + sort_order=data.sort_order, + ) + db.add(db_category) + db.commit() + db.refresh(db_category) + logger.info(f"创建纪念日分类成功: id={db_category.id}, name={db_category.name}") + return db_category + except Exception as e: + db.rollback() + logger.error(f"创建纪念日分类失败: {str(e)}") + raise HTTPException(status_code=500, detail="创建纪念日分类失败") + + +@router.put("/anniversary-categories/{category_id}", response_model=AnniversaryCategoryResponse) +def update_category(category_id: int, data: AnniversaryCategoryUpdate, db: Session = Depends(get_db)): + """更新纪念日分类""" + try: + category = get_or_404(db, AnniversaryCategory, category_id, "纪念日分类") + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(category, field, value) + db.commit() + db.refresh(category) + logger.info(f"更新纪念日分类成功: id={category_id}") + return category + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"更新纪念日分类失败: {str(e)}") + raise HTTPException(status_code=500, detail="更新纪念日分类失败") + + +@router.delete("/anniversary-categories/{category_id}") +def delete_category(category_id: int, db: Session = Depends(get_db)): + """删除纪念日分类""" + try: + category = get_or_404(db, AnniversaryCategory, category_id, "纪念日分类") + # 删除分类时,将其下纪念日的 category_id 设为 NULL + anniversaries = db.query(Anniversary).filter( + Anniversary.category_id == category_id + ).all() + for a in anniversaries: + a.category_id = None + db.delete(category) + db.commit() + logger.info(f"删除纪念日分类成功: id={category_id}") + return DeleteResponse(message="纪念日分类删除成功") + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"删除纪念日分类失败: {str(e)}") + raise HTTPException(status_code=500, detail="删除纪念日分类失败") + + +# ============ 纪念日 API ============ + +@router.get("/anniversaries", response_model=List[AnniversaryResponse]) +def get_anniversaries( + category_id: Optional[int] = Query(None, description="分类ID筛选"), + db: Session = Depends(get_db) +): + """获取纪念日列表(包含计算字段 next_date, days_until, year_count)""" + try: + query = db.query(Anniversary) + if category_id is not None: + query = query.filter(Anniversary.category_id == category_id) + + anniversaries = query.order_by( + Anniversary.date.asc(), + Anniversary.title.asc() + ).all() + + today = date.today() + result = [enrich_anniversary(a, today) for a in anniversaries] + + # 排序:即将到来的排前面,同天数的按日期排 + result.sort(key=lambda x: ( + 0 if (x["days_until"] is not None and x["days_until"] >= 0) else 1, + x["days_until"] if x["days_until"] is not None else 9999, + )) + + logger.info(f"获取纪念日列表成功,总数: {len(result)}") + return result + except Exception as e: + logger.error(f"获取纪念日列表失败: {str(e)}") + raise HTTPException(status_code=500, detail="获取纪念日列表失败") + + +@router.post("/anniversaries", response_model=AnniversaryResponse, status_code=201) +def create_anniversary(data: AnniversaryCreate, db: Session = Depends(get_db)): + """创建纪念日""" + try: + db_anniversary = Anniversary( + title=data.title, + date=data.date, + year=data.year, + category_id=data.category_id, + description=data.description, + is_recurring=data.is_recurring, + remind_days_before=data.remind_days_before, + ) + db.add(db_anniversary) + db.commit() + db.refresh(db_anniversary) + + today = date.today() + next_date, days_until, year_count = compute_next_info(db_anniversary, today) + + logger.info(f"创建纪念日成功: id={db_anniversary.id}, title={db_anniversary.title}") + return db_anniversary + except Exception as e: + db.rollback() + logger.error(f"创建纪念日失败: {str(e)}") + raise HTTPException(status_code=500, detail="创建纪念日失败") + + +@router.get("/anniversaries/{anniversary_id}", response_model=AnniversaryResponse) +def get_anniversary(anniversary_id: int, db: Session = Depends(get_db)): + """获取单个纪念日""" + try: + anniversary = get_or_404(db, Anniversary, anniversary_id, "纪念日") + return anniversary + except HTTPException: + raise + except Exception as e: + logger.error(f"获取纪念日失败: {str(e)}") + raise HTTPException(status_code=500, detail="获取纪念日失败") + + +@router.put("/anniversaries/{anniversary_id}", response_model=AnniversaryResponse) +def update_anniversary(anniversary_id: int, data: AnniversaryUpdate, db: Session = Depends(get_db)): + """更新纪念日""" + try: + anniversary = get_or_404(db, Anniversary, anniversary_id, "纪念日") + update_data = data.model_dump(exclude_unset=True) + + for field, value in update_data.items(): + if value is not None or field in data.clearable_fields: + setattr(anniversary, field, value) + + anniversary.updated_at = utcnow() + db.commit() + db.refresh(anniversary) + + logger.info(f"更新纪念日成功: id={anniversary_id}") + return anniversary + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"更新纪念日失败: {str(e)}") + raise HTTPException(status_code=500, detail="更新纪念日失败") + + +@router.delete("/anniversaries/{anniversary_id}") +def delete_anniversary(anniversary_id: int, db: Session = Depends(get_db)): + """删除纪念日""" + try: + anniversary = get_or_404(db, Anniversary, anniversary_id, "纪念日") + db.delete(anniversary) + db.commit() + logger.info(f"删除纪念日成功: id={anniversary_id}") + return DeleteResponse(message="纪念日删除成功") + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"删除纪念日失败: {str(e)}") + raise HTTPException(status_code=500, detail="删除纪念日失败") diff --git a/api/app/routers/categories.py b/api/app/routers/categories.py new file mode 100644 index 0000000..aee5919 --- /dev/null +++ b/api/app/routers/categories.py @@ -0,0 +1,102 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from typing import List + +from app.database import get_db +from app.models import Category +from app.schemas import CategoryCreate, CategoryUpdate, CategoryResponse +from app.schemas.common import DeleteResponse +from app.utils.crud import get_or_404 +from app.utils.logger import logger + +router = APIRouter(prefix="/api/categories", tags=["分类"]) + + +@router.get("", response_model=List[CategoryResponse]) +def get_categories( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100), + db: Session = Depends(get_db) +): + """获取分类列表""" + try: + categories = db.query(Category).offset(skip).limit(limit).all() + logger.info(f"获取分类列表成功,总数: {len(categories)}") + return categories + + except Exception as e: + logger.error(f"获取分类列表失败: {str(e)}") + raise HTTPException(status_code=500, detail="获取分类列表失败") + + +@router.post("", response_model=CategoryResponse, status_code=201) +def create_category(category_data: CategoryCreate, db: Session = Depends(get_db)): + """创建分类""" + try: + db_category = Category( + name=category_data.name, + color=category_data.color, + icon=category_data.icon, + ) + + db.add(db_category) + db.commit() + db.refresh(db_category) + + logger.info(f"创建分类成功: id={db_category.id}, name={db_category.name}") + return db_category + + except Exception as e: + db.rollback() + logger.error(f"创建分类失败: {str(e)}") + raise HTTPException(status_code=500, detail="创建分类失败") + + +@router.put("/{category_id}", response_model=CategoryResponse) +def update_category(category_id: int, category_data: CategoryUpdate, db: Session = Depends(get_db)): + """更新分类""" + try: + category = get_or_404(db, Category, category_id, "分类") + + update_data = category_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + if value is not None: + setattr(category, field, value) + + db.commit() + db.refresh(category) + + logger.info(f"更新分类成功: id={category_id}") + return category + + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"更新分类失败: {str(e)}") + raise HTTPException(status_code=500, detail="更新分类失败") + + +@router.delete("/{category_id}") +def delete_category(category_id: int, db: Session = Depends(get_db)): + """删除分类""" + try: + category = get_or_404(db, Category, category_id, "分类") + + # 检查是否有任务关联 + if category.tasks: + logger.warning(f"分类下有关联任务,无法删除: id={category_id}") + raise HTTPException(status_code=400, detail="该分类下有关联任务,无法删除") + + db.delete(category) + db.commit() + + logger.info(f"删除分类成功: id={category_id}") + return DeleteResponse(message="分类删除成功") + + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"删除分类失败: {str(e)}") + raise HTTPException(status_code=500, detail="删除分类失败") diff --git a/api/app/routers/habits.py b/api/app/routers/habits.py new file mode 100644 index 0000000..c8c91ef --- /dev/null +++ b/api/app/routers/habits.py @@ -0,0 +1,368 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func, distinct +from typing import Optional, List +from datetime import date, timedelta + +from app.database import get_db +from app.models.habit import Habit, HabitGroup, HabitCheckin +from app.schemas.habit import ( + HabitGroupCreate, HabitGroupUpdate, HabitGroupResponse, + HabitCreate, HabitUpdate, HabitResponse, + CheckinCreate, CheckinResponse, HabitStatsResponse, +) +from app.schemas.common import DeleteResponse +from app.utils.crud import get_or_404 +from app.utils.datetime import utcnow, today +from app.utils.logger import logger + +router = APIRouter(tags=["习惯"]) + + +# ============ 习惯分组 CRUD ============ + +habit_group_router = APIRouter(prefix="/api/habit-groups", tags=["习惯分组"]) + + +@habit_group_router.get("", response_model=List[HabitGroupResponse]) +def get_habit_groups(db: Session = Depends(get_db)): + """获取所有习惯分组""" + try: + groups = db.query(HabitGroup).order_by(HabitGroup.sort_order, HabitGroup.id).all() + return groups + except Exception as e: + logger.error(f"获取习惯分组失败: {str(e)}") + raise HTTPException(status_code=500, detail="获取习惯分组失败") + + +@habit_group_router.post("", response_model=HabitGroupResponse, status_code=201) +def create_habit_group(data: HabitGroupCreate, db: Session = Depends(get_db)): + """创建习惯分组""" + try: + group = HabitGroup(**data.model_dump()) + db.add(group) + db.commit() + db.refresh(group) + logger.info(f"创建习惯分组成功: id={group.id}, name={group.name}") + return group + except Exception as e: + db.rollback() + logger.error(f"创建习惯分组失败: {str(e)}") + raise HTTPException(status_code=500, detail="创建习惯分组失败") + + +@habit_group_router.put("/{group_id}", response_model=HabitGroupResponse) +def update_habit_group(group_id: int, data: HabitGroupUpdate, db: Session = Depends(get_db)): + """更新习惯分组""" + try: + group = get_or_404(db, HabitGroup, group_id, "习惯分组") + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(group, field, value) + db.commit() + db.refresh(group) + logger.info(f"更新习惯分组成功: id={group_id}") + return group + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"更新习惯分组失败: {str(e)}") + raise HTTPException(status_code=500, detail="更新习惯分组失败") + + +@habit_group_router.delete("/{group_id}") +def delete_habit_group(group_id: int, db: Session = Depends(get_db)): + """删除习惯分组(习惯的 group_id 会被置空)""" + try: + group = get_or_404(db, HabitGroup, group_id, "习惯分组") + + # 将该分组下所有习惯的 group_id 置空 + db.query(Habit).filter(Habit.group_id == group_id).update({Habit.group_id: None}) + + db.delete(group) + db.commit() + logger.info(f"删除习惯分组成功: id={group_id}") + return DeleteResponse(message="习惯分组删除成功") + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"删除习惯分组失败: {str(e)}") + raise HTTPException(status_code=500, detail="删除习惯分组失败") + + +# ============ 习惯 CRUD ============ + +habit_router = APIRouter(prefix="/api/habits", tags=["习惯"]) + + +@habit_router.get("", response_model=List[HabitResponse]) +def get_habits( + include_archived: bool = Query(False, description="是否包含已归档的习惯"), + db: Session = Depends(get_db), +): + """获取所有习惯""" + try: + query = db.query(Habit) + if not include_archived: + query = query.filter(Habit.is_archived == False) + habits = query.order_by(Habit.created_at.desc()).all() + return habits + except Exception as e: + logger.error(f"获取习惯列表失败: {str(e)}") + raise HTTPException(status_code=500, detail="获取习惯列表失败") + + +@habit_router.post("", response_model=HabitResponse, status_code=201) +def create_habit(data: HabitCreate, db: Session = Depends(get_db)): + """创建习惯""" + try: + habit = Habit(**data.model_dump()) + db.add(habit) + db.commit() + db.refresh(habit) + logger.info(f"创建习惯成功: id={habit.id}, name={habit.name}") + return habit + except Exception as e: + db.rollback() + logger.error(f"创建习惯失败: {str(e)}") + raise HTTPException(status_code=500, detail="创建习惯失败") + + +@habit_router.put("/{habit_id}", response_model=HabitResponse) +def update_habit(habit_id: int, data: HabitUpdate, db: Session = Depends(get_db)): + """更新习惯""" + try: + habit = get_or_404(db, Habit, habit_id, "习惯") + update_data = data.model_dump(exclude_unset=True) + + for field, value in update_data.items(): + if value is not None or field in data.clearable_fields: + setattr(habit, field, value) + + habit.updated_at = utcnow() + db.commit() + db.refresh(habit) + logger.info(f"更新习惯成功: id={habit_id}") + return habit + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"更新习惯失败: {str(e)}") + raise HTTPException(status_code=500, detail="更新习惯失败") + + +@habit_router.delete("/{habit_id}") +def delete_habit(habit_id: int, db: Session = Depends(get_db)): + """删除习惯(级联删除打卡记录)""" + try: + habit = get_or_404(db, Habit, habit_id, "习惯") + db.delete(habit) + db.commit() + logger.info(f"删除习惯成功: id={habit_id}") + return DeleteResponse(message="习惯删除成功") + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"删除习惯失败: {str(e)}") + raise HTTPException(status_code=500, detail="删除习惯失败") + + +@habit_router.patch("/{habit_id}/archive", response_model=HabitResponse) +def toggle_archive_habit(habit_id: int, db: Session = Depends(get_db)): + """切换习惯归档状态""" + try: + habit = get_or_404(db, Habit, habit_id, "习惯") + habit.is_archived = not habit.is_archived + habit.updated_at = utcnow() + db.commit() + db.refresh(habit) + logger.info(f"切换习惯归档状态成功: id={habit_id}, is_archived={habit.is_archived}") + return habit + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"切换习惯归档状态失败: {str(e)}") + raise HTTPException(status_code=500, detail="切换习惯归档状态失败") + + +# ============ 打卡 ============ + +checkin_router = APIRouter(prefix="/api/habits/{habit_id}/checkins", tags=["习惯打卡"]) + + +@checkin_router.get("", response_model=List[CheckinResponse]) +def get_checkins( + habit_id: int, + from_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"), + to_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"), + db: Session = Depends(get_db), +): + """获取习惯打卡记录""" + try: + get_or_404(db, Habit, habit_id, "习惯") + query = db.query(HabitCheckin).filter(HabitCheckin.habit_id == habit_id) + + if from_date: + query = query.filter(HabitCheckin.checkin_date >= date.fromisoformat(from_date)) + if to_date: + query = query.filter(HabitCheckin.checkin_date <= date.fromisoformat(to_date)) + + checkins = query.order_by(HabitCheckin.checkin_date.desc()).all() + return checkins + except HTTPException: + raise + except Exception as e: + logger.error(f"获取打卡记录失败: {str(e)}") + raise HTTPException(status_code=500, detail="获取打卡记录失败") + + +@checkin_router.post("", response_model=CheckinResponse) +def create_checkin( + habit_id: int, + data: Optional[CheckinCreate] = None, + db: Session = Depends(get_db), +): + """打卡(当天 count 累加)""" + try: + habit = get_or_404(db, Habit, habit_id, "习惯") + today_date = today() + add_count = data.count if data else 1 + + # 查找今日已有记录 + checkin = db.query(HabitCheckin).filter( + HabitCheckin.habit_id == habit_id, + HabitCheckin.checkin_date == today_date, + ).first() + + if checkin: + checkin.count += add_count + else: + checkin = HabitCheckin( + habit_id=habit_id, + checkin_date=today_date, + count=add_count, + ) + db.add(checkin) + + db.commit() + db.refresh(checkin) + logger.info(f"打卡成功: habit_id={habit_id}, date={today_date}, count={checkin.count}") + return checkin + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"打卡失败: {str(e)}") + raise HTTPException(status_code=500, detail="打卡失败") + + +@checkin_router.delete("") +def cancel_checkin( + habit_id: int, + count: int = Query(1, ge=1, description="取消的打卡次数"), + db: Session = Depends(get_db), +): + """取消今日打卡(count-1,为0时删除记录)""" + try: + habit = get_or_404(db, Habit, habit_id, "习惯") + today_date = today() + + checkin = db.query(HabitCheckin).filter( + HabitCheckin.habit_id == habit_id, + HabitCheckin.checkin_date == today_date, + ).first() + + if not checkin: + return DeleteResponse(message="今日无打卡记录") + + checkin.count = max(0, checkin.count - count) + if checkin.count <= 0: + db.delete(checkin) + + db.commit() + logger.info(f"取消打卡: habit_id={habit_id}, date={today_date}") + return DeleteResponse(message="取消打卡成功") + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"取消打卡失败: {str(e)}") + raise HTTPException(status_code=500, detail="取消打卡失败") + + +@checkin_router.get("/stats", response_model=HabitStatsResponse) +def get_habit_stats(habit_id: int, db: Session = Depends(get_db)): + """获取习惯统计数据""" + try: + habit = get_or_404(db, Habit, habit_id, "习惯") + today_date = today() + + # 今日打卡 + today_checkin = db.query(HabitCheckin).filter( + HabitCheckin.habit_id == habit_id, + HabitCheckin.checkin_date == today_date, + ).first() + today_count = today_checkin.count if today_checkin else 0 + today_completed = today_count >= habit.target_count + + # 所有完成打卡的日期(count >= target_count) + completed_dates = [ + row[0] + for row in db.query(HabitCheckin.checkin_date).filter( + HabitCheckin.habit_id == habit_id, + HabitCheckin.count >= habit.target_count, + ).order_by(HabitCheckin.checkin_date).all() + ] + + total_days = len(completed_dates) + + # 计算连续天数(从今天往回推算) + current_streak = 0 + check_date = today_date + + # 如果今天还没完成,从昨天开始算 + if not today_completed: + check_date = check_date - timedelta(days=1) + + while True: + if check_date in completed_dates: + current_streak += 1 + check_date -= timedelta(days=1) + else: + break + + # 计算最长连续天数 + longest_streak = 0 + streak = 0 + prev_date = None + for d in completed_dates: + if prev_date is None or d == prev_date + timedelta(days=1): + streak += 1 + else: + streak = 1 + longest_streak = max(longest_streak, streak) + prev_date = d + + return HabitStatsResponse( + total_days=total_days, + current_streak=current_streak, + longest_streak=longest_streak, + today_count=today_count, + today_completed=today_completed, + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"获取习惯统计失败: {str(e)}") + raise HTTPException(status_code=500, detail="获取习惯统计失败") + + +# 将子路由组合到主路由 +router.include_router(habit_group_router) +router.include_router(habit_router) +router.include_router(checkin_router) diff --git a/api/app/routers/tags.py b/api/app/routers/tags.py new file mode 100644 index 0000000..22fb549 --- /dev/null +++ b/api/app/routers/tags.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from typing import List + +from app.database import get_db +from app.models import Tag +from app.schemas import TagCreate, TagResponse +from app.schemas.common import DeleteResponse +from app.utils.crud import get_or_404 +from app.utils.logger import logger + +router = APIRouter(prefix="/api/tags", tags=["标签"]) + + +@router.get("", response_model=List[TagResponse]) +def get_tags( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100), + db: Session = Depends(get_db) +): + """获取标签列表""" + try: + tags = db.query(Tag).offset(skip).limit(limit).all() + logger.info(f"获取标签列表成功,总数: {len(tags)}") + return tags + + except Exception as e: + logger.error(f"获取标签列表失败: {str(e)}") + raise HTTPException(status_code=500, detail="获取标签列表失败") + + +@router.post("", response_model=TagResponse, status_code=201) +def create_tag(tag_data: TagCreate, db: Session = Depends(get_db)): + """创建标签""" + try: + db_tag = Tag(name=tag_data.name) + + db.add(db_tag) + db.commit() + db.refresh(db_tag) + + logger.info(f"创建标签成功: id={db_tag.id}, name={db_tag.name}") + return db_tag + + except IntegrityError: + db.rollback() + logger.warning(f"标签名称已存在: name={tag_data.name}") + raise HTTPException(status_code=400, detail="标签名称已存在") + + except Exception as e: + db.rollback() + logger.error(f"创建标签失败: {str(e)}") + raise HTTPException(status_code=500, detail="创建标签失败") + + +@router.delete("/{tag_id}") +def delete_tag(tag_id: int, db: Session = Depends(get_db)): + """删除标签""" + try: + tag = get_or_404(db, Tag, tag_id, "标签") + + db.delete(tag) + db.commit() + + logger.info(f"删除标签成功: id={tag_id}") + return DeleteResponse(message="标签删除成功") + + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"删除标签失败: {str(e)}") + raise HTTPException(status_code=500, detail="删除标签失败") diff --git a/api/app/routers/tasks.py b/api/app/routers/tasks.py new file mode 100644 index 0000000..9f7a953 --- /dev/null +++ b/api/app/routers/tasks.py @@ -0,0 +1,185 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from typing import Optional, List + +from app.database import get_db +from app.models import Task, Tag +from app.schemas import TaskCreate, TaskUpdate, TaskResponse +from app.schemas.common import DeleteResponse +from app.utils.crud import get_or_404 +from app.utils.datetime import utcnow +from app.utils.logger import logger + +router = APIRouter(prefix="/api/tasks", tags=["任务"]) + + +@router.get("", response_model=List[TaskResponse]) +def get_tasks( + status: Optional[str] = Query(None, description="筛选状态: all/in_progress/completed"), + category_id: Optional[int] = Query(None, description="分类ID"), + priority: Optional[str] = Query(None, description="优先级: q1/q2/q3/q4"), + sort_by: Optional[str] = Query("created_at", description="排序字段: created_at/priority/due_date"), + sort_order: Optional[str] = Query("desc", description="排序方向: asc/desc"), + db: Session = Depends(get_db) +) -> List[TaskResponse]: + """获取任务列表(支持筛选和排序)""" + try: + query = db.query(Task) + + # 状态筛选 + if status == "in_progress": + query = query.filter(Task.is_completed == False) + elif status == "completed": + query = query.filter(Task.is_completed == True) + + # 分类筛选 + if category_id is not None: + query = query.filter(Task.category_id == category_id) + + # 优先级筛选 + if priority: + query = query.filter(Task.priority == priority) + + # 排序 + if sort_by == "priority": + order_col = Task.priority + elif sort_by == "due_date": + order_col = Task.due_date + else: + order_col = Task.created_at + + if sort_order == "asc": + query = query.order_by(order_col.asc().nullslast()) + else: + query = query.order_by(order_col.desc().nullslast()) + + tasks = query.all() + + logger.info(f"获取任务列表成功,总数: {len(tasks)}") + return tasks + + except Exception as e: + logger.error(f"获取任务列表失败: {str(e)}") + raise HTTPException(status_code=500, detail="获取任务列表失败") + + +@router.post("", response_model=TaskResponse, status_code=201) +def create_task(task_data: TaskCreate, db: Session = Depends(get_db)): + """创建任务""" + try: + # 创建任务对象 + db_task = Task( + title=task_data.title, + description=task_data.description, + priority=task_data.priority, + due_date=task_data.due_date, + category_id=task_data.category_id, + ) + + # 添加标签 + if task_data.tag_ids: + tags = db.query(Tag).filter(Tag.id.in_(task_data.tag_ids)).all() + db_task.tags = tags + + db.add(db_task) + db.commit() + db.refresh(db_task) + + logger.info(f"创建任务成功: id={db_task.id}, title={db_task.title}") + return db_task + + except Exception as e: + db.rollback() + logger.error(f"创建任务失败: {str(e)}") + raise HTTPException(status_code=500, detail="创建任务失败") + + +@router.get("/{task_id}", response_model=TaskResponse) +def get_task(task_id: int, db: Session = Depends(get_db)): + """获取单个任务""" + try: + task = get_or_404(db, Task, task_id, "任务") + return task + except HTTPException: + raise + except Exception as e: + logger.error(f"获取任务失败: {str(e)}") + raise HTTPException(status_code=500, detail="获取任务失败") + + +@router.put("/{task_id}", response_model=TaskResponse) +def update_task(task_id: int, task_data: TaskUpdate, db: Session = Depends(get_db)): + """更新任务""" + try: + task = get_or_404(db, Task, task_id, "任务") + + # exclude_unset=True 保证:前端没传的字段不会出现在 dict 中,不会意外清空 + # 前端显式传了 null 的字段会出现在 dict 中,允许清空可空字段 + update_data = task_data.model_dump(exclude_unset=True) + tag_ids = update_data.pop("tag_ids", None) + + for field, value in update_data.items(): + # 非 clearable 字段(如 title)只有非 None 值才更新 + # clearable 字段(description, due_date, category_id)允许设为 None + if value is not None or field in task_data.clearable_fields: + setattr(task, field, value) + + # 更新标签 + if tag_ids is not None: + tags = db.query(Tag).filter(Tag.id.in_(tag_ids)).all() + task.tags = tags + + task.updated_at = utcnow() + db.commit() + db.refresh(task) + logger.info(f"更新任务成功: id={task_id}") + return task + + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"更新任务失败: {str(e)}") + raise HTTPException(status_code=500, detail="更新任务失败") + + +@router.delete("/{task_id}") +def delete_task(task_id: int, db: Session = Depends(get_db)): + """删除任务""" + try: + task = get_or_404(db, Task, task_id, "任务") + + db.delete(task) + db.commit() + + logger.info(f"删除任务成功: id={task_id}") + return DeleteResponse(message="任务删除成功") + + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"删除任务失败: {str(e)}") + raise HTTPException(status_code=500, detail="删除任务失败") + + +@router.patch("/{task_id}/toggle", response_model=TaskResponse) +def toggle_task(task_id: int, db: Session = Depends(get_db)): + """切换任务完成状态""" + try: + task = get_or_404(db, Task, task_id, "任务") + + task.is_completed = not task.is_completed + task.updated_at = utcnow() + db.commit() + db.refresh(task) + + logger.info(f"切换任务状态成功: id={task_id}, is_completed={task.is_completed}") + return task + + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"切换任务状态失败: {str(e)}") + raise HTTPException(status_code=500, detail="切换任务状态失败") diff --git a/api/app/routers/user_settings.py b/api/app/routers/user_settings.py new file mode 100644 index 0000000..4ff7ff0 --- /dev/null +++ b/api/app/routers/user_settings.py @@ -0,0 +1,57 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.user_settings import UserSettings +from app.schemas.user_settings import UserSettingsUpdate, UserSettingsResponse +from app.utils.datetime import utcnow +from app.utils.logger import logger + +router = APIRouter(prefix="/api/user-settings", tags=["用户设置"]) + + +@router.get("", response_model=UserSettingsResponse) +def get_user_settings(db: Session = Depends(get_db)): + """获取用户设置(单例模式)""" + try: + settings = db.query(UserSettings).filter(UserSettings.id == 1).first() + if not settings: + # 首次访问时自动创建默认设置 + settings = UserSettings(id=1) + db.add(settings) + db.commit() + db.refresh(settings) + logger.info("自动创建默认用户设置") + return settings + except Exception as e: + logger.error(f"获取用户设置失败: {str(e)}") + raise HTTPException(status_code=500, detail="获取用户设置失败") + + +@router.put("", response_model=UserSettingsResponse) +def update_user_settings( + data: UserSettingsUpdate, + db: Session = Depends(get_db) +): + """更新用户设置(upsert 单条记录)""" + try: + settings = db.query(UserSettings).filter(UserSettings.id == 1).first() + if not settings: + settings = UserSettings(id=1) + db.add(settings) + + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(settings, field, value) + + settings.updated_at = utcnow() + db.commit() + db.refresh(settings) + + logger.info("更新用户设置成功") + return settings + + except Exception as e: + db.rollback() + logger.error(f"更新用户设置失败: {str(e)}") + raise HTTPException(status_code=500, detail="更新用户设置失败") diff --git a/api/app/schemas/__init__.py b/api/app/schemas/__init__.py new file mode 100644 index 0000000..1bdf7df --- /dev/null +++ b/api/app/schemas/__init__.py @@ -0,0 +1,44 @@ +from app.schemas.task import ( + TaskBase, + TaskCreate, + TaskUpdate, + TaskResponse, +) +from app.schemas.category import ( + CategoryBase, + CategoryCreate, + CategoryUpdate, + CategoryResponse, +) +from app.schemas.tag import ( + TagBase, + TagCreate, + TagResponse, +) +from app.schemas.common import ( + DeleteResponse, + PaginatedResponse, +) +from app.schemas.user_settings import ( + UserSettingsUpdate, + UserSettingsResponse, +) +from app.schemas.habit import ( + HabitGroupCreate, + HabitGroupUpdate, + HabitGroupResponse, + HabitCreate, + HabitUpdate, + HabitResponse, + CheckinCreate, + CheckinResponse, + HabitStatsResponse, +) +from app.schemas.anniversary import ( + AnniversaryCategoryCreate, + AnniversaryCategoryUpdate, + AnniversaryCategoryResponse, + AnniversaryCreate, + AnniversaryUpdate, + AnniversaryResponse, +) diff --git a/api/app/schemas/account.py b/api/app/schemas/account.py new file mode 100644 index 0000000..d25503b --- /dev/null +++ b/api/app/schemas/account.py @@ -0,0 +1,133 @@ +from pydantic import BaseModel, Field +from datetime import date, datetime +from typing import Optional, List + + +# ============ 财务账户 Schema ============ + +class AccountBase(BaseModel): + """账户基础模型""" + name: str = Field(..., min_length=1, max_length=100) + account_type: str = Field(default="savings", pattern="^(savings|debt)$") + balance: float = Field(default=0.0) + icon: str = Field(default="wallet", max_length=50) + color: str = Field(default="#FFB7C5", max_length=20) + sort_order: int = Field(default=0) + is_active: bool = Field(default=True) + description: Optional[str] = None + + +class AccountCreate(AccountBase): + """创建账户请求模型""" + pass + + +class AccountUpdate(BaseModel): + """更新账户请求模型""" + name: Optional[str] = Field(None, max_length=100) + account_type: Optional[str] = Field(None, pattern="^(savings|debt)$") + balance: Optional[float] = None + icon: Optional[str] = Field(None, max_length=50) + color: Optional[str] = Field(None, max_length=20) + sort_order: Optional[int] = None + is_active: Optional[bool] = None + description: Optional[str] = None + + +class AccountResponse(AccountBase): + """账户响应模型""" + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class AccountListItemResponse(BaseModel): + """账户列表项响应模型(含分期摘要)""" + id: int + name: str + account_type: str + balance: float + icon: str + color: str + sort_order: int + is_active: bool + description: Optional[str] = None + created_at: datetime + updated_at: datetime + installments: List[dict] = [] + + +class BalanceUpdateRequest(BaseModel): + """更新余额请求模型""" + new_balance: float + note: Optional[str] = Field(None, max_length=200) + + +# ============ 账户变更历史 Schema ============ + +class AccountHistoryResponse(BaseModel): + """变更历史响应模型""" + id: int + account_id: int + change_amount: float + balance_before: float + balance_after: float + note: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + + +# ============ 分期还款计划 Schema ============ + +class DebtInstallmentBase(BaseModel): + """分期计划基础模型""" + account_id: int + total_amount: float + total_periods: int = Field(..., ge=1) + current_period: int = Field(default=1, ge=1) + payment_day: int = Field(..., ge=1, le=31) + payment_amount: float = Field(..., gt=0) + start_date: date + is_completed: bool = Field(default=False) + + +class DebtInstallmentCreate(DebtInstallmentBase): + """创建分期计划请求模型""" + pass + + +class DebtInstallmentUpdate(BaseModel): + """更新分期计划请求模型""" + account_id: Optional[int] = None + total_amount: Optional[float] = None + total_periods: Optional[int] = Field(None, ge=1) + current_period: Optional[int] = Field(None, ge=1) + payment_day: Optional[int] = Field(None, ge=1, le=31) + payment_amount: Optional[float] = Field(None, gt=0) + start_date: Optional[date] = None + is_completed: Optional[bool] = None + + +class DebtInstallmentResponse(DebtInstallmentBase): + """分期计划响应模型(含计算字段)""" + id: int + created_at: datetime + updated_at: datetime + + # 计算字段 + next_payment_date: Optional[date] = None + days_until_payment: Optional[int] = None + remaining_periods: Optional[int] = None + + # 关联账户信息 + account_name: Optional[str] = None + account_icon: Optional[str] = None + account_color: Optional[str] = None + + class Config: + from_attributes = True diff --git a/api/app/schemas/anniversary.py b/api/app/schemas/anniversary.py new file mode 100644 index 0000000..93a6042 --- /dev/null +++ b/api/app/schemas/anniversary.py @@ -0,0 +1,122 @@ +from pydantic import BaseModel, Field, field_validator +from datetime import date, datetime, timezone +from typing import Optional, List + + +def parse_date(value): + """解析日期字符串""" + if value is None or value == '': + return None + if isinstance(value, date): + return value + if isinstance(value, datetime): + return value.date() + formats = [ + '%Y-%m-%d', + '%Y-%m-%dT%H:%M:%S', + '%Y-%m-%d %H:%M:%S', + '%Y-%m-%dT%H:%M:%S.%f', + ] + for fmt in formats: + try: + return datetime.strptime(value, fmt).date() + except ValueError: + continue + try: + return date.fromisoformat(value) + except ValueError: + raise ValueError(f"无法解析日期: {value}") + + +# ============ 纪念日分类 Schema ============ + +class AnniversaryCategoryBase(BaseModel): + """纪念日分类基础模型""" + name: str = Field(..., max_length=50) + icon: str = Field(default="calendar", max_length=50) + color: str = Field(default="#FFB7C5", max_length=20) + sort_order: int = Field(default=0) + + +class AnniversaryCategoryCreate(AnniversaryCategoryBase): + """创建纪念日分类请求模型""" + pass + + +class AnniversaryCategoryUpdate(BaseModel): + """更新纪念日分类请求模型""" + name: Optional[str] = Field(None, max_length=50) + icon: Optional[str] = Field(None, max_length=50) + color: Optional[str] = Field(None, max_length=20) + sort_order: Optional[int] = None + + +class AnniversaryCategoryResponse(AnniversaryCategoryBase): + """纪念日分类响应模型""" + id: int + + class Config: + from_attributes = True + + +# ============ 纪念日 Schema ============ + +class AnniversaryBase(BaseModel): + """纪念日基础模型""" + title: str = Field(..., max_length=200) + date: date + year: Optional[int] = None + category_id: Optional[int] = None + description: Optional[str] = None + is_recurring: bool = Field(default=True) + remind_days_before: int = Field(default=3) + + @field_validator('date', mode='before') + @classmethod + def parse_anniversary_date(cls, v): + result = parse_date(v) + if result is None: + raise ValueError("纪念日日期不能为空") + return result + + +class AnniversaryCreate(AnniversaryBase): + """创建纪念日请求模型""" + pass + + +class AnniversaryUpdate(BaseModel): + """更新纪念日请求模型""" + title: Optional[str] = Field(None, max_length=200) + date: Optional[date] = None + year: Optional[int] = None + category_id: Optional[int] = None + description: Optional[str] = None + is_recurring: Optional[bool] = None + remind_days_before: Optional[int] = None + + @field_validator('date', mode='before') + @classmethod + def parse_anniversary_date(cls, v): + if v is None: + return None + return parse_date(v) + + @property + def clearable_fields(self) -> set: + """允许被显式清空(设为 None)的字段集合""" + return {'description', 'category_id', 'year'} + + +class AnniversaryResponse(AnniversaryBase): + """纪念日响应模型""" + id: int + created_at: datetime + updated_at: datetime + category: Optional[AnniversaryCategoryResponse] = None + next_date: Optional[date] = None + days_until: Optional[int] = None + year_count: Optional[int] = None + + class Config: + from_attributes = True diff --git a/api/app/schemas/category.py b/api/app/schemas/category.py new file mode 100644 index 0000000..0d4376d --- /dev/null +++ b/api/app/schemas/category.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel, Field + + +class CategoryBase(BaseModel): + """分类基础模型""" + name: str = Field(..., max_length=100) + color: str = Field(default="#FFB7C5", max_length=20) + icon: str = Field(default="folder", max_length=50) + + +class CategoryCreate(CategoryBase): + """创建分类请求模型""" + pass + + +class CategoryUpdate(BaseModel): + """更新分类请求模型""" + name: str = Field(None, max_length=100) + color: str = Field(None, max_length=20) + icon: str = Field(None, max_length=50) + + +class CategoryResponse(CategoryBase): + """分类响应模型""" + id: int + + class Config: + from_attributes = True diff --git a/api/app/schemas/common.py b/api/app/schemas/common.py new file mode 100644 index 0000000..1a8a2f4 --- /dev/null +++ b/api/app/schemas/common.py @@ -0,0 +1,39 @@ +""" +通用响应模型 +""" +from typing import Generic, TypeVar, List, Optional +from pydantic import BaseModel, Field + +T = TypeVar("T") + + +class DeleteResponse(BaseModel): + """删除成功响应""" + success: bool = Field(default=True, description="操作是否成功") + message: str = Field(description="响应消息") + + class Config: + json_schema_extra = { + "example": { + "success": True, + "message": "删除成功" + } + } + + +class PaginatedResponse(BaseModel, Generic[T]): + """分页列表响应""" + items: List[T] = Field(description="数据列表") + total: int = Field(description="总记录数") + skip: int = Field(description="跳过的记录数") + limit: int = Field(description="返回的记录数") + + class Config: + json_schema_extra = { + "example": { + "items": [], + "total": 0, + "skip": 0, + "limit": 20 + } + } diff --git a/api/app/schemas/habit.py b/api/app/schemas/habit.py new file mode 100644 index 0000000..114438b --- /dev/null +++ b/api/app/schemas/habit.py @@ -0,0 +1,105 @@ +from pydantic import BaseModel, Field, field_validator +from datetime import datetime, date +from typing import Optional, List + + +# ============ 习惯分组 Schemas ============ + +class HabitGroupBase(BaseModel): + """习惯分组基础模型""" + name: str = Field(..., max_length=100) + color: str = Field(default="#FFB7C5", max_length=20) + icon: str = Field(default="flag", max_length=50) + sort_order: int = Field(default=0) + + +class HabitGroupCreate(HabitGroupBase): + """创建习惯分组请求模型""" + pass + + +class HabitGroupUpdate(BaseModel): + """更新习惯分组请求模型""" + name: Optional[str] = Field(None, max_length=100) + color: Optional[str] = Field(None, max_length=20) + icon: Optional[str] = Field(None, max_length=50) + sort_order: Optional[int] = None + + +class HabitGroupResponse(HabitGroupBase): + """习惯分组响应模型""" + id: int + + class Config: + from_attributes = True + + +# ============ 习惯 Schemas ============ + +class HabitBase(BaseModel): + """习惯基础模型""" + name: str = Field(..., max_length=200) + description: Optional[str] = None + group_id: Optional[int] = None + target_count: int = Field(default=1, ge=1) + frequency: str = Field(default="daily", pattern="^(daily|weekly)$") + active_days: Optional[str] = None + + +class HabitCreate(HabitBase): + """创建习惯请求模型""" + pass + + +class HabitUpdate(BaseModel): + """更新习惯请求模型""" + name: Optional[str] = Field(None, max_length=200) + description: Optional[str] = None + group_id: Optional[int] = None + target_count: Optional[int] = Field(None, ge=1) + frequency: Optional[str] = Field(None, pattern="^(daily|weekly)$") + active_days: Optional[str] = None + + @property + def clearable_fields(self) -> set: + return {"description", "group_id", "active_days"} + + +class HabitResponse(HabitBase): + """习惯响应模型""" + id: int + is_archived: bool + created_at: datetime + updated_at: datetime + group: Optional[HabitGroupResponse] = None + + class Config: + from_attributes = True + + +# ============ 打卡 Schemas ============ + +class CheckinCreate(BaseModel): + """打卡请求模型""" + count: Optional[int] = Field(default=1, ge=1) + + +class CheckinResponse(BaseModel): + """打卡记录响应模型""" + id: int + habit_id: int + checkin_date: date + count: int + created_at: datetime + + class Config: + from_attributes = True + + +class HabitStatsResponse(BaseModel): + """习惯统计响应模型""" + total_days: int = 0 + current_streak: int = 0 + longest_streak: int = 0 + today_count: int = 0 + today_completed: bool = False diff --git a/api/app/schemas/tag.py b/api/app/schemas/tag.py new file mode 100644 index 0000000..0c998d8 --- /dev/null +++ b/api/app/schemas/tag.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, Field + + +class TagBase(BaseModel): + """标签基础模型""" + name: str = Field(..., max_length=50) + + +class TagCreate(TagBase): + """创建标签请求模型""" + pass + + +class TagResponse(TagBase): + """标签响应模型""" + id: int + + class Config: + from_attributes = True diff --git a/api/app/schemas/task.py b/api/app/schemas/task.py new file mode 100644 index 0000000..8351370 --- /dev/null +++ b/api/app/schemas/task.py @@ -0,0 +1,85 @@ +from pydantic import BaseModel, Field, field_validator +from datetime import datetime +from typing import Optional, List + +from app.schemas.category import CategoryResponse +from app.schemas.tag import TagResponse + + +def parse_datetime(value): + """解析日期时间字符串""" + if value is None or value == '': + return None + if isinstance(value, datetime): + return value + # 尝试多种格式 + formats = [ + '%Y-%m-%dT%H:%M:%S', + '%Y-%m-%d %H:%M:%S', + '%Y-%m-%dT%H:%M:%S.%f', + ] + for fmt in formats: + try: + return datetime.strptime(value, fmt) + except ValueError: + continue + # 最后尝试 ISO 格式 + return datetime.fromisoformat(value.replace('Z', '+00:00')) + + +class TaskBase(BaseModel): + """任务基础模型""" + title: str = Field(..., max_length=200) + description: Optional[str] = None + priority: str = Field(default="q4", pattern="^(q1|q2|q3|q4)$") + due_date: Optional[datetime] = None + category_id: Optional[int] = None + + @field_validator('due_date', mode='before') + @classmethod + def parse_due_date(cls, v): + return parse_datetime(v) + + +class TaskCreate(TaskBase): + """创建任务请求模型""" + tag_ids: Optional[List[int]] = [] + + +class TaskUpdate(BaseModel): + """更新任务请求模型 + + 通过 exclude_unset=True 区分"前端没传"和"前端传了 null": + - 前端没传某个字段 -> model_dump 结果中不包含该 key -> 不修改 + - 前端传了 null -> model_dump 结果中包含 key: None -> 视为"清空" + """ + title: Optional[str] = Field(None, max_length=200) + description: Optional[str] = None + priority: Optional[str] = Field(None, pattern="^(q1|q2|q3|q4)$") + due_date: Optional[datetime] = None + is_completed: Optional[bool] = None + category_id: Optional[int] = None + tag_ids: Optional[List[int]] = None + + @field_validator('due_date', mode='before') + @classmethod + def parse_due_date(cls, v): + return parse_datetime(v) + + @property + def clearable_fields(self) -> set: + """允许被显式清空(设为 None)的字段集合""" + return {'description', 'due_date', 'category_id'} + + +class TaskResponse(TaskBase): + """任务响应模型""" + id: int + is_completed: bool + created_at: datetime + updated_at: datetime + category: Optional[CategoryResponse] = None + tags: List[TagResponse] = [] + + class Config: + from_attributes = True diff --git a/api/app/schemas/user_settings.py b/api/app/schemas/user_settings.py new file mode 100644 index 0000000..ada4c99 --- /dev/null +++ b/api/app/schemas/user_settings.py @@ -0,0 +1,39 @@ +from pydantic import BaseModel, Field +from datetime import datetime, date +from typing import Optional + + +class UserSettingsUpdate(BaseModel): + """更新用户设置请求模型""" + nickname: Optional[str] = Field(None, max_length=50) + avatar: Optional[str] = None + signature: Optional[str] = Field(None, max_length=200) + birthday: Optional[date] = None + email: Optional[str] = Field(None, max_length=100) + site_name: Optional[str] = Field(None, max_length=50) + theme: Optional[str] = Field(None, max_length=20) + language: Optional[str] = Field(None, max_length=10) + default_view: Optional[str] = Field(None, max_length=20) + default_sort_by: Optional[str] = Field(None, max_length=20) + default_sort_order: Optional[str] = Field(None, max_length=10) + + +class UserSettingsResponse(BaseModel): + """用户设置响应模型""" + id: int + nickname: str + avatar: Optional[str] = None + signature: Optional[str] = None + birthday: Optional[date] = None + email: Optional[str] = None + site_name: str + theme: str + language: str + default_view: str + default_sort_by: str + default_sort_order: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/api/app/utils/__init__.py b/api/app/utils/__init__.py new file mode 100644 index 0000000..e3353b7 --- /dev/null +++ b/api/app/utils/__init__.py @@ -0,0 +1,5 @@ +""" +Utils 工具模块 +""" +from app.utils.crud import get_or_404 +from app.utils.logger import logger diff --git a/api/app/utils/crud.py b/api/app/utils/crud.py new file mode 100644 index 0000000..5e9fca0 --- /dev/null +++ b/api/app/utils/crud.py @@ -0,0 +1,34 @@ +""" +通用 CRUD 工具函数 +""" +from typing import Type, TypeVar +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.database import Base +from app.utils.logger import logger + + +ModelType = TypeVar("ModelType", bound=Base) + + +def get_or_404( + db: Session, + model: Type[ModelType], + item_id: int, + name: str = "资源" +) -> ModelType: + """ + 获取实体,不存在时抛出 404 异常 + + Args: + db: 数据库会话 + model: 模型类 + item_id: 实体 ID + name: 实体名称(用于错误信息) + """ + item = db.query(model).filter(model.id == item_id).first() + if not item: + logger.warning(f"{name}不存在: id={item_id}") + raise HTTPException(status_code=404, detail=f"{name}不存在") + return item diff --git a/api/app/utils/datetime.py b/api/app/utils/datetime.py new file mode 100644 index 0000000..60cf979 --- /dev/null +++ b/api/app/utils/datetime.py @@ -0,0 +1,11 @@ +from datetime import datetime, timezone, date + + +def utcnow() -> datetime: + """统一获取 UTC 时间的工具函数""" + return datetime.now(timezone.utc) + + +def today() -> date: + """获取当天日期""" + return date.today() diff --git a/api/app/utils/logger.py b/api/app/utils/logger.py new file mode 100644 index 0000000..81bd64c --- /dev/null +++ b/api/app/utils/logger.py @@ -0,0 +1,48 @@ +import logging +import os +from datetime import datetime +from logging.handlers import TimedRotatingFileHandler + +from app.config import LOG_LEVEL, LOG_DIR + +# 确保日志目录存在 +os.makedirs(LOG_DIR, exist_ok=True) + + +def setup_logger(name: str) -> logging.Logger: + """设置日志记录器""" + logger = logging.getLogger(name) + logger.setLevel(getattr(logging, LOG_LEVEL)) + + # 避免重复添加处理器 + if logger.handlers: + return logger + + # 日志格式 + formatter = logging.Formatter( + "[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) + + # 控制台处理器 + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + # 文件处理器(按日期分割) + log_file = os.path.join(LOG_DIR, "app.log") + file_handler = TimedRotatingFileHandler( + log_file, + when="midnight", + interval=1, + backupCount=30, + encoding="utf-8" + ) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger + + +# 创建日志记录器 +logger = setup_logger("app") diff --git a/api/webui/index.html b/api/webui/index.html new file mode 100644 index 0000000..2d67728 --- /dev/null +++ b/api/webui/index.html @@ -0,0 +1,16 @@ + + + + + + + webui + + + + + + +
+ + diff --git a/api/webui/vite.svg b/api/webui/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/api/webui/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..e051499 --- /dev/null +++ b/main.py @@ -0,0 +1,229 @@ +""" +爱莉希雅待办事项 - 项目启动入口 + +功能: +1. 编译前端项目 +2. 启动 FastAPI 后端服务 +""" +import os +import sys +import shutil +import subprocess +import time + + +# 路径配置 +PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) +WEBUI_DIR = os.path.join(PROJECT_ROOT, "WebUI") +DIST_DIR = os.path.join(WEBUI_DIR, "dist") +WEBUI_TARGET = os.path.join(PROJECT_ROOT, "api", "webui") +API_MAIN = os.path.join(PROJECT_ROOT, "api", "app", "main.py") + + +def log_info(msg): + """打印信息""" + print(f"[启动] {msg}") + + +def log_error(msg): + """打印错误""" + print(f"[错误] {msg}", file=sys.stderr) + + +def needs_rebuild() -> bool: + """判断是否需要重新编译前端(基于文件修改时间)""" + if not os.path.exists(DIST_DIR): + return True + src_dir = os.path.join(WEBUI_DIR, "src") + if not os.path.exists(src_dir): + return True + # 获取 dist 目录最新修改时间 + dist_mtime = max( + os.path.getmtime(os.path.join(dp, f)) + for dp, dn, filenames in os.walk(DIST_DIR) + for f in filenames + ) + # 检查 src 目录中是否有比 dist 更新的文件 + for dp, dn, filenames in os.walk(src_dir): + for f in filenames: + if f.endswith(('.vue', '.ts', '.js', '.scss', '.css')): + filepath = os.path.join(dp, f) + if os.path.getmtime(filepath) > dist_mtime: + return True + return False + + +def build_frontend(): + """编译前端项目""" + # 智能判断是否需要重新编译 + if not needs_rebuild(): + log_info("前端产物已是最新,跳过编译") + return True + + log_info("开始编译前端项目...") + + # 检查 WebUI 目录是否存在 + if not os.path.exists(WEBUI_DIR): + log_error(f"WebUI 目录不存在: {WEBUI_DIR}") + return False + + # 检查 node_modules 是否存在 + node_modules = os.path.join(WEBUI_DIR, "node_modules") + if not os.path.exists(node_modules): + log_info("安装前端依赖...") + # Windows 上使用 npm.cmd + npm_cmd = "npm.cmd" if os.name == "nt" else "npm" + result = subprocess.run( + [npm_cmd, "install"], + cwd=WEBUI_DIR, + encoding="utf-8", + errors="ignore" + ) + if result.returncode != 0: + log_error(f"安装依赖失败") + return False + + # 执行编译 + log_info("执行 npm run build...") + npm_cmd = "npm.cmd" if os.name == "nt" else "npm" + result = subprocess.run( + [npm_cmd, "run", "build"], + cwd=WEBUI_DIR, + encoding="utf-8", + errors="ignore" + ) + if result.returncode != 0: + log_error(f"编译失败") + return False + + log_info("前端编译完成!") + return True + + +def copy_dist_to_webui(): + """复制编译产物到 webui 目录""" + log_info("复制编译产物到 webui 目录...") + + # 检查 dist 目录是否存在 + if not os.path.exists(DIST_DIR): + log_error(f"编译产物目录不存在: {DIST_DIR}") + return False + + # 删除旧的 webui 目录(如果存在) + if os.path.exists(WEBUI_TARGET): + shutil.rmtree(WEBUI_TARGET) + + # 复制 dist 到 webui + shutil.copytree(DIST_DIR, WEBUI_TARGET) + log_info(f"编译产物已复制到: {WEBUI_TARGET}") + return True + + +def find_pid_on_port(port: int) -> int | None: + """查找占用指定端口的进程 PID""" + if os.name == "nt": + result = subprocess.run( + ["netstat", "-ano", "-p", "TCP"], + capture_output=True, text=True, encoding="gbk", errors="ignore" + ) + for line in result.stdout.splitlines(): + if f":{port}" in line and "LISTENING" in line: + parts = line.split() + return int(parts[-1]) + else: + result = subprocess.run( + ["lsof", "-t", "-i", f":{port}", "-sTCP:LISTEN"], + capture_output=True, text=True, errors="ignore" + ) + output = result.stdout.strip() + if output: + return int(output.splitlines()[0]) + return None + + +def kill_process(pid: int) -> bool: + """终止指定 PID 的进程""" + log_info(f"正在终止占用端口的进程 (PID: {pid})...") + try: + if os.name == "nt": + subprocess.run(["taskkill", "/PID", str(pid), "/F"], + capture_output=True, timeout=10) + else: + os.kill(pid, 9) + time.sleep(1) + log_info(f"进程 {pid} 已终止") + return True + except Exception as e: + log_error(f"终止进程 {pid} 失败: {e}") + return False + + +def check_and_free_port(port: int) -> bool: + """检测端口是否被占用,如果被占用则尝试释放""" + pid = find_pid_on_port(port) + if pid is None: + return True + + log_info(f"端口 {port} 已被进程 {pid} 占用") + if kill_process(pid): + # 验证端口是否已释放 + if find_pid_on_port(port) is None: + log_info(f"端口 {port} 已释放") + return True + log_error(f"端口 {port} 仍被占用,尝试再次终止...") + time.sleep(2) + pid2 = find_pid_on_port(port) + if pid2 is not None: + kill_process(pid2) + if find_pid_on_port(port) is None: + return True + + log_error(f"无法释放端口 {port},请手动处理") + return False + + +def start_backend(): + """启动后端服务""" + log_info("启动后端服务...") + + # 添加 api 目录到 Python 路径(不使用 os.chdir,避免全局副作用) + api_dir = os.path.join(PROJECT_ROOT, "api") + if api_dir not in sys.path: + sys.path.insert(0, api_dir) + + from app.config import HOST, PORT + + # 检查端口是否被占用,自动释放 + if not check_and_free_port(PORT): + log_error(f"端口 {PORT} 无法释放,启动失败") + sys.exit(1) + + import uvicorn + + log_info(f"服务启动成功: http://{HOST}:{PORT}") + log_info(f"API 文档: http://{HOST}:{PORT}/docs") + log_info(f"前端页面: http://{HOST}:{PORT}/") + + uvicorn.run("app.main:app", host=HOST, port=PORT, reload=False) + + +def main(): + """主函数""" + print("=" * 50) + print(" 爱莉希雅待办事项 - 项目启动") + print("=" * 50) + + # 1. 编译前端 + if not build_frontend(): + sys.exit(1) + + # 2. 复制编译产物 + if not copy_dist_to_webui(): + sys.exit(1) + + # 3. 启动后端 + start_backend() + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4e3bb51 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.109.0 +uvicorn==0.27.0 +sqlalchemy==2.0.25 +pydantic==2.5.3 +pydantic-settings==2.1.0 +python-multipart==0.0.6 diff --git a/tests/test_accounts.py b/tests/test_accounts.py new file mode 100644 index 0000000..309c0f2 --- /dev/null +++ b/tests/test_accounts.py @@ -0,0 +1,618 @@ +""" +资产总览功能 - 全面测试脚本 +测试覆盖:账户 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)