设计规格: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 无条件调用修复
1840 lines
55 KiB
Markdown
1840 lines
55 KiB
Markdown
# 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,计算 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<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 下能正常渲染折线图 → 采用方案 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 (
|
||
<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 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' };
|
||
}
|
||
};
|
||
|
||
// 在渲染中:
|
||
<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): 文章详情支持微信好友/朋友圈分享"
|
||
```
|