Files
home-automation/docs/design/m2-frontend-v2.md
T
2026-06-13 15:20:50 +02:00

251 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M2 — 前端 v2React 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` | 返回配置 sectionssecret 不回显)|
| 配置 | `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(依赖 T07T10 达到对齐)
M2-T12 多阶段 Dockerfile + CI/compose
M2-T13 文档 + OpenAPI 收尾
```
---
## 7. 原子任务(任务卡)
> 后端任务沿用 M1 的校验闸门(`pytest` / `ruff` / `export_openapi`)。前端任务的闸门见 §8。
### M2-T01 — config JSON API
- **Status**: `done` · **Depends**: noneM1 完成后)
- **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` 返回 sectionssecret 字段被遮罩。
- [ ] `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 — 数据读取 APIlocations / 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` locationPK person+datetime)与 pooPK 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..T05OpenAPI 已稳定)
- **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-T06CRUD 来自 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**: `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` 全绿。