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 = {
|
type EditorTaskListResponse = {
|
||||||
content?: EditorTaskListItem[];
|
content?: EditorTaskListItem[];
|
||||||
|
totalElements?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ExportPayload = {
|
type ExportPayload = {
|
||||||
@@ -76,6 +80,16 @@ type ExportPayload = {
|
|||||||
type SwitchDecision = "save" | "discard" | "cancel";
|
type SwitchDecision = "save" | "discard" | "cancel";
|
||||||
|
|
||||||
const LSF_IFRAME_SRC = "/lsf/lsf.html";
|
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) => {
|
const resolveSegmentIndex = (value: unknown) => {
|
||||||
if (value === null || value === undefined) return undefined;
|
if (value === null || value === undefined) return undefined;
|
||||||
@@ -135,6 +149,41 @@ const buildAnnotationSnapshot = (annotation?: Record<string, unknown>) => {
|
|||||||
const buildSnapshotKey = (fileId: string, segmentIndex?: number) =>
|
const buildSnapshotKey = (fileId: string, segmentIndex?: number) =>
|
||||||
`${fileId}::${segmentIndex ?? "full"}`;
|
`${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() {
|
export default function LabelStudioTextEditor() {
|
||||||
const { projectId = "" } = useParams();
|
const { projectId = "" } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -163,6 +212,10 @@ export default function LabelStudioTextEditor() {
|
|||||||
const [lsReady, setLsReady] = useState(false);
|
const [lsReady, setLsReady] = useState(false);
|
||||||
const [project, setProject] = useState<EditorProjectInfo | null>(null);
|
const [project, setProject] = useState<EditorProjectInfo | null>(null);
|
||||||
const [tasks, setTasks] = useState<EditorTaskListItem[]>([]);
|
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 [selectedFileId, setSelectedFileId] = useState<string>("");
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [autoSaveOnSwitch, setAutoSaveOnSwitch] = useState(false);
|
const [autoSaveOnSwitch, setAutoSaveOnSwitch] = useState(false);
|
||||||
@@ -205,31 +258,89 @@ export default function LabelStudioTextEditor() {
|
|||||||
}
|
}
|
||||||
}, [message, projectId]);
|
}, [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 (!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 {
|
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, {
|
const resp = (await listEditorTasksUsingGet(projectId, {
|
||||||
page: 0,
|
page: nextPage,
|
||||||
size: 200,
|
size: TASK_PAGE_SIZE,
|
||||||
})) as ApiResponse<EditorTaskListResponse>;
|
})) as ApiResponse<EditorTaskListResponse>;
|
||||||
const content = resp?.data?.content || [];
|
const normalized = normalizeTaskListResponse(resp, nextPage);
|
||||||
const items = Array.isArray(content) ? content : [];
|
const nextItems =
|
||||||
setTasks(items);
|
mode === "append" ? mergeTaskItems(tasks, normalized.items) : normalized.items;
|
||||||
const defaultFileId =
|
setTasks(nextItems);
|
||||||
items.find((item) => !item.hasAnnotation)?.fileId || items[0]?.fileId || "";
|
setTaskPage(normalized.page);
|
||||||
setSelectedFileId((prev) => {
|
setTaskTotal(normalized.total);
|
||||||
if (prev && items.some((item) => item.fileId === prev)) return prev;
|
setTaskTotalPages(normalized.totalPages);
|
||||||
return defaultFileId;
|
updateTaskSelection(nextItems);
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
if (!silent) message.error("获取文件列表失败");
|
if (!silent && mode !== "append") message.error("获取文件列表失败");
|
||||||
setTasks([]);
|
if (mode === "reset") {
|
||||||
|
setTasks([]);
|
||||||
|
setTaskPage(TASK_PAGE_START);
|
||||||
|
setTaskTotal(0);
|
||||||
|
setTaskTotalPages(0);
|
||||||
|
}
|
||||||
} finally {
|
} 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) => {
|
const initEditorForFile = useCallback(async (fileId: string, segmentIdx?: number) => {
|
||||||
if (!project?.supported) return;
|
if (!project?.supported) return;
|
||||||
@@ -397,7 +508,7 @@ export default function LabelStudioTextEditor() {
|
|||||||
segmentIndex,
|
segmentIndex,
|
||||||
});
|
});
|
||||||
message.success("标注已保存");
|
message.success("标注已保存");
|
||||||
await loadTasks(true);
|
await loadTasks({ mode: "refresh", silent: true });
|
||||||
|
|
||||||
const snapshotKey = buildSnapshotKey(String(fileId), segmentIndex);
|
const snapshotKey = buildSnapshotKey(String(fileId), segmentIndex);
|
||||||
const snapshot = buildAnnotationSnapshot(isRecord(annotation) ? annotation : undefined);
|
const snapshot = buildAnnotationSnapshot(isRecord(annotation) ? annotation : undefined);
|
||||||
@@ -580,6 +691,10 @@ export default function LabelStudioTextEditor() {
|
|||||||
setIframeReady(false);
|
setIframeReady(false);
|
||||||
setProject(null);
|
setProject(null);
|
||||||
setTasks([]);
|
setTasks([]);
|
||||||
|
setTaskPage(TASK_PAGE_START);
|
||||||
|
setTaskTotal(0);
|
||||||
|
setTaskTotalPages(0);
|
||||||
|
setLoadingMore(false);
|
||||||
setSelectedFileId("");
|
setSelectedFileId("");
|
||||||
initSeqRef.current = 0;
|
initSeqRef.current = 0;
|
||||||
setLsReady(false);
|
setLsReady(false);
|
||||||
@@ -599,7 +714,7 @@ export default function LabelStudioTextEditor() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!project?.supported) return;
|
if (!project?.supported) return;
|
||||||
loadTasks();
|
loadTasks({ mode: "reset" });
|
||||||
}, [project?.supported, loadTasks]);
|
}, [project?.supported, loadTasks]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -731,6 +846,25 @@ export default function LabelStudioTextEditor() {
|
|||||||
return () => window.removeEventListener("message", handler);
|
return () => window.removeEventListener("message", handler);
|
||||||
}, [message, origin, saveFromExport]);
|
}, [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) {
|
if (loadingProject) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex items-center justify-center">
|
<div className="h-full flex items-center justify-center">
|
||||||
@@ -786,7 +920,11 @@ export default function LabelStudioTextEditor() {
|
|||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
||||||
<Button
|
<Button
|
||||||
@@ -816,6 +954,7 @@ export default function LabelStudioTextEditor() {
|
|||||||
loading={loadingTasks}
|
loading={loadingTasks}
|
||||||
size="small"
|
size="small"
|
||||||
dataSource={tasks}
|
dataSource={tasks}
|
||||||
|
loadMore={loadMoreNode}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<List.Item
|
<List.Item
|
||||||
key={item.fileId}
|
key={item.fileId}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import case, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -420,38 +420,34 @@ class AnnotationEditorService:
|
|||||||
)
|
)
|
||||||
total = int(count_result.scalar() or 0)
|
total = int(count_result.scalar() or 0)
|
||||||
|
|
||||||
|
annotated_sort_key = case(
|
||||||
|
(AnnotationResult.id.isnot(None), 1),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
files_result = await self.db.execute(
|
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)
|
.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)
|
.offset(page * size)
|
||||||
.limit(size)
|
.limit(size)
|
||||||
)
|
)
|
||||||
files = files_result.scalars().all()
|
rows = files_result.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
|
|
||||||
|
|
||||||
items: List[EditorTaskListItem] = []
|
items: List[EditorTaskListItem] = []
|
||||||
for f in files:
|
for file_record, annotation_id, annotation_updated_at in rows:
|
||||||
fid = str(f.id) # type: ignore[arg-type]
|
fid = str(file_record.id) # type: ignore[arg-type]
|
||||||
items.append(
|
items.append(
|
||||||
EditorTaskListItem(
|
EditorTaskListItem(
|
||||||
fileId=fid,
|
fileId=fid,
|
||||||
fileName=str(getattr(f, "file_name", "")),
|
fileName=str(getattr(file_record, "file_name", "")),
|
||||||
fileType=getattr(f, "file_type", None),
|
fileType=getattr(file_record, "file_type", None),
|
||||||
hasAnnotation=fid in updated_map,
|
hasAnnotation=annotation_id is not None,
|
||||||
annotationUpdatedAt=updated_map.get(fid),
|
annotationUpdatedAt=annotation_updated_at,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user