feat(web): AI 分析详情增加建议面板 — 风险等级+建议列表+审批操作
- 新增 suggestions API 层(list/approve/getComparison) - 展开分析详情时自动加载关联的 AI 建议列表 - 风险等级彩色标签(低/中/高) - 建议类型、原因、执行状态展示 - 待审批建议支持批准/拒绝操作
This commit is contained in:
34
apps/web/src/api/ai/suggestions.ts
Normal file
34
apps/web/src/api/ai/suggestions.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import client from '../client';
|
||||
|
||||
export interface SuggestionItem {
|
||||
id: string;
|
||||
analysis_id: string;
|
||||
suggestion_type: string;
|
||||
risk_level: string;
|
||||
params: Record<string, unknown> | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ComparisonReport {
|
||||
suggestion_id: string;
|
||||
baseline: Record<string, unknown> | null;
|
||||
current: Record<string, unknown> | null;
|
||||
comparison_available: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const suggestionApi = {
|
||||
list: async (params?: { analysis_id?: string; status?: string }) => {
|
||||
const resp = await client.get('/ai/suggestions', { params });
|
||||
return resp.data.data as { data: SuggestionItem[]; total: number };
|
||||
},
|
||||
approve: async (id: string, action: 'approve' | 'reject') => {
|
||||
const resp = await client.post(`/ai/suggestions/${id}/approve`, { action });
|
||||
return resp.data.data as { id: string; status: string };
|
||||
},
|
||||
getComparison: async (id: string) => {
|
||||
const resp = await client.get(`/ai/suggestions/${id}/comparison`);
|
||||
return resp.data.data as ComparisonReport;
|
||||
},
|
||||
};
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Table, Select, Tag, Space, message, Typography } from 'antd';
|
||||
import { Table, Select, Tag, Space, Button, message, Typography } from 'antd';
|
||||
import {
|
||||
RobotOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
import { analysisApi, type AnalysisItem } from '../../api/ai/analysis';
|
||||
import { suggestionApi, type SuggestionItem } from '../../api/ai/suggestions';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -27,6 +32,27 @@ const TYPE_OPTIONS = Object.entries(ANALYSIS_TYPE_MAP).map(([value, label]) => (
|
||||
label,
|
||||
}));
|
||||
|
||||
const RISK_CONFIG: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
|
||||
low: { color: 'green', text: '低风险', icon: <CheckCircleOutlined /> },
|
||||
medium: { color: 'orange', text: '中风险', icon: <ExclamationCircleOutlined /> },
|
||||
high: { color: 'red', text: '高风险', icon: <WarningOutlined /> },
|
||||
};
|
||||
|
||||
const SUGGESTION_TYPE_MAP: Record<string, string> = {
|
||||
followup: '随访建议',
|
||||
appointment: '预约建议',
|
||||
alert: '预警通知',
|
||||
};
|
||||
|
||||
const SUGGESTION_STATUS_CONFIG: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'orange', text: '待审批' },
|
||||
approved: { color: 'green', text: '已批准' },
|
||||
rejected: { color: 'red', text: '已拒绝' },
|
||||
executed: { color: 'blue', text: '已执行' },
|
||||
expired: { color: 'default', text: '已过期' },
|
||||
parse_failed: { color: 'red', text: '解析失败' },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 分析结果渲染(Markdown 风格)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -105,6 +131,117 @@ function renderInlineStyles(text: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI 建议面板
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SuggestionPanel({ analysisId, isDark }: { analysisId: string; isDark: boolean }) {
|
||||
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
const fetchSuggestions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await suggestionApi.list({ analysis_id: analysisId });
|
||||
setSuggestions(result.data || []);
|
||||
} catch {
|
||||
// 静默处理
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [analysisId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (analysisId) fetchSuggestions();
|
||||
}, [analysisId, fetchSuggestions]);
|
||||
|
||||
const handleAction = async (id: string, action: 'approve' | 'reject') => {
|
||||
setActionLoading(id);
|
||||
try {
|
||||
await suggestionApi.approve(id, action);
|
||||
message.success(action === 'approve' ? '已批准' : '已拒绝');
|
||||
fetchSuggestions();
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ padding: '8px 0' }}>加载建议中...</div>;
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${isDark ? '#1e293b' : '#e2e8f0'}`,
|
||||
}}>
|
||||
<Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
|
||||
AI 建议列表 ({suggestions.length})
|
||||
</Text>
|
||||
{suggestions.map((s) => {
|
||||
const risk = RISK_CONFIG[s.risk_level] || { color: 'default', text: s.risk_level, icon: null };
|
||||
const status = SUGGESTION_STATUS_CONFIG[s.status] || { color: 'default', text: s.status };
|
||||
const typeLabel = SUGGESTION_TYPE_MAP[s.suggestion_type] || s.suggestion_type;
|
||||
const isPending = s.status === 'pending';
|
||||
const params = s.params as Record<string, unknown> | null;
|
||||
const reason = params?.reason as string || params?.message as string || '';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
marginBottom: 6,
|
||||
background: isDark ? '#111827' : '#fff',
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${isDark ? '#1e293b' : '#f0f0f0'}`,
|
||||
}}
|
||||
>
|
||||
<Space size={8}>
|
||||
<Tag color={risk.color} style={{ margin: 0 }}>
|
||||
{risk.icon} {risk.text}
|
||||
</Tag>
|
||||
<Tag style={{ margin: 0 }}>{typeLabel}</Tag>
|
||||
{reason && <Text type="secondary" style={{ fontSize: 12, maxWidth: 300 }} ellipsis>{reason}</Text>}
|
||||
<Tag color={status.color} style={{ margin: 0, fontSize: 11 }}>{status.text}</Tag>
|
||||
</Space>
|
||||
{isPending && (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<CheckCircleOutlined />}
|
||||
loading={actionLoading === s.id}
|
||||
onClick={() => handleAction(s.id, 'approve')}
|
||||
>
|
||||
批准
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<CloseCircleOutlined />}
|
||||
loading={actionLoading === s.id}
|
||||
onClick={() => handleAction(s.id, 'reject')}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 主组件
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -298,6 +435,12 @@ export default function AiAnalysisList() {
|
||||
<Text type="secondary">暂无结果内容</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI 建议面板 */}
|
||||
{detail.id && (
|
||||
<SuggestionPanel analysisId={detail.id} isDark={isDark} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user