feat(miniprogram): AI 报告查看 — 列表页/详情页/首页入口
- AI 分析 API service (ai-analysis.ts) - 报告列表页: 滚动加载 + 状态标签 + 点击详情 - 报告详情页: Markdown 转 HTML + RichText 渲染 - app.config.ts 注册路由 - 首页添加 AI 报告快捷入口
This commit is contained in:
52
apps/miniprogram/src/pages/ai-report/detail/index.scss
Normal file
52
apps/miniprogram/src/pages/ai-report/detail/index.scss
Normal 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;
|
||||
}
|
||||
73
apps/miniprogram/src/pages/ai-report/detail/index.tsx
Normal file
73
apps/miniprogram/src/pages/ai-report/detail/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
apps/miniprogram/src/pages/ai-report/list/index.scss
Normal file
87
apps/miniprogram/src/pages/ai-report/list/index.scss
Normal 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;
|
||||
}
|
||||
97
apps/miniprogram/src/pages/ai-report/list/index.tsx
Normal file
97
apps/miniprogram/src/pages/ai-report/list/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user