diff --git a/frontend/public/lsf/lsf.html b/frontend/public/lsf/lsf.html index 91a6f7f..101ad8c 100644 --- a/frontend/public/lsf/lsf.html +++ b/frontend/public/lsf/lsf.html @@ -314,6 +314,17 @@ return; } + if (msg.type === "LS_EXPORT_CHECK") { + const raw = exportSelectedAnnotation(); + const requestId = + msg.payload && typeof msg.payload === "object" ? msg.payload.requestId : null; + if (requestId) { + raw.requestId = requestId; + } + postToParent("LS_EXPORT_CHECK_RESULT", raw); + return; + } + if (msg.type === "LS_RESET") { destroyLabelStudio(); postToParent("LS_RESET_DONE", {}); diff --git a/frontend/src/pages/DataAnnotation/Annotate/LabelStudioTextEditor.tsx b/frontend/src/pages/DataAnnotation/Annotate/LabelStudioTextEditor.tsx index b754d19..1a97e5c 100644 --- a/frontend/src/pages/DataAnnotation/Annotate/LabelStudioTextEditor.tsx +++ b/frontend/src/pages/DataAnnotation/Annotate/LabelStudioTextEditor.tsx @@ -68,6 +68,7 @@ type ExportPayload = { fileId?: string | null; segmentIndex?: number | string | null; annotation?: Record; + requestId?: string | null; }; const LSF_IFRAME_SRC = "/lsf/lsf.html"; @@ -91,20 +92,67 @@ const resolvePayloadMessage = (payload: unknown) => { 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 } = App.useApp(); + 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); @@ -193,10 +241,13 @@ export default function LabelStudioTextEditor() { if (seq !== initSeqRef.current) return; // 更新分段状态 + const segmentIndex = data?.segmented + ? resolveSegmentIndex(data.currentSegmentIndex) ?? 0 + : undefined; if (data?.segmented) { setSegmented(true); setSegments(data.segments || []); - setCurrentSegmentIndex(data.currentSegmentIndex || 0); + setCurrentSegmentIndex(segmentIndex ?? 0); } else { setSegmented(false); setSegments([]); @@ -209,15 +260,20 @@ export default function LabelStudioTextEditor() { fileId: fileId, }; if (data?.segmented) { - const segmentIndex = resolveSegmentIndex(data.currentSegmentIndex) ?? 0; - taskData.segment_index = segmentIndex; - taskData.segmentIndex = segmentIndex; + 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, @@ -262,14 +318,14 @@ export default function LabelStudioTextEditor() { if (expectedTaskIdRef.current && payloadTaskId) { if (Number(payloadTaskId) !== expectedTaskIdRef.current) { message.warning("已忽略过期的保存请求"); - return; + return false; } } const fileId = payload?.fileId || selectedFileId; const annotation = payload?.annotation; if (!fileId || !annotation || typeof annotation !== "object") { message.error("导出标注失败:缺少 fileId/annotation"); - return; + return false; } const payloadSegmentIndex = resolveSegmentIndex(payload?.segmentIndex); const segmentIndex = @@ -288,6 +344,10 @@ export default function LabelStudioTextEditor() { 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) => @@ -298,9 +358,11 @@ export default function LabelStudioTextEditor() { ) ); } + return true; } catch (e) { console.error(e); message.error("保存失败"); + return false; } finally { setSaving(false); } @@ -313,6 +375,45 @@ export default function LabelStudioTextEditor() { 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) => { + modal.confirm({ + title: "当前段落有未保存标注", + content: "切换段落前请先保存当前标注。", + okText: "保存并切换", + cancelText: "取消", + onOk: () => resolve(true), + onCancel: () => resolve(false), + }); + }); + }, [modal]); + const requestExport = () => { if (!selectedFileId) { message.warning("请先选择文件"); @@ -324,8 +425,55 @@ export default function LabelStudioTextEditor() { // 段落切换处理 const handleSegmentChange = async (newIndex: number) => { if (newIndex === currentSegmentIndex) return; - setCurrentSegmentIndex(newIndex); - await initEditorForFile(selectedFileId, newIndex); + 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) { + const shouldSave = await confirmSaveBeforeSwitch(); + if (!shouldSave) return; + const saved = await saveFromExport(payload); + if (!saved) return; + } + + await initEditorForFile(selectedFileId, newIndex); + } finally { + setSegmentSwitching(false); + } }; useEffect(() => { @@ -340,6 +488,11 @@ export default function LabelStudioTextEditor() { setSegmented(false); setSegments([]); setCurrentSegmentIndex(0); + savedSnapshotsRef.current = {}; + if (exportCheckRef.current?.timer) { + window.clearTimeout(exportCheckRef.current.timer); + } + exportCheckRef.current = null; if (projectId) loadProject(); }, [projectId, loadProject]); @@ -381,6 +534,19 @@ export default function LabelStudioTextEditor() { 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); @@ -531,6 +697,7 @@ export default function LabelStudioTextEditor() { size="small" type={seg.idx === currentSegmentIndex ? "primary" : "default"} onClick={() => handleSegmentChange(seg.idx)} + disabled={segmentSwitching || saving || loadingTaskDetail || !lsReady} style={{ minWidth: 32, padding: "0 8px" }} > {seg.idx + 1}