Files
MuMuAINovel/frontend/src/pages/PromptTemplates.tsx
T

578 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react';
import {
Card,
Tabs,
Button,
Switch,
Modal,
Input,
Tag,
message,
Space,
Typography,
Row,
Col,
Alert,
Upload,
Spin,
Empty,
theme
} from 'antd';
import {
EditOutlined,
ReloadOutlined,
DownloadOutlined,
UploadOutlined,
CheckCircleOutlined,
FileSearchOutlined,
InfoCircleOutlined
} from '@ant-design/icons';
import axios from 'axios';
import { promptTemplateCardStyles, promptTemplateCardHoverHandlers, promptTemplateGridConfig } from '../components/CardStyles';
const { TextArea } = Input;
const { Title, Text, Paragraph } = Typography;
interface PromptTemplate {
id: string;
user_id: string;
template_key: string;
template_name: string;
template_content: string;
description: string;
category: string;
parameters: string;
is_active: boolean;
is_system_default: boolean;
created_at: string;
updated_at: string;
}
interface CategoryGroup {
category: string;
count: number;
templates: PromptTemplate[];
}
export default function PromptTemplates() {
const { token } = theme.useToken();
const [modal, contextHolder] = Modal.useModal();
const [categories, setCategories] = useState<CategoryGroup[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>('0');
const [editingTemplate, setEditingTemplate] = useState<PromptTemplate | null>(null);
const [editorVisible, setEditorVisible] = useState(false);
const [loading, setLoading] = useState(false);
const isMobile = window.innerWidth <= 768;
// 加载模板数据
const loadTemplates = async () => {
try {
setLoading(true);
const response = await axios.get<CategoryGroup[]>('/api/prompt-templates/categories');
setCategories(response.data);
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } };
message.error(err.response?.data?.detail || '加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadTemplates();
}, []);
// 获取当前分类的模板
const getCurrentTemplates = (): PromptTemplate[] => {
const index = parseInt(selectedCategory);
if (index === 0) {
return categories.flatMap(cat => cat.templates);
}
return categories[index - 1]?.templates || [];
};
// 编辑模板
const handleEdit = (template: PromptTemplate) => {
setEditingTemplate({ ...template });
setEditorVisible(true);
};
// 保存模板
const handleSave = async () => {
if (!editingTemplate) return;
try {
setLoading(true);
await axios.post('/api/prompt-templates', {
template_key: editingTemplate.template_key,
template_name: editingTemplate.template_name,
template_content: editingTemplate.template_content,
description: editingTemplate.description,
category: editingTemplate.category,
parameters: editingTemplate.parameters,
is_active: editingTemplate.is_active
});
message.success('保存成功');
setEditorVisible(false);
loadTemplates();
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } };
message.error(err.response?.data?.detail || '保存失败');
} finally {
setLoading(false);
}
};
// 重置为系统默认
const handleReset = async (templateKey: string) => {
modal.confirm({
title: '确认重置',
content: '确定要重置为系统默认模板吗?这将覆盖您的自定义内容。',
okText: '确定',
cancelText: '取消',
centered: true,
onOk: async () => {
try {
setLoading(true);
await axios.post(`/api/prompt-templates/${templateKey}/reset`);
message.success('已重置为系统默认');
loadTemplates();
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } };
message.error(err.response?.data?.detail || '重置失败');
} finally {
setLoading(false);
}
}
});
};
// 切换启用状态
const handleToggleActive = async (template: PromptTemplate, checked: boolean) => {
try {
await axios.put(`/api/prompt-templates/${template.template_key}`, {
is_active: checked
});
loadTemplates();
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } };
message.error(err.response?.data?.detail || '操作失败');
}
};
// 导出所有模板
const handleExport = async () => {
try {
const response = await axios.post('/api/prompt-templates/export');
const stats = response.data.statistics;
const blob = new Blob([JSON.stringify(response.data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `prompt-templates-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
if (stats) {
message.success(
`成功导出 ${stats.total} 个提示词配置(${stats.customized} 个自定义,${stats.system_default} 个系统默认)`,
5
);
} else {
message.success('导出成功');
}
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } };
message.error(err.response?.data?.detail || '导出失败');
}
};
// 导入模板
const handleImport = async (file: File) => {
try {
const text = await file.text();
const data = JSON.parse(text);
const response = await axios.post('/api/prompt-templates/import', data);
const result = response.data;
const stats = result.statistics;
// 构建详细的成功消息
let successMsg = `导入成功!\n`;
if (stats) {
successMsg += `• 保持系统默认:${stats.kept_system_default}\n`;
successMsg += `• 创建/更新自定义:${stats.created_or_updated}`;
if (stats.converted_to_custom > 0) {
successMsg += `\n• 检测到修改(已转为自定义):${stats.converted_to_custom}`;
}
}
// 如果有被转换的模板,显示详细信息
if (result.converted_templates && result.converted_templates.length > 0) {
modal.info({
title: '导入完成',
width: 600,
centered: true,
content: (
<div>
<p style={{ marginBottom: 16 }}>{successMsg}</p>
{result.converted_templates.length > 0 && (
<div>
<p style={{ fontWeight: 'bold', marginBottom: 8 }}></p>
<ul style={{ marginLeft: 20 }}>
{result.converted_templates.map((t: { template_key: string; template_name: string }) => (
<li key={t.template_key}>
{t.template_name} ({t.template_key})
</li>
))}
</ul>
</div>
)}
</div>
),
okText: '确定'
});
} else {
message.success(successMsg, 5);
}
loadTemplates();
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } };
message.error(err.response?.data?.detail || '导入失败');
}
return false; // 阻止默认上传行为
};
const currentTemplates = getCurrentTemplates();
const pageBackground = `linear-gradient(180deg, ${token.colorBgLayout} 0%, ${token.colorFillSecondary} 100%)`;
const headerBackground = `linear-gradient(135deg, ${token.colorPrimary} 0%, ${token.colorPrimaryHover} 100%)`;
return (
<>
{contextHolder}
<div style={{
minHeight: '90vh',
background: pageBackground,
padding: isMobile ? '20px 16px 70px' : '24px 24px 70px',
display: 'flex',
flexDirection: 'column',
}}>
<div style={{
maxWidth: 1400,
margin: '0 auto',
width: '100%',
flex: 1,
display: 'flex',
flexDirection: 'column',
}}>
{/* 顶部导航卡片 */}
<Card
variant="borderless"
style={{
background: headerBackground,
borderRadius: isMobile ? 16 : 24,
boxShadow: token.boxShadowSecondary,
marginBottom: isMobile ? 20 : 24,
border: 'none',
position: 'relative',
overflow: 'hidden'
}}
>
{/* 装饰性背景元素 */}
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: token.colorWhite, opacity: 0.08, pointerEvents: 'none' }} />
<div style={{ position: 'absolute', bottom: -40, left: '30%', width: 120, height: 120, borderRadius: '50%', background: token.colorWhite, opacity: 0.05, pointerEvents: 'none' }} />
<div style={{ position: 'absolute', top: '50%', right: '15%', width: 80, height: 80, borderRadius: '50%', background: token.colorWhite, opacity: 0.06, pointerEvents: 'none' }} />
<Row align="middle" justify="space-between" gutter={[16, 16]} style={{ position: 'relative', zIndex: 1 }}>
<Col xs={24} sm={12} md={14}>
<Space direction="vertical" size={4}>
<Title level={isMobile ? 3 : 2} style={{ margin: 0, color: token.colorWhite, textShadow: `0 2px 4px ${token.colorBgMask}` }}>
<FileSearchOutlined style={{ color: token.colorWhite, opacity: 0.9, marginRight: 8 }} />
</Title>
<Text style={{ fontSize: isMobile ? 12 : 14, color: token.colorTextLightSolid, opacity: 0.85, marginLeft: isMobile ? 40 : 48 }}>
AI生成提示词
</Text>
</Space>
</Col>
<Col xs={24} sm={12} md={10}>
<Space wrap style={{ justifyContent: isMobile ? 'flex-start' : 'flex-end', width: '100%' }}>
<Button
icon={<DownloadOutlined />}
onClick={handleExport}
size={isMobile ? 'small' : 'middle'}
style={{
borderRadius: 12,
background: token.colorWhite,
border: `1px solid ${token.colorWhite}`,
boxShadow: token.boxShadow,
color: token.colorPrimary,
fontWeight: 600,
backdropFilter: 'blur(10px)',
transition: 'all 0.3s ease'
}}
>
</Button>
<Upload
accept=".json"
showUploadList={false}
beforeUpload={handleImport}
>
<Button
icon={<UploadOutlined />}
size={isMobile ? 'small' : 'middle'}
style={{
borderRadius: 12,
background: token.colorWhite,
border: `1px solid ${token.colorWhite}`,
boxShadow: token.boxShadow,
color: token.colorPrimary,
fontWeight: 600,
backdropFilter: 'blur(10px)',
}}
>
</Button>
</Upload>
</Space>
</Col>
</Row>
{/* 使用提示 */}
<Alert
message={
<Space align="center">
<InfoCircleOutlined style={{ fontSize: 16, color: token.colorPrimary }} />
<Text strong style={{ fontSize: isMobile ? 13 : 14 }}>使</Text>
</Space>
}
description={
<div>
<Text style={{ fontSize: isMobile ? 12 : 13, display: 'block', marginBottom: 8 }}>
<strong></strong>"编辑"
</Text>
<Text style={{ fontSize: isMobile ? 12 : 13, display: 'block' }}>
<strong></strong>/使 <Text code>{'{variable_name}'}</Text> "重置"
</Text>
</div>
}
type="info"
showIcon={false}
style={{
marginTop: isMobile ? 16 : 24,
borderRadius: 12,
background: token.colorInfoBg,
border: `1px solid ${token.colorInfoBorder}`
}}
/>
</Card>
{/* 主内容区 */}
<div style={{ flex: 1 }}>
<Spin spinning={loading}>
{/* 分类标签 */}
{categories.length > 0 && (
<Card
variant="borderless"
style={{
background: token.colorBgContainer,
borderRadius: isMobile ? 12 : 16,
boxShadow: token.boxShadowSecondary,
marginBottom: isMobile ? 16 : 24
}}
styles={{ body: { padding: isMobile ? '12px' : '16px' } }}
>
<Tabs
activeKey={selectedCategory}
onChange={setSelectedCategory}
items={[
{ key: '0', label: `全部 (${categories.reduce((sum, cat) => sum + cat.count, 0)})` },
...categories.map((cat, index) => ({
key: (index + 1).toString(),
label: `${cat.category} (${cat.count})`
}))
]}
/>
</Card>
)}
{/* 模板列表 */}
{currentTemplates.length === 0 ? (
<Card
variant="borderless"
style={{
background: token.colorBgContainer,
borderRadius: isMobile ? 12 : 16,
boxShadow: token.boxShadowSecondary,
}}
>
<Empty
description="暂无模板数据"
style={{ padding: '80px 0' }}
/>
</Card>
) : (
<Row gutter={[16, 16]}>
{currentTemplates.map(template => (
<Col {...promptTemplateGridConfig} key={template.id}>
<Card
hoverable
variant="borderless"
style={promptTemplateCardStyles.templateCard}
styles={{ body: { padding: 0, overflow: 'hidden' } }}
{...promptTemplateCardHoverHandlers}
>
{/* 头部 */}
<div style={{
background: template.is_system_default
? token.colorFillTertiary
: token.colorPrimary,
padding: isMobile ? '16px' : '20px',
position: 'relative'
}}>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Title level={isMobile ? 5 : 4} style={{ margin: 0, color: template.is_system_default ? token.colorText : token.colorWhite, flex: 1 }} ellipsis>
{template.template_name}
</Title>
{!template.is_system_default && (
<Switch
checked={template.is_active}
onChange={(checked) => handleToggleActive(template, checked)}
size={isMobile ? 'small' : 'default'}
style={{ marginLeft: 8 }}
/>
)}
</div>
<Space wrap>
<Tag color={template.is_system_default ? 'default' : 'rgba(255,255,255,0.3)'} style={{ color: template.is_system_default ? token.colorTextSecondary : token.colorWhite, border: 'none' }}>
{template.category}
</Tag>
<Tag color={template.is_system_default ? 'default' : 'rgba(255,255,255,0.3)'} style={{ color: template.is_system_default ? token.colorTextSecondary : token.colorWhite, border: 'none' }}>
{template.is_system_default ? '系统默认' : '已自定义'}
</Tag>
</Space>
</Space>
</div>
{/* 内容 */}
<div style={{ padding: isMobile ? '16px' : '20px' }}>
<Paragraph
type="secondary"
ellipsis={{ rows: 3 }}
style={{ minHeight: 66, marginBottom: 16 }}
>
{template.description || '暂无描述'}
</Paragraph>
<Space wrap style={{ marginBottom: 16 }}>
<Tag
icon={<CheckCircleOutlined />}
color={template.is_system_default || template.is_active ? 'success' : 'default'}
>
{template.is_system_default ? '始终启用' : (template.is_active ? '已启用' : '已禁用')}
</Tag>
</Space>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 16 }}>
: {template.template_key}
</Text>
{/* 操作按钮 */}
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => handleEdit(template)}
size={isMobile ? 'small' : 'middle'}
style={{ borderRadius: 6 }}
>
</Button>
<Button
icon={<ReloadOutlined />}
onClick={() => handleReset(template.template_key)}
size={isMobile ? 'small' : 'middle'}
style={{ borderRadius: 6 }}
>
</Button>
</Space>
</div>
</Card>
</Col>
))}
</Row>
)}
</Spin>
</div>
</div>
{/* 编辑对话框 */}
<Modal
title={`编辑模板: ${editingTemplate?.template_name}`}
open={editorVisible}
onCancel={() => setEditorVisible(false)}
onOk={handleSave}
width={isMobile ? '100%' : 900}
centered={!isMobile}
confirmLoading={loading}
okText="保存"
cancelText="取消"
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
styles={isMobile ? {
body: {
maxHeight: 'calc(100vh - 110px)',
overflowY: 'auto',
padding: '16px'
}
} : undefined}
>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 500 }}></label>
<Input
value={editingTemplate?.template_name || ''}
onChange={(e) => setEditingTemplate(prev => prev ? { ...prev, template_name: e.target.value } : null)}
placeholder="输入模板名称"
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 500 }}></label>
<TextArea
value={editingTemplate?.description || ''}
onChange={(e) => setEditingTemplate(prev => prev ? { ...prev, description: e.target.value } : null)}
rows={2}
placeholder="简要描述模板用途"
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 500 }}></label>
<TextArea
value={editingTemplate?.template_content || ''}
onChange={(e) => setEditingTemplate(prev => prev ? { ...prev, template_content: e.target.value } : null)}
rows={isMobile ? 15 : 20}
style={{ fontFamily: 'monospace', fontSize: '13px' }}
placeholder="输入提示词模板内容..."
/>
</div>
<Alert
message="提示:使用 {variable_name} 格式表示变量占位符"
type="info"
showIcon
style={{ borderRadius: 8 }}
/>
</Space>
</Modal>
</div>
</>
);
}