feat: data annotation page adaptation to backend API. Improve labeling project creation module.

* feat: data annotation page adaptation to the backend API.

* feat: Implement labeling configuration editor and enhance annotation task creation form
This commit is contained in:
Jason Wang
2025-10-31 15:56:29 +08:00
committed by GitHub
parent 452d279195
commit ba0c69086a
20 changed files with 737 additions and 194 deletions

View File

@@ -1,14 +1,12 @@
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
import {
datasetTypeMap,
mapDataset,
} from "@/pages/DataManagement/dataset.const";
import { Button, Form, Input, Modal, Select } from "antd";
import { mapDataset } from "@/pages/DataManagement/dataset.const";
import { Button, Form, Input, Modal, Select, message } from "antd";
import TextArea from "antd/es/input/TextArea";
import { Database } from "lucide-react";
import { useEffect, useState } from "react";
import { createAnnotationTaskUsingPost } from "../../annotation.api";
import { Dataset } from "@/pages/DataManagement/dataset.model";
import LabelingConfigEditor from "./LabelingConfigEditor";
import { useRef } from "react";
export default function CreateAnnotationTask({
open,
@@ -21,6 +19,10 @@ export default function CreateAnnotationTask({
}) {
const [form] = Form.useForm();
const [datasets, setDatasets] = useState<Dataset[]>([]);
const [submitting, setSubmitting] = useState(false);
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
const editorRef = useRef<any>(null);
const EDITOR_LIST_HEIGHT = 420;
useEffect(() => {
if (!open) return;
@@ -34,11 +36,36 @@ export default function CreateAnnotationTask({
fetchDatasets();
}, [open]);
// Reset form and manual-edit flag when modal opens
useEffect(() => {
if (open) {
form.resetFields();
setNameManuallyEdited(false);
}
}, [open, form]);
const handleSubmit = async () => {
const values = await form.validateFields();
await createAnnotationTaskUsingPost(values);
onClose();
onRefresh();
try {
const values = await form.validateFields();
setSubmitting(true);
await createAnnotationTaskUsingPost(values);
message?.success?.("创建标注任务成功");
onClose();
onRefresh();
} catch (err: any) {
console.error("Create annotation task failed", err);
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
// show a user friendly message
(message as any)?.error?.(msg);
} finally {
setSubmitting(false);
}
};
// Placeholder function: generates labeling interface from config
// For now it simply returns the parsed config (per requirement)
const generateLabelingInterface = (config: any) => {
return config;
};
return (
@@ -48,51 +75,124 @@ export default function CreateAnnotationTask({
title="创建标注任务"
footer={
<>
<Button onClick={onClose}></Button>
<Button type="primary" onClick={handleSubmit}>
<Button onClick={onClose} disabled={submitting}>
</Button>
<Button type="primary" onClick={handleSubmit} loading={submitting}>
</Button>
</>
}
width={1200}
>
<Form layout="vertical">
<Form.Item
label="名称"
name="name"
rules={[{ required: true, message: "请输入任务名称" }]}
>
<Input placeholder="输入任务名称" />
</Form.Item>
<Form.Item
label="描述"
name="description"
rules={[{ required: true, message: "请输入任务描述" }]}
>
<TextArea placeholder="详细描述标注任务的要求和目标" rows={3} />
</Form.Item>
<Form.Item
label="数据集"
name="datasetId"
rules={[{ required: true, message: "请选择数据集" }]}
>
<Select
placeholder="请选择数据集"
options={datasets.map((dataset) => {
return {
label: (
<div className="flex items-center justify-between gap-3 py-2">
<div className="flex items-center font-sm text-gray-900">
<span className="mr-2">{dataset.icon}</span>
<span>{dataset.name}</span>
<Form form={form} layout="vertical">
{/* 数据集 与 标注工程名称 并排显示(数据集在左) */}
<div className="grid grid-cols-2 gap-4">
<Form.Item
label="数据集"
name="datasetId"
rules={[{ required: true, message: "请选择数据集" }]}
>
<Select
placeholder="请选择数据集"
options={datasets.map((dataset) => {
return {
label: (
<div className="flex items-center justify-between gap-3 py-2">
<div className="flex items-center font-sm text-gray-900">
<span className="mr-2">{(dataset as any).icon}</span>
<span>{dataset.name}</span>
</div>
<div className="text-xs text-gray-500">{dataset.size}</div>
</div>
<div className="text-xs text-gray-500">{dataset.size}</div>
</div>
),
value: dataset.id,
};
})}
/>
),
value: dataset.id,
};
})}
onChange={(value) => {
// 如果用户未手动修改名称,则用数据集名称作为默认任务名
if (!nameManuallyEdited) {
const ds = datasets.find((d) => d.id === value);
if (ds) {
form.setFieldsValue({ name: ds.name });
}
}
}}
/>
</Form.Item>
<Form.Item
label="标注工程名称"
name="name"
rules={[{ required: true, message: "请输入任务名称" }]}
>
<Input
placeholder="输入标注工程名称"
onChange={() => setNameManuallyEdited(true)}
/>
</Form.Item>
</div>
{/* 描述变为可选 */}
<Form.Item label="描述" name="description">
<TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={3} />
</Form.Item>
{/* 标注页面设计 模块:左侧为配置编辑,右侧为预览(作为表单的一部分,与其他字段同级) */}
<div style={{ marginTop: 8 }}>
<label className="block font-medium mb-2"></label>
<div style={{ display: "grid", gridTemplateColumns: "minmax(360px, 1fr) 1fr", gridTemplateRows: "auto 1fr", gap: 16 }}>
{/* Row 1: buttons on the left, spacer on the right so preview aligns with editor below */}
<div style={{ gridColumn: 1, gridRow: 1, display: 'flex', gap: 8 }}>
<Button onClick={() => editorRef.current?.addLabel?.()}></Button>
<Button type="primary" onClick={() => editorRef.current?.generate?.()}></Button>
</div>
{/* empty spacer to occupy top-right cell so preview starts on the second row */}
<div style={{ gridColumn: 2, gridRow: 1 }} />
{/* Row 2, Col 1: 编辑列表(固定高度) */}
<div style={{ gridColumn: 1, gridRow: 2, height: EDITOR_LIST_HEIGHT, overflowY: 'auto', paddingRight: 8, border: '1px solid #e6e6e6', borderRadius: 6, padding: 12 }}>
<LabelingConfigEditor
ref={editorRef}
hideFooter={true}
initial={undefined}
onGenerate={(config: any) => {
form.setFieldsValue({ labelingConfig: JSON.stringify(config, null, 2), labelingInterface: JSON.stringify(generateLabelingInterface(config), null, 2) });
}}
/>
<Form.Item
name="labelingConfig"
rules={[
{
validator: async (_, value) => {
if (!value || value === "") return Promise.resolve();
try {
JSON.parse(value);
return Promise.resolve();
} catch (e) {
return Promise.reject(new Error("请输入有效的 JSON"));
}
},
},
]}
style={{ display: "none" }}
>
<Input />
</Form.Item>
</div>
{/* Row 2, Col 2: 预览,与编辑列表在同一行,保持一致高度 */}
<div style={{ gridColumn: 2, gridRow: 2, display: 'flex', flexDirection: 'column' }}>
<Form.Item name="labelingInterface" style={{ flex: 1 }}>
<TextArea
placeholder="标注页面设计(只读,由标注配置生成)"
disabled
style={{ height: EDITOR_LIST_HEIGHT, resize: 'none' }}
/>
</Form.Item>
</div>
</div>
</div>
</Form>
</Modal>
);

View File

@@ -0,0 +1,187 @@
import { Button, Card, Input, InputNumber, Popconfirm, Select, Switch, Tooltip } from "antd";
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
import { useState, useImperativeHandle, forwardRef } from "react";
type LabelType = "string" | "number" | "enum";
type LabelItem = {
id: string;
name: string;
type: LabelType;
required?: boolean;
// for enum: values; for number: min/max
values?: string[];
min?: number | null;
max?: number | null;
step?: number | null;
};
type LabelingConfigEditorProps = {
initial?: any;
onGenerate: (config: any) => void;
hideFooter?: boolean;
};
export default forwardRef<any, LabelingConfigEditorProps>(function LabelingConfigEditor(
{ initial, onGenerate, hideFooter }: LabelingConfigEditorProps,
ref: any
) {
const [labels, setLabels] = useState<LabelItem[]>(() => {
if (initial && initial.labels && Array.isArray(initial.labels)) {
return initial.labels.map((l: any, idx: number) => ({
id: `${Date.now()}-${idx}`,
name: l.name || "",
type: l.type || "string",
required: !!l.required,
values: l.values || (l.type === "enum" ? [] : undefined),
min: l.min ?? null,
max: l.max ?? null,
}));
}
return [];
});
const addLabel = () => {
setLabels((s) => [
...s,
{ id: `${Date.now()}-${Math.random()}`, name: "", type: "string", required: false, step: null },
]);
};
const updateLabel = (id: string, patch: Partial<LabelItem>) => {
setLabels((s) => s.map((l) => (l.id === id ? { ...l, ...patch } : l)));
};
const removeLabel = (id: string) => {
setLabels((s) => s.filter((l) => l.id !== id));
};
const generate = () => {
// basic validation: label name non-empty
for (const l of labels) {
if (!l.name || l.name.trim() === "") {
// focus not available here, just abort
// Could show a more friendly UI; keep simple for now
// eslint-disable-next-line no-alert
alert("请为所有标签填写名称");
return;
}
if (l.type === "enum") {
if (!l.values || l.values.length === 0) {
alert(`枚举标签 ${l.name} 需要至少一个取值`);
return;
}
}
if (l.type === "number") {
// validate min/max
if (l.min != null && l.max != null && l.min > l.max) {
alert(`数值标签 ${l.name} 的最小值不能大于最大值`);
return;
}
// validate step
if (l.step != null && (!(typeof l.step === "number") || l.step <= 0)) {
alert(`数值标签 ${l.name} 的步长必须为大于 0 的数值`);
return;
}
}
}
const config = {
labels: labels.map((l) => {
const item: any = { name: l.name, type: l.type, required: !!l.required };
if (l.type === "enum") item.values = l.values || [];
if (l.type === "number") {
if (l.min != null) item.min = l.min;
if (l.max != null) item.max = l.max;
}
return item;
}),
};
onGenerate(config);
};
useImperativeHandle(ref, () => ({
addLabel,
generate,
}));
return (
<div>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{labels.map((label) => (
<Card size="small" key={label.id} styles={{ body: { padding: 10 } }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<Input
placeholder="标签名称"
value={label.name}
onChange={(e) => updateLabel(label.id, { name: e.target.value })}
style={{ flex: "1 1 160px", minWidth: 120 }}
/>
<Select
value={label.type}
onChange={(v) => updateLabel(label.id, { type: v as LabelType })}
options={[{ label: "文本", value: "string" }, { label: "数值", value: "number" }, { label: "枚举", value: "enum" }]}
style={{ width: 120, flex: "0 0 120px" }}
/>
{label.type === "enum" && (
<Input.TextArea
placeholder="每行一个枚举值,按回车换行"
value={(label.values || []).join("\n")}
onChange={(e) => updateLabel(label.id, { values: e.target.value.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) })}
onKeyDown={(e) => {
// Prevent parent handlers (like Form submit or modal shortcuts) from intercepting Enter
e.stopPropagation();
}}
rows={3}
style={{ flex: "1 1 220px", minWidth: 160, width: "100%", resize: "vertical" }}
/>
)}
{label.type === "number" && (
<div style={{ display: "flex", gap: 8, alignItems: "center", flex: "0 0 auto" }}>
<Tooltip title="最小值">
<InputNumber value={label.min ?? null} onChange={(v) => updateLabel(label.id, { min: v ?? null })} placeholder="min" />
</Tooltip>
<Tooltip title="最大值">
<InputNumber value={label.max ?? null} onChange={(v) => updateLabel(label.id, { max: v ?? null })} placeholder="max" />
</Tooltip>
<Tooltip title="步长 (step)">
<InputNumber value={label.step ?? null} onChange={(v) => updateLabel(label.id, { step: v ?? null })} placeholder="step" min={0} />
</Tooltip>
</div>
)}
<div style={{ display: "flex", alignItems: "center", gap: 8, marginLeft: "auto" }}>
<span style={{ fontSize: 12, color: "rgba(0,0,0,0.65)" }}></span>
<Switch checked={!!label.required} onChange={(v) => updateLabel(label.id, { required: v })} />
<Popconfirm title="确认删除该标签?" onConfirm={() => removeLabel(label.id)}>
<Button type="text" icon={<DeleteOutlined />} />
</Popconfirm>
</div>
</div>
<div style={{ marginTop: 8, color: "rgba(0,0,0,0.45)", fontSize: 12 }}>
{label.type === "string" && <span></span>}
{label.type === "number" && <span> min / max / step</span>}
{label.type === "enum" && <span></span>}
</div>
</Card>
))}
{!hideFooter && (
<div style={{ display: "flex", gap: 8 }}>
<Button icon={<PlusOutlined />} onClick={addLabel}>
</Button>
<Button type="primary" onClick={generate}>
JSON
</Button>
</div>
)}
</div>
</div>
);
}
);

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { Card, Button, Table, message } from "antd";
import { useState, useEffect } from "react";
import { Card, Button, Table, message, Modal } from "antd";
import {
PlusOutlined,
EditOutlined,
@@ -8,22 +8,22 @@ import {
} from "@ant-design/icons";
import { SearchControls } from "@/components/SearchControls";
import CardView from "@/components/CardView";
import { useNavigate } from "react-router";
import type { AnnotationTask } from "../annotation.model";
import useFetchData from "@/hooks/useFetchData";
import {
deleteAnnotationTaskByIdUsingDelete,
queryAnnotationTasksUsingGet,
syncAnnotationTaskUsingPost,
getConfigUsingGet,
} from "../annotation.api";
import { mapAnnotationTask } from "../annotation.const";
import CreateAnnotationTask from "../Create/components/CreateAnnptationTaskDialog";
import { ColumnType } from "antd/es/table";
import DevelopmentInProgress from "@/components/DevelopmentInProgress";
// Note: DevelopmentInProgress intentionally not used here
export default function DataAnnotation() {
return <DevelopmentInProgress showTime="2025.10.30" />;
const navigate = useNavigate();
// return <DevelopmentInProgress showTime="2025.10.30" />;
// navigate not needed for label studio external redirect
const [viewMode, setViewMode] = useState<"list" | "card">("list");
const [showCreateDialog, setShowCreateDialog] = useState(false);
@@ -35,22 +35,182 @@ export default function DataAnnotation() {
setSearchParams,
fetchData,
handleFiltersChange,
} = useFetchData(queryAnnotationTasksUsingGet, mapAnnotationTask);
} = useFetchData(queryAnnotationTasksUsingGet, mapAnnotationTask, 30000, true, [], 0);
const [labelStudioBase, setLabelStudioBase] = useState<string | null>(null);
const [selectedRowKeys, setSelectedRowKeys] = useState<(string | number)[]>([]);
const [selectedRows, setSelectedRows] = useState<any[]>([]);
// prefetch config on mount so clicking annotate is fast and we know whether base URL exists
// useEffect ensures this runs once
useEffect(() => {
let mounted = true;
(async () => {
try {
const cfg = await getConfigUsingGet();
const url = cfg?.data?.labelStudioUrl || "";
if (mounted) setLabelStudioBase((url).replace(/\/+$/, "") || null);
} catch (e) {
if (mounted) setLabelStudioBase(null);
}
})();
return () => {
mounted = false;
};
}, []);
const handleAnnotate = (task: AnnotationTask) => {
navigate(`/data/annotation/task-annotate/${task.datasetType}/${task.id}`);
// Open Label Studio project page in a new tab
(async () => {
try {
// prefer using labeling project id already present on the task
// `mapAnnotationTask` normalizes upstream fields into `labelingProjId`/`projId`,
// so prefer those and fall back to the task id if necessary.
let labelingProjId = (task as any).labelingProjId || (task as any).projId || undefined;
// no fallback external mapping lookup; rely on normalized fields from mapAnnotationTask
// use prefetched base if available
const base = labelStudioBase;
// no debug logging in production
if (labelingProjId) {
// only open external Label Studio when we have a configured base url
if (base) {
const target = `${base}/projects/${labelingProjId}/data`;
window.open(target, "_blank");
} else {
// no external Label Studio URL configured — do not perform internal redirect in this version
message.error("无法跳转到 Label Studio:未配置 Label Studio 基础 URL");
return;
}
} else {
// no labeling project id available — do not attempt internal redirect in this version
message.error("无法跳转到 Label Studio:该映射未绑定标注项目");
return;
}
} catch (error) {
// on error, surface a user-friendly message instead of redirecting
message.error("无法跳转到 Label Studio:发生错误,请检查配置或控制台日志");
return;
}
})();
};
const handleDelete = async (task: AnnotationTask) => {
await deleteAnnotationTaskByIdUsingDelete({
m: task.id,
proj: task.projId,
const handleDelete = (task: AnnotationTask) => {
Modal.confirm({
title: `确认删除标注任务「${task.name}」吗?`,
content: (
<div>
<div></div>
<div></div>
</div>
),
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
await deleteAnnotationTaskByIdUsingDelete({ m: task.id, proj: task.labelingProjId });
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 handleSync = async (task: AnnotationTask, format: string) => {
await syncAnnotationTaskUsingPost({ task, format });
message.success("任务同步请求已发送");
const handleSync = (task: AnnotationTask, batchSize: number = 50) => {
Modal.confirm({
title: `确认同步标注任务「${task.name}」吗?`,
content: (
<div>
<div></div>
<div></div>
</div>
),
okText: "同步",
cancelText: "取消",
onOk: async () => {
try {
await syncAnnotationTaskUsingPost({ id: task.id, batchSize });
message.success("任务同步请求已发送");
// optional: refresh list/status
fetchData();
// clear selection for the task
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 handleBatchSync = (batchSize: number = 50) => {
if (!selectedRows || selectedRows.length === 0) return;
Modal.confirm({
title: `确认同步所选 ${selectedRows.length} 个标注任务吗?`,
content: (
<div>
<div></div>
<div></div>
</div>
),
okText: "同步",
cancelText: "取消",
onOk: async () => {
try {
await Promise.all(
selectedRows.map((r) => syncAnnotationTaskUsingPost({ id: r.id, batchSize }))
);
message.success("批量同步请求已发送");
fetchData();
setSelectedRowKeys([]);
setSelectedRows([]);
} catch (e) {
console.error(e);
message.error("批量同步失败,请稍后重试");
}
},
});
};
const handleBatchDelete = () => {
if (!selectedRows || selectedRows.length === 0) return;
Modal.confirm({
title: `确认删除所选 ${selectedRows.length} 个标注任务吗?`,
content: (
<div>
<div></div>
<div></div>
</div>
),
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
await Promise.all(
selectedRows.map((r) => deleteAnnotationTaskByIdUsingDelete({ m: r.id, proj: r.labelingProjId }))
);
message.success("批量删除已完成");
fetchData();
setSelectedRowKeys([]);
setSelectedRows([]);
} catch (e) {
console.error(e);
message.error("批量删除失败,请稍后重试");
}
},
});
};
const operations = [
@@ -79,7 +239,7 @@ export default function DataAnnotation() {
},
];
const columns: ColumnType[] = [
const columns: ColumnType<any>[] = [
{
title: "任务名称",
dataIndex: "name",
@@ -115,14 +275,14 @@ export default function DataAnnotation() {
fixed: "right" as const,
width: 150,
dataIndex: "actions",
render: (_: any, task: AnnotationTask) => (
render: (_: any, task: any) => (
<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)}
onClick={() => (operation?.onClick as any)?.(task)}
title={operation.label}
/>
))}
@@ -136,13 +296,31 @@ export default function DataAnnotation() {
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setShowCreateDialog(true)}
>
</Button>
<div className="flex items-center space-x-2">
{/* Batch action buttons - availability depends on selection count */}
<div className="flex items-center space-x-1">
<Button
onClick={() => handleBatchSync(50)}
disabled={selectedRowKeys.length === 0}
>
</Button>
<Button
danger
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
>
</Button>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setShowCreateDialog(true)}
>
</Button>
</div>
</div>
{/* Filters Toolbar */}
@@ -163,15 +341,23 @@ export default function DataAnnotation() {
<Card>
<Table
key="id"
rowKey="id"
loading={loading}
columns={columns}
dataSource={tableData}
pagination={pagination}
rowSelection={{
selectedRowKeys,
onChange: (keys, rows) => {
setSelectedRowKeys(keys as (string | number)[]);
setSelectedRows(rows as any[]);
},
}}
scroll={{ x: "max-content", y: "calc(100vh - 20rem)" }}
/>
</Card>
) : (
<CardView data={tableData} operations={operations} />
<CardView data={tableData} operations={operations as any} pagination={pagination} loading={loading} />
)}
<CreateAnnotationTask
open={showCreateDialog}

View File

@@ -2,22 +2,41 @@ import { get, post, put, del, download } from "@/utils/request";
// 标注任务管理相关接口
export function queryAnnotationTasksUsingGet(params?: any) {
return get("/project/mappings/list", params);
return get("/api/annotation/project", params);
}
// 获取应用配置(包含 Label Studio 基础 URL)
export function getConfigUsingGet() {
return get("/api/annotation/about");
}
export function createAnnotationTaskUsingPost(data: any) {
return post("/api/project/create", data);
return post("/api/annotation/project", data);
}
export function syncAnnotationTaskUsingPost(data: any) {
return post(`/api/project/sync`, data);
return post(`/api/annotation/task/sync`, data);
}
export function queryAnnotationTaskByIdUsingGet(taskId: string | number) {
return get(`/api/v1/annotation/tasks/${taskId}`);
export function queryAnnotationTaskByIdUsingGet(mappingId: string | number) {
return get(`/api/annotation/project/${mappingId}`);
}
// 根据源 datasetId 查询映射关系(分页)
export function queryMappingsBySourceUsingGet(datasetId: string, params?: any) {
return get(`/api/annotation/project/by-source/${datasetId}`, params);
}
export function deleteAnnotationTaskByIdUsingDelete(params?: any) {
return del(`/api/project/mappings`, params);
// Ensure query params are sent in the URL for backend endpoints that expect Query parameters
if (params && typeof params === "object" && !Array.isArray(params)) {
const pairs = Object.keys(params)
.filter((k) => params[k] !== undefined && params[k] !== null)
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`);
const query = pairs.length ? `?${pairs.join("&")}` : "";
return del(`/api/annotation/project${query}`);
}
return del(`/api/annotation/project`, params);
}
// 智能预标注相关接口

View File

@@ -1,5 +1,5 @@
import { StickyNote } from "lucide-react";
import { AnnotationTask, AnnotationTaskStatus } from "./annotation.model";
import { AnnotationTaskStatus } from "./annotation.model";
import {
CheckCircleOutlined,
ClockCircleOutlined,
@@ -31,26 +31,41 @@ export const AnnotationTaskStatusMap = {
},
};
export function mapAnnotationTask(task: AnnotationTask) {
export function mapAnnotationTask(task: any) {
// Normalize labeling project id from possible backend field names
const labelingProjId = task?.labelingProjId || task?.labelingProjectId || task?.projId || task?.labeling_project_id || "";
const statsArray = task?.statistics
? [
{ label: "准确率", value: task.statistics.accuracy ?? "-" },
{ label: "平均时长", value: task.statistics.averageTime ?? "-" },
{ label: "待复核", value: task.statistics.reviewCount ?? "-" },
]
: [];
return {
...task,
id: task.mapping_id,
projId: task.labelling_project_id,
name: task.labelling_project_name,
createdAt: task.created_at,
updatedAt: task.last_updated_at,
id: task.id,
// provide consistent field for components
labelingProjId,
projId: labelingProjId,
name: task.name,
description: task.description || "",
createdAt: task.createdAt,
updatedAt: task.updatedAt,
icon: <StickyNote />,
iconColor: "bg-blue-100",
status: {
label:
task.status === "completed"
? "已完成"
: task.status === "in_progress"
? "进行中"
: task.status === "skipped"
? "已跳过"
: "待开始",
: task.status === "processing"
? "进行中"
: task.status === "skipped"
? "已跳过"
: "待开始",
color: "bg-blue-100",
},
statistics: statsArray,
};
}

View File

@@ -2,16 +2,20 @@ import type { DatasetType } from "@/pages/DataManagement/dataset.model";
export enum AnnotationTaskStatus {
ACTIVE = "active",
PROCESSING = "processing",
INACTIVE = "inactive",
PROCESSING = "processing",
COMPLETED = "completed",
SKIPPED = "skipped",
}
export interface AnnotationTask {
id: string;
name: string;
annotationCount: number;
createdAt: string;
labelingProjId: string;
datasetId: string;
annotationCount: number;
description?: string;
assignedTo?: string;
progress: number;
@@ -23,5 +27,7 @@ export interface AnnotationTask {
status: AnnotationTaskStatus;
totalDataCount: number;
type: DatasetType;
createdAt: string;
updatedAt: string;
}