import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { App, Button, Card, List, Spin, Typography, Tag, Switch, Tree, Empty } 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; 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; }; type LsfMessage = { type?: string; payload?: unknown; }; type SegmentInfo = { idx: number; text: string; start: number; end: number; hasAnnotation: boolean; lineIndex: number; chunkIndex: number; }; 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[]; 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; }; type SwitchDecision = "save" | "discard" | "cancel"; const LSF_IFRAME_SRC = "/lsf/lsf.html"; const TASK_PAGE_START = 0; const TASK_PAGE_SIZE = 200; 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 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"}`; 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 exportCheckSeqRef = useRef(0); 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 [segmentSwitching, setSegmentSwitching] = 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 [autoSaveOnSwitch, setAutoSaveOnSwitch] = useState(false); // 分段相关状态 const [segmented, setSegmented] = useState(false); const [segments, setSegments] = useState([]); const [currentSegmentIndex, setCurrentSegmentIndex] = 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 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 defaultFileId = items.find((item) => !item.hasAnnotation)?.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 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 advanceAfterSave = useCallback(async (fileId: string, segmentIndex?: number) => { if (!fileId) return; if (segmented && segments.length > 0) { const sortedSegmentIndices = segments .map((seg) => seg.idx) .sort((a, b) => a - b); const baseIndex = segmentIndex ?? currentSegmentIndex; const currentPos = sortedSegmentIndices.indexOf(baseIndex); const nextSegmentIndex = currentPos >= 0 ? sortedSegmentIndices[currentPos + 1] : sortedSegmentIndices[0]; if (nextSegmentIndex !== undefined) { 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, segments, 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; setSaving(true); try { const resp = (await upsertEditorAnnotationUsingPut(projectId, String(fileId), { annotation, segmentIndex, })) as ApiResponse; const updatedAt = resp?.data?.updatedAt; message.success("标注已保存"); setTasks((prev) => prev.map((item) => item.fileId === String(fileId) ? { ...item, hasAnnotation: true, annotationUpdatedAt: updatedAt || item.annotationUpdatedAt, } : item ) ); 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 ) ); } if (options?.autoAdvance) { await advanceAfterSave(String(fileId), segmentIndex); } return true; } catch (e) { console.error(e); message.error("保存失败"); return false; } finally { setSaving(false); } }, [ advanceAfterSave, currentSegmentIndex, 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; } pendingAutoAdvanceRef.current = true; postToIframe("LS_EXPORT", {}); }; // 段落切换处理 const handleSegmentChange = useCallback(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); } }, [ autoSaveOnSwitch, confirmSaveBeforeSwitch, currentSegmentIndex, iframeReady, initEditorForFile, loadingTaskDetail, lsReady, message, requestExportForCheck, saveFromExport, segmented, selectedFileId, segmentSwitching, 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); 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({ 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]); const segmentTreeData = useMemo(() => { if (!segmented || segments.length === 0) return []; const lineMap = new Map(); segments.forEach((seg) => { const list = lineMap.get(seg.lineIndex) || []; list.push(seg); lineMap.set(seg.lineIndex, list); }); return Array.from(lineMap.entries()) .sort((a, b) => a[0] - b[0]) .map(([lineIndex, lineSegments]) => ({ key: `line-${lineIndex}`, title: `第${lineIndex + 1}行`, selectable: false, children: lineSegments .sort((a, b) => a.chunkIndex - b.chunkIndex) .map((seg) => ({ key: `seg-${seg.idx}`, title: ( {`片${seg.chunkIndex + 1}`} {seg.hasAnnotation && ( )} ), })), })); }, [segmented, segments]); const segmentLineKeys = useMemo( () => segmentTreeData.map((item) => String(item.key)), [segmentTreeData] ); const handleSegmentSelect = useCallback((keys: Array) => { const [first] = keys; if (first === undefined || first === null) return; const key = String(first); if (!key.startsWith("seg-")) return; const nextIndex = Number(key.replace("seg-", "")); if (!Number.isFinite(nextIndex)) return; handleSegmentChange(nextIndex); }, [handleSegmentChange]); 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_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 loadMoreNode = canLoadMore ? (
{prefetching && ( 后台加载中... )} {taskTotal > 0 && ( 已加载 {tasks.length}/{taskTotal} )}
) : null; if (loadingProject) { return (
); } if (!project) { return (
未找到标注项目
); } if (!project.supported) { return (
暂不支持该数据类型 {project.unsupportedReason || "当前仅支持 TEXT/IMAGE 项目的内嵌编辑器。"}
); } return (
{/* 顶部工具栏 */}
{/* 主体区域 */}
{/* 左侧文件列表 - 可折叠 */}
文件列表
( setSelectedFileId(item.fileId)} >
{item.fileName}
{item.hasAnnotation ? "已标注" : "未标注"} {item.annotationUpdatedAt && ( {item.annotationUpdatedAt} )}
)} />
{segmented && (
段落/分段 {currentSegmentIndex + 1} / {segments.length}
{segments.length > 0 ? ( ) : (
)}
切段自动保存 setAutoSaveOnSwitch(checked)} disabled={segmentSwitching || saving || loadingTaskDetail || !lsReady} />
)}
{/* 右侧编辑器 - Label Studio iframe */}
{/* 编辑器区域 */}
{(!iframeReady || loadingTaskDetail || (selectedFileId && !lsReady)) && (
)}