Files
DataMate/frontend/src/pages/DataAnnotation/Home/DataAnnotation.tsx
Jerry Yan 626c0fcd9a fix(data-annotation): 修复数据标注任务进度计算问题
- 添加 toSafeCount 工具函数确保数值安全处理
- 支持 totalCount 和 total_count 字段兼容性
-
2026-02-01 23:42:06 +08:00

396 lines
12 KiB
TypeScript

import { useState } from "react";
import { Card, Button, Table, Tag, message, Modal, Tabs } from "antd";
import {
PlusOutlined,
EditOutlined,
FormOutlined,
DeleteOutlined,
DownloadOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router";
import { SearchControls } from "@/components/SearchControls";
import CardView from "@/components/CardView";
import useFetchData from "@/hooks/useFetchData";
import {
deleteAnnotationTaskByIdUsingDelete,
queryAnnotationTasksUsingGet,
} from "../annotation.api";
import {
AnnotationTypeMap,
mapAnnotationTask,
type AnnotationTaskListItem,
} from "../annotation.const";
import CreateAnnotationTask from "../Create/components/CreateAnnotationTaskDialog";
import ExportAnnotationDialog from "./ExportAnnotationDialog";
import { ColumnType } from "antd/es/table";
import { TemplateList } from "../Template";
// Note: DevelopmentInProgress intentionally not used here
type AnnotationTaskRowKey = string | number;
type AnnotationTaskOperation = {
key: string;
label: string;
icon: JSX.Element;
danger?: boolean;
onClick: (task: AnnotationTaskListItem) => void;
};
export default function DataAnnotation() {
// return <DevelopmentInProgress showTime="2025.10.30" />;
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState("tasks");
const [viewMode, setViewMode] = useState<"list" | "card">("list");
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [exportTask, setExportTask] = useState<AnnotationTaskListItem | null>(null);
const [editTask, setEditTask] = useState<AnnotationTaskListItem | null>(null);
const {
loading,
tableData,
pagination,
searchParams,
fetchData,
handleFiltersChange,
handleKeywordChange,
} = useFetchData<AnnotationTaskListItem>(queryAnnotationTasksUsingGet, mapAnnotationTask, 30000, true, [], 0);
const [selectedRowKeys, setSelectedRowKeys] = useState<AnnotationTaskRowKey[]>([]);
const [selectedRows, setSelectedRows] = useState<AnnotationTaskListItem[]>([]);
const toSafeCount = (value: unknown) =>
typeof value === "number" && Number.isFinite(value) ? value : 0;
const handleAnnotate = (task: AnnotationTaskListItem) => {
const projectId = task.id;
if (!projectId) {
message.error("无法进入标注:缺少标注项目ID");
return;
}
navigate(`/data/annotation/annotate/${projectId}`);
};
const handleExport = (task: AnnotationTaskListItem) => {
setExportTask(task);
};
const handleEdit = (task: AnnotationTaskListItem) => {
setEditTask(task);
};
const handleDelete = (task: AnnotationTaskListItem) => {
Modal.confirm({
title: `确认删除标注任务「${task.name}」吗?`,
content: "删除标注任务不会删除对应数据集,但会删除该任务的所有标注结果。",
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
await deleteAnnotationTaskByIdUsingDelete(task.id);
message.success("删除成功");
fetchData();
// clear selection if deleted item was selected
setSelectedRowKeys((keys) => keys.filter((k) => k !== task.id));
setSelectedRows((rows) => rows.filter((r) => r.id !== task.id));
} catch (e) {
console.error(e);
message.error("删除失败,请稍后重试");
}
},
});
};
const handleBatchDelete = () => {
if (!selectedRows || selectedRows.length === 0) return;
Modal.confirm({
title: `确认删除所选 ${selectedRows.length} 个标注任务吗?`,
content: "删除标注任务不会删除对应数据集,但会删除这些任务的所有标注结果。",
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
await Promise.all(
selectedRows.map((r) => deleteAnnotationTaskByIdUsingDelete(r.id))
);
message.success("批量删除已完成");
fetchData();
setSelectedRowKeys([]);
setSelectedRows([]);
} catch (e) {
console.error(e);
message.error("批量删除失败,请稍后重试");
}
},
});
};
const operations: AnnotationTaskOperation[] = [
{
key: "annotate",
label: "标注",
icon: (
<EditOutlined
className="w-4 h-4 text-green-400"
style={{ color: "#52c41a" }}
/>
),
onClick: handleAnnotate,
},
{
key: "edit",
label: "编辑",
icon: <FormOutlined className="w-4 h-4" style={{ color: "#722ed1" }} />,
onClick: handleEdit,
},
{
key: "export",
label: "导出",
icon: <DownloadOutlined className="w-4 h-4" style={{ color: "#1890ff" }} />,
onClick: handleExport,
},
{
key: "delete",
label: "删除",
icon: <DeleteOutlined style={{ color: "#f5222d" }} />,
onClick: handleDelete,
},
];
const columns: ColumnType<AnnotationTaskListItem>[] = [
{
title: "序号",
key: "index",
width: 80,
align: "center" as const,
render: (_value: unknown, _record: AnnotationTaskListItem, index: number) => {
const current = pagination.current ?? 1;
const pageSize = pagination.pageSize ?? tableData.length ?? 0;
return (current - 1) * pageSize + index + 1;
},
},
{
title: "任务名称",
dataIndex: "name",
key: "name",
fixed: "left" as const,
},
{
title: "数据集",
dataIndex: "datasetName",
key: "datasetName",
width: 180,
},
{
title: "标注类型",
dataIndex: "labelingType",
key: "labelingType",
width: 160,
render: (value?: string) => {
if (!value) {
return "-";
}
const label =
AnnotationTypeMap[value as keyof typeof AnnotationTypeMap]?.label ||
value;
return <Tag color="geekblue">{label}</Tag>;
},
},
{
title: "数据量",
dataIndex: "totalCount",
key: "totalCount",
width: 100,
align: "center" as const,
},
{
title: "已标注",
dataIndex: "annotatedCount",
key: "annotatedCount",
width: 100,
align: "center" as const,
render: (value: number, record: AnnotationTaskListItem) => {
const total = toSafeCount(record.totalCount ?? record.total_count);
const annotatedRaw = toSafeCount(
value ?? record.annotatedCount ?? record.annotated_count
);
const segmentationEnabled =
record.segmentationEnabled ?? record.segmentation_enabled;
const inProgressRaw = segmentationEnabled
? toSafeCount(record.inProgressCount ?? record.in_progress_count)
: 0;
const shouldExcludeInProgress =
total > 0 && annotatedRaw + inProgressRaw > total;
const annotated = shouldExcludeInProgress
? Math.max(annotatedRaw - inProgressRaw, 0)
: annotatedRaw;
const percent = total > 0 ? Math.round((annotated / total) * 100) : 0;
return (
<span title={`${annotated}/${total} (${percent}%)`}>
{annotated}
</span>
);
},
},
{
title: "标注中",
dataIndex: "inProgressCount",
key: "inProgressCount",
width: 100,
align: "center" as const,
render: (value: number, record: AnnotationTaskListItem) => {
const segmentationEnabled =
record.segmentationEnabled ?? record.segmentation_enabled;
if (!segmentationEnabled) return "-";
const resolved =
Number.isFinite(value)
? value
: record.inProgressCount ?? record.in_progress_count ?? 0;
return resolved;
},
},
{
title: "创建时间",
dataIndex: "createdAt",
key: "createdAt",
width: 180,
},
{
title: "更新时间",
dataIndex: "updatedAt",
key: "updatedAt",
width: 180,
},
{
title: "操作",
key: "actions",
fixed: "right" as const,
width: 150,
dataIndex: "actions",
render: (_value: unknown, task: AnnotationTaskListItem) => (
<div className="flex items-center justify-center space-x-1">
{operations.map((operation) => (
<Button
key={operation.key}
type="text"
icon={operation.icon}
onClick={() => operation.onClick(task)}
title={operation.label}
/>
))}
</div>
),
},
];
return (
<div className="flex flex-col h-full gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
</div>
{/* Tabs */}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: "tasks",
label: "标注任务",
children: (
<div className="flex flex-col gap-4">
{/* Search, Filters and Buttons in one row */}
<div className="flex items-center justify-between gap-2">
{/* Left side: Search and view controls */}
<div className="flex items-center gap-2">
<SearchControls
searchTerm={searchParams.keyword}
onSearchChange={handleKeywordChange}
searchPlaceholder="搜索任务名称、描述"
onFiltersChange={handleFiltersChange}
viewMode={viewMode}
onViewModeChange={setViewMode}
showViewToggle={true}
onReload={fetchData}
/>
</div>
{/* Right side: All action buttons */}
<div className="flex items-center gap-2">
<Button
danger
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setShowCreateDialog(true)}
>
</Button>
</div>
</div>
{/* Task List/Card */}
{viewMode === "list" ? (
<Card>
<Table
key="id"
rowKey="id"
loading={loading}
columns={columns}
dataSource={tableData}
pagination={pagination}
rowSelection={{
selectedRowKeys,
onChange: (keys: AnnotationTaskRowKey[], rows: AnnotationTaskListItem[]) => {
setSelectedRowKeys(keys);
setSelectedRows(rows);
},
}}
scroll={{ x: "max-content", y: "calc(100vh - 24rem)" }}
/>
</Card>
) : (
<CardView
data={tableData}
operations={operations}
pagination={pagination}
loading={loading}
/>
)}
<CreateAnnotationTask
open={showCreateDialog || !!editTask}
onClose={() => {
setShowCreateDialog(false);
setEditTask(null);
}}
onRefresh={() => fetchData()}
editTask={editTask}
/>
<ExportAnnotationDialog
open={!!exportTask}
projectId={exportTask?.id || ""}
projectName={exportTask?.name || ""}
onClose={() => setExportTask(null)}
/>
</div>
),
},
{
key: "templates",
label: "标注模板",
children: <TemplateList />,
},
]}
/>
</div>
);
}