feat(miniprogram): AI 报告查看 — 列表页/详情页/首页入口
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

- AI 分析 API service (ai-analysis.ts)
- 报告列表页: 滚动加载 + 状态标签 + 点击详情
- 报告详情页: Markdown 转 HTML + RichText 渲染
- app.config.ts 注册路由
- 首页添加 AI 报告快捷入口
This commit is contained in:
iven
2026-04-25 23:53:01 +08:00
parent 5621dbe273
commit 98de5ad3b9
7 changed files with 335 additions and 0 deletions

View File

@@ -12,6 +12,8 @@ export default defineAppConfig({
'pages/article/index',
'pages/article/detail/index',
'pages/report/detail/index',
'pages/ai-report/list/index',
'pages/ai-report/detail/index',
'pages/followup/detail/index',
'pages/consultation/index',
'pages/mall/index',

View File

@@ -0,0 +1,52 @@
.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;
}

View File

@@ -0,0 +1,73 @@
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_interpretation: '化验单解读',
health_trend_analysis: '趋势分析',
personalized_checkup_plan: '体检方案',
report_summary_generation: '报告摘要',
};
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>[\s\S]*?<\/li>)/g, '<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>
);
}

View File

@@ -0,0 +1,87 @@
.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;
}
.status-pending {
color: #d97706;
background: #fef3c7;
}
.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;
}

View File

@@ -0,0 +1,97 @@
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_interpretation: '化验单解读',
health_trend_analysis: '趋势分析',
personalized_checkup_plan: '体检方案',
report_summary_generation: '报告摘要',
};
const STATUS_MAP: Record<string, { text: string; className: string }> = {
completed: { text: '已完成', className: 'status-completed' },
streaming: { text: '分析中', className: 'status-streaming' },
failed: { text: '失败', className: 'status-failed' },
pending: { text: '等待中', className: 'status-pending' },
};
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 : (prev) => [...prev, ...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>
);
}

View File

@@ -79,6 +79,7 @@ export default function Index() {
{ label: '健康录入', icon: '📊', path: '/pages/health/input/index' },
{ label: '健康趋势', icon: '📈', path: '/pages/health/trend/index' },
{ label: '资讯文章', icon: '📰', path: '/pages/article/index' },
{ label: 'AI 报告', icon: '🤖', path: '/pages/ai-report/list/index' },
];
const handleServiceClick = (path: string) => {

View File

@@ -0,0 +1,23 @@
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}`);
}