diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7ddb7a6..1df9db8 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -40,6 +40,9 @@ const PointsProductList = lazy(() => import('./pages/health/PointsProductList')) const PointsOrderList = lazy(() => import('./pages/health/PointsOrderList')); const OfflineEventList = lazy(() => import('./pages/health/OfflineEventList')); const StatisticsDashboard = lazy(() => import('./pages/health/StatisticsDashboard')); +const AiPromptList = lazy(() => import('./pages/health/AiPromptList')); +const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList')); +const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard')); function PrivateRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); @@ -186,6 +189,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> diff --git a/apps/web/src/api/ai/analysis.ts b/apps/web/src/api/ai/analysis.ts new file mode 100644 index 0000000..1c4822d --- /dev/null +++ b/apps/web/src/api/ai/analysis.ts @@ -0,0 +1,27 @@ +import client from '../client'; +import type { PaginatedResponse } from '../types'; + +export interface AnalysisItem { + id: string; + patient_id: string; + analysis_type: string; + source_ref: string; + model_used: string; + status: string; + result_content: string | null; + result_metadata: Record | null; + error_message: string | null; + created_at: string; + updated_at: string; +} + +export const analysisApi = { + list: async (params?: { patient_id?: string; analysis_type?: string; page?: number; page_size?: number }) => { + const resp = await client.get('/ai/analysis/history', { params }); + return resp.data.data as PaginatedResponse; + }, + get: async (id: string) => { + const resp = await client.get(`/ai/analysis/${id}`); + return resp.data.data as AnalysisItem; + }, +}; diff --git a/apps/web/src/api/ai/prompts.ts b/apps/web/src/api/ai/prompts.ts new file mode 100644 index 0000000..c591dab --- /dev/null +++ b/apps/web/src/api/ai/prompts.ts @@ -0,0 +1,45 @@ +import client from '../client'; +import type { PaginatedResponse } from '../types'; + +export interface PromptItem { + id: string; + name: string; + description: string; + system_prompt: string; + user_prompt_template: string; + model_config: Record; + version: number; + is_active: boolean; + category: string; + tags: Record | null; + created_at: string; + updated_at: string; +} + +export interface CreatePromptReq { + name: string; + description?: string; + system_prompt: string; + user_prompt_template: string; + model_config: Record; + category: string; +} + +export const promptApi = { + list: async (params?: { category?: string; page?: number; page_size?: number }) => { + const resp = await client.get('/ai/prompts', { params }); + return resp.data.data as PaginatedResponse; + }, + create: async (data: CreatePromptReq) => { + const resp = await client.post('/ai/prompts', data); + return resp.data.data as PromptItem; + }, + activate: async (id: string) => { + const resp = await client.post(`/ai/prompts/${id}/activate`); + return resp.data.data as PromptItem; + }, + rollback: async (id: string) => { + const resp = await client.post(`/ai/prompts/${id}/rollback`); + return resp.data.data as PromptItem; + }, +}; diff --git a/apps/web/src/api/ai/usage.ts b/apps/web/src/api/ai/usage.ts new file mode 100644 index 0000000..7a104c5 --- /dev/null +++ b/apps/web/src/api/ai/usage.ts @@ -0,0 +1,21 @@ +import client from '../client'; + +export interface UsageOverview { + total_count: number; +} + +export interface TypeDistribution { + analysis_type: string; + count: number; +} + +export const usageApi = { + overview: async () => { + const resp = await client.get('/ai/usage/overview'); + return resp.data.data as UsageOverview; + }, + byType: async () => { + const resp = await client.get('/ai/usage/by-type'); + return resp.data.data as TypeDistribution[]; + }, +}; diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index 389e24a..82491f9 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -28,6 +28,9 @@ import { ShopOutlined, FileTextOutlined, DashboardOutlined, + RobotOutlined, + HistoryOutlined, + BarChartOutlined, } from '@ant-design/icons'; import { useNavigate, useLocation } from 'react-router-dom'; import { useAppStore } from '../stores/app'; @@ -69,6 +72,9 @@ const healthMenuItems: MenuItem[] = [ { key: '/health/points-products', icon: , label: '商品管理' }, { key: '/health/points-orders', icon: , label: '订单管理' }, { key: '/health/offline-events', icon: , label: '线下活动' }, + { key: '/health/ai-prompts', icon: , label: 'AI Prompt 管理' }, + { key: '/health/ai-analysis', icon: , label: 'AI 分析历史' }, + { key: '/health/ai-usage', icon: , label: 'AI 用量统计' }, ]; const sysMenuItems: MenuItem[] = [ diff --git a/apps/web/src/pages/health/AiAnalysisList.tsx b/apps/web/src/pages/health/AiAnalysisList.tsx new file mode 100644 index 0000000..964c844 --- /dev/null +++ b/apps/web/src/pages/health/AiAnalysisList.tsx @@ -0,0 +1,201 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Table, Select, Tag, Space, message, Typography } from 'antd'; +import { useThemeMode } from '../../hooks/useThemeMode'; +import { analysisApi, type AnalysisItem } from '../../api/ai/analysis'; + +const ANALYSIS_TYPE_MAP: Record = { + lab_report_interpretation: '化验单解读', + health_trend_analysis: '趋势分析', + personalized_checkup_plan: '体检方案', + report_summary_generation: '报告摘要', +}; + +const STATUS_CONFIG: Record = { + completed: { color: 'green', text: '已完成' }, + failed: { color: 'red', text: '失败' }, + streaming: { color: 'blue', text: '进行中' }, + pending: { color: 'orange', text: '等待中' }, +}; + +const TYPE_OPTIONS = Object.entries(ANALYSIS_TYPE_MAP).map(([value, label]) => ({ + value, + label, +})); + +export default function AiAnalysisList() { + const [data, setData] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [query, setQuery] = useState<{ page: number; page_size: number; analysis_type?: string }>({ + page: 1, + page_size: 20, + }); + const [expandedId, setExpandedId] = useState(null); + const [detail, setDetail] = useState(null); + const isDark = useThemeMode(); + + const fetchData = useCallback( + async (params: { page: number; page_size: number; analysis_type?: string }) => { + setLoading(true); + try { + const result = await analysisApi.list(params); + setData(result.data); + setTotal(result.total); + } catch { + message.error('加载分析历史失败'); + } finally { + setLoading(false); + } + }, + [], + ); + + useEffect(() => { + fetchData(query); + }, [query, fetchData]); + + const handleExpand = async (expanded: boolean, record: AnalysisItem) => { + if (expanded && record.id !== expandedId) { + try { + const item = await analysisApi.get(record.id); + setDetail(item); + setExpandedId(record.id); + } catch { + // 展开失败不阻塞 + } + } else if (!expanded) { + setExpandedId(null); + setDetail(null); + } + }; + + const columns = [ + { + title: '分析类型', + dataIndex: 'analysis_type', + key: 'analysis_type', + width: 130, + render: (v: string) => ( + {ANALYSIS_TYPE_MAP[v] || v} + ), + }, + { + title: '患者 ID', + dataIndex: 'patient_id', + key: 'patient_id', + width: 120, + render: (v: string) => ( + {v.slice(0, 8)} + ), + }, + { + title: '模型', + dataIndex: 'model_used', + key: 'model_used', + width: 130, + render: (v: string) => v || '-', + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 90, + render: (v: string) => { + const cfg = STATUS_CONFIG[v] || { color: 'default', text: v }; + return {cfg.text}; + }, + }, + { + title: '创建时间', + dataIndex: 'created_at', + key: 'created_at', + width: 170, + render: (v: string) => (v ? new Date(v).toLocaleString('zh-CN') : '-'), + }, + ]; + + return ( +
+
+
+

AI 分析历史

+
查看所有 AI 智能分析记录和结果
+
+ + { + setCategoryFilter(v); + setPage(1); + }} + options={CATEGORIES} + allowClear + style={{ width: 150 }} + /> + + + + +
+ +
+ { + setPage(p); + fetchData(p); + }, + showTotal: (t) => `共 ${t} 条记录`, + style: { padding: '12px 16px', margin: 0 }, + }} + /> + + + setModalOpen(false)} + onOk={() => form.submit()} + width={600} + destroyOnClose + > +
+ + + + +