You've already forked DataMate
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
);
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 智能预标注相关接口
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button, Card, Badge, Breadcrumb } from "antd";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
Users,
|
||||
Scissors,
|
||||
@@ -16,10 +15,10 @@ import {
|
||||
mockTasks,
|
||||
presetEvaluationDimensions,
|
||||
} from "@/mock/evaluation";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { Link } from "react-router";
|
||||
|
||||
const EvaluationTaskReport = () => {
|
||||
const navigate = useNavigate();
|
||||
// const navigate = useNavigate();
|
||||
const selectedTask = mockTasks[0]; // 假设我们只展示第一个任务的报告
|
||||
|
||||
// 获取任务的所有维度
|
||||
@@ -131,7 +130,7 @@ const EvaluationTaskReport = () => {
|
||||
评估结果
|
||||
</span>
|
||||
}
|
||||
bodyStyle={{ paddingTop: 0 }}
|
||||
styles={{ body: { paddingTop: 0 } }}
|
||||
>
|
||||
{/* 维度评分 */}
|
||||
<div className="mt-4">
|
||||
@@ -198,7 +197,7 @@ const EvaluationTaskReport = () => {
|
||||
切片信息
|
||||
</span>
|
||||
}
|
||||
bodyStyle={{ paddingTop: 0 }}
|
||||
styles={{ body: { paddingTop: 0 } }}
|
||||
>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
@@ -257,7 +256,7 @@ const EvaluationTaskReport = () => {
|
||||
QA对详情
|
||||
</span>
|
||||
}
|
||||
bodyStyle={{ paddingTop: 0 }}
|
||||
styles={{ body: { paddingTop: 0 } }}
|
||||
>
|
||||
<div className="space-y-4 mt-4">
|
||||
{mockQAPairs.map((qa) => (
|
||||
@@ -282,11 +281,10 @@ const EvaluationTaskReport = () => {
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`w-4 h-4 ${
|
||||
star <= qa.score
|
||||
className={`w-4 h-4 ${star <= qa.score
|
||||
? "text-yellow-400"
|
||||
: "text-gray-300"
|
||||
}`}
|
||||
}`}
|
||||
style={star <= qa.score ? { fill: "#facc15" } : {}}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -308,7 +308,7 @@ export default function WorkflowEditor({
|
||||
className="cursor-grab active:cursor-grabbing hover:shadow-md transition-shadow"
|
||||
draggable
|
||||
onDragStart={(event) => onDragStart(event, nodeType.type)}
|
||||
bodyStyle={{ padding: 16 }}
|
||||
styles={{ body: { padding: 16 } }}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
@@ -408,12 +408,12 @@ export default function WorkflowEditor({
|
||||
nds.map((node) =>
|
||||
node.id === selectedNode.id
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
name: e.target.value,
|
||||
},
|
||||
}
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
name: e.target.value,
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
);
|
||||
@@ -436,12 +436,12 @@ export default function WorkflowEditor({
|
||||
nds.map((node) =>
|
||||
node.id === selectedNode.id
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
description: e.target.value,
|
||||
},
|
||||
}
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
description: e.target.value,
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
);
|
||||
|
||||
@@ -54,12 +54,11 @@ const CustomNode = ({ data, selected }: { data: any; selected: boolean }) => {
|
||||
/>
|
||||
|
||||
<Card
|
||||
className={`w-80 transition-all duration-200 ${
|
||||
selected
|
||||
className={`w-80 transition-all duration-200 ${selected
|
||||
? "ring-2 ring-blue-500 shadow-lg"
|
||||
: "shadow-md hover:shadow-lg"
|
||||
}`}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
}`}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<div className="pb-3 bg-blue-50 border-b px-4 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -99,19 +99,15 @@ class Client:
|
||||
title: str,
|
||||
description: str = "",
|
||||
label_config: Optional[str] = None,
|
||||
data_type: str = "image"
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""创建Label Studio项目"""
|
||||
try:
|
||||
logger.debug(f"Creating Label Studio project: {title}")
|
||||
|
||||
if not label_config:
|
||||
label_config = self.get_label_config_by_type(data_type)
|
||||
|
||||
project_data = {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"label_config": label_config.strip()
|
||||
"label_config": label_config or "<View></View>"
|
||||
}
|
||||
|
||||
response = await self.client.post("/api/projects", json=project_data)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .about import router as about_router
|
||||
from .project import router as project_router
|
||||
from .task import router as task_router
|
||||
|
||||
@@ -8,5 +9,6 @@ router = APIRouter(
|
||||
tags = ["annotation"]
|
||||
)
|
||||
|
||||
router.include_router(about_router)
|
||||
router.include_router(project_router)
|
||||
router.include_router(task_router)
|
||||
@@ -0,0 +1,35 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import List, Optional
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.module.shared.schema import StandardResponse
|
||||
from app.module.dataset import DatasetManagementService
|
||||
from app.core.logging import get_logger
|
||||
from app.core.config import settings
|
||||
from app.exception import NoDatasetInfoFoundError, DatasetMappingNotFoundError
|
||||
|
||||
from ..client import LabelStudioClient
|
||||
from ..service.sync import SyncService
|
||||
from ..service.mapping import DatasetMappingService
|
||||
from ..schema import (
|
||||
ConfigResponse
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/about",
|
||||
tags=["annotation/about"]
|
||||
)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@router.get("", response_model=StandardResponse[ConfigResponse])
|
||||
async def get_config():
|
||||
"""获取配置信息"""
|
||||
return StandardResponse(
|
||||
code=200,
|
||||
message="success",
|
||||
data=ConfigResponse(
|
||||
label_studio_url=settings.label_studio_base_url,
|
||||
)
|
||||
)
|
||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.db.models import LabelingProject
|
||||
from app.module.shared.schema import StandardResponse, PaginatedData
|
||||
from app.module.dataset import DatasetManagementService
|
||||
from app.core.logging import get_logger
|
||||
@@ -12,6 +13,7 @@ from app.core.config import settings
|
||||
|
||||
from ..client import LabelStudioClient
|
||||
from ..service.mapping import DatasetMappingService
|
||||
from ..service.sync import SyncService
|
||||
from ..schema import (
|
||||
DatasetMappingCreateRequest,
|
||||
DatasetMappingCreateResponse,
|
||||
@@ -25,7 +27,7 @@ router = APIRouter(
|
||||
)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@router.post("/", response_model=StandardResponse[DatasetMappingCreateResponse], status_code=201)
|
||||
@router.post("", response_model=StandardResponse[DatasetMappingCreateResponse], status_code=201)
|
||||
async def create_mapping(
|
||||
request: DatasetMappingCreateRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
@@ -42,7 +44,8 @@ async def create_mapping(
|
||||
dm_client = DatasetManagementService(db)
|
||||
ls_client = LabelStudioClient(base_url=settings.label_studio_base_url,
|
||||
token=settings.label_studio_user_token)
|
||||
service = DatasetMappingService(db)
|
||||
mapping_service = DatasetMappingService(db)
|
||||
sync_service = SyncService(dm_client, ls_client, mapping_service)
|
||||
|
||||
logger.info(f"Create dataset mapping request: {request.dataset_id}")
|
||||
|
||||
@@ -54,24 +57,18 @@ async def create_mapping(
|
||||
detail=f"Dataset not found in DM service: {request.dataset_id}"
|
||||
)
|
||||
|
||||
# 确定数据类型(基于数据集类型)
|
||||
data_type = "image" # 默认值
|
||||
if dataset_info.type and dataset_info.type.code:
|
||||
type_code = dataset_info.type.code.lower()
|
||||
if "audio" in type_code:
|
||||
data_type = "audio"
|
||||
elif "video" in type_code:
|
||||
data_type = "video"
|
||||
elif "text" in type_code:
|
||||
data_type = "text"
|
||||
|
||||
project_name = f"{dataset_info.name}"
|
||||
project_name = request.name or \
|
||||
dataset_info.name or \
|
||||
"A new project from DataMate"
|
||||
|
||||
project_description = request.description or \
|
||||
dataset_info.description or \
|
||||
f"Imported from DM dataset {dataset_info.name} ({dataset_info.id})"
|
||||
|
||||
# 在Label Studio中创建项目
|
||||
project_data = await ls_client.create_project(
|
||||
title=project_name,
|
||||
description=dataset_info.description or f"Imported from DM dataset {dataset_info.id}",
|
||||
data_type=data_type
|
||||
description=project_description,
|
||||
)
|
||||
|
||||
if not project_data:
|
||||
@@ -97,13 +94,18 @@ async def create_mapping(
|
||||
logger.warning(f"Failed to configure local storage for project {project_id}")
|
||||
else:
|
||||
logger.info(f"Local storage configured for project {project_id}: {local_storage_path}")
|
||||
|
||||
labeling_project = LabelingProject(
|
||||
dataset_id=request.dataset_id,
|
||||
labeling_project_id=str(project_id),
|
||||
name=project_name,
|
||||
)
|
||||
|
||||
# 创建映射关系,包含项目名称(先持久化映射以获得 mapping.id)
|
||||
mapping = await mapping_service.create_mapping(labeling_project)
|
||||
|
||||
# 创建映射关系,包含项目名称
|
||||
mapping = await service.create_mapping(
|
||||
request,
|
||||
str(project_id),
|
||||
project_name
|
||||
)
|
||||
# 进行一次同步,使用创建后的 mapping.id
|
||||
await sync_service.sync_dataset_files(mapping.id, 100)
|
||||
|
||||
response_data = DatasetMappingCreateResponse(
|
||||
id=mapping.id,
|
||||
@@ -123,7 +125,7 @@ async def create_mapping(
|
||||
logger.error(f"Error while creating dataset mapping: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
@router.get("/", response_model=StandardResponse[PaginatedData[DatasetMappingResponse]])
|
||||
@router.get("", response_model=StandardResponse[PaginatedData[DatasetMappingResponse]])
|
||||
async def list_mappings(
|
||||
page: int = Query(1, ge=1, description="页码(从1开始)"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页记录数"),
|
||||
@@ -260,7 +262,7 @@ async def get_mappings_by_source(
|
||||
logger.error(f"Error getting mappings: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
@router.delete("/", response_model=StandardResponse[DeleteDatasetResponse])
|
||||
@router.delete("", response_model=StandardResponse[DeleteDatasetResponse])
|
||||
async def delete_mapping(
|
||||
m: Optional[str] = Query(None, description="映射UUID"),
|
||||
proj: Optional[str] = Query(None, description="Label Studio项目ID"),
|
||||
@@ -279,8 +281,11 @@ async def delete_mapping(
|
||||
2. 软删除数据库中的映射记录
|
||||
"""
|
||||
try:
|
||||
# Log incoming request parameters for debugging
|
||||
logger.debug(f"Delete mapping request received: m={m!r}, proj={proj!r}")
|
||||
# 至少需要提供一个参数
|
||||
if not m and not proj:
|
||||
logger.debug("Missing both 'm' and 'proj' in delete request")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Either 'm' (mapping UUID) or 'proj' (project ID) must be provided"
|
||||
@@ -300,6 +305,8 @@ async def delete_mapping(
|
||||
mapping = await service.get_mapping_by_labeling_project_id(proj)
|
||||
else:
|
||||
mapping = None
|
||||
|
||||
logger.debug(f"Mapping lookup result: {mapping}")
|
||||
|
||||
if not mapping:
|
||||
raise HTTPException(
|
||||
@@ -309,12 +316,12 @@ async def delete_mapping(
|
||||
|
||||
id = mapping.id
|
||||
labeling_project_id = mapping.labeling_project_id
|
||||
labeling_project_name = mapping.name
|
||||
|
||||
logger.debug(f"Found mapping: {id}, Label Studio project ID: {labeling_project_id}")
|
||||
|
||||
# 1. 删除 Label Studio 项目
|
||||
try:
|
||||
logger.debug(f"Deleting Label Studio project: {labeling_project_id}")
|
||||
delete_success = await ls_client.delete_project(int(labeling_project_id))
|
||||
if delete_success:
|
||||
logger.debug(f"Successfully deleted Label Studio project: {labeling_project_id}")
|
||||
@@ -326,6 +333,7 @@ async def delete_mapping(
|
||||
|
||||
# 2. 软删除映射记录
|
||||
soft_delete_success = await service.soft_delete_mapping(id)
|
||||
logger.debug(f"Soft delete result for mapping {id}: {soft_delete_success}")
|
||||
|
||||
if not soft_delete_success:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from .config import ConfigResponse
|
||||
|
||||
from .mapping import (
|
||||
_DatasetMappingBase,
|
||||
DatasetMappingCreateRequest,
|
||||
DatasetMappingCreateResponse,
|
||||
DatasetMappingUpdateRequest,
|
||||
@@ -13,7 +14,7 @@ from .sync import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"_DatasetMappingBase",
|
||||
"ConfigResponse",
|
||||
"DatasetMappingCreateRequest",
|
||||
"DatasetMappingCreateResponse",
|
||||
"DatasetMappingUpdateRequest",
|
||||
|
||||
@@ -5,7 +5,4 @@ from app.module.shared.schema import StandardResponse
|
||||
|
||||
class ConfigResponse(BaseResponseModel):
|
||||
"""配置信息响应模型"""
|
||||
app_name: str = Field(..., description="应用名称")
|
||||
version: str = Field(..., description="应用版本")
|
||||
label_studio_url: str = Field(..., description="Label Studio基础URL")
|
||||
debug: bool = Field(..., description="调试模式状态")
|
||||
label_studio_url: str = Field(..., description="Label Studio基础URL")
|
||||
@@ -1,17 +1,27 @@
|
||||
from pydantic import Field
|
||||
from pydantic import Field, BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.module.shared.schema import BaseResponseModel
|
||||
from app.module.shared.schema import StandardResponse
|
||||
|
||||
class _DatasetMappingBase(BaseResponseModel):
|
||||
"""数据集映射 基础模型"""
|
||||
dataset_id: str = Field(..., description="源数据集ID")
|
||||
|
||||
class DatasetMappingCreateRequest(_DatasetMappingBase):
|
||||
"""数据集映射 创建 请求模型"""
|
||||
pass
|
||||
class DatasetMappingCreateRequest(BaseModel):
|
||||
"""数据集映射 创建 请求模型
|
||||
|
||||
Accept both snake_case and camelCase field names from frontend JSON by
|
||||
declaring explicit aliases. Frontend sends `datasetId`, `name`,
|
||||
`description` (camelCase), so provide aliases so pydantic will map them
|
||||
to the internal attributes used in the service code (dataset_id, name,
|
||||
description).
|
||||
"""
|
||||
dataset_id: str = Field(..., alias="datasetId", description="源数据集ID")
|
||||
name: Optional[str] = Field(None, alias="name", description="标注项目名称")
|
||||
description: Optional[str] = Field(None, alias="description", description="标注项目描述")
|
||||
|
||||
class Config:
|
||||
# allow population by field name when constructing model programmatically
|
||||
allow_population_by_field_name = True
|
||||
|
||||
class DatasetMappingCreateResponse(BaseResponseModel):
|
||||
"""数据集映射 创建 响应模型"""
|
||||
@@ -23,7 +33,8 @@ class DatasetMappingUpdateRequest(BaseResponseModel):
|
||||
"""数据集映射 更新 请求模型"""
|
||||
dataset_id: Optional[str] = Field(None, description="源数据集ID")
|
||||
|
||||
class DatasetMappingResponse(_DatasetMappingBase):
|
||||
class DatasetMappingResponse(BaseModel):
|
||||
dataset_id: str = Field(..., description="源数据集ID")
|
||||
"""数据集映射 查询 响应模型"""
|
||||
id: str = Field(..., description="映射UUID")
|
||||
labeling_project_id: str = Field(..., description="标注项目ID")
|
||||
|
||||
@@ -23,18 +23,16 @@ class DatasetMappingService:
|
||||
|
||||
async def create_mapping(
|
||||
self,
|
||||
mapping_data: DatasetMappingCreateRequest,
|
||||
labeling_project_id: str,
|
||||
labeling_project_name: str
|
||||
labeling_project: LabelingProject
|
||||
) -> DatasetMappingResponse:
|
||||
"""创建数据集映射"""
|
||||
logger.info(f"Create dataset mapping: {mapping_data.dataset_id} -> {labeling_project_id}")
|
||||
logger.info(f"Create dataset mapping: {labeling_project.dataset_id} -> {labeling_project.labeling_project_id}")
|
||||
|
||||
db_mapping = LabelingProject(
|
||||
id=str(uuid.uuid4()),
|
||||
dataset_id=mapping_data.dataset_id,
|
||||
labeling_project_id=labeling_project_id,
|
||||
name=labeling_project_name
|
||||
dataset_id=labeling_project.dataset_id,
|
||||
labeling_project_id=labeling_project.labeling_project_id,
|
||||
name=labeling_project.name
|
||||
)
|
||||
|
||||
self.db.add(db_mapping)
|
||||
|
||||
@@ -94,22 +94,22 @@ class SyncService:
|
||||
|
||||
async def sync_dataset_files(
|
||||
self,
|
||||
id: str,
|
||||
mapping_id: str,
|
||||
batch_size: int = 50
|
||||
) -> SyncDatasetResponse:
|
||||
"""同步数据集文件到Label Studio"""
|
||||
logger.info(f"Start syncing dataset by mapping: {id}")
|
||||
logger.info(f"Start syncing dataset by mapping: {mapping_id}")
|
||||
|
||||
# 获取映射关系
|
||||
mapping = await self.mapping_service.get_mapping_by_uuid(id)
|
||||
mapping = await self.mapping_service.get_mapping_by_uuid(mapping_id)
|
||||
if not mapping:
|
||||
logger.error(f"Dataset mapping not found: {id}")
|
||||
logger.error(f"Dataset mapping not found: {mapping_id}")
|
||||
return SyncDatasetResponse(
|
||||
id="",
|
||||
status="error",
|
||||
synced_files=0,
|
||||
total_files=0,
|
||||
message=f"Dataset mapping not found: {id}"
|
||||
message=f"Dataset mapping not found: {mapping_id}"
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Dict, Any
|
||||
from app.core.config import settings
|
||||
from app.module.shared.schema import StandardResponse
|
||||
|
||||
from ..schema import ConfigResponse, HealthResponse
|
||||
from ..schema import HealthResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -19,18 +19,4 @@ async def health_check():
|
||||
service="Label Studio Adapter",
|
||||
version=settings.app_version
|
||||
)
|
||||
)
|
||||
|
||||
@router.get("/config", response_model=StandardResponse[ConfigResponse])
|
||||
async def get_config():
|
||||
"""获取配置信息"""
|
||||
return StandardResponse(
|
||||
code=200,
|
||||
message="success",
|
||||
data=ConfigResponse(
|
||||
app_name=settings.app_name,
|
||||
version=settings.app_version,
|
||||
label_studio_url=settings.label_studio_base_url,
|
||||
debug=settings.debug
|
||||
)
|
||||
)
|
||||
@@ -1,4 +1,3 @@
|
||||
from .config import ConfigResponse
|
||||
from .health import HealthResponse
|
||||
|
||||
__all__ = ["ConfigResponse", "HealthResponse"]
|
||||
__all__ = ["HealthResponse"]
|
||||
Reference in New Issue
Block a user