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

432 lines
17 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, { useState, useEffect } from 'react';
import { Card, Button, Input, Space, Drawer, Form, Select, Tag, message, Popconfirm, Typography, Divider, Tooltip, Row, Col, List, Empty, Skeleton, Switch, Modal, Pagination } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, CopyOutlined, SearchOutlined, SaveOutlined, StarFilled } from '@ant-design/icons';
import ReactMarkdown from 'react-markdown';
import { useDict } from '../../hooks/useDict';
import {
getPromptPage,
savePromptTemplate,
updatePromptTemplate,
deletePromptTemplate,
updatePromptStatus,
PromptTemplateVO,
PromptTemplateDTO
} from '../../api/business/prompt';
import { useTranslation } from 'react-i18next';
const { Option } = Select;
const { Text, Title } = Typography;
const PromptTemplates: React.FC = () => {
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 [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 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(() => {
fetchData();
}, [current, pageSize]);
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 && res.data.data) {
setData(res.data.data.records);
setTotal(res.data.data.total);
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const handleStatusChange = async (id: number, checked: boolean) => {
try {
await updatePromptStatus(id, checked ? 1 : 0);
message.success(checked ? '模板已启用' : '模板已停用');
fetchData();
} catch (err) {
console.error(err);
}
};
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;
} else {
canEdit = false;
}
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) => {
Modal.info({
title: record.templateName,
width: 800,
icon: null,
content: (
<div style={{ maxHeight: '65vh', overflowY: 'auto', padding: '12px 0' }}>
<ReactMarkdown>{record.promptContent}</ReactMarkdown>
</div>
),
okText: '关闭',
maskClosable: true
});
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitLoading(true);
// 处理 tenantId如果是新增且是平台管理员设为系统模板手动设置 tenantId 为 0
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);
fetchData();
} catch (err) {
console.error(err);
} 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;
// 权限判定逻辑 (使用 Number 强制转换防止类型不匹配)
let canEdit = false;
const currentUserId = userProfile.userId ? Number(userProfile.userId) : -1;
if (isPersonalLevel) {
// 个人模板仅本人可编辑
canEdit = Number(item.creatorId) === currentUserId;
} else if (isPlatformAdmin) {
// 平台管理员管理平台公开模板 (tenantId = 0)
canEdit = Number(item.tenantId) === 0;
} else if (isTenantAdmin) {
// 租户管理员管理本租户公开模板
canEdit = Number(item.tenantId) === activeTenantId;
} else {
// 普通用户不可编辑公开模板
canEdit = false;
}
// 标签颜色与文字
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 #f0f0f0', position: 'relative', overflow: 'hidden' }}
bodyStyle={{ padding: '24px' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 40, height: 40, borderRadius: 10,
backgroundColor: isPlatformLevel ? '#fffbe6' : (isTenantLevel ? '#e6f7ff' : '#e6fffb'),
display: 'flex', justifyContent: 'center', alignItems: 'center'
}}>
<StarFilled style={{ fontSize: 20, color: isPlatformLevel ? '#faad14' : (isTenantLevel ? '#1890ff' : '#13c2c2') }} />
</div>
{levelTag}
</div>
<Space onClick={e => e.stopPropagation()}>
{canEdit && <EditOutlined style={{ fontSize: 18, color: '#bfbfbf', cursor: 'pointer' }} onClick={() => handleOpenDrawer(item)} />}
<Switch
size="small"
checked={item.status === 1}
onChange={(checked) => handleStatusChange(item.id, checked)}
disabled={false}
/>
</Space>
</div>
<div style={{ marginBottom: 12 }}>
<Text strong style={{ fontSize: 16, display: 'block' }} ellipsis={{ tooltip: item.templateName }}>{item.templateName}</Text>
{/*<Text type="secondary" style={{ fontSize: 12 }}>使用次数: {item.usageCount || 0}</Text>*/}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 20, height: 22, overflow: 'hidden' }}>
{item.tags?.map(tag => {
const dictItem = dictTags.find(dt => dt.itemValue === tag);
return (
<Tag key={tag} style={{ margin: 0, border: 'none', backgroundColor: '#f0f2f5', color: '#595959', borderRadius: 4, fontSize: 10 }}>
{dictItem ? dictItem.itemLabel : tag}
</Tag>
);
})}
</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', backgroundColor: '#fff', minHeight: '100%', overflowY: 'auto' }}>
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
<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 bordered={false} bodyStyle={{ padding: '20px 24px', backgroundColor: '#f9f9f9', borderRadius: 12, marginBottom: 32 }}>
<Form form={searchForm} layout="inline" onFinish={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(); fetchData(); }}></Button>
</Space>
</Form.Item>
</Form>
</Card>
<Skeleton loading={loading} active>
{Object.keys(groupedData).length === 0 ? (
<Empty description="暂无可用模板" />
) : (
<>
{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 style={{ display: 'flex', justifyContent: 'center', marginTop: 40, paddingBottom: 20 }}>
<Pagination
current={current}
pageSize={pageSize}
total={total}
showSizeChanger
showQuickJumper
onChange={(page, size) => {
setCurrent(page);
setPageSize(size);
}}
showTotal={(total) => `${total} 条模板`}
/>
</div>
</>
)}
</Skeleton>
</div>
<Drawer
title={<Title level={4} style={{ margin: 0 }}>{editingId ? '编辑模板' : '创建新模板'}</Title>}
width="80%"
onClose={() => setDrawerVisible(false)}
open={drawerVisible}
extra={
<Space>
<Button onClick={() => setDrawerVisible(false)}></Button>
<Button type="primary" icon={<SaveOutlined />} loading={submitLoading} onClick={handleSubmit}></Button>
</Space>
}
destroyOnClose
>
<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="tags" label="业务标签" tooltip="可从现有标签中选择,也可输入新内容按回车保存">
<Select
mode="tags"
placeholder="选择或输入新标签"
allowClear
tokenSeparators={[',', ' ', ';']}
>
{dictTags.map(t => <Option key={t.itemValue} value={t.itemValue}>{t.itemLabel}</Option>)}
</Select>
</Form.Item>
<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: '#fafafa', border: '1px solid #f0f0f0', borderRadius: 8, padding: '16px 24px' }}>
<div className="markdown-preview"><ReactMarkdown>{previewContent}</ReactMarkdown></div>
</Col>
</Row>
</Form>
</Drawer>
</div>
);
};
export default PromptTemplates;