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

507 lines
18 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,
Space, message, Spin, Popconfirm, Card, Switch,
} from "antd";
import {
PlusOutlined, DeleteOutlined, EditOutlined, SaveOutlined,
ArrowLeftOutlined, ThunderboltOutlined, DownloadOutlined,
ExperimentOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
MenuOutlined, PaperClipOutlined,
} from "@ant-design/icons";
import { Editor } from "@tinymce/tinymce-react";
import type { Editor as TinyMCEEditor } from "tinymce";
import type { GenerationPoint, AIModel } from "../types";
import * as templateApi from "../api/templates";
import * as pointApi from "../api/generationPoints";
import * as modelApi from "../api/models";
import request from "../api/request";
import GenerateModal from "../components/GenerateModal";
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 [generateModalOpen, setGenerateModalOpen] = useState(false);
const dragItem = useRef<number | null>(null);
const dragOverItem = useRef<number | 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();
}, [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);
setPointModalOpen(true);
};
const handleEditPoint = (point: GenerationPoint) => {
setEditingPoint(point);
setPointModalOpen(true);
};
useEffect(() => {
if (!pointModalOpen) return;
const timer = setTimeout(() => {
if (editingPoint) {
pointForm.setFieldsValue({
prompt: editingPoint.prompt,
model_id: editingPoint.model_id,
need_ref_file: editingPoint.need_ref_file,
remark: editingPoint.remark,
});
} else {
pointForm.resetFields();
if (selectionRef.current) {
pointForm.setFieldsValue({
prompt: `根据上下文生成关于「${selectionRef.current.text.slice(0, 20)}...」的内容`,
});
}
}
}, 0);
return () => clearTimeout(timer);
}, [pointModalOpen]);
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;
if (editingPoint) {
const updateData: Record<string, unknown> = {};
if (values.prompt) updateData.prompt = values.prompt;
if (values.model_id) updateData.model_id = values.model_id;
updateData.need_ref_file = values.need_ref_file || false;
if (values.remark !== undefined) updateData.remark = values.remark;
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,
selected_text: selectionRef.current?.text,
need_ref_file: values.need_ref_file || false,
remark: values.remark || "",
});
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 handleDragStart = (_e: React.DragEvent, index: number) => {
dragItem.current = index;
};
const handleDragEnter = (_e: React.DragEvent, index: number) => {
dragOverItem.current = index;
};
const handleDragEnd = async () => {
if (dragItem.current === null || dragOverItem.current === null) return;
if (dragItem.current === dragOverItem.current) return;
const newPoints = [...points];
const [moved] = newPoints.splice(dragItem.current, 1);
newPoints.splice(dragOverItem.current!, 0, moved);
const reordered = newPoints.map((p, i) => ({ ...p, order: i }));
setPoints(reordered);
try {
await pointApi.batchUpdateOrder(
reordered.map((p) => ({ id: p.id, order: p.order }))
);
} catch {
message.error("排序更新失败");
}
dragItem.current = null;
dragOverItem.current = null;
};
const handleTestPoint = async (pointId: string) => {
const point = points.find((p) => p.id === pointId);
if (!point) return;
if (point.need_ref_file) {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.multiple = true;
fileInput.accept = ".txt,.docx,.pdf";
fileInput.onchange = async (e: Event) => {
const files = (e.target as HTMLInputElement).files;
if (!files || files.length === 0) return;
try {
message.loading({ content: "测试中...", key: "test" });
const formData = new FormData();
Array.from(files).forEach((f) => formData.append("ref_files", f));
const { default: request } = await import("../api/request");
const { data } = await request.post<{ result: string }>(
`/generation-points/${pointId}/test`,
formData
);
message.success({ content: "测试完成", key: "test" });
Modal.info({ title: "AI 生成结果", content: data.result, width: 600 });
} catch {
message.error({ content: "测试失败", key: "test" });
}
};
fileInput.click();
return;
}
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 = () => {
if (!id) return;
if (points.length === 0) {
message.warning("请先添加至少一个生成点");
return;
}
setGenerateModalOpen(true);
};
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}
>
</Button>
</Space>
</div>
<Layout style={{ height: "calc(100% - 50px)", background: "#fff" }}>
<Content style={{ position: "relative", overflow: "hidden" }}>
{showSelectionBtn && (
<div style={{
position: "absolute",
zIndex: 1310,
top: 0,
right: 8,
padding: "4px 8px",
background: "#fff",
borderRadius: "0 0 4px 4px",
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
}}>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={handleCreatePoint}
>
AI
</Button>
</div>
)}
<Editor
key={id}
tinymceScriptSrc="/tinymce/tinymce.min.js"
initialValue={htmlContent}
onInit={handleEditorInit}
init={{
height: "100%",
menubar: true,
license_key: "gpl",
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; }",
}}
/>
</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 (
<div
key={point.id}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragEnter={(e) => handleDragEnter(e, index)}
onDragEnd={handleDragEnd}
onDragOver={(e) => e.preventDefault()}
style={{ marginBottom: 8, cursor: "grab" }}
>
<Card
size="small"
title={
<Space size={4}>
<MenuOutlined style={{ color: "#bbb", fontSize: 12, cursor: "grab" }} />
<span style={{ fontSize: 12, color: "#999" }}>#{index + 1}</span>
{model && (
<span style={{ fontSize: 12, color: "#1677ff" }}>
{model.name}
</span>
)}
</Space>
}
extra={
<Space size={4}>
{point.need_ref_file && (
<Button
type="text"
size="small"
icon={<PaperClipOutlined />}
onClick={async () => {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.accept = ".txt,.docx,.pdf";
input.onchange = async (e) => {
const files = (e.target as HTMLInputElement).files;
if (!files?.length) return;
const fd = new FormData();
Array.from(files).forEach((f) => fd.append("ref_files", f));
await request.post(`/generation-points/${point.id}/upload-ref`, fd);
message.success("上传成功");
const updated = await pointApi.listGenerationPoints(id!);
setPoints(updated);
};
input.click();
}}
/>
)}
<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>
</div>
);
})}
{points.length === 0 && (
<p style={{ color: "#999", fontSize: 13 }}>
"设为 AI 生成点"
</p>
)}
</Sider>
)}
</Layout>
<Modal
title={editingPoint ? "编辑生成点" : "新建生成点"}
open={pointModalOpen}
onCancel={() => { setPointModalOpen(false); pointForm.resetFields(); }}
onOk={handlePointSubmit}
width={560}
>
<Form form={pointForm} layout="vertical">
{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>
<Form.Item name="need_ref_file" label="需要参考文件" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="补充说明(可选)" />
</Form.Item>
</Form>
</Modal>
<GenerateModal
open={generateModalOpen}
onClose={() => setGenerateModalOpen(false)}
templateId={id || ""}
points={points}
models={models}
/>
</div>
);
}