doc-forge-reds/web/src/pages/TemplateEditor.tsx

443 lines
15 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 { useEffect, useState, useRef, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Layout, Button, Modal, Form, Input, Select, Upload,
Space, message, Spin, Progress, Popconfirm, Card, Collapse,
} from "antd";
import {
PlusOutlined, DeleteOutlined, EditOutlined, SaveOutlined,
ArrowLeftOutlined, ThunderboltOutlined, DownloadOutlined,
ExperimentOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
} from "@ant-design/icons";
import { Editor } from "@tinymce/tinymce-react";
import type { Editor as TinyMCEEditor } from "tinymce";
import type { GenerationPoint, AIModel, GenerationTask } from "../types";
import * as templateApi from "../api/templates";
import * as pointApi from "../api/generationPoints";
import * as modelApi from "../api/models";
import * as taskApi from "../api/tasks";
const { Sider, Content } = Layout;
export default function TemplateEditor() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const editorRef = useRef<TinyMCEEditor | null>(null);
const selectionRef = useRef<{ start: number; end: number; text: string } | null>(null);
const [htmlContent, setHtmlContent] = useState("");
const [templateName, setTemplateName] = useState("");
const [points, setPoints] = useState<GenerationPoint[]>([]);
const [models, setModels] = useState<AIModel[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [pointModalOpen, setPointModalOpen] = useState(false);
const [editingPoint, setEditingPoint] = useState<GenerationPoint | null>(null);
const [showSelectionBtn, setShowSelectionBtn] = useState(false);
const [selBtnPos, setSelBtnPos] = useState({ x: 0, y: 0 });
const [pointForm] = Form.useForm();
const [panelCollapsed, setPanelCollapsed] = useState(false);
const [generating, setGenerating] = useState(false);
const [taskStatus, setTaskStatus] = useState<GenerationTask | null>(null);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const loadData = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const [template, pointsData, modelsData] = await Promise.all([
templateApi.getTemplate(id),
pointApi.listGenerationPoints(id),
modelApi.listModels({ enabled: true }),
]);
setTemplateName(template.name);
setHtmlContent(template.html_content || "");
setPoints(pointsData);
setModels(modelsData);
} catch {
message.error("加载数据失败");
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
loadData();
return () => {
if (pollingRef.current) clearInterval(pollingRef.current);
};
}, [loadData]);
const handleSave = async () => {
if (!id) return;
setSaving(true);
try {
const html = editorRef.current?.getContent() || htmlContent;
await templateApi.updateTemplateHtml(id, html);
setHtmlContent(html);
message.success("保存成功");
} catch {
message.error("保存失败");
} finally {
setSaving(false);
}
};
const handleEditorInit = (_evt: unknown, editor: TinyMCEEditor) => {
editorRef.current = editor;
editor.on("selectionchange", () => {
const sel = editor.selection.getContent({ format: "text" });
if (sel && sel.trim().length > 0) {
const content = editor.getContent();
const rng = editor.selection.getRng();
if (rng) {
const preRange = rng.cloneRange();
preRange.selectNodeContents(editor.getBody());
preRange.setEnd(rng.startContainer, rng.startOffset);
const start = preRange.toString().length;
const end = start + sel.length;
selectionRef.current = { start, end, text: sel.trim() };
}
const ed = editor.getContainer();
const rect = ed.getBoundingClientRect();
setSelBtnPos({ x: rect.left + 100, y: rect.top + 10 });
setShowSelectionBtn(true);
} else {
setShowSelectionBtn(false);
}
});
};
const handleCreatePoint = () => {
setEditingPoint(null);
pointForm.resetFields();
if (selectionRef.current) {
pointForm.setFieldsValue({
prompt: `根据上下文生成关于「${selectionRef.current.text.slice(0, 20)}...」的内容`,
});
}
setPointModalOpen(true);
};
const handleEditPoint = (point: GenerationPoint) => {
setEditingPoint(point);
pointForm.setFieldsValue({
prompt: point.prompt,
model_id: point.model_id,
});
setPointModalOpen(true);
};
const handlePointSubmit = async () => {
if (!id) return;
try {
const values = await pointForm.validateFields();
const position = selectionRef.current
? { start: selectionRef.current.start, end: selectionRef.current.end }
: editingPoint?.position;
const refFile = values.ref_file?.fileList?.[0]?.originFileObj;
if (editingPoint) {
const updateData: Record<string, unknown> = {};
if (values.prompt) updateData.prompt = values.prompt;
if (values.model_id) updateData.model_id = values.model_id;
await pointApi.updateGenerationPoint(editingPoint.id, updateData);
message.success("更新成功");
} else if (position) {
await pointApi.createGenerationPoint({
template_id: id,
position: JSON.stringify(position),
prompt: values.prompt,
model_id: values.model_id,
order: points.length,
ref_file: refFile,
});
message.success("创建成功");
selectionRef.current = null;
setShowSelectionBtn(false);
} else {
message.warning("请先在编辑器中选中文本");
return;
}
setPointModalOpen(false);
const updated = await pointApi.listGenerationPoints(id);
setPoints(updated);
} catch (err: unknown) {
if (err instanceof Error) message.error(err.message);
}
};
const handleDeletePoint = async (pointId: string) => {
await pointApi.deleteGenerationPoint(pointId);
message.success("删除成功");
const updated = await pointApi.listGenerationPoints(id!);
setPoints(updated);
};
const handleTestPoint = async (pointId: string) => {
try {
message.loading({ content: "测试中...", key: "test" });
const result = await pointApi.testGenerationPoint(pointId);
message.success({ content: "测试完成", key: "test" });
Modal.info({ title: "AI 生成结果", content: result, width: 600 });
} catch {
message.error({ content: "测试失败", key: "test" });
}
};
const handleGenerate = async () => {
if (!id) return;
if (points.length === 0) {
message.warning("请先添加至少一个生成点");
return;
}
setGenerating(true);
try {
const { task_id } = await taskApi.triggerGeneration(id);
pollingRef.current = setInterval(async () => {
try {
const status = await taskApi.getTaskStatus(task_id);
setTaskStatus(status);
if (status.status === "done" || status.status === "failed") {
if (pollingRef.current) clearInterval(pollingRef.current);
if (status.status === "done") {
message.success("生成完成!");
} else {
message.error(`生成失败: ${status.error_msg}`);
}
setGenerating(false);
}
} catch {
if (pollingRef.current) clearInterval(pollingRef.current);
setGenerating(false);
}
}, 2000);
} catch (err: unknown) {
if (err instanceof Error) message.error(err.message);
setGenerating(false);
}
};
const handleDownload = (format: "docx" | "pdf" = "docx") => {
if (taskStatus?.id) {
window.open(taskApi.getTaskDownloadUrl(taskStatus.id, format));
}
};
if (loading) {
return (
<div style={{ textAlign: "center", padding: 100 }}>
<Spin size="large" />
</div>
);
}
return (
<div style={{ height: "calc(100vh - 120px)" }}>
<div
style={{
marginBottom: 12,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate("/templates")}>
</Button>
<h3 style={{ margin: 0 }}>{templateName}</h3>
</Space>
<Space>
<Button
icon={panelCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setPanelCollapsed(!panelCollapsed)}
/>
<Button icon={<SaveOutlined />} onClick={handleSave} loading={saving}>
</Button>
<Button
type="primary"
danger
icon={<ThunderboltOutlined />}
onClick={handleGenerate}
loading={generating}
>
</Button>
{taskStatus?.status === "done" && (
<>
<Button icon={<DownloadOutlined />} onClick={() => handleDownload("docx")}>
DOCX
</Button>
<Button onClick={() => handleDownload("pdf")}>PDF</Button>
</>
)}
</Space>
</div>
{taskStatus && taskStatus.status !== "done" && taskStatus.status !== "failed" && (
<div style={{ marginBottom: 12 }}>
<Progress
percent={taskStatus.status === "processing" ? 50 : 10}
status="active"
format={() => taskStatus.status === "pending" ? "等待中..." : "生成中..."}
/>
</div>
)}
{taskStatus?.status === "failed" && (
<div style={{ marginBottom: 12, color: "red" }}>: {taskStatus.error_msg}</div>
)}
<Layout style={{ height: "calc(100% - 50px)", background: "#fff" }}>
<Content style={{ position: "relative", overflow: "hidden" }}>
{showSelectionBtn && (
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={handleCreatePoint}
style={{
position: "absolute",
zIndex: 1310,
top: 8,
left: 8,
}}
>
AI
</Button>
)}
<Editor
tinymceScriptSrc="/tinymce/tinymce.min.js"
onInit={handleEditorInit}
value={htmlContent}
init={{
height: "100%",
menubar: true,
plugins: [
"advlist", "autolink", "lists", "link", "image",
"charmap", "preview", "anchor", "searchreplace",
"visualblocks", "code", "fullscreen", "insertdatetime",
"media", "table", "help", "wordcount",
],
toolbar:
"undo redo | blocks | bold italic forecolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | removeformat | help",
content_style: "body { font-family: Arial, sans-serif; font-size: 14px; }",
setup: (editor) => {
editor.on("init", () => {
handleEditorInit(null, editor);
});
},
}}
/>
</Content>
{!panelCollapsed && (
<Sider width={320} theme="light" style={{ padding: 16, overflow: "auto", borderLeft: "1px solid #f0f0f0" }}>
<h4 style={{ marginBottom: 12 }}> ({points.length})</h4>
{points.map((point, index) => {
const model = models.find((m) => m.id === point.model_id);
return (
<Card
key={point.id}
size="small"
style={{ marginBottom: 8 }}
title={
<Space size={4}>
<span style={{ fontSize: 12, color: "#999" }}>#{index + 1}</span>
{model && (
<span style={{ fontSize: 12, color: "#1677ff" }}>
{model.name}
</span>
)}
</Space>
}
extra={
<Space size={4}>
<Button
type="text"
size="small"
icon={<ExperimentOutlined />}
onClick={() => handleTestPoint(point.id)}
/>
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => handleEditPoint(point)}
/>
<Popconfirm
title="确定删除?"
onConfirm={() => handleDeletePoint(point.id)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
}
>
<p style={{ fontSize: 12, color: "#666", margin: 0, wordBreak: "break-all" }}>
{point.prompt.slice(0, 80)}
{point.prompt.length > 80 && "..."}
</p>
</Card>
);
})}
{points.length === 0 && (
<p style={{ color: "#999", fontSize: 13 }}>
"设为 AI 生成点"
</p>
)}
</Sider>
)}
</Layout>
<Modal
title={editingPoint ? "编辑生成点" : "新建生成点"}
open={pointModalOpen}
onCancel={() => setPointModalOpen(false)}
onOk={handlePointSubmit}
width={560}
destroyOnClose
>
<Form form={pointForm} layout="vertical" preserve={false}>
{selectionRef.current && !editingPoint && (
<div
style={{
marginBottom: 12,
padding: 8,
background: "#f6f8fa",
borderRadius: 6,
fontSize: 13,
color: "#666",
}}
>
: {selectionRef.current.text.slice(0, 50)}
{selectionRef.current.text.length > 50 ? "..." : ""}
</div>
)}
<Form.Item name="prompt" label="提示词" rules={[{ required: true }]}>
<Input.TextArea
rows={4}
placeholder="请输入 AI 生成提示词,例如:请根据参考文件内容,生成一段项目背景介绍..."
/>
</Form.Item>
<Form.Item name="model_id" label="AI 模型">
<Select
allowClear
placeholder="留空则使用全局默认模型"
options={models.map((m) => ({ label: m.name, value: m.id }))}
/>
</Form.Item>
{!editingPoint && (
<Form.Item name="ref_file" label="参考文件" valuePropName="fileList" getValueFromEvent={(e) => e.fileList}>
<Upload maxCount={1} beforeUpload={() => false}>
<Button icon={<PlusOutlined />}></Button>
</Upload>
</Form.Item>
)}
</Form>
</Modal>
</div>
);
}