fix(mp): DevTools 卡死 + 主包 2MB→766KB + 代码质量 4 项全通过

根因:主包 2MB 全量组件注入导致 DevTools 渲染引擎内存渐增,
叠加离线时固定 3s 抑制期后的请求洪泛。

修复:
- app.config.ts 添加 lazyCodeLoading: requiredComponents
  主包 2.0MB→766KB,taro.js 526→131KB,vendors.js 230→28KB
- request.ts 离线抑制改为指数退避(3s→6s→12s→30s cap)
  后端不可达时自动延长抑制,防止请求风暴
- SegmentTabs Tab 接口改为 readonly,修复 TS 编译错误
- AbortController polyfill 补齐小程序运行时缺失
- 健康首页/设备同步/健康档案/报告/设置页 UI 重构
- 文章页公开端点适配游客访问
- 健康首页 Swiper 间隔优化 4s→5s,动画 500→300ms
This commit is contained in:
iven
2026-05-24 11:32:40 +08:00
parent 675f8a4b10
commit 1e59007bd5
58 changed files with 4950 additions and 494 deletions

View File

@@ -0,0 +1,97 @@
# HMS V3 Beta 多学科综合测试报告 — 执行摘要
> 测试日期: 2026-05-21 | 分支: feat/media-library-banner
> 测试团队: 5 个专家团队并行Web功能 / 性能兼容 / 小程序 / API / 静态分析)
> 报告版本: v1.0
## 1. 测试范围与方法
| 维度 | 方法 | 工具 |
|------|------|------|
| Web 前端功能 | 核心业务流程操作 + 边缘场景 | chrome-devtools MCP |
| Web 性能/兼容性 | Lighthouse + Core Web Vitals + 5 种视口 | chrome-devtools MCP |
| 小程序功能 | 5 Tab 页 + 核心功能 + API 验证 | weapp-local MCP |
| API 端点 | 69 个测试用例CRUD/权限/注入/边界值) | curl/Bash |
| 静态代码分析 | TypeScript 类型/安全/性能反模式 | Grep/Read/Bash |
## 2. 总体评估
| 指标 | 值 |
|------|-----|
| **综合质量评级** | **B- (6.5/10)** |
| **测试总项数** | **248 项**(功能 54 + 性能 26 + API 69 + 静态 99+ |
| **综合通过率** | **78.2%** |
| **发现问题总数** | **36 个** |
| **CRITICAL** | **4 个** |
| **HIGH** | **8 个** |
| **MEDIUM** | **15 个** |
| **LOW** | **9 个** |
## 3. 关键发现
### CRITICAL阻塞 Beta 发布)
| ID | 来源 | 问题 | 影响 |
|----|------|------|------|
| C-01 | 小程序 | `inject_auth` 写明文键,`request.ts` 只读加密键,所有 API 无 token | 小程序所有认证功能不可用 |
| C-02 | 小程序 | `secure-storage.ts` UTF-16 截断中文,加密存储后解密损坏 | 用户数据(含中文名)存储失败 |
| C-03 | Web 兼容 | 移动端 375px 表格不可用,无响应式替代布局 | 移动端用户完全无法操作 |
| C-04 | Web 兼容 | 移动横屏 812x375 内容区域空白 | 横屏模式页面无法使用 |
### HIGH影响核心业务流程
| ID | 来源 | 问题 | 影响 |
|----|------|------|------|
| H-01 | Web 功能 | 患者创建表单缺少前端必填校验,空表单提交成功 | 脏数据进入系统 |
| H-02 | Web 功能 | 预约列表 API 网络连接异常,无数据显示 | 预约管理不可用 |
| H-03 | Web 兼容 | 平板 768px 表格数据不加载 | 平板端不可用 |
| H-04 | Web 性能 | 患者列表 LCP 2643msrender delay 99.8% | 页面加载慢 |
| H-05 | Web 性能 | 仪表盘 API 每个端点重复调用 4 次 | 不必要的网络/服务器负载 |
| H-06 | API | 健康数据 DTO-Entity 映射断裂,测量值全存 null通过率 20% | 日常监测功能实质失效 |
| H-07 | API | 500 字符文章标题导致 HTTP 500 内部错误 | 应返回 400 验证错误 |
| H-08 | 静态分析 | Web 前端 10+ 处 `.catch(() => {})` 静默吞错 | 错误不可追踪 |
## 4. 各维度通过率
| 测试域 | 通过率 | 评级 |
|--------|--------|------|
| API 端点69 项) | 82.6% | B |
| 小程序 UI 渲染38 项) | 100% | A |
| 小程序功能(应用内 3 项) | 0% | Ftoken 问题) |
| 小程序功能API 直测 4 项) | 100% | A |
| Web 前端功能8 大领域) | 62.5%5/8 完全通过) | B- |
| Lighthouse Desktop | 94/100/100 | A |
| Lighthouse Mobile | 94/100/100 | A |
| Web Desktop 视口 | PASS | A |
| Web Tablet 视口 | FAIL | D |
| Web Mobile 视口 | FAIL | F |
## 5. 发布就绪度判定
### 结论: **CONDITIONAL BETA** — 需修复 4 个 CRITICAL + 3 个 HIGH 后可发布
### 阻塞项(必须修复,预计 3-4 天)
1. **C-01/C-02 小程序 token/加密问题** — 统一 `safeGet` fallback + 修复 UTF-8 编码(预计 3h
2. **C-03/C-04 移动端响应式** — 添加卡片视图 + 修复 768px 断点(预计 2d
3. **H-01 患者表单验证** — 前端添加 `form.validateFields()`(预计 1h
4. **H-06 健康数据 DTO 映射** — 修复字段映射(预计 4h
5. **H-07 文章标题 500 错误** — 添加 DTO 长度校验(预计 30min
### 建议项Beta 后迭代,预计 5-7 天)
- M-01~M-05: 对比度/暗色模式/API 校验/XSS/搜索等
- L-01~L-09: 弃用警告/i18n/内联样式等
## 6. 报告索引
| 章节 | 文件 |
|------|------|
| 执行摘要(本文档) | `01-executive-summary.md` |
| Web 前端功能测试 | `02-web-functional.md` |
| Web 性能与兼容性测试 | `03-web-perf-compat.md` |
| 小程序功能测试 | `04-miniprogram.md` |
| API 深度测试 | `05-api-deep-test.md` |
| 静态代码分析 | `06-static-analysis.md` |
| 跨部门头脑风暴 | `07-brainstorm.md` |
| Beta 就绪验收清单 | `08-beta-checklist.md` |

View File

@@ -0,0 +1,119 @@
# Web 前端核心业务功能测试
> 测试工具: chrome-devtools MCP | 环境: Chrome, 1920x1080
> 测试账号: admin / Admin@2026 | 截图: `docs/qa/screenshots/`
## 1. 登录流程 — PASS
| 测试项 | 结果 | 说明 |
|--------|------|------|
| 登录页渲染 | PASS | 双栏布局,品牌信息完整 |
| 登录后跳转 | PASS | 跳转至工作台 `/#/` |
| 侧边栏菜单 | PASS | 7 个一级菜单加载(工作台/患者中心/随访关怀/健康监测/运营管理/AI助手/系统管理) |
| 用户信息显示 | PASS | 右上角"系统管理员" + 头像 |
| 权限不足页面 | PASS | 403 页面清晰,含返回首页按钮 |
| XSS 安全 | PASS | SQL 注入测试数据 `Robert"); DROP TABLE patients;--` 正确转义显示 |
## 2. 患者管理 — FAIL (2 issues)
| 测试项 | 结果 | 说明 |
|--------|------|------|
| 患者列表加载 | PASS | 136 条记录7 页分页 |
| 分页切换 | PASS | 第 2 页数据正确 |
| 创建表单打开 | PASS | 4 个分组(基本信息/联系方式/医疗信息/紧急联系人) |
| 编辑表单 | PASS | 预填充已有数据 |
| **空表单提交** | **FAIL** | 空表单成功提交创建患者(后端有校验但前端未拦截) |
| **搜索功能** | **FAIL** | 输入 "Test" 搜索后列表仍显示全部 136 条 |
### H-01: 患者创建表单缺少前端必填校验
- **严重性:** HIGH
- **证据:** 点击"保存"空表单后审计日志显示"创建 了 患者"
- **根因:** Ant Design Form 未配置 `rules: [{ required: true }]` 或未调用 `form.validateFields()`
- **修复:** 在 `PatientList.tsx` 的 DrawerForm 中添加 `rules` 配置,提交前调 `form.validateFields()`
- **预计工时:** 1h
### M-01: 患者搜索不生效
- **严重性:** MEDIUM
- **证据:** 搜索框输入 "Test" + 回车,列表无变化
- **根因:** 搜索框 `keyword` 参数可能未正确传递到 API 请求
- **修复:** 检查搜索输入与 API 参数绑定
- **预计工时:** 2h
## 3. 健康数据/实时监控 — PASS
| 测试项 | 结果 | 说明 |
|--------|------|------|
| 实时监控页 | PASS | 危急/高危/中等/低危告警计数正确 |
| 告警面板 | PASS | 1 个高危患者活跃告警 |
| 告警列表 | PASS | 5 条告警记录,状态/严重程度正确 |
| 筛选功能 | PASS | 患者下拉框存在 |
## 4. 预约管理 — FAIL (1 issue)
| 测试项 | 结果 | 说明 |
|--------|------|------|
| 预约列表页渲染 | PASS | 表头正确(患者/医护/类型/日期/时段/状态/创建时间/备注/操作) |
| **预约数据** | **FAIL** | 表格显示 "No data" + "网络连接异常,请检查网络" |
| 新建预约按钮 | PASS | 按钮可见 |
### H-02: 预约列表 API 网络连接异常
- **严重性:** HIGH
- **证据:** 页面显示"网络连接异常"No data"同时出现
- **根因:** 可能是后端 API 错误或前端 API 路径不匹配
- **修复:** 检查 `/api/v1/health/appointments` 端点状态和前端 API 路径
- **预计工时:** 2h
## 5. 咨询管理 — PASS
| 测试项 | 结果 | 说明 |
|--------|------|------|
| 咨询列表加载 | PASS | 18 条咨询记录 |
| 状态显示 | PASS | 已关闭/进行中/等待中正确 |
| 操作按钮 | PASS | 进行中的会话显示"关闭"按钮 |
| 未读消息计数 | PASS | 患者端/医护端分别显示 |
| 筛选/导出 | PASS | 状态筛选、日期范围、导出按钮均存在 |
## 6. 工作台/仪表盘 — PASS_WITH_ISSUES
| 测试项 | 结果 | 说明 |
|--------|------|------|
| 工作台首页 | PASS | 6 大状态卡片 + 统计 + 模块状态 + 活跃度 |
| 系统状态 | PASS | PostgreSQL/API/定时任务/文件存储/消息队列/缓存 全绿 |
| 统计数据 | PASS | 注册用户 27/今日活跃 4/本周 9/月活 18 |
| 最近操作 | PASS | 实时显示登录/创建/删除操作 |
| 通知面板 | PASS | 危急值告警和待办事项正常 |
| 侧边栏折叠 | PASS | 折叠后仅图标,悬停展开子菜单 |
| **Admin Dashboard** | **FAIL** | `/#/health/admin-dashboard` 显示 403 |
### M-02: Admin Dashboard URL 直接访问 403
- **严重性:** MEDIUM
- **说明:** AdminDashboard 组件存在但路由未注册,该页面可能仅作为工作台内嵌组件使用
- **修复:** 移除直接访问路径或正确注册路由并配置权限
- **预计工时:** 1h
## 7. 主题切换 — PASS (4/4)
| 主题 | 结果 | 说明 |
|------|------|------|
| 信任蓝(默认) | PASS | 蓝色系侧边栏 |
| 深邃夜色 | PASS | 深色侧边栏和页头 |
| 翡翠清雅 | PASS | 绿色系 |
| 温润东方 | PASS | 暖色调 |
| 持久化 | PASS | localStorage `hms-theme` 保存,刷新后保持 |
## 8. 控制台警告
| 类型 | 消息 | 严重性 |
|------|------|--------|
| WARN | `[antd: Drawer] width is deprecated. Please use size instead.` | LOW |
| WARN | `[antd: List] component is deprecated. And will be removed in next major version.` | LOW |
## 小结
- **完全通过领域:** 5/8登录/健康数据/咨询/工作台/主题)
- **存在问题领域:** 3/8患者管理/预约/仪表盘路由)
- **HIGH 问题:** 2 个 | **MEDIUM 问题:** 2 个 | **LOW 问题:** 2 个

View File

@@ -0,0 +1,175 @@
# Web 前端性能与兼容性测试
> 测试工具: chrome-devtools MCP (Lighthouse + Performance Trace + Emulate)
> 截图: `g:\hms\screenshots/` | 追踪: `g:\hms\trace-*.json`
## 1. Lighthouse 审计
### 1.1 Desktop (Navigation)
| 类别 | 分数 | 状态 |
|------|------|------|
| Accessibility | **94** | GOOD |
| Best Practices | **100** | PERFECT |
| SEO | **100** | PERFECT |
| Agentic Browsing | **61** | NEEDS_WORK |
**失败审计项 (4):**
1. CLS 0.127 超过 0.1 阈值Desktop 有Mobile 无)
2. 浅色模式 `#94a3b8` 灰色文字在白底上对比度 2.56:1需 4.5:1
3. h1 后直接跳 h3缺少 h2 层级
4. llms.txt 文件缺少 H1 标题和链接
### 1.2 Mobile (Navigation)
| 类别 | 分数 | 状态 |
|------|------|------|
| Accessibility | **94** | GOOD |
| Best Practices | **100** | PERFECT |
| SEO | **100** | PERFECT |
| Agentic Browsing | **67** | NEEDS_WORK |
**失败项与 Desktop 相同**color-contrast + heading-order + llms-txt。Mobile CLS 为 0 通过。
### 1.3 Dark Mode (Snapshot)
| 类别 | 分数 | 下降 |
|------|------|------|
| Accessibility | **92** | -2 |
| Best Practices | **100** | — |
| SEO | **80** | -20 |
**Dark Mode 额外问题:**
- 侧边栏菜单项对比度不足4.39:1 / 3.95:1 / 4.45:1均未达 4.5:1
- 表单元素缺少 `label` 关联
- 分页链接不可爬取
## 2. Core Web Vitals
### 2.1 工作台Dashboard
| 指标 | 值 | 评级 |
|------|-----|------|
| **LCP** | **1381ms** | NEEDS IMPROVEMENT |
| **CLS** | **0.04** | GOOD |
| **TTFB** | **6ms** | GOOD |
| DOM 大小 | 311 elements | GOOD |
| DOM 深度 | 13 层 | GOOD |
**LCP 瓶颈:** TTFB 6ms (0.4%) + Render Delay **1375ms (99.6%)**
**CLS 根因:** Noto Sans SC 字体从 Google Fonts 加载导致 FOUT5 个 woff2 文件
### 2.2 患者列表
| 指标 | 值 | 评级 |
|------|-----|------|
| **LCP** | **2643ms** | NEEDS IMPROVEMENT |
| **CLS** | **0.01** | GOOD |
| **TTFB** | **4ms** | GOOD |
| DOM 大小 | 944 elements | MODERATE |
**LCP 瓶颈:** TTFB 4ms (0.2%) + Render Delay **2639ms (99.8%)**
**强制回流:** 总计 **460ms**
- `measureScrollbarSize` (antd): 341ms + 43ms
- `setScaleParam` (antd): 76ms
- 全部来自 Ant Design 表格组件内部
## 3. 多视口兼容性
### 3.1 Desktop 1920×1080 — PASS
- 侧边栏展开,菜单完整
- 表格完整显示
- **注意:** 仪表盘出现"网络连接异常"错误提示
### 3.2 Laptop 1366×768 — PASS
- 侧边栏正常展开
- 患者表格完整,分页器可见
- 筛选栏全部可见
### 3.3 Tablet iPad 768×1024 — **FAIL (HIGH)**
- 侧边栏折叠为仅图标模式
- **面包屑显示"页面"而非实际名称**
- **表格数据完全未加载** — 主内容区只有头部和筛选栏,表格区域为空
- 评级: **H-03**
### 3.4 Mobile iPhone 375×812 — **FAIL (CRITICAL)**
- 侧边栏展开覆盖全屏
- 8 列数据在 375px 宽度严重挤压
- 出现 3 条错误消息("网络连接异常" + 2×"加载数据失败"
- 操作按钮edit/delete极小触摸目标不足 44px
- 评级: **C-03** — 应提供卡片视图替代
### 3.5 Mobile Landscape 812×375 — **FAIL (CRITICAL)**
- **内容区域完全空白** — main 区域只有 loading/busy 状态
- 面包屑显示"页面"
- 评级: **C-04**
## 4. Dark Mode 对比度问题
### 4.1 侧边栏低对比度
| 元素 | 对比度 | 标准 |
|------|--------|------|
| 跳转链接 / H logo | 4.07:1 | 需 4.5:1 |
| 患者中心 | 4.39:1 | 需 4.5:1 |
| 患者管理 | 3.95:1 | 需 4.5:1 |
### 4.2 系统管理卡片浅色背景Dark Mode 下不协调)
| 元素 | 对比度 | 背景 |
|------|--------|------|
| 运行中 | 3.15:1 | 浅绿 |
| 菜单管理 | 3.84:1 | 浅蓝 |
| 系统配置 | 3.07:1 | 浅黄 |
**根因:** 系统管理区块在 Dark Mode 下仍使用浅色背景,未跟随主题切换。
## 5. 网络请求分析
### 5.1 API 重复调用
仪表盘每个端点被调用 **4 次**:
| 端点 | 调用次数 |
|------|---------|
| `/health/admin/statistics/patients` | ×4 |
| `/health/admin/statistics/consultations` | ×4 |
| `/health/admin/statistics/follow-ups` | ×4 |
| `/health/admin/points/statistics` | ×4 |
| `/health/admin/statistics/health-data` | ×4 |
| `/health/admin/statistics/dialysis` | ×4 |
| `/health/doctors` | ×4 |
| `/menus/user` | ×4 |
| `/config/themes` | ×4 |
| `/health/action-inbox` | ×4 |
**根因:** 可能来自 React Strict Mode 双重渲染 + 组件重复挂载
**评级:** H-05
### 5.2 第三方资源
| 资源 | 大小 | 影响 |
|------|------|------|
| Google Fonts (Noto Sans SC) | 1.3 MB | 最大外部资源,导致 CLS |
## 6. 问题汇总
| ID | 严重性 | 问题 | 修复建议 | 工时 |
|----|--------|------|----------|------|
| C-03 | CRITICAL | Mobile 375px 表格不可用 | 添加 `<768px` 卡片视图 | 2d |
| C-04 | CRITICAL | Mobile 横屏内容空白 | 修复 812×375 路由加载 | 4h |
| H-03 | HIGH | Tablet 768px 数据不加载 | 修复断点 + 侧边栏同步 | 4h |
| H-04 | HIGH | 患者列表 LCP 2643ms | 字体预加载 + 虚拟滚动 | 1d |
| H-05 | HIGH | 仪表盘 API ×4 重复调用 | 检查 useEffect 依赖 | 4h |
| M-03 | MEDIUM | 浅色模式 #94a3b8 对比度 | 改为 #64748b | 30min |
| M-04 | MEDIUM | Dark Mode 系统管理卡片 | 深色背景变体 | 4h |
| M-05 | MEDIUM | Antd 表格 reflow 460ms | 固定 scroll.x/y | 2h |
| M-06 | MEDIUM | Noto Sans SC 1.3MB CLS | font-display: optional | 1h |
| M-07 | MEDIUM | 面包屑显示"页面" | 修复 tablet/mobile 路由名 | 1h |
| L-01 | LOW | heading-order h1→h3 | 插入 h2 或 aria-level | 30min |
| L-02 | LOW | 表单元素缺 label | 添加 aria-label | 1h |
| L-03 | LOW | antd Drawer width 弃用 | 迁移到 size 属性 | 30min |

View File

@@ -0,0 +1,146 @@
# 小程序功能测试报告
> 测试工具: weapp-local MCP | 环境: 微信开发者工具, iPhone 12/13 Pro 模拟器
> iOS 10.0.1, 390×844 | 分支: feat/media-library-banner
## 1. 连接与认证
| 项目 | 结果 | 说明 |
|------|------|------|
| MCP 连接 | PASS | ws://localhost:9420 连接成功 |
| inject_auth | PASS_WITH_ISSUES | 报告"注入成功"但存在集成问题C-01 |
| Auth 手动恢复 | PASS | 通过 `__hms` bridge 手动 restoreAuth 成功 |
## 2. Tab 页面测试
### 2.1 首页 (pages/index/index) — PASS
| 检查项 | 结果 | 详情 |
|--------|------|------|
| 问候语 | PASS | "晚上好,系统管理员" + "5月21日周四" |
| 消息铃铛 | PASS | 可点击 |
| 签到卡片 | PASS | 进度环 0%4 个 capsule血压/心率/血糖/体重) |
| 今日体征 | PASS | 4 张卡片,值"---",标签"未记录" |
| 操作按钮 | PASS | "记录体征" + "预约挂号" |
| SOS 按钮 | PASS | 存在 |
| 访客模式 | PASS | 未登录显示轮播图 + 健康资讯 + 注册 CTA |
| Console 错误 | PASS | 无 |
### 2.2 健康 Tab (pages/health/index) — PASS_WITH_ISSUES
| 检查项 | 结果 | 详情 |
|--------|------|------|
| 页面加载 | PASS | 分段选项卡(血压/心率/血糖/体重) |
| 录入表单 | PASS | 收缩压+舒张压输入框 + 参考范围提示 |
| 趋势图 | PASS | 空状态"暂无趋势数据"正确显示 |
| **保存功能** | **FAIL** | 日志 `[health] 保存体征数据失败: {}`C-01 |
### 2.3 助手 Tab / AI 聊天 (pages/messages/index) — PASS
| 检查项 | 结果 | 详情 |
|--------|------|------|
| 页面加载 | PASS | 标题"健康助手 . 小华" |
| 在线状态 | PASS | 绿色圆点 + "24小时在线" |
| 输入框 | PASS | placeholder "输入您的问题..." |
| 发送按钮 | PASS | 存在,无输入时 disabled |
### 2.4 我的 Tab (pages/profile/index) — PASS
| 检查项 | 结果 | 详情 |
|--------|------|------|
| 用户卡片 | PASS | 头像"系" + "系统管理员" |
| 统计数据 | PASS | 健康积分 0 + 连续打卡 0 天 |
| 功能菜单 | PASS | 5 大分组 17 个菜单项完整 |
| 退出登录 | PASS | 红色按钮存在 |
| Console 错误 | PASS | 无 |
### 2.5 商城 Tab — 不在 TabBar 内,需导航访问
## 3. 非 Tab 页面测试
### 3.1 积分商城 (pages/mall/index) — PASS_WITH_ISSUES
| 检查项 | 结果 | 详情 |
|--------|------|------|
| 页面加载 | PASS | 积分头部 + 签到按钮 |
| 空状态 | PASS | "暂无商品" + "更多好物即将上架" |
| **签到功能** | **FAIL** | 日志 `[points] 签到失败: {}`C-01 |
### 3.2 咨询列表 (pages/consultation/index) — PASS_WITH_ISSUES
| 检查项 | 结果 | 详情 |
|--------|------|------|
| 页面导航 | PASS | 成功导航 |
| 骨架屏 | PASS | 4 个 loading card |
| **数据加载** | **FAIL** | 永久 loading 状态无超时提示C-01 + BUG-03 |
## 4. 核心功能 API 验证(绕过小程序 request 层)
| API | 方法 | 结果 | 详情 |
|-----|------|------|------|
| 积分账户 | GET /health/points/account | **PASS** | 余额 40总获得 50总消费 10 |
| 血压保存 | POST /health/patients/{id}/vital-signs | **PASS** | 200返回完整记录 |
| 每日签到 | POST /health/points/checkin | **PASS** | 200checked_in_today=true连续 2 天 |
| 咨询列表 | GET /health/consultation-sessions | **PASS** | 2001 条 active 会话 |
**结论:** 后端 API 全部正常,所有功能性问题源于小程序端 token 读取。
## 5. BUG 详细分析
### C-01: inject_auth 与 request.ts 的 storage 键不匹配
- **严重性:** CRITICAL
- **文件:** `services/request.ts:23-29`
- **现象:** `inject_auth` 写入明文键(`access_token``request.ts``safeGet()` 只调用 `secureGet()`(读 `_es_` 前缀加密键),不 fallback 到明文键
- **根因:** `safeGet``secureGet` 返回空字符串时不 fallback空字符串不抛异常只在 catch 中 fallback。而 `auth.ts``storageGet``secureGet` 返回 falsy 时正确 fallback
- **影响:** 所有需要认证的功能不可用(体征保存、签到、咨询、数据加载)
- **修复:** 统一 `safeGet``storageGet` 的 fallback 逻辑,或让 `inject_auth` 写入加密键
- **预计工时:** 1h
### C-02: secure-storage.ts UTF-16 截断中文字符
- **严重性:** CRITICAL
- **文件:** `utils/secure-storage.ts:13-23`
- **现象:** `toBase64` 使用 `Uint8Array` 截断 UTF-16 高位字节
- **根因:** `charCodeAt` 返回的 UTF-16 编码值超过 255 时被截断为 8 位
- **影响:** 任何含中文的数据(如 `display_name`="系统管理员")经 encrypt-decrypt 循环后损坏,`JSON.parse` 失败
- **修复:** 使用 `TextEncoder`/`TextDecoder` 进行 UTF-8 编解码
- **预计工时:** 2h
### BUG-03: 咨询列表无超时处理
- **严重性:** MEDIUM
- **文件:** `pages/consultation/index`
- **现象:** API 失败时无用户反馈,页面永远显示骨架屏
- **修复:** 添加加载超时和错误状态 UI
- **预计工时:** 1h
### BUG-04: 错误日志输出空对象
- **严重性:** MEDIUM
- **现象:** 签到/体征保存失败时 `catch` 输出 `{}`
- **修复:** 使用 `JSON.stringify(err, Object.getOwnPropertyNames(err))` 输出完整错误
- **预计工时:** 30min
## 6. 测试统计
| 类别 | 测试项 | PASS | FAIL | PASS_WITH_ISSUES |
|------|--------|------|------|------------------|
| 连接与认证 | 3 | 1 | 0 | 2 |
| Tab 页面 | 4 | 3 | 0 | 1 |
| 非 Tab 页面 | 2 | 0 | 0 | 2 |
| UI 元素 | 38 | 38 | 0 | 0 |
| 核心功能API 直测) | 4 | 4 | 0 | 0 |
| 核心功能(应用内) | 3 | 0 | 3 | 0 |
| **合计** | **54** | **46** | **3** | **5** |
**UI 渲染通过率:** 100% (38/38)
**API 直测通过率:** 100% (4/4)
**应用内功能通过率:** 0% (0/3) — 全部因 C-01 失败
**综合通过率:** 85.2% (46/54)
## 7. 评价
**UI 层质量:** 优秀A 级)— 所有页面正确渲染,空状态处理完善,设计系统一致性好。
**功能层质量:** 失败F 级)— 但根因集中在一个 CRITICAL 问题C-01 token 读取),修复后预计 100% 通过。后端 API 经独立验证全部正常。

View File

@@ -0,0 +1,159 @@
# API 端点深度测试报告
> 测试工具: curl/Bash | 环境: http://localhost:3000
> 测试账号: admin / Admin@2026 (完整权限) | 总用例: 69
## 1. 模块通过率汇总
| 模块 | 测试数 | 通过 | 失败 | 通过率 |
|------|--------|------|------|--------|
| 认证与权限 | 8 | 8 | 0 | **100%** |
| 患者 CRUD | 11 | 10 | 1 | **90.9%** |
| 患者分页/注入 | 7 | 5 | 2 | **71.4%** |
| 患者删除 | 2 | 2 | 0 | **100%** |
| 健康数据 | 5 | 1 | 4 | **20%** |
| 预约系统 | 7 | 7 | 0 | **100%** |
| 咨询管理 | 9 | 7 | 2 | **77.8%** |
| 内容管理 | 13 | 10 | 3 | **76.9%** |
| 通用/跨切面 | 7 | 7 | 0 | **100%** |
| **总计** | **69** | **57** | **12** | **82.6%** |
## 2. 认证与权限 — 100% PASS
| ID | 测试 | 结果 |
|----|------|------|
| AUTH-01 | 错误密码 | PASS — `message=未授权` |
| AUTH-02 | 不存在的用户 | PASS — `message=未授权` |
| AUTH-03 | 无 Token 访问 | PASS — HTTP 401 |
| AUTH-04 | 无效 Token | PASS — HTTP 401 |
| AUTH-05 | 空 body 登录 | PASS — 429 限流触发 |
| AUTH-06 | SQL 注入 (`' OR 1=1 --`) | PASS — 无数据泄漏 |
| AUTH-07 | 超长密码 (10000 字符) | PASS — 429 限流触发 |
| AUTH-08 | 有效 Token | PASS — 200 + data |
**亮点:** 限流机制有效,登录端点不泄漏信息(统一返回"未授权"SQL 注入被正确处理。
## 3. 患者 CRUD — 90.9% PASS
| ID | 测试 | 结果 | 说明 |
|----|------|------|------|
| PATIENT-01 | 空名称创建 | PASS | `400: 患者姓名不能为空` |
| PATIENT-02 | 500 字符名称 | PASS | `400: 长度不能超过255` |
| PATIENT-03 | 未来出生日期 (2099) | PASS | `400: 出生日期不能是未来日期` |
| PATIENT-04 | XSS in name (`<script>`) | **FAIL** | HTTP 200, 存储原值 |
| PATIENT-05 | 无效 gender | PASS | `400: 不是有效值` |
| PATIENT-06 | 有效创建 | PASS | success, version=1 |
| PATIENT-14 | 按 ID 查询 | PASS | success |
| PATIENT-15 | 不存在的 ID | PASS | `404: 患者不存在` |
| PATIENT-16 | 有效更新 | PASS | version=2 |
| PATIENT-17 | 乐观锁冲突 | PASS | `409: 版本冲突` |
| PATIENT-18 | 未来日期更新 | PASS | `400: 出生日期不能是未来日期` |
### PATIENT-04: XSS 存储未消毒 (MEDIUM)
- `<script>alert(1)</script>` 直接存入 name 字段
- 前端 React 默认转义,但建议服务端也做消毒
- **修复:** 添加 HTML sanitize 或正则剥离标签
### 患者分页/注入测试
| ID | 测试 | 结果 |
|----|------|------|
| PATIENT-10 | limit=10000 | **FAIL (LOW)** — 无上限,可能导致性能问题 |
| PATIENT-12 | SQL 注入 in search | **FAIL (MEDIUM)** — 连接错误 (HTTP 000) |
## 4. 健康数据 — 20% PASS (最差模块)
| ID | 测试 | 结果 | 说明 |
|----|------|------|------|
| HEALTH-01 | 极端血压 (0/0) | **FAIL** | HTTP 200值存为 null |
| HEALTH-02 | 极端心率 (999) | **FAIL** | HTTP 200值存为 null |
| HEALTH-03 | 负值 (-10) | **FAIL** | HTTP 200值存为 null |
| HEALTH-04 | 无效 UUID | PASS | `422: UUID parsing failed` |
| HEALTH-05 | 未来日期 (2099) | **FAIL** | HTTP 200记录被创建 |
### H-06: 日常监测 DTO-Entity 映射断裂 (HIGH)
**这是本次测试发现的最严重的后端问题。**
- **现象:** API 接受 `indicator_type``value``systolic``diastolic` 等字段但静默忽略,创建的记录所有测量字段为 null
- **根因:** DTO 字段与 Entity 列名不匹配。DTO 使用 `systolic`/`diastolic`Entity 期望 `morning_bp_systolic`/`morning_bp_diastolic`
- **影响:** 日常监测功能实质失效 — 小程序录入的体征数据无法正确存储
- **修复:** 重构 DTO 字段映射,或统一 DTO/Entity 字段命名
- **预计工时:** 4h
**同时发现:** 无值范围校验(血压 0、心率 999 被接受)、未来 record_date 无校验。
## 5. 预约系统 — 100% PASS
| ID | 测试 | 结果 |
|----|------|------|
| APPOINT-01 | 列表查询 | PASS |
| APPOINT-02 | 空 doctor_id | PASS — 422 |
| APPOINT-03 | 无效 UUID | PASS — 422 |
| APPOINT-04 | 不存在的预约 | PASS — 404 |
| APPOINT-05 | page=0 | PASS |
| APPOINT-11 | 排班已满 | PASS — `400: 排班已满` |
| APPOINT-12 | 重复预约 | PASS — `400: 排班已满` |
**亮点:** UUID 校验、容量检查、404 处理全部正确。
## 6. 咨询管理 — 77.8% PASS
| ID | 测试 | 结果 |
|----|------|------|
| CONSULT-02 | 空描述创建 | **FAIL (LOW)** — 接受空描述 |
| CONSULT-05 | XSS in description | **FAIL (MEDIUM)** — XSS 存储原值 |
| CONSULT-06~09 | 评分范围 1-5 | **PASS** — 校验完善 |
**亮点:** 评分校验优秀1-5 范围 + 只能评已关闭会话)。
## 7. 内容管理 — 76.9% PASS
| ID | 测试 | 结果 |
|----|------|------|
| ARTICLE-04 | 500 字符标题 | **FAIL (HIGH)** — HTTP 500 内部错误 |
| CATEGORY-02 | 空分类名称 | **FAIL (MEDIUM)** — 接受空名称 |
| TAG-04 | 重复标签名 | **FAIL (LOW)** — 允许重复 |
### ARTICLE-04: 500 字符标题导致 500 错误 (HIGH)
- **现象:** 500 字符文章标题返回 HTTP 500 Internal Server Error
- **根因:** DTO 缺少 `#[validate(length(max=255))]`,数据库列长度约束违反导致未处理的 DB 错误
- **修复:** 添加 DTO 长度校验 + 全局 DB 错误映射
- **预计工时:** 30min
### CATEGORY-02: 空分类名称被接受 (MEDIUM)
- 文章标题有空校验,标签名称有空校验,但分类名称没有
- **修复:** 添加 `#[validate(length(min=1))]`
## 8. 通用/跨切面 — 100% PASS
| ID | 测试 | 结果 |
|----|------|------|
| GENERIC-01 | 3 个并发更新 | PASS — 1 成功 + 2 冲突 (409) |
| GENERIC-02 | 错误 JSON body | PASS — 400 |
| GENERIC-03 | 缺少 Content-Type | PASS — 415 |
| GENERIC-04 | GET 带 body | PASS — body 被忽略 |
| GENERIC-05 | 超大页码 | PASS — 空列表 |
| GENERIC-06 | 快速连续请求 | PASS — 全 200 |
| GENERIC-07 | 不存在的文章 ID | PASS — 404 |
**亮点:** 乐观锁在并发下表现完美1 成功 + 2 冲突HTTP 状态码使用规范。
## 9. 失败项汇总
| ID | 严重性 | 模块 | 问题 | 修复 | 工时 |
|----|--------|------|------|------|------|
| H-06 | HIGH | 健康数据 | DTO-Entity 映射断裂 | 重构字段映射 | 4h |
| H-07 | HIGH | 内容管理 | 500 字符标题 → HTTP 500 | 添加 DTO 校验 | 30min |
| M-08 | MEDIUM | 健康数据 | 极端值无校验 | 添加范围校验 | 2h |
| M-09 | MEDIUM | 健康数据 | 未来 record_date | 添加日期校验 | 30min |
| M-10 | MEDIUM | 咨询 | XSS 存储未消毒 | HTML sanitize | 1h |
| M-11 | MEDIUM | 内容管理 | 空分类名被接受 | 添加 validate | 30min |
| M-12 | MEDIUM | 患者 | SQL 注入导致连接错误 | 调查 URL 编码 | 2h |
| M-13 | MEDIUM | 患者 | XSS 存储未消毒 | HTML sanitize | 1h |
| L-04 | LOW | 患者 | limit 无上限 | 设 max=200 | 30min |
| L-05 | LOW | 咨询 | 空描述被接受 | validate 或文档 | 30min |
| L-06 | LOW | 内容管理 | 重复标签名 | 唯一约束 | 1h |

View File

@@ -0,0 +1,139 @@
# 前端代码静态分析报告
> 分析范围: apps/web/src/ (316 TS/TSX) + apps/miniprogram/src/ (167 TS/TSX)
> 分析工具: Grep/Read/Bash
## 1. TypeScript 类型安全 — MEDIUM
### Web 前端
生产代码仅 1 处 `any`:
| 文件 | 行号 | 问题 |
|------|------|------|
| `hooks/usePaginatedData.ts` | 39 | `fetchFn: (...args: any[]) =>` — 建议用泛型 `A extends unknown[]` |
测试文件中 17 处 `as any`mock 场景),影响低。
### 小程序 — 10 处 `as any`
| 文件 | 行号 | 问题 | 严重性 |
|------|------|------|--------|
| `app.tsx` | 24, 29 | `(globalThis as any).__hms` | LOW — 调试辅助 |
| `pages/login/index.tsx` | 9 | `(__wxConfig as any).envVersion` | MEDIUM |
| `services/request.ts` | 250 | `method: method as any` | MEDIUM |
| `pages/pkg-health/device-sync/index.tsx` | 69 | `(bleManager as any).dataBuffer` | HIGH |
| `pages/appointment/create/index.tsx` | 132 | `(Taro.requestSubscribeMessage as any)` | MEDIUM |
**修复建议:** 创建 `types/global.d.ts``types/taro.d.ts` 补全缺失类型。
## 2. 错误处理 — HIGH
### Web 前端静默吞错 (10+ 处)
| 文件 | 行号 | 模式 |
|------|------|------|
| `pages/Home.tsx` | 224, 232, 238 | 个人统计加载失败被吞 |
| `pages/Roles.tsx` | 46 | 权限列表加载失败被吞 |
| `pages/health/ArticleManageList.tsx` | 119 | 文章列表加载失败被吞 |
| `pages/health/DialysisManageList.tsx` | 49 | 透析列表加载失败被吞 |
| `pages/health/components/DoctorSelect.tsx` | 28 | 医生列表加载失败被吞 |
| `pages/health/components/workbench/OperatorWorkbench.tsx` | 35 | 工作台数据加载失败被吞 |
另有 10 处 `catch { }`ChatPage 4 处 / useAlertSSE 2 处 / MainLayout 1 处 / usePaginatedData 1 处 / NotificationPanel 1 处 / App.tsx 1 处)。
**修复:** `.catch(() => {})``.catch((err) => console.warn('[context] 操作失败:', err))`,或设置错误状态。
### 小程序
仅 1 处静默 catch`followups/detail/index.tsx:58`),有注释解释,属合理模式。
## 3. 安全问题 — HIGH (1 处)
### dangerouslySetInnerHTML 无消毒
`pages/health/articleEditor/ArticlePhonePreview.tsx:243`:
```tsx
<div dangerouslySetInnerHTML={{ __html: content }} />
```
- `content` 来自 wangEditor 富文本输出
- 后台管理预览组件,内容由管理员创建(非 UGC
- **仍建议引入 DOMPurify 做客户端消毒**
- 预计工时: 30min
### 硬编码 URL — LOW
| 文件 | 内容 | 评估 |
|------|------|------|
| `AiConfigPage.tsx:340,402` | `http://localhost:11434` | Ollama 默认 URL仅作 placeholder |
| `miniprogram/services/request.ts:4` | `localhost:3000` fallback | 开发环境 fallback生产需运行时校验 |
**无硬编码密钥或密码。**
## 4. 可访问性 — LOW
- 未发现缺少 `alt``<img>` — Web 前端全用 Ant Design 组件
- 3 处 `onClick` 在非 button 元素上使用MainLayout 侧边栏 logo/折叠按钮 + ActionThreadDrawer 事件链接)
- **修复:** 添加 `role="button"` + `tabIndex={0}` + `onKeyDown`
## 5. 大文件 — MEDIUM
### Web 前端 (500+ 行)
| 文件 | 行数 | 建议 |
|------|------|------|
| `AdminDashboard.tsx` | 734 | 拆分统计卡片、图表、表格 |
| `ArticleManageList.tsx` | 654 | 拆分筛选栏、表格、详情抽屉 |
| `FollowUpTaskList.tsx` | 543 | 拆分筛选、列表、详情 |
| `ConsultationDetail.tsx` | 542 | 拆分消息区、信息栏 |
| `BannerManage.tsx` | 526 | 拆分表格和表单 |
| `AppointmentList.tsx` | 520 | 拆分筛选和表格 |
| `AiKnowledgePage.tsx` | 508 | 拆分列表和编辑 |
所有文件在 800 行限制内CLAUDE.md 规范),但建议拆分提升可维护性。
### 小程序 (300+ 行)
| 文件 | 行数 |
|------|------|
| `daily-monitoring/index.tsx` | 449 |
| `health/index.tsx` | 376 |
| `index/index.tsx` | 371 |
小程序文件总体控制得更好。
## 6. 国际化 — MEDIUM (不阻塞)
- **Web 前端:** 97 个文件 / 375 处硬编码中文文本
- **高频文件:** DashboardWidgets (47) / DoctorWorkbench (19) / OperatorWorkbench (18)
- **影响:** 当前定位国内单语平台,短期不影响
- **建议:** 新代码使用 i18n key旧代码逐步迁移
## 7. 内联样式 — LOW
- **1,548 处** `style={{}}` 分布在 129 个文件
- **高频:** DoctorWorkbench (68) / AdminDashboard (54) / OperatorWorkbench (49) / DashboardWidgets (47)
- 部分动态计算width/height不可避免静态样式应迁移到 CSS
## 8. 值得肯定的方面
1. **TypeScript 类型安全整体优秀** — 生产代码仅 1 处 `any`
2. **小程序已完全消除 Web API 依赖** — 无 `localStorage`/`btoa`/`atob`
3. **无硬编码密钥或密码** — 敏感值全走环境变量
4. **eslint-disable 使用规范** — 每处有注释解释
5. **所有文件在 800 行限制内**
6. **小程序 console 日志格式统一**`[模块名] 描述: error`
## 9. 问题汇总
| 严重性 | 问题 | 文件数 | 修复工作量 |
|--------|------|--------|-----------|
| HIGH | 静默吞错 `.catch(() => {})` | 10+ | 小 — 改为 warn 日志 |
| HIGH | dangerouslySetInnerHTML 无消毒 | 1 | 小 — 引入 DOMPurify |
| MEDIUM | 小程序 `as any` 类型断言 | 10 | 中 — 补全类型声明 |
| MEDIUM | 硬编码中文 (i18n) | 97 | 大 — 渐进迁移 |
| MEDIUM | 500+ 行大文件 | 7 | 中 — 拆分子组件 |
| LOW | 内联样式过多 | 129 | 大 — 渐进迁移 |
| LOW | localhost fallback URL | 2 | 小 — 运行时校验 |
| LOW | 非交互元素 onClick 缺 a11y | 3 | 小 |

View File

@@ -0,0 +1,222 @@
# 跨部门头脑风暴 — 问题研讨与优化方案
> 日期: 2026-05-21 | 参与方: 前端/后端/小程序/安全/UX/DevOps
> 基于 V3 Beta 综合测试发现
## 1. 会议议题
基于 5 个专家团队的测试发现,识别出 **4 个 CRITICAL + 8 个 HIGH + 15 个 MEDIUM** 问题。本次头脑风暴聚焦于:
1. CRITICAL 问题修复方案与优先级
2. 移动端响应式架构决策
3. 小程序安全存储架构改进
4. 后端 DTO-Entity 映射质量管控
5. Beta 发布时间线
---
## 2. 议题一: 小程序认证链路断裂 (C-01 + C-02)
### 问题
`inject_auth` → 明文键 → `request.ts safeGet` 只读加密键 → 所有 API 无 token
`secure-storage.ts` → UTF-16 截断 → 中文数据加密后解密损坏
### 方案讨论
| 方案 | 描述 | 优点 | 缺点 |
|------|------|------|------|
| **A. 统一 safeGet fallback** | `safeGet``secureGet` 返回空时 fallback 到明文键 | 改动最小1 文件) | 认证路径依赖两套存储 |
| **B. inject_auth 写加密键** | MCP 注入时直接写 `_es_` 前缀加密键 | 根因修复 | MCP 需实现加密逻辑 |
| **C. 统一存储层重构** | 所有读写走单一 `storageGet/storageSet`,内部处理加密/明文 fallback | 架构最优 | 改动范围大 |
### 决策
**采用方案 A + 修复 C-02**,预计 3h
1. `request.ts safeGet` 添加与 `auth.ts storageGet` 一致的 fallback 逻辑
2. `secure-storage.ts toBase64/fromBase64` 改用 `TextEncoder/TextDecoder`
3. 添加单元测试验证中文字符加密/解密循环
---
## 3. 议题二: 移动端响应式 (C-03 + C-04 + H-03)
### 问题
- 375px: 表格不可用,列严重挤压
- 812×375: 内容区域空白
- 768px: 表格数据不加载
### 方案讨论
| 方案 | 描述 | 工时 | 效果 |
|------|------|------|------|
| **A. Ant Design ProTable 响应式** | 使用 `responsive` 配置自动切换卡片视图 | 2d | 列表页全覆盖 |
| **B. CSS Grid + 媒体查询** | 手写 `@media` 断点,表格→卡片 | 3d | 精细控制 |
| **C. 独立移动端组件** | 为移动端创建 `MobilePatientCard` 等组件 | 5d | 最佳 UX |
### 决策
**采用方案 A**Ant Design ProTable 自带 responsive 支持:
1.`<768px` 启用 `cardView` 模式
2. 修复 768px 断点侧边栏折叠同步问题
3. 修复 812×375 高度不足导致懒加载未触发
**注意:** HMS 定位为 PC 管理后台,移动端支持优先级低于小程序。方案 A 满足"基本可用"即可。
---
## 4. 议题三: 健康数据 DTO 映射 (H-06)
### 问题
日常监测 API 通过率 20%DTO 字段(`systolic`/`diastolic`)与 Entity 列名(`morning_bp_systolic`/`morning_bp_diastolic`)不匹配,导致所有测量值存为 null。
### 根因分析
1. DTO 设计采用通用字段名Entity 使用具体时段字段名
2. Handler 层缺少 DTO→Entity 的显式映射逻辑
3. SeaORM 隐式匹配字段名,不匹配的静默为 null
### 修复方案
1. **DTO 重构:** 定义 `CreateDailyMonitoringReq` 明确映射到 Entity 字段
2. **Handler 添加映射:** 显式 `entity.morning_bp_systolic = dto.systolic`
3. **添加集成测试:** 确保写入后能正确读回
4. **值范围校验:** 血压 60-300 / 心率 30-250 / 血糖 1-50
5. **日期校验:** `record_date <= today`
预计工时: 4h
---
## 5. 议题四: 安全问题汇总 (XSS + SSRF + 输入校验)
### 发现清单
| 问题 | 位置 | 风险 |
|------|------|------|
| XSS 存储未消毒(患者名/咨询描述) | patient_handler / consultation_handler | Stored XSS |
| dangerouslySetInnerHTML 无消毒 | ArticlePhonePreview.tsx | DOM XSS |
| 空分类名被接受 | article_category_handler | 数据质量 |
| 文章标题超长导致 500 | article_handler | DoS/信息泄漏 |
| API limit 无上限 | 多个 list 端点 | 资源耗尽 |
### 修复优先级
1. **P0 (1h):** 文章标题添加 `#[validate(length(max=255))]`
2. **P1 (2h):** 患者名/咨询描述添加 HTML sanitize
3. **P1 (30min):** ArticlePhonePreview 引入 DOMPurify
4. **P2 (1h):** 所有 list 端点 limit 上限设为 200
5. **P2 (30min):** 分类名称添加 `#[validate(length(min=1))]`
---
## 6. 议题五: 性能优化路线图
### 关键性能指标
| 指标 | 当前值 | 目标 | 优先级 |
|------|--------|------|--------|
| Dashboard LCP | 1381ms | < 1000ms | P1 |
| Patient List LCP | 2643ms | < 2000ms | P1 |
| API 重复调用 | ×4 | ×1 | P0 |
| Antd Table Reflow | 460ms | < 100ms | P2 |
| Noto Sans SC | 1.3MB | < 300KB | P2 |
### 优化方案
1. **API 去重 (P0, 4h):** 检查 AdminDashboard useEffect 依赖项,考虑 React Query 缓存
2. **字体优化 (P2, 1h):** `font-display: optional` + 预加载关键子集
3. **虚拟滚动 (P2, 2h):** Antd Table `scroll={{ virtual: true }}`
4. **固定 scroll (P2, 1h):** 设置固定 `scroll.x`/`scroll.y` 避免 `measureScrollbarSize`
---
## 7. 议题六: 代码质量提升
### 静默吞错治理
**原则:** 所有 catch 块至少记录 `console.warn`,关键路径设置错误状态。
```typescript
// BAD
.catch(() => {})
// GOOD
.catch((err) => {
console.warn('[PatientList] 加载统计数据失败:', err);
// 可选: setErrorState(true)
})
```
### 大文件拆分计划
| 文件 | 行数 | 拆分方案 | 优先级 |
|------|------|---------|--------|
| AdminDashboard.tsx | 734 | StatsCards + Charts + ModuleStatus | P2 |
| ArticleManageList.tsx | 654 | FilterBar + ArticleTable + DetailDrawer | P2 |
| FollowUpTaskList.tsx | 543 | TaskFilter + TaskTable + TaskDetail | P3 |
---
## 8. 行动计划与时间线
### Phase 0: CRITICAL 修复Day 1-2阻塞 Beta
| 任务 | 负责方 | 工时 | 依赖 |
|------|--------|------|------|
| C-01: safeGet fallback | 前端 | 1h | — |
| C-02: UTF-8 编码 | 前端 | 2h | — |
| H-01: 患者表单验证 | 前端 | 1h | — |
| H-06: DTO-Entity 映射 | 后端 | 4h | — |
| H-07: 文章标题校验 | 后端 | 30min | — |
| H-02: 预约列表 API | 全栈 | 2h | 需调查根因 |
### Phase 1: HIGH 修复Day 3-4
| 任务 | 负责方 | 工时 |
|------|--------|------|
| C-03/C-04: 移动端卡片视图 | 前端 | 2d |
| H-03: 768px 断点修复 | 前端 | 4h |
| H-05: API 去重 | 前端 | 4h |
| XSS sanitize (患者/咨询) | 后端 | 2h |
### Phase 2: MEDIUM + 性能优化Day 5-7
| 任务 | 负责方 | 工时 |
|------|--------|------|
| 对比度修复 | 前端 | 30min |
| Dark Mode 卡片 | 前端 | 4h |
| 静默吞错治理 | 前端 | 2h |
| 字体优化 | 前端 | 1h |
| API 输入校验补全 | 后端 | 3h |
### Phase 3: LOW + 技术债Beta 后迭代)
- i18n 迁移(渐进)
- 大文件拆分(渐进)
- 内联样式清理(渐进)
- 类型声明补全(小程序)
---
## 9. 会议结论
### Beta 发布条件
**必须在 Phase 0 + Phase 1 完成后才能发布 Beta 版本:**
1. ✅ 4 个 CRITICAL 全部修复
2. ✅ 8 个 HIGH 全部修复
3. ✅ 所有修复通过回归测试
4.`cargo check` + `cargo test` + `pnpm build` 全部通过
5. ✅ 浏览器 + 小程序手动验证核心流程
### 预计时间线
- **Phase 0:** Day 1-2 (CRITICAL + HIGH 后端)
- **Phase 1:** Day 3-4 (移动端 + API 去重 + XSS)
- **Beta 发布:** Day 4 结束
- **Phase 2:** Day 5-7 (MEDIUM + 性能)
- **正式版 V1:** Day 7+ (根据 Beta 反馈)

View File

@@ -0,0 +1,142 @@
# Beta 就绪验收清单
> 基于 V3 Beta 综合测试发现 | 更新: 2026-05-21
> 目标: 明确 Beta 发布前的必须完成项和验证标准
## 1. 阻塞项(必须修复)— Phase 0
### 1.1 小程序认证链路
- [ ] **C-01:** `services/request.ts``safeGet` 添加明文键 fallback 逻辑
- [ ] **C-02:** `utils/secure-storage.ts``toBase64/fromBase64` 改用 `TextEncoder/TextDecoder`
- [ ] 验证: 小程序内体征保存、签到、咨询列表 API 调用成功
- [ ] 验证: 含中文的 `user_data` 加密存储后解密正确
### 1.2 Web 前端核心功能
- [ ] **H-01:** `PatientList.tsx` 创建表单添加 `form.validateFields()` 前端校验
- [ ] **H-02:** 预约列表 API 网络异常排查修复
- [ ] 验证: 空表单提交被前端拦截,显示校验错误
- [ ] 验证: 预约列表页正常加载数据
### 1.3 后端数据完整性
- [ ] **H-06:** 日常监测 DTO-Entity 字段映射修复
- [ ] **H-07:** 文章标题 DTO 添加 `#[validate(length(max=255))]`
- [ ] 验证: 血压/心率/血糖写入后能正确读回
- [ ] 验证: 500 字符标题返回 400 而非 500
## 2. HIGH 项(应该修复)— Phase 1
### 2.1 移动端响应式
- [ ] **C-03:** Mobile 375px 添加卡片/列表视图替代表格
- [ ] **C-04:** Mobile 横屏 812×375 内容区域空白修复
- [ ] **H-03:** Tablet 768px 侧边栏折叠与内容区域同步
- [ ] 验证: 5 种视口 (1920×1080 / 1366×768 / 768×1024 / 375×812 / 812×375) 全部 PASS
### 2.2 性能
- [ ] **H-04:** 患者列表 LCP 优化至 < 2000ms
- [ ] **H-05:** 仪表盘 API 每个端点从 ×4 降至 ×1
- [ ] 验证: Lighthouse Desktop Accessibility ≥ 94
### 2.3 安全
- [ ] 患者名/咨询描述 HTML sanitize
- [ ] ArticlePhonePreview 引入 DOMPurify
- [ ] 验证: XSS payload 存储后不执行
## 3. 构建与部署验证
### 3.1 后端
- [ ] `cargo check --workspace` 无错误
- [ ] `cargo test --workspace` 全部通过
- [ ] `cargo clippy -- -D warnings` 无警告
- [ ] 后端服务正常启动,健康检查 200
### 3.2 Web 前端
- [ ] `pnpm build` 生产构建通过
- [ ] `pnpm test` 单元测试通过
- [ ] 4 种主题切换正常
- [ ] 所有核心页面加载无 console error
### 3.3 小程序
- [ ] `pnpm build:weapp` 构建通过
- [ ] 微信开发者工具中 5 个 Tab 页全部可访问
- [ ] 体征保存、签到、咨询功能正常
- [ ] 无 JS 异常
## 4. 回归测试清单
### 4.1 核心业务流程
| 流程 | 验证点 | 状态 |
|------|--------|------|
| 登录 → 工作台 | 菜单加载、统计数据显示 | ⬜ |
| 患者创建 | 表单校验、数据保存 | ⬜ |
| 患者搜索 | 关键字过滤生效 | ⬜ |
| 预约列表 | 数据加载、分页 | ⬜ |
| 咨询管理 | 列表、状态切换、评分 | ⬜ |
| 主题切换 | 4 种主题 + 持久化 | ⬜ |
### 4.2 API 端点抽检
| 端点 | 方法 | 验证 | 状态 |
|------|------|------|------|
| /auth/login | POST | 正确/错误密码 | ⬜ |
| /health/patients | GET/POST | CRUD + 校验 | ⬜ |
| /health/daily-monitoring | POST | DTO 映射正确 | ⬜ |
| /health/articles | POST | 标题长度校验 | ⬜ |
| /health/appointments | GET | 列表加载 | ⬜ |
### 4.3 小程序核心功能
| 功能 | 验证点 | 状态 |
|------|--------|------|
| 登录 | Token 获取、存储、读取 | ⬜ |
| 首页 | 体征概览、操作按钮 | ⬜ |
| 体征保存 | 血压写入 + 读回 | ⬜ |
| 签到 | 积分增加 | ⬜ |
| AI 聊天 | 消息发送 | ⬜ |
| 咨询列表 | 数据加载 | ⬜ |
## 5. 发布签名
| 角色 | 确认 | 日期 |
|------|------|------|
| 前端负责人 | ⬜ | — |
| 后端负责人 | ⬜ | — |
| 小程序负责人 | ⬜ | — |
| 安全负责人 | ⬜ | — |
| QA 负责人 | ⬜ | — |
| 产品负责人 | ⬜ | — |
---
## 6. 已知限制Beta 版本)
以下问题在 Beta 版本中 **不阻塞**,将在后续迭代中修复:
1. **移动端响应式** — PC 管理后台移动端体验不佳(有小程序替代)
2. **i18n** — 375 处硬编码中文(国内单语定位)
3. **内联样式** — 1,548 处 `style={{}}`(功能不影响)
4. **API limit 上限** — 无 200 上限(可通过浏览器 DevTools 触发)
5. **重复标签** — 无唯一约束(管理员操作,风险低)
6. **Dark Mode 对比度** — 部分卡片浅色背景(视觉问题,不影响功能)
7. **大文件** — 7 个 500+ 行 TSX 文件(可维护性,非功能问题)
## 7. 测试报告索引
| 章节 | 文件 | 关键发现 |
|------|------|---------|
| 执行摘要 | `01-executive-summary.md` | 36 个问题B- 评级 |
| Web 功能测试 | `02-web-functional.md` | 8 领域 5 通过H×2 M×2 |
| 性能/兼容性 | `03-web-perf-compat.md` | Lighthouse 94/100/100移动端 FAIL |
| 小程序测试 | `04-miniprogram.md` | UI 100%,功能 0%token 问题) |
| API 深度测试 | `05-api-deep-test.md` | 82.6% 通过率,健康数据 20% |
| 静态分析 | `06-static-analysis.md` | 吞错 10+i18n 375 处 |
| 头脑风暴 | `07-brainstorm.md` | 3 Phase 修复计划7 天时间线 |

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

View File

@@ -0,0 +1,754 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HMS 小程序 — 设备同步(重新设计)</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a1a1a; font-family: -apple-system, 'PingFang SC', sans-serif; display: flex; flex-direction: column; align-items: center; padding: 40px 20px; gap: 24px; }
.page-title { color: #999; font-size: 13px; letter-spacing: 0.15em; }
.note { color: #666; font-size: 12px; max-width: 1200px; text-align: center; line-height: 1.8; }
.screens { display: flex; gap: 32px; flex-wrap: wrap; justify-content: center; align-items: flex-start; }
.screen-wrap { display: flex; flex-direction: column; align-items: center; gap: 12px; }
.screen-label { color: #888; font-size: 12px; font-style: italic; }
/* 蓝牙脉冲动画 */
@keyframes pulse-ring {
0% { transform: scale(0.8); opacity: 0.6; }
50% { transform: scale(1.3); opacity: 0; }
100% { transform: scale(0.8); opacity: 0; }
}
@keyframes pulse-dot {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
@keyframes connect-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body>
<div class="page-title">HMS 小程序 · 设备同步(重新设计)</div>
<div class="note">7 个状态屏幕:空闲 → 扫描中 → 设备列表 → 连接中 → 已连接(实时数据)→ 同步完成 → 错误状态</div>
<div id="root"></div>
<script type="text/babel">
// ─── iOS 设备框 ───
const iosFrameStyles = {
wrapper: { display: 'inline-block', padding: 12, background: '#000', borderRadius: 60, boxShadow: '0 0 0 2px #1f2937, 0 20px 60px rgba(0,0,0,0.3)', position: 'relative' },
screen: { position: 'relative', borderRadius: 48, overflow: 'hidden', background: '#fff' },
statusBar: { position: 'absolute', top: 0, left: 0, right: 0, height: 54, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 32px', fontSize: 16, fontWeight: 600, fontFamily: '-apple-system, "SF Pro Text", sans-serif', zIndex: 20, pointerEvents: 'none' },
dynamicIsland: { position: 'absolute', top: 12, left: '50%', transform: 'translateX(-50%)', width: 124, height: 36, background: '#000', borderRadius: 999, zIndex: 30 },
content: { position: 'absolute', top: 54, left: 0, right: 0, bottom: 34, overflow: 'auto' },
homeIndicator: { position: 'absolute', bottom: 10, left: '50%', transform: 'translateX(-50%)', width: 140, height: 5, background: 'rgba(0,0,0,0.3)', borderRadius: 999, zIndex: 10 },
};
function IosFrame({ children, width = 360, height = 780, time = '9:41', battery = 85, darkStatus = false }) {
const c = darkStatus ? '#fff' : '#000';
return (
<div style={iosFrameStyles.wrapper}>
<div style={{ ...iosFrameStyles.screen, width, height }}>
<div style={{ ...iosFrameStyles.statusBar, color: c }}>
<span>{time}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="16" height="12" viewBox="0 0 16 12" fill="none"><path d="M8 11.5a1 1 0 100-2 1 1 0 000 2z" fill={c}/><path d="M3 7.5a7 7 0 0110 0" stroke={c} strokeWidth="1.3" fill="none" strokeLinecap="round"/><path d="M1 4.5a11 11 0 0114 0" stroke={c} strokeWidth="1.3" fill="none" strokeLinecap="round" opacity="0.7"/></svg>
<div style={{ width: 26, height: 12, border: `1.5px solid ${c}`, borderRadius: 3, padding: 1, position: 'relative' }}>
<div style={{ width: `${battery}%`, height: '100%', background: c, borderRadius: 1 }} />
</div>
</div>
</div>
<div style={iosFrameStyles.dynamicIsland} />
<div style={iosFrameStyles.content}>{children}</div>
<div style={iosFrameStyles.homeIndicator} />
</div>
</div>
);
}
// ─── 设计 Token ───
const T = {
pri: '#C4623A', priL: '#F0DDD4', priD: '#8B3E1F',
bg: '#F5F0EB', card: '#FFFFFF', surface: '#EDE8E2',
tx: '#2D2A26', tx2: '#5A554F', tx3: '#78716C',
bd: '#E8E2DC', bdL: '#F0EBE5',
acc: '#5B7A5E', accL: '#E8F0E8',
wrn: '#C4873A', wrnL: '#FFF3E0',
dan: '#B54A4A', danL: '#FDEAEA',
serif: "Georgia, 'Times New Roman', serif",
sans: "-apple-system, 'PingFang SC', sans-serif",
r: 16, rSm: 12, rXs: 8,
};
// ─── SVG 图标 ───
function BluetoothIcon({ size = 24, color = T.pri }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path d="M6 7l8 8-4 4V3l4 4-8 8" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
}
function HeartIcon({ size = 20, color = T.dan }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z" fill={color} opacity="0.15"/>
<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z" stroke={color} strokeWidth="1.5" fill="none"/>
</svg>
);
}
function CheckIcon({ size = 32, color = T.acc }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill={color} opacity="0.12"/>
<path d="M8 12.5l2.5 2.5 5.5-5.5" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
}
function ErrorIcon({ size = 32, color = T.dan }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill={color} opacity="0.12"/>
<path d="M15 9l-6 6M9 9l6 6" stroke={color} strokeWidth="2" strokeLinecap="round"/>
</svg>
);
}
// ─── 信号强度条 ───
function SignalBars({ level = 3 }) {
const bars = [4, 7, 10, 13];
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 16 }}>
{bars.map((h, i) => (
<div key={i} style={{
width: 3, height: h, borderRadius: 1,
background: i < level ? T.acc : T.bd,
}} />
))}
</div>
);
}
// ─── 导航栏 ───
function NavBar({ title, dark = false }) {
return (
<div style={{
height: 44, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: dark ? T.pri : T.bg, position: 'relative',
}}>
<svg style={{ position: 'absolute', left: 16, top: '50%', transform: 'translateY(-50%)' }}
width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M15 19l-7-7 7-7" stroke={dark ? '#fff' : T.tx} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span style={{ fontFamily: T.serif, fontSize: 18, fontWeight: 700, color: dark ? '#fff' : T.tx }}>{title}</span>
</div>
);
}
// ─── 设备类型标签 ───
function DeviceTypeTag({ icon, label }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
background: T.card, border: `1px solid ${T.bdL}`,
borderRadius: T.rXs, padding: '8px 12px',
}}>
{icon}
<span style={{ fontSize: 13, color: T.tx2 }}>{label}</span>
</div>
);
}
// ─── 屏幕一:空闲态 ───
function IdleScreen() {
return (
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
<NavBar title="设备同步" dark />
{/* Hero 区域 */}
<div style={{
background: `linear-gradient(135deg, ${T.pri} 0%, ${T.priD} 100%)`,
padding: '32px 20px 28px',
display: 'flex', flexDirection: 'column', alignItems: 'center',
}}>
{/* 蓝牙设备插图 */}
<div style={{
width: 72, height: 72, borderRadius: '50%',
background: 'rgba(255,255,255,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 16,
}}>
<BluetoothIcon size={36} color="#fff" />
</div>
<div style={{ fontFamily: T.serif, fontSize: 22, fontWeight: 700, color: '#fff', marginBottom: 6 }}>
智能设备同步
</div>
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.75)', textAlign: 'center', lineHeight: 1.5 }}>
连接蓝牙设备自动采集健康数据
</div>
</div>
<div style={{ flex: 1, padding: '16px 16px 100px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 支持的设备类型 */}
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: T.tx, marginBottom: 10, paddingLeft: 2 }}>
支持的设备
</div>
<div style={{ display: 'flex', gap: 8 }}>
<DeviceTypeTag icon={<HeartIcon size={16} color={T.dan} />} label="心率手环" />
<DeviceTypeTag icon={
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 21V3M8 6l4-3 4 3M8 18l4 3 4-3" stroke={T.pri} strokeWidth="1.5" strokeLinecap="round"/>
<circle cx="12" cy="12" r="3" stroke={T.pri} strokeWidth="1.5"/>
</svg>
} label="血压计" />
<DeviceTypeTag icon={
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 2v6M12 22v-4M4.93 4.93l4.24 4.24M14.83 14.83l4.24 4.24M2 12h6M16 12h6" stroke={T.wrn} strokeWidth="1.5" strokeLinecap="round"/>
<circle cx="12" cy="12" r="4" stroke={T.wrn} strokeWidth="1.5"/>
</svg>
} label="血糖仪" />
</div>
</div>
{/* 上次同步信息 */}
<div style={{
background: T.card, borderRadius: T.rSm,
padding: '14px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
border: `1px solid ${T.bdL}`,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 36, height: 36, borderRadius: '50%', background: T.accL, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CheckIcon size={20} color={T.acc} />
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 500, color: T.tx }}>上次同步</div>
<div style={{ fontSize: 12, color: T.tx3, marginTop: 2 }}>今天 08:30</div>
</div>
</div>
<div style={{
background: T.accL, borderRadius: T.rXs,
padding: '4px 10px', fontSize: 12, color: T.acc, fontWeight: 500,
}}>
12 条数据
</div>
</div>
{/* 待上传提示 */}
<div style={{
background: T.wrnL, borderRadius: T.rSm,
padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 10,
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M12 9v4M12 17h.01M12 2L2 22h20L12 2z" stroke={T.wrn} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span style={{ fontSize: 13, color: T.wrn, fontWeight: 500 }}>3 条数据待上传</span>
</div>
{/* 扫描按钮 */}
<div style={{
background: T.pri, borderRadius: T.rSm,
padding: '16px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
boxShadow: `0 4px 16px rgba(196, 98, 58, 0.3)`,
marginTop: 'auto',
}}>
<BluetoothIcon size={20} color="#fff" />
<span style={{ color: '#fff', fontSize: 17, fontWeight: 600 }}>扫描附近设备</span>
</div>
</div>
</div>
);
}
// ─── 屏幕二:扫描中 ───
function ScanningScreen() {
return (
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
<NavBar title="设备同步" dark />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
{/* 脉冲圆环 */}
<div style={{ position: 'relative', width: 140, height: 140, marginBottom: 32 }}>
<div style={{
position: 'absolute', top: 0, left: 0, width: 140, height: 140,
borderRadius: '50%', border: `2px solid ${T.priL}`,
animation: 'pulse-ring 2s ease-out infinite',
}} />
<div style={{
position: 'absolute', top: 15, left: 15, width: 110, height: 110,
borderRadius: '50%', border: `2px solid ${T.priL}`,
animation: 'pulse-ring 2s ease-out infinite 0.5s',
}} />
<div style={{
position: 'absolute', top: 30, left: 30, width: 80, height: 80,
borderRadius: '50%', background: T.priL,
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'pulse-dot 2s ease-in-out infinite',
}}>
<BluetoothIcon size={36} color={T.pri} />
</div>
</div>
<div style={{ fontFamily: T.serif, fontSize: 20, fontWeight: 700, color: T.tx, marginBottom: 8, textAlign: 'center' }}>
正在搜索设备...
</div>
<div style={{ fontSize: 14, color: T.tx3, textAlign: 'center', lineHeight: 1.6 }}>
请确保设备已开启蓝牙并靠近手机
</div>
{/* 进度条 */}
<div style={{
width: 180, height: 3, borderRadius: 2, background: T.bdL,
marginTop: 24, overflow: 'hidden',
}}>
<div style={{
width: '60%', height: '100%', borderRadius: 2,
background: `linear-gradient(90deg, ${T.priL}, ${T.pri})`,
}} />
</div>
<div style={{ fontSize: 12, color: T.tx3, marginTop: 8 }}>已用时 6 </div>
</div>
</div>
);
}
// ─── 屏幕三:设备列表 ───
function DeviceListScreen() {
const devices = [
{ name: 'Mi Band 8', type: '小米手环适配器', signal: 4, color: T.pri },
{ name: 'AND UA-651', type: '血压计适配器', signal: 3, color: T.pri },
{ name: 'Accu-Chek', type: '血糖仪适配器', signal: 2, color: T.wrn },
];
return (
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
<NavBar title="设备同步" dark />
<div style={{ flex: 1, padding: '16px 16px 100px' }}>
{/* 结果标题 */}
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
marginBottom: 16,
}}>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: T.tx }}>发现 {devices.length} 台设备</div>
<div style={{ fontSize: 12, color: T.tx3, marginTop: 2 }}>点击设备名称开始连接</div>
</div>
<div style={{
fontSize: 13, color: T.pri, fontWeight: 500, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 4,
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M23 4v6h-6M1 20v-6h6" stroke={T.pri} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" stroke={T.pri} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
重新扫描
</div>
</div>
{/* 设备卡片 */}
{devices.map((d, i) => (
<div key={i} style={{
background: T.card, borderRadius: T.rSm,
padding: '16px', marginBottom: 10,
border: `1px solid ${T.bdL}`,
display: 'flex', alignItems: 'center', gap: 14,
}}>
{/* 设备图标 */}
<div style={{
width: 44, height: 44, borderRadius: T.rSm,
background: T.priL, display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<BluetoothIcon size={22} color={T.pri} />
</div>
{/* 设备信息 */}
<div style={{ flex: 1 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: T.tx }}>{d.name}</div>
<div style={{ fontSize: 12, color: T.tx3, marginTop: 3 }}>{d.type}</div>
</div>
{/* 信号 + 箭头 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<SignalBars level={d.signal} />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M9 18l6-6-6-6" stroke={T.tx3} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
</div>
))}
{/* 未发现设备提示 */}
<div style={{
marginTop: 16, background: T.card, borderRadius: T.rSm,
padding: '14px 16px', border: `1px dashed ${T.bd}`,
display: 'flex', alignItems: 'center', gap: 10,
}}>
<div style={{
width: 32, height: 32, borderRadius: '50%', background: T.surface,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="8" stroke={T.tx3} strokeWidth="1.5"/>
<path d="M21 21l-4.35-4.35" stroke={T.tx3} strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</div>
<div>
<div style={{ fontSize: 13, color: T.tx2 }}>没有找到你的设备</div>
<div style={{ fontSize: 12, color: T.tx3, marginTop: 2 }}>确保设备已开机且蓝牙已开启</div>
</div>
</div>
</div>
</div>
);
}
// ─── 屏幕四:连接中 ───
function ConnectingScreen() {
return (
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
<NavBar title="设备同步" dark />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
{/* 连接动画 */}
<div style={{ position: 'relative', width: 100, height: 100, marginBottom: 28 }}>
{/* 旋转环 */}
<div style={{
position: 'absolute', top: 0, left: 0, width: 100, height: 100,
borderRadius: '50%', border: `3px solid ${T.bdL}`,
borderTopColor: T.pri,
animation: 'connect-spin 1s linear infinite',
}} />
{/* 中心图标 */}
<div style={{
position: 'absolute', top: 20, left: 20, width: 60, height: 60,
borderRadius: '50%', background: T.priL,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<BluetoothIcon size={28} color={T.pri} />
</div>
</div>
<div style={{ fontFamily: T.serif, fontSize: 18, fontWeight: 700, color: T.tx, marginBottom: 6, textAlign: 'center' }}>
正在连接 Mi Band 8
</div>
<div style={{ fontSize: 14, color: T.tx3, textAlign: 'center' }}>
正在进行蓝牙配对...
</div>
{/* 步骤指示 */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8, marginTop: 24,
}}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: T.acc }} />
<span style={{ fontSize: 12, color: T.tx3 }}>发现设备</span>
<div style={{ width: 24, height: 1, background: T.pri }} />
<div style={{ width: 8, height: 8, borderRadius: '50%', background: T.pri, animation: 'pulse-dot 1s ease-in-out infinite' }} />
<span style={{ fontSize: 12, color: T.pri, fontWeight: 500 }}>连接中</span>
<div style={{ width: 24, height: 1, background: T.bd }} />
<div style={{ width: 8, height: 8, borderRadius: '50%', background: T.bd }} />
<span style={{ fontSize: 12, color: T.tx3 }}>同步数据</span>
</div>
</div>
</div>
);
}
// ─── 屏幕五:已连接 + 实时数据 ───
function ConnectedScreen() {
const readings = [
{ type: '心率', value: '72', unit: 'bpm', color: T.dan, time: '刚刚' },
{ type: '收缩压', value: '128', unit: 'mmHg', color: T.pri, time: '2分钟前' },
{ type: '舒张压', value: '82', unit: 'mmHg', color: T.pri, time: '2分钟前' },
{ type: '心率', value: '68', unit: 'bpm', color: T.dan, time: '5分钟前' },
{ type: '心率', value: '74', unit: 'bpm', color: T.dan, time: '8分钟前' },
];
return (
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
<NavBar title="设备同步" dark />
<div style={{ flex: 1, padding: '16px 16px 100px', overflow: 'auto' }}>
{/* 连接状态卡片 */}
<div style={{
background: `linear-gradient(135deg, ${T.acc} 0%, #4A6B4E 100%)`,
borderRadius: T.r, padding: '16px',
display: 'flex', alignItems: 'center', gap: 12,
marginBottom: 16,
}}>
<div style={{
width: 40, height: 40, borderRadius: '50%',
background: 'rgba(255,255,255,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<BluetoothIcon size={22} color="#fff" />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: '#fff' }}>Mi Band 8</div>
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', marginTop: 2 }}>已连接 · 正在采集数据</div>
</div>
<div style={{
background: 'rgba(255,255,255,0.2)', borderRadius: T.rXs,
padding: '4px 10px', fontSize: 12, color: '#fff',
}}>
实时
</div>
</div>
{/* 最新读数高亮 */}
<div style={{
background: T.card, borderRadius: T.r, padding: '20px',
display: 'flex', alignItems: 'center', gap: 16,
marginBottom: 16, boxShadow: '0 2px 12px rgba(45,42,38,0.08)',
}}>
<div style={{
width: 52, height: 52, borderRadius: T.rSm,
background: `${T.dan}10`, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<HeartIcon size={28} color={T.dan} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, color: T.tx3 }}>心率 · 刚刚</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginTop: 4 }}>
<span style={{ fontFamily: T.serif, fontSize: 36, fontWeight: 700, color: T.tx }}>72</span>
<span style={{ fontSize: 14, color: T.tx3 }}>bpm</span>
</div>
</div>
</div>
{/* 历史读数列表 */}
<div style={{
fontSize: 14, fontWeight: 600, color: T.tx, marginBottom: 10, paddingLeft: 2,
}}>
历史读数
</div>
<div style={{
background: T.card, borderRadius: T.rSm, overflow: 'hidden',
boxShadow: '0 1px 4px rgba(45,42,38,0.06)',
}}>
{readings.slice(1).map((r, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', padding: '12px 16px',
borderBottom: i < readings.length - 2 ? `1px solid ${T.bdL}` : 'none',
}}>
<div style={{ width: 90, fontSize: 14, color: T.tx2 }}>{r.type}</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'baseline', gap: 3 }}>
<span style={{ fontFamily: T.serif, fontSize: 20, fontWeight: 700, color: T.tx }}>{r.value}</span>
<span style={{ fontSize: 12, color: T.tx3 }}>{r.unit}</span>
</div>
<div style={{ fontSize: 12, color: T.tx3 }}>{r.time}</div>
</div>
))}
</div>
<div style={{
textAlign: 'center', marginTop: 12, fontSize: 12, color: T.tx3,
}}>
已采集 {readings.length} 条数据
</div>
{/* 操作按钮 */}
<div style={{ display: 'flex', gap: 10, marginTop: 20 }}>
<div style={{
flex: 1, background: T.pri, borderRadius: T.rSm,
padding: '14px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
boxShadow: `0 4px 16px rgba(196, 98, 58, 0.3)`,
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span style={{ color: '#fff', fontSize: 16, fontWeight: 600 }}>上传数据</span>
</div>
<div style={{
width: 52, background: T.danL, borderRadius: T.rSm,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke={T.dan} strokeWidth="2" strokeLinecap="round"/>
</svg>
</div>
</div>
</div>
</div>
);
}
// ─── 屏幕六:同步完成 ───
function DoneScreen() {
return (
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
<NavBar title="设备同步" dark />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '24px 20px 100px' }}>
{/* 成功图标 */}
<div style={{
width: 80, height: 80, borderRadius: '50%', background: T.accL,
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 24,
}}>
<CheckIcon size={44} color={T.acc} />
</div>
<div style={{ fontFamily: T.serif, fontSize: 24, fontWeight: 700, color: T.tx, marginBottom: 8 }}>
同步完成
</div>
<div style={{ fontSize: 15, color: T.tx3, textAlign: 'center', lineHeight: 1.6 }}>
数据已安全上传至健康管理平台
</div>
{/* 统计卡片 */}
<div style={{
display: 'flex', gap: 12, marginTop: 24, width: '100%',
}}>
<div style={{
flex: 1, background: T.card, borderRadius: T.rSm, padding: '16px',
textAlign: 'center', border: `1px solid ${T.bdL}`,
}}>
<div style={{ fontFamily: T.serif, fontSize: 28, fontWeight: 700, color: T.pri }}>5</div>
<div style={{ fontSize: 12, color: T.tx3, marginTop: 4 }}>上传条数</div>
</div>
<div style={{
flex: 1, background: T.card, borderRadius: T.rSm, padding: '16px',
textAlign: 'center', border: `1px solid ${T.bdL}`,
}}>
<div style={{ fontFamily: T.serif, fontSize: 28, fontWeight: 700, color: T.tx }}>3</div>
<div style={{ fontSize: 12, color: T.tx3, marginTop: 4 }}>数据类型</div>
</div>
<div style={{
flex: 1, background: T.card, borderRadius: T.rSm, padding: '16px',
textAlign: 'center', border: `1px solid ${T.bdL}`,
}}>
<div style={{ fontFamily: T.serif, fontSize: 22, fontWeight: 700, color: T.acc }}>100%</div>
<div style={{ fontSize: 12, color: T.tx3, marginTop: 4 }}>成功率</div>
</div>
</div>
{/* 完成按钮 */}
<div style={{
width: '100%', background: T.pri, borderRadius: T.rSm,
padding: '16px', display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: `0 4px 16px rgba(196, 98, 58, 0.3)`,
marginTop: 32,
}}>
<span style={{ color: '#fff', fontSize: 17, fontWeight: 600 }}>完成</span>
</div>
</div>
</div>
);
}
// ─── 屏幕七:错误状态 ───
function ErrorScreen() {
return (
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
<NavBar title="设备同步" dark />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '24px 20px 100px' }}>
{/* 错误图标 */}
<div style={{
width: 80, height: 80, borderRadius: '50%', background: T.danL,
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 24,
}}>
<ErrorIcon size={44} color={T.dan} />
</div>
<div style={{ fontFamily: T.serif, fontSize: 22, fontWeight: 700, color: T.tx, marginBottom: 8 }}>
连接失败
</div>
<div style={{ fontSize: 14, color: T.tx3, textAlign: 'center', lineHeight: 1.6, maxWidth: 260 }}>
无法连接到 Mi Band 8请检查设备是否在范围内并重试
</div>
{/* 错误详情卡片 */}
<div style={{
width: '100%', background: T.card, borderRadius: T.rSm,
padding: '16px', marginTop: 24, border: `1px solid ${T.bdL}`,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke={T.tx3} strokeWidth="1.5"/>
<path d="M12 16v.01M12 8v4" stroke={T.tx3} strokeWidth="1.5" strokeLinecap="round"/>
</svg>
<span style={{ fontSize: 13, fontWeight: 500, color: T.tx }}>错误详情</span>
</div>
<div style={{ fontSize: 13, color: T.tx3, lineHeight: 1.7 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span>错误码</span><span style={{ color: T.tx }}>BLE_TIMEOUT</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span>设备</span><span style={{ color: T.tx }}>Mi Band 8</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>时间</span><span style={{ color: T.tx }}>09:15:32</span>
</div>
</div>
</div>
{/* 重试按钮 */}
<div style={{
width: '100%', background: T.pri, borderRadius: T.rSm,
padding: '16px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
boxShadow: `0 4px 16px rgba(196, 98, 58, 0.3)`,
marginTop: 24,
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M23 4v6h-6M1 20v-6h6" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span style={{ color: '#fff', fontSize: 17, fontWeight: 600 }}>重新扫描</span>
</div>
{/* 返回按钮 */}
<div style={{
width: '100%', borderRadius: T.rSm,
padding: '14px', display: 'flex', alignItems: 'center', justifyContent: 'center',
marginTop: 10, border: `1px solid ${T.bd}`,
}}>
<span style={{ color: T.tx2, fontSize: 16, fontWeight: 500 }}>返回</span>
</div>
</div>
</div>
);
}
// ─── 主渲染 ───
const screens = [
{ label: '空闲态', Component: IdleScreen },
{ label: '扫描中', Component: ScanningScreen },
{ label: '设备列表', Component: DeviceListScreen },
{ label: '连接中', Component: ConnectingScreen },
{ label: '已连接', Component: ConnectedScreen },
{ label: '同步完成', Component: DoneScreen },
{ label: '错误状态', Component: ErrorScreen },
];
function App() {
return (
<div className="screens">
{screens.map(({ label, Component }) => (
<div className="screen-wrap" key={label}>
<div className="screen-label">{label}</div>
<IosFrame width={360} height={780}>
<Component />
</IosFrame>
</div>
))}
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,12 @@
prototype: mp-device-sync-redesign.html
source: docs/design/mp-device-sync-redesign.html
variant: patient
generated_at: "2026-05-23T12:00:00+08:00"
tokens:
matched: 23
unmatched: 2
components:
total: 12
mapped: 8
new: 2
interactions: 9

View File

@@ -0,0 +1,246 @@
# 设备同步页面 设计规格
> 来源: mp-device-sync-redesign.html | 平台: 小程序(患者端) | 页面数: 7 | 生成: 2026-05-23
## 页面索引
| 页面 | 截图 | 路由 |
|------|------|------|
| 空闲态 | ![空闲态](./screenshots/screen-1.png) | pages/pkg-health/device-sync/index |
| 扫描中 | ![扫描中](./screenshots/screen-2.png) | pages/pkg-health/device-sync/index |
| 设备列表 | ![设备列表](./screenshots/list.png) | pages/pkg-health/device-sync/index |
| 连接中 | ![连接中](./screenshots/screen-4.png) | pages/pkg-health/device-sync/index |
| 已连接 | ![已连接](./screenshots/screen-5.png) | pages/pkg-health/device-sync/index |
| 同步完成 | ![同步完成](./screenshots/screen-6.png) | pages/pkg-health/device-sync/index |
| 错误状态 | ![错误状态](./screenshots/screen-7.png) | pages/pkg-health/device-sync/index |
## 一、Token 映射
| 原型值 | 项目 Token | 状态 |
|--------|-----------|------|
| T.pri (#C4623A) | --tk-pri | ✅ |
| T.priL (#F0DDD4) | --tk-pri-l | ✅ |
| T.priD (#8B3E1F) | --tk-pri-d | ✅ |
| T.bg (#F5F0EB) | $bg SCSS 变量 | ⚠️ 无 CSS Token直接用 $bg |
| T.card (#FFFFFF) | --tk-card-bg ($card) | ✅ |
| T.surface (#EDE8E2) | --tk-card-bg (≈) | ⚠️ 近似,用 $surface-alt SCSS 变量 |
| T.tx (#2D2A26) | $tx SCSS 变量 | ⚠️ 无 CSS Token直接用 $tx |
| T.tx2 (#5A554F) | $tx2 SCSS 变量 | ⚠️ 无 CSS Token直接用 $tx2 |
| T.tx3 (#78716C) | --tk-text-secondary ($tx3) | ✅ |
| T.bd (#E8E2DC) | $bd SCSS 变量 | ⚠️ 无 CSS Token直接用 $bd |
| T.bdL (#F0EBE5) | $bd-l SCSS 变量 | ⚠️ 无 CSS Token |
| T.acc (#5B7A5E) | $acc SCSS 变量 | ⚠️ 无 CSS Token |
| T.accL (#E8F0E8) | $acc-l SCSS 变量 | ⚠️ 无 CSS Token |
| T.wrn (#C4873A) | $wrn SCSS 变量 | ⚠️ 无 CSS Token |
| T.wrnL (#FFF3E0) | $wrn-l SCSS 变量 | ⚠️ 无 CSS Token |
| T.dan (#B54A4A) | $dan SCSS 变量 | ⚠️ 无 CSS Token |
| T.danL (#FDEAEA) | $dan-l SCSS 变量 | ⚠️ 无 CSS Token |
| T.r (16) | --tk-card-radius ($r) | ✅ |
| T.rSm (12) | $r-sm SCSS 变量 | ⚠️ 无 CSS Token |
| T.rXs (8) | $r-xs SCSS 变量 | ⚠️ 无 CSS Token |
| T.serif (Georgia...) | 字体栈 | ❌ 不映射,直接硬编码 |
| T.sans (-apple-system...) | 字体栈 | ❌ 不映射,直接硬编码 |
> 状态标记: ✅ confirmed 直接使用 | ⚠️ pending 需 SCSS 变量 | ❌ unmatched 需硬编码
## 二、页面结构
### 1. 空闲态idle
![空闲态](./screenshots/screen-1.png)
布局层级(从上到下):
- **NavBar** — 深色主色背景,标题"设备同步"
- **Hero 区域** — 主色渐变背景135deg pri→priD包含
- 蓝牙图标72px 圆形,半透明白底)
- 标题"智能设备同步"serif 22px 700
- 副标题14px 0.75 白色透明度)
- **支持设备** — 三列标签(心率手环/血压计/血糖仪),每个含 SVG 图标
- **上次同步卡片** — ContentCard 样式,左侧绿色勾选图标 + 时间 + 右侧数据量 badge
- **待上传提示** — 黄色背景警告条($wrnL三角感叹号图标
- **扫描按钮** — 全宽主色按钮,蓝牙图标 + "扫描附近设备"
### 2. 扫描中scanning
![扫描中](./screenshots/screen-2.png)
布局层级:
- **NavBar** — 同上
- **居中脉冲区域**
- 三层脉冲圆环CSS animation: pulse-ring外层→中层→内层递进
- 中心 80px 圆形蓝牙图标($priL 底色)
- **标题** — serif 20px "正在搜索设备..."
- **副文本** — 14px $tx3 提示文字
- **进度条** — 180px 宽,渐变填充 $priL→$pri
- **计时文字** — 12px "已用时 6 秒"
### 3. 设备列表found
![设备列表](./screenshots/list.png)
布局层级:
- **NavBar** — 同上
- **结果头部** — 左侧"发现 N 台设备"标题 + 右侧"重新扫描"链接(含刷新图标)
- **设备卡片列表**×3— 每张卡片含:
-44px 圆角方块图标($priL 底色 + 蓝牙 SVG
-设备名16px 600+ 适配器类型12px $tx3
-信号强度条4 级竖条) + 箭头
- **未发现设备提示** — 虚线边框卡片,搜索图标 + 提示文字
### 4. 连接中connecting
![连接中](./screenshots/screen-4.png)
布局层级:
- **NavBar** — 同上
- **居中动画区域**
- 100px 旋转环border-top-color: $priCSS animation: connect-spin
- 60px 中心圆形蓝牙图标
- **标题** — serif 18px "正在连接 {设备名}"
- **副文本** — "正在进行蓝牙配对..."
- **步骤指示器** — 三点一线:发现设备(✓) → 连接中(●脉冲) → 同步数据(○)
### 5. 已连接connected
![已连接](./screenshots/screen-5.png)
布局层级:
- **NavBar** — 同上
- **连接状态卡片** — 绿色渐变背景acc→#4A6B4E),蓝牙图标 + 设备名 + "实时" badge
- **最新读数高亮卡片** — 大卡片r=16 圆角 + shadow
- 52px 心形图标
- 类型+时间小字
- 数值serif 36px 700+ 单位
- **历史读数列表** — 标题 + 表格行(类型/数值/时间),每行 12px 分隔线
- **采集计数** — 居中小字
- **操作按钮行** — 左侧全宽"上传数据"主色按钮 + 右侧 52px 红色断开按钮
### 6. 同步完成done
![同步完成](./screenshots/screen-6.png)
布局层级:
- **NavBar** — 同上
- **居中成功区域**
- 80px 绿色圆形勾选图标
- 标题"同步完成"serif 24px 700
- 副文本"数据已安全上传至健康管理平台"
- **三列统计卡片** — 上传条数(5)/数据类型(3)/成功率(100%)
- **完成按钮** — 全宽主色按钮
### 7. 错误状态error
![错误状态](./screenshots/screen-7.png)
布局层级:
- **NavBar** — 同上
- **居中错误区域**
- 80px 红色圆形叉号图标
- 标题"连接失败"serif 22px 700
- 错误描述文字
- **错误详情卡片** — 含错误码/设备/时间三行键值对
- **重试按钮** — 全宽主色按钮,含刷新图标
- **返回按钮** — 描边按钮
## 三、组件映射
| 原型元素 | 推荐组件 | 来源 | 备注 |
|----------|---------|------|------|
| 页面外壳 | PageShell | @components/ui/PageShell | padding="none"NavBar 自带 |
| 连接状态卡片 | ContentCard | @components/ui/ContentCard | variant="elevated",绿色渐变背景自定义 |
| 成功结果卡片 | ContentCard | @components/ui/ContentCard | variant="elevated",居中布局 |
| 错误详情卡片 | ContentCard | @components/ui/ContentCard | variant="outlined" |
| 扫描按钮/上传按钮 | PrimaryButton | @components/ui/PrimaryButton | size="large"full width |
| 断开连接按钮 | — | 自定义 | 红色小方块图标按钮 |
| 返回按钮 | SecondaryButton | @components/ui/SecondaryButton | — |
| 设备类型标签 | — | 自定义 DeviceTypeTag | 小图标+文字,$bdL 边框 |
| 信号强度 | — | 自定义 SignalBars | 4 级竖条 |
| 上次同步信息 | ListItem | @components/ui/ListItem | leftIcon + title + subtitle + extra |
| 历史读数行 | InfoRow | @components/ui/InfoRow | label + value + last |
| 待上传警告 | AlertCard | @components/ui/AlertCard | variant="bordered",黄色 |
> ⚠️ **需新建**: SignalBars — 4 级竖条信号强度指示器20 行以内小组件)
> ⚠️ **需新建**: DeviceTypeTag — 设备类型标签(图标+文字,已非常简单,可直接内联)
## 四、交互规格
| 元素 | 交互 | 触发 | 反馈 | 备注 |
|------|------|------|------|------|
| 扫描按钮 | 调用 handleScan | onClick | 按钮变灰+loading状态→scanning | 触发 BLE 扫描 |
| 设备卡片 | 调用 handleConnect | onClick | 状态→connecting显示旋转动画 | 传递选中的 BLEDevice |
| 重新扫描链接 | 调用 handleScan | onClick | 同扫描按钮 | 刷新设备列表 |
| 上传数据按钮 | 调用 handleSync | onClick | 状态→syncing → done/error | 上传采集数据到后端 |
| 断开连接按钮 | 调用 handleDisconnect | onClick | 断开 BLE状态→idle | 清空 liveReadings |
| 完成按钮 | handleDisconnect + navigateBack | onClick | 返回上一页 | 如果 returnTo=input 则回填 Storage |
| 重试按钮 | handleScan | onClick | 重新扫描 | 从 error 恢复 |
| 返回按钮 | Taro.navigateBack | onClick | 返回上一页 | 错误状态 |
| 实时数据面板 | 被动更新 | BLE 通知 | 新数据插入列表顶部,数值动画 | useBLEManager hook 驱动 |
## 五、状态变体
- **idle**: 默认状态,展示 Hero + 设备类型 + 上次同步 + 扫描按钮
- **scanning**: 脉冲动画 + 进度条 + 计时,不可操作(无按钮)
- **found**: 设备列表 + 重新扫描链接,点击设备进入 connecting
- **connecting**: 旋转环动画 + 步骤指示器,不可操作
- **connected**: 绿色连接状态卡 + 实时数据面板 + 上传/断开按钮
- **done**: 成功图标 + 统计卡片 + 完成按钮
- **error**: 错误图标 + 错误详情 + 重试/返回按钮
- **syncing**: 复用 scanning 的加载态样式,文字改为"正在上传数据..."
## 六、样式清单
### 关键样式参数
```
/* Hero 渐变 */
background: linear-gradient(135deg, $pri 0%, $pri-d 100%)
padding: 32px 20px 28px
/* 脉冲圆环 */
animation: pulse-ring 2s ease-out infinite
三层: 140px / 110px / 80px (center)
/* 旋转环 */
animation: connect-spin 1s linear infinite
border-top-color: $pri
/* 最新读数数值 */
font-family: $serif; font-size: 36px; font-weight: 700
/* 连接状态卡片渐变 */
background: linear-gradient(135deg, $acc 0%, #4A6B4E 100%)
/* 信号条 */
4 根竖条: height [4, 7, 10, 13]px, width: 3px, gap: 2px
活跃色: $acc, 非活跃: $bd
/* 主按钮 */
background: $pri; border-radius: $r-sm; padding: 16px;
box-shadow: 0 4px 16px rgba(196, 98, 58, 0.3)
```
### 字号映射
| 原型字号 | Token | 用途 |
|---------|-------|------|
| 36px | 超大数值,直接用 serif bold | 最新读数数值 |
| 28px | --tk-font-h1 | 统计卡片数值 |
| 24px | — | 成功/错误标题 |
| 22px | --tk-font-h2 | Hero 标题、连接中标题 |
| 20px | — | 历史读数数值 |
| 18px | --tk-font-body-lg | NavBar 标题、按钮文字 |
| 17px | — | 主按钮文字 |
| 16px | --tk-font-body | 设备名、按钮文字 |
| 15px | — | 完成页副文本 |
| 14px | --tk-font-body-sm | 副文本、描述、列表类型 |
| 13px | --tk-font-cap | 标签文字、小字 |
| 12px | — | 时间、提示 |
---
> 此规格由 design-handoff skill 自动生成。LLM 实施时请:
> 1. 先阅读截图建立视觉印象
> 2. 按 Token 映射表使用项目 Token✅ 标记的直接用,⚠️ 用 SCSS 变量)
> 3. 优先使用"组件映射"中列出的已有组件
> 4. 参考"交互规格"实现对应的交互逻辑
> 5. "需新建"的组件参考截图和布局描述从头实现

View File

@@ -0,0 +1,104 @@
# 小程序上线前五专家组深度审计 + 头脑风暴
> 日期: 2026-05-20 | 参与者: UX/UI 审计 / 性能稳定性 / 安全审计 / 产品架构 / 代码质量
## 背景
小程序62 页面 + 34 组件 + 38 service即将交付用户测试。启动 5 个并行专家组进行全方位深度审计,确保交付版本的质量和可用性。
## 五专家组综合评分
| 专家组 | 评分 | CRITICAL | HIGH | MEDIUM | LOW | 总问题数 |
|--------|------|----------|------|--------|-----|----------|
| UX/UI 审计 | 6.2/10 B- | 3 | 8 | 14 | 9 | 34 |
| 性能稳定性 | 6.5/10 B- | 1 | 4 | 10 | 8 | 25 |
| 安全审计 | 5.1/10 D+ | 2 | 5 | 8 | 6 | 21 |
| 产品架构 | 6.0/10 C+ | 2 | 6 | 8 | 5 | 21 |
| 代码质量 | — | 0 | 2 | 3 | 0 | 134 空 catch + 10 any |
| **综合** | **6.0/10 C+** | **8** | **25** | **43** | **28** | **~135+** |
## CRITICAL 汇总(必须修复,阻断用户测试)
| # | 来源 | 问题 | 影响 |
|---|------|------|------|
| 1 | 产品 | 咨询创建页缺失,"发起咨询"按钮导航失败 | 核心咨询流程阻断 |
| 2 | 产品 | 随访流程不闭环(患者无触发入口 + 医生无执行页面) | 医疗质量核心链路断裂 |
| 3 | 安全 | 硬编码管理员凭据 `admin/Admin@2026` 在源码中 | 反编译可获取管理员权限 |
| 4 | 安全 | Token 明文存储在 Storagesecure-storage 实际无加密) | 设备丢失 = 身份冒用 |
| 5 | UX | AI 聊天页 13 处硬编码字号,长者模式完全失效 | TabBar 核心页老年用户不可用 |
| 6 | UX | 咨询详情页 14 处硬编码字号 | 医患沟通场景老年患者无法阅读 |
| 7 | UX | Loading 文字 28px 过大,误认为标题 | 视觉层级混乱 |
| 8 | 性能 | 咨询页长轮询可能永远不启动dataLoadedRef 时序竞争) | 咨询消息收不到 |
## HIGH 汇总(严重影响体验,应在上线前修复)
| # | 来源 | 问题 |
|---|------|------|
| 1 | 产品 | "消息" Tab 实为 AI 聊天,非消息中心,命名误导 |
| 2 | 产品 | 预约创建未选就诊人,多就诊人场景不可用 |
| 3 | 产品 | 趋势图仅 7 天柱状图,缺长期趋势和对比 |
| 4 | 产品 | 日常监测/设备同步入口层级过深 |
| 5 | UX | 87 处页面硬编码字号,长者模式系统性失效 |
| 6 | UX | StatusTag 色值与设计系统不一致 |
| 7 | UX | 44 个页面缺少 ErrorState |
| 8 | UX | AI 聊天页未使用 PageShell 组件 |
| 9 | 安全 | X-Patient-Id/X-Tenant-Id Header 可能导致越权 |
| 10 | 安全 | openid 明文存储和跨网络传输 |
| 11 | 安全 | RichText XSS 绕过风险 |
| 12 | 性能 | 主包 12 页面可能超 2MB无法发布 |
| 13 | 性能 | 无虚拟滚动,长列表性能差 |
| 14 | 性能 | 首页 4 个并行 API 无批量优化 |
| 15 | 代码 | 134 处空 catch 静默吞错 |
## 头脑风暴 — 上线策略
### 方案 A: 保守上线(修复所有 CRITICAL + 安全加固)
**时间**: 3-4 天
**范围**: 8 个 CRITICAL + 安全 TOP 3
**风险**: HIGH 级别问题可能影响用户第一印象
### 方案 B: 全面打磨(修复 CRITICAL + HIGH + 关键 MEDIUM
**时间**: 7-10 天
**范围**: 全部 CRITICAL + HIGH + 选定 MEDIUM
**风险**: 延迟用户测试,但交付质量更高
### 方案 C: 分层交付(推荐)
**时间**: 分 3 批,每批 2-3 天
**范围**:
- **Batch 1 (P0, 2天)**: 安全 CRITICAL + 功能 CRITICAL + 性能 CRITICAL
- **Batch 2 (P1, 2天)**: UX 一致性 + 长者模式修复 + HIGH 级产品问题
- **Batch 3 (P2, 3天)**: MEDIUM 级优化 + 性能优化 + 代码质量
## 决策
采用**方案 C 分层交付**,优先确保安全和功能完整,然后打磨体验。
### Batch 1 修复清单P0, 预估 2 天)
1. 移除硬编码凭据 → 环境变量注入1h
2. 确认后端不信任前端 Header2h
3. 咨询创建页缺失 → 新增页面或移除入口按钮4h
4. 咨询页长轮询启动时序修复2h
5. Loading 文字 token 修正0.5h
6. Token 存储安全加固4h— 可延至 Batch 2
### Batch 2 修复清单P1, 预估 2 天)
7. AI 聊天页 + 咨询详情页字号 token 替换4h
8. 医生端核心页面字号 token 替换3h
9. StatusTag 色值对齐设计系统1h
10. AI 聊天页接入 PageShell2h
11. 移除 forceSetAuth bridge0.5h
12. 随访流程闭环补全4h— 可延至 Batch 3
### Batch 3 修复清单P2, 预估 3 天)
13. 全局 87 处硬字号 → token 批量替换
14. 74 处硬 padding → token 批量替换
15. 44 个页面补充 ErrorState
16. 主包瘦身 + splitChunks 配置
17. 空 catch 添加日志
18. AI 聊天历史持久化(接后端 API

View File

@@ -0,0 +1,92 @@
# 小程序"我的"页面子页面必要性分析
> 日期: 2026-05-22 | 参与者: 产品经理 / UX 研究员 / UX 架构师 / 医疗业务专家 / 前端技术专家
## 背景
小程序患者端"我的"页面当前有 5 个分组共 19 个菜单入口 + 1 个消息通知独立入口 = 20 个可点击项。远超移动端认知负荷上限7±2需要从全局角度分析各子页面的存在必要性。
## 讨论要点
### 核心问题诊断
1. **功能堆砌**:把所有没有找到更好归属的功能都塞进"我的",导致它变成了"功能大全"而非"个人中心"
2. **入口重复**4 个入口在其他 Tab 已有更自然的路径(积分商城、用药记录、在线咨询、我的报告)
3. **透析噪音**:透析管理 3 个入口对所有用户无条件展示80%+ 非透析用户看到无关功能
4. **语义模糊**:健康记录/我的报告/诊断记录三入口,患者分不清区别
5. **性能浪费**:消息未读数请求 50 条列表而非 count 接口
6. **静态菜单**:无法按患者画像动态显示
### 各入口使用频率评估
| 频率 | 入口 |
|------|------|
| 高频(日活) | 消息通知、用药记录(慢病) |
| 中频(周活) | 我的预约、我的随访、在线咨询、积分商城 |
| 低频(月活) | 我的报告、健康记录、AI 分析、诊断记录、就诊人管理 |
| 极低频 | 透析处方、知情同意、线下活动、长辈模式、设备同步、设置 |
### 患者画像与功能需求矩阵
| 功能 | 普通体检者(50-60%) | 慢病患者(20-25%) | 透析患者(5-8%) | 术后随访(10-15%) |
|------|:---:|:---:|:---:|:---:|
| 我的报告 | 高 | 高 | 高 | 高 |
| 我的预约 | 高 | 中 | 中 | 中 |
| AI 分析 | 高 | 高 | 中 | 高 |
| 健康记录 | 中 | 高 | 中 | 高 |
| 用药记录 | 低 | 高 | 高 | 中 |
| 我的随访 | 低 | 高 | 中 | 高 |
| 透析管理 | 无 | 无 | 高 | 无 |
| 诊断记录 | 低 | 中 | 中 | 高 |
## 结论
### 共识意见
1. **入口数应从 20 缩减到 9-11 个**(常驻 9 + 动态 1-2
2. **移除 4 个重复入口**积分商城TabBar已有、用药记录健康Tab已有、在线咨询助手Tab可达、我的报告"我的"保留但健康Tab快捷入口改为AI分析
3. **透析管理按需显示**:仅透析患者可见,三入口合并为一
4. **健康数据合并**:健康记录+诊断记录合并为"健康档案"Tab切换
5. **长辈模式降级**:从一级入口移入设置页
### 优化后菜单结构
```
[消息通知] ← 优化为 getUnreadCount()
健康档案
├── 我的报告Tab: 检查报告 / AI 解读)
└── 健康档案Tab: 体检记录 / 诊断记录)
就诊服务
├── 我的预约
├── 我的随访
└── 在线咨询
透析管理 ← 仅透析患者可见
└── (内页 Tab: 透析记录 / 透析处方 / 同意书)
账号
├── 就诊人管理
├── 设备同步
└── 设置(含长辈模式开关)
```
### 行动优先级
| 优先级 | 行动 | 预期效果 | 工期 |
|--------|------|---------|------|
| P0 | 未读消息改用 getUnreadCount() | 节省 500ms+ | 0.5天 |
| P0 | 移除 3 个重复入口 | 减少 3 个入口 | 0.5天 |
| P1 | 透析管理条件显示 | 80%用户减少3个无关入口 | 1-2天 |
| P1 | 透析三页合并为一 | 节省 20-30KB | 1-2天 |
| P1 | 抽取 usePaginatedList hook | 消除 300 行重复代码 | 1天 |
| P2 | 健康记录/诊断合并为健康档案 | 减少 2 个入口 | 1-2天 |
| P2 | 长辈模式降级到设置页 | 减少 1 个入口 | 0.5天 |
| P2 | 线下活动改为消息推送触达 | 减少 1 个入口 | 0.5天 |
### 待定
- 后端 `patient` 表是否已有 `patient_type` 字段?需确认才能实现动态菜单
- AI 分析是否应完全合并到"我的报告"Tab还是保留独立入口
- 设备同步最终放在"账号"组还是"健康"Tab

View File

@@ -60,7 +60,7 @@ HMS 平台的设计围绕一个核心命题:**让患者的健康数据产生
└──────────────┼───────────────┘
┌─────────┴─────────┐
│ 统一 API 网关
│ 统一 API 网关 │
│ /api/v1/* │
│ + /api/v1/fhir/* │
└─────────┬─────────┘