Files
hms/docs/superpowers/plans/2026-04-24-hms-miniprogram-iteration-plan.md
iven 19be2a08c7
Some checks failed
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
docs(miniprogram): 新增小程序迭代设计规格 + 25 Task 实施计划
设计规格:4 Sprint 混合策略(Sprint 0 修基础 → Sprint 1-3 模块打磨),
覆盖 18 个问题,含健康数据、预约挂号、报告详情、安全加固、增长基础。

实施计划:25 个 Task,4 个 Chunk,经 4 轮审查修复关键问题:
- Task 10 依赖后端 today 端点 status/reference_range 字段
- Task 14/15 补全 StepIndicator 连接线 + WeekCalendar 完整实现
- Task 21 request.ts Token 加密绕过修复
- Task 22 手机号解密前后端 API 契约明确(推荐 code 模式)
- Task 24 埋点补充核心页面手动调用
- Task 25 hooks 无条件调用修复
2026-04-24 12:08:13 +08:00

1840 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<IndicatorSummary>,
pub heart_rate: Option<IndicatorSummary>,
pub blood_sugar: Option<IndicatorSummary>,
pub weight: Option<IndicatorSummary>,
}
#[derive(Debug, Serialize)]
pub struct IndicatorSummary {
pub value: f64,
pub status: String, // "normal" | "high" | "low"
pub reference_range: Option<String>, // 如 "60-100"
// 血压专用
pub systolic: Option<f64>,
pub diastolic: Option<f64>,
}
```
- [ ] **Step 2: 实现 handler**
`crates/erp-health/src/handler/health_data_handler.rs` 新增 `get_mini_today`。参照已有的 `get_mini_trend`(第 337-352 行)模式:通过 `Extension<TenantContext>` 获取 `ctx.user_id`,调用 service 层获取今日最新体征数据。
- [ ] **Step 3: 实现 service 方法**
`crates/erp-health/src/service/health_data_service.rs` 新增查询方法:按 user_id 查找关联 patient查询今日最新体征记录按指标类型聚合为 summary计算 statusnormal/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<TodaySummary>('/health/vital-signs?date=today');
// 新:
export async function getTodaySummary() {
return api.get<TodaySummary>('/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<Props, State> {
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 (
<View style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '60vh', padding: '40px' }}>
<Text style={{ fontSize: '48px', marginBottom: '20px' }}>😵</Text>
<Text style={{ fontSize: '32px', color: '#134E4A', marginBottom: '12px' }}></Text>
<Text style={{ fontSize: '24px', color: '#94A3B8', marginBottom: '24px' }}></Text>
</View>
);
}
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<Record<string, unknown>>) {
return <ErrorBoundary>{children}</ErrorBoundary>;
}
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<Appointment>(`/health/appointments/${id}`);
}
```
- [ ] **Step 2: services/followup.ts 新增 getTaskDetail**
```typescript
export async function getTaskDetail(id: string) {
return api.get<FollowUpTask>(`/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 ? (
<Loading />
) : (
<View className='health-card'>...</View>
)}
```
- [ ] **Step 2: 健康页增加 loading**
修改 `pages/health/index.tsx`:已有 `loading` 解构但未使用,在数据为空且 loading 时展示 `Loading`
```tsx
import Loading from '../../components/Loading';
// ...
// 在 health-grid 渲染前
{loading && !todaySummary ? (
<Loading />
) : (
<View className='health-grid'>...</View>
)}
```
- [ ] **Step 3: 详情页统一 Loading**
修改 `pages/report/detail/index.tsx` 第 38-43 行,将内联 `<Text>加载中...</Text>` 替换为 `<Loading />`
```tsx
import Loading from '../../../components/Loading';
// ...
if (loading) {
return (
<View className='detail-page'>
<Loading />
</View>
);
}
```
- [ ] **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 下能正常渲染折线图 → 采用方案 Aecharts-taro3-react
如果报错或不渲染 → 切换到方案 Becharts-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 seriesdata 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 (
<View className='trend-page'>
<View className='trend-header'>
<Text className='trend-title'>{indicator.replace(/_/g, ' ')} </Text>
{/* 7d/30d/90d tab 切换 */}
</View>
<TrendChart
data={points}
referenceMin={getReferenceMin(indicator)}
referenceMax={getReferenceMax(indicator)}
unit={getUnit(indicator)}
/>
{/* 保留数据列表作为可折叠的详情区 */}
</View>
);
```
- [ ] **Step 2: stores/health.ts 添加缓存 TTL**
修改 `getTrend` 方法,缓存增加 5 分钟过期机制:
```typescript
interface CachedTrend {
data: { date: string; value: number }[];
cachedAt: number; // Date.now()
}
// 更新 HealthState 中 trendData 类型:
// trendData: Record<string, CachedTrend>
// 在 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<string, { max?: number; min?: number; warning: string }> = {
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 (
<View className='step-indicator'>
{steps.map((step, idx) => {
const isCurrent = idx === current;
const isDone = idx < current;
const isClickable = isDone && onChange;
return (
<View className='step-item' key={step.label}>
{idx > 0 && (
<View className={`step-line ${isDone ? 'step-line-done' : ''}`} />
)}
<View
className={`step-dot ${isCurrent ? 'step-current' : ''} ${isDone ? 'step-done' : ''}`}
onClick={isClickable ? () => onChange(idx) : undefined}
>
{isDone ? <Text className='step-check'></Text> : <Text className='step-num'>{idx + 1}</Text>}
</View>
<Text className={`step-label ${isCurrent ? 'step-current' : ''} ${isDone ? 'step-done' : ''}`}>
{step.label}
</Text>
</View>
);
})}
</View>
);
}
```
- [ ] **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<string>;
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 (
<View className='week-calendar'>
<View className='week-nav'>
<Text className='week-arrow' onClick={() => setWeekOffset(weekOffset - 1)}></Text>
<Text className='week-label'>{dates[0].slice(5)} ~ {dates[6].slice(5)}</Text>
<Text className='week-arrow' onClick={() => setWeekOffset(weekOffset + 1)}></Text>
</View>
<View className='week-grid'>
{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 (
<View
className={`week-cell ${isSelected ? 'cell-selected' : ''} ${!isScheduled ? 'cell-empty' : ''} ${isPast ? 'cell-past' : ''}`}
key={dateStr}
onClick={isScheduled && !isPast ? () => onSelectDate(dateStr) : undefined}
>
<Text className='cell-weekday'>{day}</Text>
<Text className={`cell-date ${isToday ? 'cell-today' : ''}`}>{parseInt(dateStr.slice(8))}</Text>
{isScheduled && <View className='cell-dot' />}
</View>
);
})}
</View>
</View>
);
}
```
- [ ] **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` 替换为 `<StepIndicator>` 组件:
```tsx
import StepIndicator from '../../../components/StepIndicator';
// ...
<StepIndicator
steps={[{ label: '选科室' }, { label: '选医生' }, { label: '选时段' }]}
current={currentStep}
onChange={(idx) => {
// 回退时清除下级已选状态
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'``<WeekCalendar>`,排班数据从 `calendarView()` API 获取。
需要从排班数据提取排班日期集合传给 WeekCalendar
```tsx
const scheduledDates = useMemo(() => {
if (!schedules) return new Set<string>();
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) => (
<View
className={`slot-card ${getSlotStyle(slot.available_count)} ${selectedSlot === slot.time_slot ? 'slot-selected' : ''}`}
onClick={slot.available_count > 0 ? () => setSelectedSlot(slot.time_slot) : undefined}
>
<Text className='slot-time'>{slot.time_slot}</Text>
<Text className='slot-count'>{slot.available_count > 0 ? `剩余 ${slot.available_count}` : '已满'}</Text>
</View>
))}
```
- [ ] **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 APIMVP 阶段仅预留 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' };
}
};
// 在渲染中:
<View className={`indicator-item ${statusDisplay.cls}`}>
```
- [ ] **Step 2: 添加汇总标签**
在指标列表上方添加汇总:
```tsx
const abnormalCount = indicators.filter((i) => i.status === 'high' || i.status === 'low').length;
const normalCount = indicators.length - abnormalCount;
// ...
<View className='indicator-summary'>
{abnormalCount > 0 && <Text className='tag-abnormal'>{abnormalCount} </Text>}
{normalCount > 0 && <Text className='tag-normal'>{normalCount} </Text>}
</View>
```
- [ ] **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 && (
<View className='success-overlay'>
<View className='success-check'></View>
<Text className='success-text'></Text>
</View>
)}
```
```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
<Picker mode='time' value={med.time} onChange={(e) => updateMedicine(index, 'time', e.detail.value)}>
<View className='time-picker'>
<Text>{med.time || '选择时间'}</Text>
<Text className='picker-arrow'></Text>
</View>
</Picker>
```
增加 `enabled` 开关:
```tsx
<Switch checked={med.enabled} onChange={(e) => 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);
<View className='agreement-row'>
<View className='agreement-check' onClick={() => setAgreed(!agreed)}>
<Text>{agreed ? '☑' : '☐'}</Text>
</View>
<Text className='agreement-text'>
<Text className='agreement-link' onClick={() => Taro.navigateTo({ url: '/pages/agreement/index?type=terms' })}></Text>
<Text className='agreement-link' onClick={() => Taro.navigateTo({ url: '/pages/agreement/index?type=privacy' })}></Text>
</Text>
</View>
```
`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): 文章详情支持微信好友/朋友圈分享"
```