# 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:新增 `GET /api/session` 返回当前用户 + 该会话的 `csrf_token`;SPA 在所有写请求(POST/PUT/PATCH/DELETE)放 `X-CSRF-Token` header,后端校验其与 session 内 `csrf_token` 一致。等价于把现有表单 CSRF 平移到 header。 - 浏览器面向的所有新端点一律 session 保护;**裸 ingestion 端点(设备调用的 `POST /location/record`、`POST /poo/record`)维持现状到 M3**。 ### 3.3 前端工程 - `frontend/`:**Vite + React + TypeScript**。 - API client:由后端 `openapi/openapi.json` **自动生成** TS 类型与请求函数(如 `openapi-typescript` + 轻量 fetch 封装,或同类工具)。生成物入库或在 build 时生成(见 T06 决策)。 - 可视化:地图 + 热力图(location 轨迹 / poo 点位)。建议 **MapLibre GL 或 Leaflet + heatmap 插件**(最终选型见 §5 决策)。 - 状态/数据请求:轻量即可(如 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. 需先拍板的决策(Orchestrator 在派 T06 前确认) 1. **地图/热力图库**:MapLibre GL(矢量、现代)vs Leaflet(简单、生态大)。推荐 Leaflet + `leaflet.heat`(试水门槛低)。 2. **OpenAPI client 生成物**:入库(确定性、便于 review)vs build 时生成(仓库干净)。推荐**入库**,并加一个 `npm run codegen` + CI 校验"生成物与 openapi 同步"。 3. **CSRF 落地**:header `X-CSRF-Token` + `GET /api/session` 下发(推荐)vs 双提交 cookie。 4. **是否保留少量 Jinja**:建议 SPA 对齐后**全量移除** `templates/`,只留 SPA。 > 这些可用 1 个轻量"决策任务"或直接由 Orchestrator 在本节记录选择,再开 T06。 ## 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**: `todo` · **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**: `todo` · **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**: `todo` · **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**: `todo` · **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**: `todo` · **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**: `todo` · **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**: `todo` · **Depends**: M2-T06 - **Acceptance**: 登录成功进受保护区;未登录访问受保护路由跳登录;强制改密流程可走完;`build/lint/typecheck/test` 全绿。 ### M2-T08 — 配置 UI(取代 Jinja config 页) - **Status**: `todo` · **Depends**: M2-T06 - **Acceptance**: 能读/存所有现有配置 section;secret 不回显、留空保留;SMTP 测试按钮反映三态;前端闸门全绿。 ### M2-T09 — 数据可视化 UI(地图 + 热力图) - **Status**: `todo` · **Depends**: M2-T06(数据来自 T03) - **Context**: 接管 Grafana 原职责:location 轨迹/热力图、poo 点位。 - **Acceptance**: 地图渲染 location/poo 点;热力图层可切换;时间范围筛选生效;前端闸门全绿。 ### M2-T10 — 记录管理 UI(按需展示 + 增删改) - **Status**: `todo` · **Depends**: M2-T06(CRUD 来自 T04) - **Acceptance**: 列表分页展示 poo/location;可编辑、可删除单条并即时刷新;删除有二次确认;前端闸门全绿。 ### M2-T11 — FastAPI 托管 SPA + 移除 Jinja - **Status**: `todo` · **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**: `todo` · **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**: `todo` · **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` 全绿。