You've already forked DataMate
feat(annotation): 实现任务列表分页加载和优化排序功能
- 添加分页相关字段到EditorTaskListResponse类型定义 - 定义TASK_PAGE_START和TASK_PAGE_SIZE常量及NormalizedTaskList类型 - 实现mergeTaskItems、mergeTaskPages和normalizeTaskListResponse工具函数 - 添加taskPage、taskTotal、taskTotalPages和loadingMore状态管理 - 优化后端查询逻辑,使用case语句实现标注状态排序 - 集成外连接查询同时获取文件信息和标注结果 - 改进前端任务列表的数据合并和分页加载机制
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user