feat: fix the problem in the Operator Market frontend pages

This commit is contained in:
root
2025-12-29 11:38:47 +08:00
parent 29e4a333a9
commit 844add27ea
213 changed files with 45547 additions and 45537 deletions

View File

@@ -1,192 +1,192 @@
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
import { mapDataset } from "@/pages/DataManagement/dataset.const";
import { Button, Form, Input, Modal, Select, message } from "antd";
import TextArea from "antd/es/input/TextArea";
import { useEffect, useState } from "react";
import { createAnnotationTaskUsingPost, queryAnnotationTemplatesUsingGet } from "../../annotation.api";
import { Dataset } from "@/pages/DataManagement/dataset.model";
import type { AnnotationTemplate } from "../../annotation.model";
export default function CreateAnnotationTask({
open,
onClose,
onRefresh,
}: {
open: boolean;
onClose: () => void;
onRefresh: () => void;
}) {
const [form] = Form.useForm();
const [datasets, setDatasets] = useState<Dataset[]>([]);
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
const [submitting, setSubmitting] = useState(false);
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
useEffect(() => {
if (!open) return;
const fetchData = async () => {
try {
// Fetch datasets
const { data: datasetData } = await queryDatasetsUsingGet({
page: 0,
pageSize: 1000, // Use camelCase for HTTP params
});
setDatasets(datasetData.content.map(mapDataset) || []);
// Fetch templates
const templateResponse = await queryAnnotationTemplatesUsingGet({
page: 1,
size: 100, // Backend max is 100 (template API uses 'size' not 'pageSize')
});
// The API returns: {code, message, data: {content, total, page, ...}}
if (templateResponse.code === 200 && templateResponse.data) {
const fetchedTemplates = templateResponse.data.content || [];
console.log("Fetched templates:", fetchedTemplates);
setTemplates(fetchedTemplates);
} else {
console.error("Failed to fetch templates:", templateResponse);
setTemplates([]);
}
} catch (error) {
console.error("Error fetching data:", error);
setTemplates([]);
}
};
fetchData();
}, [open]);
// Reset form and manual-edit flag when modal opens
useEffect(() => {
if (open) {
form.resetFields();
setNameManuallyEdited(false);
}
}, [open, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
// Send templateId instead of labelingConfig
const requestData = {
name: values.name,
description: values.description,
datasetId: values.datasetId,
templateId: values.templateId,
};
await createAnnotationTaskUsingPost(requestData);
message?.success?.("创建标注任务成功");
onClose();
onRefresh();
} catch (err: any) {
console.error("Create annotation task failed", err);
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
(message as any)?.error?.(msg);
} finally {
setSubmitting(false);
}
};
return (
<Modal
open={open}
onCancel={onClose}
title="创建标注任务"
footer={
<>
<Button onClick={onClose} disabled={submitting}>
</Button>
<Button type="primary" onClick={handleSubmit} loading={submitting}>
</Button>
</>
}
width={800}
>
<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>
),
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>
{/* 标注模板选择 */}
<Form.Item
label="标注模板"
name="templateId"
rules={[{ required: true, message: "请选择标注模板" }]}
>
<Select
placeholder={templates.length === 0 ? "暂无可用模板,请先创建模板" : "请选择标注模板"}
showSearch
optionFilterProp="label"
notFoundContent={templates.length === 0 ? "暂无模板,请前往「标注模板」页面创建" : "未找到匹配的模板"}
options={templates.map((template) => ({
label: template.name,
value: template.id,
// Add description as subtitle
title: template.description,
}))}
optionRender={(option) => (
<div>
<div style={{ fontWeight: 500 }}>{option.label}</div>
{option.data.title && (
<div style={{ fontSize: 12, color: '#999', marginTop: 2 }}>
{option.data.title}
</div>
)}
</div>
)}
/>
</Form.Item>
</Form>
</Modal>
);
}
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
import { mapDataset } from "@/pages/DataManagement/dataset.const";
import { Button, Form, Input, Modal, Select, message } from "antd";
import TextArea from "antd/es/input/TextArea";
import { useEffect, useState } from "react";
import { createAnnotationTaskUsingPost, queryAnnotationTemplatesUsingGet } from "../../annotation.api";
import { Dataset } from "@/pages/DataManagement/dataset.model";
import type { AnnotationTemplate } from "../../annotation.model";
export default function CreateAnnotationTask({
open,
onClose,
onRefresh,
}: {
open: boolean;
onClose: () => void;
onRefresh: () => void;
}) {
const [form] = Form.useForm();
const [datasets, setDatasets] = useState<Dataset[]>([]);
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
const [submitting, setSubmitting] = useState(false);
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
useEffect(() => {
if (!open) return;
const fetchData = async () => {
try {
// Fetch datasets
const { data: datasetData } = await queryDatasetsUsingGet({
page: 0,
pageSize: 1000, // Use camelCase for HTTP params
});
setDatasets(datasetData.content.map(mapDataset) || []);
// Fetch templates
const templateResponse = await queryAnnotationTemplatesUsingGet({
page: 1,
size: 100, // Backend max is 100 (template API uses 'size' not 'pageSize')
});
// The API returns: {code, message, data: {content, total, page, ...}}
if (templateResponse.code === 200 && templateResponse.data) {
const fetchedTemplates = templateResponse.data.content || [];
console.log("Fetched templates:", fetchedTemplates);
setTemplates(fetchedTemplates);
} else {
console.error("Failed to fetch templates:", templateResponse);
setTemplates([]);
}
} catch (error) {
console.error("Error fetching data:", error);
setTemplates([]);
}
};
fetchData();
}, [open]);
// Reset form and manual-edit flag when modal opens
useEffect(() => {
if (open) {
form.resetFields();
setNameManuallyEdited(false);
}
}, [open, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
// Send templateId instead of labelingConfig
const requestData = {
name: values.name,
description: values.description,
datasetId: values.datasetId,
templateId: values.templateId,
};
await createAnnotationTaskUsingPost(requestData);
message?.success?.("创建标注任务成功");
onClose();
onRefresh();
} catch (err: any) {
console.error("Create annotation task failed", err);
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
(message as any)?.error?.(msg);
} finally {
setSubmitting(false);
}
};
return (
<Modal
open={open}
onCancel={onClose}
title="创建标注任务"
footer={
<>
<Button onClick={onClose} disabled={submitting}>
</Button>
<Button type="primary" onClick={handleSubmit} loading={submitting}>
</Button>
</>
}
width={800}
>
<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>
),
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>
{/* 标注模板选择 */}
<Form.Item
label="标注模板"
name="templateId"
rules={[{ required: true, message: "请选择标注模板" }]}
>
<Select
placeholder={templates.length === 0 ? "暂无可用模板,请先创建模板" : "请选择标注模板"}
showSearch
optionFilterProp="label"
notFoundContent={templates.length === 0 ? "暂无模板,请前往「标注模板」页面创建" : "未找到匹配的模板"}
options={templates.map((template) => ({
label: template.name,
value: template.id,
// Add description as subtitle
title: template.description,
}))}
optionRender={(option) => (
<div>
<div style={{ fontWeight: 500 }}>{option.label}</div>
{option.data.title && (
<div style={{ fontSize: 12, color: '#999', marginTop: 2 }}>
{option.data.title}
</div>
)}
</div>
)}
/>
</Form.Item>
</Form>
</Modal>
);
}

View File

@@ -1,195 +1,195 @@
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
import { mapDataset } from "@/pages/DataManagement/dataset.const";
import { Button, Form, Input, Modal, Select, message } from "antd";
import TextArea from "antd/es/input/TextArea";
import { useEffect, useState } from "react";
import { createAnnotationTaskUsingPost, queryAnnotationTemplatesUsingGet } from "../../annotation.api";
import { Dataset } from "@/pages/DataManagement/dataset.model";
import type { AnnotationTemplate } from "../../annotation.model";
export default function CreateAnnotationTask({
open,
onClose,
onRefresh,
}: {
open: boolean;
onClose: () => void;
onRefresh: () => void;
}) {
const [form] = Form.useForm();
const [datasets, setDatasets] = useState<Dataset[]>([]);
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
const [submitting, setSubmitting] = useState(false);
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
useEffect(() => {
if (!open) return;
const fetchData = async () => {
try {
// Fetch datasets
const { data: datasetData } = await queryDatasetsUsingGet({
page: 0,
pageSize: 1000, // Use camelCase for HTTP params
});
setDatasets(datasetData.content.map(mapDataset) || []);
// Fetch templates
const templateResponse = await queryAnnotationTemplatesUsingGet({
page: 1,
size: 100, // Backend max is 100 (template API uses 'size' not 'pageSize')
});
// The API returns: {code, message, data: {content, total, page, ...}}
if (templateResponse.code === 200 && templateResponse.data) {
const fetchedTemplates = templateResponse.data.content || [];
console.log("Fetched templates:", fetchedTemplates);
setTemplates(fetchedTemplates);
} else {
console.error("Failed to fetch templates:", templateResponse);
setTemplates([]);
}
} catch (error) {
console.error("Error fetching data:", error);
setTemplates([]);
}
};
fetchData();
}, [open]);
// Reset form and manual-edit flag when modal opens
useEffect(() => {
if (open) {
form.resetFields();
setNameManuallyEdited(false);
}
}, [open, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
// Send templateId instead of labelingConfig
const requestData = {
name: values.name,
description: values.description,
datasetId: values.datasetId,
templateId: values.templateId,
};
await createAnnotationTaskUsingPost(requestData);
message?.success?.("创建标注任务成功");
onClose();
onRefresh();
} catch (err: any) {
console.error("Create annotation task failed", err);
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
(message as any)?.error?.(msg);
} finally {
setSubmitting(false);
}
};
return (
<Modal
open={open}
onCancel={onClose}
title="创建标注任务"
footer={
<>
<Button onClick={onClose} disabled={submitting}>
</Button>
<Button type="primary" onClick={handleSubmit} loading={submitting}>
</Button>
</>
}
width={800}
>
<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>
),
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>
{/* 标注模板选择 */}
<Form.Item
label="标注模板"
name="templateId"
rules={[{ required: true, message: "请选择标注模板" }]}
>
<Select
placeholder={templates.length === 0 ? "暂无可用模板,请先创建模板" : "请选择标注模板"}
showSearch
optionFilterProp="label"
notFoundContent={templates.length === 0 ? "暂无模板,请前往「标注模板」页面创建" : "未找到匹配的模板"}
options={templates.map((template) => ({
label: template.name,
value: template.id,
// Add description as subtitle
title: template.description,
}))}
optionRender={(option) => (
<div>
<div style={{ fontWeight: 500 }}>{option.label}</div>
{option.data.title && (
<div style={{ fontSize: 12, color: '#999', marginTop: 2 }}>
{option.data.title}
</div>
)}
</div>
)}
/>
</Form.Item>
</Form>
</Modal>
);
}
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
import { mapDataset } from "@/pages/DataManagement/dataset.const";
import { Button, Form, Input, Modal, Select, message } from "antd";
import TextArea from "antd/es/input/TextArea";
import { useEffect, useState } from "react";
import { createAnnotationTaskUsingPost, queryAnnotationTemplatesUsingGet } from "../../annotation.api";
import { Dataset } from "@/pages/DataManagement/dataset.model";
import type { AnnotationTemplate } from "../../annotation.model";
export default function CreateAnnotationTask({
open,
onClose,
onRefresh,
}: {
open: boolean;
onClose: () => void;
onRefresh: () => void;
}) {
const [form] = Form.useForm();
const [datasets, setDatasets] = useState<Dataset[]>([]);
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
const [submitting, setSubmitting] = useState(false);
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
useEffect(() => {
if (!open) return;
const fetchData = async () => {
try {
// Fetch datasets
const { data: datasetData } = await queryDatasetsUsingGet({
page: 0,
pageSize: 1000, // Use camelCase for HTTP params
});
setDatasets(datasetData.content.map(mapDataset) || []);
// Fetch templates
const templateResponse = await queryAnnotationTemplatesUsingGet({
page: 1,
size: 100, // Backend max is 100 (template API uses 'size' not 'pageSize')
});
// The API returns: {code, message, data: {content, total, page, ...}}
if (templateResponse.code === 200 && templateResponse.data) {
const fetchedTemplates = templateResponse.data.content || [];
console.log("Fetched templates:", fetchedTemplates);
setTemplates(fetchedTemplates);
} else {
console.error("Failed to fetch templates:", templateResponse);
setTemplates([]);
}
} catch (error) {
console.error("Error fetching data:", error);
setTemplates([]);
}
};
fetchData();
}, [open]);
// Reset form and manual-edit flag when modal opens
useEffect(() => {
if (open) {
form.resetFields();
setNameManuallyEdited(false);
}
}, [open, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
// Send templateId instead of labelingConfig
const requestData = {
name: values.name,
description: values.description,
datasetId: values.datasetId,
templateId: values.templateId,
};
await createAnnotationTaskUsingPost(requestData);
message?.success?.("创建标注任务成功");
onClose();
onRefresh();
} catch (err: any) {
console.error("Create annotation task failed", err);
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
(message as any)?.error?.(msg);
} finally {
setSubmitting(false);
}
};
return (
<Modal
open={open}
onCancel={onClose}
title="创建标注任务"
footer={
<>
<Button onClick={onClose} disabled={submitting}>
</Button>
<Button type="primary" onClick={handleSubmit} loading={submitting}>
</Button>
</>
}
width={800}
>
<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>
),
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>
{/* 标注模板选择 */}
<Form.Item
label="标注模板"
name="templateId"
rules={[{ required: true, message: "请选择标注模板" }]}
>
<Select
placeholder={templates.length === 0 ? "暂无可用模板,请先创建模板" : "请选择标注模板"}
showSearch
optionFilterProp="label"
notFoundContent={templates.length === 0 ? "暂无模板,请前往「标注模板」页面创建" : "未找到匹配的模板"}
options={templates.map((template) => ({
label: template.name,
value: template.id,
// Add description as subtitle
title: template.description,
}))}
optionRender={(option) => (
<div>
<div style={{ fontWeight: 500 }}>{option.label}</div>
{option.data.title && (
<div style={{ fontSize: 12, color: '#999', marginTop: 2 }}>
{option.data.title}
</div>
)}
</div>
)}
/>
</Form.Item>
</Form>
</Modal>
);
}

View File

@@ -1,225 +1,225 @@
import { useState } from "react";
import {
Modal,
Input,
Card,
message,
Divider,
Radio,
Form,
} from "antd";
import {
AppstoreOutlined,
BorderOutlined,
DotChartOutlined,
EditOutlined,
CheckSquareOutlined,
BarsOutlined,
DeploymentUnitOutlined,
} from "@ant-design/icons";
interface CustomTemplateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSaveTemplate: (templateData: any) => void;
datasetType: "text" | "image";
}
const { TextArea } = Input;
const defaultImageTemplate = `<View style="display: flex; flex-direction: column; height: 100vh; overflow: auto;">
<View style="display: flex; height: 100%; gap: 10px;">
<View style="height: 100%; width: 85%; display: flex; flex-direction: column; gap: 5px;">
<Header value="WSI图像预览" />
<View style="min-height: 100%;">
<Image name="image" value="$image" zoom="true" />
</View>
</View>
<View style="height: 100%; width: auto;">
<View style="width: auto; display: flex;">
<Text name="case_id_title" toName="image" value="病例号: $case_id" />
</View>
<Text name="part_title" toName="image" value="取材部位: $part" />
<Header value="标注" />
<View style="display: flex; gap: 5px;">
<View>
<Text name="cancer_or_not_title" value="是否有肿瘤" />
<Choices name="cancer_or_not" toName="image">
<Choice value="是" alias="1" />
<Choice value="否" alias="0" />
</Choices>
<Text name="remark_title" value="备注" />
<TextArea name="remark" toName="image" editable="true"/>
</View>
</View>
</View>
</View>
</View>`;
const defaultTextTemplate = `<View style="display: flex; flex-direction: column; height: 100vh;">
<Header value="文本标注界面" />
<View style="display: flex; height: 100%; gap: 10px;">
<View style="flex: 1; padding: 10px;">
<Text name="content" value="$text" />
<Labels name="label" toName="content">
<Label value="正面" background="green" />
<Label value="负面" background="red" />
<Label value="中性" background="gray" />
</Labels>
</View>
<View style="width: 300px; padding: 10px; border-left: 1px solid #ccc;">
<Header value="标注选项" />
<Text name="sentiment_title" value="情感分类" />
<Choices name="sentiment" toName="content">
<Choice value="正面" />
<Choice value="负面" />
<Choice value="中性" />
</Choices>
<Text name="confidence_title" value="置信度" />
<Rating name="confidence" toName="content" maxRating="5" />
<Text name="comment_title" value="备注" />
<TextArea name="comment" toName="content" placeholder="添加备注..." />
</View>
</View>
</View>`;
const annotationTools = [
{ id: "rectangle", label: "矩形框", icon: <BorderOutlined />, type: "image" },
{
id: "polygon",
label: "多边形",
icon: <DeploymentUnitOutlined />,
type: "image",
},
{ id: "circle", label: "圆形", icon: <DotChartOutlined />, type: "image" },
{ id: "point", label: "关键点", icon: <AppstoreOutlined />, type: "image" },
{ id: "text", label: "文本", icon: <EditOutlined />, type: "both" },
{ id: "choices", label: "选择题", icon: <BarsOutlined />, type: "both" },
{
id: "checkbox",
label: "多选框",
icon: <CheckSquareOutlined />,
type: "both",
},
{ id: "textarea", label: "文本域", icon: <BarsOutlined />, type: "both" },
];
export default function CustomTemplateDialog({
open,
onOpenChange,
onSaveTemplate,
datasetType,
}: CustomTemplateDialogProps) {
const [templateName, setTemplateName] = useState("");
const [templateDescription, setTemplateDescription] = useState("");
const [templateCode, setTemplateCode] = useState(
datasetType === "image" ? defaultImageTemplate : defaultTextTemplate
);
const handleSave = () => {
if (!templateName.trim()) {
message.error("请输入模板名称");
return;
}
if (!templateCode.trim()) {
message.error("请输入模板代码");
return;
}
const templateData = {
id: `custom-${Date.now()}`,
name: templateName,
description: templateDescription,
code: templateCode,
type: datasetType,
isCustom: true,
};
onSaveTemplate(templateData);
onOpenChange(false);
message.success("自定义模板已保存");
setTemplateName("");
setTemplateDescription("");
setTemplateCode(
datasetType === "image" ? defaultImageTemplate : defaultTextTemplate
);
};
return (
<Modal
open={open}
onCancel={() => onOpenChange(false)}
okText={"保存模板"}
onOk={handleSave}
width={1200}
className="max-h-[80vh] overflow-auto"
title="自定义标注模板"
>
<div className="flex min-h-[500px]">
<div className="flex-1 pl-6">
<Form layout="vertical">
<Form.Item label="模板名称 *" required>
<Input
placeholder="输入模板名称"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
/>
</Form.Item>
<Form.Item label="模板描述">
<Input
placeholder="输入模板描述"
value={templateDescription}
onChange={(e) => setTemplateDescription(e.target.value)}
/>
</Form.Item>
</Form>
<div className="flex gap-6">
<div className="flex-1">
<div className="mb-2 font-medium"></div>
<Card>
<TextArea
rows={20}
value={templateCode}
onChange={(e) => setTemplateCode(e.target.value)}
placeholder="输入模板代码"
/>
</Card>
</div>
<div className="w-96 border-l border-gray-100 pl-6">
<div className="mb-2 font-medium"></div>
<Card
cover={
<img
alt="预览图像"
src="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/img_v3_02oi_9b855efe-ce37-4387-a845-d8ef9aaa1a8g.jpg-GhkhlenJlzOQLSDqyBm2iaC6jbv7VA.jpeg"
className="object-cover h-48"
/>
}
>
<div className="mb-2">
<span className="text-gray-500"></span>
<span>undefined</span>
</div>
<div className="mb-2">
<span className="text-gray-500"></span>
<span>undefined</span>
</div>
<Divider />
<div>
<div className="font-medium mb-2"></div>
<div className="mb-2 text-gray-500"></div>
<Radio.Group>
<Radio value="1">[1]</Radio>
<Radio value="0">[2]</Radio>
</Radio.Group>
<div className="mt-4">
<div className="text-gray-500 mb-1"></div>
<TextArea rows={3} placeholder="添加备注..." />
</div>
</div>
</Card>
</div>
</div>
</div>
</div>
</Modal>
);
}
import { useState } from "react";
import {
Modal,
Input,
Card,
message,
Divider,
Radio,
Form,
} from "antd";
import {
AppstoreOutlined,
BorderOutlined,
DotChartOutlined,
EditOutlined,
CheckSquareOutlined,
BarsOutlined,
DeploymentUnitOutlined,
} from "@ant-design/icons";
interface CustomTemplateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSaveTemplate: (templateData: any) => void;
datasetType: "text" | "image";
}
const { TextArea } = Input;
const defaultImageTemplate = `<View style="display: flex; flex-direction: column; height: 100vh; overflow: auto;">
<View style="display: flex; height: 100%; gap: 10px;">
<View style="height: 100%; width: 85%; display: flex; flex-direction: column; gap: 5px;">
<Header value="WSI图像预览" />
<View style="min-height: 100%;">
<Image name="image" value="$image" zoom="true" />
</View>
</View>
<View style="height: 100%; width: auto;">
<View style="width: auto; display: flex;">
<Text name="case_id_title" toName="image" value="病例号: $case_id" />
</View>
<Text name="part_title" toName="image" value="取材部位: $part" />
<Header value="标注" />
<View style="display: flex; gap: 5px;">
<View>
<Text name="cancer_or_not_title" value="是否有肿瘤" />
<Choices name="cancer_or_not" toName="image">
<Choice value="是" alias="1" />
<Choice value="否" alias="0" />
</Choices>
<Text name="remark_title" value="备注" />
<TextArea name="remark" toName="image" editable="true"/>
</View>
</View>
</View>
</View>
</View>`;
const defaultTextTemplate = `<View style="display: flex; flex-direction: column; height: 100vh;">
<Header value="文本标注界面" />
<View style="display: flex; height: 100%; gap: 10px;">
<View style="flex: 1; padding: 10px;">
<Text name="content" value="$text" />
<Labels name="label" toName="content">
<Label value="正面" background="green" />
<Label value="负面" background="red" />
<Label value="中性" background="gray" />
</Labels>
</View>
<View style="width: 300px; padding: 10px; border-left: 1px solid #ccc;">
<Header value="标注选项" />
<Text name="sentiment_title" value="情感分类" />
<Choices name="sentiment" toName="content">
<Choice value="正面" />
<Choice value="负面" />
<Choice value="中性" />
</Choices>
<Text name="confidence_title" value="置信度" />
<Rating name="confidence" toName="content" maxRating="5" />
<Text name="comment_title" value="备注" />
<TextArea name="comment" toName="content" placeholder="添加备注..." />
</View>
</View>
</View>`;
const annotationTools = [
{ id: "rectangle", label: "矩形框", icon: <BorderOutlined />, type: "image" },
{
id: "polygon",
label: "多边形",
icon: <DeploymentUnitOutlined />,
type: "image",
},
{ id: "circle", label: "圆形", icon: <DotChartOutlined />, type: "image" },
{ id: "point", label: "关键点", icon: <AppstoreOutlined />, type: "image" },
{ id: "text", label: "文本", icon: <EditOutlined />, type: "both" },
{ id: "choices", label: "选择题", icon: <BarsOutlined />, type: "both" },
{
id: "checkbox",
label: "多选框",
icon: <CheckSquareOutlined />,
type: "both",
},
{ id: "textarea", label: "文本域", icon: <BarsOutlined />, type: "both" },
];
export default function CustomTemplateDialog({
open,
onOpenChange,
onSaveTemplate,
datasetType,
}: CustomTemplateDialogProps) {
const [templateName, setTemplateName] = useState("");
const [templateDescription, setTemplateDescription] = useState("");
const [templateCode, setTemplateCode] = useState(
datasetType === "image" ? defaultImageTemplate : defaultTextTemplate
);
const handleSave = () => {
if (!templateName.trim()) {
message.error("请输入模板名称");
return;
}
if (!templateCode.trim()) {
message.error("请输入模板代码");
return;
}
const templateData = {
id: `custom-${Date.now()}`,
name: templateName,
description: templateDescription,
code: templateCode,
type: datasetType,
isCustom: true,
};
onSaveTemplate(templateData);
onOpenChange(false);
message.success("自定义模板已保存");
setTemplateName("");
setTemplateDescription("");
setTemplateCode(
datasetType === "image" ? defaultImageTemplate : defaultTextTemplate
);
};
return (
<Modal
open={open}
onCancel={() => onOpenChange(false)}
okText={"保存模板"}
onOk={handleSave}
width={1200}
className="max-h-[80vh] overflow-auto"
title="自定义标注模板"
>
<div className="flex min-h-[500px]">
<div className="flex-1 pl-6">
<Form layout="vertical">
<Form.Item label="模板名称 *" required>
<Input
placeholder="输入模板名称"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
/>
</Form.Item>
<Form.Item label="模板描述">
<Input
placeholder="输入模板描述"
value={templateDescription}
onChange={(e) => setTemplateDescription(e.target.value)}
/>
</Form.Item>
</Form>
<div className="flex gap-6">
<div className="flex-1">
<div className="mb-2 font-medium"></div>
<Card>
<TextArea
rows={20}
value={templateCode}
onChange={(e) => setTemplateCode(e.target.value)}
placeholder="输入模板代码"
/>
</Card>
</div>
<div className="w-96 border-l border-gray-100 pl-6">
<div className="mb-2 font-medium"></div>
<Card
cover={
<img
alt="预览图像"
src="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/img_v3_02oi_9b855efe-ce37-4387-a845-d8ef9aaa1a8g.jpg-GhkhlenJlzOQLSDqyBm2iaC6jbv7VA.jpeg"
className="object-cover h-48"
/>
}
>
<div className="mb-2">
<span className="text-gray-500"></span>
<span>undefined</span>
</div>
<div className="mb-2">
<span className="text-gray-500"></span>
<span>undefined</span>
</div>
<Divider />
<div>
<div className="font-medium mb-2"></div>
<div className="mb-2 text-gray-500"></div>
<Radio.Group>
<Radio value="1">[1]</Radio>
<Radio value="0">[2]</Radio>
</Radio.Group>
<div className="mt-4">
<div className="text-gray-500 mb-1"></div>
<TextArea rows={3} placeholder="添加备注..." />
</div>
</div>
</Card>
</div>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -1,187 +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>
);
}
);
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>
);
}
);