feat(annotation): 实现任务列表分页加载和优化排序功能

- 添加分页相关字段到EditorTaskListResponse类型定义
- 定义TASK_PAGE_START和TASK_PAGE_SIZE常量及NormalizedTaskList类型
- 实现mergeTaskItems、mergeTaskPages和normalizeTaskListResponse工具函数
- 添加taskPage、taskTotal、taskTotalPages和loadingMore状态管理
- 优化后端查询逻辑,使用case语句实现标注状态排序
- 集成外连接查询同时获取文件信息和标注结果
- 改进前端任务列表的数据合并和分页加载机制
This commit is contained in:
2026-01-27 18:22:17 +08:00
parent 3a93098b57
commit 1158647217
2 changed files with 178 additions and 43 deletions

View File

@@ -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<string, unknown>) => {
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<EditorTaskListResponse> | 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<EditorProjectInfo | null>(null);
const [tasks, setTasks] = useState<EditorTaskListItem[]>([]);
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<string>("");
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<EditorTaskListResponse>;
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<EditorTaskListResponse>;
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<EditorTaskListResponse>;
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 ? (
<div className="p-2 text-center">
<Button
size="small"
loading={loadingMore}
disabled={loadingTasks}
onClick={() => loadTasks({ mode: "append" })}
>
</Button>
{taskTotal > 0 && (
<Typography.Text type="secondary" style={{ fontSize: 10, marginLeft: 8 }}>
{tasks.length}/{taskTotal}
</Typography.Text>
)}
</div>
) : null;
if (loadingProject) {
return (
<div className="h-full flex items-center justify-center">
@@ -786,7 +920,11 @@ export default function LabelStudioTextEditor() {
</Typography.Title>
</div>
<div className="flex items-center gap-2">
<Button icon={<ReloadOutlined />} loading={loadingTasks} onClick={() => loadTasks()}>
<Button
icon={<ReloadOutlined />}
loading={loadingTasks}
onClick={() => loadTasks({ mode: "reset" })}
>
</Button>
<Button
@@ -816,6 +954,7 @@ export default function LabelStudioTextEditor() {
loading={loadingTasks}
size="small"
dataSource={tasks}
loadMore={loadMoreNode}
renderItem={(item) => (
<List.Item
key={item.fileId}

View File

@@ -18,7 +18,7 @@ import hashlib
import json
import xml.etree.ElementTree as ET
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
@@ -420,38 +420,34 @@ class AnnotationEditorService:
)
total = int(count_result.scalar() or 0)
annotated_sort_key = case(
(AnnotationResult.id.isnot(None), 1),
else_=0,
)
files_result = await self.db.execute(
select(DatasetFiles)
select(DatasetFiles, AnnotationResult.id, AnnotationResult.updated_at)
.outerjoin(
AnnotationResult,
(AnnotationResult.file_id == DatasetFiles.id)
& (AnnotationResult.project_id == project_id),
)
.where(DatasetFiles.dataset_id == project.dataset_id)
.order_by(DatasetFiles.created_at.desc())
.order_by(annotated_sort_key.asc(), DatasetFiles.created_at.desc())
.offset(page * size)
.limit(size)
)
files = files_result.scalars().all()
file_ids = [str(f.id) for f in files] # type: ignore[arg-type]
updated_map: Dict[str, datetime] = {}
if file_ids:
ann_result = await self.db.execute(
select(AnnotationResult.file_id, AnnotationResult.updated_at).where(
AnnotationResult.project_id == project_id,
AnnotationResult.file_id.in_(file_ids),
)
)
for file_id, updated_at in ann_result.all():
if file_id and updated_at:
updated_map[str(file_id)] = updated_at
rows = files_result.all()
items: List[EditorTaskListItem] = []
for f in files:
fid = str(f.id) # type: ignore[arg-type]
for file_record, annotation_id, annotation_updated_at in rows:
fid = str(file_record.id) # type: ignore[arg-type]
items.append(
EditorTaskListItem(
fileId=fid,
fileName=str(getattr(f, "file_name", "")),
fileType=getattr(f, "file_type", None),
hasAnnotation=fid in updated_map,
annotationUpdatedAt=updated_map.get(fid),
fileName=str(getattr(file_record, "file_name", "")),
fileType=getattr(file_record, "file_type", None),
hasAnnotation=annotation_id is not None,
annotationUpdatedAt=annotation_updated_at,
)
)