Files
hms/docs/superpowers/plans/2026-04-25-slice3-miniprogram-ai-report.md
iven eb79424305
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
docs(plan): 切片 3 小程序 AI 报告查看实施计划
6 个 Task,2 个 Chunk:
- Chunk 1: API service + 列表页 + 详情页(Taro/React)
- Chunk 2: 路由注册 + 首页入口 + 集成验证
2026-04-25 22:47:33 +08:00

13 KiB
Raw Permalink Blame History

切片 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.tsapi 封装,新增 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:

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_idpatient_id 自动过滤(小程序端通过 X-Patient-Id header 传递)。若后端未自动过滤,需在请求参数中传 patient_id

  • Step 2: 验证编译

Run: cd apps/miniprogram && npx tsc --noEmit Expected: 无错误

  • Step 3: 提交
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:

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:

.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: 提交
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:

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:

.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: 提交
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.tspages 数组中,在 pages/report/detail/index 之后添加:

'pages/ai-report/list/index',
'pages/ai-report/detail/index',
  • Step 2: 验证编译 + 提交
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 中添加一个导航卡片:

<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: 验证编译 + 提交
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: 推送
git push