Compare commits
16 Commits
3c03866021
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| bee9658b2d | |||
|
|
4ce7de48c4 | ||
|
|
4ee1e39454 | ||
|
|
5048de4fa1 | ||
|
|
0ab719500b | ||
|
|
944d20dcc7 | ||
|
|
f838840bda | ||
|
|
bfdf0c9987 | ||
|
|
5af8cb5486 | ||
|
|
0bca9e6654 | ||
|
|
9d4d869d57 | ||
|
|
1047bcece9 | ||
|
|
58559064d2 | ||
|
|
e3f73048a7 | ||
|
|
9c5ef36fe8 | ||
|
|
5f23b8ef5b |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -72,3 +72,7 @@ check_*.py
|
|||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
CLAUDE.md
|
||||||
|
AGENTS.md
|
||||||
|
|||||||
81
AGENTS.md
81
AGENTS.md
@@ -1,81 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
## Project overview
|
|
||||||
- Full-stack todo app: **Vue 3 + Element Plus** (WebUI/) + **FastAPI + SQLAlchemy** (api/)
|
|
||||||
- **Python 3.10+, Node 18+**; SQLite database
|
|
||||||
- Single `master` branch, no CI/CD
|
|
||||||
|
|
||||||
## Quick commands
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Install Python deps (required before first run)
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Full-stack one-shot (builds frontend, then starts backend on :23994)
|
|
||||||
python main.py
|
|
||||||
|
|
||||||
# Frontend dev only (hot-reload on :5173, proxies /api → :23994)
|
|
||||||
cd WebUI; npm run dev
|
|
||||||
|
|
||||||
# Backend only
|
|
||||||
cd api; uvicorn app.main:app --host 0.0.0.0 --port 23994
|
|
||||||
|
|
||||||
# Type-check frontend (noEmit; uses project references tsconfig)
|
|
||||||
cd WebUI; npm run typecheck
|
|
||||||
|
|
||||||
# Run the one hand-written test (backend must be running on :23994)
|
|
||||||
python tests/test_accounts.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**Build order matters:** `python main.py` automatically compiles WebUI (`npm install` + `npm run build`), copies `WebUI/dist/` → `api/webui/`, then starts uvicorn. It checks timestamps to skip rebuilds when frontend is unchanged.
|
|
||||||
|
|
||||||
**Import path:** `main.py` injects `api/` into `sys.path` (no `os.chdir`). Backend imports resolve relative to `api/` — e.g. `from app.config import ...`, not `from api.app.config import ...`.
|
|
||||||
|
|
||||||
**Port conflict:** `main.py` will auto-kill any process already listening on port 23994 before starting. If you have another instance running, it will be terminated without warning.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Backend (`api/app/`)
|
|
||||||
- **Config is hardcoded** in `api/app/config.py` — no `.env`, no `os.getenv()`. Port `23994`, CORS origins `["http://localhost:5173", "http://localhost:23994"]`. Note: `pydantic-settings` is in `requirements.txt` but unused.
|
|
||||||
- **SQLite path** is computed relative to `api/` via `__file__` — safe regardless of cwd. `connect_args={"check_same_thread": False}` for FastAPI async compatibility.
|
|
||||||
- **`database.py:init_db()`** auto-creates tables on startup (`create_all`) and auto-adds missing columns via `ALTER TABLE ADD COLUMN` (no Alembic). Columns that are non-nullable with no default are skipped.
|
|
||||||
- **UserSettings is a singleton**: always id=1, auto-created on first `GET`.
|
|
||||||
- **Account balance changes** auto-create `AccountHistory` records in `update_balance()`.
|
|
||||||
- **Habit checkins for the same day** accumulate count (not new rows), enforced by a `(habit_id, checkin_date)` unique constraint.
|
|
||||||
- **Anniversaries / DebtInstallments** have computed fields (`next_date`, `days_until`, `year_count` / `remaining_periods`) calculated at request time, not stored in DB.
|
|
||||||
- **`task_tags` M2M table** is defined in `models/tag.py` (not `models/task.py`).
|
|
||||||
- **Update schemas** use `clearable_fields` + `exclude_unset=True` to distinguish "field not sent" from "field sent as null".
|
|
||||||
- **JWT authentication** — mandatory for all `/api/*` routes except `/api/auth/*` and `/health`. Default password: `elysia`. Login via `POST /api/auth/login`. Token key in localStorage: `elysia_auth_token`. Frontend axios interceptor auto-attaches `Authorization: Bearer <token>`.
|
|
||||||
|
|
||||||
### Frontend (`WebUI/`)
|
|
||||||
- Vue Router uses `createWebHistory()` (HTML5 history mode) — **requires the backend SPA fallback** (`/{full_path:path}` → `index.html`).
|
|
||||||
- Vite dev proxy forwards `/api` → `http://localhost:23994`.
|
|
||||||
- `@` alias maps to `src/`.
|
|
||||||
- Global styles in SCSS (`src/styles/`).
|
|
||||||
- 8 Pinia stores; Element Plus icons registered globally in `main.ts`.
|
|
||||||
- Element Plus uses Chinese locale (`zh-cn`).
|
|
||||||
|
|
||||||
### Route registration order matters
|
|
||||||
The `/health` endpoint must be registered **before** the `/{full_path:path}` SPA fallback catch-all, otherwise `/health` requests return `index.html`. This is enforced in `api/app/main.py:114` — do not reorder these registrations.
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
- `Dockerfile` copies pre-built `api/webui/` — you must build frontend before `docker build`.
|
|
||||||
- Docker CMD is an inline `python -c` one-liner that injects `api/` into `sys.path` and starts uvicorn. No separate entrypoint script.
|
|
||||||
- `docker-compose.yml` mounts `api/data/` and `api/logs/` for persistence; `api/webui/` is read-only.
|
|
||||||
|
|
||||||
## Testing quirks
|
|
||||||
- Only one test file: `tests/test_accounts.py` — **hand-written, no framework** (not pytest). It counts pass/fail manually and uses `requests` directly against `localhost:23994`.
|
|
||||||
- Backend must be running before executing tests.
|
|
||||||
- **The test permanently mutates the database** — Section 15 deliberately leaves test data in place for UI display. Run it on a disposable database copy or reset manually if you need a clean state.
|
|
||||||
- **The test sends no auth headers** and will fail with 401 if JWT auth is enforced. You must first `POST /api/auth/login` with `{"password": "elysia"}` to get a token, then include `Authorization: Bearer <token>` in requests, or temporarily comment out the auth middleware for testing.
|
|
||||||
- No test coverage for tasks, habits, anniversaries, or tags.
|
|
||||||
|
|
||||||
## What's missing (agents should not assume)
|
|
||||||
- No linter, formatter, pre-commit hooks, or CI/CD
|
|
||||||
- No `.env` or environment variable loading
|
|
||||||
- No database migrations framework (Alembic)
|
|
||||||
|
|
||||||
## Additional notes
|
|
||||||
- Swagger UI at `/docs` when backend is running — the live, auto-generated API reference.
|
|
||||||
- `python-multipart` in `requirements.txt` is required for FastAPI to parse form data (not optional).
|
|
||||||
- `sass` is a runtime `dependency` (not `devDependency`) in `package.json` — unusual, but intentional.
|
|
||||||
@@ -15,8 +15,8 @@ COPY api/ ./api/
|
|||||||
# 拷贝编译后的前端产物
|
# 拷贝编译后的前端产物
|
||||||
COPY api/webui/ ./api/webui/
|
COPY api/webui/ ./api/webui/
|
||||||
|
|
||||||
# 创建数据和日志目录
|
# 创建日志目录
|
||||||
RUN mkdir -p api/data api/logs
|
RUN mkdir -p api/logs
|
||||||
|
|
||||||
EXPOSE 23994
|
EXPOSE 23994
|
||||||
|
|
||||||
|
|||||||
298
README.md
298
README.md
@@ -1,173 +1,215 @@
|
|||||||
# Elysia ToDo - 爱莉希雅待办事项
|
# Elysia ToDo - 爱莉希雅待办事项
|
||||||
|
|
||||||
一款全栈个人信息管理应用,集待办任务、习惯打卡、纪念日提醒、资产总览于一体。
|
Elysia ToDo 是一个全栈个人信息管理应用,当前代码包含待办任务、习惯打卡、纪念日、长期目标、证书管理、数据备份导入、WebDAV 同步和单用户密码登录能力。
|
||||||
|
|
||||||
## 功能概览
|
## 功能概览
|
||||||
|
|
||||||
### 任务管理
|
### 任务管理
|
||||||
- **待办列表** — 创建、编辑、删除任务,支持分类、标签、优先级
|
|
||||||
- **四象限视图** — 基于艾森豪威尔矩阵(重要/紧急)的四象限优先级模型
|
- 待办列表:创建、编辑、删除、完成切换、分类、标签、截止时间和优先级。
|
||||||
- **日历视图** — 按月/周/日查看任务排布
|
- 四象限视图:使用 `q1` 到 `q4` 表示重要/紧急优先级。
|
||||||
- **拼音搜索** — 支持中文拼音快速检索任务和分类
|
- 日历视图:按日历方式查看任务排布。
|
||||||
|
- 拼音搜索:前端使用 `pinyin-pro` 支持中文拼音检索。
|
||||||
|
|
||||||
### 习惯打卡
|
### 习惯打卡
|
||||||
- 习惯分组管理(学习、运动、生活等)
|
|
||||||
- 每日打卡记录,支持周期配置与休息日
|
|
||||||
- 周视图打卡进度展示,一目了然
|
|
||||||
|
|
||||||
### 纪念日管理
|
- 习惯分组管理,支持颜色、图标和排序。
|
||||||
- 自定义纪念日分类
|
- 习惯支持每日/每周频率、目标次数、指定活跃日期和归档状态。
|
||||||
- 支持农历/公历日期
|
- 打卡记录按日期保存,可用于周视图/月视图展示。
|
||||||
- 倒计时提醒,不错过重要日子
|
|
||||||
|
|
||||||
### 资产总览
|
### 纪念日
|
||||||
- 财务账户管理(现金、银行卡、电子钱包等)
|
|
||||||
- 收支记录与历史查询
|
|
||||||
- 分期还款跟踪
|
|
||||||
- 资产汇总统计
|
|
||||||
|
|
||||||
### 系统功能
|
- 纪念日分类管理。
|
||||||
- 偏好设置(站点名称、默认视图等)
|
- 纪念日支持日期、年份、循环标记、提前提醒天数和说明。
|
||||||
- 可折叠侧边栏
|
- 前端提供倒计时、分类筛选和编辑弹窗。
|
||||||
- 响应式布局
|
|
||||||
- SPA 单页应用,History 路由模式
|
### 目标管理
|
||||||
|
|
||||||
|
- 长期目标支持 `milestone` 里程碑模式和 `cumulative` 累计打卡模式。
|
||||||
|
- 目标可设置状态、目标值、输入单位、换算率、目标日期、分类、颜色、图标和排序。
|
||||||
|
- 目标详情支持阶段/里程碑、复盘、关联任务和目标打卡记录。
|
||||||
|
|
||||||
|
### 证书管理
|
||||||
|
|
||||||
|
- 证书分类管理。
|
||||||
|
- 证书支持图片、颁发机构、颁发日期、到期日期、说明和排序。
|
||||||
|
|
||||||
|
### 系统与数据
|
||||||
|
|
||||||
|
- 首次设置密码、登录、退出、修改密码,使用 Cookie 中的 JWT 做会话认证。
|
||||||
|
- 用户偏好设置:昵称、头像、签名、生日、邮箱、站点名称、主题、默认视图和默认排序。
|
||||||
|
- 数据备份导出和导入:通过 `/api/backup/export` 与 `/api/backup/import` 处理 JSON 备份。
|
||||||
|
- WebDAV 同步:支持配置、测试连接、推送、拉取、双向同步、状态查询和清空远端同步数据。
|
||||||
|
- SPA 单页应用,后端静态服务支持 History 路由回退。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
| 层级 | 技术 |
|
| 层级 | 当前实现 |
|
||||||
|------|------|
|
|------|----------|
|
||||||
| 前端框架 | Vue 3 + TypeScript |
|
| 前端框架 | Vue 3 + TypeScript |
|
||||||
| UI 组件库 | Element Plus |
|
| UI 组件库 | Element Plus |
|
||||||
| 状态管理 | Pinia |
|
| 状态管理 | Pinia |
|
||||||
| 路由 | Vue Router 4 |
|
| 路由 | Vue Router 4 |
|
||||||
| 构建工具 | Vite |
|
| 构建工具 | Vite 7 |
|
||||||
|
| HTTP 客户端 | Axios |
|
||||||
| 后端框架 | FastAPI |
|
| 后端框架 | FastAPI |
|
||||||
| ORM | SQLAlchemy |
|
| ORM | SQLAlchemy 2 |
|
||||||
| 数据库 | SQLite |
|
| 数据库 | PostgreSQL,默认由 `DATABASE_URL` 指定 |
|
||||||
| ASGI 服务器 | Uvicorn |
|
| ASGI 服务器 | Uvicorn |
|
||||||
|
| 密码与令牌 | bcrypt + python-jose |
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```text
|
||||||
ToDoList/
|
ToDoList/
|
||||||
├── main.py # 启动入口(编译前端 + 启动后端)
|
├── main.py # 编译前端、复制静态资源、启动后端
|
||||||
├── requirements.txt # Python 依赖
|
├── requirements.txt # Python 依赖锁定
|
||||||
├── .gitignore
|
├── Dockerfile # 后端镜像,依赖预构建的 api/webui
|
||||||
├── api/ # 后端
|
├── docker-compose.yml # PostgreSQL + 应用服务
|
||||||
│ └── app/
|
├── scripts/
|
||||||
│ ├── config.py # 配置(端口、路径、CORS 等)
|
│ ├── start.bat # Windows CMD 启动脚本
|
||||||
│ ├── database.py # 数据库引擎与会话管理
|
│ └── stop.bat # Windows CMD 停止脚本
|
||||||
│ ├── main.py # FastAPI 应用(路由、中间件、静态文件)
|
├── api/
|
||||||
│ ├── models/ # SQLAlchemy 数据模型
|
│ ├── app/
|
||||||
│ ├── schemas/ # Pydantic 请求/响应模型
|
│ │ ├── config.py # 数据库、端口、CORS、日志和 JWT 配置
|
||||||
│ ├── routers/ # API 路由
|
│ │ ├── database.py # SQLAlchemy 引擎、会话、建表和轻量迁移
|
||||||
│ └── utils/ # 工具函数(CRUD、日志、日期)
|
│ │ ├── main.py # FastAPI 应用、中间件、路由和静态文件
|
||||||
├── WebUI/ # 前端
|
│ │ ├── models/ # SQLAlchemy 数据模型
|
||||||
│ ├── package.json
|
│ │ ├── schemas/ # Pydantic 请求/响应模型
|
||||||
│ ├── vite.config.ts
|
│ │ ├── routers/ # API 路由
|
||||||
│ └── src/
|
│ │ └── utils/ # 日志、认证、同步、WebDAV、加密等工具
|
||||||
│ ├── api/ # Axios 接口封装
|
│ └── webui/ # 已构建前端静态资源,启动时由 main.py 覆盖
|
||||||
│ ├── components/ # 通用组件
|
└── WebUI/
|
||||||
│ ├── views/ # 页面视图
|
├── package.json
|
||||||
│ ├── stores/ # Pinia 状态管理
|
├── package-lock.json
|
||||||
│ ├── router/ # 路由配置
|
├── vite.config.ts
|
||||||
│ ├── styles/ # 全局样式 (SCSS)
|
└── src/
|
||||||
│ └── utils/ # 前端工具(拼音、优先级、日期)
|
├── api/ # Axios 接口封装
|
||||||
└── tests/ # 测试
|
├── components/ # 复用组件和弹窗
|
||||||
|
├── router/ # 路由和认证守卫
|
||||||
|
├── stores/ # Pinia 状态
|
||||||
|
├── styles/ # 全局 SCSS
|
||||||
|
├── utils/ # 日期、拼音、优先级工具
|
||||||
|
└── views/ # 页面视图
|
||||||
```
|
```
|
||||||
|
|
||||||
## 快速开始
|
当前仓库只保留根目录 `README.md` 作为统一文档入口;没有 `tests/` 目录,也没有独立的测试脚本。
|
||||||
|
|
||||||
### 环境要求
|
## 环境要求
|
||||||
|
|
||||||
- Python 3.10+
|
- Windows CMD 是默认运行环境。
|
||||||
- Node.js 18+
|
- Python 3.10+。当前本机检查到的解释器是 Python 3.11.0。
|
||||||
- npm
|
- Node.js 满足 Vite 7 要求:`^20.19.0` 或 `>=22.12.0`。
|
||||||
|
- npm。Windows 下优先使用 `npm.cmd`,避免 PowerShell shim 路径问题。
|
||||||
|
- PostgreSQL。默认连接由 `api/app/config.py` 中的 `DATABASE_URL` 读取,推荐通过环境变量覆盖。
|
||||||
|
|
||||||
### 安装与运行
|
## 启动与停止
|
||||||
|
|
||||||
```bash
|
官方启动入口是脚本,不建议把 `python`、`npm`、`uvicorn` 作为日常手工启动方式。
|
||||||
# 1. 克隆项目
|
|
||||||
git clone <your-repo-url>
|
|
||||||
cd ToDoList
|
|
||||||
|
|
||||||
# 2. 安装 Python 依赖
|
```cmd
|
||||||
pip install -r requirements.txt
|
scripts\start.bat
|
||||||
|
```
|
||||||
|
|
||||||
# 3. 一键启动(自动编译前端 + 启动后端)
|
`scripts\start.bat` 会执行以下动作:
|
||||||
python main.py
|
|
||||||
|
1. 切换到项目根目录。
|
||||||
|
2. 检查 Python 和项目文件。
|
||||||
|
3. 首次运行时安装 Python 依赖。
|
||||||
|
4. 检查并释放 `23994` 端口。
|
||||||
|
5. 运行 `main.py`。
|
||||||
|
6. `main.py` 会按需执行前端依赖安装、`npm.cmd run build`、复制 `WebUI/dist` 到 `api/webui`,再启动 FastAPI。
|
||||||
|
|
||||||
|
停止服务:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
scripts\stop.bat
|
||||||
```
|
```
|
||||||
|
|
||||||
启动后访问:
|
启动后访问:
|
||||||
|
|
||||||
- 前端页面:http://localhost:23994
|
- 前端页面:http://localhost:23994
|
||||||
- API 文档:http://localhost:23994/docs
|
- API 文档:http://localhost:23994/docs
|
||||||
|
- 健康检查:http://localhost:23994/health
|
||||||
### 前端开发模式
|
|
||||||
|
|
||||||
如果需要前后端分离开发(热更新):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 终端 1 — 启动后端
|
|
||||||
cd api
|
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 23994
|
|
||||||
|
|
||||||
# 终端 2 — 启动前端开发服务器
|
|
||||||
cd WebUI
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
前端开发服务器运行在 http://localhost:5173,已配置 API 代理到后端。
|
|
||||||
|
|
||||||
## API 概览
|
|
||||||
|
|
||||||
所有接口均以 `/api` 为前缀,启动后端后访问 `/docs` 查看 Swagger 文档。
|
|
||||||
|
|
||||||
| 模块 | 前缀 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| 任务 | `/api/tasks` | 任务 CRUD、状态切换、批量操作 |
|
|
||||||
| 分类 | `/api/categories` | 分类 CRUD |
|
|
||||||
| 标签 | `/api/tags` | 标签 CRUD |
|
|
||||||
| 习惯 | `/api/habits` | 习惯、习惯组、打卡记录 |
|
|
||||||
| 纪念日 | `/api/anniversaries` | 纪念日、纪念日分类 |
|
|
||||||
| 资产 | `/api/accounts` | 账户、交易记录、分期还款 |
|
|
||||||
| 设置 | `/api/user-settings` | 用户偏好设置 |
|
|
||||||
| 健康检查 | `/health` | 服务状态检查 |
|
|
||||||
|
|
||||||
## 数据模型关系
|
|
||||||
|
|
||||||
```
|
|
||||||
Category ──< Task >── Tag
|
|
||||||
│
|
|
||||||
HabitGroup ──< Habit ──< HabitCheckin
|
|
||||||
|
|
||||||
AnniversaryCategory ──< Anniversary
|
|
||||||
|
|
||||||
FinancialAccount ──< AccountHistory
|
|
||||||
──< DebtInstallment
|
|
||||||
|
|
||||||
UserSettings (单例)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 配置说明
|
## 配置说明
|
||||||
|
|
||||||
在 `api/app/config.py` 中可以修改:
|
主要配置位于 `api/app/config.py`。
|
||||||
|
|
||||||
| 配置项 | 默认值 | 说明 |
|
| 配置项 | 当前默认值 | 说明 |
|
||||||
|--------|--------|------|
|
|--------|------------|------|
|
||||||
| HOST | `0.0.0.0` | 监听地址 |
|
| `DATABASE_URL` | 从环境变量读取,否则使用内置 PostgreSQL 连接 | SQLAlchemy 数据库连接串 |
|
||||||
| PORT | `23994` | 服务端口 |
|
| `WEBUI_PATH` | `api/webui` | 后端挂载的前端静态资源目录 |
|
||||||
| DATABASE_PATH | `api/data/todo.db` | SQLite 数据库路径 |
|
| `CORS_ORIGINS` | `http://localhost:5173`、`http://localhost:23994` | 允许的跨域来源 |
|
||||||
| WEBUI_PATH | `api/webui` | 前端静态文件目录 |
|
| `LOG_LEVEL` | `INFO` | 应用日志级别 |
|
||||||
| CORS_ORIGINS | `localhost:5173, 23994` | 允许的跨域来源 |
|
| `LOG_DIR` | `api/logs` | 文件日志目录 |
|
||||||
|
| `HOST` | `0.0.0.0` | 服务监听地址 |
|
||||||
|
| `PORT` | `23994` | 服务端口 |
|
||||||
|
| `JWT_SECRET` | 首次启动写入 `api/data/.jwt_secret` | JWT 和 WebDAV 密码加密派生密钥 |
|
||||||
|
|
||||||
数据库文件和日志文件会在首次运行时自动创建,无需手动初始化。
|
运行时目录:
|
||||||
|
|
||||||
## 部署
|
- `api/logs/app.log`:应用日志,按天轮转。
|
||||||
|
- `api/data/.jwt_secret`:本机 JWT 密钥文件。
|
||||||
|
- `api/data/backups/`:WebDAV 拉取前的本地 JSON 快照。
|
||||||
|
|
||||||
项目支持在 Windows 和 Linux 上运行。`main.py` 会自动处理平台差异(npm 命令、端口占用检测等)。
|
## API 概览
|
||||||
|
|
||||||
对于生产环境部署,建议:
|
除 `/health` 外,业务接口均在 `/api` 下。大多数 `/api/*` 接口需要登录 Cookie。
|
||||||
- 使用 `gunicorn` + `uvicorn worker` 替代直接运行
|
|
||||||
- 配置反向代理(Nginx)
|
| 模块 | 路径 | 说明 |
|
||||||
- 数据库可替换为 PostgreSQL 或 MySQL(修改 `config.py` 中的 `DATABASE_URL`)
|
|------|------|------|
|
||||||
|
| 认证 | `/api/auth/status`、`/api/auth/setup`、`/api/auth/login`、`/api/auth/logout`、`/api/auth/me`、`/api/auth/change-password` | 首次设置、登录状态、登录退出和改密 |
|
||||||
|
| 任务 | `/api/tasks`、`/api/tasks/{task_id}/toggle` | 任务 CRUD 和完成切换 |
|
||||||
|
| 分类 | `/api/categories` | 任务分类 CRUD |
|
||||||
|
| 标签 | `/api/tags` | 标签列表、创建、删除 |
|
||||||
|
| 习惯分组 | `/api/habit-groups` | 习惯分组 CRUD |
|
||||||
|
| 习惯 | `/api/habits`、`/api/habits/{habit_id}/checkins` | 习惯 CRUD、归档和打卡 |
|
||||||
|
| 纪念日 | `/api/anniversary-categories`、`/api/anniversaries` | 纪念日分类和纪念日 CRUD |
|
||||||
|
| 目标 | `/api/goals` | 目标、步骤、复盘、关联任务和目标打卡 |
|
||||||
|
| 证书 | `/api/certificate-categories`、`/api/certificates` | 证书分类和证书 CRUD |
|
||||||
|
| 用户设置 | `/api/user-settings` | 用户偏好获取和更新 |
|
||||||
|
| 备份 | `/api/backup/export`、`/api/backup/import` | JSON 备份导出和覆盖导入 |
|
||||||
|
| WebDAV 同步 | `/api/sync/config`、`/api/sync/test`、`/api/sync/push`、`/api/sync/pull`、`/api/sync/sync`、`/api/sync/status`、`/api/sync/remote` | 同步配置和操作 |
|
||||||
|
| 健康检查 | `/health` | 服务状态 |
|
||||||
|
|
||||||
|
## 数据模型关系
|
||||||
|
|
||||||
|
```text
|
||||||
|
Category 1 -> N Task
|
||||||
|
Task N <-> N Tag
|
||||||
|
|
||||||
|
HabitGroup 1 -> N Habit
|
||||||
|
Habit 1 -> N HabitCheckin
|
||||||
|
|
||||||
|
AnniversaryCategory 1 -> N Anniversary
|
||||||
|
|
||||||
|
Category 1 -> N Goal
|
||||||
|
Goal 1 -> N GoalStep
|
||||||
|
GoalStep 1 -> N GoalStep
|
||||||
|
Goal 1 -> N GoalReview
|
||||||
|
Goal 1 -> N GoalCheckin
|
||||||
|
Goal N <-> N Task
|
||||||
|
|
||||||
|
CertificateCategory 1 -> N Certificate
|
||||||
|
|
||||||
|
UserSettings 1 -> 1
|
||||||
|
SyncSettings 1 -> 1
|
||||||
|
```
|
||||||
|
|
||||||
|
可同步模型使用 `uuid`、`sync_version`、`is_deleted` 支持 WebDAV 同步和软删除传播。
|
||||||
|
|
||||||
|
## Docker 部署
|
||||||
|
|
||||||
|
`docker-compose.yml` 包含两个服务:
|
||||||
|
|
||||||
|
- `db`:PostgreSQL,容器名 `elysia-todo-db`。
|
||||||
|
- `elysia-todo`:应用服务,容器名 `elysia-todo`,对外暴露 `23994`。
|
||||||
|
|
||||||
|
注意:`Dockerfile` 只复制 `api/` 和已经存在的 `api/webui/`,不会在镜像构建时编译 `WebUI/`。构建镜像前需要先通过启动流程或构建流程生成前端产物并复制到 `api/webui`。
|
||||||
|
|
||||||
|
## 当前已知差异
|
||||||
|
|
||||||
|
- `requirements.txt` 当前未显式列出 `requests`,但 `api/app/utils/webdav.py` 会导入并使用它。干净环境如果报 `ModuleNotFoundError: No module named 'requests'`,需要先补齐依赖配置。
|
||||||
|
- 连接串、容器数据库密码等敏感配置当前存在于仓库配置文件中。对外发布或多人协作前,建议改为环境变量和 `.env.example`。
|
||||||
|
- 仓库当前没有测试目录和测试脚本。新增功能时应先补 `scripts/` 下的验证脚本,再通过脚本执行验证。
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
# Vue 3 + TypeScript + Vite
|
|
||||||
|
|
||||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
|
||||||
|
|
||||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
|
||||||
@@ -24,7 +24,7 @@ const authStore = useAuthStore()
|
|||||||
// 路由变化时同步 currentView
|
// 路由变化时同步 currentView
|
||||||
watch(() => route.meta.view, (view) => {
|
watch(() => route.meta.view, (view) => {
|
||||||
if (view) {
|
if (view) {
|
||||||
uiStore.setCurrentView(view as 'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries' | 'assets')
|
uiStore.setCurrentView(view as 'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries')
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<el-config-provider :locale="zhCn">
|
<el-config-provider :locale="zhCn">
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<template v-if="route.name !== 'login'">
|
<template v-if="route.name !== 'login' && route.name !== 'setup'">
|
||||||
<div class="decoration-star" style="top: 20%; right: 8%; animation-delay: 0.5s;"></div>
|
<div class="decoration-star" style="top: 20%; right: 8%; animation-delay: 0.5s;"></div>
|
||||||
<div class="decoration-star" style="top: 60%; left: 3%; animation-delay: 1s;"></div>
|
<div class="decoration-star" style="top: 60%; left: 3%; animation-delay: 1s;"></div>
|
||||||
<div class="decoration-star" style="top: 80%; right: 5%; animation-delay: 1.5s;"></div>
|
<div class="decoration-star" style="top: 80%; right: 5%; animation-delay: 1.5s;"></div>
|
||||||
@@ -89,7 +89,7 @@ onMounted(async () => {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="uiStore.currentView !== 'settings' && uiStore.currentView !== 'profile' && uiStore.currentView !== 'habits' && uiStore.currentView !== 'anniversaries' && uiStore.currentView !== 'assets'" class="fab-container">
|
<div v-if="uiStore.currentView !== 'settings' && uiStore.currentView !== 'profile' && uiStore.currentView !== 'habits' && uiStore.currentView !== 'anniversaries'" class="fab-container">
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
circle
|
circle
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import { get, post, put, del, patch } from './request'
|
|
||||||
import type {
|
|
||||||
FinancialAccount, AccountFormData, BalanceUpdateData,
|
|
||||||
AccountHistoryResponse, DebtInstallment, DebtInstallmentFormData
|
|
||||||
} from './types'
|
|
||||||
|
|
||||||
export interface GetAccountHistoryParams {
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const accountApi = {
|
|
||||||
// ============ 账户 CRUD ============
|
|
||||||
getAccounts(): Promise<FinancialAccount[]> {
|
|
||||||
return get<FinancialAccount[]>('/accounts')
|
|
||||||
},
|
|
||||||
|
|
||||||
getAccount(id: number): Promise<FinancialAccount> {
|
|
||||||
return get<FinancialAccount>(`/accounts/${id}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
createAccount(data: AccountFormData): Promise<FinancialAccount> {
|
|
||||||
return post<FinancialAccount>('/accounts', data)
|
|
||||||
},
|
|
||||||
|
|
||||||
updateAccount(id: number, data: Partial<AccountFormData>): Promise<FinancialAccount> {
|
|
||||||
return put<FinancialAccount>(`/accounts/${id}`, data)
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteAccount(id: number): Promise<{ success: boolean; message?: string }> {
|
|
||||||
return del<{ success: boolean; message?: string }>(`/accounts/${id}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============ 余额操作 ============
|
|
||||||
updateBalance(id: number, data: BalanceUpdateData): Promise<FinancialAccount> {
|
|
||||||
return post<FinancialAccount>(`/accounts/${id}/balance`, data)
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============ 变更历史 ============
|
|
||||||
getHistory(id: number, params?: GetAccountHistoryParams): Promise<AccountHistoryResponse> {
|
|
||||||
return get<AccountHistoryResponse>(`/accounts/${id}/history`, { params })
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============ 分期计划 ============
|
|
||||||
getInstallments(): Promise<DebtInstallment[]> {
|
|
||||||
return get<DebtInstallment[]>('/debt-installments')
|
|
||||||
},
|
|
||||||
|
|
||||||
createInstallment(data: DebtInstallmentFormData): Promise<DebtInstallment> {
|
|
||||||
return post<DebtInstallment>('/debt-installments', data)
|
|
||||||
},
|
|
||||||
|
|
||||||
updateInstallment(id: number, data: Partial<DebtInstallmentFormData>): Promise<DebtInstallment> {
|
|
||||||
return put<DebtInstallment>(`/debt-installments/${id}`, data)
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteInstallment(id: number): Promise<{ success: boolean; message?: string }> {
|
|
||||||
return del<{ success: boolean; message?: string }>(`/debt-installments/${id}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
payInstallment(id: number): Promise<DebtInstallment> {
|
|
||||||
return patch<DebtInstallment>(`/debt-installments/${id}/pay`)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { post } from './request'
|
import { post, get } from './request'
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
access_token: string
|
message: string
|
||||||
token_type: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChangePasswordData {
|
export interface ChangePasswordData {
|
||||||
@@ -10,10 +9,37 @@ export interface ChangePasswordData {
|
|||||||
new_password: string
|
new_password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthStatusResponse {
|
||||||
|
authenticated: boolean
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupStatusResponse {
|
||||||
|
has_password: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export function login(password: string): Promise<LoginResponse> {
|
export function login(password: string): Promise<LoginResponse> {
|
||||||
return post<LoginResponse>('/auth/login', { password })
|
return post<LoginResponse>('/auth/login', { password })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function logout(): Promise<{ message: string }> {
|
||||||
|
return post<{ message: string }>('/auth/logout')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkAuth(): Promise<AuthStatusResponse> {
|
||||||
|
return get<AuthStatusResponse>('/auth/me')
|
||||||
|
}
|
||||||
|
|
||||||
export function changePassword(data: ChangePasswordData): Promise<{ message: string }> {
|
export function changePassword(data: ChangePasswordData): Promise<{ message: string }> {
|
||||||
return post<{ message: string }>('/auth/change-password', data)
|
return post<{ message: string }>('/auth/change-password', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function checkSetupStatus(): Promise<SetupStatusResponse> {
|
||||||
|
return get<SetupStatusResponse>('/auth/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupPassword(password: string, nickname?: string): Promise<{ message: string }> {
|
||||||
|
const data: Record<string, string> = { password }
|
||||||
|
if (nickname) data.nickname = nickname
|
||||||
|
return post<{ message: string }>('/auth/setup', data)
|
||||||
|
}
|
||||||
|
|||||||
13
WebUI/src/api/backup.ts
Normal file
13
WebUI/src/api/backup.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
export function exportBackup(): Promise<Blob> {
|
||||||
|
return request.get('/backup/export', { responseType: 'blob' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importBackup(file: File): Promise<{ message: string; count: number }> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return request.post('/backup/import', form, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
}
|
||||||
46
WebUI/src/api/certificates.ts
Normal file
46
WebUI/src/api/certificates.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { get, post, put, del } from './request'
|
||||||
|
import type {
|
||||||
|
Certificate, CertificateFormData,
|
||||||
|
CertificateCategory, CertificateCategoryFormData,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
// ============ Categories ============
|
||||||
|
|
||||||
|
export function getCategories(): Promise<CertificateCategory[]> {
|
||||||
|
return get<CertificateCategory[]>('/certificate-categories')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCategory(data: CertificateCategoryFormData): Promise<CertificateCategory> {
|
||||||
|
return post<CertificateCategory>('/certificate-categories', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCategory(id: number, data: Partial<CertificateCategoryFormData>): Promise<CertificateCategory> {
|
||||||
|
return put<CertificateCategory>(`/certificate-categories/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCategory(id: number): Promise<{ message: string }> {
|
||||||
|
return del<{ message: string }>(`/certificate-categories/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Certificates ============
|
||||||
|
|
||||||
|
export function getCertificates(categoryId?: number): Promise<Certificate[]> {
|
||||||
|
const params = categoryId ? `?category_id=${categoryId}` : ''
|
||||||
|
return get<Certificate[]>(`/certificates${params}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCertificate(id: number): Promise<Certificate> {
|
||||||
|
return get<Certificate>(`/certificates/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCertificate(data: CertificateFormData): Promise<Certificate> {
|
||||||
|
return post<Certificate>('/certificates', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCertificate(id: number, data: Partial<CertificateFormData>): Promise<Certificate> {
|
||||||
|
return put<Certificate>(`/certificates/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCertificate(id: number): Promise<{ message: string }> {
|
||||||
|
return del<{ message: string }>(`/certificates/${id}`)
|
||||||
|
}
|
||||||
94
WebUI/src/api/goals.ts
Normal file
94
WebUI/src/api/goals.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { get, post, put, del, patch } from './request'
|
||||||
|
import type {
|
||||||
|
Goal, GoalDetail, GoalFormData,
|
||||||
|
GoalStep, GoalStepFormData,
|
||||||
|
GoalReview, GoalReviewFormData,
|
||||||
|
GoalCheckin, GoalCheckinFormData,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
// ============ Goals ============
|
||||||
|
|
||||||
|
export function getGoals(status?: string): Promise<Goal[]> {
|
||||||
|
const params = status ? `?status=${status}` : ''
|
||||||
|
return get<Goal[]>(`/goals${params}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGoal(id: number): Promise<GoalDetail> {
|
||||||
|
return get<GoalDetail>(`/goals/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGoal(data: GoalFormData): Promise<GoalDetail> {
|
||||||
|
return post<GoalDetail>('/goals', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateGoal(id: number, data: Partial<GoalFormData>): Promise<GoalDetail> {
|
||||||
|
return put<GoalDetail>(`/goals/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteGoal(id: number): Promise<{ message: string }> {
|
||||||
|
return del<{ message: string }>(`/goals/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateGoalStatus(id: number, status: string): Promise<GoalDetail> {
|
||||||
|
return patch<GoalDetail>(`/goals/${id}/status`, { status })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Steps ============
|
||||||
|
|
||||||
|
export function createStep(goalId: number, data: GoalStepFormData): Promise<GoalStep> {
|
||||||
|
return post<GoalStep>(`/goals/${goalId}/steps`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateStep(goalId: number, stepId: number, data: Partial<GoalStepFormData>): Promise<GoalStep> {
|
||||||
|
return put<GoalStep>(`/goals/${goalId}/steps/${stepId}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteStep(goalId: number, stepId: number): Promise<{ message: string }> {
|
||||||
|
return del<{ message: string }>(`/goals/${goalId}/steps/${stepId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleStep(goalId: number, stepId: number): Promise<GoalStep> {
|
||||||
|
return patch<GoalStep>(`/goals/${goalId}/steps/${stepId}/toggle`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reorderSteps(goalId: number, items: { id: number; sort_order: number }[]): Promise<{ message: string }> {
|
||||||
|
return put<{ message: string }>(`/goals/${goalId}/steps/reorder`, { items })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Reviews ============
|
||||||
|
|
||||||
|
export function createReview(goalId: number, data: GoalReviewFormData): Promise<GoalReview> {
|
||||||
|
return post<GoalReview>(`/goals/${goalId}/reviews`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteReview(goalId: number, reviewId: number): Promise<{ message: string }> {
|
||||||
|
return del<{ message: string }>(`/goals/${goalId}/reviews/${reviewId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Task Linking ============
|
||||||
|
|
||||||
|
export function linkTask(goalId: number, taskId: number): Promise<{ message: string }> {
|
||||||
|
return post<{ message: string }>(`/goals/${goalId}/tasks/${taskId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unlinkTask(goalId: number, taskId: number): Promise<{ message: string }> {
|
||||||
|
return del<{ message: string }>(`/goals/${goalId}/tasks/${taskId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Checkins (累计打卡) ============
|
||||||
|
|
||||||
|
export function getCheckins(goalId: number): Promise<GoalCheckin[]> {
|
||||||
|
return get<GoalCheckin[]>(`/goals/${goalId}/checkins`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCheckin(goalId: number, data: GoalCheckinFormData): Promise<GoalCheckin> {
|
||||||
|
return post<GoalCheckin>(`/goals/${goalId}/checkins`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCheckin(goalId: number, checkinId: number, data: Partial<GoalCheckinFormData>): Promise<GoalCheckin> {
|
||||||
|
return put<GoalCheckin>(`/goals/${goalId}/checkins/${checkinId}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCheckin(goalId: number, checkinId: number): Promise<{ message: string }> {
|
||||||
|
return del<{ message: string }>(`/goals/${goalId}/checkins/${checkinId}`)
|
||||||
|
}
|
||||||
@@ -1,24 +1,15 @@
|
|||||||
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
|
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
const TOKEN_KEY = 'elysia_auth_token'
|
|
||||||
|
|
||||||
const instance: AxiosInstance = axios.create({
|
const instance: AxiosInstance = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
instance.interceptors.request.use((config) => {
|
|
||||||
const token = localStorage.getItem(TOKEN_KEY)
|
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
})
|
|
||||||
|
|
||||||
instance.interceptors.response.use(
|
instance.interceptors.response.use(
|
||||||
(response: AxiosResponse) => {
|
(response: AxiosResponse) => {
|
||||||
return response
|
return response
|
||||||
@@ -28,7 +19,7 @@ instance.interceptors.response.use(
|
|||||||
if (error.response) {
|
if (error.response) {
|
||||||
const data = error.response.data
|
const data = error.response.data
|
||||||
if (data?.detail) {
|
if (data?.detail) {
|
||||||
message = Array.isArray(data.detail)
|
message = Array.isArray(data.detail)
|
||||||
? data.detail.map((d: any) => d.msg || d.loc?.join('.')).join('; ')
|
? data.detail.map((d: any) => d.msg || d.loc?.join('.')).join('; ')
|
||||||
: data.detail
|
: data.detail
|
||||||
}
|
}
|
||||||
@@ -37,8 +28,10 @@ instance.interceptors.response.use(
|
|||||||
message = data?.detail || '请求参数有误,请检查一下~'
|
message = data?.detail || '请求参数有误,请检查一下~'
|
||||||
break
|
break
|
||||||
case 401:
|
case 401:
|
||||||
|
if (error.config?.url?.includes('/auth/login') || error.config?.url?.includes('/auth/setup')) {
|
||||||
|
break
|
||||||
|
}
|
||||||
message = '登录状态已失效~'
|
message = '登录状态已失效~'
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
case 403:
|
case 403:
|
||||||
@@ -47,6 +40,12 @@ instance.interceptors.response.use(
|
|||||||
case 404:
|
case 404:
|
||||||
message = '请求的资源不存在~'
|
message = '请求的资源不存在~'
|
||||||
break
|
break
|
||||||
|
case 429:
|
||||||
|
message = data?.detail || '请求过于频繁,请稍后再试~'
|
||||||
|
break
|
||||||
|
case 503:
|
||||||
|
message = data?.detail || '正在同步数据,请稍后再试~'
|
||||||
|
break
|
||||||
case 500:
|
case 500:
|
||||||
message = '服务器内部错误~'
|
message = '服务器内部错误~'
|
||||||
break
|
break
|
||||||
|
|||||||
52
WebUI/src/api/sync.ts
Normal file
52
WebUI/src/api/sync.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { get, put, post, del } from './request'
|
||||||
|
|
||||||
|
export interface SyncConfig {
|
||||||
|
webdav_url: string | null
|
||||||
|
webdav_username: string | null
|
||||||
|
webdav_password: string | null
|
||||||
|
webdav_path: string
|
||||||
|
sync_enabled: boolean
|
||||||
|
auto_sync: boolean
|
||||||
|
auto_sync_interval: number
|
||||||
|
last_sync_at: string | null
|
||||||
|
last_sync_version: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncConfigUpdate {
|
||||||
|
webdav_url?: string | null
|
||||||
|
webdav_username?: string | null
|
||||||
|
webdav_password?: string | null
|
||||||
|
webdav_path?: string
|
||||||
|
auto_sync?: boolean
|
||||||
|
auto_sync_interval?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncStatus {
|
||||||
|
syncing: boolean
|
||||||
|
last_sync_at: string | null
|
||||||
|
last_sync_version: number
|
||||||
|
sync_enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncResult {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const syncApi = {
|
||||||
|
getConfig: () => get<SyncConfig>('/sync/config'),
|
||||||
|
|
||||||
|
updateConfig: (data: SyncConfigUpdate) => put<SyncConfig>('/sync/config', data),
|
||||||
|
|
||||||
|
testConnection: () => post<SyncResult>('/sync/test'),
|
||||||
|
|
||||||
|
push: () => post<SyncResult>('/sync/push'),
|
||||||
|
|
||||||
|
pull: () => post<SyncResult>('/sync/pull'),
|
||||||
|
|
||||||
|
sync: () => post<SyncResult>('/sync/sync'),
|
||||||
|
|
||||||
|
getStatus: () => get<SyncStatus>('/sync/status'),
|
||||||
|
|
||||||
|
clearRemote: () => del<SyncResult>('/sync/remote'),
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export type QuadrantPriority = 'q1' | 'q2' | 'q3' | 'q4'
|
|||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: number
|
id: number
|
||||||
|
uuid?: string
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
priority: QuadrantPriority
|
priority: QuadrantPriority
|
||||||
@@ -16,6 +17,7 @@ export interface Task {
|
|||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
id: number
|
id: number
|
||||||
|
uuid?: string
|
||||||
name: string
|
name: string
|
||||||
color: string
|
color: string
|
||||||
icon: string
|
icon: string
|
||||||
@@ -23,6 +25,7 @@ export interface Category {
|
|||||||
|
|
||||||
export interface Tag {
|
export interface Tag {
|
||||||
id: number
|
id: number
|
||||||
|
uuid?: string
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +91,7 @@ export interface UserSettingsUpdate {
|
|||||||
|
|
||||||
export interface HabitGroup {
|
export interface HabitGroup {
|
||||||
id: number
|
id: number
|
||||||
|
uuid?: string
|
||||||
name: string
|
name: string
|
||||||
color: string
|
color: string
|
||||||
icon: string
|
icon: string
|
||||||
@@ -105,6 +109,7 @@ export type HabitFrequency = 'daily' | 'weekly'
|
|||||||
|
|
||||||
export interface Habit {
|
export interface Habit {
|
||||||
id: number
|
id: number
|
||||||
|
uuid?: string
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
group_id?: number
|
group_id?: number
|
||||||
@@ -128,6 +133,7 @@ export interface HabitFormData {
|
|||||||
|
|
||||||
export interface HabitCheckin {
|
export interface HabitCheckin {
|
||||||
id: number
|
id: number
|
||||||
|
uuid?: string
|
||||||
habit_id: number
|
habit_id: number
|
||||||
checkin_date: string
|
checkin_date: string
|
||||||
count: number
|
count: number
|
||||||
@@ -146,6 +152,7 @@ export interface HabitStats {
|
|||||||
|
|
||||||
export interface AnniversaryCategory {
|
export interface AnniversaryCategory {
|
||||||
id: number
|
id: number
|
||||||
|
uuid?: string
|
||||||
name: string
|
name: string
|
||||||
icon: string
|
icon: string
|
||||||
color: string
|
color: string
|
||||||
@@ -161,6 +168,7 @@ export interface AnniversaryCategoryFormData {
|
|||||||
|
|
||||||
export interface Anniversary {
|
export interface Anniversary {
|
||||||
id: number
|
id: number
|
||||||
|
uuid?: string
|
||||||
title: string
|
title: string
|
||||||
date: string
|
date: string
|
||||||
year?: number | null
|
year?: number | null
|
||||||
@@ -186,93 +194,158 @@ export interface AnniversaryFormData {
|
|||||||
remind_days_before: number
|
remind_days_before: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 资产账户相关 ============
|
|
||||||
|
|
||||||
export type AccountType = 'savings' | 'debt'
|
// ============ 目标相关 ============
|
||||||
|
|
||||||
export interface FinancialAccount {
|
export type GoalStatus = 'active' | 'paused' | 'completed' | 'abandoned'
|
||||||
|
export type StepType = 'phase' | 'milestone'
|
||||||
|
export type StepStatus = 'pending' | 'in_progress' | 'completed'
|
||||||
|
export type TrackType = 'milestone' | 'cumulative'
|
||||||
|
|
||||||
|
export interface Goal {
|
||||||
id: number
|
id: number
|
||||||
|
uuid?: string
|
||||||
|
title: string
|
||||||
|
description?: string | null
|
||||||
|
status: GoalStatus
|
||||||
|
track_type: TrackType
|
||||||
|
progress: number
|
||||||
|
target_value?: number | null
|
||||||
|
target_unit?: string | null
|
||||||
|
input_unit?: string | null
|
||||||
|
conversion_rate: number
|
||||||
|
current_value: number
|
||||||
|
target_date?: string | null
|
||||||
|
completed_at?: string | null
|
||||||
|
category_id?: number | null
|
||||||
|
category?: Category | null
|
||||||
|
color: string
|
||||||
|
icon: string
|
||||||
|
sort_order: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
total_steps: number
|
||||||
|
completed_steps: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoalDetail extends Goal {
|
||||||
|
steps: GoalStep[]
|
||||||
|
reviews: GoalReview[]
|
||||||
|
checkins: GoalCheckin[]
|
||||||
|
tasks: Task[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoalStep {
|
||||||
|
id: number
|
||||||
|
uuid?: string
|
||||||
|
goal_id: number
|
||||||
|
parent_id?: number | null
|
||||||
|
title: string
|
||||||
|
step_type: StepType
|
||||||
|
status: StepStatus
|
||||||
|
target_date?: string | null
|
||||||
|
reached_at?: string | null
|
||||||
|
sort_order: number
|
||||||
|
created_at: string
|
||||||
|
children: GoalStep[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoalReview {
|
||||||
|
id: number
|
||||||
|
uuid?: string
|
||||||
|
goal_id: number
|
||||||
|
content: string
|
||||||
|
rating?: number | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoalFormData {
|
||||||
|
title: string
|
||||||
|
description?: string | null
|
||||||
|
status: GoalStatus
|
||||||
|
track_type: TrackType
|
||||||
|
target_value?: number | null
|
||||||
|
target_unit?: string | null
|
||||||
|
input_unit?: string | null
|
||||||
|
conversion_rate: number
|
||||||
|
target_date?: string | null
|
||||||
|
category_id?: number | null
|
||||||
|
color: string
|
||||||
|
icon: string
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoalStepFormData {
|
||||||
|
title: string
|
||||||
|
step_type: StepType
|
||||||
|
status: StepStatus
|
||||||
|
target_date?: string | null
|
||||||
|
parent_id?: number | null
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoalReviewFormData {
|
||||||
|
content: string
|
||||||
|
rating?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoalCheckin {
|
||||||
|
id: number
|
||||||
|
uuid?: string
|
||||||
|
goal_id: number
|
||||||
|
value: number
|
||||||
|
note?: string | null
|
||||||
|
checkin_date: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoalCheckinFormData {
|
||||||
|
value: number
|
||||||
|
note?: string | null
|
||||||
|
checkin_date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 证书相关 ============
|
||||||
|
|
||||||
|
export interface CertificateCategory {
|
||||||
|
id: number
|
||||||
|
uuid?: string
|
||||||
name: string
|
name: string
|
||||||
account_type: AccountType
|
|
||||||
balance: number
|
|
||||||
icon: string
|
icon: string
|
||||||
color: string
|
color: string
|
||||||
sort_order: number
|
sort_order: number
|
||||||
is_active: boolean
|
|
||||||
description?: string | null
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
installments?: InstallmentInfo[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountFormData {
|
export interface CertificateCategoryFormData {
|
||||||
name: string
|
name: string
|
||||||
account_type: AccountType
|
|
||||||
balance: number
|
|
||||||
icon: string
|
icon: string
|
||||||
color: string
|
color: string
|
||||||
sort_order: number
|
sort_order?: number
|
||||||
is_active: boolean
|
}
|
||||||
|
|
||||||
|
export interface Certificate {
|
||||||
|
id: number
|
||||||
|
uuid?: string
|
||||||
|
title: string
|
||||||
|
category_id?: number | null
|
||||||
|
image?: string | null
|
||||||
|
issuer?: string | null
|
||||||
|
issue_date?: string | null
|
||||||
|
expiry_date?: string | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
}
|
sort_order: number
|
||||||
|
|
||||||
export interface BalanceUpdateData {
|
|
||||||
new_balance: number
|
|
||||||
note?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InstallmentInfo {
|
|
||||||
next_payment_date: string | null
|
|
||||||
days_until_payment: number | null
|
|
||||||
remaining_periods: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AccountHistoryRecord {
|
|
||||||
id: number
|
|
||||||
account_id: number
|
|
||||||
change_amount: number
|
|
||||||
balance_before: number
|
|
||||||
balance_after: number
|
|
||||||
note?: string | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AccountHistoryResponse {
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
page_size: number
|
|
||||||
records: AccountHistoryRecord[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 分期还款计划相关 ============
|
|
||||||
|
|
||||||
export interface DebtInstallment {
|
|
||||||
id: number
|
|
||||||
account_id: number
|
|
||||||
total_amount: number
|
|
||||||
total_periods: number
|
|
||||||
current_period: number
|
|
||||||
payment_day: number
|
|
||||||
payment_amount: number
|
|
||||||
start_date: string
|
|
||||||
is_completed: boolean
|
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
next_payment_date: string | null
|
category?: CertificateCategory | null
|
||||||
days_until_payment: number | null
|
|
||||||
remaining_periods: number | null
|
|
||||||
account_name: string | null
|
|
||||||
account_icon: string | null
|
|
||||||
account_color: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DebtInstallmentFormData {
|
export interface CertificateFormData {
|
||||||
account_id: number
|
title: string
|
||||||
total_amount: number
|
category_id?: number | null
|
||||||
total_periods: number
|
image?: string | null
|
||||||
current_period: number
|
issuer?: string | null
|
||||||
payment_day: number
|
issue_date?: string | null
|
||||||
payment_amount: number
|
expiry_date?: string | null
|
||||||
start_date: string
|
description?: string | null
|
||||||
is_completed: boolean
|
sort_order?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,361 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
import { useAccountStore } from '@/stores/useAccountStore'
|
|
||||||
import type { FinancialAccount } from '@/api/types'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
visible: boolean
|
|
||||||
editAccount?: FinancialAccount | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
editAccount: null
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:visible', val: boolean): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const store = useAccountStore()
|
|
||||||
|
|
||||||
const isEdit = computed(() => !!props.editAccount)
|
|
||||||
const dialogTitle = computed(() => isEdit.value ? '编辑账户' : '新建账户')
|
|
||||||
|
|
||||||
const form = ref({
|
|
||||||
name: '',
|
|
||||||
account_type: 'savings' as 'savings' | 'debt',
|
|
||||||
balance: 0,
|
|
||||||
icon: 'wallet',
|
|
||||||
color: '#FFB7C5',
|
|
||||||
is_active: true,
|
|
||||||
description: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const iconOptions = [
|
|
||||||
{ label: '钱包', value: 'wallet' },
|
|
||||||
{ label: '微信', value: 'wechat' },
|
|
||||||
{ label: '支付宝', value: 'alipay' },
|
|
||||||
{ label: '银行卡', value: 'bank' },
|
|
||||||
{ label: '信用卡', value: 'credit-card' },
|
|
||||||
{ label: '花呗', value: 'huabei' },
|
|
||||||
{ label: '白条', value: 'baitiao' },
|
|
||||||
{ label: '现金', value: 'cash' },
|
|
||||||
{ label: '投资', value: 'investment' },
|
|
||||||
{ label: '其他', value: 'other' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const colorOptions = [
|
|
||||||
'#FFB7C5', '#C8A2C8', '#98D8C8', '#FFB347',
|
|
||||||
'#87CEEB', '#FF6B6B', '#A8E6CF', '#DDA0DD',
|
|
||||||
'#F0E68C', '#20B2AA', '#FF8C69', '#9370DB',
|
|
||||||
]
|
|
||||||
|
|
||||||
watch(() => props.visible, (val) => {
|
|
||||||
if (val) {
|
|
||||||
if (props.editAccount) {
|
|
||||||
const a = props.editAccount
|
|
||||||
form.value = {
|
|
||||||
name: a.name,
|
|
||||||
account_type: a.account_type,
|
|
||||||
balance: a.balance,
|
|
||||||
icon: a.icon,
|
|
||||||
color: a.color,
|
|
||||||
is_active: a.is_active,
|
|
||||||
description: a.description || ''
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
form.value = {
|
|
||||||
name: '',
|
|
||||||
account_type: 'savings',
|
|
||||||
balance: 0,
|
|
||||||
icon: 'wallet',
|
|
||||||
color: '#FFB7C5',
|
|
||||||
is_active: true,
|
|
||||||
description: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
if (!form.value.name.trim()) {
|
|
||||||
ElMessage.warning('请输入账户名称~')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
name: form.value.name.trim(),
|
|
||||||
account_type: form.value.account_type,
|
|
||||||
balance: form.value.balance,
|
|
||||||
icon: form.value.icon,
|
|
||||||
color: form.value.color,
|
|
||||||
sort_order: 0,
|
|
||||||
is_active: form.value.is_active,
|
|
||||||
description: form.value.description.trim() || null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEdit.value && props.editAccount) {
|
|
||||||
const result = await store.updateAccount(props.editAccount.id, data)
|
|
||||||
if (result) ElMessage.success('账户更新成功~')
|
|
||||||
} else {
|
|
||||||
const result = await store.createAccount(data)
|
|
||||||
if (result) ElMessage.success('账户创建成功~')
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('update:visible', false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
emit('update:visible', false)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<el-dialog
|
|
||||||
:model-value="visible"
|
|
||||||
:title="dialogTitle"
|
|
||||||
width="480px"
|
|
||||||
@close="handleClose"
|
|
||||||
class="account-dialog"
|
|
||||||
>
|
|
||||||
<div class="form-content">
|
|
||||||
<div class="form-item">
|
|
||||||
<label class="form-label">账户名称</label>
|
|
||||||
<el-input
|
|
||||||
v-model="form.name"
|
|
||||||
placeholder="如:微信、支付宝、花呗、招商银行..."
|
|
||||||
maxlength="100"
|
|
||||||
show-word-limit
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-item">
|
|
||||||
<label class="form-label">账户类型</label>
|
|
||||||
<div class="type-switch">
|
|
||||||
<button
|
|
||||||
class="type-btn"
|
|
||||||
:class="{ active: form.account_type === 'savings' }"
|
|
||||||
@click="form.account_type = 'savings'"
|
|
||||||
>
|
|
||||||
<el-icon><Wallet /></el-icon>
|
|
||||||
<span>存款</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="type-btn"
|
|
||||||
:class="{ active: form.account_type === 'debt' }"
|
|
||||||
@click="form.account_type = 'debt'"
|
|
||||||
>
|
|
||||||
<el-icon><CreditCard /></el-icon>
|
|
||||||
<span>欠款</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-item">
|
|
||||||
<label class="form-label">
|
|
||||||
当前余额
|
|
||||||
<span class="form-hint">({{ form.account_type === 'savings' ? '存款金额' : '欠款金额' }})</span>
|
|
||||||
</label>
|
|
||||||
<el-input-number
|
|
||||||
v-model="form.balance"
|
|
||||||
:precision="2"
|
|
||||||
:step="100"
|
|
||||||
:min="0"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-item">
|
|
||||||
<label class="form-label">图标</label>
|
|
||||||
<div class="icon-grid">
|
|
||||||
<button
|
|
||||||
v-for="opt in iconOptions"
|
|
||||||
:key="opt.value"
|
|
||||||
class="icon-btn"
|
|
||||||
:class="{ active: form.icon === opt.value }"
|
|
||||||
@click="form.icon = opt.value"
|
|
||||||
:title="opt.label"
|
|
||||||
>
|
|
||||||
<el-icon :size="18">
|
|
||||||
<Wallet v-if="opt.value === 'wallet'" />
|
|
||||||
<ChatDotRound v-else-if="opt.value === 'wechat'" />
|
|
||||||
<ShoppingCart v-else-if="opt.value === 'alipay'" />
|
|
||||||
<CreditCard v-else-if="opt.value === 'bank' || opt.value === 'credit-card' || opt.value === 'huabei'" />
|
|
||||||
<Ticket v-else-if="opt.value === 'baitiao'" />
|
|
||||||
<Money v-else-if="opt.value === 'cash'" />
|
|
||||||
<TrendCharts v-else-if="opt.value === 'investment'" />
|
|
||||||
<MoreFilled v-else />
|
|
||||||
</el-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-item">
|
|
||||||
<label class="form-label">主题色</label>
|
|
||||||
<div class="color-grid">
|
|
||||||
<button
|
|
||||||
v-for="color in colorOptions"
|
|
||||||
:key="color"
|
|
||||||
class="color-btn"
|
|
||||||
:class="{ active: form.color === color }"
|
|
||||||
:style="{ background: color }"
|
|
||||||
@click="form.color = color"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-item">
|
|
||||||
<label class="form-label">备注</label>
|
|
||||||
<el-input
|
|
||||||
v-model="form.description"
|
|
||||||
type="textarea"
|
|
||||||
:rows="2"
|
|
||||||
placeholder="可选的备注信息"
|
|
||||||
maxlength="500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isEdit" class="form-item">
|
|
||||||
<label class="form-label">启用状态</label>
|
|
||||||
<el-switch v-model="form.is_active" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<div class="dialog-footer">
|
|
||||||
<el-button @click="handleClose">取消</el-button>
|
|
||||||
<el-button type="primary" @click="handleSave">
|
|
||||||
{{ isEdit ? '保存' : '创建' }}
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.form-content {
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-item {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
|
|
||||||
.form-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-switch {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
|
|
||||||
.type-btn {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
border: 2px solid rgba(255, 183, 197, 0.2);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: white;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--primary);
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
border-color: var(--primary);
|
|
||||||
background: rgba(255, 183, 197, 0.1);
|
|
||||||
color: var(--primary);
|
|
||||||
box-shadow: 0 2px 8px rgba(255, 183, 197, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-grid {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
.icon-btn {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 2px solid rgba(255, 183, 197, 0.15);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: white;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--primary);
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
border-color: var(--primary);
|
|
||||||
background: rgba(255, 183, 197, 0.1);
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-grid {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
.color-btn {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
outline: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: scale(1.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
border-color: var(--text-primary);
|
|
||||||
box-shadow: 0 0 0 2px white, 0 0 0 4px var(--text-primary);
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { useAccountStore } from '@/stores/useAccountStore'
|
|
||||||
import type { FinancialAccount, AccountHistoryRecord } from '@/api/types'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
visible: boolean
|
|
||||||
account: FinancialAccount | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
account: null
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:visible', val: boolean): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const store = useAccountStore()
|
|
||||||
|
|
||||||
const records = ref<AccountHistoryRecord[]>([])
|
|
||||||
const total = ref(0)
|
|
||||||
const page = ref(1)
|
|
||||||
const pageSize = 20
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
async function fetchHistory() {
|
|
||||||
if (!props.account) return
|
|
||||||
loading.value = true
|
|
||||||
const result = await store.fetchHistory(props.account.id, page.value, pageSize)
|
|
||||||
records.value = result.records
|
|
||||||
total.value = result.total
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.visible, (val) => {
|
|
||||||
if (val && props.account) {
|
|
||||||
page.value = 1
|
|
||||||
fetchHistory()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function handlePageChange(newPage: number) {
|
|
||||||
page.value = newPage
|
|
||||||
fetchHistory()
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAmount(amount: number): string {
|
|
||||||
if (amount > 0) return `+${amount.toFixed(2)}`
|
|
||||||
return amount.toFixed(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
const d = new Date(dateStr)
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
emit('update:visible', false)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<el-dialog
|
|
||||||
:model-value="visible"
|
|
||||||
:title="account ? `${account.name} - 变更历史` : '变更历史'"
|
|
||||||
width="600px"
|
|
||||||
@close="handleClose"
|
|
||||||
class="history-dialog"
|
|
||||||
>
|
|
||||||
<div class="history-content">
|
|
||||||
<div v-if="loading" class="loading-state">
|
|
||||||
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
|
|
||||||
<span>加载中...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="records.length === 0" class="empty-state">
|
|
||||||
<el-icon :size="40" color="#C8A2C8"><Document /></el-icon>
|
|
||||||
<p>暂无变更记录</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="history-list">
|
|
||||||
<div
|
|
||||||
v-for="record in records"
|
|
||||||
:key="record.id"
|
|
||||||
class="history-item"
|
|
||||||
>
|
|
||||||
<div class="history-left">
|
|
||||||
<div
|
|
||||||
class="amount-badge"
|
|
||||||
:class="{ positive: record.change_amount > 0, negative: record.change_amount < 0 }"
|
|
||||||
>
|
|
||||||
<el-icon v-if="record.change_amount > 0"><Top /></el-icon>
|
|
||||||
<el-icon v-else-if="record.change_amount < 0"><Bottom /></el-icon>
|
|
||||||
<el-icon v-else><Minus /></el-icon>
|
|
||||||
<span>{{ formatAmount(record.change_amount) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="history-info">
|
|
||||||
<span class="history-note">{{ record.note || '未备注' }}</span>
|
|
||||||
<span class="history-date">{{ formatDate(record.created_at) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="history-right">
|
|
||||||
<span class="balance-change">
|
|
||||||
{{ record.balance_before.toFixed(2) }}
|
|
||||||
<el-icon :size="12"><Right /></el-icon>
|
|
||||||
{{ record.balance_after.toFixed(2) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="total > pageSize" class="pagination-wrapper">
|
|
||||||
<el-pagination
|
|
||||||
:current-page="page"
|
|
||||||
:page-size="pageSize"
|
|
||||||
:total="total"
|
|
||||||
layout="prev, pager, next"
|
|
||||||
small
|
|
||||||
@current-change="handlePageChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="handleClose">关闭</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.history-content {
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 40px 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 40px 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 14px 0;
|
|
||||||
border-bottom: 1px dashed rgba(255, 183, 197, 0.15);
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.amount-badge {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&.positive {
|
|
||||||
color: #67C23A;
|
|
||||||
background: rgba(103, 194, 58, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.negative {
|
|
||||||
color: #F56C6C;
|
|
||||||
background: rgba(245, 108, 108, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
|
|
||||||
.history-note {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-date {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-right {
|
|
||||||
.balance-change {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-wrapper {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding-top: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -22,9 +22,9 @@ function setView(view: string) {
|
|||||||
router.push(`/${view === 'list' ? 'tasks' : view}`)
|
router.push(`/${view === 'list' ? 'tasks' : view}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCommand(command: string) {
|
async function handleCommand(command: string) {
|
||||||
if (command === 'logout') {
|
if (command === 'logout') {
|
||||||
authStore.logout()
|
await authStore.logout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -97,11 +97,19 @@ const currentRouteName = computed(() => route.name as string)
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: currentRouteName === 'assets' }"
|
:class="{ active: currentRouteName === 'goals' || currentRouteName === 'goalDetail' }"
|
||||||
@click="router.push('/assets')"
|
@click="router.push('/goals')"
|
||||||
>
|
>
|
||||||
<el-icon><Wallet /></el-icon>
|
<el-icon><Aim /></el-icon>
|
||||||
<span>资产</span>
|
<span>目标</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: currentRouteName === 'certificates' }"
|
||||||
|
@click="router.push('/certificates')"
|
||||||
|
>
|
||||||
|
<el-icon><Medal /></el-icon>
|
||||||
|
<span>证书</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
import { useAccountStore } from '@/stores/useAccountStore'
|
|
||||||
import type { FinancialAccount } from '@/api/types'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
visible: boolean
|
|
||||||
account: FinancialAccount | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
account: null
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:visible', val: boolean): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const store = useAccountStore()
|
|
||||||
|
|
||||||
const newBalance = ref(0)
|
|
||||||
const note = ref('')
|
|
||||||
const saving = ref(false)
|
|
||||||
|
|
||||||
const changeAmount = computed(() => {
|
|
||||||
if (!props.account) return 0
|
|
||||||
return Math.round((newBalance.value - props.account.balance) * 100) / 100
|
|
||||||
})
|
|
||||||
|
|
||||||
const changeText = computed(() => {
|
|
||||||
const diff = changeAmount.value
|
|
||||||
if (diff > 0) return `+${diff.toFixed(2)}`
|
|
||||||
if (diff < 0) return diff.toFixed(2)
|
|
||||||
return '0.00'
|
|
||||||
})
|
|
||||||
|
|
||||||
const isPositive = computed(() => changeAmount.value >= 0)
|
|
||||||
|
|
||||||
watch(() => props.visible, (val) => {
|
|
||||||
if (val && props.account) {
|
|
||||||
newBalance.value = props.account.balance
|
|
||||||
note.value = ''
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
if (!props.account) return
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const result = await store.updateBalance(props.account.id, {
|
|
||||||
new_balance: newBalance.value,
|
|
||||||
note: note.value.trim() || null
|
|
||||||
})
|
|
||||||
if (result) {
|
|
||||||
ElMessage.success('余额更新成功~')
|
|
||||||
emit('update:visible', false)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
emit('update:visible', false)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<el-dialog
|
|
||||||
:model-value="visible"
|
|
||||||
title="更新余额"
|
|
||||||
width="420px"
|
|
||||||
@close="handleClose"
|
|
||||||
class="balance-dialog"
|
|
||||||
>
|
|
||||||
<div class="form-content">
|
|
||||||
<div v-if="account" class="balance-preview">
|
|
||||||
<span class="current-label">{{ account.name }}</span>
|
|
||||||
<span class="current-balance">{{ account.balance.toFixed(2) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="change-indicator" :class="{ positive: isPositive, negative: !isPositive }">
|
|
||||||
<el-icon><Top v-if="isPositive" /><Bottom v-else /></el-icon>
|
|
||||||
<span>{{ changeText }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-item">
|
|
||||||
<label class="form-label">新余额</label>
|
|
||||||
<el-input-number
|
|
||||||
v-model="newBalance"
|
|
||||||
:precision="2"
|
|
||||||
:step="100"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-item">
|
|
||||||
<label class="form-label">备注</label>
|
|
||||||
<el-input
|
|
||||||
v-model="note"
|
|
||||||
placeholder="如:工资到账、还花呗、日常消费..."
|
|
||||||
maxlength="200"
|
|
||||||
show-word-limit
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<div class="dialog-footer">
|
|
||||||
<el-button @click="handleClose">取消</el-button>
|
|
||||||
<el-button type="primary" @click="handleSave" :loading="saving">
|
|
||||||
确认更新
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.form-content {
|
|
||||||
padding: 8px 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.balance-preview {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: rgba(255, 183, 197, 0.08);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
|
|
||||||
.current-label {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-balance {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.change-indicator {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
|
|
||||||
&.positive {
|
|
||||||
color: #67C23A;
|
|
||||||
background: rgba(103, 194, 58, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.negative {
|
|
||||||
color: #F56C6C;
|
|
||||||
background: rgba(245, 108, 108, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-item {
|
|
||||||
.form-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
195
WebUI/src/components/CertificateDialog.vue
Normal file
195
WebUI/src/components/CertificateDialog.vue
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useCertificateStore } from '@/stores/useCertificateStore'
|
||||||
|
import type { Certificate, CertificateFormData } from '@/api/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
editingCert?: Certificate | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
saved: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useCertificateStore()
|
||||||
|
|
||||||
|
const form = ref<CertificateFormData>({
|
||||||
|
title: '',
|
||||||
|
category_id: null,
|
||||||
|
image: null,
|
||||||
|
issuer: null,
|
||||||
|
issue_date: null,
|
||||||
|
expiry_date: null,
|
||||||
|
description: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
watch(() => props.visible, (val) => {
|
||||||
|
if (val) {
|
||||||
|
store.fetchCategories()
|
||||||
|
if (props.editingCert) {
|
||||||
|
isEditing.value = true
|
||||||
|
form.value = {
|
||||||
|
title: props.editingCert.title,
|
||||||
|
category_id: props.editingCert.category_id ?? null,
|
||||||
|
image: props.editingCert.image ?? null,
|
||||||
|
issuer: props.editingCert.issuer ?? null,
|
||||||
|
issue_date: props.editingCert.issue_date ?? null,
|
||||||
|
expiry_date: props.editingCert.expiry_date ?? null,
|
||||||
|
description: props.editingCert.description ?? null,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isEditing.value = false
|
||||||
|
form.value = {
|
||||||
|
title: '',
|
||||||
|
category_id: null,
|
||||||
|
image: null,
|
||||||
|
issuer: null,
|
||||||
|
issue_date: null,
|
||||||
|
expiry_date: null,
|
||||||
|
description: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleImageUpload(e: Event) {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
form.value.image = reader.result as string
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage() {
|
||||||
|
form.value.image = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!form.value.title.trim()) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (isEditing.value && props.editingCert) {
|
||||||
|
await store.updateCertificate(props.editingCert.id, form.value)
|
||||||
|
} else {
|
||||||
|
await store.createCertificate(form.value)
|
||||||
|
}
|
||||||
|
emit('saved')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="visible"
|
||||||
|
:title="isEditing ? '编辑证书' : '添加证书'"
|
||||||
|
width="520px"
|
||||||
|
@close="emit('close')"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form :model="form" label-position="top">
|
||||||
|
<el-form-item label="证书名称" required>
|
||||||
|
<el-input v-model="form.title" maxlength="200" placeholder="例如:CET-6 英语六级证书" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="分类">
|
||||||
|
<el-select v-model="form.category_id" style="width:100%" clearable placeholder="选择分类">
|
||||||
|
<el-option v-for="cat in store.categories" :key="cat.id" :label="cat.name" :value="cat.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="颁发机构">
|
||||||
|
<el-input v-model="form.issuer" maxlength="200" placeholder="例如:教育部考试中心" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="获取日期">
|
||||||
|
<el-date-picker v-model="form.issue_date" type="date" placeholder="选择日期" style="width:100%" value-format="YYYY-MM-DD" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="有效期至">
|
||||||
|
<el-date-picker v-model="form.expiry_date" type="date" placeholder="留空表示永久有效" style="width:100%" value-format="YYYY-MM-DD" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-form-item label="证书图片">
|
||||||
|
<div class="image-upload">
|
||||||
|
<div v-if="form.image" class="image-preview">
|
||||||
|
<img :src="form.image" alt="证书预览" />
|
||||||
|
<el-button text type="danger" size="small" class="remove-img-btn" @click="removeImage">移除图片</el-button>
|
||||||
|
</div>
|
||||||
|
<label v-else class="upload-area">
|
||||||
|
<input type="file" accept="image/*" style="display:none" @change="handleImageUpload" />
|
||||||
|
<el-icon :size="32"><Plus /></el-icon>
|
||||||
|
<span>上传证书图片</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="补充说明..." />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="emit('close')">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||||
|
{{ isEditing ? '保存' : '添加' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.image-upload {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 120px;
|
||||||
|
border: 2px dashed #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #999;
|
||||||
|
transition: all 0.2s;
|
||||||
|
&:hover { border-color: var(--primary); color: var(--primary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.remove-img-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
background: rgba(255,255,255,0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
284
WebUI/src/components/GoalDialog.vue
Normal file
284
WebUI/src/components/GoalDialog.vue
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useGoalStore } from '@/stores/useGoalStore'
|
||||||
|
import { useCategoryStore } from '@/stores/useCategoryStore'
|
||||||
|
import { useUIStore } from '@/stores/useUIStore'
|
||||||
|
import type { Goal, GoalFormData } from '@/api/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
editingGoal?: Goal | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
saved: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const goalStore = useGoalStore()
|
||||||
|
const categoryStore = useCategoryStore()
|
||||||
|
const uiStore = useUIStore()
|
||||||
|
|
||||||
|
const form = ref<GoalFormData>({
|
||||||
|
title: '',
|
||||||
|
description: null,
|
||||||
|
status: 'active',
|
||||||
|
track_type: 'milestone',
|
||||||
|
target_value: null,
|
||||||
|
target_unit: null,
|
||||||
|
input_unit: null,
|
||||||
|
conversion_rate: 1.0,
|
||||||
|
target_date: null,
|
||||||
|
category_id: null,
|
||||||
|
color: '#FFB7C5',
|
||||||
|
icon: 'flag',
|
||||||
|
sort_order: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
watch(() => props.visible, (val) => {
|
||||||
|
if (val) {
|
||||||
|
categoryStore.fetchCategories()
|
||||||
|
if (props.editingGoal) {
|
||||||
|
isEditing.value = true
|
||||||
|
form.value = {
|
||||||
|
title: props.editingGoal.title,
|
||||||
|
description: props.editingGoal.description ?? null,
|
||||||
|
status: props.editingGoal.status,
|
||||||
|
track_type: props.editingGoal.track_type ?? 'milestone',
|
||||||
|
target_value: props.editingGoal.target_value ?? null,
|
||||||
|
target_unit: props.editingGoal.target_unit ?? null,
|
||||||
|
input_unit: props.editingGoal.input_unit ?? null,
|
||||||
|
conversion_rate: props.editingGoal.conversion_rate ?? 1.0,
|
||||||
|
target_date: props.editingGoal.target_date ?? null,
|
||||||
|
category_id: props.editingGoal.category_id ?? null,
|
||||||
|
color: props.editingGoal.color,
|
||||||
|
icon: props.editingGoal.icon,
|
||||||
|
sort_order: props.editingGoal.sort_order,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isEditing.value = false
|
||||||
|
form.value = {
|
||||||
|
title: '',
|
||||||
|
description: null,
|
||||||
|
status: 'active',
|
||||||
|
track_type: 'milestone',
|
||||||
|
target_value: null,
|
||||||
|
target_unit: null,
|
||||||
|
input_unit: null,
|
||||||
|
conversion_rate: 1.0,
|
||||||
|
target_date: null,
|
||||||
|
category_id: null,
|
||||||
|
color: '#FFB7C5',
|
||||||
|
icon: 'flag',
|
||||||
|
sort_order: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!form.value.title.trim()) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (isEditing.value && props.editingGoal) {
|
||||||
|
await goalStore.updateGoal(props.editingGoal.id, form.value)
|
||||||
|
} else {
|
||||||
|
await goalStore.createGoal(form.value)
|
||||||
|
}
|
||||||
|
emit('saved')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorOptions = [
|
||||||
|
'#FFB7C5', '#FF6B81', '#FFA502', '#7BED9F', '#70A1FF', '#5352ED', '#A29BFE', '#FF7979',
|
||||||
|
]
|
||||||
|
const iconOptions = ['flag', 'star', 'trophy', 'aim', 'medal', 'magic', 'sunny', 'moon']
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="visible"
|
||||||
|
:title="isEditing ? '编辑目标' : '新建目标'"
|
||||||
|
width="520px"
|
||||||
|
@close="emit('close')"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form :model="form" label-position="top">
|
||||||
|
<el-form-item label="目标名称" required>
|
||||||
|
<el-input v-model="form.title" maxlength="200" placeholder="例如:学好 Rust 开发" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="描述一下这个目标..." />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="form.status" style="width:100%">
|
||||||
|
<el-option label="进行中" value="active" />
|
||||||
|
<el-option label="暂停" value="paused" />
|
||||||
|
<el-option label="已完成" value="completed" />
|
||||||
|
<el-option label="已放弃" value="abandoned" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="目标日期">
|
||||||
|
<el-date-picker v-model="form.target_date" type="date" placeholder="选择日期" style="width:100%" value-format="YYYY-MM-DD" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-form-item label="追踪方式">
|
||||||
|
<el-radio-group v-model="form.track_type">
|
||||||
|
<el-radio value="milestone">里程碑模式</el-radio>
|
||||||
|
<el-radio value="cumulative">累计打卡模式</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<template v-if="form.track_type === 'cumulative'">
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="目标值">
|
||||||
|
<el-input-number v-model="form.target_value" :min="0" :precision="1" placeholder="如 600" style="width:100%" controls-position="right" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="目标单位">
|
||||||
|
<el-input v-model="form.target_unit" maxlength="20" placeholder="如 g、kg、次" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="打卡单位">
|
||||||
|
<el-input v-model="form.input_unit" maxlength="20" placeholder="如 kcal、次、km" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="换算率">
|
||||||
|
<el-input-number v-model="form.conversion_rate" :min="0.01" :precision="2" :step="0.1" style="width:100%" controls-position="right" />
|
||||||
|
<div class="field-hint">多少打卡单位 = 1 目标单位</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="16">
|
||||||
|
<el-form-item label="分类">
|
||||||
|
<div class="category-row">
|
||||||
|
<el-select v-model="form.category_id" style="flex:1" clearable placeholder="无分类">
|
||||||
|
<el-option v-for="cat in categoryStore.categories" :key="cat.id" :label="cat.name" :value="cat.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-button text size="small" class="manage-cat-btn" @click="uiStore.openCategoryDialog()">
|
||||||
|
<el-icon><Setting /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="颜色">
|
||||||
|
<div class="color-picker">
|
||||||
|
<button
|
||||||
|
v-for="c in colorOptions" :key="c"
|
||||||
|
class="color-dot"
|
||||||
|
:class="{ active: form.color === c }"
|
||||||
|
:style="{ background: c }"
|
||||||
|
@click.prevent="form.color = c"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-form-item label="图标">
|
||||||
|
<div class="icon-picker">
|
||||||
|
<button
|
||||||
|
v-for="icon in iconOptions" :key="icon"
|
||||||
|
class="icon-btn"
|
||||||
|
:class="{ active: form.icon === icon }"
|
||||||
|
@click.prevent="form.icon = icon"
|
||||||
|
>
|
||||||
|
<el-icon :size="20"><component :is="icon" /></el-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="emit('close')">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||||
|
{{ isEditing ? '保存' : '创建' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.category-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-cat-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #999;
|
||||||
|
&:hover { color: var(--primary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #bbb;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 4px;
|
||||||
|
|
||||||
|
.color-dot {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
|
||||||
|
&:hover { transform: scale(1.15); }
|
||||||
|
&.active { border-color: #333; transform: scale(1.1); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&:hover { border-color: var(--primary); color: var(--primary); }
|
||||||
|
&.active { background: var(--primary); color: white; border-color: var(--primary); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
import { useAccountStore } from '@/stores/useAccountStore'
|
|
||||||
import type { DebtInstallment, FinancialAccount } from '@/api/types'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
visible: boolean
|
|
||||||
editInstallment?: DebtInstallment | null
|
|
||||||
accountId?: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
editInstallment: null,
|
|
||||||
accountId: null
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:visible', val: boolean): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const store = useAccountStore()
|
|
||||||
|
|
||||||
const isEdit = computed(() => !!props.editInstallment)
|
|
||||||
const dialogTitle = computed(() => isEdit.value ? '编辑分期计划' : '新建分期计划')
|
|
||||||
|
|
||||||
const form = ref({
|
|
||||||
account_id: null as number | null,
|
|
||||||
total_amount: 0,
|
|
||||||
total_periods: 3,
|
|
||||||
current_period: 1,
|
|
||||||
payment_day: 12,
|
|
||||||
payment_amount: 0,
|
|
||||||
start_date: '',
|
|
||||||
is_completed: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const debtAccounts = computed(() => store.debtAccounts)
|
|
||||||
|
|
||||||
watch(() => props.visible, (val) => {
|
|
||||||
if (val) {
|
|
||||||
if (props.editInstallment) {
|
|
||||||
const inst = props.editInstallment
|
|
||||||
form.value = {
|
|
||||||
account_id: inst.account_id,
|
|
||||||
total_amount: inst.total_amount,
|
|
||||||
total_periods: inst.total_periods,
|
|
||||||
current_period: inst.current_period,
|
|
||||||
payment_day: inst.payment_day,
|
|
||||||
payment_amount: inst.payment_amount,
|
|
||||||
start_date: inst.start_date,
|
|
||||||
is_completed: inst.is_completed
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
form.value = {
|
|
||||||
account_id: props.accountId || (debtAccounts.value.length > 0 ? debtAccounts.value[0].id : null),
|
|
||||||
total_amount: 0,
|
|
||||||
total_periods: 3,
|
|
||||||
current_period: 1,
|
|
||||||
payment_day: 12,
|
|
||||||
payment_amount: 0,
|
|
||||||
start_date: '',
|
|
||||||
is_completed: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch([() => form.value.total_amount, () => form.value.total_periods], ([amount, periods]) => {
|
|
||||||
if (!isEdit.value && amount > 0 && periods > 0) {
|
|
||||||
form.value.payment_amount = Math.round((amount / periods) * 100) / 100
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
if (!form.value.account_id) {
|
|
||||||
ElMessage.warning('请选择关联的欠款账户~')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (form.value.total_amount <= 0) {
|
|
||||||
ElMessage.warning('请输入分期总额~')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (form.value.payment_amount <= 0) {
|
|
||||||
ElMessage.warning('请输入每期还款金额~')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!form.value.start_date) {
|
|
||||||
ElMessage.warning('请选择首次还款日期~')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
account_id: form.value.account_id,
|
|
||||||
total_amount: form.value.total_amount,
|
|
||||||
total_periods: form.value.total_periods,
|
|
||||||
current_period: form.value.current_period,
|
|
||||||
payment_day: form.value.payment_day,
|
|
||||||
payment_amount: form.value.payment_amount,
|
|
||||||
start_date: form.value.start_date,
|
|
||||||
is_completed: form.value.is_completed
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEdit.value && props.editInstallment) {
|
|
||||||
const result = await store.updateInstallment(props.editInstallment.id, data)
|
|
||||||
if (result) ElMessage.success('分期计划更新成功~')
|
|
||||||
} else {
|
|
||||||
const result = await store.createInstallment(data)
|
|
||||||
if (result) ElMessage.success('分期计划创建成功~')
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('update:visible', false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
emit('update:visible', false)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<el-dialog
|
|
||||||
:model-value="visible"
|
|
||||||
:title="dialogTitle"
|
|
||||||
width="460px"
|
|
||||||
@close="handleClose"
|
|
||||||
class="installment-dialog"
|
|
||||||
>
|
|
||||||
<div class="form-content">
|
|
||||||
<div class="form-item">
|
|
||||||
<label class="form-label">关联账户</label>
|
|
||||||
<el-select
|
|
||||||
v-model="form.account_id"
|
|
||||||
placeholder="选择欠款账户"
|
|
||||||
style="width: 100%"
|
|
||||||
:disabled="isEdit"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="acc in debtAccounts"
|
|
||||||
:key="acc.id"
|
|
||||||
:label="acc.name"
|
|
||||||
:value="acc.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
<div v-if="debtAccounts.length === 0" class="form-hint" style="margin-top: 8px;">
|
|
||||||
暂无欠款账户,请先创建一个欠款类型的账户~
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-item">
|
|
||||||
<label class="form-label">分期总额</label>
|
|
||||||
<el-input-number
|
|
||||||
v-model="form.total_amount"
|
|
||||||
:precision="2"
|
|
||||||
:step="500"
|
|
||||||
:min="0"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-item form-row">
|
|
||||||
<div class="form-col">
|
|
||||||
<label class="form-label">总期数</label>
|
|
||||||
<el-input-number
|
|
||||||
v-model="form.total_periods"
|
|
||||||
:min="1"
|
|
||||||
:max="36"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-col">
|
|
||||||
<label class="form-label">每月还款日</label>
|
|
||||||
<el-input-number
|
|
||||||
v-model="form.payment_day"
|
|
||||||
:min="1"
|
|
||||||
:max="31"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-item">
|
|
||||||
<label class="form-label">每期还款金额</label>
|
|
||||||
<el-input-number
|
|
||||||
v-model="form.payment_amount"
|
|
||||||
:precision="2"
|
|
||||||
:step="100"
|
|
||||||
:min="0"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
<div v-if="!isEdit && form.total_amount > 0 && form.total_periods > 0" class="form-hint" style="margin-top: 8px;">
|
|
||||||
自动计算: {{ form.total_amount.toFixed(2) }} / {{ form.total_periods }} = {{ (form.total_amount / form.total_periods).toFixed(2) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isEdit" class="form-item">
|
|
||||||
<label class="form-label">当前期数(第几期待还)</label>
|
|
||||||
<el-input-number
|
|
||||||
v-model="form.current_period"
|
|
||||||
:min="1"
|
|
||||||
:max="form.total_periods"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-item">
|
|
||||||
<label class="form-label">首次还款日期</label>
|
|
||||||
<el-date-picker
|
|
||||||
v-model="form.start_date"
|
|
||||||
type="date"
|
|
||||||
:locale="zhCn"
|
|
||||||
placeholder="选择首次还款日期"
|
|
||||||
format="YYYY-MM-DD"
|
|
||||||
value-format="YYYY-MM-DD"
|
|
||||||
style="width: 100%"
|
|
||||||
:clearable="false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<div class="dialog-footer">
|
|
||||||
<el-button @click="handleClose">取消</el-button>
|
|
||||||
<el-button type="primary" @click="handleSave">
|
|
||||||
{{ isEdit ? '保存' : '创建' }}
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.form-content {
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-item {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
|
|
||||||
.form-col {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import type { RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
import { useUserSettingsStore } from '@/stores/useUserSettingsStore'
|
import { useUserSettingsStore } from '@/stores/useUserSettingsStore'
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
@@ -9,6 +10,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/LoginView.vue'),
|
component: () => import('@/views/LoginView.vue'),
|
||||||
meta: { title: '登录', noAuth: true }
|
meta: { title: '登录', noAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/setup',
|
||||||
|
name: 'setup',
|
||||||
|
component: () => import('@/views/SetupView.vue'),
|
||||||
|
meta: { title: '首次设置', noAuth: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
redirect: '/tasks'
|
redirect: '/tasks'
|
||||||
@@ -49,17 +56,29 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/AnniversaryPage.vue'),
|
component: () => import('@/views/AnniversaryPage.vue'),
|
||||||
meta: { title: '纪念日', view: 'anniversaries' }
|
meta: { title: '纪念日', view: 'anniversaries' }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/assets',
|
|
||||||
name: 'assets',
|
|
||||||
component: () => import('@/views/AssetPage.vue'),
|
|
||||||
meta: { title: '资产总览', view: 'assets' }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
component: () => import('@/views/SettingsView.vue'),
|
component: () => import('@/views/SettingsView.vue'),
|
||||||
meta: { title: '偏好设置', view: 'settings' }
|
meta: { title: '偏好设置', view: 'settings' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/goals',
|
||||||
|
name: 'goals',
|
||||||
|
component: () => import('@/views/GoalPage.vue'),
|
||||||
|
meta: { title: '目标管理', view: 'goals' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/goals/:id',
|
||||||
|
name: 'goalDetail',
|
||||||
|
component: () => import('@/views/GoalDetailPage.vue'),
|
||||||
|
meta: { title: '目标详情', view: 'goals' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/certificates',
|
||||||
|
name: 'certificates',
|
||||||
|
component: () => import('@/views/CertificatePage.vue'),
|
||||||
|
meta: { title: '证书管理', view: 'certificates' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -74,18 +93,46 @@ const router = createRouter({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const TOKEN_KEY = 'elysia_auth_token'
|
router.beforeEach(async (to, from) => {
|
||||||
|
|
||||||
router.beforeEach((to, from) => {
|
|
||||||
const page = (to.meta.title as string) || ''
|
const page = (to.meta.title as string) || ''
|
||||||
const userStore = useUserSettingsStore()
|
const userStore = useUserSettingsStore()
|
||||||
const siteName = userStore.siteName || '爱莉希雅待办'
|
const siteName = userStore.siteName || '爱莉希雅待办'
|
||||||
document.title = page ? `${page} - ${siteName}` : siteName
|
document.title = page ? `${page} - ${siteName}` : siteName
|
||||||
|
|
||||||
|
// 已登录用户访问 setup/login 页面时重定向到主页
|
||||||
|
if ((to.path === '/setup' || to.path === '/login')) {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
if (authStore.checked && authStore.isLoggedIn && !authStore.needSetup) {
|
||||||
|
return { path: '/' }
|
||||||
|
}
|
||||||
|
if (authStore.checked && authStore.needSetup && to.path === '/login') {
|
||||||
|
return { path: '/setup' }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (to.meta.noAuth) return
|
if (to.meta.noAuth) return
|
||||||
|
|
||||||
const token = localStorage.getItem(TOKEN_KEY)
|
const authStore = useAuthStore()
|
||||||
if (!token) {
|
|
||||||
|
// 已知状态直接判断
|
||||||
|
if (authStore.needSetup) {
|
||||||
|
return { path: '/setup', query: { redirect: to.fullPath } }
|
||||||
|
}
|
||||||
|
if (authStore.checked && !authStore.isLoggedIn) {
|
||||||
|
return { path: '/login', query: { redirect: to.fullPath } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未验证:先检查认证状态
|
||||||
|
if (!authStore.checked) {
|
||||||
|
const ok = await authStore.checkAuth()
|
||||||
|
if (ok) return
|
||||||
|
|
||||||
|
// 未认证:检查是否需要先设置密码
|
||||||
|
const needSetup = await authStore.checkSetup()
|
||||||
|
if (needSetup) {
|
||||||
|
return { path: '/setup', query: { redirect: to.fullPath } }
|
||||||
|
}
|
||||||
return { path: '/login', query: { redirect: to.fullPath } }
|
return { path: '/login', query: { redirect: to.fullPath } }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { accountApi } from '@/api/accounts'
|
|
||||||
import type {
|
|
||||||
FinancialAccount, AccountFormData, BalanceUpdateData,
|
|
||||||
AccountHistoryRecord, DebtInstallment, DebtInstallmentFormData
|
|
||||||
} from '@/api/types'
|
|
||||||
|
|
||||||
export const useAccountStore = defineStore('account', () => {
|
|
||||||
const accounts = ref<FinancialAccount[]>([])
|
|
||||||
const installments = ref<DebtInstallment[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
const savingsAccounts = computed(() =>
|
|
||||||
accounts.value.filter(a => a.account_type === 'savings' && a.is_active)
|
|
||||||
)
|
|
||||||
|
|
||||||
const debtAccounts = computed(() =>
|
|
||||||
accounts.value.filter(a => a.account_type === 'debt' && a.is_active)
|
|
||||||
)
|
|
||||||
|
|
||||||
const totalSavings = computed(() =>
|
|
||||||
savingsAccounts.value.reduce((sum, a) => sum + a.balance, 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
const totalDebt = computed(() =>
|
|
||||||
debtAccounts.value.reduce((sum, a) => sum + a.balance, 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
const netAssets = computed(() => totalSavings.value - totalDebt.value)
|
|
||||||
|
|
||||||
const activeInstallments = computed(() =>
|
|
||||||
installments.value.filter(i => !i.is_completed && i.days_until_payment !== null)
|
|
||||||
)
|
|
||||||
|
|
||||||
const upcomingPayments = computed(() =>
|
|
||||||
activeInstallments.value
|
|
||||||
.filter(i => i.days_until_payment! >= 0)
|
|
||||||
.sort((a, b) => a.days_until_payment! - b.days_until_payment!)
|
|
||||||
)
|
|
||||||
|
|
||||||
// ============ 账户操作 ============
|
|
||||||
|
|
||||||
async function fetchAccounts() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
accounts.value = await accountApi.getAccounts()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取账户列表失败:', error)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createAccount(data: AccountFormData): Promise<FinancialAccount | null> {
|
|
||||||
try {
|
|
||||||
const account = await accountApi.createAccount(data)
|
|
||||||
accounts.value.push(account)
|
|
||||||
return account
|
|
||||||
} catch (error) {
|
|
||||||
console.error('创建账户失败:', error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateAccount(id: number, data: Partial<AccountFormData>): Promise<FinancialAccount | null> {
|
|
||||||
try {
|
|
||||||
const updated = await accountApi.updateAccount(id, data)
|
|
||||||
const index = accounts.value.findIndex(a => a.id === id)
|
|
||||||
if (index !== -1) {
|
|
||||||
accounts.value[index] = updated
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
} catch (error) {
|
|
||||||
console.error('更新账户失败:', error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAccount(id: number): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await accountApi.deleteAccount(id)
|
|
||||||
accounts.value = accounts.value.filter(a => a.id !== id)
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除账户失败:', error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateBalance(id: number, data: BalanceUpdateData): Promise<FinancialAccount | null> {
|
|
||||||
try {
|
|
||||||
const updated = await accountApi.updateBalance(id, data)
|
|
||||||
const index = accounts.value.findIndex(a => a.id === id)
|
|
||||||
if (index !== -1) {
|
|
||||||
accounts.value[index] = updated
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
} catch (error) {
|
|
||||||
console.error('更新余额失败:', error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchHistory(id: number, page = 1, pageSize = 20): Promise<AccountHistoryResponse> {
|
|
||||||
try {
|
|
||||||
return await accountApi.getHistory(id, { page, page_size: pageSize })
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取变更历史失败:', error)
|
|
||||||
return { total: 0, page: 1, page_size: pageSize, records: [] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 分期计划操作 ============
|
|
||||||
|
|
||||||
async function fetchInstallments() {
|
|
||||||
try {
|
|
||||||
installments.value = await accountApi.getInstallments()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取分期计划失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createInstallment(data: DebtInstallmentFormData): Promise<DebtInstallment | null> {
|
|
||||||
try {
|
|
||||||
const inst = await accountApi.createInstallment(data)
|
|
||||||
installments.value.push(inst)
|
|
||||||
installments.value.sort((a, b) => {
|
|
||||||
const aActive = !a.is_completed && a.days_until_payment !== null ? 0 : 1
|
|
||||||
const bActive = !b.is_completed && b.days_until_payment !== null ? 0 : 1
|
|
||||||
if (aActive !== bActive) return aActive - bActive
|
|
||||||
return (a.days_until_payment ?? 9999) - (b.days_until_payment ?? 9999)
|
|
||||||
})
|
|
||||||
return inst
|
|
||||||
} catch (error) {
|
|
||||||
console.error('创建分期计划失败:', error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateInstallment(id: number, data: Partial<DebtInstallmentFormData>): Promise<DebtInstallment | null> {
|
|
||||||
try {
|
|
||||||
const updated = await accountApi.updateInstallment(id, data)
|
|
||||||
const index = installments.value.findIndex(i => i.id === id)
|
|
||||||
if (index !== -1) {
|
|
||||||
installments.value[index] = updated
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
} catch (error) {
|
|
||||||
console.error('更新分期计划失败:', error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteInstallment(id: number): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await accountApi.deleteInstallment(id)
|
|
||||||
installments.value = installments.value.filter(i => i.id !== id)
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除分期计划失败:', error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function payInstallment(id: number): Promise<DebtInstallment | null> {
|
|
||||||
try {
|
|
||||||
const updated = await accountApi.payInstallment(id)
|
|
||||||
const index = installments.value.findIndex(i => i.id === id)
|
|
||||||
if (index !== -1) {
|
|
||||||
installments.value[index] = updated
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
} catch (error) {
|
|
||||||
console.error('标记还款失败:', error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
await Promise.all([fetchAccounts(), fetchInstallments()])
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
accounts,
|
|
||||||
installments,
|
|
||||||
loading,
|
|
||||||
savingsAccounts,
|
|
||||||
debtAccounts,
|
|
||||||
totalSavings,
|
|
||||||
totalDebt,
|
|
||||||
netAssets,
|
|
||||||
activeInstallments,
|
|
||||||
upcomingPayments,
|
|
||||||
fetchAccounts,
|
|
||||||
createAccount,
|
|
||||||
updateAccount,
|
|
||||||
deleteAccount,
|
|
||||||
updateBalance,
|
|
||||||
fetchHistory,
|
|
||||||
fetchInstallments,
|
|
||||||
createInstallment,
|
|
||||||
updateInstallment,
|
|
||||||
deleteInstallment,
|
|
||||||
payInstallment,
|
|
||||||
init,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,35 +1,48 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { login as apiLogin } from '@/api/auth'
|
import { login as apiLogin, logout as apiLogout, checkAuth as apiCheckAuth, checkSetupStatus, setupPassword as apiSetupPassword } from '@/api/auth'
|
||||||
|
|
||||||
const TOKEN_KEY = 'elysia_auth_token'
|
|
||||||
|
|
||||||
function getStoredToken(): string {
|
|
||||||
return localStorage.getItem(TOKEN_KEY) || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStoredToken(token: string) {
|
|
||||||
localStorage.setItem(TOKEN_KEY, token)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearStoredToken() {
|
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const token = ref<string>(getStoredToken())
|
const isLoggedIn = ref(false)
|
||||||
|
const checked = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const needSetup = ref(false)
|
||||||
|
const setupChecked = ref(false)
|
||||||
|
|
||||||
const isLoggedIn = computed(() => !!token.value)
|
async function checkAuth(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await apiCheckAuth()
|
||||||
|
isLoggedIn.value = res.authenticated
|
||||||
|
checked.value = true
|
||||||
|
return isLoggedIn.value
|
||||||
|
} catch {
|
||||||
|
isLoggedIn.value = false
|
||||||
|
checked.value = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSetup(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await checkSetupStatus()
|
||||||
|
needSetup.value = !res.has_password
|
||||||
|
setupChecked.value = true
|
||||||
|
return needSetup.value
|
||||||
|
} catch {
|
||||||
|
needSetup.value = false
|
||||||
|
setupChecked.value = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function login(password: string): Promise<boolean> {
|
async function login(password: string): Promise<boolean> {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
const res = await apiLogin(password)
|
await apiLogin(password)
|
||||||
token.value = res.access_token
|
isLoggedIn.value = true
|
||||||
setStoredToken(res.access_token)
|
checked.value = true
|
||||||
return true
|
return true
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e?.response?.data?.detail || '登录失败'
|
error.value = e?.response?.data?.detail || '登录失败'
|
||||||
@@ -39,11 +52,32 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
async function setupPassword(password: string, nickname?: string): Promise<boolean> {
|
||||||
token.value = ''
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
clearStoredToken()
|
try {
|
||||||
|
await apiSetupPassword(password, nickname)
|
||||||
|
isLoggedIn.value = true
|
||||||
|
checked.value = true
|
||||||
|
needSetup.value = false
|
||||||
|
return true
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '设置失败'
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { token, loading, error, isLoggedIn, login, logout }
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await apiLogout()
|
||||||
|
} finally {
|
||||||
|
isLoggedIn.value = false
|
||||||
|
checked.value = true
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isLoggedIn, checked, loading, error, needSetup, setupChecked, login, logout, checkAuth, checkSetup, setupPassword }
|
||||||
})
|
})
|
||||||
|
|||||||
105
WebUI/src/stores/useCertificateStore.ts
Normal file
105
WebUI/src/stores/useCertificateStore.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Certificate, CertificateCategory, CertificateFormData, CertificateCategoryFormData } from '@/api/types'
|
||||||
|
import * as certApi from '@/api/certificates'
|
||||||
|
|
||||||
|
export const useCertificateStore = defineStore('certificate', () => {
|
||||||
|
const certificates = ref<Certificate[]>([])
|
||||||
|
const categories = ref<CertificateCategory[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function fetchCertificates(categoryId?: number) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
certificates.value = await certApi.getCertificates(categoryId)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '获取证书列表失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCategories() {
|
||||||
|
try {
|
||||||
|
categories.value = await certApi.getCategories()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '获取证书分类失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCertificate(data: CertificateFormData): Promise<Certificate | null> {
|
||||||
|
try {
|
||||||
|
const cert = await certApi.createCertificate(data)
|
||||||
|
await fetchCertificates()
|
||||||
|
return cert
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '创建证书失败'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCertificate(id: number, data: Partial<CertificateFormData>): Promise<Certificate | null> {
|
||||||
|
try {
|
||||||
|
const cert = await certApi.updateCertificate(id, data)
|
||||||
|
await fetchCertificates()
|
||||||
|
return cert
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '更新证书失败'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCertificate(id: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await certApi.deleteCertificate(id)
|
||||||
|
await fetchCertificates()
|
||||||
|
return true
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '删除证书失败'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCategory(data: CertificateCategoryFormData): Promise<CertificateCategory | null> {
|
||||||
|
try {
|
||||||
|
const cat = await certApi.createCategory(data)
|
||||||
|
categories.value.push(cat)
|
||||||
|
return cat
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '创建证书分类失败'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCategory(id: number, data: Partial<CertificateCategoryFormData>): Promise<CertificateCategory | null> {
|
||||||
|
try {
|
||||||
|
const cat = await certApi.updateCategory(id, data)
|
||||||
|
const idx = categories.value.findIndex(c => c.id === id)
|
||||||
|
if (idx !== -1) categories.value[idx] = cat
|
||||||
|
return cat
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '更新证书分类失败'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCategory(id: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await certApi.deleteCategory(id)
|
||||||
|
categories.value = categories.value.filter(c => c.id !== id)
|
||||||
|
return true
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '删除证书分类失败'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
certificates, categories, loading, error,
|
||||||
|
fetchCertificates, fetchCategories,
|
||||||
|
createCertificate, updateCertificate, deleteCertificate,
|
||||||
|
createCategory, updateCategory, deleteCategory,
|
||||||
|
}
|
||||||
|
})
|
||||||
261
WebUI/src/stores/useGoalStore.ts
Normal file
261
WebUI/src/stores/useGoalStore.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { Goal, GoalDetail, GoalStep, GoalReview, GoalCheckin, GoalFormData, GoalStepFormData, GoalReviewFormData, GoalCheckinFormData } from '@/api/types'
|
||||||
|
import * as goalApi from '@/api/goals'
|
||||||
|
|
||||||
|
export const useGoalStore = defineStore('goal', () => {
|
||||||
|
const goals = ref<Goal[]>([])
|
||||||
|
const currentGoal = ref<GoalDetail | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const activeGoals = computed(() => goals.value.filter(g => g.status === 'active'))
|
||||||
|
const pausedGoals = computed(() => goals.value.filter(g => g.status === 'paused'))
|
||||||
|
const completedGoals = computed(() => goals.value.filter(g => g.status === 'completed'))
|
||||||
|
|
||||||
|
async function fetchGoals(status?: string) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
goals.value = await goalApi.getGoals(status)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '获取目标列表失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGoal(id: number) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
currentGoal.value = await goalApi.getGoal(id)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '获取目标详情失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGoal(data: GoalFormData): Promise<GoalDetail | null> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const goal = await goalApi.createGoal(data)
|
||||||
|
await fetchGoals()
|
||||||
|
return goal
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '创建目标失败'
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateGoal(id: number, data: Partial<GoalFormData>): Promise<GoalDetail | null> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const goal = await goalApi.updateGoal(id, data)
|
||||||
|
if (currentGoal.value?.id === id) {
|
||||||
|
currentGoal.value = { ...currentGoal.value, ...goal }
|
||||||
|
}
|
||||||
|
await fetchGoals()
|
||||||
|
return goal
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '更新目标失败'
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGoal(id: number): Promise<boolean> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await goalApi.deleteGoal(id)
|
||||||
|
if (currentGoal.value?.id === id) {
|
||||||
|
currentGoal.value = null
|
||||||
|
}
|
||||||
|
await fetchGoals()
|
||||||
|
return true
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '删除目标失败'
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateGoalStatus(id: number, status: string) {
|
||||||
|
try {
|
||||||
|
const goal = await goalApi.updateGoalStatus(id, status)
|
||||||
|
if (currentGoal.value?.id === id) {
|
||||||
|
currentGoal.value = { ...currentGoal.value, ...goal }
|
||||||
|
}
|
||||||
|
await fetchGoals()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '更新状态失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Steps ============
|
||||||
|
|
||||||
|
async function createStep(goalId: number, data: GoalStepFormData): Promise<GoalStep | null> {
|
||||||
|
try {
|
||||||
|
const step = await goalApi.createStep(goalId, data)
|
||||||
|
if (currentGoal.value?.id === goalId) {
|
||||||
|
await fetchGoal(goalId)
|
||||||
|
}
|
||||||
|
await fetchGoals()
|
||||||
|
return step
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '添加步骤失败'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStep(goalId: number, stepId: number, data: Partial<GoalStepFormData>) {
|
||||||
|
try {
|
||||||
|
await goalApi.updateStep(goalId, stepId, data)
|
||||||
|
if (currentGoal.value?.id === goalId) {
|
||||||
|
await fetchGoal(goalId)
|
||||||
|
}
|
||||||
|
await fetchGoals()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '更新步骤失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteStep(goalId: number, stepId: number) {
|
||||||
|
try {
|
||||||
|
await goalApi.deleteStep(goalId, stepId)
|
||||||
|
if (currentGoal.value?.id === goalId) {
|
||||||
|
await fetchGoal(goalId)
|
||||||
|
}
|
||||||
|
await fetchGoals()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '删除步骤失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStep(goalId: number, stepId: number) {
|
||||||
|
try {
|
||||||
|
await goalApi.toggleStep(goalId, stepId)
|
||||||
|
if (currentGoal.value?.id === goalId) {
|
||||||
|
await fetchGoal(goalId)
|
||||||
|
}
|
||||||
|
await fetchGoals()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '切换步骤状态失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Reviews ============
|
||||||
|
|
||||||
|
async function createReview(goalId: number, data: GoalReviewFormData) {
|
||||||
|
try {
|
||||||
|
await goalApi.createReview(goalId, data)
|
||||||
|
if (currentGoal.value?.id === goalId) {
|
||||||
|
await fetchGoal(goalId)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '创建复盘失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteReview(goalId: number, reviewId: number) {
|
||||||
|
try {
|
||||||
|
await goalApi.deleteReview(goalId, reviewId)
|
||||||
|
if (currentGoal.value?.id === goalId) {
|
||||||
|
await fetchGoal(goalId)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '删除复盘失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Task Linking ============
|
||||||
|
|
||||||
|
async function linkTask(goalId: number, taskId: number) {
|
||||||
|
try {
|
||||||
|
await goalApi.linkTask(goalId, taskId)
|
||||||
|
if (currentGoal.value?.id === goalId) {
|
||||||
|
await fetchGoal(goalId)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '关联任务失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkTask(goalId: number, taskId: number) {
|
||||||
|
try {
|
||||||
|
await goalApi.unlinkTask(goalId, taskId)
|
||||||
|
if (currentGoal.value?.id === goalId) {
|
||||||
|
await fetchGoal(goalId)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '取消关联失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Checkins (累计打卡) ============
|
||||||
|
|
||||||
|
async function fetchCheckins(goalId: number): Promise<GoalCheckin[]> {
|
||||||
|
try {
|
||||||
|
return await goalApi.getCheckins(goalId)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '获取打卡记录失败'
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCheckin(goalId: number, data: GoalCheckinFormData): Promise<GoalCheckin | null> {
|
||||||
|
try {
|
||||||
|
const checkin = await goalApi.createCheckin(goalId, data)
|
||||||
|
if (currentGoal.value?.id === goalId) {
|
||||||
|
await fetchGoal(goalId)
|
||||||
|
}
|
||||||
|
await fetchGoals()
|
||||||
|
return checkin
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '创建打卡记录失败'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCheckin(goalId: number, checkinId: number, data: Partial<GoalCheckinFormData>) {
|
||||||
|
try {
|
||||||
|
await goalApi.updateCheckin(goalId, checkinId, data)
|
||||||
|
if (currentGoal.value?.id === goalId) {
|
||||||
|
await fetchGoal(goalId)
|
||||||
|
}
|
||||||
|
await fetchGoals()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '更新打卡记录失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCheckin(goalId: number, checkinId: number) {
|
||||||
|
try {
|
||||||
|
await goalApi.deleteCheckin(goalId, checkinId)
|
||||||
|
if (currentGoal.value?.id === goalId) {
|
||||||
|
await fetchGoal(goalId)
|
||||||
|
}
|
||||||
|
await fetchGoals()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail || '删除打卡记录失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
goals, currentGoal, loading, error,
|
||||||
|
activeGoals, pausedGoals, completedGoals,
|
||||||
|
fetchGoals, fetchGoal, createGoal, updateGoal, deleteGoal, updateGoalStatus,
|
||||||
|
createStep, updateStep, deleteStep, toggleStep,
|
||||||
|
createReview, deleteReview,
|
||||||
|
linkTask, unlinkTask,
|
||||||
|
fetchCheckins, createCheckin, updateCheckin, deleteCheckin,
|
||||||
|
}
|
||||||
|
})
|
||||||
147
WebUI/src/stores/useSyncStore.ts
Normal file
147
WebUI/src/stores/useSyncStore.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { SyncConfig, SyncConfigUpdate, SyncStatus } from '@/api/sync'
|
||||||
|
import { syncApi } from '@/api/sync'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
export type SyncDirection = 'push' | 'pull' | 'sync'
|
||||||
|
|
||||||
|
export const useSyncStore = defineStore('sync', () => {
|
||||||
|
const config = ref<SyncConfig | null>(null)
|
||||||
|
const status = ref<SyncStatus | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const syncing = ref(false)
|
||||||
|
const syncMessage = ref('')
|
||||||
|
|
||||||
|
const isConfigured = computed(() => !!config.value?.webdav_url)
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
config.value = await syncApi.getConfig()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig(data: SyncConfigUpdate) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
config.value = await syncApi.updateConfig(data)
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('保存失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConnection() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await syncApi.testConnection()
|
||||||
|
if (result.success) {
|
||||||
|
ElMessage.success(result.message)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(result.message)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('连接测试失败')
|
||||||
|
return { success: false as const, message: '连接测试失败' }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStatus() {
|
||||||
|
try {
|
||||||
|
status.value = await syncApi.getStatus()
|
||||||
|
syncing.value = status.value.syncing
|
||||||
|
} catch {
|
||||||
|
// 静默失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSync(direction: SyncDirection) {
|
||||||
|
if (syncing.value) {
|
||||||
|
ElMessage.warning('正在同步中,请稍后再试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const directionLabel = direction === 'push' ? '推送' : direction === 'pull' ? '拉取' : '双向合并'
|
||||||
|
|
||||||
|
if (direction === 'push' || direction === 'pull') {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`${direction === 'push' ? '推送' : '拉取'}操作会覆盖${direction === 'push' ? '远端' : '本地'}数据,确定继续吗?`,
|
||||||
|
'警告',
|
||||||
|
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncing.value = true
|
||||||
|
syncMessage.value = `正在${directionLabel}...`
|
||||||
|
try {
|
||||||
|
let result
|
||||||
|
switch (direction) {
|
||||||
|
case 'push':
|
||||||
|
result = await syncApi.push()
|
||||||
|
break
|
||||||
|
case 'pull':
|
||||||
|
result = await syncApi.pull()
|
||||||
|
break
|
||||||
|
case 'sync':
|
||||||
|
result = await syncApi.sync()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (result.success) {
|
||||||
|
ElMessage.success(result.message)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(result.message)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ElMessage.error(`${directionLabel}失败`)
|
||||||
|
} finally {
|
||||||
|
syncing.value = false
|
||||||
|
syncMessage.value = ''
|
||||||
|
await fetchStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearRemote() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'确定要清空远端所有同步数据吗?此操作不可恢复!',
|
||||||
|
'危险操作',
|
||||||
|
{ confirmButtonText: '确定清空', cancelButtonText: '取消', type: 'error' }
|
||||||
|
)
|
||||||
|
const result = await syncApi.clearRemote()
|
||||||
|
if (result.success) {
|
||||||
|
ElMessage.success(result.message)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(result.message)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 取消操作
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
status,
|
||||||
|
loading,
|
||||||
|
syncing,
|
||||||
|
syncMessage,
|
||||||
|
isConfigured,
|
||||||
|
fetchConfig,
|
||||||
|
saveConfig,
|
||||||
|
testConnection,
|
||||||
|
fetchStatus,
|
||||||
|
startSync,
|
||||||
|
clearRemote,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -12,7 +12,7 @@ export const useUIStore = defineStore('ui', () => {
|
|||||||
const editingCategory = ref<Category | null>(null)
|
const editingCategory = ref<Category | null>(null)
|
||||||
const sidebarCollapsed = ref(false)
|
const sidebarCollapsed = ref(false)
|
||||||
const globalLoading = ref(false)
|
const globalLoading = ref(false)
|
||||||
const currentView = ref<'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries' | 'assets'>('list')
|
const currentView = ref<'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries'>('list')
|
||||||
const calendarMode = ref<'week' | 'monthly'>('monthly')
|
const calendarMode = ref<'week' | 'monthly'>('monthly')
|
||||||
|
|
||||||
function openTaskDialog(task?: Task) {
|
function openTaskDialog(task?: Task) {
|
||||||
@@ -43,7 +43,7 @@ export const useUIStore = defineStore('ui', () => {
|
|||||||
globalLoading.value = loading
|
globalLoading.value = loading
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCurrentView(view: 'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries' | 'assets') {
|
function setCurrentView(view: 'list' | 'calendar' | 'quadrant' | 'profile' | 'settings' | 'habits' | 'anniversaries') {
|
||||||
currentView.value = view
|
currentView.value = view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
314
WebUI/src/views/CertificatePage.vue
Normal file
314
WebUI/src/views/CertificatePage.vue
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { Plus, Edit, Delete, Medal } from '@element-plus/icons-vue'
|
||||||
|
import { useCertificateStore } from '@/stores/useCertificateStore'
|
||||||
|
import CertificateDialog from '@/components/CertificateDialog.vue'
|
||||||
|
import type { Certificate, CertificateCategory } from '@/api/types'
|
||||||
|
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const store = useCertificateStore()
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editingCert = ref<Certificate | null>(null)
|
||||||
|
const selectedCategoryId = ref<number | undefined>()
|
||||||
|
|
||||||
|
const catDialogVisible = ref(false)
|
||||||
|
const editingCat = ref<CertificateCategory | null>(null)
|
||||||
|
const catForm = ref({ name: '', color: '#FFB7C5', icon: 'medal' })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.fetchCategories()
|
||||||
|
await store.fetchCertificates()
|
||||||
|
})
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingCert.value = null
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(cert: Certificate) {
|
||||||
|
editingCert.value = cert
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(cert: Certificate) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除证书「${cert.title}」吗?`, '删除确认', {
|
||||||
|
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
await store.deleteCertificate(cert.id)
|
||||||
|
ElMessage.success('证书已删除')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSaved() {
|
||||||
|
dialogVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByCategory(catId?: number) {
|
||||||
|
selectedCategoryId.value = catId
|
||||||
|
store.fetchCertificates(catId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCatDialog(cat?: CertificateCategory) {
|
||||||
|
editingCat.value = cat || null
|
||||||
|
if (cat) {
|
||||||
|
catForm.value = { name: cat.name, color: cat.color, icon: cat.icon }
|
||||||
|
} else {
|
||||||
|
catForm.value = { name: '', color: '#FFB7C5', icon: 'medal' }
|
||||||
|
}
|
||||||
|
catDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCatSave() {
|
||||||
|
if (!catForm.value.name.trim()) return
|
||||||
|
if (editingCat.value) {
|
||||||
|
await store.updateCategory(editingCat.value.id, catForm.value)
|
||||||
|
} else {
|
||||||
|
await store.createCategory(catForm.value)
|
||||||
|
}
|
||||||
|
catDialogVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCatDelete() {
|
||||||
|
if (!editingCat.value) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除分类「${editingCat.value.name}」吗?`, '删除确认', {
|
||||||
|
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
await store.deleteCategory(editingCat.value.id)
|
||||||
|
catDialogVisible.value = false
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpired = (cert: Certificate): boolean => {
|
||||||
|
if (!cert.expiry_date) return false
|
||||||
|
return new Date(cert.expiry_date) < new Date()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cert-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>我的证书</h2>
|
||||||
|
<el-button type="primary" :icon="Plus" @click="openCreate">添加证书</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cert-layout">
|
||||||
|
<aside class="cert-sidebar">
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>分类</h3>
|
||||||
|
<el-button text size="small" @click="openCatDialog()">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<ul class="cat-list">
|
||||||
|
<li
|
||||||
|
:class="{ active: selectedCategoryId === undefined }"
|
||||||
|
@click="filterByCategory()"
|
||||||
|
>全部</li>
|
||||||
|
<li
|
||||||
|
v-for="cat in store.categories" :key="cat.id"
|
||||||
|
:class="{ active: selectedCategoryId === cat.id }"
|
||||||
|
@click="filterByCategory(cat.id)"
|
||||||
|
>
|
||||||
|
<span class="cat-dot" :style="{ background: cat.color }"></span>
|
||||||
|
{{ cat.name }}
|
||||||
|
<span class="cat-actions">
|
||||||
|
<el-button text size="small" @click.stop="openCatDialog(cat)"><el-icon><Edit /></el-icon></el-button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="cert-grid">
|
||||||
|
<div v-if="store.loading" class="loading-state">加载中...</div>
|
||||||
|
|
||||||
|
<div v-else-if="store.certificates.length === 0" class="empty-state">
|
||||||
|
<el-icon :size="64" color="#ccc"><Medallion /></el-icon>
|
||||||
|
<p>还没有证书</p>
|
||||||
|
<el-button type="primary" @click="openCreate">添加第一个证书</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="cert in store.certificates" :key="cert.id" class="cert-card" :class="{ expired: isExpired(cert) }">
|
||||||
|
<div class="card-image" @click="openEdit(cert)">
|
||||||
|
<img v-if="cert.image" :src="cert.image" alt="" />
|
||||||
|
<el-icon v-else :size="48" color="#ddd"><Medallion /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="card-title">{{ cert.title }}</h4>
|
||||||
|
<div class="card-meta">
|
||||||
|
<span v-if="cert.issuer" class="meta-item">
|
||||||
|
<el-icon :size="14"><OfficeBuilding /></el-icon>
|
||||||
|
{{ cert.issuer }}
|
||||||
|
</span>
|
||||||
|
<span v-if="cert.issue_date" class="meta-item">
|
||||||
|
<el-icon :size="14"><Calendar /></el-icon>
|
||||||
|
{{ cert.issue_date }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<el-tag v-if="cert.expiry_date" :type="isExpired(cert) ? 'danger' : 'success'" size="small">
|
||||||
|
{{ isExpired(cert) ? '已过期' : '有效期至 ' + cert.expiry_date }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">永久有效</el-tag>
|
||||||
|
<span v-if="cert.category" class="cat-badge" :style="{ color: cert.category.color }">
|
||||||
|
{{ cert.category.name }}
|
||||||
|
</span>
|
||||||
|
<div class="card-actions">
|
||||||
|
<el-button text size="small" @click="openEdit(cert)"><el-icon><Edit /></el-icon></el-button>
|
||||||
|
<el-button text size="small" type="danger" @click="handleDelete(cert)"><el-icon><Delete /></el-icon></el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CertificateDialog :visible="dialogVisible" :editing-cert="editingCert" @close="dialogVisible = false" @saved="onSaved" />
|
||||||
|
|
||||||
|
<!-- Category Dialog -->
|
||||||
|
<el-dialog v-model="catDialogVisible" :title="editingCat ? '编辑分类' : '新建分类'" width="400px" destroy-on-close>
|
||||||
|
<el-form :model="catForm" label-position="top">
|
||||||
|
<el-form-item label="分类名称" required>
|
||||||
|
<el-input v-model="catForm.name" maxlength="50" placeholder="例如:比赛证书" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="颜色">
|
||||||
|
<div class="color-row">
|
||||||
|
<button v-for="c in ['#FFB7C5','#FF6B81','#FFA502','#7BED9F','#70A1FF','#5352ED','#A29BFE','#FF7979']" :key="c"
|
||||||
|
class="color-dot" :class="{ active: catForm.color === c }" :style="{ background: c }" @click.prevent="catForm.color = c" />
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button v-if="editingCat" type="danger" text @click="handleCatDelete">删除分类</el-button>
|
||||||
|
<el-button @click="catDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleCatSave">{{ editingCat ? '保存' : '创建' }}</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.cert-page {
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
h2 { margin: 0; font-size: 22px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-sidebar {
|
||||||
|
width: 180px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
h3 { margin: 0; font-size: 13px; color: #999; text-transform: uppercase; }
|
||||||
|
}
|
||||||
|
.cat-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
transition: all 0.15s;
|
||||||
|
&:hover { background: #f5f5f5; }
|
||||||
|
&.active { background: #fef0f5; color: var(--primary); font-weight: 500; }
|
||||||
|
.cat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.cat-actions { margin-left: auto; opacity: 0; }
|
||||||
|
&:hover .cat-actions { opacity: 1; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-grid {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
&.expired { opacity: 0.7; }
|
||||||
|
&:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||||
|
.card-image {
|
||||||
|
height: 160px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fafafa;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
}
|
||||||
|
.card-body {
|
||||||
|
padding: 14px;
|
||||||
|
.card-title { margin: 0 0 8px; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.card-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
.meta-item { font-size: 12px; color: #999; display: flex; align-items: center; gap: 4px; }
|
||||||
|
}
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
.cat-badge { font-size: 12px; margin-left: auto; }
|
||||||
|
.card-actions { display: flex; gap: 2px; opacity: 0; }
|
||||||
|
}
|
||||||
|
&:hover .card-actions { opacity: 1; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state, .empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
color: #999;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
.color-dot {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover { transform: scale(1.15); }
|
||||||
|
&.active { border-color: #333; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
775
WebUI/src/views/GoalDetailPage.vue
Normal file
775
WebUI/src/views/GoalDetailPage.vue
Normal file
@@ -0,0 +1,775 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ArrowLeft, Plus, Delete } from '@element-plus/icons-vue'
|
||||||
|
import { useGoalStore } from '@/stores/useGoalStore'
|
||||||
|
import { useTaskStore } from '@/stores/useTaskStore'
|
||||||
|
import { reorderSteps } from '@/api/goals'
|
||||||
|
import GoalDialog from '@/components/GoalDialog.vue'
|
||||||
|
import type { Goal, GoalStep, GoalDetail } from '@/api/types'
|
||||||
|
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const goalStore = useGoalStore()
|
||||||
|
const taskStore = useTaskStore()
|
||||||
|
|
||||||
|
const goalId = computed(() => Number(route.params.id))
|
||||||
|
|
||||||
|
const stepFormVisible = ref(false)
|
||||||
|
const stepParentId = ref<number | null>(null)
|
||||||
|
const stepTitle = ref('')
|
||||||
|
const stepType = ref<'phase' | 'milestone'>('milestone')
|
||||||
|
|
||||||
|
const reviewFormVisible = ref(false)
|
||||||
|
const reviewContent = ref('')
|
||||||
|
const reviewRating = ref<number | null>(null)
|
||||||
|
|
||||||
|
const checkinFormVisible = ref(false)
|
||||||
|
const checkinValue = ref<number>(0)
|
||||||
|
const checkinNote = ref('')
|
||||||
|
const checkinDate = ref(new Date().toISOString().slice(0, 10))
|
||||||
|
|
||||||
|
const editDialogVisible = ref(false)
|
||||||
|
const linkTaskVisible = ref(false)
|
||||||
|
const selectedTaskId = ref<number | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await goalStore.fetchGoal(goalId.value)
|
||||||
|
await taskStore.fetchTasks()
|
||||||
|
})
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.push('/goals')
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepLabel: Record<string, string> = {
|
||||||
|
pending: '待开始',
|
||||||
|
in_progress: '进行中',
|
||||||
|
completed: '已完成',
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPhaseParent(step: GoalStep): boolean {
|
||||||
|
if (!step.parent_id) return false
|
||||||
|
return goalStore.currentGoal?.steps.some(
|
||||||
|
(s: GoalStep) => s.id === step.parent_id && s.step_type === 'phase'
|
||||||
|
) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseParentIds = computed(() => {
|
||||||
|
if (!goalStore.currentGoal) return new Set<number>()
|
||||||
|
return new Set(
|
||||||
|
goalStore.currentGoal.steps
|
||||||
|
.filter((s: GoalStep) => s.step_type === 'phase')
|
||||||
|
.map((s: GoalStep) => s.id)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const stepColor: Record<string, string> = {
|
||||||
|
pending: '#909399',
|
||||||
|
in_progress: '#E6A23C',
|
||||||
|
completed: '#67C23A',
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleStep(stepId: number) {
|
||||||
|
await goalStore.toggleStep(goalId.value, stepId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteStep(stepId: number) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定删除此步骤吗?', '删除确认', { type: 'warning' })
|
||||||
|
await goalStore.deleteStep(goalId.value, stepId)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddStep() {
|
||||||
|
if (!stepTitle.value.trim()) return
|
||||||
|
await goalStore.createStep(goalId.value, {
|
||||||
|
title: stepTitle.value,
|
||||||
|
step_type: stepType.value,
|
||||||
|
status: 'pending',
|
||||||
|
parent_id: stepParentId.value,
|
||||||
|
})
|
||||||
|
stepTitle.value = ''
|
||||||
|
stepParentId.value = null
|
||||||
|
stepFormVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddReview() {
|
||||||
|
if (!reviewContent.value.trim()) return
|
||||||
|
await goalStore.createReview(goalId.value, {
|
||||||
|
content: reviewContent.value,
|
||||||
|
rating: reviewRating.value,
|
||||||
|
})
|
||||||
|
reviewContent.value = ''
|
||||||
|
reviewRating.value = null
|
||||||
|
reviewFormVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteReview(reviewId: number) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定删除此复盘记录吗?', '删除确认', { type: 'warning' })
|
||||||
|
await goalStore.deleteReview(goalId.value, reviewId)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Checkin handlers ============
|
||||||
|
|
||||||
|
async function handleAddCheckin() {
|
||||||
|
if (checkinValue.value <= 0) return
|
||||||
|
await goalStore.createCheckin(goalId.value, {
|
||||||
|
value: checkinValue.value,
|
||||||
|
note: checkinNote.value || null,
|
||||||
|
checkin_date: checkinDate.value,
|
||||||
|
})
|
||||||
|
checkinValue.value = 0
|
||||||
|
checkinNote.value = ''
|
||||||
|
checkinDate.value = new Date().toISOString().slice(0, 10)
|
||||||
|
checkinFormVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteCheckin(checkinId: number) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定删除此打卡记录吗?', '删除确认', { type: 'warning' })
|
||||||
|
await goalStore.deleteCheckin(goalId.value, checkinId)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentGoalCheckins = computed(() => {
|
||||||
|
return goalStore.currentGoal?.checkins ?? []
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatCheckinProgress(goal: GoalDetail): string {
|
||||||
|
if (!goal.target_value) return ''
|
||||||
|
const rate = goal.conversion_rate || 1
|
||||||
|
const progressInTarget = goal.current_value / rate
|
||||||
|
return `${progressInTarget.toFixed(1)} / ${goal.target_value} ${goal.target_unit || ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLinkTask() {
|
||||||
|
if (!selectedTaskId.value) return
|
||||||
|
await goalStore.linkTask(goalId.value, selectedTaskId.value)
|
||||||
|
selectedTaskId.value = null
|
||||||
|
linkTaskVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnlinkTask(taskId: number) {
|
||||||
|
await goalStore.unlinkTask(goalId.value, taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const editableGoal = computed(() => goalStore.currentGoal ? {
|
||||||
|
id: goalStore.currentGoal.id,
|
||||||
|
title: goalStore.currentGoal.title,
|
||||||
|
description: goalStore.currentGoal.description ?? null,
|
||||||
|
status: goalStore.currentGoal.status,
|
||||||
|
track_type: goalStore.currentGoal.track_type ?? 'milestone',
|
||||||
|
target_value: goalStore.currentGoal.target_value ?? null,
|
||||||
|
target_unit: goalStore.currentGoal.target_unit ?? null,
|
||||||
|
input_unit: goalStore.currentGoal.input_unit ?? null,
|
||||||
|
conversion_rate: goalStore.currentGoal.conversion_rate ?? 1.0,
|
||||||
|
target_date: goalStore.currentGoal.target_date ?? null,
|
||||||
|
category_id: goalStore.currentGoal.category_id ?? null,
|
||||||
|
color: goalStore.currentGoal.color,
|
||||||
|
icon: goalStore.currentGoal.icon,
|
||||||
|
sort_order: goalStore.currentGoal.sort_order,
|
||||||
|
} as Goal : null)
|
||||||
|
|
||||||
|
const unlinkedTasks = computed(() => {
|
||||||
|
if (!goalStore.currentGoal) return []
|
||||||
|
const linkedIds = new Set(goalStore.currentGoal.tasks.map(t => t.id))
|
||||||
|
return taskStore.activeTasks.filter(t => !linkedIds.has(t.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============ Drag & Drop ============
|
||||||
|
interface DragItem {
|
||||||
|
id: number
|
||||||
|
step_type: string
|
||||||
|
parent_id: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragItem = ref<DragItem | null>(null)
|
||||||
|
const dragOverId = ref<number | null>(null)
|
||||||
|
|
||||||
|
function onDragStart(step: GoalStep) {
|
||||||
|
dragItem.value = { id: step.id, step_type: step.step_type, parent_id: step.parent_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e: DragEvent, step: GoalStep) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (dragItem.value && dragItem.value.id !== step.id) {
|
||||||
|
dragOverId.value = step.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave() {
|
||||||
|
dragOverId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDrop(targetStep: GoalStep) {
|
||||||
|
dragOverId.value = null
|
||||||
|
if (!dragItem.value || dragItem.value.id === targetStep.id) return
|
||||||
|
if (!goalStore.currentGoal) return
|
||||||
|
|
||||||
|
const src = dragItem.value
|
||||||
|
const currentGoal = goalStore.currentGoal
|
||||||
|
|
||||||
|
// Only allow reorder within the same parent scope
|
||||||
|
if (src.parent_id !== targetStep.parent_id) {
|
||||||
|
dragItem.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const siblingSteps = currentGoal.steps.filter(
|
||||||
|
(s: GoalStep) => s.parent_id === targetStep.parent_id && s.step_type === targetStep.step_type
|
||||||
|
)
|
||||||
|
const children = targetStep.parent_id
|
||||||
|
? (currentGoal.steps.find((s: GoalStep) => s.id === targetStep.parent_id)?.children || [])
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Build ordered list: remove dragged item, insert at target position
|
||||||
|
const ordered = siblingSteps.filter((s: GoalStep) => s.id !== src.id)
|
||||||
|
const targetIdx = ordered.findIndex((s: GoalStep) => s.id === targetStep.id)
|
||||||
|
ordered.splice(targetIdx, 0, currentGoal.steps.find((s: GoalStep) => s.id === src.id)!)
|
||||||
|
|
||||||
|
// Assign new sort_order values
|
||||||
|
const items = ordered.map((s: GoalStep, i: number) => ({ id: s.id, sort_order: i }))
|
||||||
|
|
||||||
|
// Optimistic local update
|
||||||
|
for (const item of items) {
|
||||||
|
const step = currentGoal.steps.find((s: GoalStep) => s.id === item.id)
|
||||||
|
if (step) (step as any).sort_order = item.sort_order
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist to backend
|
||||||
|
try {
|
||||||
|
await reorderSteps(currentGoal.id, items)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('排序更新失败')
|
||||||
|
await goalStore.fetchGoal(currentGoal.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
dragItem.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
dragItem.value = null
|
||||||
|
dragOverId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
active: '进行中', paused: '已暂停', completed: '已完成', abandoned: '已放弃',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="goal-detail" v-if="goalStore.currentGoal">
|
||||||
|
<div class="detail-header">
|
||||||
|
<el-button text :icon="ArrowLeft" @click="goBack">返回</el-button>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button @click="editDialogVisible = true">编辑目标</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="goal-hero" :style="{ borderColor: goalStore.currentGoal.color }">
|
||||||
|
<div class="hero-left">
|
||||||
|
<el-icon :size="36" :color="goalStore.currentGoal.color">
|
||||||
|
<component :is="goalStore.currentGoal.icon" />
|
||||||
|
</el-icon>
|
||||||
|
<div>
|
||||||
|
<h1>{{ goalStore.currentGoal.title }}</h1>
|
||||||
|
<p v-if="goalStore.currentGoal.description" class="hero-desc">{{ goalStore.currentGoal.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-right">
|
||||||
|
<div class="big-progress">{{ goalStore.currentGoal.progress }}%</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="goalStore.currentGoal.progress"
|
||||||
|
:color="goalStore.currentGoal.color"
|
||||||
|
:stroke-width="10"
|
||||||
|
:show-text="false"
|
||||||
|
style="width:140px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-meta">
|
||||||
|
<el-tag>{{ statusLabel[goalStore.currentGoal.status] || goalStore.currentGoal.status }}</el-tag>
|
||||||
|
<span v-if="goalStore.currentGoal.track_type === 'cumulative' && goalStore.currentGoal.target_value">
|
||||||
|
{{ formatCheckinProgress(goalStore.currentGoal) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="goalStore.currentGoal.target_date">目标日期: {{ goalStore.currentGoal.target_date }}</span>
|
||||||
|
<span v-if="goalStore.currentGoal.category">
|
||||||
|
<el-icon :size="14"><Folder /></el-icon>
|
||||||
|
{{ goalStore.currentGoal.category.name }}
|
||||||
|
</span>
|
||||||
|
<span v-if="goalStore.currentGoal.track_type !== 'cumulative'">{{ goalStore.currentGoal.completed_steps }}/{{ goalStore.currentGoal.total_steps }} 里程碑完成</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-body">
|
||||||
|
<!-- 累计打卡模式 -->
|
||||||
|
<section v-if="goalStore.currentGoal.track_type === 'cumulative'" class="checkins-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>每日打卡</h3>
|
||||||
|
<el-button size="small" :icon="Plus" @click="checkinFormVisible = true">记录打卡</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="checkinFormVisible" class="checkin-form">
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item :label="`数值 (${goalStore.currentGoal.input_unit || '次'})`">
|
||||||
|
<el-input-number v-model="checkinValue" :min="0.01" :precision="1" style="width:100%" controls-position="right" @keyup.enter="handleAddCheckin" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="日期">
|
||||||
|
<el-date-picker v-model="checkinDate" type="date" style="width:100%" value-format="YYYY-MM-DD" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="checkinNote" placeholder="可选" maxlength="200" @keyup.enter="handleAddCheckin" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<div class="checkin-form-actions">
|
||||||
|
<el-button size="small" type="primary" @click="handleAddCheckin" :disabled="checkinValue <= 0">提交</el-button>
|
||||||
|
<el-button size="small" @click="checkinFormVisible = false">取消</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="goalStore.currentGoal.target_value" class="checkin-summary">
|
||||||
|
<div class="summary-item">
|
||||||
|
<span class="summary-label">累计</span>
|
||||||
|
<span class="summary-value">{{ goalStore.currentGoal.current_value.toFixed(1) }} {{ goalStore.currentGoal.input_unit || '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<span class="summary-label">折算</span>
|
||||||
|
<span class="summary-value">{{ (goalStore.currentGoal.current_value / (goalStore.currentGoal.conversion_rate || 1)).toFixed(1) }} {{ goalStore.currentGoal.target_unit || '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<span class="summary-label">目标</span>
|
||||||
|
<span class="summary-value">{{ goalStore.currentGoal.target_value }} {{ goalStore.currentGoal.target_unit || '' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="currentGoalCheckins.length === 0" class="empty-hint">还没有打卡记录,点击上方按钮开始记录</div>
|
||||||
|
<div v-for="checkin in currentGoalCheckins" :key="checkin.id" class="checkin-row">
|
||||||
|
<div class="checkin-left">
|
||||||
|
<span class="checkin-value">+{{ checkin.value }} {{ goalStore.currentGoal.input_unit || '' }}</span>
|
||||||
|
<span v-if="checkin.note" class="checkin-note">{{ checkin.note }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="checkin-right">
|
||||||
|
<span class="checkin-date">{{ checkin.checkin_date }}</span>
|
||||||
|
<el-button text size="small" type="danger" :icon="Delete" @click="handleDeleteCheckin(checkin.id)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 里程碑模式:阶段 & 里程碑 -->
|
||||||
|
<section v-if="goalStore.currentGoal.track_type !== 'cumulative'" class="steps-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>阶段 & 里程碑</h3>
|
||||||
|
<el-button size="small" :icon="Plus" @click="stepFormVisible = true; stepType = 'milestone'">添加里程碑</el-button>
|
||||||
|
<el-button size="small" :icon="Plus" @click="stepFormVisible = true; stepType = 'phase'">添加阶段</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="stepFormVisible" class="step-form">
|
||||||
|
<el-input v-model="stepTitle" placeholder="步骤名称" size="small" style="flex:1" @keyup.enter="handleAddStep" />
|
||||||
|
<el-select v-model="stepType" size="small" style="width:100px">
|
||||||
|
<el-option label="里程碑" value="milestone" />
|
||||||
|
<el-option label="阶段" value="phase" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-if="stepType === 'milestone'" v-model="stepParentId" size="small" style="width:140px" clearable placeholder="所属阶段(可选)">
|
||||||
|
<el-option
|
||||||
|
v-for="s in goalStore.currentGoal.steps.filter((s: GoalStep) => s.step_type === 'phase')"
|
||||||
|
:key="s.id" :label="s.title" :value="s.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-button size="small" type="primary" @click="handleAddStep">添加</el-button>
|
||||||
|
<el-button size="small" @click="stepFormVisible = false">取消</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="goalStore.currentGoal.steps.length === 0" class="empty-hint">还没有阶段或里程碑,点击上方按钮添加</div>
|
||||||
|
|
||||||
|
<div v-for="step in goalStore.currentGoal.steps" :key="step.id" class="step-tree">
|
||||||
|
<!-- Phase -->
|
||||||
|
<div v-if="step.step_type === 'phase'" class="phase-node">
|
||||||
|
<div
|
||||||
|
class="step-row"
|
||||||
|
:class="[step.status, { 'drag-over': dragOverId === step.id, 'dragging': dragItem?.id === step.id }]"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart(step)"
|
||||||
|
@dragover="onDragOver($event, step)"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@drop="onDrop(step)"
|
||||||
|
@dragend="onDragEnd"
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
text
|
||||||
|
class="toggle-btn"
|
||||||
|
:style="{ color: stepColor[step.status] }"
|
||||||
|
@click="handleToggleStep(step.id)"
|
||||||
|
>
|
||||||
|
<el-icon :size="18">
|
||||||
|
<CircleCheck v-if="step.status === 'completed'" />
|
||||||
|
<Loading v-else-if="step.status === 'in_progress'" />
|
||||||
|
<CirclePlus v-else />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
<span class="step-title">{{ step.title }}</span>
|
||||||
|
<el-tag size="small" :type="step.status === 'completed' ? 'success' : step.status === 'in_progress' ? 'warning' : 'info'">
|
||||||
|
{{ stepLabel[step.status] }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="step.target_date" class="step-date">{{ step.target_date }}</span>
|
||||||
|
<el-button text size="small" type="danger" :icon="Delete" @click="handleDeleteStep(step.id)" />
|
||||||
|
</div>
|
||||||
|
<!-- child milestones -->
|
||||||
|
<div v-for="child in step.children" :key="child.id" class="child-milestone">
|
||||||
|
<div
|
||||||
|
class="step-row"
|
||||||
|
:class="[child.status, { 'drag-over': dragOverId === child.id, 'dragging': dragItem?.id === child.id }]"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart(child)"
|
||||||
|
@dragover="onDragOver($event, child)"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@drop="onDrop(child)"
|
||||||
|
@dragend="onDragEnd"
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
text
|
||||||
|
class="toggle-btn"
|
||||||
|
:style="{ color: stepColor[child.status] }"
|
||||||
|
@click="handleToggleStep(child.id)"
|
||||||
|
>
|
||||||
|
<el-icon :size="16">
|
||||||
|
<CircleCheck v-if="child.status === 'completed'" />
|
||||||
|
<Loading v-else-if="child.status === 'in_progress'" />
|
||||||
|
<CirclePlus v-else />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
<span class="step-title">{{ child.title }}</span>
|
||||||
|
<el-tag size="small" :type="child.status === 'completed' ? 'success' : child.status === 'in_progress' ? 'warning' : 'info'">
|
||||||
|
{{ stepLabel[child.status] }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="child.target_date" class="step-date">{{ child.target_date }}</span>
|
||||||
|
<el-button text size="small" type="danger" :icon="Delete" @click="handleDeleteStep(child.id)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Standalone milestone (no parent phase) -->
|
||||||
|
<div
|
||||||
|
v-else-if="!hasPhaseParent(step)"
|
||||||
|
class="step-row standalone"
|
||||||
|
:class="[step.status, { 'drag-over': dragOverId === step.id, 'dragging': dragItem?.id === step.id }]"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart(step)"
|
||||||
|
@dragover="onDragOver($event, step)"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@drop="onDrop(step)"
|
||||||
|
@dragend="onDragEnd"
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
text
|
||||||
|
class="toggle-btn"
|
||||||
|
:style="{ color: stepColor[step.status] }"
|
||||||
|
@click="handleToggleStep(step.id)"
|
||||||
|
>
|
||||||
|
<el-icon :size="18">
|
||||||
|
<CircleCheck v-if="step.status === 'completed'" />
|
||||||
|
<Loading v-else-if="step.status === 'in_progress'" />
|
||||||
|
<CirclePlus v-else />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
<span class="step-title">{{ step.title }}</span>
|
||||||
|
<el-tag size="small" type="info" effect="plain">里程碑</el-tag>
|
||||||
|
<el-tag size="small" :type="step.status === 'completed' ? 'success' : step.status === 'in_progress' ? 'warning' : 'info'">
|
||||||
|
{{ stepLabel[step.status] }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="step.target_date" class="step-date">{{ step.target_date }}</span>
|
||||||
|
<el-button text size="small" type="danger" :icon="Delete" @click="handleDeleteStep(step.id)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 关联任务 -->
|
||||||
|
<section class="tasks-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>关联任务</h3>
|
||||||
|
<el-button size="small" :icon="Plus" @click="linkTaskVisible = true">关联任务</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="linkTaskVisible" class="link-form">
|
||||||
|
<el-select v-model="selectedTaskId" placeholder="选择要关联的任务" size="small" style="flex:1" filterable>
|
||||||
|
<el-option v-for="t in unlinkedTasks" :key="t.id" :label="t.title" :value="t.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-button size="small" type="primary" @click="handleLinkTask" :disabled="!selectedTaskId">关联</el-button>
|
||||||
|
<el-button size="small" @click="linkTaskVisible = false">取消</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="goalStore.currentGoal.tasks.length === 0" class="empty-hint">暂无关联任务</div>
|
||||||
|
<div v-for="task in goalStore.currentGoal.tasks" :key="task.id" class="linked-task">
|
||||||
|
<span class="task-title">{{ task.title }}</span>
|
||||||
|
<span v-if="task.is_completed" class="done-badge">已完成</span>
|
||||||
|
<span v-else class="pending-badge">进行中</span>
|
||||||
|
<el-button text size="small" type="danger" @click="handleUnlinkTask(task.id)">取消关联</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 复盘记录 -->
|
||||||
|
<section class="reviews-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>复盘记录</h3>
|
||||||
|
<el-button size="small" :icon="Plus" @click="reviewFormVisible = true">添加复盘</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="reviewFormVisible" class="review-form">
|
||||||
|
<el-input
|
||||||
|
v-model="reviewContent"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="记录本次复盘的思考和收获..."
|
||||||
|
/>
|
||||||
|
<div class="review-form-actions">
|
||||||
|
<span>自评:</span>
|
||||||
|
<el-rate v-model="reviewRating" :max="5" show-score />
|
||||||
|
<el-button size="small" type="primary" @click="handleAddReview" :disabled="!reviewContent.trim()">提交</el-button>
|
||||||
|
<el-button size="small" @click="reviewFormVisible = false">取消</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="goalStore.currentGoal.reviews.length === 0" class="empty-hint">暂无复盘记录</div>
|
||||||
|
<div v-for="review in goalStore.currentGoal.reviews" :key="review.id" class="review-card">
|
||||||
|
<div class="review-meta">
|
||||||
|
<span class="review-date">{{ review.created_at.slice(0, 10) }}</span>
|
||||||
|
<el-rate v-if="review.rating" :model-value="review.rating" :max="5" disabled show-score size="small" />
|
||||||
|
<el-button text size="small" type="danger" :icon="Delete" @click="handleDeleteReview(review.id)" />
|
||||||
|
</div>
|
||||||
|
<p class="review-content">{{ review.content }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GoalDialog
|
||||||
|
:visible="editDialogVisible"
|
||||||
|
:editing-goal="editableGoal"
|
||||||
|
@close="editDialogVisible = false"
|
||||||
|
@saved="editDialogVisible = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="goalStore.loading" class="loading-state">
|
||||||
|
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.goal-detail {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-left: 5px solid #FFB7C5;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.hero-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
h1 { margin: 0; font-size: 22px; }
|
||||||
|
.hero-desc { margin: 6px 0 0; color: #999; font-size: 14px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-right {
|
||||||
|
text-align: center;
|
||||||
|
.big-progress { font-size: 32px; font-weight: 800; color: #333; margin-bottom: 8px; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
h3 { margin: 0; font-size: 16px; flex:1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 16px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steps
|
||||||
|
.step-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
cursor: grab;
|
||||||
|
&:hover { background: #fafafa; }
|
||||||
|
&:active { cursor: grabbing; }
|
||||||
|
|
||||||
|
&.dragging { opacity: 0.4; }
|
||||||
|
&.drag-over { background: #e6f7ff; border: 1px dashed #69b1ff; }
|
||||||
|
|
||||||
|
.toggle-btn { padding: 4px; flex-shrink: 0; cursor: pointer; }
|
||||||
|
.step-title { flex: 1; font-size: 14px; }
|
||||||
|
.step-date { font-size: 12px; color: #bbb; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-node {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
.phase-node { padding-left: 32px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-milestone {
|
||||||
|
padding-left: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone {
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reviews
|
||||||
|
.review-form {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
.review-form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-card {
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
padding: 12px 0;
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.review-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
.review-date { font-size: 12px; color: #999; }
|
||||||
|
}
|
||||||
|
.review-content { font-size: 14px; line-height: 1.6; color: #555; margin: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tasks
|
||||||
|
.link-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-task {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
.task-title { flex: 1; font-size: 14px; }
|
||||||
|
.done-badge { font-size: 12px; color: #67C23A; }
|
||||||
|
.pending-badge { font-size: 12px; color: #E6A23C; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state { text-align: center; padding: 80px; }
|
||||||
|
|
||||||
|
// Checkins (累计打卡)
|
||||||
|
.checkin-form {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 8px;
|
||||||
|
.checkin-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkin-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: linear-gradient(135deg, #f5f7fa, #e8ecf1);
|
||||||
|
border-radius: 8px;
|
||||||
|
.summary-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
.summary-label { font-size: 12px; color: #999; }
|
||||||
|
.summary-value { font-size: 16px; font-weight: 700; color: #333; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkin-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 4px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
.checkin-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
.checkin-value { font-size: 16px; font-weight: 600; color: #67C23A; }
|
||||||
|
.checkin-note { font-size: 13px; color: #999; }
|
||||||
|
}
|
||||||
|
.checkin-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
.checkin-date { font-size: 12px; color: #bbb; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
249
WebUI/src/views/GoalPage.vue
Normal file
249
WebUI/src/views/GoalPage.vue
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import { useGoalStore } from '@/stores/useGoalStore'
|
||||||
|
import GoalDialog from '@/components/GoalDialog.vue'
|
||||||
|
import type { Goal } from '@/api/types'
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const goalStore = useGoalStore()
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editingGoal = ref<Goal | null>(null)
|
||||||
|
const statusFilter = ref<string>('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await goalStore.fetchGoals()
|
||||||
|
})
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingGoal.value = null
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(goal: Goal) {
|
||||||
|
editingGoal.value = goal
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(goal: Goal) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除目标「${goal.title}」吗?`, '删除确认', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
await goalStore.deleteGoal(goal.id)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDetail(id: number) {
|
||||||
|
router.push(`/goals/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSaved() {
|
||||||
|
dialogVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredGoals = ref<Goal[]>([])
|
||||||
|
function applyFilter() {
|
||||||
|
goalStore.fetchGoals(statusFilter.value || undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
active: '进行中',
|
||||||
|
paused: '已暂停',
|
||||||
|
completed: '已完成',
|
||||||
|
abandoned: '已放弃',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColor: Record<string, string> = {
|
||||||
|
active: '#67C23A',
|
||||||
|
paused: '#E6A23C',
|
||||||
|
completed: '#409EFF',
|
||||||
|
abandoned: '#909399',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="goal-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>目标管理</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-select v-model="statusFilter" placeholder="全部状态" clearable style="width:140px" @change="applyFilter">
|
||||||
|
<el-option label="进行中" value="active" />
|
||||||
|
<el-option label="已暂停" value="paused" />
|
||||||
|
<el-option label="已完成" value="completed" />
|
||||||
|
<el-option label="已放弃" value="abandoned" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" :icon="Plus" @click="openCreate">新建目标</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="goalStore.loading" class="loading-state">
|
||||||
|
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="goalStore.goals.length === 0" class="empty-state">
|
||||||
|
<el-icon :size="64" color="#ccc"><Flag /></el-icon>
|
||||||
|
<p>还没有目标</p>
|
||||||
|
<p class="hint">设定一个长期目标,拆解成阶段和里程碑来追踪进度</p>
|
||||||
|
<el-button type="primary" @click="openCreate">创建第一个目标</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="goal-grid">
|
||||||
|
<div
|
||||||
|
v-for="goal in goalStore.goals"
|
||||||
|
:key="goal.id"
|
||||||
|
class="goal-card"
|
||||||
|
:style="{ borderLeftColor: goal.color }"
|
||||||
|
@click="goDetail(goal.id)"
|
||||||
|
>
|
||||||
|
<div class="card-top">
|
||||||
|
<el-icon :size="24" :color="goal.color"><component :is="goal.icon" /></el-icon>
|
||||||
|
<div class="card-actions" @click.stop>
|
||||||
|
<el-button text size="small" @click="openEdit(goal)">编辑</el-button>
|
||||||
|
<el-button text size="small" type="danger" @click="handleDelete(goal)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="card-title">{{ goal.title }}</h3>
|
||||||
|
<p v-if="goal.description" class="card-desc">{{ goal.description }}</p>
|
||||||
|
|
||||||
|
<div class="progress-section">
|
||||||
|
<div class="progress-info">
|
||||||
|
<span class="progress-text">{{ goal.progress }}%</span>
|
||||||
|
<span v-if="goal.track_type === 'cumulative' && goal.target_value" class="progress-steps">
|
||||||
|
{{ (goal.current_value / (goal.conversion_rate || 1)).toFixed(1) }}/{{ goal.target_value }} {{ goal.target_unit || '' }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="progress-steps">{{ goal.completed_steps }}/{{ goal.total_steps }} 里程碑</span>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="goal.progress"
|
||||||
|
:color="goal.color"
|
||||||
|
:stroke-width="8"
|
||||||
|
:show-text="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
<el-tag :color="statusColor[goal.status]" effect="dark" size="small">
|
||||||
|
{{ statusLabel[goal.status] || goal.status }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="goal.target_date" class="target-date">目标日期: {{ goal.target_date }}</span>
|
||||||
|
<span v-if="goal.category" class="category-badge">
|
||||||
|
<el-icon :size="14"><Folder /></el-icon>
|
||||||
|
{{ goal.category.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GoalDialog :visible="dialogVisible" :editing-goal="editingGoal" @close="dialogVisible = false" @saved="onSaved" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.goal-page {
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
h2 { margin: 0; font-size: 22px; }
|
||||||
|
.header-actions { display: flex; gap: 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state, .empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
color: #999;
|
||||||
|
.hint { font-size: 13px; color: #bbb; margin-top: 8px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
border-left: 4px solid #FFB7C5;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.card-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.15s; }
|
||||||
|
}
|
||||||
|
.goal-card:hover .card-actions { opacity: 1; }
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-section {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
|
||||||
|
.progress-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
.progress-text { font-weight: 700; color: #333; }
|
||||||
|
.progress-steps { color: #999; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
|
||||||
|
.category-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -17,7 +17,7 @@ const error = ref('')
|
|||||||
const redirect = (route.query.redirect as string) || '/'
|
const redirect = (route.query.redirect as string) || '/'
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
if (!password.value) return
|
if (!password.value || loading.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
@@ -26,7 +26,11 @@ async function handleLogin() {
|
|||||||
await userSettingsStore.fetchAndSync()
|
await userSettingsStore.fetchAndSync()
|
||||||
router.replace(redirect)
|
router.replace(redirect)
|
||||||
} else {
|
} else {
|
||||||
error.value = authStore.error || '密码错误'
|
const msg = authStore.error || '密码错误'
|
||||||
|
error.value = msg
|
||||||
|
if (msg.includes('请先设置密码')) {
|
||||||
|
router.replace({ path: '/setup', query: route.query })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { useTaskStore } from '@/stores/useTaskStore'
|
|||||||
import { useCategoryStore } from '@/stores/useCategoryStore'
|
import { useCategoryStore } from '@/stores/useCategoryStore'
|
||||||
import { useTagStore } from '@/stores/useTagStore'
|
import { useTagStore } from '@/stores/useTagStore'
|
||||||
import { useHabitStore } from '@/stores/useHabitStore'
|
import { useHabitStore } from '@/stores/useHabitStore'
|
||||||
import { get, post, del } from '@/api/request'
|
import { useGoalStore } from '@/stores/useGoalStore'
|
||||||
|
import { useAnniversaryStore } from '@/stores/useAnniversaryStore'
|
||||||
|
import { useSyncStore, type SyncDirection } from '@/stores/useSyncStore'
|
||||||
|
import { exportBackup, importBackup } from '@/api/backup'
|
||||||
import type { Task, Category, Tag, HabitGroup, Habit } from '@/api/types'
|
import type { Task, Category, Tag, HabitGroup, Habit } from '@/api/types'
|
||||||
|
|
||||||
const userStore = useUserSettingsStore()
|
const userStore = useUserSettingsStore()
|
||||||
@@ -14,9 +17,13 @@ const taskStore = useTaskStore()
|
|||||||
const categoryStore = useCategoryStore()
|
const categoryStore = useCategoryStore()
|
||||||
const tagStore = useTagStore()
|
const tagStore = useTagStore()
|
||||||
const habitStore = useHabitStore()
|
const habitStore = useHabitStore()
|
||||||
|
const goalStore = useGoalStore()
|
||||||
|
const anniversaryStore = useAnniversaryStore()
|
||||||
|
const syncStore = useSyncStore()
|
||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const exporting = ref(false)
|
const exporting = ref(false)
|
||||||
|
const importing = ref(false)
|
||||||
|
|
||||||
const viewOptions = [
|
const viewOptions = [
|
||||||
{ label: '列表', value: 'list' },
|
{ label: '列表', value: 'list' },
|
||||||
@@ -42,13 +49,48 @@ const prefs = ref({
|
|||||||
default_sort_order: 'desc'
|
default_sort_order: 'desc'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const webdavConfig = ref({
|
||||||
|
webdav_url: '',
|
||||||
|
webdav_username: '',
|
||||||
|
webdav_password: '',
|
||||||
|
webdav_path: '/elysia-todo/',
|
||||||
|
auto_sync: false,
|
||||||
|
auto_sync_interval: 300
|
||||||
|
})
|
||||||
|
|
||||||
|
const syncDirection = ref<SyncDirection>('sync')
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
prefs.value.site_name = userStore.siteName || '爱莉希雅待办'
|
prefs.value.site_name = userStore.siteName || '爱莉希雅待办'
|
||||||
prefs.value.default_view = userStore.defaultView || 'list'
|
prefs.value.default_view = userStore.defaultView || 'list'
|
||||||
prefs.value.default_sort_by = userStore.defaultSortBy || 'priority'
|
prefs.value.default_sort_by = userStore.defaultSortBy || 'priority'
|
||||||
prefs.value.default_sort_order = userStore.defaultSortOrder || 'desc'
|
prefs.value.default_sort_order = userStore.defaultSortOrder || 'desc'
|
||||||
|
|
||||||
|
syncStore.fetchConfig().then(() => {
|
||||||
|
if (syncStore.config) {
|
||||||
|
webdavConfig.value.webdav_url = syncStore.config.webdav_url || ''
|
||||||
|
webdavConfig.value.webdav_username = syncStore.config.webdav_username || ''
|
||||||
|
webdavConfig.value.webdav_password = ''
|
||||||
|
webdavConfig.value.webdav_path = syncStore.config.webdav_path || '/elysia-todo/'
|
||||||
|
webdavConfig.value.auto_sync = syncStore.config.auto_sync || false
|
||||||
|
webdavConfig.value.auto_sync_interval = syncStore.config.auto_sync_interval || 300
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
syncStore.fetchStatus()
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
syncStore.fetchStatus()
|
||||||
|
}, 5000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
@@ -60,7 +102,6 @@ async function handleSave() {
|
|||||||
})
|
})
|
||||||
userStore.syncFromSettings(userStore.settings!)
|
userStore.syncFromSettings(userStore.settings!)
|
||||||
|
|
||||||
// 保存排序后立即应用
|
|
||||||
taskStore.setFilters({
|
taskStore.setFilters({
|
||||||
sort_by: prefs.value.default_sort_by as 'priority' | 'due_date' | 'created_at',
|
sort_by: prefs.value.default_sort_by as 'priority' | 'due_date' | 'created_at',
|
||||||
sort_order: prefs.value.default_sort_order as 'asc' | 'desc'
|
sort_order: prefs.value.default_sort_order as 'asc' | 'desc'
|
||||||
@@ -74,35 +115,39 @@ async function handleSave() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveWebdavConfig() {
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
webdav_url: webdavConfig.value.webdav_url || null,
|
||||||
|
webdav_username: webdavConfig.value.webdav_username || null,
|
||||||
|
webdav_path: webdavConfig.value.webdav_path || '/elysia-todo/',
|
||||||
|
auto_sync: webdavConfig.value.auto_sync,
|
||||||
|
auto_sync_interval: webdavConfig.value.auto_sync_interval,
|
||||||
|
}
|
||||||
|
if (webdavConfig.value.webdav_password) {
|
||||||
|
data.webdav_password = webdavConfig.value.webdav_password
|
||||||
|
}
|
||||||
|
await syncStore.saveConfig(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testWebdavConnection() {
|
||||||
|
await syncStore.testConnection()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSync() {
|
||||||
|
await syncStore.startSync(syncDirection.value)
|
||||||
|
}
|
||||||
|
|
||||||
async function exportData() {
|
async function exportData() {
|
||||||
exporting.value = true
|
exporting.value = true
|
||||||
try {
|
try {
|
||||||
const [tasks, categories, tags, habitGroups, habits] = await Promise.all([
|
const blob = await exportBackup()
|
||||||
get<Task[]>('/tasks'),
|
|
||||||
get<Category[]>('/categories'),
|
|
||||||
get<Tag[]>('/tags'),
|
|
||||||
get<HabitGroup[]>('/habit-groups'),
|
|
||||||
get<Habit[]>('/habits', { params: { include_archived: true } })
|
|
||||||
])
|
|
||||||
|
|
||||||
const exportObj = {
|
|
||||||
version: 2,
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
tasks,
|
|
||||||
categories,
|
|
||||||
tags,
|
|
||||||
habitGroups,
|
|
||||||
habits
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = new Blob([JSON.stringify(exportObj, null, 2)], { type: 'application/json' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = `todo-backup-${new Date().toISOString().slice(0, 10)}.json`
|
const now = new Date().toISOString().slice(0, 19).replace(/:/g, '-')
|
||||||
|
a.download = `elysia-backup-${now}.json`
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
|
|
||||||
ElMessage.success('数据导出成功~')
|
ElMessage.success('数据导出成功~')
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('导出失败了呢~')
|
ElMessage.error('导出失败了呢~')
|
||||||
@@ -111,168 +156,46 @@ async function exportData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const importFileRef = ref<HTMLInputElement>()
|
||||||
|
|
||||||
function importData() {
|
function importData() {
|
||||||
const input = document.createElement('input')
|
importFileRef.value?.click()
|
||||||
input.type = 'file'
|
}
|
||||||
input.accept = '.json'
|
|
||||||
input.onchange = async (e) => {
|
|
||||||
const file = (e.target as HTMLInputElement).files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
try {
|
async function handleImportFile(e: Event) {
|
||||||
await ElMessageBox.confirm(
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
'导入数据会覆盖现有的所有任务、分类、标签和习惯数据,确定要继续吗?',
|
if (!file) return
|
||||||
'确认导入',
|
|
||||||
{ confirmButtonText: '确定导入', cancelButtonText: '取消', type: 'warning' }
|
|
||||||
)
|
|
||||||
|
|
||||||
const text = await file.text()
|
try {
|
||||||
const data = JSON.parse(text)
|
await ElMessageBox.confirm(
|
||||||
|
'导入数据会覆盖当前的所有数据(包括任务、分类、标签、习惯、纪念日、目标等),确定要继续吗?',
|
||||||
|
'确认导入',
|
||||||
|
{ confirmButtonText: '确定导入', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
)
|
||||||
|
|
||||||
if (!data.tasks || !Array.isArray(data.tasks)) {
|
importing.value = true
|
||||||
ElMessage.error('数据格式不正确呢~')
|
const result = await importBackup(file)
|
||||||
return
|
ElMessage.success(`数据导入成功~ 共导入 ${result.count} 条记录`)
|
||||||
}
|
|
||||||
|
|
||||||
// 先删除所有现有数据
|
// 刷新所有 store
|
||||||
const allTasks = await get<Task[]>('/tasks')
|
await Promise.all([
|
||||||
for (const t of allTasks) {
|
taskStore.fetchTasks(),
|
||||||
await del(`/tasks/${t.id}`)
|
categoryStore.fetchCategories(),
|
||||||
}
|
tagStore.fetchTags(),
|
||||||
|
habitStore.init(),
|
||||||
const allCategories = await get<Category[]>('/categories')
|
goalStore.fetchGoals(),
|
||||||
for (const c of allCategories) {
|
anniversaryStore.fetchCategories(),
|
||||||
await del(`/categories/${c.id}`)
|
anniversaryStore.fetchAnniversaries(),
|
||||||
}
|
])
|
||||||
|
} catch (err: any) {
|
||||||
const allTags = await get<Tag[]>('/tags')
|
if (err?.toString?.() !== 'cancel') {
|
||||||
for (const t of allTags) {
|
ElMessage.error(err?.response?.data?.detail || '导入失败了呢~')
|
||||||
await del(`/tags/${t.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除习惯数据(如果有的话)
|
|
||||||
if (data.habits && Array.isArray(data.habits)) {
|
|
||||||
const allHabits = await get<Habit[]>('/habits', { params: { include_archived: true } })
|
|
||||||
for (const h of allHabits) {
|
|
||||||
await del(`/habits/${h.id}`)
|
|
||||||
}
|
|
||||||
const allGroups = await get<HabitGroup[]>('/habit-groups')
|
|
||||||
for (const g of allGroups) {
|
|
||||||
await del(`/habit-groups/${g.id}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重新导入
|
|
||||||
if (data.categories && Array.isArray(data.categories)) {
|
|
||||||
for (const cat of data.categories) {
|
|
||||||
await post('/categories', { name: cat.name, color: cat.color, icon: cat.icon })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (data.tags && Array.isArray(data.tags)) {
|
|
||||||
for (const tag of data.tags) {
|
|
||||||
await post('/tags', { name: tag.name })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (data.tasks && Array.isArray(data.tasks)) {
|
|
||||||
// 建立新旧ID到名称的映射
|
|
||||||
const oldCatMap = new Map<number, string>()
|
|
||||||
const oldTagMap = new Map<number, string>()
|
|
||||||
if (data.categories) {
|
|
||||||
data.categories.forEach((c: Category) => oldCatMap.set(c.id, c.name))
|
|
||||||
}
|
|
||||||
if (data.tags) {
|
|
||||||
data.tags.forEach((t: Tag) => oldTagMap.set(t.id, t.name))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取新建后的分类和标签
|
|
||||||
const newCategories = await get<Category[]>('/categories')
|
|
||||||
const newTags = await get<Tag[]>('/tags')
|
|
||||||
const catNameToId = new Map(newCategories.map(c => [c.name, c.id]))
|
|
||||||
const tagNameToId = new Map(newTags.map(t => [t.name, t.id]))
|
|
||||||
|
|
||||||
for (const task of data.tasks) {
|
|
||||||
const taskData: Record<string, unknown> = {
|
|
||||||
title: task.title,
|
|
||||||
description: task.description || null,
|
|
||||||
priority: task.priority,
|
|
||||||
due_date: task.due_date || null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task.category_id && oldCatMap.has(task.category_id)) {
|
|
||||||
const catName = oldCatMap.get(task.category_id)
|
|
||||||
if (catName && catNameToId.has(catName)) {
|
|
||||||
taskData.category_id = catNameToId.get(catName)!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagIds: number[] = []
|
|
||||||
if (task.tags && Array.isArray(task.tags)) {
|
|
||||||
for (const tag of task.tags) {
|
|
||||||
if (tagNameToId.has(tag.name)) {
|
|
||||||
tagIds.push(tagNameToId.get(tag.name)!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
taskData.tag_ids = tagIds
|
|
||||||
|
|
||||||
await post('/tasks', taskData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导入习惯数据
|
|
||||||
if (data.habitGroups && Array.isArray(data.habitGroups)) {
|
|
||||||
for (const grp of data.habitGroups) {
|
|
||||||
await post('/habit-groups', { name: grp.name, color: grp.color, icon: grp.icon, sort_order: grp.sort_order })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (data.habits && Array.isArray(data.habits)) {
|
|
||||||
const oldGroupMap = new Map<number, string>()
|
|
||||||
if (data.habitGroups) {
|
|
||||||
data.habitGroups.forEach((g: HabitGroup) => oldGroupMap.set(g.id, g.name))
|
|
||||||
}
|
|
||||||
|
|
||||||
const newGroups = await get<HabitGroup[]>('/habit-groups')
|
|
||||||
const groupNameToId = new Map(newGroups.map(g => [g.name, g.id]))
|
|
||||||
|
|
||||||
for (const habit of data.habits) {
|
|
||||||
const habitData: Record<string, unknown> = {
|
|
||||||
name: habit.name,
|
|
||||||
description: habit.description || null,
|
|
||||||
target_count: habit.target_count || 1,
|
|
||||||
frequency: habit.frequency || 'daily',
|
|
||||||
active_days: habit.active_days || null,
|
|
||||||
is_archived: habit.is_archived || false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (habit.group_id && oldGroupMap.has(habit.group_id)) {
|
|
||||||
const grpName = oldGroupMap.get(habit.group_id)
|
|
||||||
if (grpName && groupNameToId.has(grpName)) {
|
|
||||||
habitData.group_id = groupNameToId.get(grpName)!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await post('/habits', habitData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新数据
|
|
||||||
await Promise.all([
|
|
||||||
taskStore.fetchTasks(),
|
|
||||||
categoryStore.fetchCategories(),
|
|
||||||
tagStore.fetchTags()
|
|
||||||
])
|
|
||||||
if (data.habits || data.habitGroups) {
|
|
||||||
await habitStore.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
ElMessage.success('数据导入成功~')
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as { toString?: () => string })?.toString?.() !== 'cancel') {
|
|
||||||
ElMessage.error('导入失败了呢~')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
importing.value = false
|
||||||
|
// 重置 file input,允许重新选择同一文件
|
||||||
|
if (importFileRef.value) importFileRef.value.value = ''
|
||||||
}
|
}
|
||||||
input.click()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearCompleted() {
|
async function clearCompleted() {
|
||||||
@@ -400,7 +323,7 @@ async function clearCompleted() {
|
|||||||
<div class="data-action-item">
|
<div class="data-action-item">
|
||||||
<div class="action-info">
|
<div class="action-info">
|
||||||
<span class="action-title">导出数据</span>
|
<span class="action-title">导出数据</span>
|
||||||
<span class="action-desc">将所有任务、分类、标签和习惯导出为 JSON 文件</span>
|
<span class="action-desc">将所有数据(任务、目标、习惯、纪念日、分类、标签等)导出为 JSON 文件</span>
|
||||||
</div>
|
</div>
|
||||||
<el-button
|
<el-button
|
||||||
:loading="exporting"
|
:loading="exporting"
|
||||||
@@ -415,9 +338,17 @@ async function clearCompleted() {
|
|||||||
<div class="data-action-item">
|
<div class="data-action-item">
|
||||||
<div class="action-info">
|
<div class="action-info">
|
||||||
<span class="action-title">导入数据</span>
|
<span class="action-title">导入数据</span>
|
||||||
<span class="action-desc warning">从 JSON 文件恢复数据(会覆盖现有数据)</span>
|
<span class="action-desc warning">从 JSON 备份文件恢复全部数据(会覆盖当前所有数据)</span>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
ref="importFileRef"
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
style="display:none"
|
||||||
|
@change="handleImportFile"
|
||||||
|
/>
|
||||||
<el-button
|
<el-button
|
||||||
|
:loading="importing"
|
||||||
@click="importData"
|
@click="importData"
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
>
|
>
|
||||||
@@ -444,6 +375,166 @@ async function clearCompleted() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 数据同步 -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-icon sync-icon">
|
||||||
|
<el-icon :size="24"><Connection /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title">数据同步</h3>
|
||||||
|
<p class="card-subtitle">通过 WebDAV 同步你的数据</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-label">
|
||||||
|
<span class="label-text">服务器地址</span>
|
||||||
|
<span class="label-desc">Alist WebDAV 地址</span>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-model="webdavConfig.webdav_url"
|
||||||
|
placeholder="https://alist.example.com/dav"
|
||||||
|
style="width: 260px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-label">
|
||||||
|
<span class="label-text">用户名</span>
|
||||||
|
<span class="label-desc">WebDAV 登录用户名</span>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-model="webdavConfig.webdav_username"
|
||||||
|
placeholder="用户名"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-label">
|
||||||
|
<span class="label-text">密码</span>
|
||||||
|
<span class="label-desc">WebDAV 登录密码</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 8px; align-items: center;">
|
||||||
|
<el-input
|
||||||
|
v-model="webdavConfig.webdav_password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
:placeholder="syncStore.config?.webdav_password ? '已保存(留空不变更)' : '输入密码'"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
<el-button @click="testWebdavConnection" :loading="syncStore.loading" size="default">
|
||||||
|
测试连接
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-label">
|
||||||
|
<span class="label-text">远端路径</span>
|
||||||
|
<span class="label-desc">WebDAV 上的存储路径</span>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-model="webdavConfig.webdav_path"
|
||||||
|
placeholder="/elysia-todo/"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="syncStore.loading"
|
||||||
|
@click="saveWebdavConfig"
|
||||||
|
class="save-btn"
|
||||||
|
>
|
||||||
|
保存配置
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 同步操作 -->
|
||||||
|
<div class="settings-card" v-if="syncStore.isConfigured">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-icon sync-icon">
|
||||||
|
<el-icon :size="24"><Refresh /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title">同步操作</h3>
|
||||||
|
<p class="card-subtitle">选择同步方向并执行</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-label">
|
||||||
|
<span class="label-text">同步方向</span>
|
||||||
|
<span class="label-desc">选择数据同步的方向</span>
|
||||||
|
</div>
|
||||||
|
<el-radio-group v-model="syncDirection">
|
||||||
|
<el-radio-button value="sync">双向合并</el-radio-button>
|
||||||
|
<el-radio-button value="push">推送</el-radio-button>
|
||||||
|
<el-radio-button value="pull">拉取</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sync-info" v-if="syncStore.status">
|
||||||
|
<span class="info-item">
|
||||||
|
<el-icon><Clock /></el-icon>
|
||||||
|
上次同步: {{ syncStore.status.last_sync_at ? new Date(syncStore.status.last_sync_at).toLocaleString() : '从未同步' }}
|
||||||
|
</span>
|
||||||
|
<span class="info-item" v-if="syncStore.status.last_sync_version > 0">
|
||||||
|
版本: v{{ syncStore.status.last_sync_version }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sync-overlay-hint" v-if="syncStore.syncing">
|
||||||
|
<el-icon class="sync-spin" :size="20"><Loading /></el-icon>
|
||||||
|
<span>{{ syncStore.syncMessage || '正在同步...' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-actions">
|
||||||
|
<div class="data-action-item">
|
||||||
|
<div class="action-info">
|
||||||
|
<span class="action-title">执行同步</span>
|
||||||
|
<span class="action-desc" v-if="syncDirection === 'push'">将本地数据推送到远端,远端数据将被覆盖</span>
|
||||||
|
<span class="action-desc warning" v-else-if="syncDirection === 'pull'">从远端拉取数据,本地数据将被覆盖</span>
|
||||||
|
<span class="action-desc" v-else>合并本地和远端数据,以最新版本为准</span>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="syncStore.syncing"
|
||||||
|
:disabled="syncStore.syncing"
|
||||||
|
@click="startSync"
|
||||||
|
class="action-btn"
|
||||||
|
>
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
{{ syncStore.syncing ? syncStore.syncMessage : '开始同步' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-action-item">
|
||||||
|
<div class="action-info">
|
||||||
|
<span class="action-title">清空远端数据</span>
|
||||||
|
<span class="action-desc danger">删除 WebDAV 上的所有同步数据(不可恢复)</span>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
@click="syncStore.clearRemote()"
|
||||||
|
class="action-btn"
|
||||||
|
>
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
清空远端
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -628,6 +719,46 @@ async function clearCompleted() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sync-icon {
|
||||||
|
background: linear-gradient(135deg, rgba(100, 200, 255, 0.2) 0%, rgba(150, 150, 255, 0.2) 100%) !important;
|
||||||
|
color: #6495ed !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-overlay-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 8px 0;
|
||||||
|
background: linear-gradient(135deg, rgba(100, 200, 255, 0.08) 0%, rgba(150, 150, 255, 0.08) 100%);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6495ed;
|
||||||
|
|
||||||
|
.sync-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.settings-page {
|
.settings-page {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|||||||
189
WebUI/src/views/SetupView.vue
Normal file
189
WebUI/src/views/SetupView.vue
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { User, Lock, Check } from '@element-plus/icons-vue'
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
import { useUserSettingsStore } from '@/stores/useUserSettingsStore'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const userSettingsStore = useUserSettingsStore()
|
||||||
|
|
||||||
|
const nickname = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const redirect = (route.query.redirect as string) || '/'
|
||||||
|
|
||||||
|
function validate(): string | null {
|
||||||
|
if (!password.value) return '请输入密码'
|
||||||
|
if (password.value.length < 6) return '密码长度至少6位'
|
||||||
|
if (new Set(password.value).size < 3) return '密码不能过于简单(需包含至少3种不同字符)'
|
||||||
|
if (password.value !== confirmPassword.value) return '两次输入的密码不一致'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSetup() {
|
||||||
|
if (loading.value) return
|
||||||
|
const msg = validate()
|
||||||
|
if (msg) {
|
||||||
|
error.value = msg
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const name = nickname.value.trim() || undefined
|
||||||
|
const ok = await authStore.setupPassword(password.value, name)
|
||||||
|
if (ok) {
|
||||||
|
await userSettingsStore.fetchAndSync()
|
||||||
|
router.replace(redirect)
|
||||||
|
} else {
|
||||||
|
error.value = authStore.error || '设置失败,请重试'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-wrapper">
|
||||||
|
<div class="decoration-star" style="top: 10%; right: 15%; animation-delay: 0s;"></div>
|
||||||
|
<div class="decoration-star" style="top: 70%; left: 10%; animation-delay: 1s;"></div>
|
||||||
|
<div class="decoration-star" style="top: 30%; left: 20%; animation-delay: 2s;"></div>
|
||||||
|
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<div class="logo-icon">✿</div>
|
||||||
|
<h1 class="site-name">欢迎到来</h1>
|
||||||
|
<p class="subtitle">首次使用,请设置你的账户信息~</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form @submit.prevent="handleSetup" class="login-form">
|
||||||
|
<el-input
|
||||||
|
v-model="nickname"
|
||||||
|
placeholder="你的昵称(不填默认为爱莉希雅)"
|
||||||
|
size="large"
|
||||||
|
:prefix-icon="User"
|
||||||
|
@keyup.enter="handleSetup"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请设置密码(至少6位)"
|
||||||
|
size="large"
|
||||||
|
show-password
|
||||||
|
:prefix-icon="Lock"
|
||||||
|
@keyup.enter="handleSetup"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="再次输入密码"
|
||||||
|
size="large"
|
||||||
|
show-password
|
||||||
|
:prefix-icon="Check"
|
||||||
|
@keyup.enter="handleSetup"
|
||||||
|
/>
|
||||||
|
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
:loading="loading"
|
||||||
|
class="login-btn"
|
||||||
|
@click="handleSetup"
|
||||||
|
>
|
||||||
|
开始使用
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.login-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #fce4ec 0%, #f8bbd0 30%, #f48fb1 60%, #fce4ec 100%);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 48px 40px;
|
||||||
|
width: 400px;
|
||||||
|
max-width: 90vw;
|
||||||
|
box-shadow: 0 8px 32px rgba(255, 183, 197, 0.3);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: var(--primary);
|
||||||
|
animation: twinkle 2s ease-in-out infinite;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-name {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
color: #f56c6c;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 16px rgba(255, 183, 197, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes twinkle {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.7; transform: scale(1.05); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
# 硬编码配置
|
# 硬编码配置
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger("app.config")
|
||||||
|
|
||||||
# api 目录的绝对路径(基于本文件位置计算,不依赖工作目录)
|
# api 目录的绝对路径(基于本文件位置计算,不依赖工作目录)
|
||||||
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
# 数据库配置
|
# 数据库配置
|
||||||
DATABASE_PATH = os.path.join(_BASE_DIR, "data", "todo.db")
|
DATABASE_URL = os.getenv(
|
||||||
DATABASE_URL = f"sqlite:///{DATABASE_PATH}"
|
"DATABASE_URL",
|
||||||
|
"postgresql://ToDoList:53N2PTSjMBPDy6zY@192.168.1.86:5432/ToDoList",
|
||||||
|
)
|
||||||
|
|
||||||
# WebUI 配置
|
# WebUI 配置
|
||||||
WEBUI_PATH = os.path.join(_BASE_DIR, "webui")
|
WEBUI_PATH = os.path.join(_BASE_DIR, "webui")
|
||||||
@@ -28,6 +34,21 @@ DEFAULT_PAGE_SIZE = 20
|
|||||||
HOST = "0.0.0.0"
|
HOST = "0.0.0.0"
|
||||||
PORT = 23994
|
PORT = 23994
|
||||||
|
|
||||||
# JWT 认证配置
|
|
||||||
JWT_SECRET = "elysia-todo-secret-key-change-in-production"
|
# JWT 密钥(首次启动随机生成,持久化到文件)
|
||||||
|
def _load_jwt_secret() -> str:
|
||||||
|
secret_file = os.path.join(_BASE_DIR, "data", ".jwt_secret")
|
||||||
|
if os.path.exists(secret_file):
|
||||||
|
with open(secret_file) as f:
|
||||||
|
return f.read().strip()
|
||||||
|
secret = secrets.token_hex(32)
|
||||||
|
os.makedirs(os.path.dirname(secret_file), exist_ok=True)
|
||||||
|
with open(secret_file, "w") as f:
|
||||||
|
f.write(secret)
|
||||||
|
_logger.warning("已生成新的 JWT 密钥")
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
|
JWT_SECRET = _load_jwt_secret()
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24小时
|
ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24小时
|
||||||
|
ACCESS_TOKEN_EXPIRE_SECONDS = ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||||
@@ -1,23 +1,18 @@
|
|||||||
from sqlalchemy import create_engine, inspect, text, String, Integer, Text, Boolean, Float, DateTime, Date
|
from sqlalchemy import create_engine, inspect, text, String, Integer, Text, Boolean, Float, DateTime, Date, event
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
import os
|
|
||||||
|
|
||||||
from app.config import DATABASE_PATH, DATABASE_URL
|
from app.config import DATABASE_URL
|
||||||
|
|
||||||
# 确保 data 目录存在
|
|
||||||
os.makedirs(os.path.dirname(DATABASE_PATH) if os.path.dirname(DATABASE_PATH) else ".", exist_ok=True)
|
|
||||||
|
|
||||||
# 创建引擎
|
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
DATABASE_URL,
|
DATABASE_URL,
|
||||||
connect_args={"check_same_thread": False}
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
pool_recycle=3600,
|
||||||
|
pool_pre_ping=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 创建会话工厂
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
# 创建基类
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
@@ -30,20 +25,18 @@ def get_db():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
# SQLAlchemy 类型到 SQLite 类型名的映射
|
|
||||||
_TYPE_MAP = {
|
_TYPE_MAP = {
|
||||||
String: "VARCHAR",
|
String: "VARCHAR",
|
||||||
Integer: "INTEGER",
|
Integer: "INTEGER",
|
||||||
Text: "TEXT",
|
Text: "TEXT",
|
||||||
Boolean: "BOOLEAN",
|
Boolean: "BOOLEAN",
|
||||||
Float: "REAL",
|
Float: "DOUBLE PRECISION",
|
||||||
DateTime: "DATETIME",
|
DateTime: "TIMESTAMP",
|
||||||
Date: "DATE",
|
Date: "DATE",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _col_type_str(col_type) -> str:
|
def _col_type_str(col_type) -> str:
|
||||||
"""将 SQLAlchemy 列类型转为 SQLite 类型字符串"""
|
|
||||||
if col_type.__class__ in _TYPE_MAP:
|
if col_type.__class__ in _TYPE_MAP:
|
||||||
base = _TYPE_MAP[col_type.__class__]
|
base = _TYPE_MAP[col_type.__class__]
|
||||||
else:
|
else:
|
||||||
@@ -56,14 +49,13 @@ def _col_type_str(col_type) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
"""初始化数据库表,自动补充新增的列"""
|
"""初始化数据库表,自动补充新增的列,并为缺少 uuid 的记录回填"""
|
||||||
# 导入所有模型,确保 Base.metadata 包含全部表定义
|
from app.utils.logger import logger # 避免循环导入
|
||||||
from app.models import ( # noqa: F401
|
from app.models import ( # noqa: F401
|
||||||
task, category, tag, user_settings, habit, anniversary, account,
|
task, category, tag, user_settings, habit, anniversary, goal, sync_settings,
|
||||||
)
|
)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
# 通用自动迁移:对比 ORM 模型与实际表结构,补充缺失的列(SQLite 兼容)
|
|
||||||
inspector = inspect(engine)
|
inspector = inspect(engine)
|
||||||
table_names = set(inspector.get_table_names())
|
table_names = set(inspector.get_table_names())
|
||||||
|
|
||||||
@@ -78,24 +70,79 @@ def init_db():
|
|||||||
for col in table_cls.columns:
|
for col in table_cls.columns:
|
||||||
if col.name in existing_cols:
|
if col.name in existing_cols:
|
||||||
continue
|
continue
|
||||||
# 跳过无服务端默认值且不可为空的列(容易出错)
|
|
||||||
if col.nullable is False and col.server_default is None and col.default is None:
|
if col.nullable is False and col.server_default is None and col.default is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
sqlite_type = _col_type_str(col.type)
|
col_type_str = _col_type_str(col.type)
|
||||||
|
col_name = col.name
|
||||||
|
|
||||||
ddl = f"ALTER TABLE {table_name} ADD COLUMN {col.name} {sqlite_type}"
|
|
||||||
|
|
||||||
# 为可空列或已有默认值的列附加 DEFAULT
|
|
||||||
if col.server_default is not None:
|
if col.server_default is not None:
|
||||||
|
ddl = f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type_str}"
|
||||||
ddl += f" DEFAULT {col.server_default.arg}"
|
ddl += f" DEFAULT {col.server_default.arg}"
|
||||||
elif col.default is not None and col.nullable:
|
if not col.nullable:
|
||||||
|
ddl += " NOT NULL"
|
||||||
|
elif col.default is not None:
|
||||||
default_val = col.default.arg
|
default_val = col.default.arg
|
||||||
if isinstance(default_val, str):
|
ddl = f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type_str}"
|
||||||
ddl += f" DEFAULT '{default_val}'"
|
if callable(default_val):
|
||||||
|
# callable 类型的默认值(如 uuid.uuid4)无法写入 SQL DEFAULT,
|
||||||
|
# 后续的 UUID 回填逻辑会处理已有记录
|
||||||
|
pass
|
||||||
elif isinstance(default_val, bool):
|
elif isinstance(default_val, bool):
|
||||||
ddl += f" DEFAULT {1 if default_val else 0}"
|
ddl += f" DEFAULT {'TRUE' if default_val else 'FALSE'}"
|
||||||
|
elif isinstance(default_val, str):
|
||||||
|
ddl += f" DEFAULT '{default_val}'"
|
||||||
else:
|
else:
|
||||||
ddl += f" DEFAULT {default_val}"
|
ddl += f" DEFAULT {default_val}"
|
||||||
|
if not col.nullable:
|
||||||
|
ddl += " NOT NULL"
|
||||||
|
else:
|
||||||
|
ddl = f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type_str}"
|
||||||
|
|
||||||
conn.execute(text(ddl))
|
conn.execute(text(ddl))
|
||||||
|
|
||||||
|
# 为缺少 uuid 的已有记录回填 UUID4
|
||||||
|
import uuid
|
||||||
|
db_session = SessionLocal()
|
||||||
|
try:
|
||||||
|
from app.models import Task, Category, Tag, HabitGroup, Habit, HabitCheckin
|
||||||
|
from app.models import AnniversaryCategory, Anniversary, Goal, GoalStep, GoalReview, SyncSettings
|
||||||
|
|
||||||
|
for model_cls in [Task, Category, Tag, HabitGroup, Habit, HabitCheckin,
|
||||||
|
AnniversaryCategory, Anniversary, Goal, GoalStep, GoalReview]:
|
||||||
|
if hasattr(model_cls, 'uuid'):
|
||||||
|
null_uuid_records = db_session.query(model_cls).filter(
|
||||||
|
(model_cls.uuid == None) | (model_cls.uuid == '') # noqa: E711
|
||||||
|
).all()
|
||||||
|
for record in null_uuid_records:
|
||||||
|
record.uuid = str(uuid.uuid4())
|
||||||
|
if null_uuid_records:
|
||||||
|
logger.info(f"为 {len(null_uuid_records)} 条 {model_cls.__name__} 记录回填了 uuid")
|
||||||
|
db_session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"UUID 回填时出现异常(可忽略): {e}")
|
||||||
|
db_session.rollback()
|
||||||
|
finally:
|
||||||
|
db_session.close()
|
||||||
|
|
||||||
|
# 注册 sync_version 自增事件监听
|
||||||
|
_register_sync_version_listeners()
|
||||||
|
|
||||||
|
|
||||||
|
def _bump_sync_version(mapper, connection, target):
|
||||||
|
"""before_update 事件:自动递增 sync_version(同步模式中跳过)"""
|
||||||
|
from app.utils.sync_lock import is_sync_mode
|
||||||
|
if not is_sync_mode() and hasattr(target, 'sync_version'):
|
||||||
|
target.sync_version = (target.sync_version or 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _register_sync_version_listeners():
|
||||||
|
"""为所有可同步模型注册 before_update 事件监听"""
|
||||||
|
from app.models import (
|
||||||
|
Task, Category, Tag, HabitGroup, Habit, HabitCheckin,
|
||||||
|
AnniversaryCategory, Anniversary, Goal, GoalStep, GoalReview,
|
||||||
|
)
|
||||||
|
for model_cls in [Task, Category, Tag, HabitGroup, Habit, HabitCheckin,
|
||||||
|
AnniversaryCategory, Anniversary, Goal, GoalStep, GoalReview]:
|
||||||
|
if hasattr(model_cls, 'sync_version'):
|
||||||
|
event.listen(model_cls, 'before_update', _bump_sync_version)
|
||||||
@@ -7,10 +7,13 @@ import time
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from app.config import CORS_ORIGINS, WEBUI_PATH, HOST, PORT
|
from app.config import CORS_ORIGINS, WEBUI_PATH, HOST, PORT
|
||||||
from app.database import init_db
|
from app.database import init_db, SessionLocal
|
||||||
|
from app.models.user_settings import UserSettings
|
||||||
from app.routers import api_router
|
from app.routers import api_router
|
||||||
from app.utils.logger import logger
|
from app.utils.logger import logger
|
||||||
from app.utils.auth import decode_access_token
|
from app.utils.auth import decode_access_token, get_cached_token_version, set_cached_token_version
|
||||||
|
from app.utils.sync_lock import is_syncing
|
||||||
|
from jose import JWTError
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -90,25 +93,55 @@ async def log_requests(request: Request, call_next):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
# 认证中间件(保护所有 /api/* 路由,除了 /api/auth/* 和 /health)
|
# 认证中间件(保护 /api/*,仅放行 /health 和 /api/auth/login、/api/auth/logout)
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def auth_middleware(request: Request, call_next):
|
async def auth_middleware(request: Request, call_next):
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
|
|
||||||
# 不拦截:健康检查、静态文件、auth 路由
|
# 不拦截:健康检查、静态文件、公开的 auth 端点
|
||||||
if path == "/health" or not path.startswith("/api/") or path.startswith("/api/auth/"):
|
public_paths = {"/health", "/api/auth/login", "/api/auth/logout", "/api/auth/status", "/api/auth/setup"}
|
||||||
|
if path in public_paths or not path.startswith("/api/"):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
auth_header = request.headers.get("Authorization", "")
|
token = request.cookies.get("access_token", "")
|
||||||
token = auth_header.replace("Bearer ", "")
|
|
||||||
if not token:
|
if not token:
|
||||||
return JSONResponse(status_code=401, content={"detail": "未登录"})
|
return JSONResponse(status_code=401, content={"detail": "未登录"})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
decode_access_token(token)
|
payload = decode_access_token(token)
|
||||||
except Exception:
|
except JWTError:
|
||||||
return JSONResponse(status_code=401, content={"detail": "登录已过期,请重新登录"})
|
return JSONResponse(status_code=401, content={"detail": "登录已过期,请重新登录"})
|
||||||
|
|
||||||
|
user_id = payload.get("sub", "")
|
||||||
|
token_tv = payload.get("tv")
|
||||||
|
|
||||||
|
if token_tv is not None and user_id:
|
||||||
|
cached_tv = get_cached_token_version(user_id)
|
||||||
|
if cached_tv is None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
||||||
|
cached_tv = settings.token_version if settings else 0
|
||||||
|
set_cached_token_version(user_id, cached_tv)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
if token_tv != cached_tv:
|
||||||
|
return JSONResponse(status_code=401, content={"detail": "密码已修改,请重新登录"})
|
||||||
|
|
||||||
|
request.state.user = payload
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
# 同步锁中间件(同步期间禁止写操作)
|
||||||
|
@app.middleware("http")
|
||||||
|
async def sync_lock_middleware(request: Request, call_next):
|
||||||
|
path = request.url.path
|
||||||
|
|
||||||
|
if is_syncing() and request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||||
|
if not path.startswith("/api/sync"):
|
||||||
|
return JSONResponse(status_code=503, content={"detail": "正在同步数据,请稍后再试"})
|
||||||
|
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
@@ -151,9 +184,12 @@ if os.path.exists(WEBUI_PATH):
|
|||||||
@app.get("/{full_path:path}")
|
@app.get("/{full_path:path}")
|
||||||
async def spa_fallback(request: Request, full_path: str):
|
async def spa_fallback(request: Request, full_path: str):
|
||||||
"""SPA 回退:先尝试提供真实文件,找不到则返回 index.html"""
|
"""SPA 回退:先尝试提供真实文件,找不到则返回 index.html"""
|
||||||
file_path = os.path.join(WEBUI_PATH, full_path)
|
# 规范化路径并防止路径穿越攻击
|
||||||
if os.path.isfile(file_path):
|
safe_path = os.path.normpath(os.path.join(WEBUI_PATH, full_path))
|
||||||
return FileResponse(file_path)
|
if not safe_path.startswith(os.path.normpath(WEBUI_PATH)):
|
||||||
|
return FileResponse(os.path.join(WEBUI_PATH, "index.html"))
|
||||||
|
if os.path.isfile(safe_path):
|
||||||
|
return FileResponse(safe_path)
|
||||||
return FileResponse(os.path.join(WEBUI_PATH, "index.html"))
|
return FileResponse(os.path.join(WEBUI_PATH, "index.html"))
|
||||||
|
|
||||||
logger.info(f"SPA 静态文件服务已配置: {WEBUI_PATH}")
|
logger.info(f"SPA 静态文件服务已配置: {WEBUI_PATH}")
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ from app.models.tag import Tag, task_tags
|
|||||||
from app.models.user_settings import UserSettings
|
from app.models.user_settings import UserSettings
|
||||||
from app.models.habit import HabitGroup, Habit, HabitCheckin
|
from app.models.habit import HabitGroup, Habit, HabitCheckin
|
||||||
from app.models.anniversary import AnniversaryCategory, Anniversary
|
from app.models.anniversary import AnniversaryCategory, Anniversary
|
||||||
from app.models.account import FinancialAccount, AccountHistory, DebtInstallment
|
from app.models.goal import Goal, GoalStep, GoalReview, GoalCheckin, goal_tasks
|
||||||
|
from app.models.sync_settings import SyncSettings
|
||||||
|
from app.models.certificate import Certificate, CertificateCategory
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Task", "Category", "Tag", "task_tags", "UserSettings",
|
"Task", "Category", "Tag", "task_tags", "UserSettings",
|
||||||
"HabitGroup", "Habit", "HabitCheckin",
|
"HabitGroup", "Habit", "HabitCheckin",
|
||||||
"AnniversaryCategory", "Anniversary",
|
"AnniversaryCategory", "Anniversary",
|
||||||
"FinancialAccount", "AccountHistory", "DebtInstallment",
|
"Goal", "GoalStep", "GoalReview", "GoalCheckin", "goal_tasks",
|
||||||
|
"SyncSettings",
|
||||||
|
"Certificate", "CertificateCategory",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Text, Boolean, Float, DateTime, ForeignKey, Date
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from app.database import Base
|
|
||||||
from app.utils.datetime import utcnow
|
|
||||||
|
|
||||||
|
|
||||||
class FinancialAccount(Base):
|
|
||||||
"""财务账户模型"""
|
|
||||||
__tablename__ = "financial_accounts"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
name = Column(String(100), nullable=False)
|
|
||||||
account_type = Column(String(20), nullable=False, default="savings") # savings / debt
|
|
||||||
balance = Column(Float, default=0.0)
|
|
||||||
icon = Column(String(50), default="wallet")
|
|
||||||
color = Column(String(20), default="#FFB7C5")
|
|
||||||
sort_order = Column(Integer, default=0)
|
|
||||||
is_active = Column(Boolean, default=True)
|
|
||||||
description = Column(Text, nullable=True)
|
|
||||||
created_at = Column(DateTime, default=utcnow)
|
|
||||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
|
||||||
|
|
||||||
# 关联关系
|
|
||||||
history_records = relationship("AccountHistory", back_populates="account", cascade="all, delete-orphan")
|
|
||||||
debt_installments = relationship("DebtInstallment", back_populates="account", cascade="all, delete-orphan")
|
|
||||||
|
|
||||||
|
|
||||||
class AccountHistory(Base):
|
|
||||||
"""余额变更历史"""
|
|
||||||
__tablename__ = "account_history"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
account_id = Column(Integer, ForeignKey("financial_accounts.id"), nullable=False)
|
|
||||||
change_amount = Column(Float, nullable=False)
|
|
||||||
balance_before = Column(Float, nullable=False)
|
|
||||||
balance_after = Column(Float, nullable=False)
|
|
||||||
note = Column(String(200), nullable=True)
|
|
||||||
created_at = Column(DateTime, default=utcnow)
|
|
||||||
|
|
||||||
# 关联关系
|
|
||||||
account = relationship("FinancialAccount", back_populates="history_records")
|
|
||||||
|
|
||||||
|
|
||||||
class DebtInstallment(Base):
|
|
||||||
"""分期还款计划"""
|
|
||||||
__tablename__ = "debt_installments"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
account_id = Column(Integer, ForeignKey("financial_accounts.id"), nullable=False)
|
|
||||||
total_amount = Column(Float, nullable=False)
|
|
||||||
total_periods = Column(Integer, nullable=False)
|
|
||||||
current_period = Column(Integer, nullable=False, default=1) # 1-based, 指向下一期待还
|
|
||||||
payment_day = Column(Integer, nullable=False) # 每月还款日 1-31
|
|
||||||
payment_amount = Column(Float, nullable=False)
|
|
||||||
start_date = Column(Date, nullable=False)
|
|
||||||
is_completed = Column(Boolean, default=False)
|
|
||||||
created_at = Column(DateTime, default=utcnow)
|
|
||||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
|
||||||
|
|
||||||
# 关联关系
|
|
||||||
account = relationship("FinancialAccount", back_populates="debt_installments")
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import uuid as _uuid
|
||||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Date
|
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Date
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
@@ -9,10 +10,13 @@ class AnniversaryCategory(Base):
|
|||||||
__tablename__ = "anniversary_categories"
|
__tablename__ = "anniversary_categories"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||||
name = Column(String(50), nullable=False)
|
name = Column(String(50), nullable=False)
|
||||||
icon = Column(String(50), default="calendar")
|
icon = Column(String(50), default="calendar")
|
||||||
color = Column(String(20), default="#FFB7C5")
|
color = Column(String(20), default="#FFB7C5")
|
||||||
sort_order = Column(Integer, default=0)
|
sort_order = Column(Integer, default=0)
|
||||||
|
is_deleted = Column(Boolean, default=False)
|
||||||
|
sync_version = Column(Integer, default=1)
|
||||||
|
|
||||||
# 关联关系
|
# 关联关系
|
||||||
anniversaries = relationship("Anniversary", back_populates="category")
|
anniversaries = relationship("Anniversary", back_populates="category")
|
||||||
@@ -23,6 +27,7 @@ class Anniversary(Base):
|
|||||||
__tablename__ = "anniversaries"
|
__tablename__ = "anniversaries"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||||
title = Column(String(200), nullable=False)
|
title = Column(String(200), nullable=False)
|
||||||
date = Column(Date, nullable=False) # 月-日,年份部分可选
|
date = Column(Date, nullable=False) # 月-日,年份部分可选
|
||||||
year = Column(Integer, nullable=True) # 年份,用于计算第 N 个周年
|
year = Column(Integer, nullable=True) # 年份,用于计算第 N 个周年
|
||||||
@@ -30,6 +35,8 @@ class Anniversary(Base):
|
|||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
is_recurring = Column(Boolean, default=True)
|
is_recurring = Column(Boolean, default=True)
|
||||||
remind_days_before = Column(Integer, default=3)
|
remind_days_before = Column(Integer, default=3)
|
||||||
|
is_deleted = Column(Boolean, default=False)
|
||||||
|
sync_version = Column(Integer, default=1)
|
||||||
created_at = Column(DateTime, default=utcnow)
|
created_at = Column(DateTime, default=utcnow)
|
||||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from sqlalchemy import Column, Integer, String
|
import uuid as _uuid
|
||||||
|
from sqlalchemy import Column, Integer, String, Boolean
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
@@ -8,9 +9,12 @@ class Category(Base):
|
|||||||
__tablename__ = "categories"
|
__tablename__ = "categories"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||||
name = Column(String(100), nullable=False)
|
name = Column(String(100), nullable=False)
|
||||||
color = Column(String(20), default="#FFB7C5") # 默认樱花粉
|
color = Column(String(20), default="#FFB7C5") # 默认樱花粉
|
||||||
icon = Column(String(50), default="folder") # 默认图标
|
icon = Column(String(50), default="folder") # 默认图标
|
||||||
|
is_deleted = Column(Boolean, default=False)
|
||||||
|
sync_version = Column(Integer, default=1)
|
||||||
|
|
||||||
# 关联关系
|
# 关联关系
|
||||||
tasks = relationship("Task", back_populates="category")
|
tasks = relationship("Task", back_populates="category")
|
||||||
|
|||||||
43
api/app/models/certificate.py
Normal file
43
api/app/models/certificate.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import uuid as _uuid
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, Date, Boolean, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.database import Base
|
||||||
|
from app.utils.datetime import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateCategory(Base):
|
||||||
|
"""证书分类模型"""
|
||||||
|
__tablename__ = "certificate_categories"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||||
|
name = Column(String(50), nullable=False)
|
||||||
|
icon = Column(String(50), default="medal")
|
||||||
|
color = Column(String(20), default="#FFB7C5")
|
||||||
|
sort_order = Column(Integer, default=0)
|
||||||
|
is_deleted = Column(Boolean, default=False)
|
||||||
|
sync_version = Column(Integer, default=1)
|
||||||
|
|
||||||
|
certificates = relationship("Certificate", back_populates="category")
|
||||||
|
|
||||||
|
|
||||||
|
class Certificate(Base):
|
||||||
|
"""证书模型"""
|
||||||
|
__tablename__ = "certificates"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||||
|
title = Column(String(200), nullable=False)
|
||||||
|
category_id = Column(Integer, ForeignKey("certificate_categories.id"), nullable=True)
|
||||||
|
image = Column(Text, nullable=True) # base64 data URL
|
||||||
|
issuer = Column(String(200), nullable=True) # 来源/颁发机构
|
||||||
|
issue_date = Column(Date, nullable=True)
|
||||||
|
expiry_date = Column(Date, nullable=True) # null = 永久有效
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
sort_order = Column(Integer, default=0)
|
||||||
|
is_deleted = Column(Boolean, default=False)
|
||||||
|
sync_version = Column(Integer, default=1)
|
||||||
|
created_at = Column(DateTime, default=utcnow)
|
||||||
|
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
category = relationship("CertificateCategory", back_populates="certificates")
|
||||||
119
api/app/models/goal.py
Normal file
119
api/app/models/goal.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import uuid as _uuid
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, Date, DateTime, Boolean, Float, ForeignKey, Table, desc
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.database import Base
|
||||||
|
from app.utils.datetime import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
# 目标-任务关联表(多对多)
|
||||||
|
goal_tasks = Table(
|
||||||
|
"goal_tasks",
|
||||||
|
Base.metadata,
|
||||||
|
Column("goal_id", Integer, ForeignKey("goals.id"), primary_key=True),
|
||||||
|
Column("task_id", Integer, ForeignKey("tasks.id"), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Goal(Base):
|
||||||
|
"""长期目标模型"""
|
||||||
|
__tablename__ = "goals"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||||
|
title = Column(String(200), nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
status = Column(String(20), default="active") # active/paused/completed/abandoned
|
||||||
|
track_type = Column(String(20), default="milestone") # "milestone" | "cumulative"
|
||||||
|
progress = Column(Integer, default=0) # 0-100,里程碑模式从里程碑自动计算,累计模式从打卡汇总计算
|
||||||
|
target_value = Column(Float, nullable=True) # 累计模式:目标值(目标单位)
|
||||||
|
target_unit = Column(String(20), nullable=True) # 累计模式:目标单位,如 "g"、"kg"
|
||||||
|
input_unit = Column(String(20), nullable=True) # 累计模式:打卡输入单位,如 "kcal"、"次"
|
||||||
|
conversion_rate = Column(Float, default=1.0) # 累计模式:换算率(多少输入单位 = 1 目标单位)
|
||||||
|
current_value = Column(Float, default=0) # 累计模式:累计打卡值(输入单位)
|
||||||
|
target_date = Column(Date, nullable=True)
|
||||||
|
completed_at = Column(DateTime, nullable=True)
|
||||||
|
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||||
|
color = Column(String(20), default="#FFB7C5")
|
||||||
|
icon = Column(String(50), default="flag")
|
||||||
|
sort_order = Column(Integer, default=0)
|
||||||
|
is_deleted = Column(Boolean, default=False)
|
||||||
|
sync_version = Column(Integer, default=1)
|
||||||
|
created_at = Column(DateTime, default=utcnow)
|
||||||
|
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
category = relationship("Category")
|
||||||
|
steps = relationship(
|
||||||
|
"GoalStep", back_populates="goal",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="GoalStep.sort_order",
|
||||||
|
)
|
||||||
|
reviews = relationship(
|
||||||
|
"GoalReview", back_populates="goal",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by=lambda: desc(GoalReview.created_at),
|
||||||
|
)
|
||||||
|
checkins = relationship(
|
||||||
|
"GoalCheckin", back_populates="goal",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by=lambda: desc(GoalCheckin.checkin_date),
|
||||||
|
)
|
||||||
|
tasks = relationship("Task", secondary=goal_tasks, back_populates="goals")
|
||||||
|
|
||||||
|
|
||||||
|
class GoalStep(Base):
|
||||||
|
"""目标阶段/里程碑模型(step_type 区分类型)"""
|
||||||
|
__tablename__ = "goal_steps"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||||
|
goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False)
|
||||||
|
parent_id = Column(Integer, ForeignKey("goal_steps.id"), nullable=True)
|
||||||
|
title = Column(String(200), nullable=False)
|
||||||
|
step_type = Column(String(20), nullable=False) # "phase" | "milestone"
|
||||||
|
status = Column(String(20), default="pending") # pending/in_progress/completed
|
||||||
|
target_date = Column(Date, nullable=True)
|
||||||
|
reached_at = Column(DateTime, nullable=True)
|
||||||
|
sort_order = Column(Integer, default=0)
|
||||||
|
is_deleted = Column(Boolean, default=False)
|
||||||
|
sync_version = Column(Integer, default=1)
|
||||||
|
created_at = Column(DateTime, default=utcnow)
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
goal = relationship("Goal", back_populates="steps")
|
||||||
|
parent = relationship("GoalStep", remote_side=[id], backref="children")
|
||||||
|
|
||||||
|
|
||||||
|
class GoalReview(Base):
|
||||||
|
"""目标复盘记录模型"""
|
||||||
|
__tablename__ = "goal_reviews"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||||
|
goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
rating = Column(Integer, nullable=True) # 1-5 自评
|
||||||
|
is_deleted = Column(Boolean, default=False)
|
||||||
|
sync_version = Column(Integer, default=1)
|
||||||
|
created_at = Column(DateTime, default=utcnow)
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
goal = relationship("Goal", back_populates="reviews")
|
||||||
|
|
||||||
|
|
||||||
|
class GoalCheckin(Base):
|
||||||
|
"""目标累计打卡记录模型"""
|
||||||
|
__tablename__ = "goal_checkins"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||||
|
goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False)
|
||||||
|
value = Column(Float, nullable=False)
|
||||||
|
note = Column(Text, nullable=True)
|
||||||
|
checkin_date = Column(Date, nullable=False)
|
||||||
|
is_deleted = Column(Boolean, default=False)
|
||||||
|
sync_version = Column(Integer, default=1)
|
||||||
|
created_at = Column(DateTime, default=utcnow)
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
goal = relationship("Goal", back_populates="checkins")
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import uuid as _uuid
|
||||||
from sqlalchemy import Column, Integer, String, Text, Boolean, Date, DateTime, ForeignKey, UniqueConstraint
|
from sqlalchemy import Column, Integer, String, Text, Boolean, Date, DateTime, ForeignKey, UniqueConstraint
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
@@ -9,10 +10,13 @@ class HabitGroup(Base):
|
|||||||
__tablename__ = "habit_groups"
|
__tablename__ = "habit_groups"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||||
name = Column(String(100), nullable=False)
|
name = Column(String(100), nullable=False)
|
||||||
color = Column(String(20), default="#FFB7C5")
|
color = Column(String(20), default="#FFB7C5")
|
||||||
icon = Column(String(50), default="flag")
|
icon = Column(String(50), default="flag")
|
||||||
sort_order = Column(Integer, default=0)
|
sort_order = Column(Integer, default=0)
|
||||||
|
is_deleted = Column(Boolean, default=False)
|
||||||
|
sync_version = Column(Integer, default=1)
|
||||||
|
|
||||||
# 关联关系
|
# 关联关系
|
||||||
habits = relationship("Habit", back_populates="group", order_by="Habit.created_at")
|
habits = relationship("Habit", back_populates="group", order_by="Habit.created_at")
|
||||||
@@ -23,6 +27,7 @@ class Habit(Base):
|
|||||||
__tablename__ = "habits"
|
__tablename__ = "habits"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||||
name = Column(String(200), nullable=False)
|
name = Column(String(200), nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
group_id = Column(Integer, ForeignKey("habit_groups.id"), nullable=True)
|
group_id = Column(Integer, ForeignKey("habit_groups.id"), nullable=True)
|
||||||
@@ -30,6 +35,8 @@ class Habit(Base):
|
|||||||
frequency = Column(String(20), default="daily") # daily / weekly
|
frequency = Column(String(20), default="daily") # daily / weekly
|
||||||
active_days = Column(String(100), nullable=True) # JSON 数组, 如 [0,2,4] 表示周一三五
|
active_days = Column(String(100), nullable=True) # JSON 数组, 如 [0,2,4] 表示周一三五
|
||||||
is_archived = Column(Boolean, default=False)
|
is_archived = Column(Boolean, default=False)
|
||||||
|
is_deleted = Column(Boolean, default=False)
|
||||||
|
sync_version = Column(Integer, default=1)
|
||||||
created_at = Column(DateTime, default=utcnow)
|
created_at = Column(DateTime, default=utcnow)
|
||||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
@@ -46,9 +53,12 @@ class HabitCheckin(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||||
habit_id = Column(Integer, ForeignKey("habits.id"), nullable=False)
|
habit_id = Column(Integer, ForeignKey("habits.id"), nullable=False)
|
||||||
checkin_date = Column(Date, nullable=False)
|
checkin_date = Column(Date, nullable=False)
|
||||||
count = Column(Integer, default=0)
|
count = Column(Integer, default=0)
|
||||||
|
is_deleted = Column(Boolean, default=False)
|
||||||
|
sync_version = Column(Integer, default=1)
|
||||||
created_at = Column(DateTime, default=utcnow)
|
created_at = Column(DateTime, default=utcnow)
|
||||||
|
|
||||||
# 关联关系
|
# 关联关系
|
||||||
|
|||||||
28
api/app/models/sync_settings.py
Normal file
28
api/app/models/sync_settings.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import uuid as _uuid
|
||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||||
|
from app.database import Base
|
||||||
|
from app.utils.datetime import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
class SyncSettings(Base):
|
||||||
|
"""同步设置模型(单例,始终只有一条记录 id=1)"""
|
||||||
|
__tablename__ = "sync_settings"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, default=1)
|
||||||
|
|
||||||
|
# WebDAV 连接配置
|
||||||
|
webdav_url = Column(String(500), nullable=True)
|
||||||
|
webdav_username = Column(String(200), nullable=True)
|
||||||
|
webdav_password = Column(String(500), nullable=True) # AES-256-GCM 加密存储
|
||||||
|
webdav_path = Column(String(200), default="/elysia-todo/")
|
||||||
|
|
||||||
|
# 同步状态
|
||||||
|
sync_enabled = Column(Boolean, default=False)
|
||||||
|
last_sync_at = Column(DateTime, nullable=True)
|
||||||
|
last_sync_version = Column(Integer, default=0)
|
||||||
|
auto_sync = Column(Boolean, default=False)
|
||||||
|
auto_sync_interval = Column(Integer, default=300) # 秒
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(DateTime, default=utcnow)
|
||||||
|
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Table, ForeignKey
|
import uuid as _uuid
|
||||||
|
from sqlalchemy import Column, Integer, String, Boolean, Table, ForeignKey
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
@@ -17,7 +18,10 @@ class Tag(Base):
|
|||||||
__tablename__ = "tags"
|
__tablename__ = "tags"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||||
name = Column(String(50), nullable=False, unique=True)
|
name = Column(String(50), nullable=False, unique=True)
|
||||||
|
is_deleted = Column(Boolean, default=False)
|
||||||
|
sync_version = Column(Integer, default=1)
|
||||||
|
|
||||||
# 关联关系
|
# 关联关系
|
||||||
tasks = relationship("Task", secondary=task_tags, back_populates="tags")
|
tasks = relationship("Task", secondary=task_tags, back_populates="tags")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import uuid as _uuid
|
||||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
|
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
@@ -9,15 +10,19 @@ class Task(Base):
|
|||||||
__tablename__ = "tasks"
|
__tablename__ = "tasks"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||||
title = Column(String(200), nullable=False)
|
title = Column(String(200), nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
priority = Column(String(20), default="q4") # q1(重要紧急), q2(重要不紧急), q3(不重要紧急), q4(不重要不紧急)
|
priority = Column(String(20), default="q4") # q1(重要紧急), q2(重要不紧急), q3(不重要紧急), q4(不重要不紧急)
|
||||||
due_date = Column(DateTime, nullable=True)
|
due_date = Column(DateTime, nullable=True)
|
||||||
is_completed = Column(Boolean, default=False)
|
is_completed = Column(Boolean, default=False)
|
||||||
|
is_deleted = Column(Boolean, default=False)
|
||||||
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||||
|
sync_version = Column(Integer, default=1)
|
||||||
created_at = Column(DateTime, default=utcnow)
|
created_at = Column(DateTime, default=utcnow)
|
||||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
# 关联关系
|
# 关联关系
|
||||||
category = relationship("Category", back_populates="tasks")
|
category = relationship("Category", back_populates="tasks")
|
||||||
tags = relationship("Tag", secondary="task_tags", back_populates="tasks")
|
tags = relationship("Tag", secondary="task_tags", back_populates="tasks")
|
||||||
|
goals = relationship("Goal", secondary="goal_tasks", back_populates="tasks")
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Date
|
from sqlalchemy import Column, Integer, String, Text, DateTime, Date
|
||||||
from datetime import datetime, timezone, date
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
from app.utils.datetime import utcnow
|
||||||
|
|
||||||
def utcnow():
|
|
||||||
"""统一获取 UTC 时间的工厂函数"""
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
class UserSettings(Base):
|
class UserSettings(Base):
|
||||||
"""用户设置模型(单例,始终只有一条记录 id=1)"""
|
"""用户设置模型(单例,始终只有一条记录 id=1)"""
|
||||||
@@ -33,6 +27,7 @@ class UserSettings(Base):
|
|||||||
|
|
||||||
# 认证
|
# 认证
|
||||||
password_hash = Column(String(255), default="")
|
password_hash = Column(String(255), default="")
|
||||||
|
token_version = Column(Integer, default=0)
|
||||||
|
|
||||||
# 时间戳
|
# 时间戳
|
||||||
created_at = Column(DateTime, default=utcnow)
|
created_at = Column(DateTime, default=utcnow)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, accounts, auth
|
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth, goals, sync, backup, certificates
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -10,4 +10,7 @@ api_router.include_router(tags.router)
|
|||||||
api_router.include_router(user_settings.router)
|
api_router.include_router(user_settings.router)
|
||||||
api_router.include_router(habits.router)
|
api_router.include_router(habits.router)
|
||||||
api_router.include_router(anniversaries.router)
|
api_router.include_router(anniversaries.router)
|
||||||
api_router.include_router(accounts.router)
|
api_router.include_router(goals.router)
|
||||||
|
api_router.include_router(sync.router)
|
||||||
|
api_router.include_router(backup.router)
|
||||||
|
api_router.include_router(certificates.router)
|
||||||
|
|||||||
@@ -1,498 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from typing import Optional, List
|
|
||||||
from datetime import date
|
|
||||||
from calendar import monthrange
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models.account import FinancialAccount, AccountHistory, DebtInstallment
|
|
||||||
from app.schemas.account import (
|
|
||||||
AccountCreate, AccountUpdate, AccountResponse, BalanceUpdateRequest,
|
|
||||||
AccountHistoryResponse, AccountListItemResponse,
|
|
||||||
DebtInstallmentCreate, DebtInstallmentUpdate, DebtInstallmentResponse,
|
|
||||||
)
|
|
||||||
from app.schemas.common import DeleteResponse
|
|
||||||
from app.utils.crud import get_or_404
|
|
||||||
from app.utils.datetime import utcnow
|
|
||||||
from app.utils.logger import logger
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["资产"])
|
|
||||||
|
|
||||||
|
|
||||||
def compute_installment_info(installment: DebtInstallment, today: date) -> dict:
|
|
||||||
"""计算分期计划的下次还款日期、距今天数、剩余期数"""
|
|
||||||
if installment.is_completed:
|
|
||||||
return {
|
|
||||||
"next_payment_date": None,
|
|
||||||
"days_until_payment": None,
|
|
||||||
"remaining_periods": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining = installment.total_periods - installment.current_period + 1
|
|
||||||
if remaining <= 0:
|
|
||||||
return {
|
|
||||||
"next_payment_date": None,
|
|
||||||
"days_until_payment": None,
|
|
||||||
"remaining_periods": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 根据 start_date 和 payment_day 计算下一还款日期
|
|
||||||
payment_day = installment.payment_day
|
|
||||||
start_year = installment.start_date.year
|
|
||||||
start_month = installment.start_date.month
|
|
||||||
|
|
||||||
# 计算当前应还的期数对应的月份
|
|
||||||
period_index = installment.current_period - 1
|
|
||||||
next_month_year = start_year * 12 + (start_month - 1) + period_index
|
|
||||||
next_year = next_month_year // 12
|
|
||||||
next_month = next_month_year % 12 + 1
|
|
||||||
|
|
||||||
# 处理 payment_day 超出当月天数的情况
|
|
||||||
max_day = monthrange(next_year, next_month)[1]
|
|
||||||
actual_day = min(payment_day, max_day)
|
|
||||||
next_payment_date = date(next_year, next_month, actual_day)
|
|
||||||
|
|
||||||
# 如果计算出的日期在 start_date 之前(边界情况),使用 start_date
|
|
||||||
if next_payment_date < installment.start_date:
|
|
||||||
next_payment_date = installment.start_date
|
|
||||||
|
|
||||||
days_until = (next_payment_date - today).days
|
|
||||||
|
|
||||||
return {
|
|
||||||
"next_payment_date": next_payment_date,
|
|
||||||
"days_until_payment": days_until,
|
|
||||||
"remaining_periods": remaining,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============ 财务账户 API ============
|
|
||||||
|
|
||||||
@router.get("/accounts", response_model=List[AccountListItemResponse])
|
|
||||||
def get_accounts(db: Session = Depends(get_db)):
|
|
||||||
"""获取所有账户列表"""
|
|
||||||
try:
|
|
||||||
accounts = db.query(FinancialAccount).order_by(
|
|
||||||
FinancialAccount.sort_order.asc(),
|
|
||||||
FinancialAccount.id.asc()
|
|
||||||
).all()
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for acc in accounts:
|
|
||||||
data = {
|
|
||||||
"id": acc.id,
|
|
||||||
"name": acc.name,
|
|
||||||
"account_type": acc.account_type,
|
|
||||||
"balance": acc.balance,
|
|
||||||
"icon": acc.icon,
|
|
||||||
"color": acc.color,
|
|
||||||
"sort_order": acc.sort_order,
|
|
||||||
"is_active": acc.is_active,
|
|
||||||
"description": acc.description,
|
|
||||||
"created_at": acc.created_at,
|
|
||||||
"updated_at": acc.updated_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 附加分期计划摘要(欠款账户)
|
|
||||||
if acc.account_type == "debt":
|
|
||||||
installments = db.query(DebtInstallment).filter(
|
|
||||||
DebtInstallment.account_id == acc.id,
|
|
||||||
DebtInstallment.is_completed == False
|
|
||||||
).all()
|
|
||||||
today = date.today()
|
|
||||||
active_installments = []
|
|
||||||
for inst in installments:
|
|
||||||
info = compute_installment_info(inst, today)
|
|
||||||
active_installments.append(info)
|
|
||||||
data["installments"] = active_installments
|
|
||||||
else:
|
|
||||||
data["installments"] = []
|
|
||||||
|
|
||||||
result.append(data)
|
|
||||||
|
|
||||||
logger.info(f"获取账户列表成功,总数: {len(result)}")
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"获取账户列表失败: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="获取账户列表失败")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/accounts", response_model=AccountResponse, status_code=201)
|
|
||||||
def create_account(data: AccountCreate, db: Session = Depends(get_db)):
|
|
||||||
"""创建账户"""
|
|
||||||
try:
|
|
||||||
account = FinancialAccount(
|
|
||||||
name=data.name,
|
|
||||||
account_type=data.account_type,
|
|
||||||
balance=data.balance,
|
|
||||||
icon=data.icon,
|
|
||||||
color=data.color,
|
|
||||||
sort_order=data.sort_order,
|
|
||||||
is_active=data.is_active,
|
|
||||||
description=data.description,
|
|
||||||
)
|
|
||||||
db.add(account)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(account)
|
|
||||||
logger.info(f"创建账户成功: id={account.id}, name={account.name}")
|
|
||||||
return account
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
logger.error(f"创建账户失败: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="创建账户失败")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/accounts/{account_id}", response_model=AccountResponse)
|
|
||||||
def get_account(account_id: int, db: Session = Depends(get_db)):
|
|
||||||
"""获取单个账户"""
|
|
||||||
try:
|
|
||||||
account = get_or_404(db, FinancialAccount, account_id, "账户")
|
|
||||||
return account
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"获取账户失败: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="获取账户失败")
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/accounts/{account_id}", response_model=AccountResponse)
|
|
||||||
def update_account(account_id: int, data: AccountUpdate, db: Session = Depends(get_db)):
|
|
||||||
"""更新账户基本信息"""
|
|
||||||
try:
|
|
||||||
account = get_or_404(db, FinancialAccount, account_id, "账户")
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
|
||||||
|
|
||||||
# 不允许通过此接口修改余额(使用专门的余额更新接口)
|
|
||||||
if "balance" in update_data:
|
|
||||||
del update_data["balance"]
|
|
||||||
|
|
||||||
for field, value in update_data.items():
|
|
||||||
setattr(account, field, value)
|
|
||||||
|
|
||||||
account.updated_at = utcnow()
|
|
||||||
db.commit()
|
|
||||||
db.refresh(account)
|
|
||||||
logger.info(f"更新账户成功: id={account_id}")
|
|
||||||
return account
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
logger.error(f"更新账户失败: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="更新账户失败")
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/accounts/{account_id}")
|
|
||||||
def delete_account(account_id: int, db: Session = Depends(get_db)):
|
|
||||||
"""删除账户(级联删除历史记录和分期计划)"""
|
|
||||||
try:
|
|
||||||
account = get_or_404(db, FinancialAccount, account_id, "账户")
|
|
||||||
db.delete(account)
|
|
||||||
db.commit()
|
|
||||||
logger.info(f"删除账户成功: id={account_id}")
|
|
||||||
return DeleteResponse(message="账户删除成功")
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
logger.error(f"删除账户失败: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="删除账户失败")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/accounts/{account_id}/balance", response_model=AccountResponse)
|
|
||||||
def update_balance(account_id: int, data: BalanceUpdateRequest, db: Session = Depends(get_db)):
|
|
||||||
"""更新账户余额(自动记录变更历史)"""
|
|
||||||
try:
|
|
||||||
account = get_or_404(db, FinancialAccount, account_id, "账户")
|
|
||||||
old_balance = account.balance
|
|
||||||
new_balance = data.new_balance
|
|
||||||
change_amount = round(new_balance - old_balance, 2)
|
|
||||||
|
|
||||||
# 创建历史记录
|
|
||||||
history = AccountHistory(
|
|
||||||
account_id=account_id,
|
|
||||||
change_amount=change_amount,
|
|
||||||
balance_before=old_balance,
|
|
||||||
balance_after=new_balance,
|
|
||||||
note=data.note,
|
|
||||||
)
|
|
||||||
db.add(history)
|
|
||||||
|
|
||||||
# 更新余额
|
|
||||||
account.balance = new_balance
|
|
||||||
account.updated_at = utcnow()
|
|
||||||
db.commit()
|
|
||||||
db.refresh(account)
|
|
||||||
logger.info(f"更新余额成功: account_id={account_id}, {old_balance} -> {new_balance}")
|
|
||||||
return account
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
logger.error(f"更新余额失败: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="更新余额失败")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/accounts/{account_id}/history")
|
|
||||||
def get_account_history(
|
|
||||||
account_id: int,
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
|
||||||
db: Session = Depends(get_db)):
|
|
||||||
"""获取账户变更历史"""
|
|
||||||
try:
|
|
||||||
account = get_or_404(db, FinancialAccount, account_id, "账户")
|
|
||||||
|
|
||||||
total = db.query(AccountHistory).filter(
|
|
||||||
AccountHistory.account_id == account_id
|
|
||||||
).count()
|
|
||||||
|
|
||||||
records = db.query(AccountHistory).filter(
|
|
||||||
AccountHistory.account_id == account_id
|
|
||||||
).order_by(
|
|
||||||
AccountHistory.created_at.desc()
|
|
||||||
).offset((page - 1) * page_size).limit(page_size).all()
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"total": total,
|
|
||||||
"page": page,
|
|
||||||
"page_size": page_size,
|
|
||||||
"records": [
|
|
||||||
{
|
|
||||||
"id": r.id,
|
|
||||||
"account_id": r.account_id,
|
|
||||||
"change_amount": r.change_amount,
|
|
||||||
"balance_before": r.balance_before,
|
|
||||||
"balance_after": r.balance_after,
|
|
||||||
"note": r.note,
|
|
||||||
"created_at": r.created_at,
|
|
||||||
}
|
|
||||||
for r in records
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(f"获取账户历史成功: account_id={account_id}, total={total}")
|
|
||||||
return result
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"获取账户历史失败: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="获取账户历史失败")
|
|
||||||
|
|
||||||
|
|
||||||
# ============ 分期还款计划 API ============
|
|
||||||
|
|
||||||
@router.get("/debt-installments", response_model=List[DebtInstallmentResponse])
|
|
||||||
def get_installments(db: Session = Depends(get_db)):
|
|
||||||
"""获取所有分期计划(含下次还款计算)"""
|
|
||||||
try:
|
|
||||||
installments = db.query(DebtInstallment).order_by(
|
|
||||||
DebtInstallment.is_completed.asc(),
|
|
||||||
DebtInstallment.next_payment_date.asc() if hasattr(DebtInstallment, 'next_payment_date') else DebtInstallment.id.asc()
|
|
||||||
).all()
|
|
||||||
|
|
||||||
today = date.today()
|
|
||||||
result = []
|
|
||||||
for inst in installments:
|
|
||||||
info = compute_installment_info(inst, today)
|
|
||||||
data = {
|
|
||||||
"id": inst.id,
|
|
||||||
"account_id": inst.account_id,
|
|
||||||
"total_amount": inst.total_amount,
|
|
||||||
"total_periods": inst.total_periods,
|
|
||||||
"current_period": inst.current_period,
|
|
||||||
"payment_day": inst.payment_day,
|
|
||||||
"payment_amount": inst.payment_amount,
|
|
||||||
"start_date": inst.start_date,
|
|
||||||
"is_completed": inst.is_completed,
|
|
||||||
"created_at": inst.created_at,
|
|
||||||
"updated_at": inst.updated_at,
|
|
||||||
**info,
|
|
||||||
}
|
|
||||||
|
|
||||||
if inst.account:
|
|
||||||
data["account_name"] = inst.account.name
|
|
||||||
data["account_icon"] = inst.account.icon
|
|
||||||
data["account_color"] = inst.account.color
|
|
||||||
|
|
||||||
result.append(data)
|
|
||||||
|
|
||||||
# 排序:未完成且临近的排前面
|
|
||||||
result.sort(key=lambda x: (
|
|
||||||
0 if not x["is_completed"] and x["days_until_payment"] is not None else 1,
|
|
||||||
0 if not x["is_completed"] else 1,
|
|
||||||
x["days_until_payment"] if x["days_until_payment"] is not None else 9999,
|
|
||||||
))
|
|
||||||
|
|
||||||
logger.info(f"获取分期计划列表成功,总数: {len(result)}")
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"获取分期计划列表失败: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="获取分期计划列表失败")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/debt-installments", response_model=DebtInstallmentResponse, status_code=201)
|
|
||||||
def create_installment(data: DebtInstallmentCreate, db: Session = Depends(get_db)):
|
|
||||||
"""创建分期计划"""
|
|
||||||
try:
|
|
||||||
# 验证关联账户存在且为欠款类型
|
|
||||||
account = get_or_404(db, FinancialAccount, data.account_id, "账户")
|
|
||||||
if account.account_type != "debt":
|
|
||||||
raise HTTPException(status_code=400, detail="分期计划只能关联欠款类型的账户")
|
|
||||||
|
|
||||||
installment = DebtInstallment(
|
|
||||||
account_id=data.account_id,
|
|
||||||
total_amount=data.total_amount,
|
|
||||||
total_periods=data.total_periods,
|
|
||||||
current_period=data.current_period,
|
|
||||||
payment_day=data.payment_day,
|
|
||||||
payment_amount=data.payment_amount,
|
|
||||||
start_date=data.start_date,
|
|
||||||
is_completed=data.is_completed,
|
|
||||||
)
|
|
||||||
db.add(installment)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(installment)
|
|
||||||
|
|
||||||
# 返回含计算字段的响应
|
|
||||||
today = date.today()
|
|
||||||
info = compute_installment_info(installment, today)
|
|
||||||
|
|
||||||
logger.info(f"创建分期计划成功: id={installment.id}")
|
|
||||||
return DebtInstallmentResponse(
|
|
||||||
id=installment.id,
|
|
||||||
account_id=installment.account_id,
|
|
||||||
total_amount=installment.total_amount,
|
|
||||||
total_periods=installment.total_periods,
|
|
||||||
current_period=installment.current_period,
|
|
||||||
payment_day=installment.payment_day,
|
|
||||||
payment_amount=installment.payment_amount,
|
|
||||||
start_date=installment.start_date,
|
|
||||||
is_completed=installment.is_completed,
|
|
||||||
created_at=installment.created_at,
|
|
||||||
updated_at=installment.updated_at,
|
|
||||||
**info,
|
|
||||||
account_name=account.name,
|
|
||||||
account_icon=account.icon,
|
|
||||||
account_color=account.color,
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
logger.error(f"创建分期计划失败: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="创建分期计划失败")
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/debt-installments/{installment_id}", response_model=DebtInstallmentResponse)
|
|
||||||
def update_installment(installment_id: int, data: DebtInstallmentUpdate, db: Session = Depends(get_db)):
|
|
||||||
"""更新分期计划"""
|
|
||||||
try:
|
|
||||||
installment = get_or_404(db, DebtInstallment, installment_id, "分期计划")
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
|
||||||
|
|
||||||
for field, value in update_data.items():
|
|
||||||
setattr(installment, field, value)
|
|
||||||
|
|
||||||
installment.updated_at = utcnow()
|
|
||||||
db.commit()
|
|
||||||
db.refresh(installment)
|
|
||||||
|
|
||||||
today = date.today()
|
|
||||||
info = compute_installment_info(installment, today)
|
|
||||||
|
|
||||||
result = DebtInstallmentResponse(
|
|
||||||
id=installment.id,
|
|
||||||
account_id=installment.account_id,
|
|
||||||
total_amount=installment.total_amount,
|
|
||||||
total_periods=installment.total_periods,
|
|
||||||
current_period=installment.current_period,
|
|
||||||
payment_day=installment.payment_day,
|
|
||||||
payment_amount=installment.payment_amount,
|
|
||||||
start_date=installment.start_date,
|
|
||||||
is_completed=installment.is_completed,
|
|
||||||
created_at=installment.created_at,
|
|
||||||
updated_at=installment.updated_at,
|
|
||||||
**info,
|
|
||||||
)
|
|
||||||
if installment.account:
|
|
||||||
result.account_name = installment.account.name
|
|
||||||
result.account_icon = installment.account.icon
|
|
||||||
result.account_color = installment.account.color
|
|
||||||
|
|
||||||
logger.info(f"更新分期计划成功: id={installment_id}")
|
|
||||||
return result
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
logger.error(f"更新分期计划失败: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="更新分期计划失败")
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/debt-installments/{installment_id}")
|
|
||||||
def delete_installment(installment_id: int, db: Session = Depends(get_db)):
|
|
||||||
"""删除分期计划"""
|
|
||||||
try:
|
|
||||||
installment = get_or_404(db, DebtInstallment, installment_id, "分期计划")
|
|
||||||
db.delete(installment)
|
|
||||||
db.commit()
|
|
||||||
logger.info(f"删除分期计划成功: id={installment_id}")
|
|
||||||
return DeleteResponse(message="分期计划删除成功")
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
logger.error(f"删除分期计划失败: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="删除分期计划失败")
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/debt-installments/{installment_id}/pay")
|
|
||||||
def pay_installment(installment_id: int, db: Session = Depends(get_db)):
|
|
||||||
"""标记已还一期"""
|
|
||||||
try:
|
|
||||||
installment = get_or_404(db, DebtInstallment, installment_id, "分期计划")
|
|
||||||
|
|
||||||
if installment.is_completed:
|
|
||||||
raise HTTPException(status_code=400, detail="该分期计划已全部还清")
|
|
||||||
|
|
||||||
installment.current_period += 1
|
|
||||||
|
|
||||||
# 检查是否已全部还清
|
|
||||||
if installment.current_period > installment.total_periods:
|
|
||||||
installment.is_completed = True
|
|
||||||
installment.current_period = installment.total_periods
|
|
||||||
|
|
||||||
installment.updated_at = utcnow()
|
|
||||||
db.commit()
|
|
||||||
db.refresh(installment)
|
|
||||||
|
|
||||||
today = date.today()
|
|
||||||
info = compute_installment_info(installment, today)
|
|
||||||
|
|
||||||
result = DebtInstallmentResponse(
|
|
||||||
id=installment.id,
|
|
||||||
account_id=installment.account_id,
|
|
||||||
total_amount=installment.total_amount,
|
|
||||||
total_periods=installment.total_periods,
|
|
||||||
current_period=installment.current_period,
|
|
||||||
payment_day=installment.payment_day,
|
|
||||||
payment_amount=installment.payment_amount,
|
|
||||||
start_date=installment.start_date,
|
|
||||||
is_completed=installment.is_completed,
|
|
||||||
created_at=installment.created_at,
|
|
||||||
updated_at=installment.updated_at,
|
|
||||||
**info,
|
|
||||||
)
|
|
||||||
if installment.account:
|
|
||||||
result.account_name = installment.account.name
|
|
||||||
result.account_icon = installment.account.icon
|
|
||||||
result.account_color = installment.account.color
|
|
||||||
|
|
||||||
logger.info(f"分期还款成功: id={installment_id}, current_period={installment.current_period}")
|
|
||||||
return result
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
logger.error(f"分期还款失败: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="分期还款失败")
|
|
||||||
@@ -17,6 +17,16 @@ from app.utils.logger import logger
|
|||||||
router = APIRouter(prefix="/api", tags=["纪念日"])
|
router = APIRouter(prefix="/api", tags=["纪念日"])
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_date(year: int, month: int, day: int) -> date:
|
||||||
|
"""安全构造日期,对闰年 2 月 29 日回退到 2 月 28 日"""
|
||||||
|
try:
|
||||||
|
return date(year, month, day)
|
||||||
|
except ValueError:
|
||||||
|
if month == 2 and day == 29:
|
||||||
|
return date(year, 2, 28)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def compute_next_info(anniversary: Anniversary, today: date) -> tuple:
|
def compute_next_info(anniversary: Anniversary, today: date) -> tuple:
|
||||||
"""计算纪念日的下一次日期、距今天数、周年数"""
|
"""计算纪念日的下一次日期、距今天数、周年数"""
|
||||||
month, day = anniversary.date.month, anniversary.date.day
|
month, day = anniversary.date.month, anniversary.date.day
|
||||||
@@ -24,9 +34,9 @@ def compute_next_info(anniversary: Anniversary, today: date) -> tuple:
|
|||||||
if anniversary.is_recurring:
|
if anniversary.is_recurring:
|
||||||
# 计算今年和明年的日期
|
# 计算今年和明年的日期
|
||||||
this_year = today.year
|
this_year = today.year
|
||||||
next_date = date(this_year, month, day)
|
next_date = _safe_date(this_year, month, day)
|
||||||
if next_date < today:
|
if next_date < today:
|
||||||
next_date = date(this_year + 1, month, day)
|
next_date = _safe_date(this_year + 1, month, day)
|
||||||
|
|
||||||
days_until = (next_date - today).days
|
days_until = (next_date - today).days
|
||||||
|
|
||||||
@@ -38,14 +48,14 @@ def compute_next_info(anniversary: Anniversary, today: date) -> tuple:
|
|||||||
else:
|
else:
|
||||||
# 非重复:使用原始日期(加上年份)
|
# 非重复:使用原始日期(加上年份)
|
||||||
if anniversary.year:
|
if anniversary.year:
|
||||||
target = date(anniversary.year, month, day)
|
target = _safe_date(anniversary.year, month, day)
|
||||||
if target < today:
|
if target < today:
|
||||||
return None, None, None
|
return None, None, None
|
||||||
days_until = (target - today).days
|
days_until = (target - today).days
|
||||||
return target, days_until, 0
|
return target, days_until, 0
|
||||||
else:
|
else:
|
||||||
# 无年份的日期按今年算
|
# 无年份的日期按今年算
|
||||||
target = date(today.year, month, day)
|
target = _safe_date(today.year, month, day)
|
||||||
if target < today:
|
if target < today:
|
||||||
return None, None, None
|
return None, None, None
|
||||||
days_until = (target - today).days
|
days_until = (target - today).days
|
||||||
@@ -219,10 +229,10 @@ def create_anniversary(data: AnniversaryCreate, db: Session = Depends(get_db)):
|
|||||||
db.refresh(db_anniversary)
|
db.refresh(db_anniversary)
|
||||||
|
|
||||||
today = date.today()
|
today = date.today()
|
||||||
next_date, days_until, year_count = compute_next_info(db_anniversary, today)
|
result = enrich_anniversary(db_anniversary, today)
|
||||||
|
|
||||||
logger.info(f"创建纪念日成功: id={db_anniversary.id}, title={db_anniversary.title}")
|
logger.info(f"创建纪念日成功: id={db_anniversary.id}, title={db_anniversary.title}")
|
||||||
return db_anniversary
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
logger.error(f"创建纪念日失败: {str(e)}")
|
logger.error(f"创建纪念日失败: {str(e)}")
|
||||||
@@ -234,7 +244,9 @@ def get_anniversary(anniversary_id: int, db: Session = Depends(get_db)):
|
|||||||
"""获取单个纪念日"""
|
"""获取单个纪念日"""
|
||||||
try:
|
try:
|
||||||
anniversary = get_or_404(db, Anniversary, anniversary_id, "纪念日")
|
anniversary = get_or_404(db, Anniversary, anniversary_id, "纪念日")
|
||||||
return anniversary
|
today = date.today()
|
||||||
|
result = enrich_anniversary(anniversary, today)
|
||||||
|
return result
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,33 +1,157 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
import time
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.user_settings import UserSettings
|
from app.models.user_settings import UserSettings
|
||||||
from app.schemas.auth import LoginRequest, TokenResponse, ChangePasswordRequest
|
from app.schemas.auth import LoginRequest, LoginResponse, ChangePasswordRequest, AuthStatusResponse, SetupPasswordRequest, AuthSetupStatusResponse
|
||||||
from app.utils.auth import (
|
from app.utils.auth import (
|
||||||
hash_password, verify_password, create_access_token,
|
hash_password, verify_password, create_access_token,
|
||||||
get_current_user, set_default_password
|
get_current_user, set_cached_token_version,
|
||||||
)
|
)
|
||||||
|
from app.utils.datetime import utcnow
|
||||||
|
from app.utils.rate_limiter import login_limiter
|
||||||
|
from app.config import ACCESS_TOKEN_EXPIRE_SECONDS
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
def _get_client_ip(request: Request) -> str:
|
||||||
def login(data: LoginRequest, db: Session = Depends(get_db)):
|
forwarded = request.headers.get("X-Forwarded-For")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_settings(db: Session) -> UserSettings:
|
||||||
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
||||||
if not settings:
|
if not settings:
|
||||||
settings = UserSettings(id=1)
|
settings = UserSettings(id=1)
|
||||||
db.add(settings)
|
db.add(settings)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(settings)
|
db.refresh(settings)
|
||||||
|
return settings
|
||||||
|
|
||||||
set_default_password(db, settings)
|
|
||||||
|
class _StatusLimiter:
|
||||||
|
MAX_REQUESTS = 30
|
||||||
|
WINDOW_SECONDS = 60
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._requests: dict[str, list[float]] = {}
|
||||||
|
|
||||||
|
def check(self, ip: str) -> bool:
|
||||||
|
now = time.time()
|
||||||
|
times = [t for t in self._requests.get(ip, []) if now - t < self.WINDOW_SECONDS]
|
||||||
|
self._requests[ip] = times
|
||||||
|
if len(times) >= self.MAX_REQUESTS:
|
||||||
|
return False
|
||||||
|
times.append(now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
_status_limiter = _StatusLimiter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=AuthSetupStatusResponse)
|
||||||
|
def auth_status(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""检查系统密码是否已设置"""
|
||||||
|
ip = _get_client_ip(request)
|
||||||
|
if not _status_limiter.check(ip):
|
||||||
|
raise HTTPException(status_code=429, detail="请求过于频繁")
|
||||||
|
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
||||||
|
has_password = bool(settings and settings.password_hash)
|
||||||
|
return AuthSetupStatusResponse(has_password=has_password)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/setup")
|
||||||
|
def setup_password(data: SetupPasswordRequest, db: Session = Depends(get_db)):
|
||||||
|
"""首次设置密码(仅在无密码时可用),设置成功后自动登录"""
|
||||||
|
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
||||||
|
if settings and settings.password_hash:
|
||||||
|
raise HTTPException(status_code=400, detail="密码已设置,请使用登录接口")
|
||||||
|
if not settings:
|
||||||
|
settings = UserSettings(id=1)
|
||||||
|
db.add(settings)
|
||||||
|
settings.password_hash = hash_password(data.password)
|
||||||
|
settings.token_version = 1
|
||||||
|
if data.nickname and data.nickname.strip():
|
||||||
|
nick = data.nickname.strip()
|
||||||
|
settings.nickname = nick
|
||||||
|
settings.site_name = f"{nick}待办"
|
||||||
|
settings.updated_at = utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
token = create_access_token({
|
||||||
|
"sub": str(settings.id),
|
||||||
|
"tv": settings.token_version,
|
||||||
|
})
|
||||||
|
response = JSONResponse(content={"message": "密码设置成功"})
|
||||||
|
response.set_cookie(
|
||||||
|
key="access_token",
|
||||||
|
value=token,
|
||||||
|
httponly=True,
|
||||||
|
samesite="strict",
|
||||||
|
max_age=ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=LoginResponse)
|
||||||
|
def login(data: LoginRequest, request: Request, db: Session = Depends(get_db)):
|
||||||
|
ip = _get_client_ip(request)
|
||||||
|
|
||||||
|
allowed, retry_after = login_limiter.check(ip)
|
||||||
|
if not allowed:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content={
|
||||||
|
"detail": f"登录尝试过于频繁,请 {retry_after // 60 + 1} 分钟后再试",
|
||||||
|
"retry_after": retry_after,
|
||||||
|
},
|
||||||
|
headers={"Retry-After": str(retry_after)},
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = _get_or_create_settings(db)
|
||||||
|
|
||||||
|
if not settings.password_hash:
|
||||||
|
raise HTTPException(status_code=400, detail="请先设置密码")
|
||||||
|
|
||||||
if not verify_password(data.password, settings.password_hash):
|
if not verify_password(data.password, settings.password_hash):
|
||||||
|
login_limiter.record_failure(ip)
|
||||||
raise HTTPException(status_code=401, detail="密码错误")
|
raise HTTPException(status_code=401, detail="密码错误")
|
||||||
|
|
||||||
token = create_access_token({"sub": str(settings.id)})
|
login_limiter.reset(ip)
|
||||||
return TokenResponse(access_token=token)
|
|
||||||
|
token = create_access_token({
|
||||||
|
"sub": str(settings.id),
|
||||||
|
"tv": settings.token_version,
|
||||||
|
})
|
||||||
|
|
||||||
|
response = JSONResponse(content={"message": "登录成功"})
|
||||||
|
response.set_cookie(
|
||||||
|
key="access_token",
|
||||||
|
value=token,
|
||||||
|
httponly=True,
|
||||||
|
samesite="strict",
|
||||||
|
max_age=ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout():
|
||||||
|
response = JSONResponse(content={"message": "已退出登录"})
|
||||||
|
response.delete_cookie("access_token", path="/")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=AuthStatusResponse)
|
||||||
|
def me(request: Request):
|
||||||
|
user = get_current_user(request)
|
||||||
|
return AuthStatusResponse(authenticated=True, user_id=user.get("sub", ""))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
@@ -36,7 +160,7 @@ def change_password(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
get_current_user(request)
|
user = get_current_user(request)
|
||||||
|
|
||||||
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
||||||
if not settings:
|
if not settings:
|
||||||
@@ -46,6 +170,9 @@ def change_password(
|
|||||||
raise HTTPException(status_code=400, detail="原密码错误")
|
raise HTTPException(status_code=400, detail="原密码错误")
|
||||||
|
|
||||||
settings.password_hash = hash_password(data.new_password)
|
settings.password_hash = hash_password(data.new_password)
|
||||||
|
settings.token_version = (settings.token_version or 0) + 1
|
||||||
|
set_cached_token_version(str(settings.id), settings.token_version)
|
||||||
|
settings.updated_at = utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"message": "密码修改成功"}
|
return {"message": "密码修改成功"}
|
||||||
|
|||||||
161
api/app/routers/backup.py
Normal file
161
api/app/routers/backup.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""数据备份导入导出路由"""
|
||||||
|
import json
|
||||||
|
from datetime import datetime, date
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import text
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from app.database import get_db, Base, engine
|
||||||
|
from app.utils.logger import logger
|
||||||
|
from app.utils.datetime import utcnow
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/backup", tags=["备份"])
|
||||||
|
|
||||||
|
# 导出顺序:按依赖关系(无 FK 的先导出)
|
||||||
|
EXPORT_TABLES = [
|
||||||
|
"categories", "tags", "user_settings", "sync_settings",
|
||||||
|
"habit_groups", "anniversary_categories", "certificate_categories",
|
||||||
|
"goals", "tasks", "habits", "anniversaries", "certificates",
|
||||||
|
"goal_steps", "goal_reviews", "goal_checkins", "habit_checkins",
|
||||||
|
"task_tags", "goal_tasks",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 导入时的清表顺序:子表先删(避免 FK 约束报错)
|
||||||
|
TRUNCATE_ORDER = [
|
||||||
|
"task_tags", "goal_tasks",
|
||||||
|
"habit_checkins", "goal_checkins",
|
||||||
|
"goal_reviews", "goal_steps",
|
||||||
|
"tasks", "habits", "anniversaries", "certificates",
|
||||||
|
"goals", "categories", "tags",
|
||||||
|
"habit_groups", "anniversary_categories", "certificate_categories",
|
||||||
|
"user_settings", "sync_settings",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 导入时的插入顺序:父表先插
|
||||||
|
INSERT_ORDER = [
|
||||||
|
"categories", "tags", "user_settings", "sync_settings",
|
||||||
|
"habit_groups", "anniversary_categories", "certificate_categories",
|
||||||
|
"goals", "tasks", "habits", "anniversaries", "certificates",
|
||||||
|
"goal_steps", "goal_reviews", "goal_checkins", "habit_checkins",
|
||||||
|
"task_tags", "goal_tasks",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_value(val):
|
||||||
|
"""将 Python 对象转为 JSON 可序列化的值"""
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, (datetime, date)):
|
||||||
|
return val.isoformat()
|
||||||
|
if isinstance(val, bytes):
|
||||||
|
return val.decode("utf-8", errors="replace")
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export")
|
||||||
|
def export_data(db: Session = Depends(get_db)):
|
||||||
|
"""导出所有数据为 JSON 备份文件"""
|
||||||
|
try:
|
||||||
|
data: dict[str, list[dict]] = {}
|
||||||
|
|
||||||
|
for table_name in EXPORT_TABLES:
|
||||||
|
rows = []
|
||||||
|
try:
|
||||||
|
result = db.execute(text(f"SELECT * FROM {table_name}"))
|
||||||
|
columns = list(result.keys())
|
||||||
|
for row in result:
|
||||||
|
rows.append({
|
||||||
|
col: _serialize_value(getattr(row, col))
|
||||||
|
for col in columns
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
# 表可能不存在
|
||||||
|
rows = []
|
||||||
|
data[table_name] = rows
|
||||||
|
|
||||||
|
backup = {
|
||||||
|
"metadata": {
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": utcnow().isoformat(),
|
||||||
|
},
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
json_bytes = json.dumps(backup, ensure_ascii=False, indent=2).encode("utf-8")
|
||||||
|
filename = f"elysia-backup-{utcnow().strftime('%Y%m%d-%H%M%S')}.json"
|
||||||
|
|
||||||
|
logger.info(f"数据导出成功,共 {sum(len(v) for v in data.values())} 条记录")
|
||||||
|
return StreamingResponse(
|
||||||
|
BytesIO(json_bytes),
|
||||||
|
media_type="application/json",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"导出数据失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="导出数据失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import")
|
||||||
|
async def import_data(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""导入备份数据(覆盖当前所有数据)"""
|
||||||
|
if not file.filename or not file.filename.endswith(".json"):
|
||||||
|
raise HTTPException(status_code=400, detail="请上传 JSON 格式的备份文件")
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = await file.read()
|
||||||
|
backup = json.loads(content.decode("utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise HTTPException(status_code=400, detail="备份文件格式不正确")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"读取备份文件失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=400, detail="读取备份文件失败")
|
||||||
|
|
||||||
|
payload = backup.get("data")
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(status_code=400, detail="备份文件内容为空")
|
||||||
|
|
||||||
|
# 验证必要表存在
|
||||||
|
for table_name in INSERT_ORDER:
|
||||||
|
if table_name not in payload:
|
||||||
|
raise HTTPException(status_code=400, detail=f"备份文件缺少表: {table_name}")
|
||||||
|
|
||||||
|
imported_count = 0
|
||||||
|
try:
|
||||||
|
# 1. 按序清空所有表
|
||||||
|
for table_name in TRUNCATE_ORDER:
|
||||||
|
try:
|
||||||
|
db.execute(text(f"DELETE FROM {table_name}"))
|
||||||
|
except Exception:
|
||||||
|
pass # 表可能不存在
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# 2. 按序插入数据
|
||||||
|
for table_name in INSERT_ORDER:
|
||||||
|
rows = payload.get(table_name, [])
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
columns = list(rows[0].keys())
|
||||||
|
col_str = ", ".join(columns)
|
||||||
|
placeholders = ", ".join([f":{c}" for c in columns])
|
||||||
|
|
||||||
|
for row_data in rows:
|
||||||
|
db.execute(
|
||||||
|
text(f"INSERT INTO {table_name} ({col_str}) VALUES ({placeholders})"),
|
||||||
|
{c: row_data[c] for c in columns},
|
||||||
|
)
|
||||||
|
imported_count += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"数据导入成功,共 {imported_count} 条记录")
|
||||||
|
return {"message": "数据导入成功", "count": imported_count}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"导入数据失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"导入数据失败: {str(e)}")
|
||||||
167
api/app/routers/certificates.py
Normal file
167
api/app/routers/certificates.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""证书路由"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.certificate import Certificate, CertificateCategory
|
||||||
|
from app.schemas.certificate import (
|
||||||
|
CertificateCreate, CertificateUpdate,
|
||||||
|
CertificateListResponse, CertificateDetailResponse,
|
||||||
|
CertificateCategoryCreate, CertificateCategoryUpdate, CertificateCategoryResponse,
|
||||||
|
)
|
||||||
|
from app.schemas.common import DeleteResponse
|
||||||
|
from app.utils.crud import get_or_404
|
||||||
|
from app.utils.datetime import utcnow
|
||||||
|
from app.utils.logger import logger
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["证书"])
|
||||||
|
|
||||||
|
|
||||||
|
# ============ 证书分类 API ============
|
||||||
|
|
||||||
|
@router.get("/certificate-categories", response_model=List[CertificateCategoryResponse])
|
||||||
|
def get_categories(db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
categories = db.query(CertificateCategory).order_by(
|
||||||
|
CertificateCategory.sort_order.asc(),
|
||||||
|
CertificateCategory.id.asc()
|
||||||
|
).all()
|
||||||
|
return categories
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取证书分类列表失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="获取证书分类列表失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/certificate-categories", response_model=CertificateCategoryResponse, status_code=201)
|
||||||
|
def create_category(data: CertificateCategoryCreate, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
cat = CertificateCategory(**data.model_dump())
|
||||||
|
db.add(cat)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cat)
|
||||||
|
logger.info(f"创建证书分类成功: id={cat.id}, name={cat.name}")
|
||||||
|
return cat
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"创建证书分类失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="创建证书分类失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/certificate-categories/{category_id}", response_model=CertificateCategoryResponse)
|
||||||
|
def update_category(category_id: int, data: CertificateCategoryUpdate, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
cat = get_or_404(db, CertificateCategory, category_id, "证书分类")
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(cat, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cat)
|
||||||
|
logger.info(f"更新证书分类成功: id={category_id}")
|
||||||
|
return cat
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"更新证书分类失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="更新证书分类失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/certificate-categories/{category_id}")
|
||||||
|
def delete_category(category_id: int, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
cat = get_or_404(db, CertificateCategory, category_id, "证书分类")
|
||||||
|
certs = db.query(Certificate).filter(Certificate.category_id == category_id).all()
|
||||||
|
for c in certs:
|
||||||
|
c.category_id = None
|
||||||
|
db.delete(cat)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"删除证书分类成功: id={category_id}")
|
||||||
|
return DeleteResponse(message="证书分类删除成功")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"删除证书分类失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="删除证书分类失败")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ 证书 API ============
|
||||||
|
|
||||||
|
@router.get("/certificates", response_model=List[CertificateListResponse])
|
||||||
|
def get_certificates(
|
||||||
|
category_id: Optional[int] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
query = db.query(Certificate)
|
||||||
|
if category_id is not None:
|
||||||
|
query = query.filter(Certificate.category_id == category_id)
|
||||||
|
certs = query.order_by(Certificate.sort_order.asc(), Certificate.created_at.desc()).all()
|
||||||
|
return certs
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取证书列表失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="获取证书列表失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/certificates", response_model=CertificateDetailResponse, status_code=201)
|
||||||
|
def create_certificate(data: CertificateCreate, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
cert = Certificate(**data.model_dump())
|
||||||
|
db.add(cert)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cert)
|
||||||
|
logger.info(f"创建证书成功: id={cert.id}, title={cert.title}")
|
||||||
|
return cert
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"创建证书失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="创建证书失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/certificates/{cert_id}", response_model=CertificateDetailResponse)
|
||||||
|
def get_certificate(cert_id: int, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
return get_or_404(db, Certificate, cert_id, "证书")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取证书失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="获取证书失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/certificates/{cert_id}", response_model=CertificateDetailResponse)
|
||||||
|
def update_certificate(cert_id: int, data: CertificateUpdate, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
cert = get_or_404(db, Certificate, cert_id, "证书")
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
if value is not None or field in data.clearable_fields:
|
||||||
|
setattr(cert, field, value)
|
||||||
|
cert.updated_at = utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cert)
|
||||||
|
logger.info(f"更新证书成功: id={cert_id}")
|
||||||
|
return cert
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"更新证书失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="更新证书失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/certificates/{cert_id}")
|
||||||
|
def delete_certificate(cert_id: int, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
cert = get_or_404(db, Certificate, cert_id, "证书")
|
||||||
|
db.delete(cert)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"删除证书成功: id={cert_id}")
|
||||||
|
return DeleteResponse(message="证书删除成功")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"删除证书失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="删除证书失败")
|
||||||
556
api/app/routers/goals.py
Normal file
556
api/app/routers/goals.py
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.goal import Goal, GoalStep, GoalReview, GoalCheckin
|
||||||
|
from app.models.task import Task
|
||||||
|
from app.schemas.goal import (
|
||||||
|
GoalCreate, GoalUpdate, GoalListResponse, GoalDetailResponse, GoalStatusUpdate,
|
||||||
|
GoalStepCreate, GoalStepUpdate, GoalStepResponse,
|
||||||
|
GoalReviewCreate, GoalReviewResponse,
|
||||||
|
GoalCheckinCreate, GoalCheckinUpdate, GoalCheckinResponse,
|
||||||
|
ReorderRequest,
|
||||||
|
)
|
||||||
|
from app.schemas.common import DeleteResponse
|
||||||
|
from app.utils.crud import get_or_404
|
||||||
|
from app.utils.datetime import utcnow, today
|
||||||
|
from app.utils.logger import logger
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/goals", tags=["目标"])
|
||||||
|
|
||||||
|
|
||||||
|
def recalc_progress(db: Session, goal_id: int):
|
||||||
|
"""根据追踪类型重新计算目标进度。里程碑模式按步骤完成比例;累计模式按打卡值/换算率/目标值。"""
|
||||||
|
goal = db.query(Goal).filter(Goal.id == goal_id).first()
|
||||||
|
if not goal:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if goal.track_type == "cumulative" and goal.target_value and goal.target_value > 0:
|
||||||
|
total = db.query(func.coalesce(func.sum(GoalCheckin.value), 0)).filter(
|
||||||
|
GoalCheckin.goal_id == goal_id,
|
||||||
|
).scalar()
|
||||||
|
goal.current_value = float(total)
|
||||||
|
progress_in_target = goal.current_value / goal.conversion_rate if goal.conversion_rate else goal.current_value
|
||||||
|
progress = int(progress_in_target / goal.target_value * 100)
|
||||||
|
return min(progress, 100)
|
||||||
|
|
||||||
|
total = db.query(GoalStep).filter(
|
||||||
|
GoalStep.goal_id == goal_id,
|
||||||
|
GoalStep.step_type == "milestone",
|
||||||
|
).count()
|
||||||
|
if total == 0:
|
||||||
|
return 0
|
||||||
|
completed = db.query(GoalStep).filter(
|
||||||
|
GoalStep.goal_id == goal_id,
|
||||||
|
GoalStep.step_type == "milestone",
|
||||||
|
GoalStep.status == "completed",
|
||||||
|
).count()
|
||||||
|
return int(completed / total * 100)
|
||||||
|
|
||||||
|
|
||||||
|
def build_step_tree(steps: list[GoalStep]) -> list[dict]:
|
||||||
|
"""将扁平的 step 列表转为树形结构(phase 包含子 milestone),按 sort_order 排序"""
|
||||||
|
step_map = {}
|
||||||
|
roots = []
|
||||||
|
for s in sorted(steps, key=lambda x: (x.sort_order or 0)):
|
||||||
|
step_map[s.id] = {
|
||||||
|
"id": s.id,
|
||||||
|
"goal_id": s.goal_id,
|
||||||
|
"parent_id": s.parent_id,
|
||||||
|
"title": s.title,
|
||||||
|
"step_type": s.step_type,
|
||||||
|
"status": s.status,
|
||||||
|
"target_date": s.target_date,
|
||||||
|
"reached_at": s.reached_at,
|
||||||
|
"sort_order": s.sort_order,
|
||||||
|
"created_at": s.created_at,
|
||||||
|
"children": [],
|
||||||
|
}
|
||||||
|
for s in sorted(steps, key=lambda x: (x.sort_order or 0)):
|
||||||
|
node = step_map[s.id]
|
||||||
|
if s.parent_id and s.parent_id in step_map:
|
||||||
|
step_map[s.parent_id]["children"].append(node)
|
||||||
|
else:
|
||||||
|
roots.append(node)
|
||||||
|
return roots
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Goals CRUD ============
|
||||||
|
|
||||||
|
@router.get("", response_model=List[GoalListResponse])
|
||||||
|
def get_goals(
|
||||||
|
status: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取所有目标"""
|
||||||
|
try:
|
||||||
|
query = db.query(Goal)
|
||||||
|
if status:
|
||||||
|
query = query.filter(Goal.status == status)
|
||||||
|
goals = query.order_by(Goal.sort_order, Goal.created_at.desc()).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for g in goals:
|
||||||
|
total = db.query(GoalStep).filter(
|
||||||
|
GoalStep.goal_id == g.id,
|
||||||
|
GoalStep.step_type == "milestone",
|
||||||
|
).count()
|
||||||
|
completed = db.query(GoalStep).filter(
|
||||||
|
GoalStep.goal_id == g.id,
|
||||||
|
GoalStep.step_type == "milestone",
|
||||||
|
GoalStep.status == "completed",
|
||||||
|
).count()
|
||||||
|
g.total_steps = total
|
||||||
|
g.completed_steps = completed
|
||||||
|
# 累计模式重新计算进度和当前值
|
||||||
|
if g.track_type == "cumulative":
|
||||||
|
g.current_value = float(db.query(func.coalesce(func.sum(GoalCheckin.value), 0)).filter(
|
||||||
|
GoalCheckin.goal_id == g.id,
|
||||||
|
).scalar())
|
||||||
|
g.progress = recalc_progress(db, g.id)
|
||||||
|
result.append(g)
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取目标列表失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="获取目标列表失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=GoalDetailResponse, status_code=201)
|
||||||
|
def create_goal(data: GoalCreate, db: Session = Depends(get_db)):
|
||||||
|
"""创建目标"""
|
||||||
|
try:
|
||||||
|
goal = Goal(**data.model_dump())
|
||||||
|
db.add(goal)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(goal)
|
||||||
|
logger.info(f"创建目标成功: id={goal.id}, title={goal.title}")
|
||||||
|
return goal
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"创建目标失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="创建目标失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{goal_id}", response_model=GoalDetailResponse)
|
||||||
|
def get_goal(goal_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""获取目标详情(含 steps 树、reviews、关联 tasks、checkins)"""
|
||||||
|
try:
|
||||||
|
goal = get_or_404(db, Goal, goal_id, "目标")
|
||||||
|
goal.total_steps = db.query(GoalStep).filter(
|
||||||
|
GoalStep.goal_id == goal_id,
|
||||||
|
GoalStep.step_type == "milestone",
|
||||||
|
).count()
|
||||||
|
goal.completed_steps = db.query(GoalStep).filter(
|
||||||
|
GoalStep.goal_id == goal_id,
|
||||||
|
GoalStep.step_type == "milestone",
|
||||||
|
GoalStep.status == "completed",
|
||||||
|
).count()
|
||||||
|
if goal.track_type == "cumulative":
|
||||||
|
goal.current_value = float(db.query(func.coalesce(func.sum(GoalCheckin.value), 0)).filter(
|
||||||
|
GoalCheckin.goal_id == goal_id,
|
||||||
|
).scalar())
|
||||||
|
goal.progress = recalc_progress(db, goal_id)
|
||||||
|
return goal
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取目标详情失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="获取目标详情失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{goal_id}", response_model=GoalDetailResponse)
|
||||||
|
def update_goal(goal_id: int, data: GoalUpdate, db: Session = Depends(get_db)):
|
||||||
|
"""更新目标"""
|
||||||
|
try:
|
||||||
|
goal = get_or_404(db, Goal, goal_id, "目标")
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
if value is not None or field in data.clearable_fields:
|
||||||
|
setattr(goal, field, value)
|
||||||
|
|
||||||
|
goal.updated_at = utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(goal)
|
||||||
|
logger.info(f"更新目标成功: id={goal_id}")
|
||||||
|
return goal
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"更新目标失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="更新目标失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{goal_id}")
|
||||||
|
def delete_goal(goal_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""删除目标(级联删除 steps + reviews)"""
|
||||||
|
try:
|
||||||
|
goal = get_or_404(db, Goal, goal_id, "目标")
|
||||||
|
db.delete(goal)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"删除目标成功: id={goal_id}")
|
||||||
|
return DeleteResponse(message="目标删除成功")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"删除目标失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="删除目标失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{goal_id}/status", response_model=GoalDetailResponse)
|
||||||
|
def update_goal_status(goal_id: int, data: GoalStatusUpdate, db: Session = Depends(get_db)):
|
||||||
|
"""更新目标状态"""
|
||||||
|
try:
|
||||||
|
goal = get_or_404(db, Goal, goal_id, "目标")
|
||||||
|
goal.status = data.status
|
||||||
|
if data.status == "completed":
|
||||||
|
goal.completed_at = utcnow()
|
||||||
|
goal.progress = 100
|
||||||
|
else:
|
||||||
|
goal.completed_at = None
|
||||||
|
goal.updated_at = utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(goal)
|
||||||
|
logger.info(f"更新目标状态成功: id={goal_id}, status={data.status}")
|
||||||
|
return goal
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"更新目标状态失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="更新目标状态失败")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Steps ============
|
||||||
|
|
||||||
|
@router.post("/{goal_id}/steps", response_model=GoalStepResponse, status_code=201)
|
||||||
|
def create_step(goal_id: int, data: GoalStepCreate, db: Session = Depends(get_db)):
|
||||||
|
"""添加阶段/里程碑"""
|
||||||
|
try:
|
||||||
|
get_or_404(db, Goal, goal_id, "目标")
|
||||||
|
# 自动分配 sort_order:同类步骤中取最大值 + 1
|
||||||
|
max_sort = db.query(GoalStep).filter(
|
||||||
|
GoalStep.goal_id == goal_id,
|
||||||
|
GoalStep.step_type == data.step_type,
|
||||||
|
).order_by(GoalStep.sort_order.desc()).first()
|
||||||
|
next_sort = (max_sort.sort_order + 1) if max_sort and max_sort.sort_order is not None else 0
|
||||||
|
step = GoalStep(goal_id=goal_id, sort_order=next_sort, **data.model_dump())
|
||||||
|
db.add(step)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(step)
|
||||||
|
|
||||||
|
# 重算进度
|
||||||
|
goal = db.query(Goal).filter(Goal.id == goal_id).first()
|
||||||
|
if goal:
|
||||||
|
goal.progress = recalc_progress(db, goal_id)
|
||||||
|
goal.updated_at = utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"添加{data.step_type}成功: id={step.id}, goal_id={goal_id}")
|
||||||
|
return step
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"添加步骤失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="添加步骤失败")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Reorder ============
|
||||||
|
|
||||||
|
@router.put("/{goal_id}/steps/reorder")
|
||||||
|
def reorder_steps(goal_id: int, data: ReorderRequest, db: Session = Depends(get_db)):
|
||||||
|
"""批量更新步骤排序"""
|
||||||
|
try:
|
||||||
|
get_or_404(db, Goal, goal_id, "目标")
|
||||||
|
for item in data.items:
|
||||||
|
step = db.query(GoalStep).filter(
|
||||||
|
GoalStep.id == item.id,
|
||||||
|
GoalStep.goal_id == goal_id,
|
||||||
|
).first()
|
||||||
|
if step:
|
||||||
|
step.sort_order = item.sort_order
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"步骤排序更新成功: goal_id={goal_id}, count={len(data.items)}")
|
||||||
|
return {"message": "排序更新成功"}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"更新步骤排序失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="更新排序失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{goal_id}/steps/{step_id}", response_model=GoalStepResponse)
|
||||||
|
def update_step(goal_id: int, step_id: int, data: GoalStepUpdate, db: Session = Depends(get_db)):
|
||||||
|
"""更新阶段/里程碑"""
|
||||||
|
try:
|
||||||
|
get_or_404(db, Goal, goal_id, "目标")
|
||||||
|
step = get_or_404(db, GoalStep, step_id, "步骤")
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
if value is not None or field in data.clearable_fields:
|
||||||
|
setattr(step, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(step)
|
||||||
|
|
||||||
|
# 重算进度
|
||||||
|
goal = db.query(Goal).filter(Goal.id == goal_id).first()
|
||||||
|
if goal:
|
||||||
|
goal.progress = recalc_progress(db, goal_id)
|
||||||
|
goal.updated_at = utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"更新步骤成功: id={step_id}")
|
||||||
|
return step
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"更新步骤失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="更新步骤失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{goal_id}/steps/{step_id}")
|
||||||
|
def delete_step(goal_id: int, step_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""删除阶段/里程碑(级联删除子 step)"""
|
||||||
|
try:
|
||||||
|
get_or_404(db, Goal, goal_id, "目标")
|
||||||
|
step = get_or_404(db, GoalStep, step_id, "步骤")
|
||||||
|
db.delete(step)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 重算进度
|
||||||
|
goal = db.query(Goal).filter(Goal.id == goal_id).first()
|
||||||
|
if goal:
|
||||||
|
goal.progress = recalc_progress(db, goal_id)
|
||||||
|
goal.updated_at = utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"删除步骤成功: id={step_id}")
|
||||||
|
return DeleteResponse(message="步骤删除成功")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"删除步骤失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="删除步骤失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{goal_id}/steps/{step_id}/toggle", response_model=GoalStepResponse)
|
||||||
|
def toggle_step(goal_id: int, step_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""切换步骤状态 (pending → in_progress → completed → pending)"""
|
||||||
|
try:
|
||||||
|
get_or_404(db, Goal, goal_id, "目标")
|
||||||
|
step = get_or_404(db, GoalStep, step_id, "步骤")
|
||||||
|
|
||||||
|
cycle = {"pending": "in_progress", "in_progress": "completed", "completed": "pending"}
|
||||||
|
step.status = cycle.get(step.status, "pending")
|
||||||
|
|
||||||
|
if step.status == "completed":
|
||||||
|
step.reached_at = utcnow()
|
||||||
|
else:
|
||||||
|
step.reached_at = None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(step)
|
||||||
|
|
||||||
|
# 重算进度
|
||||||
|
goal = db.query(Goal).filter(Goal.id == goal_id).first()
|
||||||
|
if goal:
|
||||||
|
goal.progress = recalc_progress(db, goal_id)
|
||||||
|
goal.updated_at = utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"切换步骤状态成功: id={step_id}, status={step.status}")
|
||||||
|
return step
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"切换步骤状态失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="切换步骤状态失败")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Reviews ============
|
||||||
|
|
||||||
|
@router.post("/{goal_id}/reviews", response_model=GoalReviewResponse, status_code=201)
|
||||||
|
def create_review(goal_id: int, data: GoalReviewCreate, db: Session = Depends(get_db)):
|
||||||
|
"""创建复盘记录"""
|
||||||
|
try:
|
||||||
|
get_or_404(db, Goal, goal_id, "目标")
|
||||||
|
review = GoalReview(goal_id=goal_id, **data.model_dump())
|
||||||
|
db.add(review)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(review)
|
||||||
|
logger.info(f"创建复盘成功: goal_id={goal_id}")
|
||||||
|
return review
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"创建复盘失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="创建复盘失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{goal_id}/reviews/{review_id}")
|
||||||
|
def delete_review(goal_id: int, review_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""删除复盘记录"""
|
||||||
|
try:
|
||||||
|
get_or_404(db, Goal, goal_id, "目标")
|
||||||
|
review = get_or_404(db, GoalReview, review_id, "复盘记录")
|
||||||
|
db.delete(review)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"删除复盘成功: id={review_id}")
|
||||||
|
return DeleteResponse(message="复盘记录删除成功")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"删除复盘失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="删除复盘失败")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Task Linking ============
|
||||||
|
|
||||||
|
@router.post("/{goal_id}/tasks/{task_id}")
|
||||||
|
def link_task(goal_id: int, task_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""关联任务到目标"""
|
||||||
|
try:
|
||||||
|
goal = get_or_404(db, Goal, goal_id, "目标")
|
||||||
|
task = get_or_404(db, Task, task_id, "任务")
|
||||||
|
if task not in goal.tasks:
|
||||||
|
goal.tasks.append(task)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"关联任务成功: goal_id={goal_id}, task_id={task_id}")
|
||||||
|
return {"message": "关联成功"}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"关联任务失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="关联任务失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{goal_id}/tasks/{task_id}")
|
||||||
|
def unlink_task(goal_id: int, task_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""取消关联任务"""
|
||||||
|
try:
|
||||||
|
goal = get_or_404(db, Goal, goal_id, "目标")
|
||||||
|
task = get_or_404(db, Task, task_id, "任务")
|
||||||
|
if task in goal.tasks:
|
||||||
|
goal.tasks.remove(task)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"取消关联任务成功: goal_id={goal_id}, task_id={task_id}")
|
||||||
|
return DeleteResponse(message="取消关联成功")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"取消关联任务失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="取消关联任务失败")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Checkins (累计打卡) ============
|
||||||
|
|
||||||
|
@router.get("/{goal_id}/checkins", response_model=List[GoalCheckinResponse])
|
||||||
|
def get_checkins(goal_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""获取目标的打卡记录列表"""
|
||||||
|
try:
|
||||||
|
get_or_404(db, Goal, goal_id, "目标")
|
||||||
|
checkins = db.query(GoalCheckin).filter(
|
||||||
|
GoalCheckin.goal_id == goal_id,
|
||||||
|
).order_by(GoalCheckin.checkin_date.desc(), GoalCheckin.created_at.desc()).all()
|
||||||
|
return checkins
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取打卡记录失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="获取打卡记录失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{goal_id}/checkins", response_model=GoalCheckinResponse, status_code=201)
|
||||||
|
def create_checkin(goal_id: int, data: GoalCheckinCreate, db: Session = Depends(get_db)):
|
||||||
|
"""创建打卡记录并重算进度"""
|
||||||
|
try:
|
||||||
|
get_or_404(db, Goal, goal_id, "目标")
|
||||||
|
checkin = GoalCheckin(goal_id=goal_id, **data.model_dump())
|
||||||
|
db.add(checkin)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(checkin)
|
||||||
|
|
||||||
|
goal = db.query(Goal).filter(Goal.id == goal_id).first()
|
||||||
|
if goal:
|
||||||
|
goal.progress = recalc_progress(db, goal_id)
|
||||||
|
goal.updated_at = utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"创建打卡记录成功: id={checkin.id}, goal_id={goal_id}, value={checkin.value}")
|
||||||
|
return checkin
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"创建打卡记录失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="创建打卡记录失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{goal_id}/checkins/{checkin_id}", response_model=GoalCheckinResponse)
|
||||||
|
def update_checkin(goal_id: int, checkin_id: int, data: GoalCheckinUpdate, db: Session = Depends(get_db)):
|
||||||
|
"""修改打卡记录并重算进度"""
|
||||||
|
try:
|
||||||
|
get_or_404(db, Goal, goal_id, "目标")
|
||||||
|
checkin = get_or_404(db, GoalCheckin, checkin_id, "打卡记录")
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
if value is not None or field in data.clearable_fields:
|
||||||
|
setattr(checkin, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(checkin)
|
||||||
|
|
||||||
|
goal = db.query(Goal).filter(Goal.id == goal_id).first()
|
||||||
|
if goal:
|
||||||
|
goal.progress = recalc_progress(db, goal_id)
|
||||||
|
goal.updated_at = utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"更新打卡记录成功: id={checkin_id}")
|
||||||
|
return checkin
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"更新打卡记录失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="更新打卡记录失败")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{goal_id}/checkins/{checkin_id}")
|
||||||
|
def delete_checkin(goal_id: int, checkin_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""删除打卡记录并重算进度"""
|
||||||
|
try:
|
||||||
|
get_or_404(db, Goal, goal_id, "目标")
|
||||||
|
checkin = get_or_404(db, GoalCheckin, checkin_id, "打卡记录")
|
||||||
|
db.delete(checkin)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
goal = db.query(Goal).filter(Goal.id == goal_id).first()
|
||||||
|
if goal:
|
||||||
|
goal.progress = recalc_progress(db, goal_id)
|
||||||
|
goal.updated_at = utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"删除打卡记录成功: id={checkin_id}")
|
||||||
|
return DeleteResponse(message="打卡记录删除成功")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"删除打卡记录失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="删除打卡记录失败")
|
||||||
152
api/app/routers/sync.py
Normal file
152
api/app/routers/sync.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import SyncSettings
|
||||||
|
from app.schemas.sync import (
|
||||||
|
SyncConfigUpdate, SyncConfigResponse,
|
||||||
|
SyncStatusResponse, SyncTestResponse, SyncOperationResponse,
|
||||||
|
)
|
||||||
|
from app.utils.crypto import encrypt, decrypt
|
||||||
|
from app.utils.webdav import WebDAVClient
|
||||||
|
from app.utils.sync_service import push_to_remote, pull_from_remote, bidirectional_sync
|
||||||
|
from app.utils.sync_lock import is_syncing
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/sync", tags=["同步"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_settings(db: Session) -> SyncSettings:
|
||||||
|
settings = db.query(SyncSettings).filter(SyncSettings.id == 1).first()
|
||||||
|
if not settings:
|
||||||
|
settings = SyncSettings(id=1)
|
||||||
|
db.add(settings)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(settings)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config", response_model=SyncConfigResponse)
|
||||||
|
def get_sync_config(request: Request, db: Session = Depends(get_db)):
|
||||||
|
settings = _get_or_create_settings(db)
|
||||||
|
response = SyncConfigResponse(
|
||||||
|
webdav_url=settings.webdav_url,
|
||||||
|
webdav_username=settings.webdav_username,
|
||||||
|
webdav_password="***" if settings.webdav_password else None,
|
||||||
|
webdav_path=settings.webdav_path or "/elysia-todo/",
|
||||||
|
sync_enabled=settings.sync_enabled,
|
||||||
|
auto_sync=settings.auto_sync,
|
||||||
|
auto_sync_interval=settings.auto_sync_interval or 300,
|
||||||
|
last_sync_at=settings.last_sync_at,
|
||||||
|
last_sync_version=settings.last_sync_version or 0,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/config", response_model=SyncConfigResponse)
|
||||||
|
def update_sync_config(config: SyncConfigUpdate, request: Request, db: Session = Depends(get_db)):
|
||||||
|
settings = _get_or_create_settings(db)
|
||||||
|
|
||||||
|
if config.webdav_url is not None:
|
||||||
|
settings.webdav_url = config.webdav_url
|
||||||
|
if config.webdav_username is not None:
|
||||||
|
settings.webdav_username = config.webdav_username
|
||||||
|
if config.webdav_password is not None and config.webdav_password != "***":
|
||||||
|
settings.webdav_password = encrypt(config.webdav_password)
|
||||||
|
if config.webdav_path is not None:
|
||||||
|
settings.webdav_path = config.webdav_path
|
||||||
|
if config.auto_sync is not None:
|
||||||
|
settings.auto_sync = config.auto_sync
|
||||||
|
if config.auto_sync_interval is not None:
|
||||||
|
settings.auto_sync_interval = config.auto_sync_interval
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(settings)
|
||||||
|
|
||||||
|
return SyncConfigResponse(
|
||||||
|
webdav_url=settings.webdav_url,
|
||||||
|
webdav_username=settings.webdav_username,
|
||||||
|
webdav_password="***" if settings.webdav_password else None,
|
||||||
|
webdav_path=settings.webdav_path or "/elysia-todo/",
|
||||||
|
sync_enabled=settings.sync_enabled,
|
||||||
|
auto_sync=settings.auto_sync,
|
||||||
|
auto_sync_interval=settings.auto_sync_interval or 300,
|
||||||
|
last_sync_at=settings.last_sync_at,
|
||||||
|
last_sync_version=settings.last_sync_version or 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test", response_model=SyncTestResponse)
|
||||||
|
def test_connection(request: Request, db: Session = Depends(get_db)):
|
||||||
|
settings = _get_or_create_settings(db)
|
||||||
|
|
||||||
|
if not settings.webdav_url:
|
||||||
|
return SyncTestResponse(success=False, message="未配置 WebDAV 地址")
|
||||||
|
|
||||||
|
password = decrypt(settings.webdav_password) if settings.webdav_password else ""
|
||||||
|
if settings.webdav_password and password is None:
|
||||||
|
return SyncTestResponse(success=False, message="WebDAV 密码解密失败,请重新配置")
|
||||||
|
|
||||||
|
client = WebDAVClient(
|
||||||
|
url=settings.webdav_url,
|
||||||
|
username=settings.webdav_username or "",
|
||||||
|
password=password or "",
|
||||||
|
path=settings.webdav_path or "/elysia-todo/",
|
||||||
|
)
|
||||||
|
|
||||||
|
success, message = client.test_connection()
|
||||||
|
return SyncTestResponse(success=success, message=message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/push", response_model=SyncOperationResponse)
|
||||||
|
def sync_push(request: Request, db: Session = Depends(get_db)):
|
||||||
|
result = push_to_remote(db)
|
||||||
|
return SyncOperationResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pull", response_model=SyncOperationResponse)
|
||||||
|
def sync_pull(request: Request, db: Session = Depends(get_db)):
|
||||||
|
result = pull_from_remote(db)
|
||||||
|
return SyncOperationResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync", response_model=SyncOperationResponse)
|
||||||
|
def sync_bidirectional(request: Request, db: Session = Depends(get_db)):
|
||||||
|
result = bidirectional_sync(db)
|
||||||
|
return SyncOperationResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=SyncStatusResponse)
|
||||||
|
def get_sync_status(request: Request, db: Session = Depends(get_db)):
|
||||||
|
settings = _get_or_create_settings(db)
|
||||||
|
return SyncStatusResponse(
|
||||||
|
syncing=is_syncing(),
|
||||||
|
last_sync_at=settings.last_sync_at,
|
||||||
|
last_sync_version=settings.last_sync_version or 0,
|
||||||
|
sync_enabled=settings.sync_enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/remote", response_model=SyncOperationResponse)
|
||||||
|
def clear_remote(request: Request, db: Session = Depends(get_db)):
|
||||||
|
from app.utils.webdav import WebDAVClient
|
||||||
|
from app.utils.crypto import decrypt
|
||||||
|
|
||||||
|
settings = _get_or_create_settings(db)
|
||||||
|
if not settings.webdav_url:
|
||||||
|
return SyncOperationResponse(success=False, message="未配置 WebDAV 地址")
|
||||||
|
|
||||||
|
password = decrypt(settings.webdav_password) if settings.webdav_password else ""
|
||||||
|
if settings.webdav_password and password is None:
|
||||||
|
return SyncOperationResponse(success=False, message="WebDAV 密码解密失败,请重新配置")
|
||||||
|
|
||||||
|
client = WebDAVClient(
|
||||||
|
url=settings.webdav_url,
|
||||||
|
username=settings.webdav_username or "",
|
||||||
|
password=password or "",
|
||||||
|
path=settings.webdav_path or "/elysia-todo/",
|
||||||
|
)
|
||||||
|
|
||||||
|
success = client.clear_remote()
|
||||||
|
if success:
|
||||||
|
return SyncOperationResponse(success=True, message="远端数据已清空")
|
||||||
|
return SyncOperationResponse(success=False, message="清空远端数据失败")
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
from pydantic import BaseModel, Field
|
|
||||||
from datetime import date, datetime
|
|
||||||
from typing import Optional, List
|
|
||||||
|
|
||||||
|
|
||||||
# ============ 财务账户 Schema ============
|
|
||||||
|
|
||||||
class AccountBase(BaseModel):
|
|
||||||
"""账户基础模型"""
|
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
|
||||||
account_type: str = Field(default="savings", pattern="^(savings|debt)$")
|
|
||||||
balance: float = Field(default=0.0)
|
|
||||||
icon: str = Field(default="wallet", max_length=50)
|
|
||||||
color: str = Field(default="#FFB7C5", max_length=20)
|
|
||||||
sort_order: int = Field(default=0)
|
|
||||||
is_active: bool = Field(default=True)
|
|
||||||
description: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class AccountCreate(AccountBase):
|
|
||||||
"""创建账户请求模型"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class AccountUpdate(BaseModel):
|
|
||||||
"""更新账户请求模型"""
|
|
||||||
name: Optional[str] = Field(None, max_length=100)
|
|
||||||
account_type: Optional[str] = Field(None, pattern="^(savings|debt)$")
|
|
||||||
balance: Optional[float] = None
|
|
||||||
icon: Optional[str] = Field(None, max_length=50)
|
|
||||||
color: Optional[str] = Field(None, max_length=20)
|
|
||||||
sort_order: Optional[int] = None
|
|
||||||
is_active: Optional[bool] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class AccountResponse(AccountBase):
|
|
||||||
"""账户响应模型"""
|
|
||||||
id: int
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class AccountListItemResponse(BaseModel):
|
|
||||||
"""账户列表项响应模型(含分期摘要)"""
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
account_type: str
|
|
||||||
balance: float
|
|
||||||
icon: str
|
|
||||||
color: str
|
|
||||||
sort_order: int
|
|
||||||
is_active: bool
|
|
||||||
description: Optional[str] = None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
installments: List[dict] = []
|
|
||||||
|
|
||||||
|
|
||||||
class BalanceUpdateRequest(BaseModel):
|
|
||||||
"""更新余额请求模型"""
|
|
||||||
new_balance: float
|
|
||||||
note: Optional[str] = Field(None, max_length=200)
|
|
||||||
|
|
||||||
|
|
||||||
# ============ 账户变更历史 Schema ============
|
|
||||||
|
|
||||||
class AccountHistoryResponse(BaseModel):
|
|
||||||
"""变更历史响应模型"""
|
|
||||||
id: int
|
|
||||||
account_id: int
|
|
||||||
change_amount: float
|
|
||||||
balance_before: float
|
|
||||||
balance_after: float
|
|
||||||
note: Optional[str] = None
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
# ============ 分期还款计划 Schema ============
|
|
||||||
|
|
||||||
class DebtInstallmentBase(BaseModel):
|
|
||||||
"""分期计划基础模型"""
|
|
||||||
account_id: int
|
|
||||||
total_amount: float
|
|
||||||
total_periods: int = Field(..., ge=1)
|
|
||||||
current_period: int = Field(default=1, ge=1)
|
|
||||||
payment_day: int = Field(..., ge=1, le=31)
|
|
||||||
payment_amount: float = Field(..., gt=0)
|
|
||||||
start_date: date
|
|
||||||
is_completed: bool = Field(default=False)
|
|
||||||
|
|
||||||
|
|
||||||
class DebtInstallmentCreate(DebtInstallmentBase):
|
|
||||||
"""创建分期计划请求模型"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class DebtInstallmentUpdate(BaseModel):
|
|
||||||
"""更新分期计划请求模型"""
|
|
||||||
account_id: Optional[int] = None
|
|
||||||
total_amount: Optional[float] = None
|
|
||||||
total_periods: Optional[int] = Field(None, ge=1)
|
|
||||||
current_period: Optional[int] = Field(None, ge=1)
|
|
||||||
payment_day: Optional[int] = Field(None, ge=1, le=31)
|
|
||||||
payment_amount: Optional[float] = Field(None, gt=0)
|
|
||||||
start_date: Optional[date] = None
|
|
||||||
is_completed: Optional[bool] = None
|
|
||||||
|
|
||||||
|
|
||||||
class DebtInstallmentResponse(DebtInstallmentBase):
|
|
||||||
"""分期计划响应模型(含计算字段)"""
|
|
||||||
id: int
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
# 计算字段
|
|
||||||
next_payment_date: Optional[date] = None
|
|
||||||
days_until_payment: Optional[int] = None
|
|
||||||
remaining_periods: Optional[int] = None
|
|
||||||
|
|
||||||
# 关联账户信息
|
|
||||||
account_name: Optional[str] = None
|
|
||||||
account_icon: Optional[str] = None
|
|
||||||
account_color: Optional[str] = None
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
@@ -54,6 +54,7 @@ class AnniversaryCategoryUpdate(BaseModel):
|
|||||||
class AnniversaryCategoryResponse(AnniversaryCategoryBase):
|
class AnniversaryCategoryResponse(AnniversaryCategoryBase):
|
||||||
"""纪念日分类响应模型"""
|
"""纪念日分类响应模型"""
|
||||||
id: int
|
id: int
|
||||||
|
uuid: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -111,6 +112,7 @@ class AnniversaryUpdate(BaseModel):
|
|||||||
class AnniversaryResponse(AnniversaryBase):
|
class AnniversaryResponse(AnniversaryBase):
|
||||||
"""纪念日响应模型"""
|
"""纪念日响应模型"""
|
||||||
id: int
|
id: int
|
||||||
|
uuid: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
category: Optional[AnniversaryCategoryResponse] = None
|
category: Optional[AnniversaryCategoryResponse] = None
|
||||||
|
|||||||
@@ -1,15 +1,48 @@
|
|||||||
from pydantic import BaseModel, Field
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
password: str = Field(..., min_length=1, max_length=100)
|
password: str = Field(..., min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class LoginResponse(BaseModel):
|
||||||
access_token: str
|
message: str = "登录成功"
|
||||||
token_type: str = "bearer"
|
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordRequest(BaseModel):
|
class ChangePasswordRequest(BaseModel):
|
||||||
old_password: str = Field(..., min_length=1, max_length=100)
|
old_password: str = Field(..., min_length=1, max_length=100)
|
||||||
new_password: str = Field(..., min_length=1, max_length=100)
|
new_password: str = Field(..., min_length=6, max_length=100)
|
||||||
|
|
||||||
|
@field_validator("new_password")
|
||||||
|
@classmethod
|
||||||
|
def validate_password_strength(cls, v: str) -> str:
|
||||||
|
if len(v) < 6:
|
||||||
|
raise ValueError("密码长度至少6位")
|
||||||
|
if len(set(v)) < 3:
|
||||||
|
raise ValueError("密码不能过于简单(需包含至少3种不同字符)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class AuthStatusResponse(BaseModel):
|
||||||
|
authenticated: bool
|
||||||
|
user_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class SetupPasswordRequest(BaseModel):
|
||||||
|
password: str = Field(..., min_length=6, max_length=100)
|
||||||
|
nickname: Optional[str] = Field(None, min_length=1, max_length=50)
|
||||||
|
|
||||||
|
@field_validator("password")
|
||||||
|
@classmethod
|
||||||
|
def validate_password_strength(cls, v: str) -> str:
|
||||||
|
if len(v) < 6:
|
||||||
|
raise ValueError("密码长度至少6位")
|
||||||
|
if len(set(v)) < 3:
|
||||||
|
raise ValueError("密码不能过于简单(需包含至少3种不同字符)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSetupStatusResponse(BaseModel):
|
||||||
|
has_password: bool
|
||||||
|
|||||||
14
api/app/schemas/backup.py
Normal file
14
api/app/schemas/backup.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""数据备份导入导出 Schema"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
|
||||||
|
class BackupMetadata(BaseModel):
|
||||||
|
version: int = 1
|
||||||
|
exported_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class BackupPayload(BaseModel):
|
||||||
|
metadata: BackupMetadata
|
||||||
|
data: dict[str, list[dict[str, Any]]]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
class CategoryBase(BaseModel):
|
class CategoryBase(BaseModel):
|
||||||
@@ -23,6 +24,7 @@ class CategoryUpdate(BaseModel):
|
|||||||
class CategoryResponse(CategoryBase):
|
class CategoryResponse(CategoryBase):
|
||||||
"""分类响应模型"""
|
"""分类响应模型"""
|
||||||
id: int
|
id: int
|
||||||
|
uuid: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
79
api/app/schemas/certificate.py
Normal file
79
api/app/schemas/certificate.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""证书 Schema"""
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
|
||||||
|
# ============ 证书分类 Schema ============
|
||||||
|
|
||||||
|
class CertificateCategoryBase(BaseModel):
|
||||||
|
name: str = Field(..., max_length=50)
|
||||||
|
icon: str = Field(default="medal", max_length=50)
|
||||||
|
color: str = Field(default="#FFB7C5", max_length=20)
|
||||||
|
sort_order: int = Field(default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateCategoryCreate(CertificateCategoryBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateCategoryUpdate(BaseModel):
|
||||||
|
name: Optional[str] = Field(None, max_length=50)
|
||||||
|
icon: Optional[str] = Field(None, max_length=50)
|
||||||
|
color: Optional[str] = Field(None, max_length=20)
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateCategoryResponse(CertificateCategoryBase):
|
||||||
|
id: int
|
||||||
|
uuid: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============ 证书 Schema ============
|
||||||
|
|
||||||
|
class CertificateBase(BaseModel):
|
||||||
|
title: str = Field(..., max_length=200)
|
||||||
|
category_id: Optional[int] = None
|
||||||
|
image: Optional[str] = None
|
||||||
|
issuer: Optional[str] = Field(None, max_length=200)
|
||||||
|
issue_date: Optional[date] = None
|
||||||
|
expiry_date: Optional[date] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
sort_order: int = Field(default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateCreate(CertificateBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateUpdate(BaseModel):
|
||||||
|
title: Optional[str] = Field(None, max_length=200)
|
||||||
|
category_id: Optional[int] = None
|
||||||
|
image: Optional[str] = None
|
||||||
|
issuer: Optional[str] = Field(None, max_length=200)
|
||||||
|
issue_date: Optional[date] = None
|
||||||
|
expiry_date: Optional[date] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def clearable_fields(self) -> set:
|
||||||
|
return {"description", "category_id", "image", "issuer", "issue_date", "expiry_date"}
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateListResponse(CertificateBase):
|
||||||
|
id: int
|
||||||
|
uuid: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
category: Optional[CertificateCategoryResponse] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateDetailResponse(CertificateListResponse):
|
||||||
|
pass
|
||||||
177
api/app/schemas/goal.py
Normal file
177
api/app/schemas/goal.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from app.schemas.category import CategoryResponse
|
||||||
|
from app.schemas.task import TaskResponse
|
||||||
|
|
||||||
|
|
||||||
|
# ============ GoalCheckin Schemas ============
|
||||||
|
|
||||||
|
class GoalCheckinCreate(BaseModel):
|
||||||
|
value: float = Field(..., gt=0)
|
||||||
|
note: Optional[str] = None
|
||||||
|
checkin_date: date
|
||||||
|
|
||||||
|
|
||||||
|
class GoalCheckinUpdate(BaseModel):
|
||||||
|
value: Optional[float] = Field(None, gt=0)
|
||||||
|
note: Optional[str] = None
|
||||||
|
checkin_date: Optional[date] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def clearable_fields(self) -> set:
|
||||||
|
return {"note"}
|
||||||
|
|
||||||
|
|
||||||
|
class GoalCheckinResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
uuid: Optional[str] = None
|
||||||
|
goal_id: int
|
||||||
|
value: float
|
||||||
|
note: Optional[str] = None
|
||||||
|
checkin_date: date
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============ GoalStep Schemas ============
|
||||||
|
|
||||||
|
class GoalStepBase(BaseModel):
|
||||||
|
title: str = Field(..., max_length=200)
|
||||||
|
step_type: str = Field(..., pattern="^(phase|milestone)$")
|
||||||
|
status: str = Field(default="pending", pattern="^(pending|in_progress|completed)$")
|
||||||
|
target_date: Optional[date] = None
|
||||||
|
parent_id: Optional[int] = None
|
||||||
|
sort_order: int = Field(default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class GoalStepCreate(GoalStepBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GoalStepUpdate(BaseModel):
|
||||||
|
title: Optional[str] = Field(None, max_length=200)
|
||||||
|
step_type: Optional[str] = Field(None, pattern="^(phase|milestone)$")
|
||||||
|
status: Optional[str] = Field(None, pattern="^(pending|in_progress|completed)$")
|
||||||
|
target_date: Optional[date] = None
|
||||||
|
parent_id: Optional[int] = None
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def clearable_fields(self) -> set:
|
||||||
|
return {"target_date", "parent_id"}
|
||||||
|
|
||||||
|
|
||||||
|
class GoalStepResponse(GoalStepBase):
|
||||||
|
id: int
|
||||||
|
uuid: Optional[str] = None
|
||||||
|
goal_id: int
|
||||||
|
reached_at: Optional[datetime] = None
|
||||||
|
created_at: datetime
|
||||||
|
children: List["GoalStepResponse"] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============ GoalReview Schemas ============
|
||||||
|
|
||||||
|
class GoalReviewCreate(BaseModel):
|
||||||
|
content: str = Field(..., min_length=1)
|
||||||
|
rating: Optional[int] = Field(None, ge=1, le=5)
|
||||||
|
|
||||||
|
|
||||||
|
class GoalReviewResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
uuid: Optional[str] = None
|
||||||
|
goal_id: int
|
||||||
|
content: str
|
||||||
|
rating: Optional[int] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Goal Schemas ============
|
||||||
|
|
||||||
|
class GoalBase(BaseModel):
|
||||||
|
title: str = Field(..., max_length=200)
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: str = Field(default="active", pattern="^(active|paused|completed|abandoned)$")
|
||||||
|
track_type: str = Field(default="milestone", pattern="^(milestone|cumulative)$")
|
||||||
|
target_value: Optional[float] = None
|
||||||
|
target_unit: Optional[str] = Field(None, max_length=20)
|
||||||
|
input_unit: Optional[str] = Field(None, max_length=20)
|
||||||
|
conversion_rate: float = Field(default=1.0)
|
||||||
|
target_date: Optional[date] = None
|
||||||
|
category_id: Optional[int] = None
|
||||||
|
color: str = Field(default="#FFB7C5", max_length=20)
|
||||||
|
icon: str = Field(default="flag", max_length=50)
|
||||||
|
sort_order: int = Field(default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class GoalCreate(GoalBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GoalUpdate(BaseModel):
|
||||||
|
title: Optional[str] = Field(None, max_length=200)
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: Optional[str] = Field(None, pattern="^(active|paused|completed|abandoned)$")
|
||||||
|
track_type: Optional[str] = Field(None, pattern="^(milestone|cumulative)$")
|
||||||
|
target_value: Optional[float] = None
|
||||||
|
target_unit: Optional[str] = Field(None, max_length=20)
|
||||||
|
input_unit: Optional[str] = Field(None, max_length=20)
|
||||||
|
conversion_rate: Optional[float] = None
|
||||||
|
target_date: Optional[date] = None
|
||||||
|
category_id: Optional[int] = None
|
||||||
|
color: Optional[str] = Field(None, max_length=20)
|
||||||
|
icon: Optional[str] = Field(None, max_length=50)
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def clearable_fields(self) -> set:
|
||||||
|
return {"description", "target_date", "category_id",
|
||||||
|
"target_value", "target_unit", "input_unit"}
|
||||||
|
|
||||||
|
|
||||||
|
class GoalListResponse(GoalBase):
|
||||||
|
id: int
|
||||||
|
uuid: Optional[str] = None
|
||||||
|
progress: int
|
||||||
|
current_value: float = 0
|
||||||
|
completed_at: Optional[datetime] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
category: Optional[CategoryResponse] = None
|
||||||
|
total_steps: int = 0
|
||||||
|
completed_steps: int = 0
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class GoalDetailResponse(GoalListResponse):
|
||||||
|
steps: List[GoalStepResponse] = []
|
||||||
|
reviews: List[GoalReviewResponse] = []
|
||||||
|
checkins: List[GoalCheckinResponse] = []
|
||||||
|
tasks: List[TaskResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
class GoalStatusUpdate(BaseModel):
|
||||||
|
status: str = Field(..., pattern="^(active|paused|completed|abandoned)$")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Reorder Schema ============
|
||||||
|
|
||||||
|
class ReorderItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
sort_order: int
|
||||||
|
|
||||||
|
|
||||||
|
class ReorderRequest(BaseModel):
|
||||||
|
items: list[ReorderItem]
|
||||||
@@ -29,6 +29,7 @@ class HabitGroupUpdate(BaseModel):
|
|||||||
class HabitGroupResponse(HabitGroupBase):
|
class HabitGroupResponse(HabitGroupBase):
|
||||||
"""习惯分组响应模型"""
|
"""习惯分组响应模型"""
|
||||||
id: int
|
id: int
|
||||||
|
uuid: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -68,6 +69,7 @@ class HabitUpdate(BaseModel):
|
|||||||
class HabitResponse(HabitBase):
|
class HabitResponse(HabitBase):
|
||||||
"""习惯响应模型"""
|
"""习惯响应模型"""
|
||||||
id: int
|
id: int
|
||||||
|
uuid: Optional[str] = None
|
||||||
is_archived: bool
|
is_archived: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -87,6 +89,7 @@ class CheckinCreate(BaseModel):
|
|||||||
class CheckinResponse(BaseModel):
|
class CheckinResponse(BaseModel):
|
||||||
"""打卡记录响应模型"""
|
"""打卡记录响应模型"""
|
||||||
id: int
|
id: int
|
||||||
|
uuid: Optional[str] = None
|
||||||
habit_id: int
|
habit_id: int
|
||||||
checkin_date: date
|
checkin_date: date
|
||||||
count: int
|
count: int
|
||||||
|
|||||||
50
api/app/schemas/sync.py
Normal file
50
api/app/schemas/sync.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SyncConfigBase(BaseModel):
|
||||||
|
webdav_url: Optional[str] = None
|
||||||
|
webdav_username: Optional[str] = None
|
||||||
|
webdav_password: Optional[str] = None
|
||||||
|
webdav_path: str = "/elysia-todo/"
|
||||||
|
auto_sync: bool = False
|
||||||
|
auto_sync_interval: int = Field(default=300, ge=60)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncConfigUpdate(SyncConfigBase):
|
||||||
|
webdav_url: Optional[str] = None
|
||||||
|
webdav_username: Optional[str] = None
|
||||||
|
webdav_password: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SyncConfigResponse(BaseModel):
|
||||||
|
webdav_url: Optional[str] = None
|
||||||
|
webdav_username: Optional[str] = None
|
||||||
|
webdav_password: Optional[str] = None
|
||||||
|
webdav_path: str = "/elysia-todo/"
|
||||||
|
sync_enabled: bool = False
|
||||||
|
auto_sync: bool = False
|
||||||
|
auto_sync_interval: int = 300
|
||||||
|
last_sync_at: Optional[datetime] = None
|
||||||
|
last_sync_version: int = 0
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class SyncStatusResponse(BaseModel):
|
||||||
|
syncing: bool
|
||||||
|
last_sync_at: Optional[datetime] = None
|
||||||
|
last_sync_version: int = 0
|
||||||
|
sync_enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SyncTestResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class SyncOperationResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
class TagBase(BaseModel):
|
class TagBase(BaseModel):
|
||||||
@@ -14,6 +15,7 @@ class TagCreate(TagBase):
|
|||||||
class TagResponse(TagBase):
|
class TagResponse(TagBase):
|
||||||
"""标签响应模型"""
|
"""标签响应模型"""
|
||||||
id: int
|
id: int
|
||||||
|
uuid: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ class TaskUpdate(BaseModel):
|
|||||||
class TaskResponse(TaskBase):
|
class TaskResponse(TaskBase):
|
||||||
"""任务响应模型"""
|
"""任务响应模型"""
|
||||||
id: int
|
id: int
|
||||||
|
uuid: Optional[str] = None
|
||||||
is_completed: bool
|
is_completed: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -1,25 +1,31 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
|
||||||
from fastapi import Request, HTTPException
|
from fastapi import Request, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.config import JWT_SECRET, ACCESS_TOKEN_EXPIRE_MINUTES
|
from app.config import JWT_SECRET, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
from app.database import get_db
|
|
||||||
from app.models.user_settings import UserSettings
|
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
_token_version_cache: dict[str, int] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_token_version(user_id: str) -> int | None:
|
||||||
|
return _token_version_cache.get(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def set_cached_token_version(user_id: str, version: int):
|
||||||
|
_token_version_cache[user_id] = version
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
return pwd_context.hash(password)
|
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
@@ -34,8 +40,9 @@ def decode_access_token(token: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def get_current_user(request: Request) -> dict:
|
def get_current_user(request: Request) -> dict:
|
||||||
auth_header = request.headers.get("Authorization", "")
|
if hasattr(request.state, "user") and request.state.user:
|
||||||
token = auth_header.replace("Bearer ", "")
|
return request.state.user
|
||||||
|
token = request.cookies.get("access_token", "")
|
||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(status_code=401, detail="未登录")
|
raise HTTPException(status_code=401, detail="未登录")
|
||||||
try:
|
try:
|
||||||
@@ -43,9 +50,3 @@ def get_current_user(request: Request) -> dict:
|
|||||||
return payload
|
return payload
|
||||||
except JWTError:
|
except JWTError:
|
||||||
raise HTTPException(status_code=401, detail="登录已过期,请重新登录")
|
raise HTTPException(status_code=401, detail="登录已过期,请重新登录")
|
||||||
|
|
||||||
|
|
||||||
def set_default_password(db: Session, settings: UserSettings):
|
|
||||||
if not settings.password_hash:
|
|
||||||
settings.password_hash = hash_password("elysia")
|
|
||||||
db.commit()
|
|
||||||
|
|||||||
53
api/app/utils/crypto.py
Normal file
53
api/app/utils/crypto.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""
|
||||||
|
AES-256-GCM 加解密工具
|
||||||
|
密钥从 JWT_SECRET 派生,用于加密 WebDAV 密码等敏感信息
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
|
||||||
|
from app.config import JWT_SECRET
|
||||||
|
|
||||||
|
_SALT = b"elysia-todo-sync-v1"
|
||||||
|
_NONCE_SIZE = 12 # AES-GCM 标准 nonce 长度
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_key() -> bytes:
|
||||||
|
"""从 JWT_SECRET 派生 256-bit AES 密钥"""
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=_SALT,
|
||||||
|
iterations=480000,
|
||||||
|
)
|
||||||
|
return kdf.derive(JWT_SECRET.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(plaintext: str) -> str:
|
||||||
|
"""AES-256-GCM 加密,返回 base64(iv + ciphertext + tag)"""
|
||||||
|
if not plaintext:
|
||||||
|
return ""
|
||||||
|
key = _derive_key()
|
||||||
|
nonce = os.urandom(_NONCE_SIZE)
|
||||||
|
aesgcm = AESGCM(key)
|
||||||
|
ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||||
|
return base64.b64encode(nonce + ciphertext).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(encrypted: str) -> str | None:
|
||||||
|
"""AES-256-GCM 解密,解密失败返回 None"""
|
||||||
|
if not encrypted:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
key = _derive_key()
|
||||||
|
raw = base64.b64decode(encrypted)
|
||||||
|
nonce = raw[:_NONCE_SIZE]
|
||||||
|
ciphertext = raw[_NONCE_SIZE:]
|
||||||
|
aesgcm = AESGCM(key)
|
||||||
|
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
|
||||||
|
return plaintext.decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
59
api/app/utils/rate_limiter.py
Normal file
59
api/app/utils/rate_limiter.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
登录频率限制器(内存实现,单用户场景适用)
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRateLimiter:
|
||||||
|
"""IP 级别的登录频率限制"""
|
||||||
|
|
||||||
|
MAX_ATTEMPTS = 5
|
||||||
|
WINDOW_SECONDS = 900 # 统计窗口:15 分钟
|
||||||
|
LOCKOUT_SECONDS = 900 # 锁定时间:15 分钟
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._attempts: dict[str, list[float]] = {}
|
||||||
|
self._lockout: dict[str, float] = {}
|
||||||
|
|
||||||
|
def _cleanup(self):
|
||||||
|
"""清除过期的记录"""
|
||||||
|
now = time.time()
|
||||||
|
self._attempts = {
|
||||||
|
ip: [t for t in times if now - t < self.WINDOW_SECONDS]
|
||||||
|
for ip, times in self._attempts.items()
|
||||||
|
}
|
||||||
|
self._attempts = {ip: times for ip, times in self._attempts.items() if times}
|
||||||
|
|
||||||
|
self._lockout = {
|
||||||
|
ip: until for ip, until in self._lockout.items()
|
||||||
|
if now < until
|
||||||
|
}
|
||||||
|
|
||||||
|
def check(self, ip: str) -> tuple[bool, int | None]:
|
||||||
|
"""返回 (是否允许, 剩余等待秒数)"""
|
||||||
|
self._cleanup()
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
if ip in self._lockout:
|
||||||
|
remaining = int(self._lockout[ip] - now)
|
||||||
|
if remaining > 0:
|
||||||
|
return False, remaining
|
||||||
|
del self._lockout[ip]
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
def record_failure(self, ip: str):
|
||||||
|
"""记录一次失败"""
|
||||||
|
now = time.time()
|
||||||
|
self._attempts.setdefault(ip, []).append(now)
|
||||||
|
if len(self._attempts[ip]) >= self.MAX_ATTEMPTS:
|
||||||
|
self._lockout[ip] = now + self.LOCKOUT_SECONDS
|
||||||
|
|
||||||
|
def reset(self, ip: str):
|
||||||
|
"""登录成功后重置计数"""
|
||||||
|
self._attempts.pop(ip, None)
|
||||||
|
self._lockout.pop(ip, None)
|
||||||
|
|
||||||
|
|
||||||
|
login_limiter = LoginRateLimiter()
|
||||||
37
api/app/utils/sync_lock.py
Normal file
37
api/app/utils/sync_lock.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
全局同步锁与同步模式标记
|
||||||
|
同步期间禁止所有写操作,前端显示同步遮罩
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
_sync_lock = threading.Lock()
|
||||||
|
_sync_in_progress = False
|
||||||
|
_sync_mode = threading.local()
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_sync_lock() -> bool:
|
||||||
|
"""非阻塞获取同步锁,成功返回 True"""
|
||||||
|
acquired = _sync_lock.acquire(blocking=False)
|
||||||
|
if acquired:
|
||||||
|
global _sync_in_progress
|
||||||
|
_sync_in_progress = True
|
||||||
|
_sync_mode.active = True
|
||||||
|
return acquired
|
||||||
|
|
||||||
|
|
||||||
|
def release_sync_lock():
|
||||||
|
"""释放同步锁"""
|
||||||
|
global _sync_in_progress
|
||||||
|
_sync_in_progress = False
|
||||||
|
_sync_mode.active = False
|
||||||
|
_sync_lock.release()
|
||||||
|
|
||||||
|
|
||||||
|
def is_syncing() -> bool:
|
||||||
|
"""检查是否正在同步"""
|
||||||
|
return _sync_in_progress
|
||||||
|
|
||||||
|
|
||||||
|
def is_sync_mode() -> bool:
|
||||||
|
"""检查当前线程是否在同步模式中(跳过 sync_version 自增)"""
|
||||||
|
return getattr(_sync_mode, 'active', False)
|
||||||
567
api/app/utils/sync_service.py
Normal file
567
api/app/utils/sync_service.py
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
"""
|
||||||
|
同步核心服务
|
||||||
|
处理 push / pull / bidirectional merge 逻辑
|
||||||
|
"""
|
||||||
|
from datetime import datetime, date as date_type
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models import (
|
||||||
|
Task, Category, Tag, task_tags, UserSettings,
|
||||||
|
HabitGroup, Habit, HabitCheckin,
|
||||||
|
AnniversaryCategory, Anniversary,
|
||||||
|
Goal, GoalStep, GoalReview, goal_tasks,
|
||||||
|
SyncSettings,
|
||||||
|
)
|
||||||
|
from app.utils.crypto import encrypt, decrypt
|
||||||
|
from app.utils.webdav import WebDAVClient
|
||||||
|
from app.utils.sync_lock import acquire_sync_lock, release_sync_lock
|
||||||
|
from app.utils.logger import logger
|
||||||
|
from app.utils.datetime import utcnow
|
||||||
|
|
||||||
|
SYNC_COLLECTIONS = [
|
||||||
|
("categories", Category),
|
||||||
|
("tags", Tag),
|
||||||
|
("tasks", Task),
|
||||||
|
("habit_groups", HabitGroup),
|
||||||
|
("habits", Habit),
|
||||||
|
("habit_checkins", HabitCheckin),
|
||||||
|
("anniversary_categories", AnniversaryCategory),
|
||||||
|
("anniversaries", Anniversary),
|
||||||
|
("goals", Goal),
|
||||||
|
("goal_steps", GoalStep),
|
||||||
|
("goal_reviews", GoalReview),
|
||||||
|
]
|
||||||
|
|
||||||
|
ASSOCIATION_COLLECTIONS = [
|
||||||
|
("task_tags", task_tags, "tasks", "tags"),
|
||||||
|
("goal_tasks", goal_tasks, "goals", "tasks"),
|
||||||
|
]
|
||||||
|
|
||||||
|
USER_SETTINGS_SYNC_FIELDS = [
|
||||||
|
"nickname", "avatar", "signature", "birthday", "email",
|
||||||
|
"site_name", "theme", "language", "default_view",
|
||||||
|
"default_sort_by", "default_sort_order",
|
||||||
|
]
|
||||||
|
|
||||||
|
MODEL_MAP = {
|
||||||
|
"tasks": Task,
|
||||||
|
"categories": Category,
|
||||||
|
"tags": Tag,
|
||||||
|
"habit_groups": HabitGroup,
|
||||||
|
"habits": Habit,
|
||||||
|
"anniversary_categories": AnniversaryCategory,
|
||||||
|
"anniversaries": Anniversary,
|
||||||
|
"goals": Goal,
|
||||||
|
"goal_steps": GoalStep,
|
||||||
|
"goal_reviews": GoalReview,
|
||||||
|
"user_settings": UserSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sync_settings(db: Session) -> SyncSettings:
|
||||||
|
settings = db.query(SyncSettings).filter(SyncSettings.id == 1).first()
|
||||||
|
if not settings:
|
||||||
|
settings = SyncSettings(id=1)
|
||||||
|
db.add(settings)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(settings)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def _create_webdav_client(settings: SyncSettings) -> WebDAVClient | None:
|
||||||
|
if not settings.webdav_url:
|
||||||
|
return None
|
||||||
|
password = decrypt(settings.webdav_password) if settings.webdav_password else ""
|
||||||
|
if settings.webdav_password and password is None:
|
||||||
|
logger.error("WebDAV 密码解密失败,可能 JWT_SECRET 已更改")
|
||||||
|
return None
|
||||||
|
return WebDAVClient(
|
||||||
|
url=settings.webdav_url,
|
||||||
|
username=settings.webdav_username or "",
|
||||||
|
password=password,
|
||||||
|
path=settings.webdav_path or "/elysia-todo/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_model(obj, model_class) -> dict:
|
||||||
|
result = {}
|
||||||
|
for col in model_class.__table__.columns:
|
||||||
|
val = getattr(obj, col.name, None)
|
||||||
|
if isinstance(val, datetime):
|
||||||
|
val = val.isoformat() if val else None
|
||||||
|
elif isinstance(val, date_type):
|
||||||
|
val = val.isoformat() if val else None
|
||||||
|
result[col.name] = val
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_association(row, left_model, right_model, db: Session) -> dict | None:
|
||||||
|
left_id, right_id = row[0], row[1]
|
||||||
|
left_obj = db.query(left_model).filter(left_model.id == left_id).first()
|
||||||
|
right_obj = db.query(right_model).filter(right_model.id == right_id).first()
|
||||||
|
if not left_obj or not right_obj or not left_obj.uuid or not right_obj.uuid:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
f"{left_model.__tablename__}_uuid": left_obj.uuid,
|
||||||
|
f"{right_model.__tablename__}_uuid": right_obj.uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_value(val, col_type_name: str):
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, (datetime, date_type)):
|
||||||
|
return val
|
||||||
|
if not isinstance(val, str):
|
||||||
|
return val
|
||||||
|
if "DateTime" in col_type_name:
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(val.replace("Z", "+00:00"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
elif "Date" in col_type_name and "Time" not in col_type_name:
|
||||||
|
try:
|
||||||
|
return date_type.fromisoformat(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def _item_to_model_kwargs(item: dict, model_class) -> dict:
|
||||||
|
"""将远程 JSON 对象转换为可用于创建模型的 kwargs,保留 uuid 和 sync_version"""
|
||||||
|
kwargs = {}
|
||||||
|
for col in model_class.__table__.columns:
|
||||||
|
if col.name not in item:
|
||||||
|
continue
|
||||||
|
val = item[col.name]
|
||||||
|
if col.name == "id":
|
||||||
|
continue
|
||||||
|
col_type_name = type(col.type).__name__
|
||||||
|
val = _convert_value(val, col_type_name)
|
||||||
|
kwargs[col.name] = val
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def _backup_local(db: Session) -> str:
|
||||||
|
timestamp = utcnow().strftime("%Y-%m-%dT%H-%M-%S")
|
||||||
|
backup_dir = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"data", "backups", timestamp
|
||||||
|
)
|
||||||
|
os.makedirs(backup_dir, exist_ok=True)
|
||||||
|
|
||||||
|
for coll_name, model_class in SYNC_COLLECTIONS:
|
||||||
|
rows = db.query(model_class).filter(model_class.is_deleted == False).all()
|
||||||
|
items = [_serialize_model(r, model_class) for r in rows]
|
||||||
|
filepath = os.path.join(backup_dir, f"{coll_name}.json")
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"collection": coll_name, "items": items}, f, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
logger.info(f"本地数据已备份到: {backup_dir}")
|
||||||
|
return backup_dir
|
||||||
|
|
||||||
|
|
||||||
|
def push_to_remote(db: Session) -> dict:
|
||||||
|
settings = _get_sync_settings(db)
|
||||||
|
client = _create_webdav_client(settings)
|
||||||
|
if not client:
|
||||||
|
return {"success": False, "message": "WebDAV 未配置或密码解密失败"}
|
||||||
|
|
||||||
|
if not acquire_sync_lock():
|
||||||
|
return {"success": False, "message": "同步正在进行中"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.ensure_dirs()
|
||||||
|
timestamp = utcnow().strftime("%Y-%m-%dT%H-%M-%S")
|
||||||
|
client.backup_remote(timestamp)
|
||||||
|
|
||||||
|
for coll_name, model_class in SYNC_COLLECTIONS:
|
||||||
|
rows = db.query(model_class).all()
|
||||||
|
items = [_serialize_model(r, model_class) for r in rows]
|
||||||
|
data = {
|
||||||
|
"version": 1,
|
||||||
|
"collection": coll_name,
|
||||||
|
"updated_at": utcnow().isoformat(),
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
if not client.upload_json(f"{coll_name}.json", data):
|
||||||
|
return {"success": False, "message": f"上传 {coll_name} 失败"}
|
||||||
|
|
||||||
|
for assoc_name, assoc_table, left_name, right_name in ASSOCIATION_COLLECTIONS:
|
||||||
|
left_model = MODEL_MAP.get(left_name)
|
||||||
|
right_model = MODEL_MAP.get(right_name)
|
||||||
|
if not left_model or not right_model:
|
||||||
|
continue
|
||||||
|
rows = db.execute(assoc_table.select()).fetchall()
|
||||||
|
items = []
|
||||||
|
for row in rows:
|
||||||
|
item = _serialize_association(row, left_model, right_model, db)
|
||||||
|
if item:
|
||||||
|
items.append(item)
|
||||||
|
client.upload_json(f"{assoc_name}.json", {
|
||||||
|
"version": 1,
|
||||||
|
"collection": assoc_name,
|
||||||
|
"updated_at": utcnow().isoformat(),
|
||||||
|
"items": items,
|
||||||
|
})
|
||||||
|
|
||||||
|
user_settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
||||||
|
if user_settings:
|
||||||
|
pref_data = {}
|
||||||
|
for field in USER_SETTINGS_SYNC_FIELDS:
|
||||||
|
val = getattr(user_settings, field, None)
|
||||||
|
if isinstance(val, (datetime, date_type)):
|
||||||
|
val = val.isoformat() if val else None
|
||||||
|
pref_data[field] = val
|
||||||
|
client.upload_json("user_settings.json", {
|
||||||
|
"version": 1,
|
||||||
|
"collection": "user_settings",
|
||||||
|
"updated_at": utcnow().isoformat(),
|
||||||
|
"items": [pref_data],
|
||||||
|
})
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"version": 1,
|
||||||
|
"last_sync_at": utcnow().isoformat(),
|
||||||
|
"collections": {},
|
||||||
|
}
|
||||||
|
for coll_name, model_class in SYNC_COLLECTIONS:
|
||||||
|
count = db.query(model_class).filter(model_class.is_deleted == False).count()
|
||||||
|
manifest["collections"][coll_name] = {
|
||||||
|
"count": count,
|
||||||
|
"updated_at": utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
client.upload_json("manifest.json", manifest)
|
||||||
|
|
||||||
|
settings.last_sync_at = utcnow()
|
||||||
|
settings.last_sync_version = (settings.last_sync_version or 0) + 1
|
||||||
|
settings.sync_enabled = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "message": "推送成功"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"推送失败: {e}", exc_info=True)
|
||||||
|
db.rollback()
|
||||||
|
return {"success": False, "message": f"推送失败: {str(e)}"}
|
||||||
|
finally:
|
||||||
|
release_sync_lock()
|
||||||
|
|
||||||
|
|
||||||
|
def pull_from_remote(db: Session) -> dict:
|
||||||
|
settings = _get_sync_settings(db)
|
||||||
|
client = _create_webdav_client(settings)
|
||||||
|
if not client:
|
||||||
|
return {"success": False, "message": "WebDAV 未配置或密码解密失败"}
|
||||||
|
|
||||||
|
if not acquire_sync_lock():
|
||||||
|
return {"success": False, "message": "同步正在进行中"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
_backup_local(db)
|
||||||
|
|
||||||
|
for coll_name, model_class in SYNC_COLLECTIONS:
|
||||||
|
remote_data = client.download_json(f"{coll_name}.json")
|
||||||
|
if remote_data is None:
|
||||||
|
continue
|
||||||
|
db.query(model_class).delete()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
for item in remote_data.get("items", []):
|
||||||
|
kwargs = _item_to_model_kwargs(item, model_class)
|
||||||
|
is_deleted = kwargs.pop("is_deleted", False)
|
||||||
|
obj = model_class(**kwargs)
|
||||||
|
obj.is_deleted = bool(is_deleted)
|
||||||
|
db.add(obj)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
for assoc_name, assoc_table, left_name, right_name in ASSOCIATION_COLLECTIONS:
|
||||||
|
remote_data = client.download_json(f"{assoc_name}.json")
|
||||||
|
if remote_data is None:
|
||||||
|
continue
|
||||||
|
db.execute(assoc_table.delete())
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
left_model = MODEL_MAP.get(left_name)
|
||||||
|
right_model = MODEL_MAP.get(right_name)
|
||||||
|
if not left_model or not right_model:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for item in remote_data.get("items", []):
|
||||||
|
left_uuid = item.get(f"{left_name}_uuid")
|
||||||
|
right_uuid = item.get(f"{right_name}_uuid")
|
||||||
|
if not left_uuid or not right_uuid:
|
||||||
|
continue
|
||||||
|
left_obj = db.query(left_model).filter(left_model.uuid == left_uuid).first()
|
||||||
|
right_obj = db.query(right_model).filter(right_model.uuid == right_uuid).first()
|
||||||
|
if left_obj and right_obj:
|
||||||
|
db.execute(assoc_table.insert().values(
|
||||||
|
left_id=left_obj.id, right_id=right_obj.id
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
remote_prefs = client.download_json("user_settings.json")
|
||||||
|
if remote_prefs and remote_prefs.get("items"):
|
||||||
|
pref = remote_prefs["items"][0]
|
||||||
|
user_settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
||||||
|
if user_settings:
|
||||||
|
for field in USER_SETTINGS_SYNC_FIELDS:
|
||||||
|
if field in pref and pref[field] is not None:
|
||||||
|
val = pref[field]
|
||||||
|
if isinstance(val, str) and field == "birthday":
|
||||||
|
try:
|
||||||
|
val = date_type.fromisoformat(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
setattr(user_settings, field, val)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
settings.last_sync_at = utcnow()
|
||||||
|
settings.last_sync_version = (settings.last_sync_version or 0) + 1
|
||||||
|
settings.sync_enabled = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "message": "拉取成功"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"拉取失败: {e}", exc_info=True)
|
||||||
|
db.rollback()
|
||||||
|
return {"success": False, "message": f"拉取失败: {str(e)}"}
|
||||||
|
finally:
|
||||||
|
release_sync_lock()
|
||||||
|
|
||||||
|
|
||||||
|
def bidirectional_sync(db: Session) -> dict:
|
||||||
|
settings = _get_sync_settings(db)
|
||||||
|
client = _create_webdav_client(settings)
|
||||||
|
if not client:
|
||||||
|
return {"success": False, "message": "WebDAV 未配置或密码解密失败"}
|
||||||
|
|
||||||
|
if not acquire_sync_lock():
|
||||||
|
return {"success": False, "message": "同步正在进行中"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.ensure_dirs()
|
||||||
|
stats = {"pushed": 0, "pulled": 0, "merged": 0, "deleted": 0}
|
||||||
|
|
||||||
|
for coll_name, model_class in SYNC_COLLECTIONS:
|
||||||
|
remote_data = client.download_json(f"{coll_name}.json")
|
||||||
|
remote_by_uuid = {}
|
||||||
|
remote_deleted_uuids = set()
|
||||||
|
if remote_data:
|
||||||
|
for item in remote_data.get("items", []):
|
||||||
|
uuid_val = item.get("uuid")
|
||||||
|
if not uuid_val:
|
||||||
|
continue
|
||||||
|
if item.get("is_deleted"):
|
||||||
|
remote_deleted_uuids.add(uuid_val)
|
||||||
|
else:
|
||||||
|
remote_by_uuid[uuid_val] = item
|
||||||
|
|
||||||
|
local_objs = db.query(model_class).all()
|
||||||
|
local_by_uuid = {}
|
||||||
|
local_deleted_uuids = set()
|
||||||
|
for obj in local_objs:
|
||||||
|
if obj.uuid:
|
||||||
|
if obj.is_deleted:
|
||||||
|
local_deleted_uuids.add(obj.uuid)
|
||||||
|
local_by_uuid[obj.uuid] = obj
|
||||||
|
|
||||||
|
all_uuids = set(remote_by_uuid.keys()) | set(local_by_uuid.keys()) | remote_deleted_uuids | local_deleted_uuids
|
||||||
|
|
||||||
|
for uuid_val in all_uuids:
|
||||||
|
remote_item = remote_by_uuid.get(uuid_val)
|
||||||
|
local_obj = local_by_uuid.get(uuid_val)
|
||||||
|
remote_deleted = uuid_val in remote_deleted_uuids and uuid_val not in remote_by_uuid
|
||||||
|
local_deleted = uuid_val in local_deleted_uuids and uuid_val not in local_by_uuid
|
||||||
|
|
||||||
|
# 两边都删除了 → 什么都不做
|
||||||
|
if remote_deleted and local_deleted:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 远端删除了,本地还在 → 删除本地
|
||||||
|
if remote_deleted and local_obj:
|
||||||
|
local_obj.is_deleted = True
|
||||||
|
local_obj.sync_version = (local_obj.sync_version or 0) + 1
|
||||||
|
stats["deleted"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 本地删除了,远端还在 → 删除远端标记(本地占优在这里意味着:远端也应标记删除)
|
||||||
|
# 这里简单处理:如果本地标记了删除但远端还活着,以远端为准拉取回来
|
||||||
|
if local_deleted and not remote_deleted and not local_obj and remote_item:
|
||||||
|
kwargs = _item_to_model_kwargs(remote_item, model_class)
|
||||||
|
kwargs.pop("is_deleted", None)
|
||||||
|
new_obj = model_class(**kwargs)
|
||||||
|
db.add(new_obj)
|
||||||
|
stats["pulled"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 仅远端有 → 拉取到本地
|
||||||
|
if remote_item and not local_obj:
|
||||||
|
kwargs = _item_to_model_kwargs(remote_item, model_class)
|
||||||
|
kwargs.pop("is_deleted", None)
|
||||||
|
new_obj = model_class(**kwargs)
|
||||||
|
db.add(new_obj)
|
||||||
|
stats["pulled"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 仅本地有 → 会在最后统一上传时推送到远端
|
||||||
|
if not remote_item and local_obj and not local_deleted:
|
||||||
|
stats["pushed"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 两边都有 → LWW 合并
|
||||||
|
if remote_item and local_obj:
|
||||||
|
remote_ver = remote_item.get("sync_version", 1) or 1
|
||||||
|
local_ver = local_obj.sync_version or 1
|
||||||
|
|
||||||
|
if remote_ver > local_ver:
|
||||||
|
kwargs = _item_to_model_kwargs(remote_item, model_class)
|
||||||
|
kwargs.pop("is_deleted", None)
|
||||||
|
kwargs.pop("sync_version", None)
|
||||||
|
for key, val in kwargs.items():
|
||||||
|
if val is not None or key in getattr(local_obj, '__clearable_fields__', set()):
|
||||||
|
setattr(local_obj, key, val)
|
||||||
|
local_obj.sync_version = remote_ver
|
||||||
|
stats["merged"] += 1
|
||||||
|
elif local_ver > remote_ver:
|
||||||
|
local_obj.sync_version = local_ver
|
||||||
|
stats["pushed"] += 1
|
||||||
|
else:
|
||||||
|
# 版本相同,以远端为准
|
||||||
|
kwargs = _item_to_model_kwargs(remote_item, model_class)
|
||||||
|
kwargs.pop("is_deleted", None)
|
||||||
|
kwargs.pop("sync_version", None)
|
||||||
|
for key, val in kwargs.items():
|
||||||
|
setattr(local_obj, key, val)
|
||||||
|
local_obj.sync_version = local_ver + 1
|
||||||
|
stats["merged"] += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 合并关联表
|
||||||
|
for assoc_name, assoc_table, left_name, right_name in ASSOCIATION_COLLECTIONS:
|
||||||
|
remote_data = client.download_json(f"{assoc_name}.json")
|
||||||
|
if remote_data is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
left_model = MODEL_MAP.get(left_name)
|
||||||
|
right_model = MODEL_MAP.get(right_name)
|
||||||
|
if not left_model or not right_model:
|
||||||
|
continue
|
||||||
|
|
||||||
|
remote_pairs = set()
|
||||||
|
for item in remote_data.get("items", []):
|
||||||
|
left_uuid = item.get(f"{left_name}_uuid")
|
||||||
|
right_uuid = item.get(f"{right_name}_uuid")
|
||||||
|
if left_uuid and right_uuid:
|
||||||
|
remote_pairs.add((left_uuid, right_uuid))
|
||||||
|
|
||||||
|
local_pairs = set()
|
||||||
|
rows = db.execute(assoc_table.select()).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
left_id, right_id = row[0], row[1]
|
||||||
|
left_obj = db.query(left_model).filter(left_model.id == left_id).first()
|
||||||
|
right_obj = db.query(right_model).filter(right_model.id == right_id).first()
|
||||||
|
if left_obj and right_obj and left_obj.uuid and right_obj.uuid:
|
||||||
|
local_pairs.add((left_obj.uuid, right_obj.uuid))
|
||||||
|
|
||||||
|
merged_pairs = local_pairs | remote_pairs
|
||||||
|
db.execute(assoc_table.delete())
|
||||||
|
for left_uuid, right_uuid in merged_pairs:
|
||||||
|
left_obj = db.query(left_model).filter(left_model.uuid == left_uuid).first()
|
||||||
|
right_obj = db.query(right_model).filter(right_model.uuid == right_uuid).first()
|
||||||
|
if left_obj and right_obj:
|
||||||
|
db.execute(assoc_table.insert().values(left_id=left_obj.id, right_id=right_obj.id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 合并 user_settings
|
||||||
|
remote_prefs = client.download_json("user_settings.json")
|
||||||
|
if remote_prefs and remote_prefs.get("items"):
|
||||||
|
pref = remote_prefs["items"][0]
|
||||||
|
user_settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
||||||
|
if user_settings:
|
||||||
|
for field in USER_SETTINGS_SYNC_FIELDS:
|
||||||
|
if field in pref and pref[field] is not None:
|
||||||
|
val = pref[field]
|
||||||
|
if isinstance(val, str) and field == "birthday":
|
||||||
|
try:
|
||||||
|
val = date_type.fromisoformat(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
setattr(user_settings, field, val)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 统一上传合并后的数据到远端
|
||||||
|
_upload_all_to_remote(db, client)
|
||||||
|
|
||||||
|
settings.last_sync_at = utcnow()
|
||||||
|
settings.last_sync_version = (settings.last_sync_version or 0) + 1
|
||||||
|
settings.sync_enabled = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "message": f"同步完成: 推送 {stats['pushed']}, 拉取 {stats['pulled']}, 合并 {stats['merged']}, 删除 {stats['deleted']}"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"双向同步失败: {e}", exc_info=True)
|
||||||
|
db.rollback()
|
||||||
|
return {"success": False, "message": f"同步失败: {str(e)}"}
|
||||||
|
finally:
|
||||||
|
release_sync_lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_all_to_remote(db: Session, client: WebDAVClient):
|
||||||
|
"""将本地所有数据上传到远端"""
|
||||||
|
for coll_name, model_class in SYNC_COLLECTIONS:
|
||||||
|
items = [_serialize_model(obj, model_class) for obj in db.query(model_class).all()]
|
||||||
|
client.upload_json(f"{coll_name}.json", {
|
||||||
|
"version": 1,
|
||||||
|
"collection": coll_name,
|
||||||
|
"updated_at": utcnow().isoformat(),
|
||||||
|
"items": items,
|
||||||
|
})
|
||||||
|
|
||||||
|
for assoc_name, assoc_table, left_name, right_name in ASSOCIATION_COLLECTIONS:
|
||||||
|
left_model = MODEL_MAP.get(left_name)
|
||||||
|
right_model = MODEL_MAP.get(right_name)
|
||||||
|
if not left_model or not right_model:
|
||||||
|
continue
|
||||||
|
rows = db.execute(assoc_table.select()).fetchall()
|
||||||
|
items = [_serialize_association(row, left_model, right_model, db) for row in rows]
|
||||||
|
items = [i for i in items if i is not None]
|
||||||
|
client.upload_json(f"{assoc_name}.json", {
|
||||||
|
"version": 1,
|
||||||
|
"collection": assoc_name,
|
||||||
|
"updated_at": utcnow().isoformat(),
|
||||||
|
"items": items,
|
||||||
|
})
|
||||||
|
|
||||||
|
user_settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
||||||
|
if user_settings:
|
||||||
|
pref_data = {}
|
||||||
|
for field in USER_SETTINGS_SYNC_FIELDS:
|
||||||
|
val = getattr(user_settings, field, None)
|
||||||
|
if isinstance(val, (datetime, date_type)):
|
||||||
|
val = val.isoformat() if val else None
|
||||||
|
pref_data[field] = val
|
||||||
|
client.upload_json("user_settings.json", {
|
||||||
|
"version": 1,
|
||||||
|
"collection": "user_settings",
|
||||||
|
"updated_at": utcnow().isoformat(),
|
||||||
|
"items": [pref_data],
|
||||||
|
})
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"version": 1,
|
||||||
|
"last_sync_at": utcnow().isoformat(),
|
||||||
|
"collections": {},
|
||||||
|
}
|
||||||
|
for coll_name, model_class in SYNC_COLLECTIONS:
|
||||||
|
count = db.query(model_class).filter(model_class.is_deleted == False).count()
|
||||||
|
manifest["collections"][coll_name] = {
|
||||||
|
"count": count,
|
||||||
|
"updated_at": utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
client.upload_json("manifest.json", manifest)
|
||||||
150
api/app/utils/webdav.py
Normal file
150
api/app/utils/webdav.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
WebDAV 客户端工具
|
||||||
|
基于 requests 实现,兼容 Alist 等 WebDAV 服务
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests.auth import HTTPBasicAuth
|
||||||
|
|
||||||
|
from app.utils.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class WebDAVClient:
|
||||||
|
"""WebDAV 客户端,用于与 Alist 等 WebDAV 服务交互"""
|
||||||
|
|
||||||
|
def __init__(self, url: str, username: str, password: str, path: str = "/elysia-todo/"):
|
||||||
|
self.base_url = url.rstrip("/")
|
||||||
|
self.username = username
|
||||||
|
self.password = username # Alist 使用用户名作为密码
|
||||||
|
self.auth = HTTPBasicAuth(username, self.password)
|
||||||
|
self.path = path if path.startswith("/") else f"/{path}"
|
||||||
|
self._session = requests.Session()
|
||||||
|
self._session.auth = self.auth
|
||||||
|
self._session.timeout = 30
|
||||||
|
self._session.headers.update({"Content-Type": "application/json"})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _data_url(self) -> str:
|
||||||
|
return f"{self.base_url}{self.path}data/"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _backups_url(self) -> str:
|
||||||
|
return f"{self.base_url}{self.path}backups/"
|
||||||
|
|
||||||
|
def _url(self, filename: str) -> str:
|
||||||
|
return f"{self._data_url}{filename}"
|
||||||
|
|
||||||
|
def _manifest_url(self) -> str:
|
||||||
|
return f"{self.base_url}{self.path}manifest.json"
|
||||||
|
|
||||||
|
def test_connection(self) -> tuple[bool, str]:
|
||||||
|
"""测试 WebDAV 连接,返回 (成功, 消息)"""
|
||||||
|
try:
|
||||||
|
resp = self._session.request("PROPFIND", f"{self.base_url}{self.path}", headers={"Depth": "0"})
|
||||||
|
if resp.status_code in (200, 207, 404):
|
||||||
|
return True, "连接成功"
|
||||||
|
return False, f"连接失败: HTTP {resp.status_code}"
|
||||||
|
except requests.ConnectionError:
|
||||||
|
return False, "连接失败: 无法连接到服务器"
|
||||||
|
except requests.Timeout:
|
||||||
|
return False, "连接超时"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"连接失败: {str(e)}"
|
||||||
|
|
||||||
|
def ensure_dirs(self) -> bool:
|
||||||
|
"""确保远端目录结构存在"""
|
||||||
|
try:
|
||||||
|
for path in [self.path, f"{self.path}data/", f"{self.path}backups/"]:
|
||||||
|
url = f"{self.base_url}{path}"
|
||||||
|
self._session.request("PROPFIND", url, headers={"Depth": "0"})
|
||||||
|
resp = self._session.request("MKCOL", url)
|
||||||
|
if resp.status_code in (200, 201, 405, 301):
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"创建远端目录失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def upload_json(self, filename: str, data: Any) -> bool:
|
||||||
|
"""上传 JSON 数据到 WebDAV"""
|
||||||
|
try:
|
||||||
|
url = self._url(filename) if filename != "manifest.json" else self._manifest_url()
|
||||||
|
content = json.dumps(data, ensure_ascii=False, indent=2, default=str).encode("utf-8")
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
resp = self._session.put(url, data=content, headers=headers)
|
||||||
|
if resp.status_code in (200, 201, 204):
|
||||||
|
return True
|
||||||
|
logger.error(f"上传 {filename} 失败: HTTP {resp.status_code}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"上传 {filename} 异常: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def download_json(self, filename: str) -> Any | None:
|
||||||
|
"""从 WebDAV 下载 JSON 数据"""
|
||||||
|
try:
|
||||||
|
url = self._url(filename) if filename != "manifest.json" else self._manifest_url()
|
||||||
|
resp = self._session.get(url)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return None
|
||||||
|
logger.error(f"下载 {filename} 失败: HTTP {resp.status_code}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"下载 {filename} 异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete_file(self, filename: str) -> bool:
|
||||||
|
"""删除 WebDAV 上的文件"""
|
||||||
|
try:
|
||||||
|
url = self._url(filename) if filename != "manifest.json" else self._manifest_url()
|
||||||
|
resp = self._session.delete(url)
|
||||||
|
return resp.status_code in (200, 204, 404)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"删除 {filename} 异常: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def backup_remote(self, timestamp: str) -> bool:
|
||||||
|
"""备份远端数据到 backups/{timestamp}/"""
|
||||||
|
try:
|
||||||
|
backup_path = f"{self.path}backups/{timestamp}/data/"
|
||||||
|
backup_url = f"{self.base_url}{backup_path}"
|
||||||
|
self._session.request("MKCOL", f"{self.base_url}{self.path}backups/")
|
||||||
|
self._session.request("MKCOL", f"{self.base_url}{self.path}backups/{timestamp}/")
|
||||||
|
self._session.request("MKCOL", backup_url)
|
||||||
|
|
||||||
|
for filename in [
|
||||||
|
"manifest.json", "user_settings.json", "categories.json",
|
||||||
|
"tasks.json", "tags.json", "task_tags.json",
|
||||||
|
"habit_groups.json", "habits.json", "habit_checkins.json",
|
||||||
|
"anniversary_categories.json", "anniversaries.json",
|
||||||
|
"goals.json", "goal_steps.json", "goal_reviews.json", "goal_tasks.json",
|
||||||
|
]:
|
||||||
|
data = self.download_json(filename)
|
||||||
|
if data is not None:
|
||||||
|
src_url = self._url(filename) if filename != "manifest.json" else self._manifest_url()
|
||||||
|
dst_url = f"{backup_url}{filename}" if filename != "manifest.json" else f"{self.base_url}{backup_path}../manifest.json"
|
||||||
|
content = json.dumps(data, ensure_ascii=False, indent=2, default=str).encode("utf-8")
|
||||||
|
self._session.put(dst_url, data=content, headers={"Content-Type": "application/json"})
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"备份远端数据失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear_remote(self) -> bool:
|
||||||
|
"""清空远端数据目录"""
|
||||||
|
filenames = [
|
||||||
|
"manifest.json", "user_settings.json", "categories.json",
|
||||||
|
"tasks.json", "tags.json", "task_tags.json",
|
||||||
|
"habit_groups.json", "habits.json", "habit_checkins.json",
|
||||||
|
"anniversary_categories.json", "anniversaries.json",
|
||||||
|
"goals.json", "goal_steps.json", "goal_reviews.json", "goal_tasks.json",
|
||||||
|
]
|
||||||
|
for f in filenames:
|
||||||
|
self.delete_file(f)
|
||||||
|
return True
|
||||||
@@ -5,10 +5,10 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>webui</title>
|
<title>webui</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BDYzX3N-.js"></script>
|
<script type="module" crossorigin src="/assets/index-BX5HkU7A.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/vendor-CwFI-VDq.js">
|
<link rel="modulepreload" crossorigin href="/assets/vendor-CwFI-VDq.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/element-plus-DsX44Q4d.js">
|
<link rel="modulepreload" crossorigin href="/assets/element-plus-CljBHM1G.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DOz3B-pr.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-O358hdvS.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
services:
|
services:
|
||||||
elysia-todo:
|
db:
|
||||||
build: .
|
image: postgres:18-alpine
|
||||||
container_name: elysia-todo
|
container_name: elysia-todo-db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
environment:
|
||||||
- "23994:23994"
|
POSTGRES_USER: ToDoList
|
||||||
volumes:
|
POSTGRES_PASSWORD: 53N2PTSjMBPDy6zY
|
||||||
# 挂载前端编译产物,本地修改后可立即生效
|
POSTGRES_DB: ToDoList
|
||||||
- ./api/webui:/app/api/webui:ro
|
ports:
|
||||||
# 挂载数据库文件,持久化数据
|
- "5432:5432"
|
||||||
- ./api/data:/app/api/data
|
volumes:
|
||||||
# 挂载日志目录,方便查看日志
|
- pgdata:/var/lib/postgresql/data
|
||||||
- ./api/logs:/app/api/logs
|
|
||||||
|
elysia-todo:
|
||||||
|
build: .
|
||||||
|
container_name: elysia-todo
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "23994:23994"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "postgresql://ToDoList:53N2PTSjMBPDy6zY@db:5432/ToDoList"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
volumes:
|
||||||
|
- ./api/webui:/app/api/webui:ro
|
||||||
|
- ./api/logs:/app/api/logs
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -5,5 +5,5 @@ pydantic==2.5.3
|
|||||||
pydantic-settings==2.1.0
|
pydantic-settings==2.1.0
|
||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib[bcrypt]==1.7.4
|
bcrypt==4.0.1
|
||||||
bcrypt==4.2.1
|
psycopg2-binary==2.9.11
|
||||||
|
|||||||
53
scripts/start.bat
Normal file
53
scripts/start.bat
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul 2>&1
|
||||||
|
title 爱莉希雅待办事项
|
||||||
|
|
||||||
|
echo ====================================================
|
||||||
|
echo 爱莉希雅待办事项 - 启动脚本
|
||||||
|
echo ====================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: 项目根目录(脚本所在目录的上级)
|
||||||
|
set "PROJECT_ROOT=%~dp0.."
|
||||||
|
cd /d "%PROJECT_ROOT%"
|
||||||
|
|
||||||
|
:: 检查 Python
|
||||||
|
where python >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [错误] 未找到 Python,请确保已安装并添加到 PATH
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
:: 检查依赖
|
||||||
|
if not exist "api\app\__init__.py" (
|
||||||
|
echo [错误] 未找到项目文件,请确认在项目根目录运行
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
:: 安装 Python 依赖(如需)
|
||||||
|
if not exist "api\__pycache__" (
|
||||||
|
echo [信息] 首次运行,安装 Python 依赖...
|
||||||
|
pip install -r requirements.txt -q
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [错误] Python 依赖安装失败
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
:: 检查端口占用
|
||||||
|
echo [信息] 检查端口 23994 占用情况...
|
||||||
|
for /f "tokens=5" %%a in ('netstat -ano -p TCP ^| findstr ":23994.*LISTENING"') do (
|
||||||
|
echo [警告] 端口 23994 已被进程 %%a 占用,正在尝试终止...
|
||||||
|
taskkill /PID %%a /F >nul 2>&1
|
||||||
|
timeout /t 2 /nobreak >nul
|
||||||
|
)
|
||||||
|
|
||||||
|
:: 启动项目
|
||||||
|
echo [信息] 正在启动项目...
|
||||||
|
echo [信息] 访问地址: http://localhost:23994
|
||||||
|
echo.
|
||||||
|
python main.py
|
||||||
|
pause
|
||||||
42
scripts/stop.bat
Normal file
42
scripts/stop.bat
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul 2>&1
|
||||||
|
title 爱莉希雅待办事项 - 停止
|
||||||
|
|
||||||
|
echo ====================================================
|
||||||
|
echo 爱莉希雅待办事项 - 停止脚本
|
||||||
|
echo ====================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
set "PORT=23994"
|
||||||
|
set "FOUND=0"
|
||||||
|
|
||||||
|
:: 查找并终止占用端口的进程
|
||||||
|
for /f "tokens=5" %%a in ('netstat -ano -p TCP ^| findstr ":%PORT%.*LISTENING"') do (
|
||||||
|
echo [信息] 发现进程 %%a 占用端口 %PORT%
|
||||||
|
taskkill /PID %%a /F >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [错误] 终止进程 %%a 失败,请尝试手动终止
|
||||||
|
) else (
|
||||||
|
echo [成功] 进程 %%a 已终止
|
||||||
|
set "FOUND=1"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%FOUND%"=="0" (
|
||||||
|
echo [信息] 端口 %PORT% 未被占用,无需停止
|
||||||
|
) else (
|
||||||
|
echo.
|
||||||
|
echo [信息] 等待端口释放...
|
||||||
|
timeout /t 2 /nobreak >nul
|
||||||
|
|
||||||
|
:: 二次确认
|
||||||
|
for /f "tokens=5" %%a in ('netstat -ano -p TCP ^| findstr ":%PORT%.*LISTENING"') do (
|
||||||
|
echo [警告] 端口 %PORT% 仍被进程 %%a 占用,再次尝试终止...
|
||||||
|
taskkill /PID %%a /F >nul 2>&1
|
||||||
|
timeout /t 1 /nobreak >nul
|
||||||
|
)
|
||||||
|
echo [成功] 项目已停止
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
@@ -1,618 +0,0 @@
|
|||||||
"""
|
|
||||||
资产总览功能 - 全面测试脚本
|
|
||||||
测试覆盖:账户 CRUD、余额更新、变更历史、分期计划 CRUD、还款操作
|
|
||||||
"""
|
|
||||||
import requests
|
|
||||||
import sys
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
BASE_URL = "http://localhost:23994/api"
|
|
||||||
|
|
||||||
passed = 0
|
|
||||||
failed = 0
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
|
|
||||||
def test(name, condition, detail=""):
|
|
||||||
global passed, failed
|
|
||||||
if condition:
|
|
||||||
passed += 1
|
|
||||||
print(f" [PASS] {name}")
|
|
||||||
else:
|
|
||||||
failed += 1
|
|
||||||
errors.append(name)
|
|
||||||
print(f" [FAIL] {name} {detail}")
|
|
||||||
|
|
||||||
|
|
||||||
def section(title):
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f" {title}")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("1. 账户 CRUD 测试")
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
# 1.1 创建存款账户 - 微信
|
|
||||||
print("\n--- 创建存款账户 ---")
|
|
||||||
r = requests.post(f"{BASE_URL}/accounts", json={
|
|
||||||
"name": "微信",
|
|
||||||
"account_type": "savings",
|
|
||||||
"balance": 5800.50,
|
|
||||||
"icon": "wechat",
|
|
||||||
"color": "#67C23A",
|
|
||||||
"is_active": True,
|
|
||||||
"description": "日常零钱"
|
|
||||||
})
|
|
||||||
test("创建微信账户", r.status_code == 201, f"status={r.status_code}")
|
|
||||||
wechat = r.json()
|
|
||||||
test("微信账户ID存在", wechat.get("id") is not None)
|
|
||||||
test("微信余额正确", wechat.get("balance") == 5800.50)
|
|
||||||
test("微信类型正确", wechat.get("account_type") == "savings")
|
|
||||||
wechat_id = wechat["id"]
|
|
||||||
|
|
||||||
# 1.2 创建存款账户 - 支付宝
|
|
||||||
r = requests.post(f"{BASE_URL}/accounts", json={
|
|
||||||
"name": "支付宝",
|
|
||||||
"account_type": "savings",
|
|
||||||
"balance": 12300.00,
|
|
||||||
"icon": "alipay",
|
|
||||||
"color": "#1677FF",
|
|
||||||
"is_active": True,
|
|
||||||
"description": "工资卡"
|
|
||||||
})
|
|
||||||
test("创建支付宝账户", r.status_code == 201, f"status={r.status_code}")
|
|
||||||
alipay = r.json()
|
|
||||||
alipay_id = alipay["id"]
|
|
||||||
|
|
||||||
# 1.3 创建存款账户 - 银行卡
|
|
||||||
r = requests.post(f"{BASE_URL}/accounts", json={
|
|
||||||
"name": "招商银行",
|
|
||||||
"account_type": "savings",
|
|
||||||
"balance": 45600.00,
|
|
||||||
"icon": "bank",
|
|
||||||
"color": "#FF6B6B",
|
|
||||||
"is_active": True
|
|
||||||
})
|
|
||||||
test("创建招商银行账户", r.status_code == 201, f"status={r.status_code}")
|
|
||||||
bank = r.json()
|
|
||||||
bank_id = bank["id"]
|
|
||||||
|
|
||||||
# 1.4 创建欠款账户 - 花呗
|
|
||||||
r = requests.post(f"{BASE_URL}/accounts", json={
|
|
||||||
"name": "花呗",
|
|
||||||
"account_type": "debt",
|
|
||||||
"balance": 3000.00,
|
|
||||||
"icon": "credit-card",
|
|
||||||
"color": "#FFB347",
|
|
||||||
"is_active": True,
|
|
||||||
"description": "分3期,每月12号还"
|
|
||||||
})
|
|
||||||
test("创建花呗账户", r.status_code == 201, f"status={r.status_code}")
|
|
||||||
huabei = r.json()
|
|
||||||
huabei_id = huabei["id"]
|
|
||||||
|
|
||||||
# 1.5 创建欠款账户 - 白条
|
|
||||||
r = requests.post(f"{BASE_URL}/accounts", json={
|
|
||||||
"name": "白条",
|
|
||||||
"account_type": "debt",
|
|
||||||
"balance": 2000.00,
|
|
||||||
"icon": "ticket",
|
|
||||||
"color": "#E6A23C",
|
|
||||||
"is_active": True,
|
|
||||||
"description": "分6期,每月15号还"
|
|
||||||
})
|
|
||||||
test("创建白条账户", r.status_code == 201, f"status={r.status_code}")
|
|
||||||
baitiao = r.json()
|
|
||||||
baitiao_id = baitiao["id"]
|
|
||||||
|
|
||||||
# 1.6 创建一个已禁用的账户
|
|
||||||
r = requests.post(f"{BASE_URL}/accounts", json={
|
|
||||||
"name": "已注销信用卡",
|
|
||||||
"account_type": "debt",
|
|
||||||
"balance": 0,
|
|
||||||
"icon": "credit-card",
|
|
||||||
"color": "#909399",
|
|
||||||
"is_active": False,
|
|
||||||
"description": "测试禁用状态"
|
|
||||||
})
|
|
||||||
test("创建已禁用账户", r.status_code == 201)
|
|
||||||
disabled_id = r.json()["id"]
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("2. 获取账户列表测试")
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
print("\n--- 获取所有账户 ---")
|
|
||||||
r = requests.get(f"{BASE_URL}/accounts")
|
|
||||||
test("获取账户列表 200", r.status_code == 200, f"status={r.status_code}")
|
|
||||||
accounts = r.json()
|
|
||||||
test("账户总数正确", len(accounts) >= 5, f"实际数量: {len(accounts)}")
|
|
||||||
|
|
||||||
savings = [a for a in accounts if a["account_type"] == "savings" and a["is_active"]]
|
|
||||||
debt = [a for a in accounts if a["account_type"] == "debt" and a["is_active"]]
|
|
||||||
test("活跃存款账户数量", len(savings) == 3, f"实际: {len(savings)}")
|
|
||||||
test("活跃欠款账户数量", len(debt) == 2, f"实际: {len(debt)}")
|
|
||||||
|
|
||||||
# 2.1 获取单个账户
|
|
||||||
print("\n--- 获取单个账户 ---")
|
|
||||||
r = requests.get(f"{BASE_URL}/accounts/{wechat_id}")
|
|
||||||
test("获取单个微信账户 200", r.status_code == 200)
|
|
||||||
test("账户名称正确", r.json().get("name") == "微信")
|
|
||||||
|
|
||||||
# 2.2 获取不存在的账户
|
|
||||||
r = requests.get(f"{BASE_URL}/accounts/99999")
|
|
||||||
test("获取不存在账户 404", r.status_code == 404, f"status={r.status_code}")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("3. 更新账户测试")
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
print("\n--- 更新账户信息 ---")
|
|
||||||
r = requests.put(f"{BASE_URL}/accounts/{wechat_id}", json={
|
|
||||||
"description": "日常零钱+红包"
|
|
||||||
})
|
|
||||||
test("更新微信描述 200", r.status_code == 200, f"status={r.status_code}")
|
|
||||||
test("描述更新成功", r.json().get("description") == "日常零钱+红包")
|
|
||||||
|
|
||||||
# 3.1 确认 balance 字段被忽略
|
|
||||||
r = requests.put(f"{BASE_URL}/accounts/{wechat_id}", json={
|
|
||||||
"balance": 99999 # 应该被忽略
|
|
||||||
})
|
|
||||||
test("更新忽略 balance", r.json().get("balance") == 5800.50,
|
|
||||||
f"余额不应被修改, 实际: {r.json().get('balance')}")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("4. 余额更新与变更历史测试")
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
print("\n--- 更新微信余额 ---")
|
|
||||||
r = requests.post(f"{BASE_URL}/accounts/{wechat_id}/balance", json={
|
|
||||||
"new_balance": 6800.50,
|
|
||||||
"note": "收到红包"
|
|
||||||
})
|
|
||||||
test("更新微信余额 200", r.status_code == 200, f"status={r.status_code}")
|
|
||||||
test("微信余额更新成功", r.json().get("balance") == 6800.50)
|
|
||||||
|
|
||||||
print("\n--- 再次更新微信余额 ---")
|
|
||||||
r = requests.post(f"{BASE_URL}/accounts/{wechat_id}/balance", json={
|
|
||||||
"new_balance": 5300.50,
|
|
||||||
"note": "日常消费"
|
|
||||||
})
|
|
||||||
test("微信消费后余额", r.json().get("balance") == 5300.50)
|
|
||||||
|
|
||||||
print("\n--- 更新支付宝余额 ---")
|
|
||||||
r = requests.post(f"{BASE_URL}/accounts/{alipay_id}/balance", json={
|
|
||||||
"new_balance": 15300.00,
|
|
||||||
"note": "工资到账"
|
|
||||||
})
|
|
||||||
test("支付宝工资到账 200", r.status_code == 200)
|
|
||||||
test("支付宝余额正确", r.json().get("balance") == 15300.00)
|
|
||||||
|
|
||||||
print("\n--- 更新招商银行余额 ---")
|
|
||||||
r = requests.post(f"{BASE_URL}/accounts/{bank_id}/balance", json={
|
|
||||||
"new_balance": 40000.00,
|
|
||||||
"note": "取现"
|
|
||||||
})
|
|
||||||
test("招商银行取现 200", r.status_code == 200)
|
|
||||||
|
|
||||||
print("\n--- 更新花呗余额 ---")
|
|
||||||
r = requests.post(f"{BASE_URL}/accounts/{huabei_id}/balance", json={
|
|
||||||
"new_balance": 2500.00,
|
|
||||||
"note": "提前还款500"
|
|
||||||
})
|
|
||||||
test("花呗提前还款 200", r.status_code == 200)
|
|
||||||
test("花呗余额更新", r.json().get("balance") == 2500.00)
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("5. 变更历史测试")
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
print("\n--- 查看微信变更历史 ---")
|
|
||||||
r = requests.get(f"{BASE_URL}/accounts/{wechat_id}/history", params={
|
|
||||||
"page": 1,
|
|
||||||
"page_size": 10
|
|
||||||
})
|
|
||||||
test("获取微信历史 200", r.status_code == 200, f"status={r.status_code}")
|
|
||||||
history = r.json()
|
|
||||||
test("历史总数正确", history.get("total") == 2, f"实际: {history.get('total')}")
|
|
||||||
test("分页参数正确", history.get("page") == 1 and history.get("page_size") == 10)
|
|
||||||
|
|
||||||
if history.get("records"):
|
|
||||||
first_record = history["records"][0]
|
|
||||||
test("最新记录是消费", first_record.get("change_amount") == -1500.0,
|
|
||||||
f"实际: {first_record.get('change_amount')}")
|
|
||||||
test("消费前余额正确", first_record.get("balance_before") == 6800.50)
|
|
||||||
test("消费后余额正确", first_record.get("balance_after") == 5300.50)
|
|
||||||
test("消费备注正确", first_record.get("note") == "日常消费")
|
|
||||||
test("记录有创建时间", first_record.get("created_at") is not None)
|
|
||||||
|
|
||||||
print("\n--- 查看支付宝变更历史 ---")
|
|
||||||
r = requests.get(f"{BASE_URL}/accounts/{alipay_id}/history")
|
|
||||||
history_alipay = r.json()
|
|
||||||
test("支付宝历史 200", r.status_code == 200)
|
|
||||||
test("支付宝历史有记录", history_alipay.get("total") == 1)
|
|
||||||
|
|
||||||
print("\n--- 查看从未变更的账户历史 ---")
|
|
||||||
r = requests.get(f"{BASE_URL}/accounts/{baitiao_id}/history")
|
|
||||||
test("白条历史 200", r.status_code == 200)
|
|
||||||
test("白条无变更记录", r.json().get("total") == 0)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("6. 分期还款计划 CRUD 测试")
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
# 6.1 为花呗创建分期计划(3期,每月12号,每期1000)
|
|
||||||
print("\n--- 创建花呗分期计划 ---")
|
|
||||||
r = requests.post(f"{BASE_URL}/debt-installments", json={
|
|
||||||
"account_id": huabei_id,
|
|
||||||
"total_amount": 3000.00,
|
|
||||||
"total_periods": 3,
|
|
||||||
"current_period": 1,
|
|
||||||
"payment_day": 12,
|
|
||||||
"payment_amount": 1000.00,
|
|
||||||
"start_date": "2026-03-12",
|
|
||||||
"is_completed": False
|
|
||||||
})
|
|
||||||
test("创建花呗分期 201", r.status_code == 201, f"status={r.status_code}")
|
|
||||||
huabei_inst = r.json()
|
|
||||||
test("花呗分期ID存在", huabei_inst.get("id") is not None)
|
|
||||||
test("花呗下次还款日期已计算", huabei_inst.get("next_payment_date") is not None)
|
|
||||||
test("花呗距今天数已计算", huabei_inst.get("days_until_payment") is not None)
|
|
||||||
test("花呗剩余期数", huabei_inst.get("remaining_periods") == 3)
|
|
||||||
test("花呗账户名称关联", huabei_inst.get("account_name") == "花呗")
|
|
||||||
huabei_inst_id = huabei_inst["id"]
|
|
||||||
|
|
||||||
# 6.2 为白条创建分期计划(6期,每月15号,每期333.33)
|
|
||||||
print("\n--- 创建白条分期计划 ---")
|
|
||||||
r = requests.post(f"{BASE_URL}/debt-installments", json={
|
|
||||||
"account_id": baitiao_id,
|
|
||||||
"total_amount": 2000.00,
|
|
||||||
"total_periods": 6,
|
|
||||||
"current_period": 3,
|
|
||||||
"payment_day": 15,
|
|
||||||
"payment_amount": 333.33,
|
|
||||||
"start_date": "2026-01-15",
|
|
||||||
"is_completed": False
|
|
||||||
})
|
|
||||||
test("创建白条分期 201", r.status_code == 201, f"status={r.status_code}")
|
|
||||||
baitiao_inst = r.json()
|
|
||||||
test("白条分期第3期", baitiao_inst.get("current_period") == 3)
|
|
||||||
test("白条剩余期数", baitiao_inst.get("remaining_periods") == 4)
|
|
||||||
baitiao_inst_id = baitiao_inst["id"]
|
|
||||||
|
|
||||||
# 6.3 验证不能给存款账户创建分期
|
|
||||||
print("\n--- 验证存款账户不能创建分期 ---")
|
|
||||||
r = requests.post(f"{BASE_URL}/debt-installments", json={
|
|
||||||
"account_id": wechat_id,
|
|
||||||
"total_amount": 1000,
|
|
||||||
"total_periods": 1,
|
|
||||||
"current_period": 1,
|
|
||||||
"payment_day": 1,
|
|
||||||
"payment_amount": 1000,
|
|
||||||
"start_date": "2026-04-01",
|
|
||||||
"is_completed": False
|
|
||||||
})
|
|
||||||
test("存款账户不能分期 400", r.status_code == 400, f"status={r.status_code}")
|
|
||||||
|
|
||||||
# 6.4 验证不存在的账户
|
|
||||||
r = requests.post(f"{BASE_URL}/debt-installments", json={
|
|
||||||
"account_id": 99999,
|
|
||||||
"total_amount": 1000,
|
|
||||||
"total_periods": 1,
|
|
||||||
"current_period": 1,
|
|
||||||
"payment_day": 1,
|
|
||||||
"payment_amount": 1000,
|
|
||||||
"start_date": "2026-04-01",
|
|
||||||
"is_completed": False
|
|
||||||
})
|
|
||||||
test("不存在账户不能分期 404", r.status_code == 404, f"status={r.status_code}")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("7. 获取分期计划列表测试")
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
print("\n--- 获取所有分期计划 ---")
|
|
||||||
r = requests.get(f"{BASE_URL}/debt-installments")
|
|
||||||
test("获取分期列表 200", r.status_code == 200, f"status={r.status_code}")
|
|
||||||
installments = r.json()
|
|
||||||
test("分期计划总数 >= 2", len(installments) >= 2, f"实际: {len(installments)}")
|
|
||||||
|
|
||||||
# 验证排序:未完成的排前面,临近的排前面
|
|
||||||
if len(installments) >= 2:
|
|
||||||
first_active = next((i for i in installments if not i["is_completed"]), None)
|
|
||||||
test("列表第一个是未完成的", first_active is not None)
|
|
||||||
if first_active:
|
|
||||||
test("第一个有还款日期", first_active.get("next_payment_date") is not None)
|
|
||||||
test("第一个有距今天数", first_active.get("days_until_payment") is not None)
|
|
||||||
|
|
||||||
# 验证每个计划都有计算字段
|
|
||||||
for inst in installments:
|
|
||||||
test(f"分期#{inst['id']}有计算字段",
|
|
||||||
inst.get("next_payment_date") is not None and
|
|
||||||
inst.get("days_until_payment") is not None and
|
|
||||||
inst.get("remaining_periods") is not None,
|
|
||||||
f"id={inst['id']}")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("8. 更新分期计划测试")
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
print("\n--- 更新花呗分期计划 ---")
|
|
||||||
r = requests.put(f"{BASE_URL}/debt-installments/{huabei_inst_id}", json={
|
|
||||||
"total_amount": 3500.00,
|
|
||||||
"payment_amount": 1166.67
|
|
||||||
})
|
|
||||||
test("更新花呗分期 200", r.status_code == 200, f"status={r.status_code}")
|
|
||||||
updated = r.json()
|
|
||||||
test("花呗总额更新", updated.get("total_amount") == 3500.00)
|
|
||||||
test("花呗每期金额更新", updated.get("payment_amount") == 1166.67)
|
|
||||||
|
|
||||||
# 恢复
|
|
||||||
requests.put(f"{BASE_URL}/debt-installments/{huabei_inst_id}", json={
|
|
||||||
"total_amount": 3000.00,
|
|
||||||
"payment_amount": 1000.00
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("9. 标记还款测试(核心流程)")
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
print("\n--- 花呗还款第1期 ---")
|
|
||||||
r = requests.patch(f"{BASE_URL}/debt-installments/{huabei_inst_id}/pay")
|
|
||||||
test("花呗还款第1期 200", r.status_code == 200, f"status={r.status_code}")
|
|
||||||
paid = r.json()
|
|
||||||
test("当前期数变为2", paid.get("current_period") == 2, f"实际: {paid.get('current_period')}")
|
|
||||||
test("未完成", paid.get("is_completed") == False)
|
|
||||||
test("剩余期数变为2", paid.get("remaining_periods") == 2)
|
|
||||||
|
|
||||||
print("\n--- 花呗还款第2期 ---")
|
|
||||||
r = requests.patch(f"{BASE_URL}/debt-installments/{huabei_inst_id}/pay")
|
|
||||||
test("花呗还款第2期 200", r.status_code == 200)
|
|
||||||
paid = r.json()
|
|
||||||
test("当前期数变为3", paid.get("current_period") == 3)
|
|
||||||
test("剩余期数变为1", paid.get("remaining_periods") == 1)
|
|
||||||
|
|
||||||
print("\n--- 花呗还款第3期(最后一期)---")
|
|
||||||
r = requests.patch(f"{BASE_URL}/debt-installments/{huabei_inst_id}/pay")
|
|
||||||
test("花呗还款第3期 200", r.status_code == 200)
|
|
||||||
paid = r.json()
|
|
||||||
test("标记为已完成", paid.get("is_completed") == True)
|
|
||||||
test("当前期数等于总期数", paid.get("current_period") == 3)
|
|
||||||
test("剩余期数为0", paid.get("remaining_periods") == 0)
|
|
||||||
|
|
||||||
print("\n--- 已完成的分期不能继续还款 ---")
|
|
||||||
r = requests.patch(f"{BASE_URL}/debt-installments/{huabei_inst_id}/pay")
|
|
||||||
test("已完成分期拒绝还款 400", r.status_code == 400, f"status={r.status_code}")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("10. 删除操作测试")
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
# 10.1 删除分期计划
|
|
||||||
print("\n--- 删除白条分期计划 ---")
|
|
||||||
r = requests.delete(f"{BASE_URL}/debt-installments/{baitiao_inst_id}")
|
|
||||||
test("删除白条分期 200", r.status_code == 200, f"status={r.status_code}")
|
|
||||||
|
|
||||||
# 验证删除后列表中没有了
|
|
||||||
r = requests.get(f"{BASE_URL}/debt-installments")
|
|
||||||
ids = [i["id"] for i in r.json()]
|
|
||||||
test("白条分期已从列表移除", baitiao_inst_id not in ids)
|
|
||||||
|
|
||||||
# 10.2 删除已禁用账户
|
|
||||||
print("\n--- 删除已禁用账户 ---")
|
|
||||||
r = requests.delete(f"{BASE_URL}/accounts/{disabled_id}")
|
|
||||||
test("删除已禁用账户 200", r.status_code == 200)
|
|
||||||
|
|
||||||
# 10.3 验证删除后
|
|
||||||
r = requests.get(f"{BASE_URL}/accounts")
|
|
||||||
ids = [a["id"] for a in r.json()]
|
|
||||||
test("已禁用账户已移除", disabled_id not in ids)
|
|
||||||
|
|
||||||
# 10.4 重新创建一个花呗分期用来测试级联删除
|
|
||||||
print("\n--- 测试级联删除 ---")
|
|
||||||
r = requests.post(f"{BASE_URL}/debt-installments", json={
|
|
||||||
"account_id": huabei_id,
|
|
||||||
"total_amount": 1000.00,
|
|
||||||
"total_periods": 1,
|
|
||||||
"current_period": 1,
|
|
||||||
"payment_day": 25,
|
|
||||||
"payment_amount": 1000.00,
|
|
||||||
"start_date": "2026-03-25",
|
|
||||||
"is_completed": False
|
|
||||||
})
|
|
||||||
cascade_inst_id = r.json()["id"]
|
|
||||||
|
|
||||||
r = requests.delete(f"{BASE_URL}/accounts/{huabei_id}")
|
|
||||||
test("级联删除花呗账户 200", r.status_code == 200)
|
|
||||||
|
|
||||||
# 验证分期计划也被删除了
|
|
||||||
r = requests.get(f"{BASE_URL}/debt-installments")
|
|
||||||
ids = [i["id"] for i in r.json()]
|
|
||||||
test("花呗关联分期也被删除", cascade_inst_id not in ids)
|
|
||||||
|
|
||||||
# 验证历史记录也被删除
|
|
||||||
r = requests.get(f"{BASE_URL}/accounts/{huabei_id}/history")
|
|
||||||
test("花呗账户删除后历史不可访问", r.status_code == 404, f"status={r.status_code}")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("11. 边界条件测试")
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
print("\n--- 余额更新到0 ---")
|
|
||||||
r = requests.post(f"{BASE_URL}/accounts/{baitiao_id}/balance", json={
|
|
||||||
"new_balance": 0,
|
|
||||||
"note": "还清"
|
|
||||||
})
|
|
||||||
test("余额归零 200", r.status_code == 200)
|
|
||||||
test("余额为0", r.json().get("balance") == 0)
|
|
||||||
|
|
||||||
print("\n--- 余额更新到大额 ---")
|
|
||||||
r = requests.post(f"{BASE_URL}/accounts/{baitiao_id}/balance", json={
|
|
||||||
"new_balance": 999999.99,
|
|
||||||
"note": "测试大额"
|
|
||||||
})
|
|
||||||
test("大额余额 200", r.status_code == 200)
|
|
||||||
test("大额余额正确", r.json().get("balance") == 999999.99)
|
|
||||||
|
|
||||||
# 恢复
|
|
||||||
requests.post(f"{BASE_URL}/accounts/{baitiao_id}/balance", json={
|
|
||||||
"new_balance": 2000.00,
|
|
||||||
"note": "恢复"
|
|
||||||
})
|
|
||||||
|
|
||||||
print("\n--- 参数校验:缺失必填字段 ---")
|
|
||||||
r = requests.post(f"{BASE_URL}/accounts", json={
|
|
||||||
"name": "",
|
|
||||||
})
|
|
||||||
test("空名称被拒绝 422", r.status_code == 422, f"status={r.status_code}")
|
|
||||||
|
|
||||||
print("\n--- 参数校验:无效类型 ---")
|
|
||||||
r = requests.post(f"{BASE_URL}/accounts", json={
|
|
||||||
"name": "测试账户",
|
|
||||||
"account_type": "invalid_type",
|
|
||||||
})
|
|
||||||
test("无效类型被拒绝 422", r.status_code == 422, f"status={r.status_code}")
|
|
||||||
|
|
||||||
print("\n--- 分期参数校验 ---")
|
|
||||||
r = requests.post(f"{BASE_URL}/debt-installments", json={
|
|
||||||
"account_id": baitiao_id,
|
|
||||||
"total_amount": 0,
|
|
||||||
"total_periods": 1,
|
|
||||||
"current_period": 1,
|
|
||||||
"payment_day": 1,
|
|
||||||
"payment_amount": 0,
|
|
||||||
"start_date": "2026-04-01",
|
|
||||||
"is_completed": False
|
|
||||||
})
|
|
||||||
test("零金额分期被拒绝 422", r.status_code == 422, f"status={r.status_code}")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("12. 账户列表的分期信息附加测试")
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
print("\n--- 获取白条账户(验证分期信息附加) ---")
|
|
||||||
# 先给白条创建一个分期
|
|
||||||
r = requests.post(f"{BASE_URL}/debt-installments", json={
|
|
||||||
"account_id": baitiao_id,
|
|
||||||
"total_amount": 2000.00,
|
|
||||||
"total_periods": 6,
|
|
||||||
"current_period": 2,
|
|
||||||
"payment_day": 15,
|
|
||||||
"payment_amount": 333.33,
|
|
||||||
"start_date": "2026-01-15",
|
|
||||||
"is_completed": False
|
|
||||||
})
|
|
||||||
test("白条分期重建 201", r.status_code == 201)
|
|
||||||
|
|
||||||
r = requests.get(f"{BASE_URL}/accounts")
|
|
||||||
accounts = r.json()
|
|
||||||
baitiao_acc = next((a for a in accounts if a["id"] == baitiao_id), None)
|
|
||||||
test("白条账户有分期附加信息", baitiao_acc is not None and baitiao_acc.get("installments") is not None)
|
|
||||||
|
|
||||||
# 存款账户不应有分期信息
|
|
||||||
wechat_acc = next((a for a in accounts if a["id"] == wechat_id), None)
|
|
||||||
if wechat_acc:
|
|
||||||
test("微信账户有分期字段(空列表)", wechat_acc.get("installments") is not None)
|
|
||||||
test("微信分期列表为空", len(wechat_acc.get("installments", [])) == 0)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("13. 历史分页测试")
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
print("\n--- 历史分页 ---")
|
|
||||||
# 先给招商银行做多次变更
|
|
||||||
for i, (bal, note) in enumerate([
|
|
||||||
(45000, "存入"),
|
|
||||||
(42000, "消费"),
|
|
||||||
(50000, "转入"),
|
|
||||||
(48000, "理财"),
|
|
||||||
(52000, "收益"),
|
|
||||||
]):
|
|
||||||
requests.post(f"{BASE_URL}/accounts/{bank_id}/balance", json={
|
|
||||||
"new_balance": bal,
|
|
||||||
"note": note
|
|
||||||
})
|
|
||||||
|
|
||||||
r = requests.get(f"{BASE_URL}/accounts/{bank_id}/history", params={"page": 1, "page_size": 3})
|
|
||||||
test("历史分页第1页 200", r.status_code == 200)
|
|
||||||
page1 = r.json()
|
|
||||||
test("每页3条", len(page1.get("records", [])) == 3, f"实际: {len(page1.get('records', []))}")
|
|
||||||
test("总记录6条", page1.get("total") == 6, f"实际: {page1.get('total')}")
|
|
||||||
|
|
||||||
r = requests.get(f"{BASE_URL}/accounts/{bank_id}/history", params={"page": 2, "page_size": 3})
|
|
||||||
test("历史分页第2页 200", r.status_code == 200)
|
|
||||||
page2 = r.json()
|
|
||||||
test("第2页3条", len(page2.get("records", [])) == 3, f"实际: {len(page2.get('records', []))}")
|
|
||||||
|
|
||||||
# 验证第1页和第2页没有重复
|
|
||||||
page1_ids = {r["id"] for r in page1["records"]}
|
|
||||||
page2_ids = {r["id"] for r in page2["records"]}
|
|
||||||
test("两页记录不重复", len(page1_ids & page2_ids) == 0)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("14. 花呗还款日期计算验证")
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
print("\n--- 验证还款日期计算 ---")
|
|
||||||
today = date.today()
|
|
||||||
r = requests.get(f"{BASE_URL}/debt-installments")
|
|
||||||
for inst in r.json():
|
|
||||||
if inst["is_completed"]:
|
|
||||||
continue
|
|
||||||
next_date_str = inst.get("next_payment_date")
|
|
||||||
days_until = inst.get("days_until_payment")
|
|
||||||
if next_date_str:
|
|
||||||
next_date = date.fromisoformat(next_date_str)
|
|
||||||
expected_days = (next_date - today).days
|
|
||||||
test(f"分期#{inst['id']}天数计算正确", days_until == expected_days,
|
|
||||||
f"计算: {days_until}, 预期: {expected_days}")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("15. 保留测试数据(跳过清理)")
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
print("\n--- 保留测试数据供页面展示 ---")
|
|
||||||
r = requests.get(f"{BASE_URL}/accounts")
|
|
||||||
remaining_accounts = len(r.json())
|
|
||||||
test("账户数据已保留", remaining_accounts > 0, f"保留: {remaining_accounts} 个账户")
|
|
||||||
|
|
||||||
r = requests.get(f"{BASE_URL}/debt-installments")
|
|
||||||
remaining_installs = len(r.json())
|
|
||||||
test("分期数据已保留", remaining_installs >= 0, f"保留: {remaining_installs} 个分期")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 最终报告
|
|
||||||
# ============================================================
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f" 测试报告")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
print(f" 通过: {passed}")
|
|
||||||
print(f" 失败: {failed}")
|
|
||||||
print(f" 总计: {passed + failed}")
|
|
||||||
if errors:
|
|
||||||
print(f"\n 失败项:")
|
|
||||||
for e in errors:
|
|
||||||
print(f" - {e}")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
if failed > 0:
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
print("\n 全部测试通过!")
|
|
||||||
sys.exit(0)
|
|
||||||
Reference in New Issue
Block a user