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

514 lines
21 KiB
TypeScript
Raw Normal View History

import React, { useEffect, useState } from 'react';
import {
App,
Button,
Card,
Col,
Divider,
Drawer,
Empty,
Form,
Input,
Modal,
Popconfirm,
Row,
Select,
Skeleton,
Space,
Switch,
Tag,
Tooltip,
Typography,
} from 'antd';
import { CopyOutlined, DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, StarFilled } 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';
const { Option } = Select;
const { Text, Title } = Typography;
const PromptTemplates: React.FC = () => {
const { message } = App.useApp();
const { t } = useTranslation();
const [form] = Form.useForm();
const [searchForm] = Form.useForm();
const { items: categories, loading: dictLoading } = useDict('biz_prompt_category');
const { items: dictTags } = useDict('biz_prompt_tag');
const { items: promptLevels } = useDict('biz_prompt_level');
const templateLevel = Form.useWatch('isSystem', form);
const [loading, setLoading] = useState(false);
const [data, setData] = useState<PromptTemplateVO[]>([]);
const [total, setTotal] = useState(0);
const [current, setCurrent] = useState(1);
const [pageSize, setPageSize] = useState(12);
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 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]);
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 () => {
const values = searchForm.getFieldsValue();
setLoading(true);
try {
const res = await getPromptPage({
current,
size: pageSize,
name: values.name,
category: values.category,
});
if (res.data?.data) {
setData(res.data.data.records);
setTotal(res.data.data.total);
}
} 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);
form.setFieldsValue({
...record,
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);
form.setFieldsValue(record);
setPreviewContent(record.promptContent);
}
} else {
setEditingId(null);
form.resetFields();
form.setFieldsValue({
status: 1,
isSystem: (isTenantAdmin || isPlatformAdmin) ? 1 : 0,
});
setPreviewContent('');
}
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 style={{ maxHeight: '65vh', overflowY: 'auto', padding: '12px 0' }}>
{detail.description ? (
<div style={{ marginBottom: 16, padding: '12px 16px', borderRadius: 8, background: 'var(--app-bg-surface-soft)' }}>
<Text type="secondary">{detail.description}</Text>
</div>
) : null}
<div style={{ marginBottom: 16 }}>
<Space wrap>
{detail.hotWordGroupName ? <Tag color="blue">{detail.hotWordGroupName}</Tag> : <Tag></Tag>}
{(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 style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 600 }}></div>
<Space wrap>
{detail.hotWords.map((word) => <Tag key={word}>{word}</Tag>)}
</Space>
</div>
) : detail.hotWordGroupId ? (
<div style={{ marginBottom: 16 }}>
<Text type="secondary"></Text>
</div>
) : null}
<ReactMarkdown>{detail.promptContent}</ReactMarkdown>
</div>
),
okText: '关闭',
maskClosable: true,
});
})();
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
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 groupedData = React.useMemo(() => {
const groups: Record<string, PromptTemplateVO[]> = {};
data.forEach((item) => {
const cat = item.category || 'default';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(item);
});
return groups;
}, [data]);
const renderCard = (item: PromptTemplateVO) => {
const isSystem = item.isSystem === 1;
const isPlatformLevel = Number(item.tenantId) === 0 && isSystem;
const isTenantLevel = Number(item.tenantId) > 0 && isSystem;
const isPersonalLevel = !isSystem;
const currentUserId = userProfile.userId ? Number(userProfile.userId) : -1;
let canEdit = false;
if (isPersonalLevel) {
canEdit = Number(item.creatorId) === currentUserId;
} else if (isPlatformAdmin) {
canEdit = Number(item.tenantId) === 0;
} else if (isTenantAdmin) {
canEdit = Number(item.tenantId) === activeTenantId;
}
const levelTag = isPlatformLevel ? (
<Tag color="gold" style={{ borderRadius: 4 }}></Tag>
) : isTenantLevel ? (
<Tag color="blue" style={{ borderRadius: 4 }}></Tag>
) : (
<Tag color="cyan" style={{ borderRadius: 4 }}></Tag>
);
return (
<Card
key={item.id}
hoverable
onClick={() => showDetail(item)}
style={{ width: 320, borderRadius: 12, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-card)', boxShadow: 'var(--app-shadow)', backdropFilter: 'blur(16px)', position: 'relative', overflow: 'hidden' }}
styles={{ body: { padding: '24px' } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flex: 1 }}>
<div style={{
width: 40,
height: 40,
borderRadius: 10,
background: isPlatformLevel ? 'color-mix(in srgb, #f5c542 14%, var(--app-bg-surface-strong))' : (isTenantLevel ? 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))' : 'color-mix(in srgb, #13c2c2 12%, var(--app-bg-surface-strong))'),
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexShrink: 0,
}}>
<StarFilled style={{ fontSize: 20, color: isPlatformLevel ? '#faad14' : (isTenantLevel ? '#1890ff' : '#13c2c2') }} />
</div>
<div style={{ flexShrink: 0 }}>{levelTag}</div>
</div>
<Space onClick={(e) => e.stopPropagation()} style={{ flexShrink: 0, marginLeft: 8 }}>
{canEdit && <EditOutlined style={{ fontSize: 18, color: '#bfbfbf', cursor: 'pointer' }} onClick={() => handleOpenDrawer(item)} />}
<Switch size="small" checked={item.status === 1} onChange={(checked) => void handleStatusChange(item.id, checked)} />
</Space>
</div>
<div style={{ marginBottom: 12 }}>
<Text strong style={{ fontSize: 16, display: 'block', width: '100%' }} ellipsis={{ tooltip: item.templateName }}>{item.templateName}</Text>
{item.description ? (
<Text type="secondary" style={{ fontSize: 12, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', marginTop: 6 }}>
{item.description}
</Text>
) : null}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12, minHeight: 22 }}>
{item.hotWordGroupName ? <Tag color="blue" style={{ margin: 0 }}>{item.hotWordGroupName}</Tag> : null}
{item.tags?.map((tag) => {
const dictItem = dictTags.find((dt) => dt.itemValue === tag);
return (
<Tag key={tag} style={{ margin: 0, border: '1px solid var(--app-border-color)', background: 'var(--app-bg-surface-soft)', color: 'var(--app-text-main)', borderRadius: 4, fontSize: 10, maxWidth: 100, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{dictItem ? dictItem.itemLabel : tag}
</Tag>
);
})}
{!item.hotWordGroupName && (!item.tags || item.tags.length === 0) ? <Text type="secondary"></Text> : null}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', paddingTop: 12, borderTop: '1px solid #f5f5f5' }}>
<Space onClick={(e) => e.stopPropagation()}>
<Tooltip title="以此创建">
<CopyOutlined style={{ color: '#bfbfbf', cursor: 'pointer', fontSize: 16 }} onClick={() => handleOpenDrawer(item, true)} />
</Tooltip>
{canEdit && (
<Popconfirm
title="确定删除?"
onConfirm={() => deletePromptTemplate(item.id).then(() => fetchData())}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Tooltip title="删除">
<DeleteOutlined style={{ color: '#bfbfbf', cursor: 'pointer', fontSize: 16 }} />
</Tooltip>
</Popconfirm>
)}
</Space>
</div>
</Card>
);
};
return (
<div style={{ padding: '32px', background: 'var(--app-bg-page)', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
<div style={{ maxWidth: 1400, margin: '0 auto', height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 32 }}>
<Title level={3} style={{ margin: 0 }}></Title>
<Button type="primary" icon={<PlusOutlined />} size="large" onClick={() => handleOpenDrawer()} style={{ borderRadius: 6 }}>
</Button>
</div>
<Card variant="borderless" style={{ borderRadius: 12, marginBottom: 32, background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', boxShadow: 'var(--app-shadow)', backdropFilter: 'blur(16px)' }} styles={{ body: { padding: '20px 24px' } }}>
<Form form={searchForm} layout="inline" onFinish={() => void fetchData()}>
<Form.Item name="name" label="模板名称"><Input placeholder="请输入..." style={{ width: 180 }} /></Form.Item>
<Form.Item name="category" label="分类">
<Select placeholder="选择分类" style={{ width: 160 }} allowClear>
{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={() => { searchForm.resetFields(); void fetchData(); }}></Button>
</Space>
</Form.Item>
</Form>
</Card>
<Card className="app-page__content-card" style={{ flex: 1, minHeight: 0 }} styles={{ body: { padding: 0, height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}>
<Skeleton loading={loading} active style={{ height: '100%' }}>
{Object.keys(groupedData).length === 0 ? (
<div className="app-page__empty-state" style={{ padding: 24 }}>
<Empty description="暂无可用模板" />
</div>
) : (
<>
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '24px 24px 0' }}>
{Object.keys(groupedData).map((catKey) => {
const catLabel = categories.find((c) => c.itemValue === catKey)?.itemLabel || catKey;
return (
<div key={catKey} style={{ marginBottom: 40 }}>
<Title level={4} style={{ marginBottom: 24, paddingLeft: 8, borderLeft: '4px solid #1890ff' }}>{catLabel}</Title>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 24 }}>
{groupedData[catKey].map(renderCard)}
</div>
</div>
);
})}
</div>
<AppPagination
current={current}
pageSize={pageSize}
total={total}
onChange={(page, size) => {
setCurrent(page);
setPageSize(size);
}}
/>
</>
)}
</Skeleton>
</Card>
</div>
<Drawer
title={<Title level={4} style={{ margin: 0 }}>{editingId ? '编辑模板' : '创建新模板'}</Title>}
width="80%"
onClose={() => setDrawerVisible(false)}
open={drawerVisible}
forceRender
extra={
<Space>
<Button onClick={() => setDrawerVisible(false)}></Button>
<Button type="primary" icon={<SaveOutlined />} loading={submitLoading} onClick={() => void handleSubmit()}></Button>
</Space>
}
destroyOnHidden
>
<Form form={form} layout="vertical">
<Row gutter={24}>
<Col span={(isPlatformAdmin || isTenantAdmin) ? 8 : 12}>
<Form.Item name="templateName" label="模板名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
</Col>
{(isPlatformAdmin || isTenantAdmin) && (
<Col span={6}>
<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={(isPlatformAdmin || isTenantAdmin) ? 5 : 6}>
<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={isPlatformAdmin ? 5 : 6}>
<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={24}>
<Col span={12}>
<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={12}>
<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={24} style={{ height: 'calc(100vh - 400px)' }}>
<Col span={12} style={{ height: '100%' }}>
<Form.Item name="promptContent" noStyle rules={[{ required: true }]}>
<Input.TextArea
onChange={(e) => setPreviewContent(e.target.value)}
style={{ height: '100%', fontFamily: 'monospace', resize: 'none', border: '1px solid #d9d9d9', borderRadius: 8, padding: 12 }}
placeholder="在此输入 Markdown 指令..."
/>
</Form.Item>
</Col>
<Col span={12} style={{ height: '100%', overflowY: 'auto', background: 'var(--app-bg-surface-soft)', border: '1px solid var(--app-border-color)', borderRadius: 8, padding: '16px 24px' }}>
<div style={{ marginBottom: 12 }}>
{form.getFieldValue('hotWordGroupId') ? (
<Tag color="blue">
{groupOptions.find((item) => item.id === form.getFieldValue('hotWordGroupId'))?.groupName || '已选择'}
</Tag>
) : (
<Tag></Tag>
)}
</div>
<div className="markdown-preview"><ReactMarkdown>{previewContent}</ReactMarkdown></div>
</Col>
</Row>
</Form>
</Drawer>
</div>
);
};
export default PromptTemplates;