设计规格: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 无条件调用修复
55 KiB
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 新增:
#[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,计算 status(normal/high/low)。
- Step 4: 注册路由
在 crates/erp-health/src/module.rs 的 protected_routes 中,在 get_mini_trend 路由旁新增:
.route("/health/vital-signs/today", get(health_data_handler::get_mini_today))
- Step 5: 验证
Run: cargo check -p erp-health
Expected: 编译通过
- Step 6: 提交
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 路径从不存在的端点改为新端点:
// 旧: 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 字段:
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: 提交
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: 删除重复文件
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。
// 删除这两行:
// 'pages/report/index',
// 'pages/followup/index',
- Step 3: 修复首页 EmptyState 导入 bug
修改 pages/index/index.tsx 第 5 行,从命名导入改为默认导入:
// 旧: import { EmptyState } from '../../components/EmptyState';
// 新:
import EmptyState from '../../components/EmptyState';
- Step 4: 验证构建
Run: cd apps/miniprogram && pnpm build:weapp
Expected: 编译通过,无 Unresolved import 错误
- Step 5: 提交
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:
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:
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 行:
} catch (err) {
console.error('[tryRefreshToken] token 刷新失败:', err);
}
- Step 4: 验证构建
Run: cd apps/miniprogram && pnpm build:weapp
Expected: 编译通过
- Step 5: 提交
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: 提交
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
export async function getAppointment(id: string) {
return api.get<Appointment>(`/health/appointments/${id}`);
}
- Step 2: services/followup.ts 新增 getTaskDetail
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组件)
// 核心变更:用 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: 提交
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 组件。
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。
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 />:
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: 提交
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 的导入改为 @/ 别名:
// 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: 提交
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 实例:
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,改安装备选方案。
# 如果方案 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 中未添加,在此补充:
// 确认 TodaySummary 中每个指标都包含:
reference_range?: string; // 如 "60-100"
- Step 2: 在健康页卡片中增加状态色逻辑
修改 pages/health/index.tsx 的 items 数组,根据 status 字段计算边条颜色和趋势标签:
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 中增加状态色相关样式:
.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: 提交
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 折线图:
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:
.trend-chart {
width: 100%;
height: 500px; // 图表容器必须有明确高度
}
- Step 3: 验证组件独立渲染
在微信开发者工具中验证折线图能正常显示。
- Step 4: 提交
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 组件:
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 分钟过期机制:
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 顶部新增:
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 验证:
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 方法:
clearCache: () => set({ trendData: {}, todaySummary: null }),
在 pages/health/input/index.tsx 录入成功回调中调用:
const { clearCache } = useHealthStore();
// ... 录入成功后:
clearCache();
- Step 5: 验证构建
Run: cd apps/miniprogram && pnpm build:weapp
Expected: 编译通过
- Step 6: 提交
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:
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: 创建样式
@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: 提交
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 实现的周视图日历:
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: 创建样式
@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: 提交
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> 组件:
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:
const scheduledDates = useMemo(() => {
if (!schedules) return new Set<string>();
return new Set(schedules.map((s) => s.date));
}, [schedules]);
替换文本输入时段为时段卡片列表,时段卡片按剩余名额着色,名额为 0 时禁用点击:
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: 提交
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:
// 后端在微信公众平台注册后填入实际值
// 注册前保持为空数组,订阅调用将自动跳过
export const TEMPLATE_IDS = {
APPOINTMENT_REMINDER: '', // 预约就诊提醒
FOLLOWUP_REMINDER: '', // 随访任务提醒
REPORT_NOTIFICATION: '', // 报告出具通知
};
- Step 1: 预约创建成功后订阅引导
在 pages/appointment/create/index.tsx 中,预约创建成功(API 返回成功)后调用 Taro.requestSubscribeMessage:
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: 提交
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 切换卡片背景色和标签:
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: 添加汇总标签
在指标列表上方添加汇总:
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 样式
.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: 提交
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 后增加倒计时计算:
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 中添加过期任务灰色样式:
.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 中添加动画样式:
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>
)}
.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: 验证构建 + 提交
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:
import { View, Text, Input, Picker, Switch } from '@tarojs/components';
将静态时间文本替换为 Picker mode='time':
<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 开关:
<Switch checked={med.enabled} onChange={(e) => updateMedicine(index, 'enabled', e.detail.value)} />
- Step 2: 就诊人编辑功能
修改 pages/profile/family/index.tsx,每个就诊人卡片增加"编辑"按钮,点击后 navigateTo 到 family-add 页面只传 id:
const handleEdit = (patient: Patient) => {
Taro.navigateTo({
url: `/pages/profile/family-add/index?id=${patient.id}`,
});
};
修改 pages/profile/family-add/index.tsx:
- 从
services/patient.ts额外导入updatePatient - 在
useEffect中检测router.params.id,如有则通过listPatients()或按 ID 查询获取完整数据(包括version字段),预填表单 - 编辑模式下提交调用
updatePatient(id, data, version)而非createPatient
- Step 3: 验证构建 + 提交
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:
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 读写走加密:
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 行附近):
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: 验证构建 + 提交
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,后端调用微信getPhoneNumberAPI 用code换手机号。前端需改为发送code字段。- 方案 B:前端发送
encryptedData + iv,后端用登录时保存的session_key做 AES-CBC 解密。需在登录时持久化session_key。推荐方案 A:微信新版 API 更简洁,不需要管理 session_key。以下步骤基于方案 A。
- Step 0: 前端改造(如选方案 A)
修改 services/auth.ts 的 bindPhone 方法,改为发送 code 字段:
// 旧: { openid, encrypted_data, iv }
// 新: { openid, code } // code 来自 getPhoneNumber 事件
同步修改 pages/login/index.tsx 的 handleGetPhone,从事件中提取 code:
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":
// 方案 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: 提交
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,在登录按钮上方增加协议勾选(确保用户点击前可见):
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: 验证构建 + 提交
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:
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:
import { trackPageView } from '@/services/analytics';
import { useDidShow } from '@tarojs/taro';
// 在组件中:
useDidShow(() => { trackPageView('home'); });
修改 pages/health/index.tsx:
useDidShow(() => { trackPageView('health'); });
修改 pages/login/index.tsx:
useDidShow(() => { trackPageView('login'); });
- Step 3: 验证构建 + 提交
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:
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: 验证构建 + 提交
cd apps/miniprogram && pnpm build:weapp
git add apps/miniprogram/src/pages/article/detail/
git commit -m "feat(article): 文章详情支持微信好友/朋友圈分享"