# M2 — 前端 v2（React SPA）

> 阅读前提：先读 [`README.md`](./README.md)。M2 依赖 M1 完成（单库 + 干净的数据层 + API 建立在合并后的 schema 上）。

## 1. 目标

用 **React SPA** 取代现有 Jinja 页面，由 FastAPI **同源**托管（同一容器、同一 origin）。一步合并 roadmap 的"前端重写"与"前端做厚"：配置界面 + 数据可视化（热力图 / 地图，接管 Grafana）+ 记录的按需展示与小幅增删改。

> **元目标（agentic 实验）**：这是用 agent 写 React 的试水，全程尽量不读代码。因此本里程碑**强约束 OpenAPI → 类型化 TS client 作为契约护栏**：后端 API 先稳，前端永远对着强类型契约写，便宜模型不易跑偏，reviewer 也有客观依据。

## 2. 现状（M1 完成后）

- 页面仍是服务端 Jinja：`app/api/routes/pages.py`（`GET/POST /config`、`/`、`/admin`、`POST /config/smtp/test`）+ `app/templates/`（`base/config/home/login.html`、`styles.css`）。
- 鉴权：`get_current_auth_session`（读 `auth_session_cookie_name` cookie），server-side session + 每会话 `csrf_token` 内嵌在表单。
- `app/main.py` 已 `app.mount("/static", StaticFiles(...))`。
- 配置读写逻辑在 `app/services/config_page.py`（`build_config_sections` / `save_config_updates` / `build_runtime_settings`）。
- 业务数据：单库中的 `location`、`poo_records`、`public_ip_state`、`public_ip_history`。

## 3. 目标架构

### 3.1 后端：JSON API + SPA 托管

- 所有数据交互走 **JSON API**，统一前缀 `/api`（SPA 是客户端渲染，必须有 API——这与"同源/同容器"无关）。
- FastAPI 既挂 `/api/*`，又挂 SPA 静态产物，并对非 `/api`、非静态资源的路径**回退到 `index.html`**（支持前端路由 deep-link）。
- Jinja 页面在 SPA 达到功能对齐后移除。

### 3.2 鉴权：复用 session cookie + SPA 版 CSRF

- 继续用现有 **HttpOnly session cookie**（同源自动携带），M2 **不引入 token**（token 属 M3）。
- CSRF（已定·简化版）：依赖 `SameSite=Lax` 的 session cookie——跨站发起的写请求（POST/PUT/PATCH/DELETE）**不会自动携带 cookie**，经典 CSRF 主路已被堵；再要求所有写请求带一个**自定义 header**（跨站无 CORS 预检发不出，且本应用不对外站开放 CORS）作为纵深防御。**不做 per-session token 比对**（个人自用场景足够）。`GET /api/session` 仍保留，用途是返回当前登录用户、引导 SPA（不再以下发/校验 `csrf_token` 为目的）。
- 浏览器面向的所有新端点一律 session 保护；**裸 ingestion 端点（设备调用的 `POST /location/record`、`POST /poo/record`）维持现状到 M3**。

### 3.3 前端工程

- `frontend/`：**Vite + React + TypeScript**。
- 组件库：**Mantine**（已定；批电池齐、TS 优先、视觉中性，最贴近此前 Vue 侧 Naive UI 的用法）。
- API client：由后端 `openapi/openapi.json` **自动生成** TS 类型与请求函数（如 `openapi-typescript` + 轻量 fetch 封装）。**生成物入库** + `npm run codegen` + CI 校验"生成物与 openapi 同步"（已定）。fetch 封装统一带 cookie、写请求注入自定义 CSRF header、401 跳登录。
- 可视化：**Leaflet**（已定）—— `react-leaflet` + `leaflet.heat`（热力图，**头号功能**）+ `leaflet.markercluster`（点多时聚合）+ OSM 栅格瓦片（零 key）。**地图封在一个自包含组件后面**（如 `<RecordsMap points mode onSelect>`，全应用只此处 import leaflet），数据获取/时间窗 state 在外面；这样将来若要换 **MapLibre GL** 是被隔离的局部重写，不波及其它。
- 状态/数据请求：轻量即可（**TanStack Query**，已定），不引入重型框架。

### 3.4 构建与部署

- 多阶段 `Dockerfile`：node 阶段 `npm ci && npm run build` → 把 `frontend/dist` 拷进 python 镜像的静态目录；运行镜像不带 node。
- compose 仍是单 app 容器（同源）。

## 4. API 契约（M2 要落地的端点）

> 全部 `/api` 前缀、session 保护、JSON 进出。具体 schema 在各任务里用 Pydantic 定义，并经 `export_openapi.py` 固化。

| 分组 | 端点 | 用途 |
| --- | --- | --- |
| 会话 | `GET /api/session` | 返回当前用户 + csrf_token；未登录 401 |
| 会话 | `POST /api/auth/login` | 账号密码登录，下发 session cookie |
| 会话 | `POST /api/auth/logout` | 注销 |
| 会话 | `POST /api/auth/password` | 改密（沿用现有强制改密语义）|
| 配置 | `GET /api/config` | 返回配置 sections（secret 不回显）|
| 配置 | `PUT /api/config` | 保存配置（留空保留旧 secret 语义不变）|
| 配置 | `POST /api/config/smtp/test` | 触发测试发信 |
| 数据 | `GET /api/locations` | location 记录查询（时间范围/分页，供地图/热力图）|
| 数据 | `GET /api/poo` | poo 记录列表（分页）|
| 数据 | `GET /api/public-ip` | 当前状态 + 变化历史 |
| CRUD | `PATCH /api/locations/{person}/{datetime}` | 修正单条 location |
| CRUD | `DELETE /api/locations/{person}/{datetime}` | 删除单条 location |
| CRUD | `PATCH /api/poo/{timestamp}` | 修正单条 poo |
| CRUD | `DELETE /api/poo/{timestamp}` | 删除单条 poo |

> 记录 CRUD 依赖现有 PK 作行标识（location PK=`person+datetime`，poo PK=`timestamp`）。路径参数需对 `datetime`/`timestamp` 做 URL 编码处理。

## 5. 已锁定决策（讨论后拍板）

> 以下为与项目所有者讨论后**已定**的选择。**线框图本里程碑不画**——按本节 + 各任务卡描述，由实现侧自行合理排版（含移动端布局）。

**技术选型**
1. **组件库 = Mantine**。批电池齐、TS 优先、视觉中性、文档好，最贴近此前 Naive UI 的用法，利于 agent 产出一致 UI。
2. **地图库 = Leaflet**（`react-leaflet` + `leaflet.heat` + `leaflet.markercluster`，OSM 栅格、零 key）。**封在自包含组件后**，预留将来迁 MapLibre 的接缝（见 §3.3）。
3. **OpenAPI client = 生成物入库** + `npm run codegen` + CI 校验"与 openapi 同步"。
4. **CSRF = 简化版**：`SameSite=Lax` cookie + 写请求带自定义 header，**不做 per-session token**（见 §3.2）。
5. **前端栈**：Vite + React + TS + TanStack Query + Mantine。
6. **Jinja**：SPA 功能对齐后**全量移除** `templates/` 与 `pages.py`。

**信息架构 / UX**
7. **首页主视图 = 地图（热力图为主）+ 时间范围选择器**。可视化优先级：**热力图（最重要）> 时间选择器（必须）> 散点点位/列表（辅助）**。
8. **列表 = 辅助页面，分页**（默认页大小 ~100、有上限；前端换页取数，不拉全量）。
9. **记录编辑/删除**：**location 靠点地图上的点**触发（不做 75k 行大列表）；**poo 靠列表 + 地图点位**。
10. **配置入口**：config 作为普通页之一，由界面上一个**齿轮图标**进入。`/admin`、`/` 现状只是重定向到 `/config`，SPA **不需要单独 admin 页**；`/` 首页直接给地图主视图（概览 dashboard 列为**可选/后续**，非 M2 核心）。
11. **响应式 = 要**（手机浏览器可用、合理移动端布局）。**PWA** 列为近期 backlog（见 `docs/future-ideas.md`），M2 设计即按移动端友好铺路。

**范围边界**
12. **CRUD = 改非主键字段 + 删单行**；主键（location=`person+datetime`、poo=`timestamp`）**不可改**；**不提供 UI 新建**（记录由设备 ingestion 产生）。
13. **裸 ingestion 端点**（`POST /location/record`、`POST /poo/record`）**维持现状到 M3**，本里程碑不加保护、不改动。
14. **trip / 轨迹连线**为**可选 / 后续**（5 分钟一点 + 手机记录较糙，先不做核心）。

> 项目定位：个人自用、家庭特化、不开源——设计可按单用户场景简化，不为通用性过度抽象。

## 6. 任务依赖图

```
后端 API（可与前端 scaffold 并行）
  M2-T01 config API
  M2-T02 session/auth API   ─┐
  M2-T03 data read API       ├─► 都产出 OpenAPI 契约
  M2-T04 record CRUD API     │
  M2-T05 smtp/action API    ─┘
        │ (openapi 稳定后)
        ▼
  M2-T06 前端 scaffold + codegen ──► M2-T07 auth UI
                                  ├─► M2-T08 config UI
                                  ├─► M2-T09 可视化 UI
                                  └─► M2-T10 records 管理 UI
        ▼
  M2-T11 FastAPI 托管 SPA + 移除 Jinja（依赖 T07–T10 达到对齐）
        ▼
  M2-T12 多阶段 Dockerfile + CI/compose
        ▼
  M2-T13 文档 + OpenAPI 收尾
```

---

## 7. 原子任务（任务卡）

> 后端任务沿用 M1 的校验闸门（`pytest` / `ruff` / `export_openapi`）。前端任务的闸门见 §8。

### M2-T01 — config JSON API
- **Status**: `done` · **Depends**: none（M1 完成后）
- **Context**: 把 `config_page` 的读写能力暴露成 JSON，复用现有 service，不重写业务逻辑。
- **Files**: `create app/api/routes/api/config.py`、`create app/schemas/config.py`；`modify app/main.py`（注册路由）；`create tests/test_api_config.py`
- **Steps**: 用 `build_config_sections`/`save_config_updates` 包出 `GET/PUT /api/config`；session 保护；secret 不回显、留空保留旧值语义照搬。
- **Acceptance**:
  - [ ] 未登录访问 `GET /api/config` 返回 401。
  - [ ] 登录后 `GET` 返回 sections，secret 字段被遮罩。
  - [ ] `PUT` 留空 secret 时保留旧值；非法值返回 4xx 且不写库。
  - [ ] 校验闸门全绿（含 `openapi/` 重导出入库）。
- **Reviewer**: 复用了 service 而非复制逻辑；CSRF 校验存在；secret 不泄漏到响应或 OpenAPI 示例。

### M2-T02 — session / auth JSON API
- **Status**: `done` · **Depends**: none
- **Context**: 给 SPA 提供登录/注销/会话探测 + CSRF 下发。
- **Files**: `create app/api/routes/api/session.py`、`app/schemas/session.py`；`modify app/main.py`；`create tests/test_api_session.py`
- **Steps**: `GET /api/session`（401 或 user+csrf）、`POST /api/auth/login`、`POST /api/auth/logout`、`POST /api/auth/password`，复用 `app/services/auth.py`。
- **Acceptance**:
  - [ ] 正确账号密码登录后置下 HttpOnly session cookie；`GET /api/session` 返回 user + csrf_token。
  - [ ] 错误凭据 401，不下发 cookie。
  - [ ] 写端点缺 `X-CSRF-Token` 或不匹配 → 403。
  - [ ] 强制改密语义与现有一致。
  - [ ] 校验闸门全绿。
- **Reviewer**: cookie 仍 HttpOnly、`Secure` 跟随 `app_env`、`SameSite=Lax`；密码仍 Argon2，不明文。

### M2-T03 — 数据读取 API（locations / poo / public-ip）
- **Status**: `done` · **Depends**: none
- **Files**: `create app/api/routes/api/data.py`、`app/schemas/data.py`；`modify app/main.py`；`create tests/test_api_data.py`
- **Steps**: `GET /api/locations`（时间范围 + 分页）、`GET /api/poo`（分页）、`GET /api/public-ip`（state + history）；session 保护；查询参数有上限防全表导出。
- **Acceptance**:
  - [ ] 分页/时间范围参数生效且有上限；越权未登录 401。
  - [ ] 返回 schema 经 OpenAPI 固化。
  - [ ] 校验闸门全绿。
- **Reviewer**: 查询走索引/PK，无 N+1；时间过滤边界正确。

### M2-T04 — 记录 CRUD API（修正 / 删除）
- **Status**: `done` · **Depends**: M2-T03
- **Files**: `modify app/api/routes/api/data.py`、`app/services/location.py`、`app/services/poo.py`；`create tests/test_api_record_crud.py`
- **Steps**: `PATCH`/`DELETE` location（PK person+datetime）与 poo（PK timestamp）；session + CSRF 保护；PK 路径参数 URL 解码；删除是**硬删单行**（不是清表）。
- **Acceptance**:
  - [ ] PATCH 改单行字段、DELETE 删单行，行数变化精确为 1。
  - [ ] 不存在的 PK → 404。
  - [ ] 缺 CSRF → 403。
  - [ ] 没有任何"批量删/清表"路径。
  - [ ] 校验闸门全绿。
- **Reviewer**: 删除限定单 PK；编辑校验输入；ingestion 裸端点未被顺手加保护或改动。

### M2-T05 — SMTP 测试 / 动作类 JSON API
- **Status**: `done` · **Depends**: M2-T01
- **Files**: `modify app/api/routes/api/config.py`；`modify tests/test_api_config.py`
- **Steps**: `POST /api/config/smtp/test` 复用 `send_smtp_test_email`，返回结构化结果（success / config-error / failed）。
- **Acceptance**:
  - [ ] 三种结果都有明确 JSON 状态码/字段；session + CSRF 保护。
  - [ ] 校验闸门全绿。

### M2-T06 — 前端 scaffold + OpenAPI codegen `[structural]`
- **Status**: `done` · **Depends**: M2-T01..T05（OpenAPI 已稳定）
- **Context**: 建 `frontend/` 工程与类型化 client 流水线，这是后续所有前端任务的地基。
- **Files**: `create frontend/`（Vite+React+TS 脚手架、`package.json`、`tsconfig.json`、eslint、vitest、`.gitignore`）、`frontend/src/api/`（codegen 产物 + fetch 封装，自动注入 `X-CSRF-Token`）、`frontend/README.md`、`npm run codegen` 脚本
- **Steps**: 初始化 Vite React-TS；接 `openapi/openapi.json` 生成类型；写一个最小 App 壳 + 受保护路由骨架；fetch 封装统一带 cookie、写请求注入 CSRF header、401 跳登录。
- **Acceptance**:
  - [ ] `npm ci && npm run build` 成功产出 `frontend/dist`。
  - [ ] `npm run lint`、`npm run typecheck`、`npm run test` 全绿（哪怕只有 1 个 smoke 测试）。
  - [ ] `npm run codegen` 生成物与当前 `openapi/openapi.json` 一致（CI 可校验）。
- **Reviewer**: client 全部基于生成类型；CSRF/cookie/401 处理在统一封装层；无手写、与契约不符的请求类型。

### M2-T07 — 鉴权 UI（登录 / 会话引导 / 改密）
- **Status**: `done` · **Depends**: M2-T06
- **Acceptance**: 登录成功进受保护区；未登录访问受保护路由跳登录；强制改密流程可走完；`build/lint/typecheck/test` 全绿。

### M2-T08 — 配置 UI（取代 Jinja config 页）
- **Status**: `done` · **Depends**: M2-T06
- **Acceptance**: 能读/存所有现有配置 section；secret 不回显、留空保留；SMTP 测试按钮反映三态；前端闸门全绿。

### M2-T09 — 数据可视化 UI（热力图为主的地图）
- **Status**: `done` · **Depends**: M2-T06（数据来自 T03）
- **Context**: 接管 Grafana 原职责，且**首页主视图就是这张地图**。优先级：**① 热力图（最重要）② 时间范围选择器（必须）③ 散点点位（辅助，主要服务编辑/删除）**。location：去过哪的密度；poo：狗最爱在哪拉。
- **Acceptance**: 首页渲染热力图（location / poo）；**时间范围选择器生效、只取窗口内数据**（不拉全量）；散点层可切换、点选某点可进入编辑/删除（接 T10/T04）；location 点多时聚合；响应式（手机浏览器可用）；前端闸门全绿。

### M2-T10 — 记录管理 UI（按需展示 + 增删改）
- **Status**: `done` · **Depends**: M2-T06（CRUD 来自 T04）
- **Acceptance**: 列表分页展示 poo/location；可编辑、可删除单条并即时刷新；删除有二次确认；前端闸门全绿。

### M2-T11 — FastAPI 托管 SPA + 移除 Jinja
- **Status**: `done` · **Depends**: M2-T07, T08, T09, T10
- **Files**: `modify app/main.py`（挂载 SPA 静态目录 + 非 `/api` 路径回退 `index.html`）；`delete app/templates/`、`app/api/routes/pages.py`（功能对齐后）；`modify tests`（移除 Jinja 页面测试，新增 SPA fallback 测试）
- **Acceptance**:
  - [ ] `/config` 等路径返回 SPA（`index.html`），`/api/*` 不被 fallback 吞掉，`/static`/资源正常。
  - [ ] 旧 Jinja 模板与 pages 路由移除后 `pytest` 全绿。
  - [ ] 校验闸门全绿（含 OpenAPI 重导出）。
- **Reviewer**: fallback 不拦截 `/api`、`/docs`、`/openapi.json`、静态资源；未登录访问 API 仍 401（不是被 SPA 壳吞掉）。

### M2-T12 — 多阶段 Dockerfile + CI/compose
- **Status**: `done` · **Depends**: M2-T11
- **Files**: `modify Dockerfile`（node build 阶段 → 拷 `dist` 进 python 镜像）；`modify .github/workflows/*`（加前端 build/lint/typecheck）；`modify tests/test_deployment.py`（镜像断言更新）
- **Acceptance**:
  - [ ] 镜像构建成功且运行镜像不含 node 运行时。
  - [ ] CI 跑前端闸门 + 后端 `pytest`。
  - [ ] 校验闸门全绿。

### M2-T13 — 文档 + OpenAPI 收尾
- **Status**: `done` · **Depends**: M2-T12
- **Acceptance**: README 增"前端 v2"段（开发/构建说明）；architecture 退役"不前后端分离"约束；roadmap 勾选 M2；`openapi/` 已同步入库。

---

## 8. 前端校验闸门（前端任务每次结束都要全绿）

在 `frontend/` 下：
```bash
npm ci
npm run codegen      # 生成类型化 client；产物须与 openapi/openapi.json 同步
npm run lint
npm run typecheck
npm run test
npm run build        # 必须产出 dist
```
- 后端若同任务改了路由/schema，仍需根目录 `python scripts/export_openapi.py` 并提交 `openapi/`。
- "codegen 产物与 OpenAPI 同步"应在 CI 校验（生成后 `git diff --exit-code`）。

## 9. 里程碑完成定义（DoD）

- 访问应用得到 React SPA；配置、可视化、记录增删改都在 SPA 内完成。
- 所有浏览器交互走 `/api` JSON 端点，session + CSRF 保护；ingestion 裸端点维持现状（留给 M3）。
- Jinja `templates/` 与 pages 路由移除；FastAPI 同源托管 SPA。
- 多阶段镜像构建通过；CI 含前端闸门。
- 后端 `pytest`/`ruff`/`export_openapi` + 前端 `build/lint/typecheck/test` 全绿。
