imeeting/frontend/src/pages/business/PromptTemplates.tsx

595 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import React, { useEffect, useState } from 'react';
import {
App,
Button,
Col,
Divider,
Empty,
Form,
Input,
Popconfirm,
Row,
Select,
Space,
Switch,
Table,
Tag,
Tooltip,
Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import PageContainer from "@/components/shared/PageContainer";
import DataListPanel from "@/components/shared/DataListPanel";
import FormDrawer from "@/components/shared/FormDrawer";
import SectionCard from "@/components/shared/SectionCard";
import { CopyOutlined, DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons';
import ReactMarkdown from 'react-markdown';
import { useTranslation } from 'react-i18next';
import { useDict } from '../../hooks/useDict';
import {
deletePromptTemplate,
getPromptDetail,
getPromptPage,
savePromptTemplate,
updatePromptStatus,
updatePromptTemplate,
type PromptTemplateVO,
} from '../../api/business/prompt';
import { getHotWordGroupOptions, type HotWordGroupVO } from '../../api/business/hotwordGroup';
import AppPagination from '../../components/shared/AppPagination';
import './PromptTemplates.css';
const { Option } = Select;
const { Text } = Typography;
const normalizePromptTags = (tags: unknown) => {
if (Array.isArray(tags)) {
return tags.map((tag) => String(tag).trim()).filter(Boolean);
}
if (typeof tags === 'string') {
return tags.split(',').map((tag) => tag.trim()).filter(Boolean);
}
return [];
};
const PromptTemplates: React.FC = () => {
const { message, modal } = App.useApp();
const { t } = useTranslation();
const { items: categories, loading: dictLoading } = useDict('biz_prompt_category');
const { items: dictTags } = useDict('biz_prompt_tag');
const { items: promptLevels } = useDict('biz_prompt_level');
const [loading, setLoading] = useState(false);
const [data, setData] = useState<PromptTemplateVO[]>([]);
const [total, setTotal] = useState(0);
const [current, setCurrent] = useState(1);
const [pageSize, setPageSize] = useState(8);
const [queryDraft, setQueryDraft] = useState<{ name?: string; category?: string }>({});
const [query, setQuery] = useState<{ name?: string; category?: string }>({});
const [drawerVisible, setDrawerVisible] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [submitLoading, setSubmitLoading] = useState(false);
const [previewContent, setPreviewContent] = useState('');
const [groupOptions, setGroupOptions] = useState<HotWordGroupVO[]>([]);
const [templateLevel, setTemplateLevel] = useState<number | undefined>();
const [selectedHotWordGroupId, setSelectedHotWordGroupId] = useState<number | undefined>();
const [drawerInitialValues, setDrawerInitialValues] = useState<Record<string, any>>({});
const [drawerFormKey, setDrawerFormKey] = useState(0);
const userProfile = React.useMemo(() => {
const profileStr = sessionStorage.getItem("userProfile");
return profileStr ? JSON.parse(profileStr) : {};
}, []);
const activeTenantId = React.useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []);
const isPlatformAdmin = userProfile.isPlatformAdmin === true;
const isTenantAdmin = userProfile.isTenantAdmin === true;
useEffect(() => {
void fetchData();
}, [current, pageSize, query]);
useEffect(() => {
void loadGroupOptions();
}, [isPlatformAdmin, templateLevel, activeTenantId]);
const loadGroupOptions = async () => {
const targetTenantId = isPlatformAdmin && Number(templateLevel) === 1 ? 0 : undefined;
const res = await getHotWordGroupOptions(targetTenantId ?? (isPlatformAdmin && activeTenantId === 0 ? 0 : undefined));
setGroupOptions(res.data?.data || []);
};
const fetchData = async () => {
setLoading(true);
try {
const res = await getPromptPage({
current,
size: pageSize,
name: query.name,
category: query.category,
});
if (res.data?.data) {
setData(res.data.data.records || []);
setTotal(res.data.data.total || 0);
}
} finally {
setLoading(false);
}
};
const handleStatusChange = async (id: number, checked: boolean) => {
await updatePromptStatus(id, checked ? 1 : 0);
message.success(checked ? '模板已启用' : '模板已停用');
await fetchData();
};
const handleOpenDrawer = (record?: PromptTemplateVO, isClone = false) => {
if (record) {
if (isClone) {
setEditingId(null);
setTemplateLevel(0);
setSelectedHotWordGroupId(record.hotWordGroupId);
setDrawerInitialValues({
...record,
tags: normalizePromptTags(record.tags),
templateName: `${record.templateName} (副本)`,
isSystem: 0,
id: undefined,
tenantId: undefined,
});
setPreviewContent(record.promptContent);
} else {
const isPlatformLevel = Number(record.tenantId) === 0 && Number(record.isSystem) === 1;
const currentUserId = userProfile.userId ? Number(userProfile.userId) : -1;
let canEdit = false;
if (Number(record.isSystem) === 0) {
canEdit = Number(record.creatorId) === currentUserId;
} else if (isPlatformAdmin) {
canEdit = isPlatformLevel;
} else if (isTenantAdmin) {
canEdit = Number(record.tenantId) === activeTenantId;
}
if (!canEdit) {
message.warning('您无权修改此层级的模板');
return;
}
setEditingId(record.id);
setTemplateLevel(Number(record.isSystem));
setSelectedHotWordGroupId(record.hotWordGroupId);
setDrawerInitialValues({
...record,
tags: normalizePromptTags(record.tags),
});
setPreviewContent(record.promptContent);
}
} else {
const defaultLevel = (isTenantAdmin || isPlatformAdmin) ? 1 : 0;
setEditingId(null);
setDrawerInitialValues({
status: 1,
isSystem: defaultLevel,
});
setTemplateLevel(defaultLevel);
setSelectedHotWordGroupId(undefined);
setPreviewContent('');
}
setDrawerFormKey((key) => key + 1);
setDrawerVisible(true);
};
const showDetail = (record: PromptTemplateVO) => {
void (async () => {
const detailRes = await getPromptDetail(record.id);
const detail = detailRes.data?.data || record;
modal.info({
title: record.templateName,
width: 800,
icon: null,
content: (
<div className="prompt-template-detail">
{detail.description ? (
<div className="prompt-template-detail__description">
<Text type="secondary">{detail.description}</Text>
</div>
) : null}
<div className="prompt-template-detail__section">
<Space wrap>
{detail.hotWordGroupName ? <Tag color="blue">{detail.hotWordGroupName}</Tag> : <Tag></Tag>}
{normalizePromptTags(detail.tags).map((tag) => {
const dictItem = dictTags.find((item) => item.itemValue === tag);
return <Tag key={tag}>{dictItem ? dictItem.itemLabel : tag}</Tag>;
})}
</Space>
</div>
{detail.hotWords && detail.hotWords.length > 0 ? (
<div className="prompt-template-detail__section">
<div className="prompt-template-detail__section-title"></div>
<Space wrap>
{detail.hotWords.map((word) => <Tag key={word}>{word}</Tag>)}
</Space>
</div>
) : detail.hotWordGroupId ? (
<div className="prompt-template-detail__section">
<Text type="secondary"></Text>
</div>
) : null}
<ReactMarkdown>{detail.promptContent}</ReactMarkdown>
</div>
),
okText: '关闭',
maskClosable: true,
});
})();
};
const handleSubmit = async (values: any) => {
try {
setSubmitLoading(true);
if (!editingId && isPlatformAdmin && values.isSystem === 1) {
values.tenantId = 0;
}
if (editingId) {
await updatePromptTemplate({ ...values, id: editingId });
message.success('更新成功');
} else {
await savePromptTemplate(values);
message.success('模板已创建');
}
setDrawerVisible(false);
await fetchData();
} finally {
setSubmitLoading(false);
}
};
const getTemplateLevelMeta = (item: PromptTemplateVO) => {
const isSystem = item.isSystem === 1;
const isPlatformLevel = Number(item.tenantId) === 0 && isSystem;
const isTenantLevel = Number(item.tenantId) > 0 && isSystem;
if (isPlatformLevel) {
return { label: '平台级', color: 'gold' as const };
}
if (isTenantLevel) {
return { label: '租户级', color: 'blue' as const };
}
return { label: '个人级', color: 'cyan' as const };
};
const handleSearch = () => {
setCurrent(1);
setQuery(queryDraft);
};
const handleResetSearch = () => {
setQueryDraft({});
setCurrent(1);
setQuery({});
};
const canManageTemplate = (item: PromptTemplateVO) => {
const isSystem = item.isSystem === 1;
const isPlatformLevel = Number(item.tenantId) === 0 && isSystem;
const isPersonalLevel = !isSystem;
const currentUserId = userProfile.userId ? Number(userProfile.userId) : -1;
if (isPersonalLevel) {
return Number(item.creatorId) === currentUserId;
}
if (isPlatformAdmin) {
return isPlatformLevel;
}
if (isTenantAdmin) {
return Number(item.tenantId) === activeTenantId;
}
return false;
};
const tableColumns: ColumnsType<PromptTemplateVO> = [
{
title: '模板名称',
dataIndex: 'templateName',
width: 280,
render: (_: unknown, item: PromptTemplateVO) => (
<div className="prompt-template-name-cell">
<Text strong ellipsis={{ tooltip: item.templateName }}>{item.templateName}</Text>
{item.description ? (
<Text type="secondary" className="prompt-template-description" ellipsis={{ tooltip: item.description }}>
{item.description}
</Text>
) : null}
</div>
),
},
{
title: '分类',
dataIndex: 'category',
width: 140,
render: (category: string) => categories.find((c) => c.itemValue === category)?.itemLabel || category || '-',
},
{
title: '层级',
dataIndex: 'isSystem',
width: 110,
render: (_: unknown, item: PromptTemplateVO) => {
const level = getTemplateLevelMeta(item);
return <Tag color={level.color} className="prompt-template-level-tag">{level.label}</Tag>;
},
},
{
title: '热词组',
dataIndex: 'hotWordGroupName',
width: 180,
render: (name: string) => name ? <Tag color="blue">{name}</Tag> : <Text type="secondary"></Text>,
},
{
title: '业务标签',
dataIndex: 'tags',
minWidth: 220,
render: (tags: unknown) => {
const tagList = normalizePromptTags(tags);
if (!tagList.length) {
return <Text type="secondary"></Text>;
}
return (
<Space size={[4, 4]} wrap className="prompt-template-tags-cell">
{tagList.slice(0, 3).map((tag) => {
const dictItem = dictTags.find((dt) => dt.itemValue === tag);
return <Tag key={tag}>{dictItem ? dictItem.itemLabel : tag}</Tag>;
})}
{tagList.length > 3 ? <Tag>+{tagList.length - 3}</Tag> : null}
</Space>
);
},
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (_: unknown, item: PromptTemplateVO) => (
<Switch
size="small"
checked={item.status === 1}
onClick={(_, event) => event.stopPropagation()}
onChange={(checked) => void handleStatusChange(item.id, checked)}
/>
),
},
{
title: '操作',
key: 'actions',
width: 150,
fixed: 'right' as const,
render: (_: unknown, item: PromptTemplateVO) => {
const canEdit = canManageTemplate(item);
return (
<Space size={2} onClick={(e) => e.stopPropagation()}>
<Tooltip title="查看">
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => showDetail(item)} />
</Tooltip>
{canEdit && (
<Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => handleOpenDrawer(item)} />
</Tooltip>
)}
<Tooltip title="以此创建">
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => handleOpenDrawer(item, true)} />
</Tooltip>
{canEdit && (
<Popconfirm
title="确定删除?"
onConfirm={() => deletePromptTemplate(item.id).then(() => fetchData())}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Tooltip title="删除">
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Tooltip>
</Popconfirm>
)}
</Space>
);
},
},
];
return (
<PageContainer title={null} className="prompt-templates-page">
<SectionCard
title="提示词模板"
description="管理 AI 会议总结的提示词模板库。"
>
<DataListPanel
className="prompt-templates-list-panel"
leftActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenDrawer()}>
</Button>
}
rightActions={
<Form layout="inline" onFinish={handleSearch} className="prompt-templates-search">
<Form.Item label="模板名称">
<Input
placeholder="请输入..."
className="prompt-templates-search__name"
value={queryDraft.name}
onChange={(event) => setQueryDraft((currentDraft) => ({ ...currentDraft, name: event.target.value }))}
/>
</Form.Item>
<Form.Item label="分类">
<Select
placeholder="选择分类"
className="prompt-templates-search__category"
allowClear
value={queryDraft.category}
onChange={(value) => setQueryDraft((currentDraft) => ({ ...currentDraft, category: value }))}
>
{categories.map((c) => <Option key={c.itemValue} value={c.itemValue}>{c.itemLabel}</Option>)}
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={handleResetSearch}></Button>
</Space>
</Form.Item>
</Form>
}
footer={
<AppPagination
current={current}
pageSize={pageSize}
total={total}
onChange={(page, size) => {
setCurrent(page);
setPageSize(size);
}}
/>
}
>
<Table<PromptTemplateVO>
className="prompt-templates-table"
columns={tableColumns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: "max-content", y: "100%" }}
onRow={(record) => ({ onClick: () => showDetail(record) })}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无可用模板" /> }}
/>
</DataListPanel>
</SectionCard>
<FormDrawer
title={editingId ? '编辑模板' : '创建新模板'}
size="md"
bodyDensity="compact"
onClose={() => setDrawerVisible(false)}
open={drawerVisible}
okIcon={<SaveOutlined />}
okLoading={submitLoading}
okButtonProps={{ htmlType: "submit", form: "prompt-template-form" }}
>
<Form
id="prompt-template-form"
key={drawerFormKey}
initialValues={drawerInitialValues}
layout="vertical"
onFinish={(values) => void handleSubmit(values)}
onValuesChange={(changedValues) => {
if (Object.prototype.hasOwnProperty.call(changedValues, 'isSystem')) {
setTemplateLevel(Number(changedValues.isSystem));
}
if (Object.prototype.hasOwnProperty.call(changedValues, 'hotWordGroupId')) {
setSelectedHotWordGroupId(changedValues.hotWordGroupId);
}
}}
>
<Row gutter={16}>
<Col span={24}>
<Form.Item name="templateName" label="模板名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
</Col>
{(isPlatformAdmin || isTenantAdmin) && (
<Col span={24}>
<Form.Item name="isSystem" label="模板属性" rules={[{ required: true }]}>
<Select placeholder="选择属性">
{promptLevels.length > 0 ? (
promptLevels.map((i) => <Option key={i.itemValue} value={Number(i.itemValue)}>{i.itemLabel}</Option>)
) : (
<>
<Option value={1}>{isPlatformAdmin ? '系统预置 (全局)' : '租户预置 (全员)'}</Option>
<Option value={0}></Option>
</>
)}
</Select>
</Form.Item>
</Col>
)}
<Col span={24}>
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
<Select loading={dictLoading}>
{categories.map((i) => <Option key={i.itemValue} value={i.itemValue}>{i.itemLabel}</Option>)}
</Select>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="status" label="状态">
<Select>
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item name="description" label="模板描述">
<Input.TextArea
maxLength={255}
showCount
autoSize={{ minRows: 2, maxRows: 4 }}
placeholder="请输入模板描述"
/>
</Form.Item>
<Row gutter={16}>
<Col span={24}>
<Form.Item name="tags" label="业务标签" tooltip="可从现有标签中选择,也可输入新内容按回车保存">
<Select mode="tags" placeholder="选择或输入新标签" allowClear tokenSeparators={[',', ' ', ';']}>
{dictTags.map((item) => <Option key={item.itemValue} value={item.itemValue}>{item.itemLabel}</Option>)}
</Select>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
name="hotWordGroupId"
label="绑定热词组"
tooltip="可选,未绑定则保持兼容"
>
<Select
placeholder="选择热词组"
allowClear
options={groupOptions.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))}
/>
</Form.Item>
</Col>
</Row>
<Divider orientation="left"> (Markdown )</Divider>
<Row gutter={[0, 16]} className="prompt-template-editor">
<Col span={24} className="prompt-template-editor__col">
<Form.Item name="promptContent" noStyle rules={[{ required: true }]}>
<Input.TextArea
onChange={(e) => setPreviewContent(e.target.value)}
className="prompt-template-editor__input"
placeholder="在此输入 Markdown 指令..."
/>
</Form.Item>
</Col>
<Col span={24} className="prompt-template-editor__preview">
<div className="prompt-template-editor__preview-meta">
{selectedHotWordGroupId ? (
<Tag color="blue">
{groupOptions.find((item) => item.id === selectedHotWordGroupId)?.groupName || '已选择'}
</Tag>
) : (
<Tag></Tag>
)}
</div>
<div className="markdown-preview"><ReactMarkdown>{previewContent}</ReactMarkdown></div>
</Col>
</Row>
</Form>
</FormDrawer>
</PageContainer>
);
};
export default PromptTemplates;