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

View File

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

View File

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

View File

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

View File

@@ -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(())
}
}

View File

@@ -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),
]
}
}

View File

@@ -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_typename 在旧数据中就是后端选择键)
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(())
}
}

View File

@@ -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(())
}
}