diff --git a/frontend/src/pages/DataAnnotation/Annotate/LabelStudioTextEditor.tsx b/frontend/src/pages/DataAnnotation/Annotate/LabelStudioTextEditor.tsx index 0cbec5a..f1bb4b6 100644 --- a/frontend/src/pages/DataAnnotation/Annotate/LabelStudioTextEditor.tsx +++ b/frontend/src/pages/DataAnnotation/Annotate/LabelStudioTextEditor.tsx @@ -63,6 +63,10 @@ type EditorTaskResponse = { type EditorTaskListResponse = { content?: EditorTaskListItem[]; + totalElements?: number; + totalPages?: number; + page?: number; + size?: number; }; type ExportPayload = { @@ -76,6 +80,16 @@ type ExportPayload = { type SwitchDecision = "save" | "discard" | "cancel"; const LSF_IFRAME_SRC = "/lsf/lsf.html"; +const TASK_PAGE_START = 0; +const TASK_PAGE_SIZE = 200; + +type NormalizedTaskList = { + items: EditorTaskListItem[]; + page: number; + size: number; + total: number; + totalPages: number; +}; const resolveSegmentIndex = (value: unknown) => { if (value === null || value === undefined) return undefined; @@ -135,6 +149,41 @@ const buildAnnotationSnapshot = (annotation?: Record) => { const buildSnapshotKey = (fileId: string, segmentIndex?: number) => `${fileId}::${segmentIndex ?? "full"}`; +const mergeTaskItems = (base: EditorTaskListItem[], next: EditorTaskListItem[]) => { + if (next.length === 0) return base; + const seen = new Set(base.map((item) => item.fileId)); + const merged = [...base]; + next.forEach((item) => { + if (seen.has(item.fileId)) return; + seen.add(item.fileId); + merged.push(item); + }); + return merged; +}; + +const mergeTaskPages = (pages: EditorTaskListItem[][]) => + pages.reduce((acc, page) => mergeTaskItems(acc, page), []); + +const normalizeTaskListResponse = ( + response: ApiResponse | null | undefined, + fallbackPage: number, +): NormalizedTaskList => { + const content = response?.data?.content; + const items = Array.isArray(content) ? content : []; + const size = response?.data?.size ?? TASK_PAGE_SIZE; + const total = response?.data?.totalElements ?? items.length; + const totalPages = + response?.data?.totalPages ?? (size > 0 ? Math.ceil(total / size) : 0); + const page = response?.data?.page ?? fallbackPage; + return { + items, + page, + size, + total, + totalPages, + }; +}; + export default function LabelStudioTextEditor() { const { projectId = "" } = useParams(); const navigate = useNavigate(); @@ -163,6 +212,10 @@ export default function LabelStudioTextEditor() { const [lsReady, setLsReady] = useState(false); const [project, setProject] = useState(null); const [tasks, setTasks] = useState([]); + const [taskPage, setTaskPage] = useState(TASK_PAGE_START); + const [taskTotal, setTaskTotal] = useState(0); + const [taskTotalPages, setTaskTotalPages] = useState(0); + const [loadingMore, setLoadingMore] = useState(false); const [selectedFileId, setSelectedFileId] = useState(""); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [autoSaveOnSwitch, setAutoSaveOnSwitch] = useState(false); @@ -205,31 +258,89 @@ export default function LabelStudioTextEditor() { } }, [message, projectId]); - const loadTasks = useCallback(async (silent = false) => { + const updateTaskSelection = useCallback((items: EditorTaskListItem[]) => { + const defaultFileId = + items.find((item) => !item.hasAnnotation)?.fileId || items[0]?.fileId || ""; + setSelectedFileId((prev) => { + if (prev && items.some((item) => item.fileId === prev)) return prev; + return defaultFileId; + }); + }, []); + + const loadTasks = useCallback(async (options?: { + mode?: "reset" | "append" | "refresh"; + silent?: boolean; + }) => { if (!projectId) return; - if (!silent) setLoadingTasks(true); + const mode = options?.mode ?? "reset"; + const silent = options?.silent ?? false; + if (mode === "append" && taskTotalPages > 0 && taskPage + 1 >= taskTotalPages) { + return; + } + if (mode === "append") { + setLoadingMore(true); + } else if (!silent) { + setLoadingTasks(true); + } try { + if (mode === "refresh") { + const firstResp = (await listEditorTasksUsingGet(projectId, { + page: TASK_PAGE_START, + size: TASK_PAGE_SIZE, + })) as ApiResponse; + const firstNormalized = normalizeTaskListResponse(firstResp, TASK_PAGE_START); + const lastPage = Math.min( + taskPage, + Math.max(firstNormalized.totalPages - 1, TASK_PAGE_START), + ); + const pages: EditorTaskListItem[][] = [firstNormalized.items]; + for (let page = TASK_PAGE_START + 1; page <= lastPage; page += 1) { + const resp = (await listEditorTasksUsingGet(projectId, { + page, + size: TASK_PAGE_SIZE, + })) as ApiResponse; + const normalized = normalizeTaskListResponse(resp, page); + pages.push(normalized.items); + } + const mergedItems = mergeTaskPages(pages); + setTasks(mergedItems); + setTaskPage(lastPage); + setTaskTotal(firstNormalized.total); + setTaskTotalPages(firstNormalized.totalPages); + updateTaskSelection(mergedItems); + return; + } + + const nextPage = mode === "append" ? taskPage + 1 : TASK_PAGE_START; const resp = (await listEditorTasksUsingGet(projectId, { - page: 0, - size: 200, + page: nextPage, + size: TASK_PAGE_SIZE, })) as ApiResponse; - const content = resp?.data?.content || []; - const items = Array.isArray(content) ? content : []; - setTasks(items); - const defaultFileId = - items.find((item) => !item.hasAnnotation)?.fileId || items[0]?.fileId || ""; - setSelectedFileId((prev) => { - if (prev && items.some((item) => item.fileId === prev)) return prev; - return defaultFileId; - }); + const normalized = normalizeTaskListResponse(resp, nextPage); + const nextItems = + mode === "append" ? mergeTaskItems(tasks, normalized.items) : normalized.items; + setTasks(nextItems); + setTaskPage(normalized.page); + setTaskTotal(normalized.total); + setTaskTotalPages(normalized.totalPages); + updateTaskSelection(nextItems); } catch (e) { console.error(e); - if (!silent) message.error("获取文件列表失败"); - setTasks([]); + if (!silent && mode !== "append") message.error("获取文件列表失败"); + if (mode === "reset") { + setTasks([]); + setTaskPage(TASK_PAGE_START); + setTaskTotal(0); + setTaskTotalPages(0); + } } finally { - if (!silent) setLoadingTasks(false); + if (mode === "append") { + setLoadingMore(false); + } else if (!silent) { + setLoadingTasks(false); + } } - }, [message, projectId]); + }, [message, projectId, taskPage, taskTotalPages, tasks, updateTaskSelection]); const initEditorForFile = useCallback(async (fileId: string, segmentIdx?: number) => { if (!project?.supported) return; @@ -397,7 +508,7 @@ export default function LabelStudioTextEditor() { segmentIndex, }); message.success("标注已保存"); - await loadTasks(true); + await loadTasks({ mode: "refresh", silent: true }); const snapshotKey = buildSnapshotKey(String(fileId), segmentIndex); const snapshot = buildAnnotationSnapshot(isRecord(annotation) ? annotation : undefined); @@ -580,6 +691,10 @@ export default function LabelStudioTextEditor() { setIframeReady(false); setProject(null); setTasks([]); + setTaskPage(TASK_PAGE_START); + setTaskTotal(0); + setTaskTotalPages(0); + setLoadingMore(false); setSelectedFileId(""); initSeqRef.current = 0; setLsReady(false); @@ -599,7 +714,7 @@ export default function LabelStudioTextEditor() { useEffect(() => { if (!project?.supported) return; - loadTasks(); + loadTasks({ mode: "reset" }); }, [project?.supported, loadTasks]); useEffect(() => { @@ -731,6 +846,25 @@ export default function LabelStudioTextEditor() { return () => window.removeEventListener("message", handler); }, [message, origin, saveFromExport]); + const canLoadMore = taskTotalPages > 0 && taskPage + 1 < taskTotalPages; + const loadMoreNode = canLoadMore ? ( +
+ + {taskTotal > 0 && ( + + 已加载 {tasks.length}/{taskTotal} + + )} +
+ ) : null; + if (loadingProject) { return (
@@ -786,7 +920,11 @@ export default function LabelStudioTextEditor() {
-