feat(annotation): 添加标注检查和段落切换保护功能

- 在LSF中实现LS_EXPORT_CHECK消息处理以获取当前标注状态
- 添加requestId支持用于标注导出请求的追踪
- 实现稳定字符串化算法用于标注快照比较
- 添加段落切换前的未保存更改检测和确认对话框
- 集成标注快
This commit is contained in:
2026-01-22 17:29:21 +08:00
parent 1eee1e248e
commit 9c9d5ecbe2
2 changed files with 187 additions and 9 deletions

View File

@@ -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", {});

View File

@@ -68,6 +68,7 @@ type ExportPayload = {
fileId?: string | null;
segmentIndex?: number | string | null;
annotation?: Record<string, unknown>;
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<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);
const normalizeSnapshotValue = (value: unknown, seen: WeakSet<object>): 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<string, unknown>;
const sorted: Record<string, unknown> = {};
Object.keys(record)
.sort()
.forEach((key) => {
sorted[key] = normalizeSnapshotValue(record[key], seen);
});
return sorted;
};
const stableStringify = (value: unknown) => {
const normalized = normalizeSnapshotValue(value, new WeakSet<object>());
return JSON.stringify(normalized);
};
const buildAnnotationSnapshot = (annotation?: Record<string, unknown>) => {
if (!annotation) return "";
const cleaned: Record<string, unknown> = { ...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<HTMLIFrameElement | null>(null);
const initSeqRef = useRef(0);
const expectedTaskIdRef = useRef<number | null>(null);
const exportCheckRef = useRef<{
requestId: string;
resolve: (payload?: ExportPayload) => void;
timer?: number;
} | null>(null);
const exportCheckSeqRef = useRef(0);
const savedSnapshotsRef = useRef<Record<string, string>>({});
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<ExportPayload | undefined>((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<boolean>((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);
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}