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:
@@ -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}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ pub struct Model {
|
||||
pub version: i32,
|
||||
pub is_active: bool,
|
||||
pub category: String,
|
||||
/// 后端选择键:与 AnalysisType::prompt_name() 对应,handler 按此字段查找激活 Prompt
|
||||
pub analysis_type: String,
|
||||
pub tags: Option<serde_json::Value>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
|
||||
@@ -95,7 +95,7 @@ where
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
.get_active_prompt(ctx.tenant_id, "lab_report_interpretation")
|
||||
.get_active_prompt(ctx.tenant_id, AnalysisType::LabReport.prompt_name())
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
@@ -190,7 +190,7 @@ where
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
.get_active_prompt(ctx.tenant_id, "health_trend_analysis")
|
||||
.get_active_prompt(ctx.tenant_id, AnalysisType::Trends.prompt_name())
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
@@ -262,7 +262,7 @@ where
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
.get_active_prompt(ctx.tenant_id, "personalized_checkup_plan")
|
||||
.get_active_prompt(ctx.tenant_id, AnalysisType::CheckupPlan.prompt_name())
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
@@ -341,7 +341,7 @@ where
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
.get_active_prompt(ctx.tenant_id, "report_summary_generation")
|
||||
.get_active_prompt(ctx.tenant_id, AnalysisType::ReportSummary.prompt_name())
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
@@ -417,7 +417,7 @@ where
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
.get_active_prompt(ctx.tenant_id, "follow_up_summary_generation")
|
||||
.get_active_prompt(ctx.tenant_id, AnalysisType::FollowUpSummary.prompt_name())
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
@@ -577,6 +577,7 @@ where
|
||||
#[derive(Debug, Deserialize, utoipa::IntoParams)]
|
||||
pub struct ListPromptsQuery {
|
||||
pub category: Option<String>,
|
||||
pub analysis_type: Option<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
@@ -605,7 +606,11 @@ where
|
||||
};
|
||||
let (items, total) = state
|
||||
.prompt
|
||||
.list_prompts(ctx.tenant_id, params.category, &pagination)
|
||||
.list_prompts(
|
||||
ctx.tenant_id,
|
||||
params.analysis_type.or(params.category),
|
||||
&pagination,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items,
|
||||
@@ -623,6 +628,7 @@ pub struct CreatePromptBody {
|
||||
pub user_prompt_template: String,
|
||||
pub model_config: serde_json::Value,
|
||||
pub category: String,
|
||||
pub analysis_type: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -655,6 +661,7 @@ where
|
||||
body.user_prompt_template,
|
||||
body.model_config,
|
||||
body.category,
|
||||
body.analysis_type,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
@@ -702,6 +709,48 @@ where
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/ai/prompts/{id}/deactivate",
|
||||
responses((status = 200, description = "停用 Prompt 模板")),
|
||||
tag = "AI Prompt",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn deactivate_prompt<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<crate::entity::ai_prompt::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.manage")?;
|
||||
let prompt = state.prompt.deactivate_prompt(id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/ai/prompts/{id}",
|
||||
responses((status = 200, description = "删除 Prompt 模板")),
|
||||
tag = "AI Prompt",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn delete_prompt<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.manage")?;
|
||||
state.prompt.delete_prompt(id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
// === 用量统计 ===
|
||||
|
||||
#[utoipa::path(
|
||||
|
||||
@@ -511,6 +511,14 @@ impl AiModule {
|
||||
"/ai/prompts/{id}/rollback",
|
||||
axum::routing::post(crate::handler::rollback_prompt),
|
||||
)
|
||||
.route(
|
||||
"/ai/prompts/{id}/deactivate",
|
||||
axum::routing::post(crate::handler::deactivate_prompt),
|
||||
)
|
||||
.route(
|
||||
"/ai/prompts/{id}",
|
||||
axum::routing::delete(crate::handler::delete_prompt),
|
||||
)
|
||||
.route(
|
||||
"/ai/usage/overview",
|
||||
axum::routing::get(crate::handler::usage_overview),
|
||||
|
||||
@@ -17,20 +17,20 @@ impl PromptService {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// 获取当前激活的 Prompt 模板
|
||||
/// 获取当前激活的 Prompt 模板(按 analysis_type 查找)
|
||||
pub async fn get_active_prompt(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
name: &str,
|
||||
analysis_type: &str,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
ai_prompt::Entity::find()
|
||||
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_prompt::Column::Name.eq(name))
|
||||
.filter(ai_prompt::Column::AnalysisType.eq(analysis_type))
|
||||
.filter(ai_prompt::Column::IsActive.eq(true))
|
||||
.filter(ai_prompt::Column::DeletedAt.is_null())
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AiError::PromptNotFound(name.into()))
|
||||
.ok_or_else(|| AiError::PromptNotFound(analysis_type.into()))
|
||||
}
|
||||
|
||||
/// 新建 Prompt
|
||||
@@ -44,6 +44,7 @@ impl PromptService {
|
||||
user_prompt_template: String,
|
||||
model_config: serde_json::Value,
|
||||
category: String,
|
||||
analysis_type: String,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = chrono::Utc::now();
|
||||
@@ -59,6 +60,7 @@ impl PromptService {
|
||||
version: Set(1),
|
||||
is_active: Set(true),
|
||||
category: Set(category),
|
||||
analysis_type: Set(analysis_type),
|
||||
tags: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
@@ -74,15 +76,15 @@ impl PromptService {
|
||||
pub async fn list_prompts(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
category: Option<String>,
|
||||
analysis_type: Option<String>,
|
||||
pagination: &Pagination,
|
||||
) -> AiResult<(Vec<ai_prompt::Model>, u64)> {
|
||||
let mut query = ai_prompt::Entity::find()
|
||||
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_prompt::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(cat) = &category {
|
||||
query = query.filter(ai_prompt::Column::Category.eq(cat.as_str()));
|
||||
if let Some(at) = &analysis_type {
|
||||
query = query.filter(ai_prompt::Column::AnalysisType.eq(at.as_str()));
|
||||
}
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
@@ -132,6 +134,7 @@ impl PromptService {
|
||||
version: Set(entity.version + 1),
|
||||
is_active: Set(entity.is_active),
|
||||
category: Set(entity.category.clone()),
|
||||
analysis_type: Set(entity.analysis_type.clone()),
|
||||
tags: Set(entity.tags.clone()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
@@ -143,7 +146,7 @@ impl PromptService {
|
||||
Ok(active.insert(&self.db).await?)
|
||||
}
|
||||
|
||||
/// 激活指定 Prompt(停用同 name+category 的其他版本)
|
||||
/// 激活指定 Prompt(停用同 analysis_type 的其他版本,原子操作)
|
||||
pub async fn activate_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
|
||||
let entity = ai_prompt::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
@@ -154,25 +157,23 @@ impl PromptService {
|
||||
return Err(AiError::Validation("跨租户操作".into()));
|
||||
}
|
||||
|
||||
// 停用同 name + category 的其他激活版本
|
||||
let siblings = ai_prompt::Entity::find()
|
||||
// 原子操作:停用同 analysis_type 的其他版本
|
||||
ai_prompt::Entity::update_many()
|
||||
.col_expr(
|
||||
ai_prompt::Column::IsActive,
|
||||
sea_orm::sea_query::Expr::value(false),
|
||||
)
|
||||
.col_expr(
|
||||
ai_prompt::Column::UpdatedAt,
|
||||
sea_orm::sea_query::Expr::value(chrono::Utc::now()),
|
||||
)
|
||||
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_prompt::Column::Name.eq(&entity.name))
|
||||
.filter(ai_prompt::Column::Category.eq(&entity.category))
|
||||
.filter(ai_prompt::Column::IsActive.eq(true))
|
||||
.filter(ai_prompt::Column::DeletedAt.is_null())
|
||||
.filter(ai_prompt::Column::AnalysisType.eq(&entity.analysis_type))
|
||||
.filter(ai_prompt::Column::Id.ne(id))
|
||||
.all(&self.db)
|
||||
.filter(ai_prompt::Column::DeletedAt.is_null())
|
||||
.exec(&self.db)
|
||||
.await?;
|
||||
|
||||
for sibling in siblings {
|
||||
let mut active: ai_prompt::ActiveModel = sibling.into();
|
||||
active.is_active = Set(false);
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
|
||||
active.update(&self.db).await?;
|
||||
}
|
||||
|
||||
// 激活目标
|
||||
let mut active: ai_prompt::ActiveModel = entity.into();
|
||||
active.is_active = Set(true);
|
||||
@@ -185,4 +186,41 @@ impl PromptService {
|
||||
pub async fn rollback_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
|
||||
self.activate_prompt(id, tenant_id).await
|
||||
}
|
||||
|
||||
/// 停用 Prompt
|
||||
pub async fn deactivate_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
|
||||
let entity = ai_prompt::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
|
||||
|
||||
if entity.tenant_id != tenant_id {
|
||||
return Err(AiError::Validation("跨租户操作".into()));
|
||||
}
|
||||
|
||||
let mut active: ai_prompt::ActiveModel = entity.into();
|
||||
active.is_active = Set(false);
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
|
||||
Ok(active.update(&self.db).await?)
|
||||
}
|
||||
|
||||
/// 删除 Prompt(软删除)
|
||||
pub async fn delete_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<()> {
|
||||
let entity = ai_prompt::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
|
||||
|
||||
if entity.tenant_id != tenant_id {
|
||||
return Err(AiError::Validation("跨租户操作".into()));
|
||||
}
|
||||
|
||||
let mut active: ai_prompt::ActiveModel = entity.into();
|
||||
active.deleted_at = Set(Some(chrono::Utc::now()));
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
|
||||
active.update(&self.db).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,8 @@ mod m20260522_000160_article_add_is_public;
|
||||
mod m20260522_000161_patient_points_manage_perm;
|
||||
mod m20260522_000162_seed_patient_miniprogram_permissions;
|
||||
mod m20260526_000163_points_rule_unique_event_type;
|
||||
mod m20260526_000164_ai_prompt_add_analysis_type;
|
||||
mod m20260526_000165_ai_prompt_fix_analysis_type;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -347,6 +349,8 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260522_000161_patient_points_manage_perm::Migration),
|
||||
Box::new(m20260522_000162_seed_patient_miniprogram_permissions::Migration),
|
||||
Box::new(m20260526_000163_points_rule_unique_event_type::Migration),
|
||||
Box::new(m20260526_000164_ai_prompt_add_analysis_type::Migration),
|
||||
Box::new(m20260526_000165_ai_prompt_fix_analysis_type::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// ai_prompt 新增 analysis_type 列作为后端选择键,name 回归显示名
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
// 1. 新增 analysis_type 列(先允许 NULL 以便回填)
|
||||
db.execute_unprepared(
|
||||
"ALTER TABLE ai_prompt ADD COLUMN IF NOT EXISTS analysis_type VARCHAR(64)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 2. 用 name 值回填 analysis_type(name 在旧数据中就是后端选择键)
|
||||
db.execute_unprepared(
|
||||
"UPDATE ai_prompt SET analysis_type = name WHERE analysis_type IS NULL AND deleted_at IS NULL",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 3. 设置 NOT NULL 约束
|
||||
db.execute_unprepared("ALTER TABLE ai_prompt ALTER COLUMN analysis_type SET NOT NULL")
|
||||
.await?;
|
||||
|
||||
// 4. 为 analysis_type 创建索引(后端按此列查询激活 Prompt)
|
||||
db.execute_unprepared(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ai_prompt_analysis_type ON ai_prompt (tenant_id, analysis_type, is_active) WHERE deleted_at IS NULL",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
db.execute_unprepared("DROP INDEX IF EXISTS idx_ai_prompt_analysis_type")
|
||||
.await?;
|
||||
db.execute_unprepared("ALTER TABLE ai_prompt DROP COLUMN IF EXISTS analysis_type")
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// 修复 ai_prompt.analysis_type 回填数据:从 name(真正的后端选择键)而非 category(泛化标签)
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
db.execute_unprepared(
|
||||
"UPDATE ai_prompt SET analysis_type = name WHERE analysis_type != name AND deleted_at IS NULL",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user