diff --git a/docs/superpowers/plans/2026-04-24-hms-miniprogram-iteration-plan.md b/docs/superpowers/plans/2026-04-24-hms-miniprogram-iteration-plan.md new file mode 100644 index 0000000..b9f6c3e --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-hms-miniprogram-iteration-plan.md @@ -0,0 +1,1839 @@ +# HMS 小程序迭代实施计划 + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 基于混合策略(先基建再模块),分 4 个 Sprint 修复小程序工程质量、打磨用户体验、加固安全合规、建设增长基础,从开发阶段推进到可测试状态。 + +**Architecture:** 小程序代码位于 `apps/miniprogram/src/`(Taro 4 + React 18 + Zustand 5 + SCSS),通过 services 层调用后端 `/api/v1/` 端点。后端 Rust 模块(erp-auth、erp-health)需配合新增部分端点。前端遵循 Pages → Services → Backend 分层,Stores 管理全局状态。 + +**Tech Stack:** Taro 4.2.0, React 18.3, TypeScript 5.8, Zustand 5.0, SCSS, Rust/Axum/SeaORM (后端) + +**Spec:** `docs/superpowers/specs/2026-04-24-hms-miniprogram-iteration-design.md` + +--- + +## Chunk 1: Sprint 0 — 工程基础修复 + +### Task 1: 后端新增小程序专用 `GET /health/vital-signs/today` 端点 + +**Files:** +- Modify: `crates/erp-health/src/handler/health_data_handler.rs` +- Modify: `crates/erp-health/src/dto/health_data_dto.rs` +- Modify: `crates/erp-health/src/module.rs` +- Modify: `crates/erp-health/src/service/health_data_service.rs` + +- [ ] **Step 1: 定义 DTO** + +在 `crates/erp-health/src/dto/health_data_dto.rs` 新增: + +```rust +#[derive(Debug, Serialize)] +pub struct MiniTodayResp { + pub blood_pressure: Option, + pub heart_rate: Option, + pub blood_sugar: Option, + pub weight: Option, +} + +#[derive(Debug, Serialize)] +pub struct IndicatorSummary { + pub value: f64, + pub status: String, // "normal" | "high" | "low" + pub reference_range: Option, // 如 "60-100" + // 血压专用 + pub systolic: Option, + pub diastolic: Option, +} +``` + +- [ ] **Step 2: 实现 handler** + +在 `crates/erp-health/src/handler/health_data_handler.rs` 新增 `get_mini_today`。参照已有的 `get_mini_trend`(第 337-352 行)模式:通过 `Extension` 获取 `ctx.user_id`,调用 service 层获取今日最新体征数据。 + +- [ ] **Step 3: 实现 service 方法** + +在 `crates/erp-health/src/service/health_data_service.rs` 新增查询方法:按 user_id 查找关联 patient,查询今日最新体征记录,按指标类型聚合为 summary,计算 status(normal/high/low)。 + +- [ ] **Step 4: 注册路由** + +在 `crates/erp-health/src/module.rs` 的 `protected_routes` 中,在 `get_mini_trend` 路由旁新增: + +```rust +.route("/health/vital-signs/today", get(health_data_handler::get_mini_today)) +``` + +- [ ] **Step 5: 验证** + +Run: `cargo check -p erp-health` +Expected: 编译通过 + +- [ ] **Step 6: 提交** + +```bash +git add crates/erp-health/src/ +git commit -m "feat(health): 新增小程序专用今日体征摘要端点 GET /health/vital-signs/today" +``` + +--- + +### Task 2: 前端对接 `getTodaySummary` 新端点 + +> **Depends on: Task 1** + +**Files:** +- Modify: `apps/miniprogram/src/services/health.ts` + +- [ ] **Step 1: 修改 API 路径** + +将 `services/health.ts` 第 19 行的 API 路径从不存在的端点改为新端点: + +```typescript +// 旧: return api.get('/health/vital-signs?date=today'); +// 新: +export async function getTodaySummary() { + return api.get('/health/vital-signs/today'); +} +``` + +- [ ] **Step 2: 扩展 TodaySummary 类型** + +更新 `TodaySummary` 接口,增加 `reference_range` 字段: + +```typescript +export interface TodaySummary { + blood_pressure?: { systolic: number; diastolic: number; status: string; reference_range?: string }; + heart_rate?: { value: number; status: string; reference_range?: string }; + blood_sugar?: { value: number; status: string; reference_range?: string }; + weight?: { value: number; status: string; reference_range?: string }; +} +``` + +- [ ] **Step 3: 验证构建** + +Run: `cd apps/miniprogram && pnpm build:weapp` +Expected: 编译通过 + +- [ ] **Step 4: 提交** + +```bash +git add apps/miniprogram/src/services/health.ts +git commit -m "fix(health): 对接今日体征摘要新端点 /health/vital-signs/today" +``` + +--- + +### Task 3: 删除重复页面 + 修复路由 + +**Files:** +- Delete: `apps/miniprogram/src/pages/report/index.tsx` +- Delete: `apps/miniprogram/src/pages/report/index.scss` +- Delete: `apps/miniprogram/src/pages/followup/index.tsx` +- Delete: `apps/miniprogram/src/pages/followup/index.scss` +- Modify: `apps/miniprogram/src/app.config.ts` +- Modify: `apps/miniprogram/src/pages/index/index.tsx` (修复 EmptyState 导入 bug) + +- [ ] **Step 1: 删除重复文件** + +```bash +rm apps/miniprogram/src/pages/report/index.tsx +rm apps/miniprogram/src/pages/report/index.scss +rm apps/miniprogram/src/pages/followup/index.tsx +rm apps/miniprogram/src/pages/followup/index.scss +``` + +- [ ] **Step 2: 从 app.config.ts 移除路由** + +删除 `app.config.ts` 中第 13-14 行的 `pages/report/index` 和 `pages/followup/index`。 + +```typescript +// 删除这两行: +// 'pages/report/index', +// 'pages/followup/index', +``` + +- [ ] **Step 3: 修复首页 EmptyState 导入 bug** + +修改 `pages/index/index.tsx` 第 5 行,从命名导入改为默认导入: + +```typescript +// 旧: import { EmptyState } from '../../components/EmptyState'; +// 新: +import EmptyState from '../../components/EmptyState'; +``` + +- [ ] **Step 4: 验证构建** + +Run: `cd apps/miniprogram && pnpm build:weapp` +Expected: 编译通过,无 Unresolved import 错误 + +- [ ] **Step 5: 提交** + +```bash +git add apps/miniprogram/src/ +git commit -m "fix(miniprogram): 删除重复页面 report/followup,修复 EmptyState 导入 bug" +``` + +--- + +### Task 4: 统一错误处理 — ErrorBoundary + ErrorState + tryRefreshToken + +**Files:** +- Create: `apps/miniprogram/src/components/ErrorBoundary/index.tsx` +- Modify: `apps/miniprogram/src/app.tsx` +- Modify: `apps/miniprogram/src/services/request.ts` + +- [ ] **Step 1: 创建 ErrorBoundary 组件** + +创建 `components/ErrorBoundary/index.tsx`: + +```tsx +import React, { Component } from 'react'; +import { View, Text } from '@tarojs/components'; + +interface Props { + children: React.ReactNode; +} + +interface State { + hasError: boolean; +} + +export default class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(): State { + return { hasError: true }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo) { + console.error('[ErrorBoundary]', error, info.componentStack); + } + + render() { + if (this.state.hasError) { + return ( + + 😵 + 页面出了点问题 + 请返回重试 + + ); + } + return this.props.children; + } +} +``` + +- [ ] **Step 2: 在 app.tsx 包裹 ErrorBoundary** + +修改 `app.tsx`: + +```tsx +import { PropsWithChildren } from 'react'; +import ErrorBoundary from './components/ErrorBoundary'; +import './app.scss'; + +function App({ children }: PropsWithChildren>) { + return {children}; +} + +export default App; +``` + +- [ ] **Step 3: 修复 tryRefreshToken 静默吞异常** + +修改 `services/request.ts` 第 36-38 行: + +```typescript + } catch (err) { + console.error('[tryRefreshToken] token 刷新失败:', err); + } +``` + +- [ ] **Step 4: 验证构建** + +Run: `cd apps/miniprogram && pnpm build:weapp` +Expected: 编译通过 + +- [ ] **Step 5: 提交** + +```bash +git add apps/miniprogram/src/components/ErrorBoundary/ apps/miniprogram/src/app.tsx apps/miniprogram/src/services/request.ts +git commit -m "fix(miniprogram): 添加全局 ErrorBoundary,修复 tryRefreshToken 静默吞异常" +``` + +--- + +### Task 5: 后端新增预约/随访单条查询端点 + +**Files:** +- Modify: `crates/erp-health/src/handler/appointment_handler.rs` +- Modify: `crates/erp-health/src/handler/follow_up_handler.rs` +- Modify: `crates/erp-health/src/module.rs` + +- [ ] **Step 1: 预约 handler 新增 get_appointment** + +在 `appointment_handler.rs` 新增 `get_appointment` 函数:从路径参数获取 `appointment_id`,调用 service 查询单条,返回 `ApiResponse::ok(data)`。 + +- [ ] **Step 2: 随访 handler 新增 get_task** + +在 `follow_up_handler.rs` 新增 `get_task` 函数:从路径参数获取 `task_id`,调用 service 查询单条,返回 `ApiResponse::ok(data)`。 + +- [ ] **Step 3: 注册路由** + +在 `module.rs` 的 `protected_routes` 中,在已有的 appointments 路由组添加 `.route("/health/appointments/{id}", get(appointment_handler::get_appointment))`,在 follow-up-tasks 路由组添加 `.route("/health/follow-up-tasks/{id}", get(follow_up_handler::get_task))`。 + +- [ ] **Step 4: 验证** + +Run: `cargo check -p erp-health` +Expected: 编译通过 + +- [ ] **Step 5: 提交** + +```bash +git add crates/erp-health/src/ +git commit -m "feat(health): 新增预约/随访单条查询 GET 端点" +``` + +--- + +### Task 6: 前端修复预约详情/随访详情数据获取 + +> **Depends on: Task 5** + +**Files:** +- Modify: `apps/miniprogram/src/services/appointment.ts` +- Modify: `apps/miniprogram/src/services/followup.ts` +- Modify: `apps/miniprogram/src/pages/appointment/detail/index.tsx` +- Modify: `apps/miniprogram/src/pages/followup/detail/index.tsx` + +- [ ] **Step 1: services/appointment.ts 新增 getAppointment** + +```typescript +export async function getAppointment(id: string) { + return api.get(`/health/appointments/${id}`); +} +``` + +- [ ] **Step 2: services/followup.ts 新增 getTaskDetail** + +```typescript +export async function getTaskDetail(id: string) { + return api.get(`/health/follow-up-tasks/${id}`); +} +``` + +- [ ] **Step 3: 重写预约详情页** + +修改 `pages/appointment/detail/index.tsx`: +- 移除 `Taro.getStorageSync('appointment_detail_cache')` 相关代码 +- 改为 `useEffect` 中调用 `getAppointment(id)` 获取数据 +- 增加 loading 状态(使用 `Loading` 组件) +- 增加错误状态(使用 `ErrorState` 组件) + +```tsx +// 核心变更:用 useEffect + API 替换 Storage 缓存 +useEffect(() => { + if (!id) return; + setLoading(true); + getAppointment(id) + .then((data) => setAppointment(data)) + .catch((err) => { + console.error('[AppointmentDetail]', err); + setError(true); + }) + .finally(() => setLoading(false)); +}, [id]); +``` + +- [ ] **Step 4: 重写随访详情页** + +修改 `pages/followup/detail/index.tsx`: +- 移除 `listTasks().find()` 低效查询 +- 改为调用 `getTaskDetail(id)` 直接获取 +- 增加 loading(使用 `Loading`)和 error(使用 `ErrorState`)状态 + +- [ ] **Step 5: 验证构建** + +Run: `cd apps/miniprogram && pnpm build:weapp` +Expected: 编译通过 + +- [ ] **Step 6: 提交** + +```bash +git add apps/miniprogram/src/ +git commit -m "fix(miniprogram): 预约详情/随访详情改为 API 获取数据,移除 Storage 缓存传递" +``` + +--- + +### Task 7: 统一 Loading 状态 + +**Files:** +- Modify: `apps/miniprogram/src/pages/index/index.tsx` +- Modify: `apps/miniprogram/src/pages/health/index.tsx` +- Modify: `apps/miniprogram/src/pages/report/detail/index.tsx` +- Modify: `apps/miniprogram/src/pages/appointment/create/index.tsx` + +- [ ] **Step 1: 首页增加 loading** + +修改 `pages/index/index.tsx`:从 `useHealthStore` 额外解构 `loading`,在 `todaySummary` 为 null 且 `loading` 为 true 时展示 `Loading` 组件。 + +```tsx +import Loading from '../../components/Loading'; +// ... +const { todaySummary, loading, refreshToday } = useHealthStore(); +// ... +// 在 health-card 渲染前增加判断 +{loading && !todaySummary ? ( + +) : ( + ... +)} +``` + +- [ ] **Step 2: 健康页增加 loading** + +修改 `pages/health/index.tsx`:已有 `loading` 解构但未使用,在数据为空且 loading 时展示 `Loading`。 + +```tsx +import Loading from '../../components/Loading'; +// ... +// 在 health-grid 渲染前 +{loading && !todaySummary ? ( + +) : ( + ... +)} +``` + +- [ ] **Step 3: 详情页统一 Loading** + +修改 `pages/report/detail/index.tsx` 第 38-43 行,将内联 `加载中...` 替换为 ``: + +```tsx +import Loading from '../../../components/Loading'; +// ... +if (loading) { + return ( + + + + ); +} +``` + +- [ ] **Step 3.5: 预约创建页步骤切换 loading** + +修改 `pages/appointment/create/index.tsx`:在步骤切换(`goNext`、`goPrev`)时,如果正在加载医生列表,展示 `Loading` 组件。已有 `loading` state,只需在步骤内容区域加入 loading 判断。 + +- [ ] **Step 4: 验证构建** + +Run: `cd apps/miniprogram && pnpm build:weapp` +Expected: 编译通过 + +- [ ] **Step 5: 提交** + +```bash +git add apps/miniprogram/src/ +git commit -m "fix(miniprogram): 首页/健康页/详情页统一使用 Loading 组件" +``` + +--- + +### Task 8: 路径别名启用 + +> **应在 Task 2-7 全部完成后执行,避免与其他 Task 修改同一文件产生冲突。** + +**Files:** +- Modify: `apps/miniprogram/src/services/health.ts` +- Modify: `apps/miniprogram/src/services/appointment.ts` +- Modify: `apps/miniprogram/src/services/followup.ts` +- Modify: `apps/miniprogram/src/services/report.ts` +- Modify: `apps/miniprogram/src/services/patient.ts` +- Modify: `apps/miniprogram/src/services/article.ts` +- Modify: `apps/miniprogram/src/services/auth.ts` +- Modify: `apps/miniprogram/src/stores/auth.ts` +- Modify: `apps/miniprogram/src/stores/health.ts` + +- [ ] **Step 1: 替换 services 层 import** + +所有 services 文件中的 `import { api } from './request'` 保持不变(同目录相对路径更清晰)。不强制改为 `@/`。 + +- [ ] **Step 2: 替换 stores 层 import** + +将 stores 中对 services 的导入改为 `@/` 别名: + +```typescript +// stores/auth.ts +import * as authApi from '@/services/auth'; + +// stores/health.ts +import * as healthApi from '@/services/health'; +``` + +- [ ] **Step 3: 验证构建** + +Run: `cd apps/miniprogram && pnpm build:weapp` +Expected: 编译通过(tsconfig.json 已配置 `@/*` → `./src/*`) + +- [ ] **Step 4: 提交** + +```bash +git add apps/miniprogram/src/stores/ +git commit -m "chore(miniprogram): stores 层启用 @/ 路径别名" +``` + +--- + +## Chunk 2: Sprint 1 — 健康数据模块打磨 + +### Task 9: ECharts 技术预研(Spike) + +**Files:** +- 无代码提交,仅验证可行性 + +- [ ] **Step 1: 安装 echarts-taro3-react** + +Run: `cd apps/miniprogram && pnpm add echarts-taro3-react echarts` + +- [ ] **Step 2: 创建最小验证页面** + +在 `pages/health/trend/index.tsx` 中创建最简 echarts 实例: + +```tsx +import React, { useEffect, useRef } from 'react'; +import { View } from '@tarojs/components'; +import * as echarts from 'echarts/core'; +import { LineChart } from 'echarts/charts'; +import { GridComponent, TooltipComponent } from 'echarts/components'; +import { CanvasRenderer } from 'echarts/renderers'; + +echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]); + +// 如果 Taro 4 不支持直接操作 DOM,则改用 echarts-for-weixin 方案 +``` + +- [ ] **Step 3: 执行 `pnpm dev:weapp` 并在微信开发者工具验证** + +如果 echarts-taro3-react 在 Taro 4 + webpack5 下能正常渲染折线图 → 采用方案 A(echarts-taro3-react)。 +如果报错或不渲染 → 切换到方案 B(echarts-for-weixin + 手动 Canvas 封装)。 + +- [ ] **Step 4: 记录结论** + +将可行性结论记录到 commit message 中。如不可用则卸载 echarts-taro3-react,改安装备选方案。 + +```bash +# 如果方案 A 可用: +git commit --allow-empty -m "spike(miniprogram): echarts-taro3-react 在 Taro 4 webpack5 下验证通过" + +# 如果方案 A 不可用,切换方案 B: +pnpm remove echarts-taro3-react +pnpm add echarts-for-weixin +git commit --allow-empty -m "spike(miniprogram): echarts-taro3-react 不兼容 Taro 4,改用 echarts-for-weixin" +``` + +--- + +### Task 10: 健康卡片状态色 + +**Files:** +- Modify: `apps/miniprogram/src/pages/health/index.tsx` +- Modify: `apps/miniprogram/src/pages/health/index.scss` +- Modify: `apps/miniprogram/src/pages/index/index.tsx` + +> **Depends on: Task 1 + Task 2**(后端 `/health/vital-signs/today` 端点必须已实现并返回 `status` 和 `reference_range` 字段) + +- [ ] **Step 1: 扩展 TodaySummary 类型(如果 Task 2 未覆盖)** + +确认 `services/health.ts` 的 `TodaySummary` 接口包含 `reference_range` 字段(Task 2 Step 2 已定义)。如 Task 2 中未添加,在此补充: + +```typescript +// 确认 TodaySummary 中每个指标都包含: +reference_range?: string; // 如 "60-100" +``` + +- [ ] **Step 2: 在健康页卡片中增加状态色逻辑** + +修改 `pages/health/index.tsx` 的 `items` 数组,根据 `status` 字段计算边条颜色和趋势标签: + +```tsx +const getStatusStyle = (status?: string) => { + if (status === 'high') return { borderColor: '$dan', label: '偏高 ▲', labelColor: '$dan' }; + if (status === 'low') return { borderColor: '$dan', label: '偏低 ▼', labelColor: '$dan' }; + if (status === 'normal') return { borderColor: '$acc', label: '正常 ─', labelColor: '$acc' }; + return { borderColor: '$bd', label: '', labelColor: '' }; +}; +``` + +在卡片 `View` 上增加 `style={{ borderLeftColor: style.borderColor }}`,在底部增加状态标签 + 参考范围(`reference_range` 显示在卡片底部灰色文字)。 + +- [ ] **Step 3: 添加 SCSS 样式** + +在 `pages/health/index.scss` 中增加状态色相关样式: + +```scss +.health-card { + // 已有样式基础上增加: + border-left: 6px solid $bd; + transition: border-left-color 0.2s; + + &.status-normal { border-left-color: $acc; } + &.status-high { border-left-color: $dan; } + &.status-low { border-left-color: $dan; } +} + +.health-card-status-tag { + font-size: 20px; + margin-top: 8px; + + &.normal { color: $acc; } + &.high, &.low { color: $dan; } +} + +.health-card-ref { + font-size: 20px; + color: $tx3; + margin-top: 4px; +} +``` + +- [ ] **Step 4: 首页健康卡片同步更新** + +修改 `pages/index/index.tsx` 的 `healthItems` 数组,增加 status 和 reference_range 显示。卡片同样增加状态色边条和标签。 + +- [ ] **Step 5: 验证构建** + +Run: `cd apps/miniprogram && pnpm build:weapp` +Expected: 编译通过 + +- [ ] **Step 6: 提交** + +```bash +git add apps/miniprogram/src/pages/health/ apps/miniprogram/src/pages/index/ +git commit -m "feat(health): 健康卡片增加状态色(正常绿/异常红)+ 参考范围显示" +``` + +--- + +### Task 11: ECharts 趋势图组件 + +**Files:** +- Create: `apps/miniprogram/src/components/TrendChart/index.tsx` +- Create: `apps/miniprogram/src/components/TrendChart/index.scss` + +> **Depends on: Task 9**(echarts 技术预研结果) + +- [ ] **Step 1: 创建 TrendChart 组件** + +创建 `components/TrendChart/index.tsx`,封装 echarts 折线图: + +```tsx +import React, { useEffect, useRef } from 'react'; +import { View } from '@tarojs/components'; + +interface TrendChartProps { + data: { date: string; value: number }[]; + referenceMin?: number; + referenceMax?: number; + unit?: string; + height?: number; // 默认 500px +} + +export default function TrendChart({ data, referenceMin, referenceMax, unit = '', height = 500 }: TrendChartProps) { + // 基于 Task 9 spike 结论选择 echarts 初始化方式 + // 空数据状态:显示"暂无数据"灰色文字 + // 核心配置: + // - line series,data points 连线 + // - markArea: 参考范围色带 (referenceMin ~ referenceMax 半透明绿) + // - markPoint: 异常值标红放大 (超出参考范围的点) + // - tooltip: 点击显示 {date}: {value} {unit} +} +``` + +- [ ] **Step 2: 创建组件样式** + +创建 `components/TrendChart/index.scss`: + +```scss +.trend-chart { + width: 100%; + height: 500px; // 图表容器必须有明确高度 +} +``` + +- [ ] **Step 3: 验证组件独立渲染** + +在微信开发者工具中验证折线图能正常显示。 + +- [ ] **Step 4: 提交** + +```bash +git add apps/miniprogram/src/components/TrendChart/ +git commit -m "feat(health): 新增 TrendChart ECharts 折线图组件" +``` + +--- + +### Task 12: 趋势图页面重写 + 缓存 TTL + +> **Depends on: Task 11**(需要 TrendChart 组件) + +**Files:** +- Modify: `apps/miniprogram/src/pages/health/trend/index.tsx` +- Modify: `apps/miniprogram/src/pages/health/trend/index.scss` +- Modify: `apps/miniprogram/src/stores/health.ts` + +- [ ] **Step 1: 重写趋势图页面** + +替换 `pages/health/trend/index.tsx` 中的纯 CSS 柱状图为 TrendChart 组件: + +```tsx +import TrendChart from '@/components/TrendChart'; +// ... +return ( + + + {indicator.replace(/_/g, ' ')} 趋势 + {/* 7d/30d/90d tab 切换 */} + + + + + {/* 保留数据列表作为可折叠的详情区 */} + +); +``` + +- [ ] **Step 2: stores/health.ts 添加缓存 TTL** + +修改 `getTrend` 方法,缓存增加 5 分钟过期机制: + +```typescript +interface CachedTrend { + data: { date: string; value: number }[]; + cachedAt: number; // Date.now() +} + +// 更新 HealthState 中 trendData 类型: +// trendData: Record + +// 在 getTrend 中: +const cacheKey = `${indicator}_${range}`; +const cached = get().trendData[cacheKey]; +if (cached && Date.now() - cached.cachedAt < 5 * 60 * 1000) { + return cached.data; // 缓存未过期,直接返回 +} +// 缓存过期或不存在,发起请求... +// 请求成功后: +set((s) => ({ trendData: { ...s.trendData, [cacheKey]: { data: points, cachedAt: Date.now() } } })); +``` +interface CachedTrend { + data: { date: string; value: number }[]; + cachedAt: number; // Date.now() +} + +// 在 getTrend 中: +const cached = get().trendData[cacheKey]; +if (cached && Date.now() - cached.cachedAt < 5 * 60 * 1000) { + return cached.data; +} +// 重新请求... +set((s) => ({ trendData: { ...s.trendData, [cacheKey]: { data: points, cachedAt: Date.now() } } })); +``` + +同步更新 `HealthState` 接口中 `trendData` 的类型。 + +- [ ] **Step 3: 验证构建** + +Run: `cd apps/miniprogram && pnpm build:weapp` +Expected: 编译通过 + +- [ ] **Step 4: 提交** + +```bash +git add apps/miniprogram/src/ +git commit -m "feat(health): 趋势图升级为 ECharts 折线图 + 缓存 TTL 5分钟" +``` + +--- + +### Task 13: 表单验证升级(zod) + +> **Depends on: Task 12**(clearCache 方法定义在 Task 12 的 store 修改中) + +**Files:** +- Modify: `apps/miniprogram/src/pages/health/input/index.tsx` +- Modify: `apps/miniprogram/src/stores/health.ts` + +- [ ] **Step 1: 安装 zod** + +Run: `cd apps/miniprogram && pnpm add zod` + +- [ ] **Step 2: 定义验证 schema** + +在 `pages/health/input/index.tsx` 顶部新增: + +```tsx +import { z } from 'zod'; + +const vitalSignSchema = z.object({ + indicator_type: z.enum(['blood_pressure', 'heart_rate', 'blood_sugar_fasting', 'blood_sugar_postprandial', 'weight', 'temperature']), + // 注意:枚举值必须与 pages/health/input/index.tsx 中 INDICATORS 数组的 value 字段完全一致 + value: z.number().positive({ message: '请输入有效数值' }), + extra: z.object({ + systolic: z.number().min(60, '收缩压过低').max(250, '收缩压过高,请及时就医').optional(), + diastolic: z.number().min(40, '舒张压过低').max(150, '舒张压过高,请及时就医').optional(), + }).optional(), + note: z.string().max(200, '备注不能超过200字').optional(), +}); + +// 异常值警告阈值 +const WARN_THRESHOLDS: Record = { + blood_pressure: { max: 180, warning: '收缩压偏高,建议及时就医' }, + heart_rate: { max: 120, min: 50, warning: '心率异常,请注意休息' }, + blood_sugar_fasting: { max: 11.0, warning: '血糖偏高,建议就医检查' }, +}; +``` + +- [ ] **Step 3: 重构 handleSubmit** + +将手动 if 验证替换为 zod schema 验证: + +```tsx +const handleSubmit = async () => { + if (!currentPatient) { /* ... */ return; } + + const currentIndicator = INDICATORS[indicatorIdx].value; + + // 构建输入对象 + const input = currentIndicator === 'blood_pressure' + ? { indicator_type: 'blood_pressure', value: parseFloat(systolic), extra: { systolic: parseFloat(systolic), diastolic: parseFloat(diastolic) } } + : { indicator_type: currentIndicator, value: parseFloat(value) }; + + // zod 验证 + const result = vitalSignSchema.safeParse(input); + if (!result.success) { + Taro.showToast({ title: result.error.errors[0].message, icon: 'none' }); + return; + } + + // 异常值警告 + const threshold = WARN_THRESHOLDS[currentIndicator]; + if (threshold) { + const val = input.value; + if ((threshold.max && val > threshold.max) || (threshold.min && val < threshold.min)) { + await Taro.showModal({ title: '健康提示', content: threshold.warning, showCancel: false }); + } + } + + // 提交... +}; +``` + +- [ ] **Step 4: 录入成功后清除缓存** + +修改 `stores/health.ts`,新增 `clearCache` 方法: + +```typescript +clearCache: () => set({ trendData: {}, todaySummary: null }), +``` + +在 `pages/health/input/index.tsx` 录入成功回调中调用: + +```tsx +const { clearCache } = useHealthStore(); +// ... 录入成功后: +clearCache(); +``` + +- [ ] **Step 5: 验证构建** + +Run: `cd apps/miniprogram && pnpm build:weapp` +Expected: 编译通过 + +- [ ] **Step 6: 提交** + +```bash +git add apps/miniprogram/src/ apps/miniprogram/package.json apps/miniprogram/pnpm-lock.yaml +git commit -m "feat(health): 表单验证升级为 zod schema + 异常值警告 + 录入后清除缓存" +``` + +--- + +## Chunk 3: Sprint 2 — 预约挂号 + 通知触达 + +### Task 14: StepIndicator 步骤指示器组件 + +**Files:** +- Create: `apps/miniprogram/src/components/StepIndicator/index.tsx` +- Create: `apps/miniprogram/src/components/StepIndicator/index.scss` + +- [ ] **Step 1: 创建组件** + +创建 `components/StepIndicator/index.tsx`: + +```tsx +import React from 'react'; +import { View, Text } from '@tarojs/components'; +import './index.scss'; + +interface Step { + label: string; +} + +interface StepIndicatorProps { + steps: Step[]; + current: number; + onChange?: (index: number) => void; +} + +export default function StepIndicator({ steps, current, onChange }: StepIndicatorProps) { + return ( + + {steps.map((step, idx) => { + const isCurrent = idx === current; + const isDone = idx < current; + const isClickable = isDone && onChange; + + return ( + + {idx > 0 && ( + + )} + onChange(idx) : undefined} + > + {isDone ? : {idx + 1}} + + + {step.label} + + + ); + })} + + ); +} +``` + +- [ ] **Step 2: 创建样式** + +```scss +@import '../../styles/variables.scss'; + +.step-indicator { + display: flex; + align-items: center; + justify-content: space-around; + padding: 24px 32px; + background: $card; +} + +.step-item { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + justify-content: center; +} + +.step-line { + flex: 1; + height: 4px; + background: $bd-l; + transition: background 0.3s ease; + + &.step-line-done { + background: $acc; + } +} + +.step-dot { + width: 48px; + height: 48px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: $bd-l; + color: $tx3; + font-size: 24px; + transition: all 0.3s ease; +} + +.step-dot.step-current { + background: $pri; + color: white; +} + +.step-dot.step-done { + background: $acc; + color: white; +} + +.step-label { + font-size: 22px; + color: $tx3; + position: absolute; + margin-top: 60px; +} + +.step-label.step-current { + color: $pri; + font-weight: bold; +} + +.step-label.step-done { + color: $acc; +} +``` + +- [ ] **Step 3: 验证构建** + +Run: `cd apps/miniprogram && pnpm build:weapp` +Expected: 编译通过 + +- [ ] **Step 4: 提交** + +```bash +git add apps/miniprogram/src/components/StepIndicator/ +git commit -m "feat(appointment): 新增 StepIndicator 步骤指示器组件" +``` + +--- + +### Task 15: WeekCalendar 周视图日历组件 + +**Files:** +- Create: `apps/miniprogram/src/components/WeekCalendar/index.tsx` +- Create: `apps/miniprogram/src/components/WeekCalendar/index.scss` + +- [ ] **Step 1: 创建组件** + +创建 `components/WeekCalendar/index.tsx`,纯 React 实现的周视图日历: + +```tsx +import React, { useState } from 'react'; +import { View, Text } from '@tarojs/components'; +import './index.scss'; + +interface WeekCalendarProps { + // 有排班的日期集合,格式 'YYYY-MM-DD' + scheduledDates: Set; + selectedDate: string; + onSelectDate: (date: string) => void; +} + +function getWeekDates(offset: number): string[] { + const now = new Date(); + const monday = new Date(now); + monday.setDate(now.getDate() - now.getDay() + 1 + offset * 7); // 获取周一 + const dates: string[] = []; + for (let i = 0; i < 7; i++) { + const d = new Date(monday); + d.setDate(monday.getDate() + i); + dates.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`); + } + return dates; +} + +const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日']; + +export default function WeekCalendar({ scheduledDates, selectedDate, onSelectDate }: WeekCalendarProps) { + const [weekOffset, setWeekOffset] = useState(0); + const dates = getWeekDates(weekOffset); + const today = (() => { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + })(); + + return ( + + + setWeekOffset(weekOffset - 1)}>◂ + {dates[0].slice(5)} ~ {dates[6].slice(5)} + setWeekOffset(weekOffset + 1)}>▸ + + + {WEEKDAYS.map((day, idx) => { + const dateStr = dates[idx]; + const isScheduled = scheduledDates.has(dateStr); + const isSelected = dateStr === selectedDate; + const isToday = dateStr === today; + const isPast = dateStr < today; + return ( + onSelectDate(dateStr) : undefined} + > + {day} + {parseInt(dateStr.slice(8))} + {isScheduled && } + + ); + })} + + + ); +} +``` + +- [ ] **Step 2: 创建样式** + +```scss +@import '../../styles/variables.scss'; + +.week-calendar { background: $card; border-radius: 12px; padding: 16px; } +.week-nav { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } +.week-arrow { font-size: 28px; color: $pri; padding: 0 16px; } +.week-label { font-size: 24px; color: $tx1; font-weight: bold; } +.week-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 8px; text-align: center; } +.week-cell { padding: 8px 4px; border-radius: 8px; position: relative; } +.cell-weekday { font-size: 20px; color: $tx3; display: block; } +.cell-date { font-size: 26px; color: $tx1; display: block; margin-top: 4px; } +.cell-today { color: $pri; font-weight: bold; } +.cell-dot { position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%); width: 8px; height: 8px; background: $acc; border-radius: 50%; } +.cell-selected { background: $pri; border-radius: 12px; } +.cell-selected .cell-date { color: white; } +.cell-selected .cell-dot { background: white; } +.cell-empty .cell-date { color: $bd; } +.cell-past { opacity: 0.4; } +``` + +- [ ] **Step 3: 验证构建** + +Run: `cd apps/miniprogram && pnpm build:weapp` +Expected: 编译通过 + +- [ ] **Step 4: 提交** + +```bash +git add apps/miniprogram/src/components/WeekCalendar/ +git commit -m "feat(appointment): 新增 WeekCalendar 周视图日历组件" +``` + +--- + +### Task 16: 预约创建页重写 + +**Files:** +- Modify: `apps/miniprogram/src/pages/appointment/create/index.tsx` +- Modify: `apps/miniprogram/src/pages/appointment/create/index.scss` + +> **Depends on: Task 14, Task 15**(StepIndicator + WeekCalendar 组件) + +- [ ] **Step 1: 替换步骤指示器** + +将内联的 `step-bar` / `step-line-wrapper` 替换为 `` 组件: + +```tsx +import StepIndicator from '../../../components/StepIndicator'; +// ... + { + // 回退时清除下级已选状态 + if (idx < currentStep) { + if (idx <= 1) setSelectedDoctor(null); + if (idx <= 2) { setAppointmentDate(''); setTimeSlot(''); } + } + setCurrentStep(idx); + }} +/> +``` + +- [ ] **Step 2: 科室选择改为宫格卡片** + +将 `Picker` 组件替换为 2×3 宫格卡片布局,每个科室卡片包含图标 + 科室名 + 医生数。选中后高亮边框。 + +- [ ] **Step 3: Step 3 整合 WeekCalendar + 时段卡片** + +替换 `Picker mode='date'` 为 ``,排班数据从 `calendarView()` API 获取。 +需要从排班数据提取排班日期集合传给 WeekCalendar: + +```tsx +const scheduledDates = useMemo(() => { + if (!schedules) return new Set(); + return new Set(schedules.map((s) => s.date)); +}, [schedules]); +``` + +替换文本输入时段为时段卡片列表,时段卡片按剩余名额着色,名额为 0 时禁用点击: + +```tsx +const getSlotStyle = (available: number) => { + if (available === 0) return 'slot-full'; + if (available <= 3) return 'slot-few'; + return 'slot-available'; +}; + +// 渲染时段卡片时: +{timeSlots.map((slot) => ( + 0 ? () => setSelectedSlot(slot.time_slot) : undefined} + > + {slot.time_slot} + {slot.available_count > 0 ? `剩余 ${slot.available_count} 位` : '已满'} + +))} +``` + +- [ ] **Step 4: 清理旧 SCSS** + +删除 `pages/appointment/create/index.scss` 中不再使用的旧样式:`.step-bar`、`.step-line-wrapper`、`.picker-card` 等已被 StepIndicator、WeekCalendar 和时段卡片替换的类。 + +- [ ] **Step 5: 验证构建** + +Run: `cd apps/miniprogram && pnpm build:weapp` +Expected: 编译通过 + +- [ ] **Step 6: 提交** + +```bash +git add apps/miniprogram/src/pages/appointment/create/ +git commit -m "feat(appointment): 预约创建页重写 — 宫格科室+周视图日历+时段卡片" +``` + +--- + +### Task 17: 微信订阅消息(前端引导) + +**Files:** +- Modify: `apps/miniprogram/src/pages/appointment/create/index.tsx` +- Modify: `apps/miniprogram/src/pages/followup/detail/index.tsx` +- Modify: `apps/miniprogram/src/pages/profile/index.tsx` + +> **注意**:此 Task 仅实现前端订阅引导 UI。后端订阅消息模板注册和定时推送作为独立后端 Task,不在此计划中。后端需先在微信公众平台注册模板,获取模板 ID。 + +- [ ] **Step 0: 创建订阅消息模板 ID 常量** + +创建 `services/wechat-templates.ts`,集中管理模板 ID: + +```typescript +// 后端在微信公众平台注册后填入实际值 +// 注册前保持为空数组,订阅调用将自动跳过 +export const TEMPLATE_IDS = { + APPOINTMENT_REMINDER: '', // 预约就诊提醒 + FOLLOWUP_REMINDER: '', // 随访任务提醒 + REPORT_NOTIFICATION: '', // 报告出具通知 +}; +``` + +- [ ] **Step 1: 预约创建成功后订阅引导** + +在 `pages/appointment/create/index.tsx` 中,预约创建成功(API 返回成功)后调用 `Taro.requestSubscribeMessage`: + +```tsx +import { TEMPLATE_IDS } from '@/services/wechat-templates'; + +const requestSubscribe = async () => { + const tmplId = TEMPLATE_IDS.APPOINTMENT_REMINDER; + if (!tmplId) return; // 模板未注册时跳过 + + try { + const res = await Taro.requestSubscribeMessage({ tmplIds: [tmplId] }); + if (res[tmplId] === 'accept') { + console.info('[Subscribe] 用户接受预约提醒订阅'); + } + } catch { + console.info('[Subscribe] 用户拒绝订阅'); + } +}; + +// 在预约创建 API 成功回调中: +// createAppointment(...).then(() => { requestSubscribe(); Taro.navigateTo({ url: '...' }); }) +``` + +- [ ] **Step 2: 随访提交后订阅引导** + +在 `pages/followup/detail/index.tsx` 中,随访记录提交成功后同样调用 `Taro.requestSubscribeMessage`,使用 `TEMPLATE_IDS.FOLLOWUP_REMINDER`。 + +- [ ] **Step 3: 降级设计 — profile 页消息红点** + +在 `pages/profile/index.tsx` 的菜单项旁增加未读消息数显示(后续对接 erp-message API,MVP 阶段仅预留 UI 位置和数据占位 `unreadCount: 0`)。 + +> **后端 erp-message 集成说明**:当用户拒绝订阅时,消息应写入 `erp-message` 消息中心。此集成需要后端配合:预约创建/随访提交时,无论用户是否订阅微信消息,都同时写入 erp-message。前端后续通过 `GET /api/v1/messages?unread=true` 获取未读数。本 Task 仅预留 UI 位置。 + +- [ ] **Step 4: 验证构建** + +Run: `cd apps/miniprogram && pnpm build:weapp` +Expected: 编译通过 + +- [ ] **Step 5: 提交** + +```bash +git add apps/miniprogram/src/ +git commit -m "feat(notification): 预约/随访成功后微信订阅消息引导 + 消息红点预留" +``` + +--- + +## Chunk 4: Sprint 3 — 报告/随访/个人中心 + 安全 + 增长 + +### Task 18: 报告详情页指标状态色 + +**Files:** +- Modify: `apps/miniprogram/src/pages/report/detail/index.tsx` +- Modify: `apps/miniprogram/src/pages/report/detail/index.scss` + +- [ ] **Step 1: 指标卡片按状态着色** + +修改 `pages/report/detail/index.tsx` 的 `indicators.map` 渲染逻辑,根据 `item.status` 切换卡片背景色和标签: + +```tsx +const getStatusDisplay = (status?: string) => { + switch (status) { + case 'high': return { text: '↑ 偏高', cls: 'status-high' }; + case 'low': return { text: '↓ 偏低', cls: 'status-low' }; + default: return { text: '✓ 正常', cls: 'status-normal' }; + } +}; + +// 在渲染中: + +``` + +- [ ] **Step 2: 添加汇总标签** + +在指标列表上方添加汇总: + +```tsx +const abnormalCount = indicators.filter((i) => i.status === 'high' || i.status === 'low').length; +const normalCount = indicators.length - abnormalCount; +// ... + + {abnormalCount > 0 && {abnormalCount} 项异常} + {normalCount > 0 && {normalCount} 项正常} + +``` + +- [ ] **Step 3: 添加 SCSS 样式** + +```scss +.indicator-item { + // 已有样式基础上: + &.status-normal { background: #F0FDF4; border-left: 4px solid $acc; } + &.status-high { background: #FEF2F2; border-left: 4px solid $dan; } + &.status-low { background: #FEF2F2; border-left: 4px solid $dan; } +} +.indicator-status-text { + &.status-normal { color: $acc; } + &.status-high, &.status-low { color: $dan; font-weight: bold; } +} +.indicator-summary { display: flex; gap: 16px; margin-bottom: 20px; } +.tag-abnormal { background: $dan; color: white; padding: 4px 16px; border-radius: 20px; font-size: 22px; } +.tag-normal { background: $acc; color: white; padding: 4px 16px; border-radius: 20px; font-size: 22px; } +``` + +- [ ] **Step 4: 验证构建** + +Run: `cd apps/miniprogram && pnpm build:weapp` + +- [ ] **Step 5: 提交** + +```bash +git add apps/miniprogram/src/pages/report/detail/ +git commit -m "feat(report): 报告详情指标按状态着色 + 异常汇总标签" +``` + +--- + +### Task 19: 随访 UX 细节 + +**Files:** +- Modify: `apps/miniprogram/src/pages/profile/followups/index.tsx` +- Modify: `apps/miniprogram/src/pages/profile/followups/index.scss` +- Modify: `apps/miniprogram/src/pages/followup/detail/index.tsx` +- Modify: `apps/miniprogram/src/pages/followup/detail/index.scss` + +- [ ] **Step 1: 任务卡片增加截止日期倒计时 + 过期变灰** + +修改 `pages/profile/followups/index.tsx`,在 `task-due` 后增加倒计时计算: + +```tsx +const getDueCountdown = (dueDate: string) => { + const now = new Date(); + // 避免时区偏移:只比较日期部分 + const [y, m, d] = dueDate.split('-').map(Number); + const due = new Date(y, m - 1, d + 1); // 设为次日 0 点,确保当天算"今天截止" + const diffDays = Math.ceil((due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + if (diffDays < 0) return { text: `已过期 ${Math.abs(diffDays)} 天`, cls: 'overdue' }; + if (diffDays === 0) return { text: '今天截止', cls: 'urgent' }; + if (diffDays <= 3) return { text: `还剩 ${diffDays} 天`, cls: 'urgent' }; + return { text: `还剩 ${diffDays} 天`, cls: 'normal' }; +}; +``` + +在 `pages/profile/followups/index.scss` 中添加过期任务灰色样式: + +```scss +.task-card.overdue { opacity: 0.5; } +.task-card.overdue .task-title { text-decoration: line-through; color: $tx3; } +``` + +- [ ] **Step 2: 提交成功确认动画** + +修改 `pages/followup/detail/index.tsx`,提交成功后展示 checkmark 动画。 +在 `pages/followup/detail/index.scss` 中添加动画样式: + +```tsx +const [showSuccess, setShowSuccess] = useState(false); + +// handleSubmit 成功后: +setShowSuccess(true); +setTimeout(() => setShowSuccess(false), 2000); + +// 渲染: +{showSuccess && ( + + + 提交成功 + +)} +``` + +```scss +.success-overlay { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + display: flex; flex-direction: column; align-items: center; justify-content: center; + background: rgba(255,255,255,0.9); z-index: 999; +} +.success-check { + font-size: 80px; color: $acc; + animation: checkScale 0.4s ease-out; +} +@keyframes checkScale { + 0% { transform: scale(0); } + 50% { transform: scale(1.2); } + 100% { transform: scale(1); } +} +``` + +- [ ] **Step 3: 验证构建 + 提交** + +```bash +cd apps/miniprogram && pnpm build:weapp +git add apps/miniprogram/src/pages/profile/followups/ apps/miniprogram/src/pages/followup/detail/ +git commit -m "feat(followup): 截止日期倒计时 + 提交成功确认动画" +``` + +--- + +### Task 20: 个人中心改进 — 用药提醒 + 就诊人编辑 + +**Files:** +- Modify: `apps/miniprogram/src/pages/profile/medication/index.tsx` +- Modify: `apps/miniprogram/src/pages/profile/family/index.tsx` +- Modify: `apps/miniprogram/src/pages/profile/family-add/index.tsx` + +- [ ] **Step 1: 用药提醒时间选择器** + +修改 `pages/profile/medication/index.tsx`,在 `@tarojs/components` 导入中增加 `Picker` 和 `Switch`: + +```tsx +import { View, Text, Input, Picker, Switch } from '@tarojs/components'; +``` + +将静态时间文本替换为 `Picker mode='time'`: + +```tsx + updateMedicine(index, 'time', e.detail.value)}> + + {med.time || '选择时间'} + + + +``` + +增加 `enabled` 开关: + +```tsx + updateMedicine(index, 'enabled', e.detail.value)} /> +``` + +- [ ] **Step 2: 就诊人编辑功能** + +修改 `pages/profile/family/index.tsx`,每个就诊人卡片增加"编辑"按钮,点击后 navigateTo 到 family-add 页面只传 `id`: + +```tsx +const handleEdit = (patient: Patient) => { + Taro.navigateTo({ + url: `/pages/profile/family-add/index?id=${patient.id}`, + }); +}; +``` + +修改 `pages/profile/family-add/index.tsx`: +1. 从 `services/patient.ts` 额外导入 `updatePatient` +2. 在 `useEffect` 中检测 `router.params.id`,如有则通过 `listPatients()` 或按 ID 查询获取完整数据(包括 `version` 字段),预填表单 +3. 编辑模式下提交调用 `updatePatient(id, data, version)` 而非 `createPatient` + +- [ ] **Step 3: 验证构建 + 提交** + +```bash +cd apps/miniprogram && pnpm build:weapp +git add apps/miniprogram/src/pages/profile/ +git commit -m "feat(profile): 用药提醒时间选择器 + 就诊人编辑功能" +``` + +--- + +### Task 21: Token 轻量混淆存储 + +**Files:** +- Create: `apps/miniprogram/src/utils/crypto.ts` +- Modify: `apps/miniprogram/src/stores/auth.ts` +- Modify: `apps/miniprogram/src/services/request.ts` + +- [ ] **Step 1: 创建 crypto 工具** + +创建 `utils/crypto.ts`: + +```typescript +import Taro from '@tarojs/taro'; + +const KEY_STORAGE = '_hms_crypto_key'; + +// 兼容小程序环境的 Base64 编解码 +function base64Encode(str: string): string { + // JWT token 仅含 ASCII 字符,XOR 后仍为 Latin-1,可安全用 charCodeAt + const bytes = Array.from(str, (c) => c.charCodeAt(0)); + // 小程序可能无 btoa,使用手动编码 + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + let result = ''; + for (let i = 0; i < bytes.length; i += 3) { + const b0 = bytes[i], b1 = bytes[i + 1] ?? 0, b2 = bytes[i + 2] ?? 0; + result += chars[b0 >> 2] + chars[((b0 & 3) << 4) | (b1 >> 4)]; + result += (i + 1 < bytes.length) ? chars[((b1 & 15) << 2) | (b2 >> 6)] : '='; + result += (i + 2 < bytes.length) ? chars[b2 & 63] : '='; + } + return result; +} + +function base64Decode(encoded: string): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + const lookup = Object.fromEntries(Array.from(chars, (c, i) => [c, i])); + const bytes: number[] = []; + for (let i = 0; i < encoded.length; i += 4) { + const b0 = lookup[encoded[i]] ?? 0, b1 = lookup[encoded[i + 1]] ?? 0; + const b2 = lookup[encoded[i + 2]] ?? 0, b3 = lookup[encoded[i + 3]] ?? 0; + bytes.push((b0 << 2) | (b1 >> 4), ((b1 & 15) << 4) | (b2 >> 2), ((b2 & 3) << 6) | b3); + } + // 移除 padding 对应的多余字节 + if (encoded.endsWith('==')) bytes.length -= 2; + else if (encoded.endsWith('=')) bytes.length -= 1; + return String.fromCharCode(...bytes); +} + +function getKey(): string { + let key = Taro.getStorageSync(KEY_STORAGE); + if (!key) { + const arr = new Uint8Array(16); + if (typeof wx !== 'undefined' && wx.getRandomValues) { + wx.getRandomValues(arr); + } else { + for (let i = 0; i < 16; i++) arr[i] = Math.floor(Math.random() * 256); + } + key = Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join(''); + Taro.setStorageSync(KEY_STORAGE, key); + } + return key; +} + +export function encrypt(value: string): string { + if (!value) return ''; + const key = getKey(); + const encrypted = Array.from(value) + .map((char, i) => String.fromCharCode(char.charCodeAt(0) ^ key.charCodeAt(i % key.length))) + .join(''); + return base64Encode(encrypted); +} + +export function decrypt(encoded: string): string { + if (!encoded) return ''; + const key = getKey(); + try { + const decoded = base64Decode(encoded); + return Array.from(decoded) + .map((char, i) => String.fromCharCode(char.charCodeAt(0) ^ key.charCodeAt(i % key.length))) + .join(''); + } catch { + return ''; + } +} +``` + +- [ ] **Step 2: auth store 集成加密层** + +修改 `stores/auth.ts`,token 的 Storage 读写走加密: + +```tsx +import { encrypt, decrypt } from '@/utils/crypto'; + +// restore 方法: +const encryptedToken = Taro.getStorageSync('access_token') || ''; +const token = encryptedToken ? decrypt(encryptedToken) : null; + +// login/bindPhone 成功后: +Taro.setStorageSync('access_token', encrypt(access_token)); + +// logout: +Taro.removeStorageSync('_hms_crypto_key'); // 连密钥一起清除 +``` + +- [ ] **Step 2.5: 修复 request.ts Token 读取绕过(关键)** + +**`services/request.ts` 直接从 Storage 读取 `access_token`,绕过了加密层。必须同步修改。** + +修改 `services/request.ts` 的 `getHeaders` 函数(第 13 行附近): + +```typescript +import { decrypt } from '@/utils/crypto'; + +// getHeaders 中: +const encryptedToken = Taro.getStorageSync('access_token'); +const token = encryptedToken ? decrypt(encryptedToken) : ''; +``` + +修改 `tryRefreshToken` 函数中的 token 读写(第 23、32-33 行),同样通过 encrypt/decrypt 处理。 + +- [ ] **Step 3: 验证构建 + 提交** + +```bash +cd apps/miniprogram && pnpm build:weapp +git add apps/miniprogram/src/utils/crypto.ts apps/miniprogram/src/stores/auth.ts +git commit -m "feat(security): Token 存储增加 XOR 混淆加密" +``` + +--- + +### Task 22: 后端手机号真实解密 + +**Files:** +- Modify: `crates/erp-auth/src/service/wechat_service.rs` +- Modify: `apps/miniprogram/src/services/auth.ts`(如果切换到 code 模式) + +> **前置决策(执行前必须确定)**:微信提供两种手机号获取方式: +> - **方案 A(推荐)**:前端用 `getPhoneNumber` 获取 `code`,后端调用微信 `getPhoneNumber` API 用 `code` 换手机号。前端需改为发送 `code` 字段。 +> - **方案 B**:前端发送 `encryptedData + iv`,后端用登录时保存的 `session_key` 做 AES-CBC 解密。需在登录时持久化 `session_key`。 +> +> **推荐方案 A**:微信新版 API 更简洁,不需要管理 session_key。以下步骤基于方案 A。 + +- [ ] **Step 0: 前端改造(如选方案 A)** + +修改 `services/auth.ts` 的 `bindPhone` 方法,改为发送 `code` 字段: + +```typescript +// 旧: { openid, encrypted_data, iv } +// 新: { openid, code } // code 来自 getPhoneNumber 事件 +``` + +同步修改 `pages/login/index.tsx` 的 `handleGetPhone`,从事件中提取 `code`: + +```tsx +const handleGetPhone = async (e) => { + if (e.detail.code) { + await authApi.bindPhone({ openid, code: e.detail.code }); + } +}; +``` + +- [ ] **Step 1: 实现微信手机号解密** + +修改 `wechat_service.rs` 的 `bind_phone` 方法(第 74-129 行),替换第 82 行的硬编码 `"13800000000"`: + +```rust +// 方案 A: 用 code 调用微信 API +// 1. 使用 appid + secret 获取 access_token(可缓存 2 小时) +// 2. POST https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=xxx +// body: { "code": code } +// 3. 解析 response.info.phoneNumber +``` + +- [ ] **Step 2: 验证** + +Run: `cargo check -p erp-auth` +Expected: 编译通过 + +- [ ] **Step 3: 提交** + +```bash +git add crates/erp-auth/src/service/wechat_service.rs +git commit -m "fix(auth): 替换手机号硬编码为微信真实解密" +``` + +--- + +### Task 23: 用户协议与隐私政策 + +**Files:** +- Create: `apps/miniprogram/src/pages/agreement/index.tsx` +- Create: `apps/miniprogram/src/pages/agreement/index.scss` +- Modify: `apps/miniprogram/src/pages/login/index.tsx` +- Modify: `apps/miniprogram/src/app.config.ts` + +- [ ] **Step 1: 创建协议页面** + +创建 `pages/agreement/index.tsx`,接收 `type` 参数(`terms` 或 `privacy`),展示对应协议文本。文本内容硬编码在页面中(MVP 阶段,后续可改为后端管理)。 + +- [ ] **Step 2: 登录页增加协议勾选** + +修改 `pages/login/index.tsx`,在登录按钮**上方**增加协议勾选(确保用户点击前可见): + +```tsx +const [agreed, setAgreed] = useState(false); + + + setAgreed(!agreed)}> + {agreed ? '☑' : '☐'} + + + 阅读并同意 + Taro.navigateTo({ url: '/pages/agreement/index?type=terms' })}>《用户协议》 + 和 + Taro.navigateTo({ url: '/pages/agreement/index?type=privacy' })}>《隐私政策》 + + +``` + +在 `handleWechatLogin` 和 `handleGetPhone` 开头增加 `if (!agreed)` 检查。 + +- [ ] **Step 3: 注册路由** + +在 `app.config.ts` 的 pages 数组中添加 `'pages/agreement/index'`。 + +- [ ] **Step 4: 验证构建 + 提交** + +```bash +cd apps/miniprogram && pnpm build:weapp +git add apps/miniprogram/src/ +git commit -m "feat(login): 新增用户协议/隐私政策页面 + 登录前强制勾选" +``` + +--- + +### Task 24: 数据埋点 service + +**Files:** +- Create: `apps/miniprogram/src/services/analytics.ts` +- Modify: `apps/miniprogram/src/app.tsx` + +- [ ] **Step 1: 创建 analytics service** + +创建 `services/analytics.ts`: + +```typescript +import Taro from '@tarojs/taro'; + +const STORAGE_KEY = 'analytics_events'; +const MAX_EVENTS = 100; + +type AnalyticsEvent = + | { type: 'page_view'; page: string; entered_at: number } + | { type: 'feature_use'; feature: string; action: string } + | { type: 'error'; message: string; stack?: string; timestamp: number }; + +function getEvents(): AnalyticsEvent[] { + return Taro.getStorageSync(STORAGE_KEY) || []; +} + +function saveEvent(event: AnalyticsEvent) { + const events = getEvents(); + events.push(event); + // 保留最近 100 条 + const trimmed = events.slice(-MAX_EVENTS); + Taro.setStorageSync(STORAGE_KEY, trimmed); + console.info('[Analytics]', event); +} + +export function trackPageView(page: string) { + saveEvent({ type: 'page_view', page, entered_at: Date.now() }); +} + +export function trackFeatureUse(feature: string, action: string) { + saveEvent({ type: 'feature_use', feature, action }); +} + +export function trackError(message: string, stack?: string) { + saveEvent({ type: 'error', message, stack, timestamp: Date.now() }); +} +``` + +- [ ] **Step 2: 核心页面接入埋点** + +在 3 个核心页面中添加 `trackPageView` 调用(Taro App 无法全局监听页面切换,需手动调用): + +修改 `pages/index/index.tsx`: +```tsx +import { trackPageView } from '@/services/analytics'; +import { useDidShow } from '@tarojs/taro'; +// 在组件中: +useDidShow(() => { trackPageView('home'); }); +``` + +修改 `pages/health/index.tsx`: +```tsx +useDidShow(() => { trackPageView('health'); }); +``` + +修改 `pages/login/index.tsx`: +```tsx +useDidShow(() => { trackPageView('login'); }); +``` + +- [ ] **Step 3: 验证构建 + 提交** + +```bash +cd apps/miniprogram && pnpm build:weapp +git add apps/miniprogram/src/services/analytics.ts apps/miniprogram/src/pages/index/ apps/miniprogram/src/pages/health/ apps/miniprogram/src/pages/login/ +git commit -m "feat(analytics): 新增轻量数据埋点 service + 核心页面接入" +``` + +--- + +### Task 25: 文章分享能力 + +**Files:** +- Modify: `apps/miniprogram/src/pages/article/detail/index.tsx` + +- [ ] **Step 1: 添加 onShareAppMessage** + +在 `pages/article/detail/index.tsx` 中添加分享 hooks。**Hooks 必须在组件顶层无条件调用**,在回调中处理 null: + +```tsx +import { useShareAppMessage, useShareTimeline } from '@tarojs/taro'; + +// 在组件函数体顶部(不能放在条件语句内): +useShareAppMessage(() => ({ + title: article?.title || '健康资讯', + path: `/pages/article/detail/index?id=${id}`, +})); + +useShareTimeline(() => ({ + title: article?.title || '健康资讯', + query: `id=${id}`, +})); +``` + +> **注意**:需确认 `@tarojs/taro` 在项目使用的 Taro 4.x 版本中导出了 `useShareAppMessage` 和 `useShareTimeline`。如不可用,改用页面配置 `enableShareAppMessage: true` + `onShareAppMessage` 生命周期。 + +- [ ] **Step 2: 验证构建 + 提交** + +```bash +cd apps/miniprogram && pnpm build:weapp +git add apps/miniprogram/src/pages/article/detail/ +git commit -m "feat(article): 文章详情支持微信好友/朋友圈分享" +``` diff --git a/docs/superpowers/specs/2026-04-24-hms-miniprogram-iteration-design.md b/docs/superpowers/specs/2026-04-24-hms-miniprogram-iteration-design.md new file mode 100644 index 0000000..b7fe49e --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-hms-miniprogram-iteration-design.md @@ -0,0 +1,469 @@ +# HMS 患者小程序迭代设计规格 + +> **版本**: v1.0 +> **日期**: 2026-04-24 +> **状态**: 草案 +> **关联**: 小程序初版设计 `2026-04-23-hms-miniprogram-design.md` + +--- + +## 1. 概述 + +### 1.1 背景 + +小程序初版已完成 21 个页面、7 个 API service 的基础实现,覆盖登录、健康数据、预约挂号、检验报告、随访管理、用药提醒、健康资讯、个人中心。当前处于**开发阶段**,工程质量和用户体验存在明显短板,距测试阶段尚有差距。 + +### 1.2 问题全景 + +| 优先级 | 问题 | 影响 | +|--------|------|------| +| P0 | 大量重复代码(profile/reports ≈ report/index, profile/followups ≈ followup/index) | 维护成本翻倍 | +| P0 | 预约详情通过 Storage 缓存传递而非 API 获取 | 数据不一致 | +| P0 | EmptyState 导入方式不一致导致运行时报错 | 页面崩溃 | +| P0 | 手机号绑定后端硬编码 `"13800000000"` | 无法上线 | +| P0 | `getTodaySummary()` 调用的后端端点不存在 | 首页/健康页数据无法加载 | +| P1 | ErrorState 组件定义但未使用 | 错误处理不统一 | +| P1 | mixins.scss 定义但未使用 | 样式重复内联 | +| P1 | 无全局错误边界 | 页面崩溃无兜底 | +| P1 | tryRefreshToken 静默吞异常 | 调试困难 | +| P1 | 趋势图缓存永不过期 | 数据过时 | +| P1 | 随访详情获取低效(listTasks().find()) | 性能浪费 | +| P1 | 首页/健康页缺少 loading 状态 | 体验空白 | +| P1 | 用药提醒纯本地 Storage | 换设备即丢失(后续版本解决) | +| P2 | 路径别名 @/* 未使用 | 代码可读性差 | +| P2 | 无 schema 验证库 | 表单验证脆弱 | +| P2 | 趋势图纯 CSS 柱状图 | 无交互能力 | +| P2 | 用药提醒时间选择器未实现 | 功能不完整 | +| P2 | 无日志/埋点/上报 | 无法追踪问题 | + +### 1.3 迭代策略:混合策略 + +采用**先基建再模块**的混合策略,分 4 个 Sprint 交付: + +``` +Sprint 0 (2-3天) Sprint 1 (3-4天) Sprint 2 (3-4天) Sprint 3 (4-5天) +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ 工程基础修复 │ → │ 健康数据打磨 │ → │ 预约+通知 │ → │ 报告/随访/ │ +│ │ │ │ │ │ │ 安全+增长 │ +│ · 消除重复代码│ │ · ECharts图表│ │ · 步骤指示器 │ │ · 指标卡片 │ +│ · 统一错误处理│ │ · 缓存TTL │ │ · 周视图日历 │ │ · Token加密 │ +│ · 修复数据传递│ │ · zod验证 │ │ · 订阅消息 │ │ · 手机号解密 │ +│ · 统一Loading │ │ · 状态色卡片 │ │ · 时段可视化 │ │ · 埋点+分享 │ +└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ +``` + +**原则**:Sprint 0 铺路,后续每个 Sprint 都受益于基础设施改善。Sprint 0 只修不建,不引入新依赖。 + +--- + +## 2. Sprint 0:工程基础修复 + +**目标**:消除最痛的工程问题,为后续所有 Sprint 铺路。约束 2-3 天完成。 + +### 2.1 修复阻断性 API 端点缺失 + +**现状**:前端 `services/health.ts` 的 `getTodaySummary()` 调用 `GET /health/vital-signs?date=today`,但后端路由中**不存在此端点**。后端仅有 `GET /health/patients/{id}/vital-signs`(需 patient_id 路径参数)。这意味着首页"今日健康"卡片和健康页的数据从一开始就**无法加载**。 + +**方案**: + +- 后端在 `erp-health` 新增小程序专用端点 `GET /health/vital-signs/today`,通过 JWT `user_id` 自动关联 patient(类似已有的 `GET /health/vital-signs/trend` 模式) +- 前端 `services/health.ts` 的 `getTodaySummary()` 调整为调用新端点 +- 此项为 **Sprint 0 最高优先级**,阻塞首页和健康页基本功能 + +**涉及文件**: +- 后端新增:`erp-health` handler + 路由注册 +- 修改:`services/health.ts` + +### 2.2 消除重复页面 + +**现状**:`pages/report/index` 与 `pages/profile/reports/index` 几乎完全重复,`pages/followup/index` 与 `pages/profile/followups/index` 同理。且 `report/index` 和 `followup/index` 没有明确的导航入口。 + +**方案**: + +1. 删除 `pages/report/index` 和 `pages/followup/index` 及其 SCSS 文件 +2. 从 `app.config.ts` 移除对应路由注册 +3. 首页快捷入口和 profile 菜单统一指向 `profile/reports` 和 `profile/followups` +4. 如果后续需要独立入口,则抽取共享组件 `components/ReportList` 和 `components/FollowupList`,两个页面只做薄壳路由 + +**涉及文件**: +- 删除:`pages/report/index.tsx`、`pages/report/index.scss` +- 删除:`pages/followup/index.tsx`、`pages/followup/index.scss` +- 修改:`app.config.ts`(移除路由) +- 修改:`pages/index/index.tsx`(快捷入口路径) + +### 2.2 统一错误处理 + +**现状**:`ErrorState` 组件已定义但未被任何页面使用,各页面内联 `showToast` 错误提示。无全局错误边界。 + +**方案**: + +1. 所有列表页、详情页统一使用 `ErrorState` 组件,替换内联错误提示 +2. 在 `app.tsx` 添加 React Error Boundary 组件,兜底页面崩溃 +3. 新建 `components/ErrorBoundary/index.tsx` +4. 修复 `tryRefreshToken` 的 catch 块,添加 `console.error` 日志 + +**涉及文件**: +- 新增:`components/ErrorBoundary/index.tsx` +- 修改:`app.tsx`(包裹 ErrorBoundary) +- 修改:所有列表页和详情页(替换内联错误处理为 ErrorState) +- 修改:`services/request.ts`(tryRefreshToken 日志) + +### 2.3 修复数据传递问题 + +**预约详情**: +- 移除 `appointment_detail_cache` Storage 传递 +- 改为进入页面时通过 `GET /health/appointments/:id` 获取数据 +- **后端需新增此端点**(当前仅有列表 `GET`、创建 `POST`、状态更新 `PUT`,缺少单条查询 `GET`) + +**随访详情**: +- 后端**需新增** `GET /health/follow-up-tasks/:id` 单条查询端点(当前 `{id}` 路由仅注册了 `PUT` 和 `DELETE`,缺少 `GET`) +- 前端替换 `listTasks().find()` 为直接按 ID 查询 + +> **注意**:以上后端新增端点为 Sprint 0 前置阻塞项。如果后端资源有限,前端先做"调用端点"的准备代码,后端并行实现。 + +**涉及文件**: +- 修改:`pages/appointment/detail/index.tsx` +- 修改:`services/appointment.ts`(新增 getDetail 方法) +- 修改:`services/followup.ts`(新增 getTaskDetail 方法) +- 后端新增:`erp-health` 预约单条查询 + 随访单条查询端点 + +### 2.4 统一 Loading 状态 + +**现状**:首页和健康页的 `loading` 状态已在 store 中定义但未在 UI 层消费。详情页使用内联 `加载中...`。 + +**方案**: + +1. 首页和健康页在数据加载时展示 `Loading` 组件 +2. 所有详情页统一使用 `Loading` 组件替换内联文字 +3. 预约创建页三步骤切换时也展示 loading + +**涉及文件**: +- 修改:`pages/index/index.tsx`(消费 loading 状态) +- 修改:`pages/health/index.tsx`(消费 loading 状态) +- 修改:所有详情页 tsx(替换内联加载文字) + +### 2.5 杂项修复 + +| 项目 | 方案 | +|------|------| +| EmptyState 导入 bug | 首页 `import { EmptyState }` 改为 `import EmptyState`(默认导入) | +| 路径别名启用 | `services/` 和 `stores/` 层的 import 逐步改为 `@/` 别名 | +| mixins.scss 复用 | 新写的页面样式使用 `@include card`、`@include flex-center`、`@include safe-bottom` | + +--- + +## 3. Sprint 1:健康数据模块打磨 + +**目标**:升级健康数据录入、展示和趋势分析体验,从"能用"到"好用"。 + +### 3.1 健康卡片状态色 + +**现状**:四张健康卡片(血压/心率/血糖/体重)样式统一灰色,无状态区分。 + +**方案**: + +每张卡片根据指标状态着色: +- **正常**:左侧绿色边条 + 绿色"正常 ─"标签 +- **偏高**:左侧红色边条 + 红色"偏高 ▲{差值}"标签 +- **偏低**:左侧红色边条 + 红色"偏低 ▼{差值}"标签 +- **无数据**:灰色,保持现状 + +异常指标数值变红,卡片底部显示参考范围。 + +**后端配合**:后端需在新增的 `GET /health/vital-signs/today` 端点中返回 `status`(normal/high/low)和 `reference_range`。前端 `TodaySummary` 类型同步新增 `reference_range` 字段(当前已有 `status` 字段但后端无对应返回)。 + +**涉及文件**: +- 修改:`pages/health/index.tsx`(卡片样式逻辑) +- 修改:`pages/index/index.tsx`(首页健康卡片同步更新) +- 修改:`services/health.ts`(类型定义增加 status 字段) + +### 3.2 ECharts 趋势图 + +**现状**:纯 CSS div 柱状图,无交互、无缩放、无 tooltip。 + +**方案**: + +引入 `echarts-taro3-react`(设计规格中已规划)。**前置条件**:Sprint 1 开始前需做技术预研(spike),验证 `echarts-taro3-react` 在 Taro 4.2.0 + webpack5 下的兼容性。如果不可用,备选方案为 `echarts-for-weixin` + 手动封装为 React 组件。 + +实现: + +- **折线图**:数据点连线,异常点标红放大 +- **参考范围色带**:正常值区间以半透明绿色背景显示 +- **Tooltip**:长按/点击显示具体数值和日期 +- **时间范围切换**:7天/30天/90天 三个 tab +- **缓存 TTL**:趋势数据缓存 5 分钟后自动过期,强制重新请求 + +**涉及文件**: +- 新增:`components/TrendChart/index.tsx`、`components/TrendChart/index.scss` +- 重写:`pages/health/trend/index.tsx` +- 修改:`stores/health.ts`(缓存 TTL 机制) +- 新增依赖:`echarts-taro3-react` + +### 3.3 表单验证升级 + +**现状**:所有表单验证为手动 if 判断,无 schema 约束。 + +**方案**: + +引入 `zod`(~3KB gzip),为每个表单定义验证 schema: + +```typescript +// 示例:体征录入验证 +const vitalSignSchema = z.object({ + indicator_type: z.enum(['blood_pressure', 'heart_rate', 'blood_sugar', 'weight']), + value: z.number().positive(), + extra: z.object({ systolic: z.number().min(60).max(250).optional(), + diastolic: z.number().min(40).max(150).optional() }).optional(), + measured_at: z.string().datetime().optional(), + note: z.string().max(200).optional() +}); +``` + +异常值即时警告(如收缩压 > 180 显示红色提示"请及时就医")。 + +录入成功后自动刷新首页卡片 + 清除趋势缓存。 + +**涉及文件**: +- 修改:`pages/health/input/index.tsx`(zod schema 验证) +- 修改:`stores/health.ts`(录入成功后清除缓存) +- 新增依赖:`zod` + +--- + +## 4. Sprint 2:预约挂号 + 通知触达 + +**目标**:优化预约三步流程体验,建立微信订阅消息通知机制。 + +### 4.1 三步流程升级 + +**现状**:三个步骤(选科室 → 选医生 → 选日期时段)无进度指示,排班信息为纯文字列表。 + +**方案**: + +**步骤指示器**: +- 新增 `components/StepIndicator/index.tsx` +- 顶部固定 1→2→3 步骤条,当前步骤高亮,已完成步骤可点击回退 +- 步骤间切换带过渡动画 + +**科室选择**: +- 从文字列表改为宫格卡片(图标 + 科室名 + 医生数) +- 每个科室卡片可点击,选中后高亮边框 + +**排班日历**: +- 新增 `components/WeekCalendar/index.tsx` 周视图日历 +- 有排班的日期标记绿点,无排班的日期灰色 +- 点击日期展示该日可用时段卡片 +- 时段卡片按剩余名额着色:>3 绿色、1-3 橙色、0 灰色不可选 + +**涉及文件**: +- 新增:`components/StepIndicator/index.tsx` +- 新增:`components/WeekCalendar/index.tsx` +- 重写:`pages/appointment/create/index.tsx` + +### 4.2 微信订阅消息 + +**现状**:无任何推送通知机制。 + +**方案**: + +1. 后端在微信公众平台注册订阅消息模板: + - 预约就诊提醒(就诊前 1 天推送) + - 随访任务提醒(截止前 1 天推送) + - 报告出具通知(新报告发布时推送) + +2. 前端在关键场景引导用户订阅: + - 预约成功后弹出订阅授权 + - 随访提交后引导订阅下次提醒 + +3. 后端定时任务检查待推送消息并触发 + +4. **降级设计**:用户拒绝订阅时,消息仍写入 `erp-message` 消息中心。小程序"我的"页面顶部显示未读消息数量红点,作为消息触达的备选渠道。 + +**涉及文件**: +- 修改:`pages/appointment/detail/index.tsx`(预约成功后订阅引导) +- 修改:`pages/followup/detail/index.tsx`(随访提交后订阅引导) +- 后端新增:`erp-server` 订阅消息模板注册 + 定时推送任务 + +--- + +## 5. Sprint 3:报告/随访/个人中心 + 安全 + 增长 + +**目标**:打磨剩余模块,完成安全加固和增长基础建设,达到可测试状态。 + +### 5.1 报告详情页升级 + +**现状**:所有指标卡片样式相同,无法一眼区分正常/异常。 + +**方案**: + +指标卡片按状态着色: +- **正常**:绿色背景 + 绿色"✓ 正常"标签 + 绿色数值 +- **偏高**:红色背景 + 红色"↑ 偏高"标签 + 红色数值 +- **偏低**:红色背景 + 红色"↓ 偏低"标签 + 红色数值 + +顶部汇总标签:`2 项异常 · 1 项正常`,一眼掌握整体状况。 + +**涉及文件**: +- 修改:`pages/report/detail/index.tsx` +- 修改:`pages/profile/reports/index.tsx`(如果仍独立存在) + +### 5.2 随访 UX 细节 + +- 任务卡片增加截止日期倒计时("还剩 2 天",红色紧迫) +- 过期任务灰色标记 +- 提交记录后增加"提交成功"确认动画(checkmark 缩放) + +**涉及文件**: +- 修改:`pages/profile/followups/index.tsx` +- 修改:`pages/followup/detail/index.tsx` + +### 5.3 个人中心改进 + +**用药提醒**: +- 实现时间选择器 Picker(替换当前静态文本) +- 增加"提醒开关"(enabled/disabled) +- 注:用药提醒数据仍为本地 Storage 存储,**后端同步作为后续版本事项**。MVP 阶段接受"换设备即丢失"的限制。 + +**就诊人管理**: +- 增加编辑功能(当前只能添加不能编辑) +- 复用 `family-add` 页面,传入已有数据进入编辑模式 + +**涉及文件**: +- 修改:`pages/profile/medication/index.tsx`(时间 Picker) +- 修改:`pages/profile/family/index.tsx`(编辑入口) +- 修改:`pages/profile/family-add/index.tsx`(编辑模式支持) + +### 5.4 安全加固 + +#### 5.4.1 Token 安全 + +**现状**:Access Token 和 Refresh Token 明文存储在 `Taro.setStorageSync`。 + +**方案**: + +MVP 阶段采用简化方案:微信小程序的 Storage 本身有沙箱隔离,明文存储的边际风险有限。做以下最低成本改进: + +- 使用 `wx.getRandomValues()` 生成随机密钥,单独 key 存储 +- Token 存储时用此密钥做简单混淆(XOR 或 AES-ECB 单块加密) +- 目的:防止 Storage 被直接明文读取,非追求密码学安全级别 + +> **后续版本**:如果合规要求提高,再升级为完整的 AES-GCM 方案。 + +**涉及文件**: +- 新增:`utils/crypto.ts`(轻量混淆工具) +- 修改:`stores/auth.ts`(Storage 读写走混淆层) + +#### 5.4.2 手机号真实解密 + +**现状**:`wechat_service.rs` 第 82 行硬编码 `"13800000000"`。 + +**方案**: +- 后端接入微信 `phonenumber.getPhoneNumber` 接口 +- 使用 `encryptedData` + `iv` + `session_key` 解密真实手机号 +- 前端无需改动(已传递正确的 encryptedData 和 iv) + +**涉及文件**: +- 修改:`crates/erp-auth/src/service/wechat_service.rs` + +#### 5.4.3 用户协议与隐私政策 + +- 新增 `pages/agreement/index.tsx` 页面 +- 登录页增加"阅读并同意《用户协议》和《隐私政策》"勾选 +- 权限使用说明文案(获取手机号用途声明) + +**涉及文件**: +- 新增:`pages/agreement/index.tsx` +- 修改:`pages/login/index.tsx`(协议勾选) +- 修改:`app.config.ts`(新增路由) + +### 5.5 增长基础 + +#### 5.5.1 数据埋点 + +新增 `services/analytics.ts`,轻量事件记录: + +```typescript +// 核心事件类型 +type AnalyticsEvent = + | { type: 'page_view'; page: string; duration_ms?: number } + | { type: 'feature_use'; feature: string; action: string } + | { type: 'error'; message: string; stack?: string } +``` + +- 页面进入/离开自动记录 `page_view` +- 关键操作(录入数据、创建预约、提交随访)记录 `feature_use` +- 捕获的错误记录 `error` +- MVP 阶段:事件写入本地 `console.info` + Taro Storage 缓存(最近 100 条) +- 后续版本:批量上报到后端 `POST /api/v1/analytics/events` + +**涉及文件**: +- 新增:`services/analytics.ts` +- 修改:`app.tsx`(全局页面进入/离开监听) + +#### 5.5.2 分享能力 + +- 文章详情页支持分享到微信好友/朋友圈 +- 自定义分享卡片(标题 + 摘要 + 封面图)通过 `onShareAppMessage` 和 `onShareTimeline` 实现 + +> **后续版本**:健康报告 Canvas 分享图片生成、PC 扫码登录。 + +**涉及文件**: +- 修改:`pages/article/detail/index.tsx`(onShareAppMessage + onShareTimeline) + +--- + +## 6. 文件变更总览 + +### 新增文件 + +| 文件 | 说明 | Sprint | +|------|------|--------| +| `components/ErrorBoundary/index.tsx` | 全局错误边界 | 0 | +| `components/TrendChart/index.tsx` | ECharts 趋势图 | 1 | +| `components/StepIndicator/index.tsx` | 步骤指示器 | 2 | +| `components/WeekCalendar/index.tsx` | 周视图日历 | 2 | +| `pages/agreement/index.tsx` | 用户协议/隐私政策 | 3 | +| `utils/crypto.ts` | Token 加密工具 | 3 | +| `services/analytics.ts` | 数据埋点 | 3 | + +### 删除文件 + +| 文件 | 原因 | Sprint | +|------|------|--------| +| `pages/report/index.tsx` | 与 profile/reports 重复 | 0 | +| `pages/report/index.scss` | 同上 | 0 | +| `pages/followup/index.tsx` | 与 profile/followups 重复 | 0 | +| `pages/followup/index.scss` | 同上 | 0 | + +### 新增依赖 + +| 依赖 | 用途 | 体积 | Sprint | +|------|------|------|--------| +| `echarts-taro3-react` | 交互式图表 | 封装层 ~5KB + echarts 按需 ~100-200KB (gzip) | 1 | +| `zod` | 表单 schema 验证(长期投资) | ~3KB (gzip) | 1 | + +--- + +## 7. 约束与风险 + +| 风险 | 应对策略 | +|------|---------| +| ECharts 增大包体积 | 按需引入 echarts 模块,不引入全量包;监控主包大小不超过 2MB | +| 微信订阅消息需用户主动触发 | 在预约成功、随访提交等高意愿场景引导订阅 | +| Token 加密增加启动耗时 | AES-GCM 加解密 < 1ms,可忽略 | +| zod 增加包体积 | 3KB gzip,远小于自写验证代码量 | +| Sprint 0 范围膨胀 | 严格只修不建,不引入新依赖,不重构架构 | +| 后端端点未实现阻塞前端 | Sprint 0/1 的端点基本已实现;Sprint 2 订阅消息、Sprint 3 analytics 需后端配合 | + +--- + +## 8. 验收标准 + +每个 Sprint 完成时必须满足: + +- [ ] `pnpm build:weapp` 生产构建通过 +- [ ] 微信开发者工具无编译错误 +- [ ] 所有涉及页面真机预览功能正常 +- [ ] 无 console.error 或未捕获异常 +- [ ] 已修改的页面 loading/error/empty 三态完整 +- [ ] 所有代码已提交