diff --git a/AGENTS.md b/AGENTS.md index 74b57d6..e1c5318 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## 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 +- Single `master` branch (4 commits total), no CI/CD ## Quick commands @@ -39,24 +39,37 @@ python tests/test_accounts.py - **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 `. +- **UserSettings is a singleton**: always id=1, auto-created on first `GET`. `set_default_password()` auto-initializes password to `"elysia"` on first access if `password_hash` is empty. +- **Account balance changes** auto-create `AccountHistory` records in `update_balance()`. You cannot directly modify balance via `PUT /api/accounts/{id}` — balance is replaced via `POST /api/accounts/{id}/balance`. +- **Habit checkins for the same day** accumulate count (not new rows), enforced by a `(habit_id, checkin_date)` unique constraint. Cancelling reduces count; deleting when count ≤ 0 removes the row. +- **Anniversaries / DebtInstallments** have computed fields (`next_date`, `days_until`, `year_count` / `remaining_periods`) calculated at request time, not stored in DB. The calculation functions live in the router layer (not models), because they depend on `date.today()`. +- **`task_tags` M2M table** is defined in `models/tag.py` (not `models/task.py`). Tags only support create/delete (no update). +- **Update schemas** use `clearable_fields` + `exclude_unset=True` to distinguish "field not sent" from "field sent as null". For non-clearable fields, `None` means "don't change"; for clearable fields, `None` means "clear it". See `schemas/task.py:TaskUpdate`, `schemas/habit.py:HabitUpdate`, `schemas/anniversary.py:AnniversaryUpdate`. +- **JWT authentication** — `utils/auth.py` handles JWT (HS256, key: `"elysia-todo-secret-key-change-in-production"`, 24h expiry). Middleware in `main.py` validates tokens 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`. **Middleware only validates the token; it does NOT inject user info into `request.state`.** Routes needing user data must call `get_current_user(request)` manually. +- **Different cascade delete strategies:** + - `Category`: refuses deletion if tasks are linked (400) + - `HabitGroup`: sets linked habits' `group_id` to NULL + - `AnniversaryCategory`: sets linked anniversaries' `category_id` to NULL + - `FinancialAccount`: full cascade delete (removes linked history + installments) + +### Router registration quirks +- **`/health` MUST be registered before `/{full_path:path}`** in `main.py:114` — otherwise SPA fallback intercepts health checks and returns `index.html`. +- **habits router** (`routers/habits.py`) is an empty-shell router that combines 3 sub-routers via `include_router`: habit-groups (`/api/habit-groups`), habits (`/api/habits`), and checkins (`/api/habits/{habit_id}/checkins`). +- **anniversaries and accounts routers** BOTH use `prefix="/api"` (not `/api/anniversaries` or `/api/accounts`). Their internal paths are `/anniversaries`, `/anniversary-categories`, `/accounts`, `/debt-installments`, etc. They coexist because internal paths don't overlap — but be careful adding new routes; the first `include_router` match wins. ### 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`. +- 9 Pinia stores: `auth`, `task`, `category`, `tag`, `habit`, `anniversary`, `account`, `userSettings`, `ui`. The `ui` store manages dialog visibility, editing state, sidebar collapse, and global loading (no API calls). +- Element Plus icons registered globally in `main.ts` — use ``, `` etc. in templates without imports. - 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. +- **Vite 7.x + TypeScript 5.9** with `erasableSyntaxOnly: true` and project references. +- **Frontend task filtering is entirely client-side.** `fetchTasks()` loads all tasks once; filtering, sorting, and pinyin search run in computed getters. No server-side filtering for tasks. +- **Pinyin search** via `pinyin-pro` (`utils/pinyin.ts`) — supports Chinese character search by pinyin initials or full pinyin. +- **Token key `elysia_auth_token` is hardcoded in 3 separate files** (`router/index.ts`, `api/request.ts`, `stores/useAuthStore.ts`). If you rename it, update all 3. +- **401 response triggers a hard page redirect** (`window.location.href = '/login'`) in the axios interceptor, which reloads the SPA entirely. This differs from the router guard's in-app redirect (`return { path: '/login' }`). +- `sass` is a runtime `dependency` (not `devDependency`) in `package.json` — intentional. ### Docker - `Dockerfile` copies pre-built `api/webui/` — you must build frontend before `docker build`. @@ -76,6 +89,6 @@ The `/health` endpoint must be registered **before** the `/{full_path:path}` SPA - No database migrations framework (Alembic) ## Additional notes -- Swagger UI at `/docs` when backend is running — the live, auto-generated API reference. +- Swagger UI at `/docs` when backend is running. - `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. +- `pydantic-settings` is installed but unused — config is hardcoded. diff --git a/api/app/routers/accounts.py b/api/app/routers/accounts.py index d379231..5e9a10b 100644 --- a/api/app/routers/accounts.py +++ b/api/app/routers/accounts.py @@ -390,6 +390,11 @@ def update_installment(installment_id: int, data: DebtInstallmentUpdate, db: Ses installment = get_or_404(db, DebtInstallment, installment_id, "分期计划") update_data = data.model_dump(exclude_unset=True) + if "account_id" in update_data: + account = get_or_404(db, FinancialAccount, update_data["account_id"], "账户") + if account.account_type != "debt": + raise HTTPException(status_code=400, detail="分期计划只能关联欠款类型的账户") + for field, value in update_data.items(): setattr(installment, field, value) diff --git a/api/app/routers/anniversaries.py b/api/app/routers/anniversaries.py index aad8f99..7a44acf 100644 --- a/api/app/routers/anniversaries.py +++ b/api/app/routers/anniversaries.py @@ -219,10 +219,10 @@ def create_anniversary(data: AnniversaryCreate, db: Session = Depends(get_db)): db.refresh(db_anniversary) 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}") - return db_anniversary + return result except Exception as e: db.rollback() logger.error(f"创建纪念日失败: {str(e)}") @@ -234,7 +234,9 @@ def get_anniversary(anniversary_id: int, db: Session = Depends(get_db)): """获取单个纪念日""" try: anniversary = get_or_404(db, Anniversary, anniversary_id, "纪念日") - return anniversary + today = date.today() + result = enrich_anniversary(anniversary, today) + return result except HTTPException: raise except Exception as e: