import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { App, Button, Card, List, Spin, Typography, Tag, Switch } from "antd"; import { LeftOutlined, ReloadOutlined, SaveOutlined, MenuFoldOutlined, MenuUnfoldOutlined, CheckOutlined } from "@ant-design/icons"; import { useNavigate, useParams } from "react-router"; import { getEditorProjectInfoUsingGet, getEditorTaskUsingGet, listEditorTasksUsingGet, upsertEditorAnnotationUsingPut, } from "../annotation.api"; type EditorProjectInfo = { projectId: string; datasetId: string; templateId?: string | null; labelConfig?: string | null; supported: boolean; unsupportedReason?: string | null; }; type EditorTaskListItem = { fileId: string; fileName: string; fileType?: string | null; hasAnnotation: boolean; annotationUpdatedAt?: string | null; }; type LsfMessage = { type?: string; payload?: unknown; }; type SegmentInfo = { idx: number; text: string; start: number; end: number; hasAnnotation: boolean; }; type ApiResponse = { code?: number; message?: string; data?: T; }; type EditorTaskPayload = { id?: number | string; data?: Record; annotations?: unknown[]; }; type EditorTaskResponse = { task?: EditorTaskPayload; segmented?: boolean; segments?: SegmentInfo[]; currentSegmentIndex?: number; }; type EditorTaskListResponse = { content?: EditorTaskListItem[]; }; type ExportPayload = { taskId?: number | string | null; fileId?: string | null; segmentIndex?: number | string | null; annotation?: Record; requestId?: string | null; }; type SwitchDecision = "save" | "discard" | "cancel"; const LSF_IFRAME_SRC = "/lsf/lsf.html"; const resolveSegmentIndex = (value: unknown) => { if (value === null || value === undefined) return undefined; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; }; const normalizePayload = (payload: unknown): ExportPayload | undefined => { if (!payload || typeof payload !== "object") return undefined; return payload as ExportPayload; }; const resolvePayloadMessage = (payload: unknown) => { if (!payload || typeof payload !== "object") return undefined; if ("message" in payload && typeof (payload as { message?: unknown }).message === "string") { return (payload as { message?: string }).message; } return undefined; }; const isRecord = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); const normalizeSnapshotValue = (value: unknown, seen: WeakSet): unknown => { if (!value || typeof value !== "object") return value; const obj = value as object; if (seen.has(obj)) return undefined; seen.add(obj); if (Array.isArray(value)) { return value.map((item) => normalizeSnapshotValue(item, seen)); } const record = value as Record; const sorted: Record = {}; Object.keys(record) .sort() .forEach((key) => { sorted[key] = normalizeSnapshotValue(record[key], seen); }); return sorted; }; const stableStringify = (value: unknown) => { const normalized = normalizeSnapshotValue(value, new WeakSet()); return JSON.stringify(normalized); }; const buildAnnotationSnapshot = (annotation?: Record) => { if (!annotation) return ""; const cleaned: Record = { ...annotation }; delete cleaned.updated_at; delete cleaned.updatedAt; delete cleaned.created_at; delete cleaned.createdAt; return stableStringify(cleaned); }; const buildSnapshotKey = (fileId: string, segmentIndex?: number) => `${fileId}::${segmentIndex ?? "full"}`; export default function LabelStudioTextEditor() { const { projectId = "" } = useParams(); const navigate = useNavigate(); const { message, modal } = App.useApp(); const origin = useMemo(() => window.location.origin, []); const iframeRef = useRef(null); const initSeqRef = useRef(0); const expectedTaskIdRef = useRef(null); const exportCheckRef = useRef<{ requestId: string; resolve: (payload?: ExportPayload) => void; timer?: number; } | null>(null); const exportCheckSeqRef = useRef(0); const savedSnapshotsRef = useRef>({}); const [loadingProject, setLoadingProject] = useState(true); const [loadingTasks, setLoadingTasks] = useState(false); const [loadingTaskDetail, setLoadingTaskDetail] = useState(false); const [saving, setSaving] = useState(false); const [segmentSwitching, setSegmentSwitching] = useState(false); const [iframeReady, setIframeReady] = useState(false); const [lsReady, setLsReady] = useState(false); const [project, setProject] = useState(null); const [tasks, setTasks] = useState([]); const [selectedFileId, setSelectedFileId] = useState(""); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [autoSaveOnSwitch, setAutoSaveOnSwitch] = useState(false); // 分段相关状态 const [segmented, setSegmented] = useState(false); const [segments, setSegments] = useState([]); const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0); const postToIframe = useCallback((type: string, payload?: unknown) => { const win = iframeRef.current?.contentWindow; if (!win) return; win.postMessage({ type, payload }, origin); }, [origin]); const loadProject = useCallback(async () => { setLoadingProject(true); try { const resp = (await getEditorProjectInfoUsingGet(projectId)) as ApiResponse; const data = resp?.data; if (!data?.projectId) { message.error("获取标注项目信息失败"); setProject(null); return; } setProject(data); } catch (e) { console.error(e); message.error("获取标注项目信息失败"); setProject(null); } finally { setLoadingProject(false); } }, [message, projectId]); const loadTasks = useCallback(async (silent = false) => { if (!projectId) return; if (!silent) setLoadingTasks(true); try { const resp = (await listEditorTasksUsingGet(projectId, { page: 0, size: 200, })) as ApiResponse; const content = resp?.data?.content || []; const items = Array.isArray(content) ? content : []; setTasks(items); if (items.length > 0) { setSelectedFileId((prev) => prev || (items[0]?.fileId ?? "")); } } catch (e) { console.error(e); if (!silent) message.error("获取文件列表失败"); setTasks([]); } finally { if (!silent) setLoadingTasks(false); } }, [message, projectId]); const initEditorForFile = useCallback(async (fileId: string, segmentIdx?: number) => { if (!project?.supported) return; if (!project?.labelConfig) { message.error("该项目未绑定标注模板,无法加载编辑器"); return; } if (!iframeReady) return; const seq = ++initSeqRef.current; setLoadingTaskDetail(true); setLsReady(false); expectedTaskIdRef.current = null; try { const resp = (await getEditorTaskUsingGet(projectId, fileId, { segmentIndex: segmentIdx, })) as ApiResponse; const data = resp?.data; const task = data?.task; if (!task) { message.error("获取任务详情失败"); return; } if (seq !== initSeqRef.current) return; // 更新分段状态 const segmentIndex = data?.segmented ? resolveSegmentIndex(data.currentSegmentIndex) ?? 0 : undefined; if (data?.segmented) { setSegmented(true); setSegments(data.segments || []); setCurrentSegmentIndex(segmentIndex ?? 0); } else { setSegmented(false); setSegments([]); setCurrentSegmentIndex(0); } const taskData = { ...(task?.data || {}), file_id: fileId, fileId: fileId, }; if (data?.segmented) { const normalizedIndex = segmentIndex ?? 0; taskData.segment_index = normalizedIndex; taskData.segmentIndex = normalizedIndex; } const taskForIframe = { ...task, data: taskData, }; const annotations = Array.isArray(taskForIframe.annotations) ? taskForIframe.annotations : []; const initialAnnotation = annotations.length > 0 && isRecord(annotations[0]) ? annotations[0] : undefined; savedSnapshotsRef.current[buildSnapshotKey(fileId, segmentIndex)] = buildAnnotationSnapshot(initialAnnotation); expectedTaskIdRef.current = Number(taskForIframe?.id) || null; postToIframe("LS_INIT", { labelConfig: project.labelConfig, task: taskForIframe, user: { id: "datamate" }, // 完整的 Label Studio 原生界面配置 interfaces: [ // 核心面板 "panel", // 导航面板(undo/redo/reset) "update", // 更新按钮 "submit", // 提交按钮 "controls", // 控制面板 // 侧边栏(包含 Outliner 和 Details) "side-column", // 标注管理 "annotations:tabs", "annotations:menu", "annotations:current", "annotations:add-new", "annotations:delete", "annotations:view-all", // 预测 "predictions:tabs", "predictions:menu", // 其他 "auto-annotation", "edit-history", ], selectedAnnotationIndex: 0, allowCreateEmptyAnnotation: true, }); } catch (e) { console.error(e); message.error("加载编辑器失败"); } finally { if (seq === initSeqRef.current) setLoadingTaskDetail(false); } }, [iframeReady, message, postToIframe, project, projectId]); const saveFromExport = useCallback(async (payload?: ExportPayload | null) => { const payloadTaskId = payload?.taskId; if (expectedTaskIdRef.current && payloadTaskId) { if (Number(payloadTaskId) !== expectedTaskIdRef.current) { message.warning("已忽略过期的保存请求"); return false; } } const fileId = payload?.fileId || selectedFileId; const annotation = payload?.annotation; if (!fileId || !annotation || typeof annotation !== "object") { message.error("导出标注失败:缺少 fileId/annotation"); return false; } const payloadSegmentIndex = resolveSegmentIndex(payload?.segmentIndex); const segmentIndex = payloadSegmentIndex !== undefined ? payloadSegmentIndex : segmented ? currentSegmentIndex : undefined; setSaving(true); try { await upsertEditorAnnotationUsingPut(projectId, String(fileId), { annotation, segmentIndex, }); message.success("标注已保存"); await loadTasks(true); const snapshotKey = buildSnapshotKey(String(fileId), segmentIndex); const snapshot = buildAnnotationSnapshot(isRecord(annotation) ? annotation : undefined); savedSnapshotsRef.current[snapshotKey] = snapshot; // 分段模式下更新当前段落的标注状态 if (segmented && segmentIndex !== undefined) { setSegments((prev) => prev.map((seg) => seg.idx === segmentIndex ? { ...seg, hasAnnotation: true } : seg ) ); } return true; } catch (e) { console.error(e); message.error("保存失败"); return false; } finally { setSaving(false); } }, [ currentSegmentIndex, loadTasks, message, projectId, segmented, selectedFileId, ]); const requestExportForCheck = useCallback(() => { if (!iframeReady || !lsReady) return Promise.resolve(undefined); if (exportCheckRef.current) { if (exportCheckRef.current.timer) { window.clearTimeout(exportCheckRef.current.timer); } exportCheckRef.current.resolve(undefined); exportCheckRef.current = null; } const requestId = `check_${Date.now()}_${++exportCheckSeqRef.current}`; return new Promise((resolve) => { const timer = window.setTimeout(() => { if (exportCheckRef.current?.requestId === requestId) { exportCheckRef.current = null; } resolve(undefined); }, 3000); exportCheckRef.current = { requestId, resolve, timer, }; postToIframe("LS_EXPORT_CHECK", { requestId }); }); }, [iframeReady, lsReady, postToIframe]); const confirmSaveBeforeSwitch = useCallback(() => { return new Promise((resolve) => { let resolved = false; let modalInstance: { destroy: () => void } | null = null; const settle = (decision: SwitchDecision) => { if (resolved) return; resolved = true; resolve(decision); }; const handleDiscard = () => { if (modalInstance) modalInstance.destroy(); settle("discard"); }; modalInstance = modal.confirm({ title: "当前段落有未保存标注", content: (
切换段落前请先保存当前标注。
), okText: "保存并切换", cancelText: "取消", onOk: () => settle("save"), onCancel: () => settle("cancel"), }); }); }, [modal]); const requestExport = () => { if (!selectedFileId) { message.warning("请先选择文件"); return; } postToIframe("LS_EXPORT", {}); }; // 段落切换处理 const handleSegmentChange = async (newIndex: number) => { if (newIndex === currentSegmentIndex) return; if (segmentSwitching || saving || loadingTaskDetail) return; if (!iframeReady || !lsReady) { message.warning("编辑器未就绪,无法切换段落"); return; } setSegmentSwitching(true); try { const payload = await requestExportForCheck(); if (!payload) { message.warning("无法读取当前标注,已取消切换"); return; } const payloadTaskId = payload.taskId; if (expectedTaskIdRef.current && payloadTaskId) { if (Number(payloadTaskId) !== expectedTaskIdRef.current) { message.warning("已忽略过期的标注数据"); return; } } const payloadFileId = payload.fileId || selectedFileId; const payloadSegmentIndex = resolveSegmentIndex(payload.segmentIndex); const resolvedSegmentIndex = payloadSegmentIndex !== undefined ? payloadSegmentIndex : segmented ? currentSegmentIndex : undefined; const annotation = isRecord(payload.annotation) ? payload.annotation : undefined; const snapshotKey = payloadFileId ? buildSnapshotKey(String(payloadFileId), resolvedSegmentIndex) : undefined; const latestSnapshot = buildAnnotationSnapshot(annotation); const lastSnapshot = snapshotKey ? savedSnapshotsRef.current[snapshotKey] : undefined; const hasUnsavedChange = snapshotKey !== undefined && lastSnapshot !== undefined && latestSnapshot !== lastSnapshot; if (hasUnsavedChange) { if (autoSaveOnSwitch) { const saved = await saveFromExport(payload); if (!saved) return; } else { const decision = await confirmSaveBeforeSwitch(); if (decision === "cancel") return; if (decision === "save") { const saved = await saveFromExport(payload); if (!saved) return; } } } await initEditorForFile(selectedFileId, newIndex); } finally { setSegmentSwitching(false); } }; useEffect(() => { setIframeReady(false); setProject(null); setTasks([]); setSelectedFileId(""); initSeqRef.current = 0; setLsReady(false); expectedTaskIdRef.current = null; // 重置分段状态 setSegmented(false); setSegments([]); setCurrentSegmentIndex(0); savedSnapshotsRef.current = {}; if (exportCheckRef.current?.timer) { window.clearTimeout(exportCheckRef.current.timer); } exportCheckRef.current = null; if (projectId) loadProject(); }, [projectId, loadProject]); useEffect(() => { if (!project?.supported) return; loadTasks(); }, [project?.supported, loadTasks]); useEffect(() => { if (!selectedFileId) return; initEditorForFile(selectedFileId); }, [selectedFileId, iframeReady, initEditorForFile]); useEffect(() => { const handler = (event: MessageEvent) => { if (event.origin !== origin) return; const msg = event.data || {}; if (!msg?.type) return; if (msg.type === "LS_IFRAME_READY") { setIframeReady(true); return; } const payload = normalizePayload(msg.payload); if (msg.type === "LS_READY") { const readyTaskId = payload?.taskId; if (expectedTaskIdRef.current && readyTaskId) { if (Number(readyTaskId) !== expectedTaskIdRef.current) return; } setLsReady(true); return; } if (msg.type === "LS_EXPORT_RESULT") { saveFromExport(payload); return; } if (msg.type === "LS_EXPORT_CHECK_RESULT") { const pending = exportCheckRef.current; if (!pending) return; const requestId = payload?.requestId; if (requestId && requestId !== pending.requestId) return; if (pending.timer) { window.clearTimeout(pending.timer); } exportCheckRef.current = null; pending.resolve(payload); return; } // 兼容 iframe 内部在 submit 时直接上报(若启用) if (msg.type === "LS_SUBMIT") { saveFromExport(payload); return; } if (msg.type === "LS_ERROR") { const payloadMessage = resolvePayloadMessage(msg.payload); message.error(payloadMessage || "编辑器发生错误"); setLsReady(false); } }; window.addEventListener("message", handler); return () => window.removeEventListener("message", handler); }, [message, origin, saveFromExport]); if (loadingProject) { return (
); } if (!project) { return (
未找到标注项目
); } if (!project.supported) { return (
暂不支持该数据类型 {project.unsupportedReason || "当前仅支持文本(TEXT)项目的内嵌编辑器。"}
); } return (
{/* 顶部工具栏 */}
{/* 主体区域 */}
{/* 左侧文件列表 - 可折叠 */}
文件列表
( setSelectedFileId(item.fileId)} >
{item.fileName}
{item.hasAnnotation ? "已标注" : "未标注"} {item.annotationUpdatedAt && ( {item.annotationUpdatedAt} )}
)} />
{/* 右侧编辑器 - Label Studio iframe */}
{/* 段落导航栏 */} {segmented && segments.length > 0 && (
段落:
{segments.map((seg) => ( ))}
{currentSegmentIndex + 1} / {segments.length} 切段自动保存 setAutoSaveOnSwitch(checked)} disabled={segmentSwitching || saving || loadingTaskDetail || !lsReady} />
)} {/* 编辑器区域 */}
{(!iframeReady || loadingTaskDetail || (selectedFileId && !lsReady)) && (
)}