513 lines
18 KiB
TypeScript
513 lines
18 KiB
TypeScript
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
|
||
tinymceScriptSrc="/tinymce/tinymce.min.js"
|
||
onInit={(_evt, editor) => {
|
||
handleEditorInit(null, editor);
|
||
if (htmlContent) editor.setContent(htmlContent);
|
||
}}
|
||
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; }",
|
||
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 (
|
||
<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>
|
||
);
|
||
}
|