feat(web): AI 管理端 3 页面 — Prompt/分析历史/用量统计
- API 封装: prompts.ts / analysis.ts / usage.ts - AiPromptList: CRUD + 激活/回滚 + AuthButton 权限 - AiAnalysisList: 历史列表 + 行展开查看结果 - AiUsageDashboard: 总次数/类型分布统计卡片 - 菜单注册 + 路由配置 (MainLayout + App.tsx)
This commit is contained in:
@@ -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() {
|
||||
<Route path="/health/points-products" element={<PointsProductList />} />
|
||||
<Route path="/health/points-orders" element={<PointsOrderList />} />
|
||||
<Route path="/health/offline-events" element={<OfflineEventList />} />
|
||||
<Route path="/health/ai-prompts" element={<AiPromptList />} />
|
||||
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
|
||||
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
27
apps/web/src/api/ai/analysis.ts
Normal file
27
apps/web/src/api/ai/analysis.ts
Normal file
@@ -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<string, unknown> | 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<AnalysisItem>;
|
||||
},
|
||||
get: async (id: string) => {
|
||||
const resp = await client.get(`/ai/analysis/${id}`);
|
||||
return resp.data.data as AnalysisItem;
|
||||
},
|
||||
};
|
||||
45
apps/web/src/api/ai/prompts.ts
Normal file
45
apps/web/src/api/ai/prompts.ts
Normal file
@@ -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<string, unknown>;
|
||||
version: number;
|
||||
is_active: boolean;
|
||||
category: string;
|
||||
tags: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreatePromptReq {
|
||||
name: string;
|
||||
description?: string;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string;
|
||||
model_config: Record<string, unknown>;
|
||||
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<PromptItem>;
|
||||
},
|
||||
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;
|
||||
},
|
||||
};
|
||||
21
apps/web/src/api/ai/usage.ts
Normal file
21
apps/web/src/api/ai/usage.ts
Normal file
@@ -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[];
|
||||
},
|
||||
};
|
||||
@@ -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: <ShopOutlined />, label: '商品管理' },
|
||||
{ key: '/health/points-orders', icon: <FileTextOutlined />, label: '订单管理' },
|
||||
{ key: '/health/offline-events', icon: <CalendarOutlined />, label: '线下活动' },
|
||||
{ key: '/health/ai-prompts', icon: <RobotOutlined />, label: 'AI Prompt 管理' },
|
||||
{ key: '/health/ai-analysis', icon: <HistoryOutlined />, label: 'AI 分析历史' },
|
||||
{ key: '/health/ai-usage', icon: <BarChartOutlined />, label: 'AI 用量统计' },
|
||||
];
|
||||
|
||||
const sysMenuItems: MenuItem[] = [
|
||||
|
||||
201
apps/web/src/pages/health/AiAnalysisList.tsx
Normal file
201
apps/web/src/pages/health/AiAnalysisList.tsx
Normal file
@@ -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<string, string> = {
|
||||
lab_report_interpretation: '化验单解读',
|
||||
health_trend_analysis: '趋势分析',
|
||||
personalized_checkup_plan: '体检方案',
|
||||
report_summary_generation: '报告摘要',
|
||||
};
|
||||
|
||||
const STATUS_CONFIG: Record<string, { color: string; text: string }> = {
|
||||
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<AnalysisItem[]>([]);
|
||||
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<string | null>(null);
|
||||
const [detail, setDetail] = useState<AnalysisItem | null>(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) => (
|
||||
<Tag color="blue">{ANALYSIS_TYPE_MAP[v] || v}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '患者 ID',
|
||||
dataIndex: 'patient_id',
|
||||
key: 'patient_id',
|
||||
width: 120,
|
||||
render: (v: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{v.slice(0, 8)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 <Tag color={cfg.color}>{cfg.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? new Date(v).toLocaleString('zh-CN') : '-'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="erp-page-header">
|
||||
<div>
|
||||
<h4>AI 分析历史</h4>
|
||||
<div className="erp-page-subtitle">查看所有 AI 智能分析记录和结果</div>
|
||||
</div>
|
||||
<Space size={8}>
|
||||
<Select
|
||||
placeholder="筛选类型"
|
||||
value={query.analysis_type}
|
||||
onChange={(v) => setQuery((prev) => ({ ...prev, analysis_type: v, page: 1 }))}
|
||||
options={TYPE_OPTIONS}
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
expandable={{
|
||||
expandedRowKeys: expandedId ? [expandedId] : [],
|
||||
onExpand: handleExpand,
|
||||
expandedRowRender: () => {
|
||||
if (!detail) return null;
|
||||
return (
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
{detail.error_message && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Typography.Text type="danger">错误: {detail.error_message}</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
{detail.result_content && (
|
||||
<div>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
分析结果
|
||||
</Typography.Text>
|
||||
<div
|
||||
style={{
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.8,
|
||||
maxHeight: 400,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{detail.result_content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!detail.result_content && !detail.error_message && (
|
||||
<Typography.Text type="secondary">暂无结果内容</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}}
|
||||
pagination={{
|
||||
current: query.page,
|
||||
total,
|
||||
pageSize: query.page_size,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => setQuery((prev) => ({ ...prev, page: p, page_size: ps })),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
274
apps/web/src/pages/health/AiPromptList.tsx
Normal file
274
apps/web/src/pages/health/AiPromptList.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, UndoOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import { promptApi, type PromptItem, type CreatePromptReq } from '../../api/ai/prompts';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'lab_report_interpretation', label: '化验单解读' },
|
||||
{ value: 'health_trend_analysis', label: '趋势分析' },
|
||||
{ value: 'personalized_checkup_plan', label: '体检方案' },
|
||||
{ value: 'report_summary_generation', label: '报告摘要' },
|
||||
];
|
||||
|
||||
const CATEGORY_MAP: Record<string, string> = Object.fromEntries(
|
||||
CATEGORIES.map((c) => [c.value, c.label]),
|
||||
);
|
||||
|
||||
export default function AiPromptList() {
|
||||
const [data, setData] = useState<PromptItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [categoryFilter, setCategoryFilter] = useState<string | undefined>();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const isDark = useThemeMode();
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (p = page) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await promptApi.list({
|
||||
page: p,
|
||||
page_size: 20,
|
||||
category: categoryFilter,
|
||||
});
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载 Prompt 列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[page, categoryFilter],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleCreate = async (values: CreatePromptReq) => {
|
||||
try {
|
||||
await promptApi.create(values);
|
||||
message.success('Prompt 创建成功');
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async (id: string) => {
|
||||
try {
|
||||
await promptApi.activate(id);
|
||||
message.success('已激活');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('激活失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRollback = async (id: string) => {
|
||||
try {
|
||||
await promptApi.rollback(id);
|
||||
message.success('已回滚');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('回滚失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 180,
|
||||
render: (name: string) => <span style={{ fontWeight: 500 }}>{name}</span>,
|
||||
},
|
||||
{
|
||||
title: '类别',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 120,
|
||||
render: (v: string) => (
|
||||
<Tag color="blue">{CATEGORY_MAP[v] || v}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '版本',
|
||||
dataIndex: 'version',
|
||||
key: 'version',
|
||||
width: 70,
|
||||
render: (v: number) => `v${v}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_active',
|
||||
key: 'is_active',
|
||||
width: 80,
|
||||
render: (v: boolean) => (
|
||||
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
key: 'updated_at',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? new Date(v).toLocaleString('zh-CN') : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render: (_: unknown, record: PromptItem) => (
|
||||
<AuthButton code="ai.prompt.manage">
|
||||
<Space size={4}>
|
||||
{!record.is_active && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => handleActivate(record.id)}
|
||||
>
|
||||
激活
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
onClick={() => handleRollback(record.id)}
|
||||
>
|
||||
回滚
|
||||
</Button>
|
||||
</Space>
|
||||
</AuthButton>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="erp-page-header">
|
||||
<div>
|
||||
<h4>AI Prompt 管理</h4>
|
||||
<div className="erp-page-subtitle">管理 AI 分析提示词模板和版本</div>
|
||||
</div>
|
||||
<Space size={8}>
|
||||
<Select
|
||||
placeholder="筛选类别"
|
||||
value={categoryFilter}
|
||||
onChange={(v) => {
|
||||
setCategoryFilter(v);
|
||||
setPage(1);
|
||||
}}
|
||||
options={CATEGORIES}
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<AuthButton code="ai.prompt.manage">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新建 Prompt
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => {
|
||||
setPage(p);
|
||||
fetchData(p);
|
||||
},
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
style: { padding: '12px 16px', margin: 0 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="新建 Prompt"
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
width={600}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} onFinish={handleCreate} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
rules={[{ required: true, message: '请输入 Prompt 名称' }]}
|
||||
>
|
||||
<Input placeholder="如:化验单解读 V2" />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="类别" rules={[{ required: true, message: '请选择类别' }]}>
|
||||
<Select options={CATEGORIES} placeholder="选择类别" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={2} placeholder="Prompt 用途说明" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="system_prompt"
|
||||
label="System Prompt"
|
||||
rules={[{ required: true, message: '请输入 System Prompt' }]}
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="系统提示词" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="user_prompt_template"
|
||||
label="User Prompt 模板"
|
||||
rules={[{ required: true, message: '请输入 User Prompt 模板' }]}
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="用户提示词模板,可用 {{变量}} 占位" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="model_config"
|
||||
label="模型配置 (JSON)"
|
||||
initialValue={{ model: 'deepseek-chat', temperature: 0.7 }}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder='{"model": "deepseek-chat", "temperature": 0.7}' />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
apps/web/src/pages/health/AiUsageDashboard.tsx
Normal file
142
apps/web/src/pages/health/AiUsageDashboard.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Spin, Statistic, message, Empty, Row, Col } from 'antd';
|
||||
import {
|
||||
ThunderboltOutlined,
|
||||
ExperimentOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
import { usageApi, type UsageOverview, type TypeDistribution } from '../../api/ai/usage';
|
||||
|
||||
const ANALYSIS_TYPE_MAP: Record<string, string> = {
|
||||
lab_report_interpretation: '化验单解读',
|
||||
health_trend_analysis: '趋势分析',
|
||||
personalized_checkup_plan: '体检方案',
|
||||
report_summary_generation: '报告摘要',
|
||||
};
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
lab_report_interpretation: '#1890ff',
|
||||
health_trend_analysis: '#52c41a',
|
||||
personalized_checkup_plan: '#722ed1',
|
||||
report_summary_generation: '#fa8c16',
|
||||
};
|
||||
|
||||
export default function AiUsageDashboard() {
|
||||
const [overview, setOverview] = useState<UsageOverview | null>(null);
|
||||
const [types, setTypes] = useState<TypeDistribution[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const isDark = useThemeMode();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [ov, tp] = await Promise.all([
|
||||
usageApi.overview(),
|
||||
usageApi.byType(),
|
||||
]);
|
||||
setOverview(ov);
|
||||
setTypes(tp);
|
||||
} catch {
|
||||
message.error('加载用量统计失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cardStyle = {
|
||||
borderRadius: 12,
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
};
|
||||
|
||||
const totalCount = types.reduce((sum, t) => sum + t.count, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="erp-page-header">
|
||||
<div>
|
||||
<h4>AI 用量统计</h4>
|
||||
<div className="erp-page-subtitle">查看 AI 分析服务的使用情况</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="总分析次数"
|
||||
value={overview?.total_count ?? 0}
|
||||
prefix={<ThunderboltOutlined style={{ color: '#1890ff' }} />}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="分析类型数"
|
||||
value={types.length}
|
||||
prefix={<ExperimentOutlined style={{ color: '#52c41a' }} />}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="本月分析"
|
||||
value={totalCount}
|
||||
prefix={<ThunderboltOutlined style={{ color: '#fa8c16' }} />}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card style={cardStyle} title="分析类型分布">
|
||||
{types.length === 0 ? (
|
||||
<Empty description="暂无分析数据" />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{types.map((t) => {
|
||||
const pct = totalCount > 0 ? Math.round((t.count / totalCount) * 100) : 0;
|
||||
const label = ANALYSIS_TYPE_MAP[t.analysis_type] || t.analysis_type;
|
||||
const color = TYPE_COLORS[t.analysis_type] || '#1890ff';
|
||||
return (
|
||||
<Col span={6} key={t.analysis_type}>
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color }}>{t.count}</div>
|
||||
<div style={{ fontSize: 13, color: isDark ? '#94a3b8' : '#475569', marginTop: 4 }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: isDark ? '#475569' : '#94a3b8', marginTop: 4 }}>
|
||||
{pct}%
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user