feat(ai): Prompt 管理 Phase 2 — analysis_type 后端选择键 + 筛选修复

- 新增 ai_prompt.analysis_type 列作为后端按链路选择 Prompt 的唯一键
- name 回归显示标识符用途,不再承担选择键角色
- 迁移 000164: 新增 analysis_type 列 + 从 name 回填 + 索引
- 迁移 000165: 修复旧数据从 category 错误回填的问题
- AiPromptList 页面重构: 分析类型/调用链路列、详情抽屉、新建表单
- DrawerForm 组件新增 onValuesChange 回调支持跨字段联动
- 新建表单选择分析类型后自动填充标识符
- 筛选过滤器改为按 analysis_type 而非 category 过滤(后端+前端同步)
- 停用/激活/回滚/删除操作完整可用
This commit is contained in:
iven
2026-05-26 17:04:26 +08:00
parent 3972db4f98
commit 3c7b48b6f6
10 changed files with 549 additions and 165 deletions

View File

@@ -11,6 +11,7 @@ export interface PromptItem {
version: number;
is_active: boolean;
category: string;
analysis_type: string;
tags: Record<string, unknown> | null;
created_at: string;
updated_at: string;
@@ -23,10 +24,11 @@ export interface CreatePromptReq {
user_prompt_template: string;
model_config: Record<string, unknown>;
category: string;
analysis_type: string;
}
export const promptApi = {
list: async (params?: { category?: string; page?: number; page_size?: number }) => {
list: async (params?: { category?: string; analysis_type?: string; page?: number; page_size?: number }) => {
const resp = await client.get('/ai/prompts', { params });
return resp.data.data as PaginatedResponse<PromptItem>;
},
@@ -38,8 +40,15 @@ export const promptApi = {
const resp = await client.post(`/ai/prompts/${id}/activate`);
return resp.data.data as PromptItem;
},
deactivate: async (id: string) => {
const resp = await client.post(`/ai/prompts/${id}/deactivate`);
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;
},
delete: async (id: string) => {
await client.delete(`/ai/prompts/${id}`);
},
};

View File

@@ -20,6 +20,7 @@ interface DrawerFormProps {
children?: React.ReactNode;
columns?: 1 | 2;
form?: ReturnType<typeof Form.useForm>[0];
onValuesChange?: (changedValues: Record<string, unknown>, allValues: Record<string, unknown>) => void;
}
export function DrawerForm({
@@ -34,6 +35,7 @@ export function DrawerForm({
children,
columns = 2,
form: externalForm,
onValuesChange,
}: DrawerFormProps) {
const [internalForm] = Form.useForm();
const form = externalForm ?? internalForm;
@@ -84,7 +86,7 @@ export function DrawerForm({
</Space>
}
>
<Form form={form} layout="vertical" initialValues={initialValues}>
<Form form={form} layout="vertical" initialValues={initialValues} onValuesChange={onValuesChange}>
{sections
? sections.map((s, i) => (
<div key={i}>

View File

@@ -3,36 +3,68 @@ import {
Table,
Button,
Space,
Modal,
Form,
Input,
Select,
Tag,
Badge,
message,
Drawer,
Descriptions,
Typography,
Slider,
InputNumber,
Modal,
} from 'antd';
import { PlusOutlined, UndoOutlined, CheckOutlined } from '@ant-design/icons';
import { promptApi, type PromptItem, type CreatePromptReq } from '../../api/ai/prompts';
import {
PlusOutlined,
UndoOutlined,
CheckOutlined,
EyeOutlined,
StopOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import { promptApi, type PromptItem } from '../../api/ai/prompts';
import { AuthButton } from '../../components/AuthButton';
import { DrawerForm } from '../../components/DrawerForm';
import type { FormSection } from '../../components/DrawerForm';
import { PageContainer } from '../../components/PageContainer';
import { useThemeMode } from '../../hooks/useThemeMode';
import { formatDateTime } from '../../utils/format';
const CATEGORIES = [
{ value: 'lab_report_interpretation', label: '化验单解读' },
{ value: 'health_trend_analysis', label: '趋势分析' },
{ value: 'personalized_checkup_plan', label: '体检方案' },
{ value: 'report_summary_generation', label: '报告摘要' },
// --- 分析类型定义(与后端 AnalysisType::prompt_name() 一一对应) ---
const ANALYSIS_TYPES = [
{ value: 'lab_report_interpretation', label: '化验单解读', api: '化验单解读 API' },
{ value: 'health_trend_analysis', label: '趋势分析', api: '趋势分析 API' },
{ value: 'personalized_checkup_plan', label: '体检方案', api: '体检方案 API' },
{ value: 'report_summary_generation', label: '报告摘要', api: '报告摘要 API' },
{ value: 'follow_up_summary_generation', label: '随访摘要', api: '随访摘要 API' },
] as const;
const ANALYSIS_TYPE_MAP = Object.fromEntries(
ANALYSIS_TYPES.map((t) => [t.value, t]),
);
const MODEL_OPTIONS = [
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
{ value: 'deepseek-reasoner', label: 'DeepSeek Reasoner' },
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'gpt-4o', label: 'GPT-4o' },
{ value: 'qwen-plus', label: 'Qwen Plus' },
];
const CATEGORY_MAP: Record<string, string> = Object.fromEntries(
CATEGORIES.map((c) => [c.value, c.label]),
);
const DEFAULT_MODEL_CONFIG = { model: 'deepseek-chat', temperature: 0.7, max_tokens: 4096 };
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 [analysisTypeFilter, setAnalysisTypeFilter] = useState<string | undefined>();
const [drawerOpen, setDrawerOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [viewing, setViewing] = useState<PromptItem | null>(null);
const [form] = Form.useForm();
const isDark = useThemeMode();
@@ -43,7 +75,7 @@ export default function AiPromptList() {
const result = await promptApi.list({
page: p,
page_size: 20,
category: categoryFilter,
analysis_type: analysisTypeFilter,
});
setData(result.data);
setTotal(result.total);
@@ -53,18 +85,29 @@ export default function AiPromptList() {
setLoading(false);
}
},
[page, categoryFilter],
[page, analysisTypeFilter],
);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleCreate = async (values: CreatePromptReq) => {
const handleCreate = async (values: Record<string, unknown>) => {
const model = String(values.model ?? 'deepseek-chat');
const temperature = Number(values.temperature ?? 0.7);
const max_tokens = Number(values.max_tokens ?? 4096);
try {
await promptApi.create(values);
await promptApi.create({
name: String(values.name ?? ''),
analysis_type: String(values.analysis_type ?? ''),
category: String(values.analysis_type ?? ''),
description: values.description ? String(values.description) : undefined,
system_prompt: String(values.system_prompt ?? ''),
user_prompt_template: String(values.user_prompt_template ?? ''),
model_config: { model, temperature, max_tokens },
});
message.success('Prompt 创建成功');
setModalOpen(false);
setDrawerOpen(false);
form.resetFields();
fetchData();
} catch {
@@ -72,7 +115,7 @@ export default function AiPromptList() {
}
};
const handleActivate = async (id: string) => {
const handleActivate = useCallback(async (id: string) => {
try {
await promptApi.activate(id);
message.success('已激活');
@@ -80,9 +123,19 @@ export default function AiPromptList() {
} catch {
message.error('激活失败');
}
};
}, [fetchData]);
const handleRollback = async (id: string) => {
const handleDeactivate = useCallback(async (id: string) => {
try {
await promptApi.deactivate(id);
message.success('已停用');
fetchData();
} catch {
message.error('停用失败');
}
}, [fetchData]);
const handleRollback = useCallback(async (id: string) => {
try {
await promptApi.rollback(id);
message.success('已回滚');
@@ -90,31 +143,78 @@ export default function AiPromptList() {
} catch {
message.error('回滚失败');
}
}, [fetchData]);
const handleDelete = useCallback((record: PromptItem) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除 Prompt「${record.name}」(v${record.version}) 吗?`,
okType: 'danger',
onOk: async () => {
try {
await promptApi.delete(record.id);
message.success('删除成功');
fetchData();
} catch {
message.error('删除失败');
}
},
});
}, [fetchData]);
const openDetail = (record: PromptItem) => {
setViewing(record);
setDetailOpen(true);
};
// 按 analysis_type 汇总当前激活版本
const activeVersionMap = useMemo(() => {
const map = new Map<string, number>();
for (const item of data) {
if (item.is_active) {
map.set(item.analysis_type, item.version);
}
}
return map;
}, [data]);
const columns = useMemo(() => [
{
title: '名称',
dataIndex: 'name',
key: 'name',
width: 180,
width: 160,
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: 'analysis_type',
key: 'analysis_type',
width: 130,
render: (v: string) => {
const cfg = ANALYSIS_TYPE_MAP[v];
return cfg ? <Tag color="blue">{cfg.label}</Tag> : <Tag>{v}</Tag>;
},
},
{
title: '调用链路',
dataIndex: 'analysis_type',
key: 'api_route',
width: 160,
render: (v: string) => {
const cfg = ANALYSIS_TYPE_MAP[v];
return <Typography.Text type="secondary" style={{ fontSize: 12 }}>{cfg?.api ?? v}</Typography.Text>;
},
},
{
title: '版本',
dataIndex: 'version',
key: 'version',
width: 70,
render: (v: number) => `v${v}`,
render: (v: number, record: PromptItem) => {
const isActive = activeVersionMap.get(record.analysis_type) === v && record.is_active;
return isActive ? <Tag color="green">v{v}</Tag> : <span>v{v}</span>;
},
},
{
title: '状态',
@@ -122,7 +222,7 @@ export default function AiPromptList() {
key: 'is_active',
width: 80,
render: (v: boolean) => (
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>
<Badge status={v ? 'success' : 'default'} text={v ? '启用' : '停用'} />
),
},
{
@@ -130,145 +230,247 @@ export default function AiPromptList() {
dataIndex: 'updated_at',
key: 'updated_at',
width: 170,
render: (v: string) => (v ? new Date(v).toLocaleString('zh-CN') : '-'),
render: (v: string) => formatDateTime(v),
},
{
title: '操作',
key: 'actions',
width: 160,
width: 220,
render: (_: unknown, record: PromptItem) => (
<AuthButton code="ai.prompt.manage">
<Space size={4}>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => openDetail(record)}>
</Button>
{!record.is_active && (
<Button
type="link"
size="small"
icon={<CheckOutlined />}
onClick={() => handleActivate(record.id)}
>
<Button type="link" size="small" icon={<CheckOutlined />} onClick={() => handleActivate(record.id)}>
</Button>
)}
<Button
type="link"
size="small"
icon={<UndoOutlined />}
onClick={() => handleRollback(record.id)}
>
{record.is_active && (
<Button type="link" size="small" icon={<StopOutlined />} onClick={() => handleDeactivate(record.id)}>
</Button>
)}
<Button type="link" size="small" icon={<UndoOutlined />} onClick={() => handleRollback(record.id)}>
</Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)}>
</Button>
</Space>
</AuthButton>
),
},
], [handleActivate, handleRollback]);
], [handleActivate, handleDeactivate, handleRollback, handleDelete, activeVersionMap]);
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}
destroyOnHidden
>
<Form form={form} onFinish={handleCreate} layout="vertical" style={{ marginTop: 16 }}>
const formSections: FormSection[] = [
{
title: '基本信息',
fields: (
<>
<Form.Item
name="analysis_type"
label="分析类型"
rules={[{ required: true, message: '请选择分析类型' }]}
extra="选择后自动填充名称,决定该 Prompt 被哪条分析链路调用"
>
<Select
options={ANALYSIS_TYPES.map((t) => ({ value: t.value, label: t.label }))}
placeholder="选择分析类型"
/>
</Form.Item>
<Form.Item
name="name"
label="名称"
rules={[{ required: true, message: '请输入 Prompt 名称' }]}
label="标识符"
rules={[
{ required: true, message: '请输入标识符' },
{ pattern: /^[a-z0-9_]{3,64}$/, message: '仅允许小写字母、数字、下划线3-64位' },
]}
extra="后端按此标识符查找 Prompt通常与分析类型一致非必要勿改"
>
<Input placeholder="如:化验单解读 V2" />
</Form.Item>
<Form.Item name="category" label="类别" rules={[{ required: true, message: '请选择类别' }]}>
<Select options={CATEGORIES} placeholder="选择类别" />
<Input placeholder="如 lab_report_interpretation" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={2} placeholder="Prompt 用途说明" />
<Input.TextArea rows={2} placeholder="Prompt 用途说明(仅展示,不影响选择)" />
</Form.Item>
</>
),
},
{
title: '模型配置',
fields: (
<>
<Form.Item name="model" label="模型" rules={[{ required: true, message: '请选择模型' }]}>
<Select options={MODEL_OPTIONS} placeholder="选择 AI 模型" />
</Form.Item>
<Form.Item name="temperature" label="Temperature" extra="越低越确定,越高越多样">
<Slider min={0} max={2} step={0.1} />
</Form.Item>
<Form.Item name="max_tokens" label="Max Tokens">
<InputNumber min={256} max={8192} step={256} style={{ width: '100%' }} placeholder="4096" />
</Form.Item>
</>
),
},
{
title: '提示词模板',
fields: (
<>
<Form.Item
name="system_prompt"
label="System Prompt"
rules={[{ required: true, message: '请输入 System Prompt' }]}
>
<Input.TextArea rows={4} placeholder="系统提示词" />
<Input.TextArea rows={6} placeholder="系统提示词,定义 AI 的角色和行为规则" />
</Form.Item>
<Form.Item
name="user_prompt_template"
label="User Prompt 模板"
rules={[{ required: true, message: '请输入 User Prompt 模板' }]}
extra="支持 Handlebars {{变量}} 语法,如 {{patient_name}}、{{report_date}}"
>
<Input.TextArea rows={4} placeholder="用户提示词模板,可用 {{变量}} 占位" />
<Input.TextArea rows={6} placeholder="用户提示词模板,可用 {{变量}} 占位" />
</Form.Item>
<Form.Item
name="model_config"
label="模型配置 (JSON)"
initialValue={{ model: 'deepseek-chat', temperature: 0.7 }}
</>
),
},
];
return (
<PageContainer
title="AI Prompt 管理"
subtitle="管理 AI 分析提示词模板和版本"
filters={
<Select
placeholder="筛选分析类型"
value={analysisTypeFilter}
onChange={(v) => {
setAnalysisTypeFilter(v);
setPage(1);
}}
options={ANALYSIS_TYPES.map((t) => ({ value: t.value, label: t.label }))}
allowClear
style={{ width: 160 }}
/>
}
actions={
<AuthButton code="ai.prompt.manage">
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
form.resetFields();
form.setFieldsValue(DEFAULT_MODEL_CONFIG);
setDrawerOpen(true);
}}
>
<Input.TextArea rows={3} placeholder='{"model": "deepseek-chat", "temperature": 0.7}' />
</Form.Item>
</Form>
</Modal>
</div>
Prompt
</Button>
</AuthButton>
}
>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => {
setPage(p);
fetchData(p);
},
showTotal: (t) => `${t} 条记录`,
}}
/>
{/* 新建 Prompt Drawer */}
<DrawerForm
title="新建 Prompt"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onSubmit={handleCreate}
form={form}
onValuesChange={(changed) => {
if ('analysis_type' in changed && changed.analysis_type) {
form.setFieldValue('name', changed.analysis_type);
}
}}
initialValues={{
...DEFAULT_MODEL_CONFIG,
category: '',
analysis_type: undefined,
name: '',
description: '',
}}
width={720}
columns={1}
sections={formSections}
/>
{/* 查看 Prompt 详情 */}
<Drawer
title={viewing ? `${viewing.name} (v${viewing.version})` : 'Prompt 详情'}
open={detailOpen}
onClose={() => { setDetailOpen(false); setViewing(null); }}
width={640}
styles={{ body: { background: isDark ? '#141414' : undefined } }}
>
{viewing && (
<>
<Descriptions column={2} size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="分析类型">
<Tag color="blue">{ANALYSIS_TYPE_MAP[viewing.analysis_type]?.label ?? viewing.analysis_type}</Tag>
</Descriptions.Item>
<Descriptions.Item label="标识符">
<Typography.Text code style={{ fontSize: 12 }}>{viewing.analysis_type}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Badge status={viewing.is_active ? 'success' : 'default'} text={viewing.is_active ? '启用' : '停用'} />
</Descriptions.Item>
<Descriptions.Item label="版本">v{viewing.version}</Descriptions.Item>
<Descriptions.Item label="调用链路" span={2}>
<Typography.Text type="secondary">{ANALYSIS_TYPE_MAP[viewing.analysis_type]?.api ?? viewing.analysis_type}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="更新时间">{formatDateTime(viewing.updated_at)}</Descriptions.Item>
</Descriptions>
{viewing.description && (
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
<div style={{ marginTop: 4 }}>{viewing.description}</div>
</div>
)}
{[
{ label: 'System Prompt', content: viewing.system_prompt },
{ label: 'User Prompt 模板', content: viewing.user_prompt_template },
{ label: '模型配置', content: JSON.stringify(viewing.model_config, null, 2) },
].map(({ label, content }) => (
<div key={label} style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{label}</Typography.Text>
<pre style={{
marginTop: 4,
padding: 12,
background: isDark ? '#1e293b' : '#f8fafc',
borderRadius: 8,
fontSize: 13,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 300,
overflow: 'auto',
}}>
{content}
</pre>
</div>
))}
</>
)}
</Drawer>
</PageContainer>
);
}