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

491 lines
17 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 { useNavigate } from 'react-router-dom';
import {
Card,
Tabs,
Button,
Switch,
Modal,
Input,
Tag,
message,
Space,
Typography,
Row,
Col,
Alert,
Upload,
Spin,
Empty
} from 'antd';
import {
EditOutlined,
ReloadOutlined,
DownloadOutlined,
UploadOutlined,
CheckCircleOutlined,
FileSearchOutlined,
ArrowLeftOutlined,
InfoCircleOutlined
} from '@ant-design/icons';
import axios from 'axios';
import { cardStyles, cardHoverHandlers, gridConfig } 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 navigate = useNavigate();
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: any) {
message.error(error.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: any) {
message.error(error.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: any) {
message.error(error.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: any) {
message.error(error.response?.data?.detail || '操作失败');
}
};
// 导出所有模板
const handleExport = async () => {
try {
const response = await axios.post('/api/prompt-templates/export');
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);
message.success('导出成功');
} catch (error: any) {
message.error(error.response?.data?.detail || '导出失败');
}
};
// 导入模板
const handleImport = async (file: File) => {
try {
const text = await file.text();
const data = JSON.parse(text);
await axios.post('/api/prompt-templates/import', data);
message.success('导入成功');
loadTemplates();
} catch (error: any) {
message.error(error.response?.data?.detail || '导入失败');
}
return false; // 阻止默认上传行为
};
const currentTemplates = getCurrentTemplates();
return (
<div style={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: isMobile ? '20px 16px' : '40px 24px'
}}>
{/* 头部卡片 */}
<div style={{
maxWidth: 1400,
margin: '0 auto',
marginBottom: isMobile ? 20 : 40
}}>
<Card
variant="borderless"
style={{
background: 'rgba(255, 255, 255, 0.95)',
borderRadius: isMobile ? 12 : 16,
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
}}
>
<Row align="middle" justify="space-between" gutter={[16, 16]}>
<Col xs={24} sm={12} md={14}>
<Space direction="vertical" size={4}>
<Space align="center">
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/projects')}
size={isMobile ? 'small' : 'middle'}
/>
<Title level={isMobile ? 3 : 2} style={{ margin: 0 }}>
<FileSearchOutlined style={{ color: '#667eea', marginRight: 8 }} />
</Title>
</Space>
<Text type="secondary" style={{ fontSize: isMobile ? 12 : 14, 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: 8 }}
>
</Button>
<Upload
accept=".json"
showUploadList={false}
beforeUpload={handleImport}
>
<Button
icon={<UploadOutlined />}
size={isMobile ? 'small' : 'middle'}
style={{ borderRadius: 8 }}
>
</Button>
</Upload>
</Space>
</Col>
</Row>
{/* 使用提示 */}
<Alert
message={
<Space align="center">
<InfoCircleOutlined style={{ fontSize: 16, color: '#1890ff' }} />
<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: 'linear-gradient(135deg, #e6f7ff 0%, #f0f5ff 100%)',
border: '1px solid #91d5ff'
}}
/>
</Card>
</div>
{/* 主内容区 */}
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
<Spin spinning={loading}>
{/* 分类标签 */}
{categories.length > 0 && (
<Card
variant="borderless"
style={{
background: 'rgba(255, 255, 255, 0.95)',
borderRadius: isMobile ? 12 : 16,
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
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: 'rgba(255, 255, 255, 0.95)',
borderRadius: 16,
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
}}
>
<Empty
description="暂无模板数据"
style={{ padding: '80px 0' }}
/>
</Card>
) : (
<Row gutter={[16, 16]}>
{currentTemplates.map(template => (
<Col {...gridConfig} key={template.id}>
<Card
hoverable
variant="borderless"
style={cardStyles.project}
styles={{ body: { padding: 0, overflow: 'hidden' } }}
{...cardHoverHandlers}
>
{/* 头部 */}
<div style={{
background: template.is_system_default
? 'linear-gradient(135deg, #a8a8a8 0%, #636363 100%)'
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
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: '#fff', 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="rgba(255,255,255,0.3)" style={{ color: '#fff', border: 'none' }}>
{template.category}
</Tag>
<Tag color="rgba(255,255,255,0.3)" style={{ color: '#fff', 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>
{/* 编辑对话框 */}
<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>
);
}