import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { App, Button, Card, List, Spin, Typography, Tag, Empty } from "antd"; import { LeftOutlined, ReloadOutlined, SaveOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons"; import { useNavigate, useParams } from "react-router"; import { getEditorProjectInfoUsingGet, getEditorTaskUsingGet, listEditorTasksUsingGet, upsertEditorAnnotationUsingPut, } from "../annotation.api"; import { AnnotationResultStatus } from "../annotation.model"; type EditorProjectInfo = { projectId: string; datasetId: string; datasetType?: string | null; 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; annotationStatus?: AnnotationResultStatus | null; }; type LsfMessage = { type?: string; payload?: unknown; }; type ApiResponse = { code?: number; message?: string; data?: T; }; type EditorTaskPayload = { id?: number | string; data?: Record; annotations?: unknown[]; }; type EditorTaskResponse = { task?: EditorTaskPayload; segmented?: boolean; totalSegments?: number; currentSegmentIndex?: number; }; type EditorTaskListResponse = { content?: EditorTaskListItem[]; totalElements?: number; totalPages?: number; page?: number; size?: number; }; type UpsertAnnotationResponse = { annotationId?: string; updatedAt?: string; }; type ExportPayload = { taskId?: number | string | null; fileId?: string | null; segmentIndex?: number | string | null; annotation?: Record; requestId?: string | null; }; const LSF_IFRAME_SRC = "/lsf/lsf.html"; const TASK_PAGE_START = 0; const TASK_PAGE_SIZE = 200; const NO_ANNOTATION_LABEL = "无标注"; const NOT_APPLICABLE_LABEL = "不适用"; const NO_ANNOTATION_CONFIRM_TITLE = "没有标注任何内容"; const NO_ANNOTATION_CONFIRM_OK_TEXT = "设为无标注并保存"; const NOT_APPLICABLE_CONFIRM_TEXT = "设为不适用并保存"; const NO_ANNOTATION_CONFIRM_CANCEL_TEXT = "继续标注"; const SAVE_AND_NEXT_LABEL = "保存并跳转到下一段/下一条"; type NormalizedTaskList = { items: EditorTaskListItem[]; page: number; size: number; total: number; totalPages: number; }; const resolveSegmentIndex = (value: unknown) => { if (value === null || value === undefined) return undefined; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; }; const isSaveShortcut = (event: KeyboardEvent) => { if (event.defaultPrevented || event.isComposing) return false; const key = event.key; const code = event.code; const isS = key === "s" || key === "S" || code === "KeyS"; if (!isS) return false; if (!(event.ctrlKey || event.metaKey)) return false; if (event.shiftKey || event.altKey) return false; return true; }; 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 isAnnotationResultEmpty = (annotation?: Record) => { if (!annotation) return true; if (!("result" in annotation)) return true; const result = (annotation as { result?: unknown }).result; if (!Array.isArray(result)) return false; return result.length === 0; }; const resolveTaskStatusMeta = (item: EditorTaskListItem) => { if (!item.hasAnnotation) { return { text: "未标注", type: "secondary" as const }; } if (item.annotationStatus === AnnotationResultStatus.NO_ANNOTATION) { return { text: NO_ANNOTATION_LABEL, type: "warning" as const }; } if (item.annotationStatus === AnnotationResultStatus.NOT_APPLICABLE) { return { text: NOT_APPLICABLE_LABEL, type: "warning" as const }; } if (item.annotationStatus === AnnotationResultStatus.IN_PROGRESS) { return { text: "标注中", type: "warning" as const }; } return { text: "已标注", type: "success" as const }; }; 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 ""; if (isAnnotationResultEmpty(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"}`; const mergeTaskItems = (base: EditorTaskListItem[], next: EditorTaskListItem[]) => { if (next.length === 0) return base; const seen = new Set(base.map((item) => item.fileId)); const merged = [...base]; next.forEach((item) => { if (seen.has(item.fileId)) return; seen.add(item.fileId); merged.push(item); }); return merged; }; const normalizeTaskListResponse = ( response: ApiResponse | null | undefined, fallbackPage: number, ): NormalizedTaskList => { const content = response?.data?.content; const items = Array.isArray(content) ? content : []; const size = response?.data?.size ?? TASK_PAGE_SIZE; const total = response?.data?.totalElements ?? items.length; const totalPages = response?.data?.totalPages ?? (size > 0 ? Math.ceil(total / size) : 0); const page = response?.data?.page ?? fallbackPage; return { items, page, size, total, totalPages, }; }; 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 prefetchSeqRef = useRef(0); const exportCheckRef = useRef<{ requestId: string; resolve: (payload?: ExportPayload) => void; timer?: number; } | null>(null); const savedSnapshotsRef = useRef>({}); const pendingAutoAdvanceRef = useRef(false); const [loadingProject, setLoadingProject] = useState(true); const [loadingTasks, setLoadingTasks] = useState(false); const [loadingTaskDetail, setLoadingTaskDetail] = useState(false); const [saving, setSaving] = useState(false); const [iframeReady, setIframeReady] = useState(false); const [lsReady, setLsReady] = useState(false); const [project, setProject] = useState(null); const [tasks, setTasks] = useState([]); const [taskPage, setTaskPage] = useState(TASK_PAGE_START); const [taskTotal, setTaskTotal] = useState(0); const [taskTotalPages, setTaskTotalPages] = useState(0); const [loadingMore, setLoadingMore] = useState(false); const [prefetching, setPrefetching] = useState(false); const [selectedFileId, setSelectedFileId] = useState(""); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); // 分段相关状态 const [segmented, setSegmented] = useState(false); const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0); const [segmentTotal, setSegmentTotal] = useState(0); const isTextProject = useMemo( () => (project?.datasetType || "").toUpperCase() === "TEXT", [project?.datasetType], ); const focusIframe = useCallback(() => { const iframe = iframeRef.current; if (!iframe) return; iframe.focus(); iframe.contentWindow?.focus?.(); }, []); const postToIframe = useCallback((type: string, payload?: unknown) => { const win = iframeRef.current?.contentWindow; if (!win) return; win.postMessage({ type, payload }, origin); }, [origin]); const confirmEmptyAnnotationStatus = useCallback(() => { return new Promise((resolve) => { let resolved = false; let modalInstance: { destroy: () => void } | null = null; const settle = (value: AnnotationResultStatus | null) => { if (resolved) return; resolved = true; resolve(value); if (modalInstance) modalInstance.destroy(); }; const handleNotApplicable = () => settle(AnnotationResultStatus.NOT_APPLICABLE); modalInstance = modal.confirm({ title: NO_ANNOTATION_CONFIRM_TITLE, content: (
当前未发现任何标注内容。 如确认为无标注或不适用,可继续保存。
), okText: NO_ANNOTATION_CONFIRM_OK_TEXT, cancelText: NO_ANNOTATION_CONFIRM_CANCEL_TEXT, onOk: () => settle(AnnotationResultStatus.NO_ANNOTATION), onCancel: () => settle(null), }); }); }, [modal]); 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 updateTaskSelection = useCallback((items: EditorTaskListItem[]) => { const isCompleted = (item: EditorTaskListItem) => { return item.hasAnnotation; }; const defaultFileId = items.find((item) => !isCompleted(item))?.fileId || items[0]?.fileId || ""; setSelectedFileId((prev) => { if (prev && items.some((item) => item.fileId === prev)) return prev; return defaultFileId; }); }, []); const startPrefetchTasks = useCallback((startPage: number, totalPages: number) => { if (!projectId) return; if (startPage >= totalPages) { setPrefetching(false); return; } const seq = ++prefetchSeqRef.current; setPrefetching(true); const run = async () => { for (let page = startPage; page < totalPages; page += 1) { if (prefetchSeqRef.current !== seq) return; try { const params = { page, size: TASK_PAGE_SIZE, ...(isTextProject ? { excludeSourceDocuments: true } : {}), }; const resp = (await listEditorTasksUsingGet(projectId, { ...params, })) as ApiResponse; const normalized = normalizeTaskListResponse(resp, page); setTasks((prev) => mergeTaskItems(prev, normalized.items)); setTaskPage((prev) => Math.max(prev, normalized.page)); setTaskTotal(normalized.total); setTaskTotalPages(normalized.totalPages); } catch (e) { console.error(e); break; } } if (prefetchSeqRef.current === seq) { setPrefetching(false); } }; void run(); }, [isTextProject, projectId]); const loadTasks = useCallback(async (options?: { mode?: "reset" | "append"; silent?: boolean; }) => { if (!projectId) return; const mode = options?.mode ?? "reset"; const silent = options?.silent ?? false; if (mode === "append" && taskTotalPages > 0 && taskPage + 1 >= taskTotalPages) { return; } if (mode === "reset") { prefetchSeqRef.current += 1; setPrefetching(false); } if (mode === "append") { setLoadingMore(true); } else if (!silent) { setLoadingTasks(true); } try { const nextPage = mode === "append" ? taskPage + 1 : TASK_PAGE_START; const params = { page: nextPage, size: TASK_PAGE_SIZE, ...(isTextProject ? { excludeSourceDocuments: true } : {}), }; const resp = (await listEditorTasksUsingGet(projectId, { ...params, })) as ApiResponse; const normalized = normalizeTaskListResponse(resp, nextPage); if (mode === "append") { setTasks((prev) => mergeTaskItems(prev, normalized.items)); setTaskPage((prev) => Math.max(prev, normalized.page)); } else { setTasks(normalized.items); setTaskPage(normalized.page); updateTaskSelection(normalized.items); } setTaskTotal(normalized.total); setTaskTotalPages(normalized.totalPages); startPrefetchTasks(normalized.page + 1, normalized.totalPages); } catch (e) { console.error(e); if (!silent && mode !== "append") message.error("获取文件列表失败"); if (mode === "reset") { setTasks([]); setTaskPage(TASK_PAGE_START); setTaskTotal(0); setTaskTotalPages(0); } } finally { if (mode === "append") { setLoadingMore(false); } else if (!silent) { setLoadingTasks(false); } } }, [ isTextProject, message, projectId, startPrefetchTasks, taskPage, taskTotalPages, updateTaskSelection, ]); 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 isSegmented = !!data?.segmented; const segmentIndex = isSegmented ? resolveSegmentIndex(data.currentSegmentIndex) ?? 0 : undefined; if (isSegmented) { setSegmented(true); setCurrentSegmentIndex(segmentIndex ?? 0); const totalSegments = Number(data?.totalSegments ?? 0); setSegmentTotal(Number.isFinite(totalSegments) && totalSegments > 0 ? totalSegments : 0); } else { setSegmented(false); setCurrentSegmentIndex(0); setSegmentTotal(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 advanceAfterSave = useCallback(async (fileId: string, segmentIndex?: number) => { if (!fileId) return; if (segmented && segmentTotal > 0) { const baseIndex = Math.max(segmentIndex ?? currentSegmentIndex, 0); const nextSegmentIndex = baseIndex + 1; if (nextSegmentIndex < segmentTotal) { await initEditorForFile(fileId, nextSegmentIndex); return; } } if (tasks.length === 0) { message.info("暂无可跳转的数据"); return; } const currentFileIndex = tasks.findIndex((item) => item.fileId === fileId); const nextTask = currentFileIndex >= 0 ? tasks[currentFileIndex + 1] : tasks[0]; if (nextTask?.fileId) { setSelectedFileId(nextTask.fileId); return; } message.info("已是最后一个数据"); }, [ currentSegmentIndex, initEditorForFile, message, segmented, segmentTotal, tasks, ]); const saveFromExport = useCallback(async ( payload?: ExportPayload | null, options?: { autoAdvance?: boolean } ) => { 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; const annotationRecord = annotation as Record; const currentTask = tasks.find((item) => item.fileId === String(fileId)); const currentStatus = currentTask?.annotationStatus; let resolvedStatus: AnnotationResultStatus; if (isAnnotationResultEmpty(annotationRecord)) { if ( currentStatus === AnnotationResultStatus.NO_ANNOTATION || currentStatus === AnnotationResultStatus.NOT_APPLICABLE ) { resolvedStatus = currentStatus; } else { const selectedStatus = await confirmEmptyAnnotationStatus(); if (!selectedStatus) return false; resolvedStatus = selectedStatus; } } else { resolvedStatus = AnnotationResultStatus.ANNOTATED; } setSaving(true); try { const resp = (await upsertEditorAnnotationUsingPut(projectId, String(fileId), { annotation, segmentIndex, annotationStatus: resolvedStatus, })) as ApiResponse; const updatedAt = resp?.data?.updatedAt; message.success("标注已保存"); setTasks((prev) => prev.map((item) => item.fileId === String(fileId) ? { ...item, hasAnnotation: true, annotationStatus: resolvedStatus, annotationUpdatedAt: updatedAt || item.annotationUpdatedAt, } : item ) ); const snapshotKey = buildSnapshotKey(String(fileId), segmentIndex); const snapshot = buildAnnotationSnapshot(isRecord(annotation) ? annotation : undefined); savedSnapshotsRef.current[snapshotKey] = snapshot; if (options?.autoAdvance) { await advanceAfterSave(String(fileId), segmentIndex); } return true; } catch (e) { console.error(e); message.error("保存失败"); return false; } finally { setSaving(false); } }, [ advanceAfterSave, confirmEmptyAnnotationStatus, currentSegmentIndex, message, projectId, segmented, selectedFileId, tasks, ]); const requestExport = useCallback((autoAdvance: boolean) => { if (!selectedFileId) { message.warning("请先选择文件"); return; } pendingAutoAdvanceRef.current = autoAdvance; postToIframe("LS_EXPORT", {}); }, [message, postToIframe, selectedFileId]); useEffect(() => { const handleSaveShortcut = (event: KeyboardEvent) => { if (!isSaveShortcut(event) || event.repeat) return; if (saving || loadingTaskDetail) return; if (!iframeReady || !lsReady) return; event.preventDefault(); event.stopPropagation(); requestExport(false); }; window.addEventListener("keydown", handleSaveShortcut); return () => window.removeEventListener("keydown", handleSaveShortcut); }, [iframeReady, loadingTaskDetail, lsReady, requestExport, saving]); useEffect(() => { setIframeReady(false); setProject(null); setTasks([]); setTaskPage(TASK_PAGE_START); setTaskTotal(0); setTaskTotalPages(0); setLoadingMore(false); prefetchSeqRef.current += 1; setPrefetching(false); setSelectedFileId(""); initSeqRef.current = 0; setLsReady(false); expectedTaskIdRef.current = null; // 重置分段状态 setSegmented(false); setCurrentSegmentIndex(0); setSegmentTotal(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({ mode: "reset" }); }, [project?.supported, loadTasks]); useEffect(() => { if (!selectedFileId) return; initEditorForFile(selectedFileId); }, [selectedFileId, iframeReady, initEditorForFile]); useEffect(() => { if (!iframeReady) return; focusIframe(); }, [focusIframe, iframeReady]); useEffect(() => { if (!lsReady) return; focusIframe(); }, [focusIframe, lsReady]); useEffect(() => { if (!lsReady) return; const handleWindowFocus = () => { focusIframe(); }; window.addEventListener("focus", handleWindowFocus); return () => window.removeEventListener("focus", handleWindowFocus); }, [focusIframe, lsReady]); 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") { const shouldAutoAdvance = pendingAutoAdvanceRef.current; pendingAutoAdvanceRef.current = false; saveFromExport(payload, { autoAdvance: shouldAutoAdvance }); return; } if (msg.type === "LS_SAVE_AND_NEXT") { pendingAutoAdvanceRef.current = false; saveFromExport(payload, { autoAdvance: true }); 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, { autoAdvance: false }); return; } if (msg.type === "LS_ERROR") { const payloadMessage = resolvePayloadMessage(msg.payload); message.error(payloadMessage || "编辑器发生错误"); setLsReady(false); pendingAutoAdvanceRef.current = false; } }; window.addEventListener("message", handler); return () => window.removeEventListener("message", handler); }, [message, origin, saveFromExport]); const canLoadMore = taskTotalPages > 0 && taskPage + 1 < taskTotalPages; const saveDisabled = !iframeReady || !selectedFileId || saving || loadingTaskDetail; const loadMoreNode = canLoadMore ? (
{prefetching && ( 后台加载中... )} {taskTotal > 0 && ( 已加载 {tasks.length}/{taskTotal} )}
) : null; if (loadingProject) { return (
); } if (!project) { return (
未找到标注项目
); } if (!project.supported) { return (
暂不支持该数据类型 {project.unsupportedReason || "当前仅支持 TEXT/IMAGE 项目的内嵌编辑器。"}
); } return (
{/* 顶部工具栏 */}
{/* 主体区域 */}
{/* 左侧文件列表 - 可折叠 */}
文件列表
{ const statusMeta = resolveTaskStatusMeta(item); return ( setSelectedFileId(item.fileId)} >
{item.fileName}
{statusMeta.text}
{item.annotationUpdatedAt && ( {item.annotationUpdatedAt} )}
); }} />
{segmented && (
段落/分段 {segmentTotal > 0 ? currentSegmentIndex + 1 : 0} / {segmentTotal}
{segmentTotal > 0 ? (
分段列表已关闭,请使用“保存并跳转到下一段/下一条”顺序标注。
) : (
)}
)}
{/* 右侧编辑器 - Label Studio iframe */}
{/* 编辑器区域 */}
{(!iframeReady || loadingTaskDetail || (selectedFileId && !lsReady)) && (
)}