feat(ai): Phase 1A 收尾 — 用量记录 + 健康摘要端点 + 小程序组件

- chat_handler 添加 log_usage 精确记录 token 消耗(provider + model)
- SSE build_sse_stream 添加估算 token 用量记录(4 字符 ≈ 1 token)
- 新增 GET /ai/health-summary 端点聚合患者洞察+分析记录
- 小程序 AiHealthSummaryCard 组件(风险等级+洞察统计+摘要列表)
- 小程序 services/ai-analysis 新增 getHealthSummary API
This commit is contained in:
iven
2026-05-18 23:20:06 +08:00
parent 281c71ebfc
commit 7e3d27ecf3
7 changed files with 397 additions and 2 deletions

View File

@@ -0,0 +1,100 @@
.ai-summary-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.ai-summary-title {
font-size: var(--tk-font-size-body-lg, 18px);
font-weight: 600;
color: var(--tk-color-text, #333);
}
.ai-summary-risk {
padding: 4px 16px;
border-radius: 100px;
&-text {
font-size: var(--tk-font-size-cap, 13px);
color: #fff;
font-weight: 500;
}
}
.ai-summary-insight {
margin-bottom: 24px;
&-label {
font-size: var(--tk-font-size-cap, 13px);
color: var(--tk-color-text-secondary, #999);
display: block;
margin-bottom: 8px;
}
&-text {
font-size: var(--tk-font-size-body, 16px);
color: var(--tk-color-text, #333);
line-height: 1.5;
}
}
.ai-summary-stats {
display: flex;
gap: 40px;
margin-bottom: 24px;
}
.ai-summary-stat {
display: flex;
flex-direction: column;
align-items: center;
&-value {
font-size: 28px;
font-weight: 700;
color: var(--tk-color-primary, #1890ff);
}
&-label {
font-size: var(--tk-font-size-cap, 13px);
color: var(--tk-color-text-secondary, #999);
margin-top: 4px;
}
}
.ai-summary-items {
border-top: 1px solid var(--tk-color-border, #f0f0f0);
padding-top: 20px;
}
.ai-summary-item {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 0;
&-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
&-title {
font-size: var(--tk-font-size-body-sm, 14px);
color: var(--tk-color-text, #333);
line-height: 1.4;
}
}
.ai-summary-loading {
display: flex;
justify-content: center;
padding: 32px 0;
&-text {
font-size: var(--tk-font-size-body-sm, 14px);
color: var(--tk-color-text-secondary, #999);
}
}

View File

@@ -0,0 +1,97 @@
import React, { useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components';
import ContentCard from '../ContentCard';
import { getHealthSummary, type HealthSummary } from '../../../services/ai-analysis';
import './index.scss';
interface AiHealthSummaryCardProps {
patientId: string;
}
const RISK_COLORS: Record<string, string> = {
critical: 'var(--tk-color-danger, #ff4d4f)',
high: 'var(--tk-color-warning, #faad14)',
medium: 'var(--tk-color-info, #1890ff)',
low: 'var(--tk-color-success, #52c41a)',
};
const RISK_LABELS: Record<string, string> = {
critical: '高风险',
high: '较高风险',
medium: '中等风险',
low: '低风险',
};
const AiHealthSummaryCard: React.FC<AiHealthSummaryCardProps> = ({ patientId }) => {
const [summary, setSummary] = useState<HealthSummary | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!patientId) return;
setLoading(true);
getHealthSummary(patientId)
.then((data) => setSummary(data))
.catch(() => setSummary(null))
.finally(() => setLoading(false));
}, [patientId]);
if (loading) {
return (
<ContentCard>
<View className='ai-summary-loading'>
<Text className='ai-summary-loading-text'>AI ...</Text>
</View>
</ContentCard>
);
}
if (!summary) return null;
const riskColor = RISK_COLORS[summary.risk_level] || RISK_COLORS.low;
const riskLabel = RISK_LABELS[summary.risk_level] || '低风险';
return (
<ContentCard>
<View className='ai-summary-header'>
<Text className='ai-summary-title'>AI </Text>
<View className='ai-summary-risk' style={{ backgroundColor: riskColor }}>
<Text className='ai-summary-risk-text'>{riskLabel}</Text>
</View>
</View>
{summary.latest_insight_title && (
<View className='ai-summary-insight'>
<Text className='ai-summary-insight-label'></Text>
<Text className='ai-summary-insight-text'>{summary.latest_insight_title}</Text>
</View>
)}
<View className='ai-summary-stats'>
<View className='ai-summary-stat'>
<Text className='ai-summary-stat-value'>{summary.active_insights_count}</Text>
<Text className='ai-summary-stat-label'></Text>
</View>
<View className='ai-summary-stat'>
<Text className='ai-summary-stat-value'>{summary.recent_analyses_count}</Text>
<Text className='ai-summary-stat-label'>AI </Text>
</View>
</View>
{summary.summary_items.length > 0 && (
<View className='ai-summary-items'>
{summary.summary_items.slice(0, 3).map((item, idx) => (
<View key={idx} className='ai-summary-item'>
<View
className='ai-summary-item-dot'
style={{ backgroundColor: item.severity ? (RISK_COLORS[item.severity] || riskColor) : riskColor }}
/>
<Text className='ai-summary-item-title'>{item.title}</Text>
</View>
))}
</View>
)}
</ContentCard>
);
};
export default React.memo(AiHealthSummaryCard);

View File

@@ -40,3 +40,29 @@ export async function listPendingSuggestions() {
);
return resp.data || [];
}
// === 健康摘要 ===
export interface SummaryItem {
category: string;
title: string;
severity: string | null;
created_at: string;
}
export interface HealthSummary {
patient_id: string;
risk_level: string;
active_insights_count: number;
recent_analyses_count: number;
latest_insight_title: string | null;
latest_analysis_type: string | null;
summary_items: SummaryItem[];
}
export async function getHealthSummary(patientId: string) {
return api.get<HealthSummary>(
'/ai/health-summary',
{ patient_id: patientId },
);
}