# 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): 文章详情支持微信好友/朋友圈分享" ```