|
|
|
|
@@ -1,12 +1,11 @@
|
|
|
|
|
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 { App, Button, Card, List, Spin, Typography, Tag, Empty } from "antd";
|
|
|
|
|
import { LeftOutlined, ReloadOutlined, SaveOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
|
|
|
|
|
import { useNavigate, useParams } from "react-router";
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
getEditorProjectInfoUsingGet,
|
|
|
|
|
getEditorTaskUsingGet,
|
|
|
|
|
getEditorTaskSegmentsUsingGet,
|
|
|
|
|
listEditorTasksUsingGet,
|
|
|
|
|
upsertEditorAnnotationUsingPut,
|
|
|
|
|
} from "../annotation.api";
|
|
|
|
|
@@ -29,7 +28,6 @@ type EditorTaskListItem = {
|
|
|
|
|
hasAnnotation: boolean;
|
|
|
|
|
annotationUpdatedAt?: string | null;
|
|
|
|
|
annotationStatus?: AnnotationResultStatus | null;
|
|
|
|
|
segmentStats?: SegmentStats;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type LsfMessage = {
|
|
|
|
|
@@ -37,18 +35,6 @@ type LsfMessage = {
|
|
|
|
|
payload?: unknown;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type SegmentInfo = {
|
|
|
|
|
idx: number;
|
|
|
|
|
hasAnnotation: boolean;
|
|
|
|
|
lineIndex: number;
|
|
|
|
|
chunkIndex: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type SegmentStats = {
|
|
|
|
|
done: number;
|
|
|
|
|
total: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type ApiResponse<T> = {
|
|
|
|
|
code?: number;
|
|
|
|
|
message?: string;
|
|
|
|
|
@@ -68,11 +54,6 @@ type EditorTaskResponse = {
|
|
|
|
|
currentSegmentIndex?: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type EditorTaskSegmentsResponse = {
|
|
|
|
|
segmented?: boolean;
|
|
|
|
|
segments?: SegmentInfo[];
|
|
|
|
|
totalSegments?: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type EditorTaskListResponse = {
|
|
|
|
|
content?: EditorTaskListItem[];
|
|
|
|
|
@@ -95,8 +76,6 @@ type ExportPayload = {
|
|
|
|
|
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;
|
|
|
|
|
@@ -158,16 +137,6 @@ const isAnnotationResultEmpty = (annotation?: Record<string, unknown>) => {
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const resolveTaskStatusMeta = (item: EditorTaskListItem) => {
|
|
|
|
|
const segmentSummary = resolveSegmentSummary(item);
|
|
|
|
|
if (segmentSummary) {
|
|
|
|
|
if (segmentSummary.done >= segmentSummary.total) {
|
|
|
|
|
return { text: "已标注", type: "success" as const };
|
|
|
|
|
}
|
|
|
|
|
if (segmentSummary.done > 0) {
|
|
|
|
|
return { text: "标注中", type: "warning" as const };
|
|
|
|
|
}
|
|
|
|
|
return { text: "未标注", type: "secondary" as const };
|
|
|
|
|
}
|
|
|
|
|
if (!item.hasAnnotation) {
|
|
|
|
|
return { text: "未标注", type: "secondary" as const };
|
|
|
|
|
}
|
|
|
|
|
@@ -220,25 +189,6 @@ const buildAnnotationSnapshot = (annotation?: Record<string, unknown>) => {
|
|
|
|
|
const buildSnapshotKey = (fileId: string, segmentIndex?: number) =>
|
|
|
|
|
`${fileId}::${segmentIndex ?? "full"}`;
|
|
|
|
|
|
|
|
|
|
const buildSegmentStats = (segmentList?: SegmentInfo[] | null): SegmentStats | null => {
|
|
|
|
|
if (!Array.isArray(segmentList) || segmentList.length === 0) return null;
|
|
|
|
|
const total = segmentList.length;
|
|
|
|
|
const done = segmentList.reduce((count, seg) => count + (seg.hasAnnotation ? 1 : 0), 0);
|
|
|
|
|
return { done, total };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const normalizeSegmentStats = (stats?: SegmentStats | null): SegmentStats | null => {
|
|
|
|
|
if (!stats) return null;
|
|
|
|
|
const total = Number(stats.total);
|
|
|
|
|
const done = Number(stats.done);
|
|
|
|
|
if (!Number.isFinite(total) || total <= 0) return null;
|
|
|
|
|
const safeDone = Math.min(Math.max(done, 0), total);
|
|
|
|
|
return { done: safeDone, total };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const resolveSegmentSummary = (item: EditorTaskListItem) =>
|
|
|
|
|
normalizeSegmentStats(item.segmentStats);
|
|
|
|
|
|
|
|
|
|
const mergeTaskItems = (base: EditorTaskListItem[], next: EditorTaskListItem[]) => {
|
|
|
|
|
if (next.length === 0) return base;
|
|
|
|
|
const seen = new Set(base.map((item) => item.fileId));
|
|
|
|
|
@@ -286,19 +236,13 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
resolve: (payload?: ExportPayload) => void;
|
|
|
|
|
timer?: number;
|
|
|
|
|
} | null>(null);
|
|
|
|
|
const exportCheckSeqRef = useRef(0);
|
|
|
|
|
const savedSnapshotsRef = useRef<Record<string, string>>({});
|
|
|
|
|
const pendingAutoAdvanceRef = useRef(false);
|
|
|
|
|
const segmentStatsCacheRef = useRef<Record<string, SegmentStats>>({});
|
|
|
|
|
const segmentStatsSeqRef = useRef(0);
|
|
|
|
|
const segmentStatsLoadingRef = useRef<Set<string>>(new Set());
|
|
|
|
|
const segmentSummaryFileRef = useRef<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);
|
|
|
|
|
@@ -311,12 +255,11 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
const [prefetching, setPrefetching] = useState(false);
|
|
|
|
|
const [selectedFileId, setSelectedFileId] = useState<string>("");
|
|
|
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
|
|
|
const [autoSaveOnSwitch, setAutoSaveOnSwitch] = useState(false);
|
|
|
|
|
|
|
|
|
|
// 分段相关状态
|
|
|
|
|
const [segmented, setSegmented] = useState(false);
|
|
|
|
|
const [segments, setSegments] = useState<SegmentInfo[]>([]);
|
|
|
|
|
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0);
|
|
|
|
|
const [segmentTotal, setSegmentTotal] = useState(0);
|
|
|
|
|
const isTextProject = useMemo(
|
|
|
|
|
() => (project?.datasetType || "").toUpperCase() === "TEXT",
|
|
|
|
|
[project?.datasetType],
|
|
|
|
|
@@ -335,68 +278,6 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
win.postMessage({ type, payload }, origin);
|
|
|
|
|
}, [origin]);
|
|
|
|
|
|
|
|
|
|
const applySegmentStats = useCallback((fileId: string, stats: SegmentStats | null) => {
|
|
|
|
|
if (!fileId) return;
|
|
|
|
|
const normalized = normalizeSegmentStats(stats);
|
|
|
|
|
setTasks((prev) =>
|
|
|
|
|
prev.map((item) =>
|
|
|
|
|
item.fileId === fileId
|
|
|
|
|
? { ...item, segmentStats: normalized || undefined }
|
|
|
|
|
: item
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const updateSegmentStatsCache = useCallback((fileId: string, stats: SegmentStats | null) => {
|
|
|
|
|
if (!fileId) return;
|
|
|
|
|
const normalized = normalizeSegmentStats(stats);
|
|
|
|
|
if (normalized) {
|
|
|
|
|
segmentStatsCacheRef.current[fileId] = normalized;
|
|
|
|
|
} else {
|
|
|
|
|
delete segmentStatsCacheRef.current[fileId];
|
|
|
|
|
}
|
|
|
|
|
applySegmentStats(fileId, normalized);
|
|
|
|
|
}, [applySegmentStats]);
|
|
|
|
|
|
|
|
|
|
const fetchSegmentStatsForFile = useCallback(async (fileId: string, seq: number) => {
|
|
|
|
|
if (!projectId || !fileId) return;
|
|
|
|
|
if (segmentStatsCacheRef.current[fileId] || segmentStatsLoadingRef.current.has(fileId)) return;
|
|
|
|
|
segmentStatsLoadingRef.current.add(fileId);
|
|
|
|
|
try {
|
|
|
|
|
const resp = (await getEditorTaskSegmentsUsingGet(projectId, fileId)) as ApiResponse<EditorTaskSegmentsResponse>;
|
|
|
|
|
if (segmentStatsSeqRef.current !== seq) return;
|
|
|
|
|
const data = resp?.data;
|
|
|
|
|
if (!data?.segmented) return;
|
|
|
|
|
const stats = buildSegmentStats(data.segments);
|
|
|
|
|
if (!stats) return;
|
|
|
|
|
segmentStatsCacheRef.current[fileId] = stats;
|
|
|
|
|
applySegmentStats(fileId, stats);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e);
|
|
|
|
|
} finally {
|
|
|
|
|
segmentStatsLoadingRef.current.delete(fileId);
|
|
|
|
|
}
|
|
|
|
|
}, [applySegmentStats, projectId]);
|
|
|
|
|
|
|
|
|
|
const prefetchSegmentStats = useCallback((items: EditorTaskListItem[]) => {
|
|
|
|
|
if (!projectId) return;
|
|
|
|
|
const fileIds = items
|
|
|
|
|
.map((item) => item.fileId)
|
|
|
|
|
.filter((fileId) => fileId && !segmentStatsCacheRef.current[fileId]);
|
|
|
|
|
if (fileIds.length === 0) return;
|
|
|
|
|
const seq = segmentStatsSeqRef.current;
|
|
|
|
|
let cursor = 0;
|
|
|
|
|
const workerCount = Math.min(3, fileIds.length);
|
|
|
|
|
const runWorker = async () => {
|
|
|
|
|
while (cursor < fileIds.length && segmentStatsSeqRef.current === seq) {
|
|
|
|
|
const fileId = fileIds[cursor];
|
|
|
|
|
cursor += 1;
|
|
|
|
|
await fetchSegmentStatsForFile(fileId, seq);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
void Promise.all(Array.from({ length: workerCount }, () => runWorker()));
|
|
|
|
|
}, [fetchSegmentStatsForFile, projectId]);
|
|
|
|
|
|
|
|
|
|
const confirmEmptyAnnotationStatus = useCallback(() => {
|
|
|
|
|
return new Promise<AnnotationResultStatus | null>((resolve) => {
|
|
|
|
|
let resolved = false;
|
|
|
|
|
@@ -599,33 +480,14 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
? resolveSegmentIndex(data.currentSegmentIndex) ?? 0
|
|
|
|
|
: undefined;
|
|
|
|
|
if (isSegmented) {
|
|
|
|
|
let nextSegments: SegmentInfo[] = [];
|
|
|
|
|
if (segmentSummaryFileRef.current === fileId && segments.length > 0) {
|
|
|
|
|
nextSegments = segments;
|
|
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
const segmentResp = (await getEditorTaskSegmentsUsingGet(projectId, fileId)) as ApiResponse<EditorTaskSegmentsResponse>;
|
|
|
|
|
if (seq !== initSeqRef.current) return;
|
|
|
|
|
const segmentData = segmentResp?.data;
|
|
|
|
|
if (segmentData?.segmented) {
|
|
|
|
|
nextSegments = Array.isArray(segmentData.segments) ? segmentData.segments : [];
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const stats = buildSegmentStats(nextSegments);
|
|
|
|
|
setSegmented(true);
|
|
|
|
|
setSegments(nextSegments);
|
|
|
|
|
setCurrentSegmentIndex(segmentIndex ?? 0);
|
|
|
|
|
updateSegmentStatsCache(fileId, stats);
|
|
|
|
|
segmentSummaryFileRef.current = fileId;
|
|
|
|
|
const totalSegments = Number(data?.totalSegments ?? 0);
|
|
|
|
|
setSegmentTotal(Number.isFinite(totalSegments) && totalSegments > 0 ? totalSegments : 0);
|
|
|
|
|
} else {
|
|
|
|
|
setSegmented(false);
|
|
|
|
|
setSegments([]);
|
|
|
|
|
setCurrentSegmentIndex(0);
|
|
|
|
|
updateSegmentStatsCache(fileId, null);
|
|
|
|
|
segmentSummaryFileRef.current = fileId;
|
|
|
|
|
setSegmentTotal(0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const taskData = {
|
|
|
|
|
@@ -685,19 +547,14 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
} finally {
|
|
|
|
|
if (seq === initSeqRef.current) setLoadingTaskDetail(false);
|
|
|
|
|
}
|
|
|
|
|
}, [iframeReady, message, postToIframe, project, projectId, segments, updateSegmentStatsCache]);
|
|
|
|
|
}, [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) {
|
|
|
|
|
if (segmented && segmentTotal > 0) {
|
|
|
|
|
const baseIndex = Math.max(segmentIndex ?? currentSegmentIndex, 0);
|
|
|
|
|
const nextSegmentIndex = baseIndex + 1;
|
|
|
|
|
if (nextSegmentIndex < segmentTotal) {
|
|
|
|
|
await initEditorForFile(fileId, nextSegmentIndex);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
@@ -719,7 +576,7 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
initEditorForFile,
|
|
|
|
|
message,
|
|
|
|
|
segmented,
|
|
|
|
|
segments,
|
|
|
|
|
segmentTotal,
|
|
|
|
|
tasks,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
@@ -793,16 +650,6 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
const snapshot = buildAnnotationSnapshot(isRecord(annotation) ? annotation : undefined);
|
|
|
|
|
savedSnapshotsRef.current[snapshotKey] = snapshot;
|
|
|
|
|
|
|
|
|
|
// 分段模式下更新当前段落的标注状态
|
|
|
|
|
if (segmented && segmentIndex !== undefined) {
|
|
|
|
|
const nextSegments = segments.map((seg) =>
|
|
|
|
|
seg.idx === segmentIndex
|
|
|
|
|
? { ...seg, hasAnnotation: true }
|
|
|
|
|
: seg
|
|
|
|
|
);
|
|
|
|
|
setSegments(nextSegments);
|
|
|
|
|
updateSegmentStatsCache(String(fileId), buildSegmentStats(nextSegments));
|
|
|
|
|
}
|
|
|
|
|
if (options?.autoAdvance) {
|
|
|
|
|
await advanceAfterSave(String(fileId), segmentIndex);
|
|
|
|
|
}
|
|
|
|
|
@@ -821,69 +668,10 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
message,
|
|
|
|
|
projectId,
|
|
|
|
|
segmented,
|
|
|
|
|
segments,
|
|
|
|
|
selectedFileId,
|
|
|
|
|
tasks,
|
|
|
|
|
updateSegmentStatsCache,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
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<SwitchDecision>((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: (
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
<Typography.Text>切换段落前请先保存当前标注。</Typography.Text>
|
|
|
|
|
<Button type="link" danger style={{ padding: 0, height: "auto" }} onClick={handleDiscard}>
|
|
|
|
|
放弃未保存并切换
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
),
|
|
|
|
|
okText: "保存并切换",
|
|
|
|
|
cancelText: "取消",
|
|
|
|
|
onOk: () => settle("save"),
|
|
|
|
|
onCancel: () => settle("cancel"),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}, [modal]);
|
|
|
|
|
|
|
|
|
|
const requestExport = useCallback((autoAdvance: boolean) => {
|
|
|
|
|
if (!selectedFileId) {
|
|
|
|
|
message.warning("请先选择文件");
|
|
|
|
|
@@ -896,7 +684,7 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleSaveShortcut = (event: KeyboardEvent) => {
|
|
|
|
|
if (!isSaveShortcut(event) || event.repeat) return;
|
|
|
|
|
if (saving || loadingTaskDetail || segmentSwitching) return;
|
|
|
|
|
if (saving || loadingTaskDetail) return;
|
|
|
|
|
if (!iframeReady || !lsReady) return;
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
@@ -904,83 +692,7 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener("keydown", handleSaveShortcut);
|
|
|
|
|
return () => window.removeEventListener("keydown", handleSaveShortcut);
|
|
|
|
|
}, [iframeReady, loadingTaskDetail, lsReady, requestExport, saving, segmentSwitching]);
|
|
|
|
|
|
|
|
|
|
// 段落切换处理
|
|
|
|
|
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,
|
|
|
|
|
]);
|
|
|
|
|
}, [iframeReady, loadingTaskDetail, lsReady, requestExport, saving]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setIframeReady(false);
|
|
|
|
|
@@ -998,13 +710,9 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
expectedTaskIdRef.current = null;
|
|
|
|
|
// 重置分段状态
|
|
|
|
|
setSegmented(false);
|
|
|
|
|
setSegments([]);
|
|
|
|
|
setCurrentSegmentIndex(0);
|
|
|
|
|
segmentSummaryFileRef.current = "";
|
|
|
|
|
setSegmentTotal(0);
|
|
|
|
|
savedSnapshotsRef.current = {};
|
|
|
|
|
segmentStatsSeqRef.current += 1;
|
|
|
|
|
segmentStatsCacheRef.current = {};
|
|
|
|
|
segmentStatsLoadingRef.current = new Set();
|
|
|
|
|
if (exportCheckRef.current?.timer) {
|
|
|
|
|
window.clearTimeout(exportCheckRef.current.timer);
|
|
|
|
|
}
|
|
|
|
|
@@ -1018,12 +726,6 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
loadTasks({ mode: "reset" });
|
|
|
|
|
}, [project?.supported, loadTasks]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!segmented) return;
|
|
|
|
|
if (tasks.length === 0) return;
|
|
|
|
|
prefetchSegmentStats(tasks);
|
|
|
|
|
}, [prefetchSegmentStats, segmented, tasks]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!selectedFileId) return;
|
|
|
|
|
initEditorForFile(selectedFileId);
|
|
|
|
|
@@ -1048,60 +750,6 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
return () => window.removeEventListener("focus", handleWindowFocus);
|
|
|
|
|
}, [focusIframe, lsReady]);
|
|
|
|
|
|
|
|
|
|
const segmentTreeData = useMemo(() => {
|
|
|
|
|
if (!segmented || segments.length === 0) return [];
|
|
|
|
|
const lineMap = new Map<number, SegmentInfo[]>();
|
|
|
|
|
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: (
|
|
|
|
|
<span className="flex items-center gap-1">
|
|
|
|
|
<span>{`片${seg.chunkIndex + 1}`}</span>
|
|
|
|
|
{seg.hasAnnotation && (
|
|
|
|
|
<CheckOutlined style={{ fontSize: 10, color: "#52c41a" }} />
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
),
|
|
|
|
|
})),
|
|
|
|
|
}));
|
|
|
|
|
}, [segmented, segments]);
|
|
|
|
|
|
|
|
|
|
const segmentLineKeys = useMemo(
|
|
|
|
|
() => segmentTreeData.map((item) => String(item.key)),
|
|
|
|
|
[segmentTreeData]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const inProgressSegmentedCount = useMemo(() => {
|
|
|
|
|
if (tasks.length === 0) return 0;
|
|
|
|
|
return tasks.reduce((count, item) => {
|
|
|
|
|
const summary = resolveSegmentSummary(item);
|
|
|
|
|
if (!summary) return count;
|
|
|
|
|
return summary.done < summary.total ? count + 1 : count;
|
|
|
|
|
}, 0);
|
|
|
|
|
}, [tasks]);
|
|
|
|
|
|
|
|
|
|
const handleSegmentSelect = useCallback((keys: Array<string | number>) => {
|
|
|
|
|
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<LsfMessage>) => {
|
|
|
|
|
if (event.origin !== origin) return;
|
|
|
|
|
@@ -1170,7 +818,7 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
|
|
|
|
|
const canLoadMore = taskTotalPages > 0 && taskPage + 1 < taskTotalPages;
|
|
|
|
|
const saveDisabled =
|
|
|
|
|
!iframeReady || !selectedFileId || saving || segmentSwitching || loadingTaskDetail;
|
|
|
|
|
!iframeReady || !selectedFileId || saving || loadingTaskDetail;
|
|
|
|
|
const loadMoreNode = canLoadMore ? (
|
|
|
|
|
<div className="p-2 text-center">
|
|
|
|
|
<Button
|
|
|
|
|
@@ -1287,11 +935,6 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
>
|
|
|
|
|
<div className="px-3 py-2 border-b border-gray-200 bg-white font-medium text-sm flex items-center justify-between gap-2">
|
|
|
|
|
<span>文件列表</span>
|
|
|
|
|
{segmented && (
|
|
|
|
|
<Tag color="orange" style={{ margin: 0 }}>
|
|
|
|
|
标注中 {inProgressSegmentedCount}
|
|
|
|
|
</Tag>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 min-h-0 overflow-auto">
|
|
|
|
|
<List
|
|
|
|
|
@@ -1300,7 +943,6 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
dataSource={tasks}
|
|
|
|
|
loadMore={loadMoreNode}
|
|
|
|
|
renderItem={(item) => {
|
|
|
|
|
const segmentSummary = resolveSegmentSummary(item);
|
|
|
|
|
const statusMeta = resolveTaskStatusMeta(item);
|
|
|
|
|
return (
|
|
|
|
|
<List.Item
|
|
|
|
|
@@ -1322,11 +964,6 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
<Typography.Text type={statusMeta.type} style={{ fontSize: 11 }}>
|
|
|
|
|
{statusMeta.text}
|
|
|
|
|
</Typography.Text>
|
|
|
|
|
{segmentSummary && (
|
|
|
|
|
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
|
|
|
|
|
已标注 {segmentSummary.done}/{segmentSummary.total}
|
|
|
|
|
</Typography.Text>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{item.annotationUpdatedAt && (
|
|
|
|
|
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
|
|
|
|
|
@@ -1345,21 +982,16 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 font-medium text-sm flex items-center justify-between">
|
|
|
|
|
<span>段落/分段</span>
|
|
|
|
|
<Tag color="blue" style={{ margin: 0 }}>
|
|
|
|
|
{currentSegmentIndex + 1} / {segments.length}
|
|
|
|
|
{segmentTotal > 0 ? currentSegmentIndex + 1 : 0} / {segmentTotal}
|
|
|
|
|
</Tag>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 min-h-0 overflow-auto px-2 py-2">
|
|
|
|
|
{segments.length > 0 ? (
|
|
|
|
|
<Tree
|
|
|
|
|
showLine
|
|
|
|
|
blockNode
|
|
|
|
|
selectedKeys={
|
|
|
|
|
segmented ? [`seg-${currentSegmentIndex}`] : []
|
|
|
|
|
}
|
|
|
|
|
expandedKeys={segmentLineKeys}
|
|
|
|
|
onSelect={handleSegmentSelect}
|
|
|
|
|
treeData={segmentTreeData}
|
|
|
|
|
/>
|
|
|
|
|
{segmentTotal > 0 ? (
|
|
|
|
|
<div className="py-6">
|
|
|
|
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
|
|
|
分段列表已关闭,请使用“保存并跳转到下一段/下一条”顺序标注。
|
|
|
|
|
</Typography.Text>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="py-6">
|
|
|
|
|
<Empty
|
|
|
|
|
@@ -1369,17 +1001,6 @@ export default function LabelStudioTextEditor() {
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="px-3 py-2 border-t border-gray-200 flex items-center justify-between">
|
|
|
|
|
<Typography.Text style={{ fontSize: 12 }}>
|
|
|
|
|
切段自动保存
|
|
|
|
|
</Typography.Text>
|
|
|
|
|
<Switch
|
|
|
|
|
size="small"
|
|
|
|
|
checked={autoSaveOnSwitch}
|
|
|
|
|
onChange={(checked) => setAutoSaveOnSwitch(checked)}
|
|
|
|
|
disabled={segmentSwitching || saving || loadingTaskDetail || !lsReady}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|