docs(plan): 切片 3 小程序 AI 报告查看实施计划
6 个 Task,2 个 Chunk: - Chunk 1: API service + 列表页 + 详情页(Taro/React) - Chunk 2: 路由注册 + 首页入口 + 集成验证
This commit is contained in:
@@ -0,0 +1,539 @@
|
||||
# 切片 3: 小程序 AI 报告查看 实施计划
|
||||
|
||||
> **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:** 患者可在微信小程序查看 AI 分析报告(只读),从首页入口进入列表页,点击查看详情。
|
||||
|
||||
**Architecture:** 后端 `list_analysis` / `get_analysis` 端点在切片 2 中已补全。小程序复用现有 `services/request.ts` 的 `api` 封装,新增 `services/ai-analysis.ts` 调用后端 API。新增 2 个页面(列表 + 详情),在首页添加入口卡片。
|
||||
|
||||
**Tech Stack:** Taro 4.2 + React 18 + TypeScript
|
||||
|
||||
**设计规格:** `docs/superpowers/specs/2026-04-25-feature-completion-design.md` §4
|
||||
|
||||
**依赖:** 切片 2 Task 1-4(后端 API 补全)必须先完成
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: API 层 + 页面
|
||||
|
||||
### Task 1: 创建 AI 分析 API service
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/miniprogram/src/services/ai-analysis.ts`
|
||||
|
||||
**参考模式:** `services/report.ts`(化验报告 service)
|
||||
|
||||
- [ ] **Step 1: 创建 service 文件**
|
||||
|
||||
`apps/miniprogram/src/services/ai-analysis.ts`:
|
||||
|
||||
```typescript
|
||||
import { api } from './request';
|
||||
|
||||
export interface AiAnalysisItem {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
analysis_type: string;
|
||||
model_used: string;
|
||||
status: string;
|
||||
result_content: string | null;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function listAiAnalysis(page = 1, pageSize = 20) {
|
||||
return api.get<{ data: AiAnalysisItem[]; total: number }>(
|
||||
'/ai/analysis/history',
|
||||
{ page, page_size: pageSize },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAiAnalysisDetail(id: string) {
|
||||
return api.get<AiAnalysisItem>(`/ai/analysis/${id}`);
|
||||
}
|
||||
```
|
||||
|
||||
**注意:** 后端 `list_analysis` 会根据 JWT 中的 `user_id` → `patient_id` 自动过滤(小程序端通过 `X-Patient-Id` header 传递)。若后端未自动过滤,需在请求参数中传 `patient_id`。
|
||||
|
||||
- [ ] **Step 2: 验证编译**
|
||||
|
||||
Run: `cd apps/miniprogram && npx tsc --noEmit`
|
||||
Expected: 无错误
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/miniprogram/src/services/ai-analysis.ts
|
||||
git commit -m "feat(miniprogram): AI 分析 API service"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: AI 报告列表页
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/miniprogram/src/pages/ai-report/list/index.tsx`
|
||||
- Create: `apps/miniprogram/src/pages/ai-report/list/index.scss`
|
||||
|
||||
**参考模式:** `pages/report/detail/index.tsx` + `pages/article/index.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建列表页组件**
|
||||
|
||||
`apps/miniprogram/src/pages/ai-report/list/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { listAiAnalysis, type AiAnalysisItem } from '@/services/ai-analysis';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
lab_report: '化验单解读',
|
||||
trend: '趋势分析',
|
||||
checkup_plan: '体检方案',
|
||||
report_summary: '报告摘要',
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<string, { text: string; className: string }> = {
|
||||
completed: { text: '已完成', className: 'status-completed' },
|
||||
streaming: { text: '分析中', className: 'status-streaming' },
|
||||
failed: { text: '失败', className: 'status-failed' },
|
||||
};
|
||||
|
||||
export default function AiReportList() {
|
||||
const [list, setList] = useState<AiAnalysisItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadList(1);
|
||||
}, []);
|
||||
|
||||
const loadList = async (p: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listAiAnalysis(p, 20);
|
||||
const items = res.data || [];
|
||||
setList(p === 1 ? items : [...list, ...items]);
|
||||
setPage(p);
|
||||
setHasMore(items.length >= 20);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goDetail = (id: string) => {
|
||||
Taro.navigateTo({ url: `/pages/ai-report/detail/index?id=${id}` });
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
if (hasMore && !loading) loadList(page + 1);
|
||||
};
|
||||
|
||||
if (loading && list.length === 0) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (list.length === 0) {
|
||||
return (
|
||||
<View className='ai-report-page'>
|
||||
<EmptyState text='暂无 AI 分析报告' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='ai-report-page'>
|
||||
<View className='page-title'>AI 分析报告</View>
|
||||
<ScrollView scrollY className='report-scroll' onScrollToLower={loadMore}>
|
||||
{list.map((item) => {
|
||||
const statusInfo = STATUS_MAP[item.status] || { text: item.status, className: '' };
|
||||
return (
|
||||
<View
|
||||
key={item.id}
|
||||
className='report-card'
|
||||
onClick={() => item.status === 'completed' && goDetail(item.id)}
|
||||
>
|
||||
<View className='card-header'>
|
||||
<Text className='card-type'>{TYPE_LABELS[item.analysis_type] || item.analysis_type}</Text>
|
||||
<Text className={`card-status ${statusInfo.className}`}>{statusInfo.text}</Text>
|
||||
</View>
|
||||
<View className='card-footer'>
|
||||
<Text className='card-time'>{new Date(item.created_at).toLocaleString('zh-CN')}</Text>
|
||||
<Text className='card-model'>{item.model_used}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{loading && <Loading />}
|
||||
{!hasMore && list.length > 0 && <Text className='no-more'>没有更多了</Text>}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建列表页样式**
|
||||
|
||||
`apps/miniprogram/src/pages/ai-report/list/index.scss`:
|
||||
|
||||
```scss
|
||||
.ai-report-page {
|
||||
min-height: 100vh;
|
||||
background: #f1f5f9;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.report-scroll {
|
||||
height: calc(100vh - 80px);
|
||||
}
|
||||
|
||||
.report-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-type {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.card-status {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
color: #16a34a;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.status-streaming {
|
||||
color: #2563eb;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
color: #dc2626;
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-time {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.card-model {
|
||||
font-size: 11px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
padding: 16px 0;
|
||||
display: block;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
Run: `cd apps/miniprogram && npx tsc --noEmit`
|
||||
Expected: 无错误
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/miniprogram/src/pages/ai-report/list/
|
||||
git commit -m "feat(miniprogram): AI 报告列表页"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: AI 报告详情页
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/miniprogram/src/pages/ai-report/detail/index.tsx`
|
||||
- Create: `apps/miniprogram/src/pages/ai-report/detail/index.scss`
|
||||
|
||||
- [ ] **Step 1: 创建详情页组件**
|
||||
|
||||
`apps/miniprogram/src/pages/ai-report/detail/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, RichText } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { getAiAnalysisDetail, type AiAnalysisItem } from '@/services/ai-analysis';
|
||||
import Loading from '@/components/Loading';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
lab_report: '化验单解读',
|
||||
trend: '趋势分析',
|
||||
checkup_plan: '体检方案',
|
||||
report_summary: '报告摘要',
|
||||
};
|
||||
|
||||
function markdownToHtml(md: string): string {
|
||||
return md
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
|
||||
.replace(/\n\n/g, '<br/><br/>')
|
||||
.replace(/\n/g, '<br/>');
|
||||
}
|
||||
|
||||
export default function AiReportDetail() {
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
|
||||
const [analysis, setAnalysis] = useState<AiAnalysisItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
getAiAnalysisDetail(id)
|
||||
.then((data) => setAnalysis(data))
|
||||
.catch(() => Taro.showToast({ title: '加载失败', icon: 'none' }))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <Loading />;
|
||||
|
||||
if (!analysis) {
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<Text className='empty-text'>报告不存在</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const htmlContent = analysis.result_content
|
||||
? markdownToHtml(analysis.result_content)
|
||||
: '<p>暂无分析结果</p>';
|
||||
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<View className='detail-card'>
|
||||
<Text className='detail-type'>{TYPE_LABELS[analysis.analysis_type] || analysis.analysis_type}</Text>
|
||||
<View className='detail-meta'>
|
||||
<Text className='meta-item'>模型: {analysis.model_used}</Text>
|
||||
<Text className='meta-item'>{new Date(analysis.created_at).toLocaleString('zh-CN')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='content-card'>
|
||||
<RichText className='report-content' nodes={htmlContent} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建详情页样式**
|
||||
|
||||
`apps/miniprogram/src/pages/ai-report/detail/index.scss`:
|
||||
|
||||
```scss
|
||||
.detail-page {
|
||||
min-height: 100vh;
|
||||
background: #f1f5f9;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.detail-type {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.report-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
Run: `cd apps/miniprogram && npx tsc --noEmit`
|
||||
Expected: 无错误
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/miniprogram/src/pages/ai-report/detail/
|
||||
git commit -m "feat(miniprogram): AI 报告详情页"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 集成
|
||||
|
||||
### Task 4: 注册页面路由
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/miniprogram/src/app.config.ts`
|
||||
|
||||
- [ ] **Step 1: 在 pages 数组中注册新页面**
|
||||
|
||||
在 `app.config.ts` 的 `pages` 数组中,在 `pages/report/detail/index` 之后添加:
|
||||
|
||||
```typescript
|
||||
'pages/ai-report/list/index',
|
||||
'pages/ai-report/detail/index',
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/miniprogram && npx tsc --noEmit
|
||||
git add apps/miniprogram/src/app.config.ts
|
||||
git commit -m "feat(miniprogram): 注册 AI 报告页面路由"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 首页入口卡片
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/miniprogram/src/pages/index/index.tsx`
|
||||
- Modify: `apps/miniprogram/src/pages/index/index.scss`
|
||||
|
||||
- [ ] **Step 1: 在首页添加 AI 报告入口**
|
||||
|
||||
在 `pages/index/index.tsx` 中,找到功能入口区域,添加 AI 报告卡片。
|
||||
|
||||
在页面 JSX 中添加一个导航卡片:
|
||||
|
||||
```tsx
|
||||
<View
|
||||
className='feature-card ai-card'
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/ai-report/list/index' })}
|
||||
>
|
||||
<Text className='feature-icon'>🤖</Text>
|
||||
<Text className='feature-title'>AI 分析报告</Text>
|
||||
<Text className='feature-desc'>查看智能健康分析结果</Text>
|
||||
</View>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加入口卡片样式(如需要)**
|
||||
|
||||
在 `pages/index/index.scss` 中,根据现有功能卡片样式添加 `.ai-card` 样式(如果现有的 `.feature-card` 已足够,可跳过)。
|
||||
|
||||
- [ ] **Step 3: 验证编译 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/miniprogram && npx tsc --noEmit
|
||||
git add apps/miniprogram/src/pages/index/
|
||||
git commit -m "feat(miniprogram): 首页添加 AI 报告入口卡片"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 集成验证
|
||||
|
||||
- [ ] **Step 1: 编译检查**
|
||||
|
||||
Run: `cd apps/miniprogram && npx tsc --noEmit`
|
||||
Expected: 无错误
|
||||
|
||||
- [ ] **Step 2: 启动后端服务**
|
||||
|
||||
Run: `cd crates/erp-server && cargo run`
|
||||
|
||||
确保 `/api/v1/ai/analysis/history` 和 `/api/v1/ai/analysis/{id}` 端点可用。
|
||||
|
||||
- [ ] **Step 3: 小程序编译**
|
||||
|
||||
Run: `cd apps/miniprogram && pnpm build:weapp`
|
||||
|
||||
Expected: 编译成功
|
||||
|
||||
- [ ] **Step 4: 功能验证(微信开发者工具)**
|
||||
|
||||
1. 登录小程序(绑定有 AI 分析记录的患者)
|
||||
2. 首页可见"AI 分析报告"入口卡片
|
||||
3. 点击进入列表页 → 显示该患者的 AI 分析记录
|
||||
4. 点击一条已完成的记录 → 进入详情页,Markdown 内容正常渲染
|
||||
5. 无记录时显示空状态
|
||||
6. 失败记录显示"失败"标签,不可点击进入详情
|
||||
|
||||
- [ ] **Step 5: 推送**
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
Reference in New Issue
Block a user