diff --git a/wiki/index.md b/wiki/index.md index 173fd8f..85e14e4 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -119,7 +119,7 @@ ### 患者端 - [[miniprogram]] — **微信小程序** · Taro 4.2 · 微信登录 · 手机绑定 · 健康数据查看 -- [[miniprogram-quality-checklist]] — **小程序质量规范清单** · 8 类规则 · 提交前检查 · PR Review 依据 +- [[miniprogram-quality-checklist]] — **小程序质量规范清单** · 12 类 44 条规则 · 66+ fix 提交抽象 · 提交前检查/PR Review/里程碑审计 ### 基础设施 - [[infrastructure]] — 连接信息 · 环境变量 · 一键启动 (**单一真相源**) diff --git a/wiki/miniprogram-quality-checklist.md b/wiki/miniprogram-quality-checklist.md index 59a8cf6..ae8a7c6 100644 --- a/wiki/miniprogram-quality-checklist.md +++ b/wiki/miniprogram-quality-checklist.md @@ -1,6 +1,6 @@ # 小程序质量规范清单 -> **最后更新:** 2026-05-17 | 来源:两轮深度审计(并发饥饿修复 + 二轮精细审查),8 CRITICAL/HIGH + 12 MEDIUM/LOW 问题抽象 +> **最后更新:** 2026-05-17 | 来源:164 条 git 提交历史 + 两轮深度审计,全量问题模式抽象 > > **用途:** 新页面/新模块开发时的自检清单,PR Review 的检查依据,新项目启动时的基线规范。每条规则都来自真实 bug 修复,不是理论推导。 @@ -8,14 +8,18 @@ ## 目录 -1. [[#1 并发与请求层]] — 限流器、长轮询、Token 刷新 +1. [[#1 并发与请求层]] — 限流器、长轮询、Token 刷新、缓存策略 2. [[#2 导航与路由]] — 页栈保护、reLaunch 去重、分包预加载 -3. [[#3 组件与渲染]] — render body 定义、ScrollView 嵌套、懒加载 +3. [[#3 组件与渲染]] — render body 定义、ScrollView 嵌套、懒加载、统一组件库 4. [[#4 数据与内存]] — 数组上限、去重索引一致性、Storage 清理 -5. [[#5 认证与会话]] — 模块级缓存清理、logout 完整性 -6. [[#6 样式与布局]] — CSS 变量主题、长者模式、设计 token -7. [[#7 错误处理与日志]] — 静默吞错、生产日志、用户提示 -8. [[#8 审计与交付]] — 提交前检查、Feature DoD +5. [[#5 认证与会话]] — 模块级缓存清理、logout 完整性、认证恢复 +6. [[#6 前后端接口契约]] — 字段对齐、类型同步、API 路径一致 +7. [[#7 样式与布局]] — CSS 变量主题、长者模式、设计 token、对齐原型 +8. [[#8 安全]] — XSS 防护、输入验证、敏感数据、权限校验 +9. [[#9 错误处理与日志]] — 静默吞错、生产日志、用户提示、ErrorBoundary +10. [[#10 定时器与副作用清理]] — setTimeout/setInterval 清理、useSafeTimeout +11. [[#11 开发环境]] — DevTools 卡死、source map、文件描述符限制 +12. [[#12 审计与交付]] — 提交前检查、PR Review、里程碑审计 --- @@ -23,8 +27,9 @@ ### 规则 1.1 — 全局并发限制器 -> **根因:** 微信小程序 `wx.request` 并发上限为 10,超出排队。无限制地发起请求会导致请求饥饿。 -> **案例:** ConcurrencyLimiter(8) 时长轮询占满 8 个槽位 25-30s,所有新请求排队等待直到超时。 +> **根因:** 微信 `wx.request` 并发上限为 10,超出排队。无限制发请求导致饥饿。 +> **案例:** ConcurrencyLimiter(8) 时长轮询占满 8 个槽位 25-30s,所有新请求排队超时。 +> **提交:** `9d50ef7` `74bffb4` - [ ] 所有 HTTP 请求必须经过全局 `ConcurrencyLimiter` - [ ] 并发上限 ≤ 12(微信限制 10,留 2 个给框架内部请求) @@ -34,7 +39,6 @@ // ✅ 正确:Token 刷新绕过限流器 async function doRefresh(): Promise { const res = await Taro.request({ url: refreshTokenUrl, method: 'POST', ... }); - // ... } // ❌ 错误:Token 刷新走限流器,所有槽位被占时死锁 @@ -45,52 +49,43 @@ async function doRefresh(): Promise { ### 规则 1.2 — 长轮询独立通道 -> **根因:** 长轮询请求 hang 25-30s,占用并发槽位,与普通 API 请求竞争。 -> **案例:** 咨询页长轮询 + Tab 切换加载 = 开发者工具卡死。 +> **根因:** 长轮询 hang 25-30s 占用并发槽位,与普通 API 竞争。 +> **案例:** 咨询页长轮询 + Tab 切换 = 开发者工具卡死。 +> **提交:** `9d50ef7` `5baa518` - [ ] 长轮询必须使用 `requestUnlimited`(绕过限流器的独立通道) -- [ ] 长轮询必须有连续失败上限(默认 10 次),达到后停止轮询 -- [ ] 长轮询必须有 generation counter 模式,组件卸载/参数变化时旧轮询自动失效 +- [ ] 必须有连续失败上限(默认 10 次),达到后停止 +- [ ] 必须有 generation counter 模式,组件卸载/参数变化时旧轮询自动失效 - [ ] 成功轮询间隔 ≥ 3 秒(不能 `delay=0` 立即递归,否则 CPU 飙升) -```typescript -// ✅ 正确:独立通道 + generation counter -useLongPolling({ - pollFn: () => requestUnlimited('GET', path, undefined, 30000), - onData: (data) => { /* ... */ }, - maxFailures: 10, -}); - -// ❌ 错误:长轮询走限流器 -useLongPolling({ - pollFn: () => api.get('/messages/poll'), // 占用槽位 30s! -}); -``` - ### 规则 1.3 — reLaunch 去重 -> **根因:** 401 时多个并发请求同时触发 `Taro.reLaunch('/pages/login/index')`,微信框架崩溃。 +> **根因:** 401 时多个并发请求同时触发 `Taro.reLaunch('/pages/login/index')`。 > **案例:** 3 个请求同时 401 → 3 次 reLaunch → 页面栈混乱。 +> **提交:** `9d50ef7` - [ ] `reLaunch` 必须全局去重(Promise 锁 + 2s 冷却期) - [ ] 401 重试上限为 1 次,避免无限循环 -```typescript -// ✅ 正确:去重 -let reLaunchPromise: Promise | null = null; -function safeReLaunch(url: string): void { - if (reLaunchPromise) return; - reLaunchPromise = Taro.reLaunch({ url }) - .then(() => {}, (err) => { console.warn('[request] reLaunch failed:', err); }) - .then(() => { setTimeout(() => { reLaunchPromise = null; }, 2000); }); -} -``` +### 规则 1.4 — 请求缓存与去重 -### 规则 1.4 — 请求层错误处理 +> **根因:** Tab 切换时同一 API 被多次并发调用,浪费请求且竞态更新。 +> **案例:** 首页/健康页每次 useDidShow 都重新请求,快速切换 Tab 触发大量重复。 +> **提交:** `6d151bb` `447126b` + +- [ ] GET 请求必须有响应缓存(TTL 60s),同 URL 短时间内命中缓存 +- [ ] 同时进行的相同 GET 请求必须去重(inflight Promise 共享) +- [ ] 缓存按 `patientId` 隔离,切换患者时自动失效 +- [ ] `clearRequestCache()` 在 logout、切换患者时调用 + +### 规则 1.5 — 请求层错误处理 + +> **提交:** `fcce2f5` `4ca9027` - [ ] `catch` 块不允许完全静默(至少 `console.warn`) -- [ ] Token 刷新函数必须有死锁风险注释(提醒不要改用 `request()`) +- [ ] Token 刷新函数必须有死锁风险注释 - [ ] 网络超时/异常必须有用户友好提示(不显示原始 error message) +- [ ] 后端 `error_code` 必须有前端映射表(`ERROR_CODE_MAP`) --- @@ -98,39 +93,35 @@ function safeReLaunch(url: string): void { ### 规则 2.1 — 页栈溢出保护 -> **根因:** 微信 `navigateTo` 页栈上限 10 层,超出后报错 `navigateTo:fail`。 -> **案例:** 患者列表 → 详情 → 报告 → 化验 → 趋势 → 咨询 → ... → 第 10 层崩溃。 +> **根因:** 微信 `navigateTo` 页栈上限 10 层,超出 `navigateTo:fail`。 +> **案例:** 患者列表→详情→报告→化验→趋势→咨询→...→第 10 层崩溃。 +> **提交:** `59dd5ef` `74bffb4` - [ ] 所有 `Taro.navigateTo` 必须替换为 `safeNavigateTo` -- [ ] `safeNavigateTo` 实现:页栈 ≥ 9 时自动降级为 `redirectTo` +- [ ] `safeNavigateTo`:页栈 ≥ 9 时自动降级为 `redirectTo` - [ ] 新代码 review 时全局搜索 `Taro.navigateTo`,不应存在任何直接调用 -```typescript -const MAX_PAGE_STACK = 9; -export function safeNavigateTo(url: string): void { - const pages = Taro.getCurrentPages(); - if (pages.length >= MAX_PAGE_STACK) { - Taro.redirectTo({ url }); - } else { - Taro.navigateTo({ url }); - } -} -``` +### 规则 2.2 — 分包预加载与拆包策略 -### 规则 2.2 — 分包预加载 +> **根因:** 主包过大导致首次加载慢;单页分包浪费空间。 +> **案例:** consultation 原在主包,移出后主包减重。 +> **提交:** `59dd5ef` `4c38fcd` - [ ] 高频入口页必须配置 `preloadRule`(首页预加载核心分包) +- [ ] 单页分包合并(多个单页合并为一个分包减少开销) +- [ ] 医生端独立分包(`pkg-doctor-core` / `pkg-doctor-clinical`) - [ ] TabBar 页面必须在 `app.config.ts` 正确声明 -- [ ] 分包大小控制在微信限制内(主包 2MB,总包 20MB) ### 规则 2.3 — 页面生命周期防重入 -> **根因:** `useEffect` + `usePageData`(useDidShow) 双重初始化,导致页面数据加载两次。 -> **案例:** 分包页 navigateTo 后超时,因为双重 API 调用叠加。 +> **根因:** `useEffect` + `usePageData`(useDidShow) 双重初始化。 +> **案例:** 分包页 navigateTo 后超时,双重 API 调用叠加。 +> **提交:** `1fd2c7a` `59dd5ef` - [ ] 数据加载统一通过 `usePageData` 单次回调,不额外添加 `useEffect` 初始化 - [ ] 需要跳过首次 mount 的场景用 `mountedRef` 守卫 - [ ] 加载状态用 `loadingRef` 防止并发重复加载(不用 `useState`,避免异步竞态) +- [ ] `usePageData` 支持 `throttleMs` 防抖(默认 5000ms) --- @@ -138,94 +129,107 @@ export function safeNavigateTo(url: string): void { ### 规则 3.1 — 禁止在 render body 内定义组件 -> **根因:** 每次父组件 render 都会创建新的组件引用,React 认为是新组件,销毁旧实例重建新实例。 -> **案例:** `dialysis/create` 的 `InputField` 在 render 内定义,14 个 Input 每次 render 全部销毁重建,导致输入焦点丢失、键盘闪烁。 +> **根因:** 每次父组件 render 创建新的组件引用,React 销毁旧实例重建新实例。 +> **案例:** dialysis/create 的 InputField 在 render 内定义,14 个 Input 每次 render 全部销毁重建,输入焦点丢失。 +> **提交:** `fcce2f5` - [ ] 组件必须定义在函数组件外部(模块顶层) - [ ] 如需访问父组件状态,通过 props 传入(value/onChange 回调) -- [ ] Code review 时搜索 `const Xxx = () =>` 或 `function Xxx()` 出现在 return 之前、组件函数内部的模式 +- [ ] Code review 时搜索 `const Xxx = () =>` 出现在 return 之前、组件函数内部的模式 ```typescript -// ✅ 正确:组件定义在模块顶层 +// ✅ 正确:组件定义在模块顶层,通过 props 传值 function InputField({ label, value, onChange }: Props) { return ...; } -export default function MyForm() { - const [val, setVal] = useState(''); - return ; -} - // ❌ 错误:组件定义在 render body 内 export default function MyForm() { - const [val, setVal] = useState(''); - const InputField = ({ label, field }) => ( // 每次 render 重建! - ... - ); - return ; + const InputField = ({ label, field }) => ( ... ); // 每次 render 重建! } ``` ### 规则 3.2 — 禁止双重 ScrollView -> **根因:** 外层 PageShell 默认 `scroll=true`,内层再嵌套 `ScrollView scrollY`,两个滚动容器冲突。 -> **案例:** ai-report/list 页面滚动不流畅,触发双重滚动事件。 +> **根因:** PageShell 默认 `scroll=true`,内层再嵌套 `ScrollView scrollY`,两容器冲突。 +> **案例:** ai-report/list 页面滚动不流畅。 +> **提交:** `fcce2f5` - [ ] 使用内层 `ScrollView` 时,外层 `PageShell` 必须设置 `scroll={false}` - [ ] 或者不使用内层 `ScrollView`,只用 `PageShell` 自带滚动 -- [ ] Code review 时检查 `PageShell` 子树中是否包含 `ScrollView` ### 规则 3.3 — 图片懒加载 +> **提交:** `fcce2f5` + - [ ] 列表页、聊天消息中的 `` 必须添加 `lazyLoad` 属性 - [ ] 首屏可见图片不加 `lazyLoad`(避免延迟渲染) - [ ] 图片 URL 必须使用 HTTPS(微信强制要求) ### 规则 3.4 — 列表渲染优化 +> **提交:** `74bffb4` + - [ ] 长列表使用虚拟滚动或分页加载(`onScrollToLower`) - [ ] 渲染层消息上限(如 `MAX_RENDER_MESSAGES = 200`),超出截断并提示 - [ ] 状态层消息上限(如 `MAX_STATE_MESSAGES = 300`),防止内存增长 +### 规则 3.5 — 统一组件库使用 + +> **根因:** 早期页面各自用原生 View/Text 手写布局,60 页面风格不统一。 +> **案例:** 迁移了 66 页面到 PageShell + ContentCard + StatusTag + LoadingCard + SearchSection + PaginationBar。 +> **提交:** `80794c9` → `900c9ba`(12 次迁移提交) + +- [ ] 新页面必须使用统一组件库:`PageShell` / `ContentCard` / `StatusTag` / `LoadingCard` / `SearchSection` / `PaginationBar` / `EmptyState` / `ErrorState` +- [ ] 不允许在页面中手写 `.card` / `.list-item` 等重复的卡片样式 +- [ ] 需要新组件时先扩展现有组件(添加 prop),不要新建 +- [ ] 组件必须有独立的 `.scss` 文件,不允许在页面 `.scss` 中覆盖组件内部样式 + +### 规则 3.6 — React 导入 + +> **根因:** Taro 4.x 的 JSX transform 不自动注入 React,但某些组件需要 React 引用。 +> **案例:** 迁移组件库后运行时报 `React is not defined`。 +> **提交:** `1786f0d` + +- [ ] 使用 JSX 的文件必须显式 `import React from 'react'`(即使 Taro 4.x 有自动 transform) +- [ ] 组件使用 `React` API(如 `React.memo`、`React.useRef`)时必须有导入 +- [ ] SCSS 导入路径使用 `./index.scss`(相对当前组件),不使用 `@/components/...` 绝对路径到 scss + --- ## 4 数据与内存 ### 规则 4.1 — 数组必须有上限 -> **根因:** 未设上限的数组在长时间运行或高频数据场景下会无限增长。 -> **案例:** BLE 设备 readings 数组持续追加,长时间连接后占用大量内存。 +> **根因:** 未设上限的数组在长时间运行或高频数据场景下无限增长。 +> **案例:** BLE 设备 readings 数组持续追加,长时间连接后占大量内存。 +> **提交:** `fcce2f5` - [ ] 所有累积型数组必须设 MAX 常量(如 `MAX_LIVE_READINGS = 200`) - [ ] 追加时检查长度,超出时 `slice(-MAX)` 保留最新数据 -- [ ] 该规则适用于:设备数据缓存、聊天消息、日志队列等 - -```typescript -// ✅ 正确 -const MAX_LIVE_READINGS = 200; -const combined = [...this.readings, ...newReadings]; -this.readings = combined.length > MAX_LIVE_READINGS - ? combined.slice(-MAX_LIVE_READINGS) - : combined; - -// ❌ 错误 -this.readings = [...this.readings, ...newReadings]; // 无上限! -``` +- [ ] 适用于:设备数据缓存、聊天消息、日志队列等 ### 规则 4.2 — 去重索引与数据一致 > **根因:** `trimToMax` 丢弃旧数据后,去重索引 `seenKeys` 仍保留已丢弃数据的 key。 -> **案例:** DataBuffer 丢弃旧桶后 seenKeys 膨胀,且可能拒绝本应接受的新数据(key 碰撞)。 +> **案例:** DataBuffer seenKeys 膨胀,拒绝本应接受的新数据。 +> **提交:** `fcce2f5` - [ ] 数据裁剪时必须同步重建去重索引 - [ ] `flush()`/`clear()` 时必须清空索引 - [ ] 不要依赖 `Set` 自动清理——它不会自动 shrink -### 规则 4.3 — Storage 清理 +### 规则 4.3 — Storage 使用规范 -- [ ] `logout` 时必须清理所有业务相关 Storage key +> **根因:** Storage 滥用导致数据残留、切换用户数据泄漏。 +> **案例:** 用 Storage 传递页面间数据,导致详情页显示上一个患者的信息。 +> **提交:** `0bf1822` `3c828bf` + +- [ ] **禁止用 Storage 传递页面间数据** — 必须通过 API 获取或 URL 参数 +- [ ] logout 时必须清理所有业务相关 Storage key - [ ] 带动态 key 的缓存(如 `ble_buffer_{id}_{bucket}`)必须用 `getStorageInfoSync().keys` 遍历清理 - [ ] 模块级缓存变量(JS 内存中的 `let` 变量)也必须在 logout 时重置 +- [ ] 敏感数据(token)使用 `secure-storage`(加密),不使用 `Taro.setStorageSync` 明文存储 --- @@ -233,62 +237,188 @@ this.readings = [...this.readings, ...newReadings]; // 无上限! ### 规则 5.1 — 模块级缓存必须可清理 -> **根因:** `auth.ts` 顶层的 `cachedUserJson`/`cachedUserObj` 等变量在 logout 后不清除,导致 restore() 时恢复已登出的用户数据。 +> **根因:** `auth.ts` 顶层的缓存变量 logout 后不清除,restore() 恢复已登出用户数据。 > **案例:** 切换账号后看到上一个用户的头像和名称。 +> **提交:** `fcce2f5` -- [ ] 所有模块级缓存变量(`let` 声明的 JSON/对象缓存)必须在 logout 时重置 -- [ ] 重置为初始值(`''` / `null` / `[]` / `{}`) +- [ ] 所有模块级缓存变量(`let` 声明的 JSON/对象缓存)必须在 logout 时重置为初始值 - [ ] `restore()` 方法必须在开头读取最新缓存,不依赖残留值 - -```typescript -// logout 中必须包含: -cachedUserJson = ''; -cachedUserObj = null; -cachedRolesJson = ''; -cachedRolesObj = []; -cachedPatientJson = ''; -cachedPatientObj = null; -``` +- [ ] restore() 内部做变更检测,无变化时跳过 `set()` 避免不必要的重渲染 ### 规则 5.2 — Token 刷新安全 +> **根因:** `getHeaders()` 中做同步 Token 刷新预检查,所有请求被阻塞。 +> **案例:** getHeaders 中 `await tryRefreshToken()` 导致并发请求排队 30s。 +> **提交:** `9d50ef7` `447126b` + - [ ] `getHeaders()` 中不做同步 Token 刷新预检查(仅依赖 401 重试路径) - [ ] 刷新失败时必须清除所有认证相关 Storage - [ ] 刷新进行中用 Promise 去重,防止多个并发请求同时触发刷新 +- [ ] 刷新失败时设置 `isLoggingOut` 标记,防止后续请求反复尝试刷新 ### 规则 5.3 — 页面级认证恢复 +> **根因:** Tab 切换回来后 auth store 未恢复,页面显示为未登录状态。 +> **案例:** 首页 Tab 正常 → 切到咨询 Tab → 回到首页,用户信息消失。 +> **提交:** `6632985` `c314093` + - [ ] 所有 TabBar 页面必须在 `useDidShow` 中调用 `auth.restore()` - [ ] 子页面依赖 `usePageData` 自动恢复 - [ ] 不在 `useEffect` 中重复恢复(避免双重初始化) +- [ ] 退出登录后刷新必须清除 `isLoggingOut` 标记(`clearLoggingOut()`),否则重新登录后请求被拒 + +### 规则 5.4 — 角色与权限 + +> **根因:** 前端未区分医生/护士/患者角色,所有页面入口暴露给所有用户。 +> **案例:** 患者能看到医生端入口,点击后 403。 +> **提交:** `c38967a` `81c174a` `3dac6a9` + +- [ ] 医生端入口必须通过 `isDoctor()` / `isNurse()` 守卫 +- [ ] 页面组件使用 `useDoctorClass()` 切换样式模式 +- [ ] 精简菜单:未实现的模块入口必须移除,不显示空白页 --- -## 6 样式与布局 +## 6 前后端接口契约 -### 规则 6.1 — CSS 变量主题 +### 规则 6.1 — 字段对齐 + +> **根因:** 前端 TypeScript 接口与后端 DTO 字段名/类型不一致,运行时静默失败。 +> **案例:** 33 个接口端到端验证发现 8 个字段不对齐。 +> **提交:** `8316281` `c53f562` `c38967a` + +- [ ] 新增前端 service 函数时,必须对照后端 DTO 逐字段验证 +- [ ] 后端 DTO 变更时,必须同步更新前端 TypeScript 接口 +- [ ] 必填字段前端必须有默认值和校验 +- [ ] 日期字段统一为 `string`(`YYYY-MM-DD`),数值字段注意 `number | undefined` + +### 规则 6.2 — API 路径一致 + +> **根因:** 前端硬编码 API 路径,后端路由变更后前端 404。 +> **案例:** FHIR 端点从 `/fhir` 迁移到 `/api/v1/fhir`,前端请求 404。 +> **提交:** `4ca9027` `8316281` + +- [ ] API 路径使用 `process.env.TARO_APP_API_URL` + 相对路径,不硬编码 +- [ ] Service 层统一路径前缀,不在组件中直接拼接 URL +- [ ] 后端新增端点必须同步添加前端调用函数,不允许留空 + +### 规则 6.3 — 必填字段完整性 + +> **根因:** 前端提交表单缺少后端必填字段,导致 400 错误。 +> **案例:** submitRecord 缺少 `task_id` 字段,后端 CreateFollowUpRecordReq 拒绝。 +> **提交:** `fbb28e6` `603af83` + +- [ ] 表单提交前检查所有后端 Required 字段是否已填 +- [ ] 缺少字段时给出具体提示(如"缺少患者信息"),不是笼统的"操作失败" +- [ ] 隐式字段(如 `patient_id`、`version`)从路由参数或 store 获取,不依赖用户输入 + +--- + +## 7 样式与布局 + +### 规则 7.1 — CSS 变量主题 + +> **根因:** 硬编码 px 值和颜色号,无法统一调整。 +> **案例:** 68 个 SCSS 文件手动 px 值,修改字号需要改 68 个文件。 +> **提交:** `890c132` `551d19d` - [ ] 所有颜色、字号、间距使用 `var(--tk-*)` 设计 token - [ ] 不允许硬编码颜色值(如 `#333`、`rgb(0,0,0)`) - [ ] 深色模式通过 CSS 变量级联覆盖,不使用条件 class 切换 +- [ ] 新增 design token 在 `tokens.scss` 统一定义,不在页面中 `--custom-var` -### 规则 6.2 — 长者模式 +### 规则 7.2 — 长者模式 -- [ ] 所有页面字号 ≥ 22px(长者模式) -- [ ] 使用 `.elder-mode` CSS 变量覆盖,不逐元素调整 +> **根因:** 线性放大(所有 px 乘以 1.2)导致布局错乱。 +> **案例:** 长者模式字号过大,卡片内容溢出。 +> **提交:** `4335f7e` `257ca94` + +- [ ] 所有页面字号 ≥ 22px(长者模式),使用 `.elder-mode` CSS 变量覆盖 +- [ ] 非线性放大:标题放大比例 > 正文(如 h1: 1.3x, body: 1.15x) - [ ] 触摸目标 ≥ 44px +- [ ] 长者模式排除特定页面(如登录页,否则键盘遮挡) -### 规则 6.3 — 医生/患者双模式 +### 规则 7.3 — 医生/患者双模式 + +> **提交:** `95e219a` - [ ] 医生端使用 `.doctor-mode` 靛蓝覆盖 - [ ] 共用页面通过 `useDoctorClass()` / `useElderClass()` 切换 - [ ] 不为同一页面维护两套代码 +### 规则 7.4 — 对齐设计原型 + +> **根因:** 开发时凭感觉写 UI,与设计稿差距大。 +> **案例:** 文章列表/详情页原型对齐、首页渐变头部移除、Profile 积分卡片等分。 +> **提交:** `b8ce19f` `b84becf` `63d8b7a` `7b2c033` `6c42d54` `29d77e8` + +- [ ] 新页面开发前先看原型稿,记录关键字号/间距/颜色 +- [ ] 完成后截图与原型对比(不是"差不多",是像素级) +- [ ] 组件变体(如 SegmentTabs pill)样式必须与原型一致 +- [ ] 卡片布局必须用 ContentCard,不手写 div 模拟 + +### 规则 7.5 — 状态色统一 + +> **根因:** 各页面自行定义状态颜色(绿色/红色/黄色),色调不一致。 +> **提交:** `4a95a83` + +- [ ] 状态色必须对齐设计系统色板(成功绿/警告黄/危险红/信息蓝) +- [ ] 使用 StatusTag 组件显示状态,不自行拼写 class 名 +- [ ] CSS 变量定义一次,全局引用 + --- -## 7 错误处理与日志 +## 8 安全 -### 规则 7.1 — 生产日志保留策略 +### 规则 8.1 — XSS 防护 + +> **根因:** AI 报告内容直接用 `dangerouslySetInnerHTML` 渲染,未过滤。 +> **案例:** AI 分析结果注入 `