You've already forked DataMate
feat(annotation): 添加标注检查和段落切换保护功能
- 在LSF中实现LS_EXPORT_CHECK消息处理以获取当前标注状态 - 添加requestId支持用于标注导出请求的追踪 - 实现稳定字符串化算法用于标注快照比较 - 添加段落切换前的未保存更改检测和确认对话框 - 集成标注快
This commit is contained in:
@@ -314,6 +314,17 @@
|
|||||||
return;
|
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") {
|
if (msg.type === "LS_RESET") {
|
||||||
destroyLabelStudio();
|
destroyLabelStudio();
|
||||||
postToParent("LS_RESET_DONE", {});
|
postToParent("LS_RESET_DONE", {});
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ type ExportPayload = {
|
|||||||
fileId?: string | null;
|
fileId?: string | null;
|
||||||
segmentIndex?: number | string | null;
|
segmentIndex?: number | string | null;
|
||||||
annotation?: Record<string, unknown>;
|
annotation?: Record<string, unknown>;
|
||||||
|
requestId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LSF_IFRAME_SRC = "/lsf/lsf.html";
|
const LSF_IFRAME_SRC = "/lsf/lsf.html";
|
||||||
@@ -91,20 +92,67 @@ const resolvePayloadMessage = (payload: unknown) => {
|
|||||||
return undefined;
|
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() {
|
export default function LabelStudioTextEditor() {
|
||||||
const { projectId = "" } = useParams();
|
const { projectId = "" } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { message } = App.useApp();
|
const { message, modal } = App.useApp();
|
||||||
|
|
||||||
const origin = useMemo(() => window.location.origin, []);
|
const origin = useMemo(() => window.location.origin, []);
|
||||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||||
const initSeqRef = useRef(0);
|
const initSeqRef = useRef(0);
|
||||||
const expectedTaskIdRef = useRef<number | null>(null);
|
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 [loadingProject, setLoadingProject] = useState(true);
|
||||||
const [loadingTasks, setLoadingTasks] = useState(false);
|
const [loadingTasks, setLoadingTasks] = useState(false);
|
||||||
const [loadingTaskDetail, setLoadingTaskDetail] = useState(false);
|
const [loadingTaskDetail, setLoadingTaskDetail] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [segmentSwitching, setSegmentSwitching] = useState(false);
|
||||||
|
|
||||||
const [iframeReady, setIframeReady] = useState(false);
|
const [iframeReady, setIframeReady] = useState(false);
|
||||||
const [lsReady, setLsReady] = useState(false);
|
const [lsReady, setLsReady] = useState(false);
|
||||||
@@ -193,10 +241,13 @@ export default function LabelStudioTextEditor() {
|
|||||||
if (seq !== initSeqRef.current) return;
|
if (seq !== initSeqRef.current) return;
|
||||||
|
|
||||||
// 更新分段状态
|
// 更新分段状态
|
||||||
|
const segmentIndex = data?.segmented
|
||||||
|
? resolveSegmentIndex(data.currentSegmentIndex) ?? 0
|
||||||
|
: undefined;
|
||||||
if (data?.segmented) {
|
if (data?.segmented) {
|
||||||
setSegmented(true);
|
setSegmented(true);
|
||||||
setSegments(data.segments || []);
|
setSegments(data.segments || []);
|
||||||
setCurrentSegmentIndex(data.currentSegmentIndex || 0);
|
setCurrentSegmentIndex(segmentIndex ?? 0);
|
||||||
} else {
|
} else {
|
||||||
setSegmented(false);
|
setSegmented(false);
|
||||||
setSegments([]);
|
setSegments([]);
|
||||||
@@ -209,15 +260,20 @@ export default function LabelStudioTextEditor() {
|
|||||||
fileId: fileId,
|
fileId: fileId,
|
||||||
};
|
};
|
||||||
if (data?.segmented) {
|
if (data?.segmented) {
|
||||||
const segmentIndex = resolveSegmentIndex(data.currentSegmentIndex) ?? 0;
|
const normalizedIndex = segmentIndex ?? 0;
|
||||||
taskData.segment_index = segmentIndex;
|
taskData.segment_index = normalizedIndex;
|
||||||
taskData.segmentIndex = segmentIndex;
|
taskData.segmentIndex = normalizedIndex;
|
||||||
}
|
}
|
||||||
const taskForIframe = {
|
const taskForIframe = {
|
||||||
...task,
|
...task,
|
||||||
data: taskData,
|
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;
|
expectedTaskIdRef.current = Number(taskForIframe?.id) || null;
|
||||||
postToIframe("LS_INIT", {
|
postToIframe("LS_INIT", {
|
||||||
labelConfig: project.labelConfig,
|
labelConfig: project.labelConfig,
|
||||||
@@ -262,14 +318,14 @@ export default function LabelStudioTextEditor() {
|
|||||||
if (expectedTaskIdRef.current && payloadTaskId) {
|
if (expectedTaskIdRef.current && payloadTaskId) {
|
||||||
if (Number(payloadTaskId) !== expectedTaskIdRef.current) {
|
if (Number(payloadTaskId) !== expectedTaskIdRef.current) {
|
||||||
message.warning("已忽略过期的保存请求");
|
message.warning("已忽略过期的保存请求");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const fileId = payload?.fileId || selectedFileId;
|
const fileId = payload?.fileId || selectedFileId;
|
||||||
const annotation = payload?.annotation;
|
const annotation = payload?.annotation;
|
||||||
if (!fileId || !annotation || typeof annotation !== "object") {
|
if (!fileId || !annotation || typeof annotation !== "object") {
|
||||||
message.error("导出标注失败:缺少 fileId/annotation");
|
message.error("导出标注失败:缺少 fileId/annotation");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
const payloadSegmentIndex = resolveSegmentIndex(payload?.segmentIndex);
|
const payloadSegmentIndex = resolveSegmentIndex(payload?.segmentIndex);
|
||||||
const segmentIndex =
|
const segmentIndex =
|
||||||
@@ -288,6 +344,10 @@ export default function LabelStudioTextEditor() {
|
|||||||
message.success("标注已保存");
|
message.success("标注已保存");
|
||||||
await loadTasks(true);
|
await loadTasks(true);
|
||||||
|
|
||||||
|
const snapshotKey = buildSnapshotKey(String(fileId), segmentIndex);
|
||||||
|
const snapshot = buildAnnotationSnapshot(isRecord(annotation) ? annotation : undefined);
|
||||||
|
savedSnapshotsRef.current[snapshotKey] = snapshot;
|
||||||
|
|
||||||
// 分段模式下更新当前段落的标注状态
|
// 分段模式下更新当前段落的标注状态
|
||||||
if (segmented && segmentIndex !== undefined) {
|
if (segmented && segmentIndex !== undefined) {
|
||||||
setSegments((prev) =>
|
setSegments((prev) =>
|
||||||
@@ -298,9 +358,11 @@ export default function LabelStudioTextEditor() {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
message.error("保存失败");
|
message.error("保存失败");
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -313,6 +375,45 @@ export default function LabelStudioTextEditor() {
|
|||||||
selectedFileId,
|
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 = () => {
|
const requestExport = () => {
|
||||||
if (!selectedFileId) {
|
if (!selectedFileId) {
|
||||||
message.warning("请先选择文件");
|
message.warning("请先选择文件");
|
||||||
@@ -324,8 +425,55 @@ export default function LabelStudioTextEditor() {
|
|||||||
// 段落切换处理
|
// 段落切换处理
|
||||||
const handleSegmentChange = async (newIndex: number) => {
|
const handleSegmentChange = async (newIndex: number) => {
|
||||||
if (newIndex === currentSegmentIndex) return;
|
if (newIndex === currentSegmentIndex) return;
|
||||||
setCurrentSegmentIndex(newIndex);
|
if (segmentSwitching || saving || loadingTaskDetail) return;
|
||||||
await initEditorForFile(selectedFileId, newIndex);
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -340,6 +488,11 @@ export default function LabelStudioTextEditor() {
|
|||||||
setSegmented(false);
|
setSegmented(false);
|
||||||
setSegments([]);
|
setSegments([]);
|
||||||
setCurrentSegmentIndex(0);
|
setCurrentSegmentIndex(0);
|
||||||
|
savedSnapshotsRef.current = {};
|
||||||
|
if (exportCheckRef.current?.timer) {
|
||||||
|
window.clearTimeout(exportCheckRef.current.timer);
|
||||||
|
}
|
||||||
|
exportCheckRef.current = null;
|
||||||
|
|
||||||
if (projectId) loadProject();
|
if (projectId) loadProject();
|
||||||
}, [projectId, loadProject]);
|
}, [projectId, loadProject]);
|
||||||
@@ -381,6 +534,19 @@ export default function LabelStudioTextEditor() {
|
|||||||
return;
|
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 时直接上报(若启用)
|
// 兼容 iframe 内部在 submit 时直接上报(若启用)
|
||||||
if (msg.type === "LS_SUBMIT") {
|
if (msg.type === "LS_SUBMIT") {
|
||||||
saveFromExport(payload);
|
saveFromExport(payload);
|
||||||
@@ -531,6 +697,7 @@ export default function LabelStudioTextEditor() {
|
|||||||
size="small"
|
size="small"
|
||||||
type={seg.idx === currentSegmentIndex ? "primary" : "default"}
|
type={seg.idx === currentSegmentIndex ? "primary" : "default"}
|
||||||
onClick={() => handleSegmentChange(seg.idx)}
|
onClick={() => handleSegmentChange(seg.idx)}
|
||||||
|
disabled={segmentSwitching || saving || loadingTaskDetail || !lsReady}
|
||||||
style={{ minWidth: 32, padding: "0 8px" }}
|
style={{ minWidth: 32, padding: "0 8px" }}
|
||||||
>
|
>
|
||||||
{seg.idx + 1}
|
{seg.idx + 1}
|
||||||
|
|||||||
Reference in New Issue
Block a user