feat(web): AI 管理端 3 页面 — Prompt/分析历史/用量统计
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- API 封装: prompts.ts / analysis.ts / usage.ts
- AiPromptList: CRUD + 激活/回滚 + AuthButton 权限
- AiAnalysisList: 历史列表 + 行展开查看结果
- AiUsageDashboard: 总次数/类型分布统计卡片
- 菜单注册 + 路由配置 (MainLayout + App.tsx)
This commit is contained in:
iven
2026-04-25 23:44:15 +08:00
parent 69313a177e
commit 5621dbe273
8 changed files with 722 additions and 0 deletions

View File

@@ -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>

View 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;
},
};

View 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;
},
};

View 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[];
},
};

View File

@@ -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[] = [

View 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>
);
}

View 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>
);
}

View 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>
);
}