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(null); const selectionRef = useRef<{ start: number; end: number; text: string } | null>(null); const [htmlContent, setHtmlContent] = useState(""); const [templateName, setTemplateName] = useState(""); const [points, setPoints] = useState([]); const [models, setModels] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [pointModalOpen, setPointModalOpen] = useState(false); const [editingPoint, setEditingPoint] = useState(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(null); const dragOverItem = useRef(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 = {}; 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 (
); } return (

{templateName}

{showSelectionBtn && (
)}
{!panelCollapsed && (

生成点列表 ({points.length})

{points.map((point, index) => { const model = models.find((m) => m.id === point.model_id); return (
handleDragStart(e, index)} onDragEnter={(e) => handleDragEnter(e, index)} onDragEnd={handleDragEnd} onDragOver={(e) => e.preventDefault()} style={{ marginBottom: 8, cursor: "grab" }} > #{index + 1} {model && ( {model.name} )} } extra={ {point.need_ref_file && (
); })} {points.length === 0 && (

选中编辑器中的文本,点击"设为 AI 生成点"

)}
)}
{ setPointModalOpen(false); pointForm.resetFields(); }} onOk={handlePointSubmit} width={560} >
{selectionRef.current && !editingPoint && (
已选中: 「{selectionRef.current.text.slice(0, 50)} {selectionRef.current.text.length > 50 ? "..." : ""}」
)}