You've already forked DataMate
feat(annotation): 添加标注检查和段落切换保护功能
- 在LSF中实现LS_EXPORT_CHECK消息处理以获取当前标注状态 - 添加requestId支持用于标注导出请求的追踪 - 实现稳定字符串化算法用于标注快照比较 - 添加段落切换前的未保存更改检测和确认对话框 - 集成标注快
This commit is contained in:
@@ -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", {});
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user