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

578 lines
22 KiB
TypeScript
Raw Normal View History

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