You've already forked DataMate
396 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|