Revert "feat: fix the problem in the Operator Market frontend pages"

This commit is contained in:
Kecheng Sha
2025-12-29 12:00:37 +08:00
committed by GitHub
parent 8f30f71a68
commit 0df7a872e4
213 changed files with 45537 additions and 45547 deletions

View File

@@ -1,155 +1,155 @@
import React from "react";
import { Modal, Descriptions, Tag, Space, Divider, Card, Typography } from "antd";
import type { AnnotationTemplate } from "../annotation.model";
const { Text, Paragraph } = Typography;
interface TemplateDetailProps {
visible: boolean;
template?: AnnotationTemplate;
onClose: () => void;
}
const TemplateDetail: React.FC<TemplateDetailProps> = ({
visible,
template,
onClose,
}) => {
if (!template) return null;
return (
<Modal
title="模板详情"
open={visible}
onCancel={onClose}
footer={null}
width={800}
>
<Descriptions bordered column={2}>
<Descriptions.Item label="名称" span={2}>
{template.name}
</Descriptions.Item>
<Descriptions.Item label="描述" span={2}>
{template.description || "-"}
</Descriptions.Item>
<Descriptions.Item label="数据类型">
<Tag color="cyan">{template.dataType}</Tag>
</Descriptions.Item>
<Descriptions.Item label="标注类型">
<Tag color="geekblue">{template.labelingType}</Tag>
</Descriptions.Item>
<Descriptions.Item label="分类">
<Tag color="blue">{template.category}</Tag>
</Descriptions.Item>
<Descriptions.Item label="样式">
{template.style}
</Descriptions.Item>
<Descriptions.Item label="类型">
<Tag color={template.builtIn ? "gold" : "default"}>
{template.builtIn ? "系统内置" : "自定义"}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="版本">
{template.version}
</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>
{new Date(template.createdAt).toLocaleString()}
</Descriptions.Item>
{template.updatedAt && (
<Descriptions.Item label="更新时间" span={2}>
{new Date(template.updatedAt).toLocaleString()}
</Descriptions.Item>
)}
</Descriptions>
<Divider></Divider>
<Card title="数据对象" size="small" style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: "100%" }}>
{template.configuration.objects.map((obj, index) => (
<Card key={index} size="small" type="inner">
<Space>
<Text strong></Text>
<Tag>{obj.name}</Tag>
<Text strong></Text>
<Tag color="blue">{obj.type}</Tag>
<Text strong></Text>
<Tag color="green">{obj.value}</Tag>
</Space>
</Card>
))}
</Space>
</Card>
<Card title="标注控件" size="small" style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: "100%" }} size="middle">
{template.configuration.labels.map((label, index) => (
<Card key={index} size="small" type="inner" title={`控件 ${index + 1}`}>
<Space direction="vertical" style={{ width: "100%" }}>
<div>
<Text strong></Text>
<Tag>{label.fromName}</Tag>
<Text strong style={{ marginLeft: 16 }}></Text>
<Tag>{label.toName}</Tag>
<Text strong style={{ marginLeft: 16 }}></Text>
<Tag color="purple">{label.type}</Tag>
{label.required && <Tag color="red"></Tag>}
</div>
{label.description && (
<div>
<Text strong></Text>
<Text type="secondary">{label.description}</Text>
</div>
)}
{label.options && label.options.length > 0 && (
<div>
<Text strong></Text>
<div style={{ marginTop: 4 }}>
{label.options.map((opt, i) => (
<Tag key={i} color="cyan">{opt}</Tag>
))}
</div>
</div>
)}
{label.labels && label.labels.length > 0 && (
<div>
<Text strong></Text>
<div style={{ marginTop: 4 }}>
{label.labels.map((lbl, i) => (
<Tag key={i} color="geekblue">{lbl}</Tag>
))}
</div>
</div>
)}
</Space>
</Card>
))}
</Space>
</Card>
{template.labelConfig && (
<Card title="Label Studio XML 配置" size="small">
<Paragraph>
<pre style={{
background: "#f5f5f5",
padding: 12,
borderRadius: 4,
overflow: "auto",
maxHeight: 300
}}>
{template.labelConfig}
</pre>
</Paragraph>
</Card>
)}
</Modal>
);
};
export default TemplateDetail;
import React from "react";
import { Modal, Descriptions, Tag, Space, Divider, Card, Typography } from "antd";
import type { AnnotationTemplate } from "../annotation.model";
const { Text, Paragraph } = Typography;
interface TemplateDetailProps {
visible: boolean;
template?: AnnotationTemplate;
onClose: () => void;
}
const TemplateDetail: React.FC<TemplateDetailProps> = ({
visible,
template,
onClose,
}) => {
if (!template) return null;
return (
<Modal
title="模板详情"
open={visible}
onCancel={onClose}
footer={null}
width={800}
>
<Descriptions bordered column={2}>
<Descriptions.Item label="名称" span={2}>
{template.name}
</Descriptions.Item>
<Descriptions.Item label="描述" span={2}>
{template.description || "-"}
</Descriptions.Item>
<Descriptions.Item label="数据类型">
<Tag color="cyan">{template.dataType}</Tag>
</Descriptions.Item>
<Descriptions.Item label="标注类型">
<Tag color="geekblue">{template.labelingType}</Tag>
</Descriptions.Item>
<Descriptions.Item label="分类">
<Tag color="blue">{template.category}</Tag>
</Descriptions.Item>
<Descriptions.Item label="样式">
{template.style}
</Descriptions.Item>
<Descriptions.Item label="类型">
<Tag color={template.builtIn ? "gold" : "default"}>
{template.builtIn ? "系统内置" : "自定义"}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="版本">
{template.version}
</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>
{new Date(template.createdAt).toLocaleString()}
</Descriptions.Item>
{template.updatedAt && (
<Descriptions.Item label="更新时间" span={2}>
{new Date(template.updatedAt).toLocaleString()}
</Descriptions.Item>
)}
</Descriptions>
<Divider></Divider>
<Card title="数据对象" size="small" style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: "100%" }}>
{template.configuration.objects.map((obj, index) => (
<Card key={index} size="small" type="inner">
<Space>
<Text strong></Text>
<Tag>{obj.name}</Tag>
<Text strong></Text>
<Tag color="blue">{obj.type}</Tag>
<Text strong></Text>
<Tag color="green">{obj.value}</Tag>
</Space>
</Card>
))}
</Space>
</Card>
<Card title="标注控件" size="small" style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: "100%" }} size="middle">
{template.configuration.labels.map((label, index) => (
<Card key={index} size="small" type="inner" title={`控件 ${index + 1}`}>
<Space direction="vertical" style={{ width: "100%" }}>
<div>
<Text strong></Text>
<Tag>{label.fromName}</Tag>
<Text strong style={{ marginLeft: 16 }}></Text>
<Tag>{label.toName}</Tag>
<Text strong style={{ marginLeft: 16 }}></Text>
<Tag color="purple">{label.type}</Tag>
{label.required && <Tag color="red"></Tag>}
</div>
{label.description && (
<div>
<Text strong></Text>
<Text type="secondary">{label.description}</Text>
</div>
)}
{label.options && label.options.length > 0 && (
<div>
<Text strong></Text>
<div style={{ marginTop: 4 }}>
{label.options.map((opt, i) => (
<Tag key={i} color="cyan">{opt}</Tag>
))}
</div>
</div>
)}
{label.labels && label.labels.length > 0 && (
<div>
<Text strong></Text>
<div style={{ marginTop: 4 }}>
{label.labels.map((lbl, i) => (
<Tag key={i} color="geekblue">{lbl}</Tag>
))}
</div>
</div>
)}
</Space>
</Card>
))}
</Space>
</Card>
{template.labelConfig && (
<Card title="Label Studio XML 配置" size="small">
<Paragraph>
<pre style={{
background: "#f5f5f5",
padding: 12,
borderRadius: 4,
overflow: "auto",
maxHeight: 300
}}>
{template.labelConfig}
</pre>
</Paragraph>
</Card>
)}
</Modal>
);
};
export default TemplateDetail;

View File

@@ -1,427 +1,427 @@
import React, { useState, useEffect } from "react";
import {
Modal,
Form,
Input,
Select,
Button,
Space,
message,
Divider,
Card,
Checkbox,
} from "antd";
import { PlusOutlined, MinusCircleOutlined } from "@ant-design/icons";
import {
createAnnotationTemplateUsingPost,
updateAnnotationTemplateByIdUsingPut,
} from "../annotation.api";
import type { AnnotationTemplate } from "../annotation.model";
import TagSelector from "./components/TagSelector";
const { TextArea } = Input;
const { Option } = Select;
interface TemplateFormProps {
visible: boolean;
mode: "create" | "edit";
template?: AnnotationTemplate;
onSuccess: () => void;
onCancel: () => void;
}
const TemplateForm: React.FC<TemplateFormProps> = ({
visible,
mode,
template,
onSuccess,
onCancel,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
useEffect(() => {
if (visible && template && mode === "edit") {
form.setFieldsValue({
name: template.name,
description: template.description,
dataType: template.dataType,
labelingType: template.labelingType,
style: template.style,
category: template.category,
labels: template.configuration.labels,
objects: template.configuration.objects,
});
} else if (visible && mode === "create") {
form.resetFields();
// Set default values
form.setFieldsValue({
style: "horizontal",
category: "custom",
labels: [],
objects: [{ name: "image", type: "Image", value: "$image" }],
});
}
}, [visible, template, mode, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setLoading(true);
console.log("Form values:", values);
const requestData = {
name: values.name,
description: values.description,
dataType: values.dataType,
labelingType: values.labelingType,
style: values.style,
category: values.category,
configuration: {
labels: values.labels,
objects: values.objects,
},
};
console.log("Request data:", requestData);
let response;
if (mode === "create") {
response = await createAnnotationTemplateUsingPost(requestData);
} else {
response = await updateAnnotationTemplateByIdUsingPut(template!.id, requestData);
}
if (response.code === 200) {
message.success(`模板${mode === "create" ? "创建" : "更新"}成功`);
form.resetFields();
onSuccess();
} else {
message.error(response.message || `模板${mode === "create" ? "创建" : "更新"}失败`);
}
} catch (error: any) {
if (error.errorFields) {
message.error("请填写所有必填字段");
} else {
message.error(`模板${mode === "create" ? "创建" : "更新"}失败`);
console.error(error);
}
} finally {
setLoading(false);
}
};
const needsOptions = (type: string) => {
return ["Choices", "RectangleLabels", "PolygonLabels", "Labels"].includes(type);
};
return (
<Modal
title={mode === "create" ? "创建模板" : "编辑模板"}
open={visible}
onCancel={onCancel}
onOk={handleSubmit}
confirmLoading={loading}
width={900}
destroyOnClose
>
<Form
form={form}
layout="vertical"
style={{ maxHeight: "70vh", overflowY: "auto", paddingRight: 8 }}
>
<Form.Item
label="模板名称"
name="name"
rules={[{ required: true, message: "请输入模板名称" }]}
>
<Input placeholder="例如:产品质量分类" maxLength={100} />
</Form.Item>
<Form.Item label="描述" name="description">
<TextArea
placeholder="描述此模板的用途"
rows={2}
maxLength={500}
/>
</Form.Item>
<Space style={{ width: "100%" }} size="large">
<Form.Item
label="数据类型"
name="dataType"
rules={[{ required: true, message: "请选择数据类型" }]}
style={{ width: 200 }}
>
<Select placeholder="选择数据类型">
<Option value="image"></Option>
<Option value="text"></Option>
<Option value="audio"></Option>
<Option value="video"></Option>
</Select>
</Form.Item>
<Form.Item
label="标注类型"
name="labelingType"
rules={[{ required: true, message: "请选择标注类型" }]}
style={{ width: 220 }}
>
<Select placeholder="选择标注类型">
<Option value="classification"></Option>
<Option value="object-detection"></Option>
<Option value="segmentation"></Option>
<Option value="ner"></Option>
<Option value="multi-stage"></Option>
</Select>
</Form.Item>
<Form.Item
label="样式"
name="style"
style={{ width: 150 }}
>
<Select>
<Option value="horizontal"></Option>
<Option value="vertical"></Option>
</Select>
</Form.Item>
<Form.Item
label="分类"
name="category"
style={{ width: 180 }}
>
<Select>
<Option value="computer-vision"></Option>
<Option value="nlp"></Option>
<Option value="audio"></Option>
<Option value="quality-control"></Option>
<Option value="custom"></Option>
</Select>
</Form.Item>
</Space>
<Divider></Divider>
<Form.List name="objects">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Card key={field.key} size="small" style={{ marginBottom: 8 }}>
<Space align="start" style={{ width: "100%" }}>
<Form.Item
{...field}
label="名称"
name={[field.name, "name"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0, width: 150 }}
>
<Input placeholder="例如:image" />
</Form.Item>
<Form.Item
{...field}
label="类型"
name={[field.name, "type"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0, width: 150 }}
>
<TagSelector type="object" />
</Form.Item>
<Form.Item
{...field}
label="值"
name={[field.name, "value"]}
rules={[
{ required: true, message: "必填" },
{ pattern: /^\$/, message: "必须以 $ 开头" },
]}
style={{ marginBottom: 0, width: 150 }}
>
<Input placeholder="$image" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined
style={{ marginTop: 30, color: "red" }}
onClick={() => remove(field.name)}
/>
)}
</Space>
</Card>
))}
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
</Button>
</>
)}
</Form.List>
<Divider></Divider>
<Form.List name="labels">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Card
key={field.key}
size="small"
style={{ marginBottom: 12 }}
title={
<Space>
<span> {fields.indexOf(field) + 1}</span>
<Form.Item noStyle shouldUpdate>
{() => {
const controlType = form.getFieldValue(["labels", field.name, "type"]);
const fromName = form.getFieldValue(["labels", field.name, "fromName"]);
if (controlType || fromName) {
return (
<span style={{ fontSize: 12, fontWeight: 'normal', color: '#999' }}>
({fromName || '未命名'} - {controlType || '未设置类型'})
</span>
);
}
return null;
}}
</Form.Item>
</Space>
}
extra={
<MinusCircleOutlined
style={{ color: "red" }}
onClick={() => remove(field.name)}
/>
}
>
<Space direction="vertical" style={{ width: "100%" }} size="middle">
{/* Row 1: 控件名称, 标注目标对象, 控件类型 */}
<div style={{ display: 'grid', gridTemplateColumns: '180px 220px 1fr auto', gap: 12, alignItems: 'flex-end' }}>
<Form.Item
{...field}
label="来源名称"
name={[field.name, "fromName"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0 }}
tooltip="此控件的唯一标识符"
>
<Input placeholder="例如:choice" />
</Form.Item>
<Form.Item
{...field}
label="标注目标对象"
name={[field.name, "toName"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0 }}
tooltip="选择此控件将标注哪个数据对象"
dependencies={['objects']}
>
<Select placeholder="选择数据对象">
{(form.getFieldValue("objects") || []).map((obj: any, idx: number) => (
<Option key={idx} value={obj?.name || ''}>
{obj?.name || `对象 ${idx + 1}`} ({obj?.type || '未知类型'})
</Option>
))}
</Select>
</Form.Item>
<Form.Item
{...field}
label="控件类型"
name={[field.name, "type"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0 }}
>
<TagSelector type="control" />
</Form.Item>
<Form.Item
{...field}
label=" "
name={[field.name, "required"]}
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<Checkbox></Checkbox>
</Form.Item>
</div>
{/* Row 2: 取值范围定义(添加选项) - Conditionally rendered based on type */}
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => {
const prevType = prevValues.labels?.[field.name]?.type;
const currType = currentValues.labels?.[field.name]?.type;
return prevType !== currType;
}}
>
{({ getFieldValue }) => {
const controlType = getFieldValue(["labels", field.name, "type"]);
const fieldName = controlType === "Choices" ? "options" : "labels";
if (needsOptions(controlType)) {
return (
<Form.Item
{...field}
label={controlType === "Choices" ? "选项" : "标签"}
name={[field.name, fieldName]}
rules={[{ required: true, message: "至少需要一个选项" }]}
style={{ marginBottom: 0 }}
>
<Select
mode="tags"
open={false}
placeholder={
controlType === "Choices"
? "输入选项内容,按回车添加。例如:是、否、不确定"
: "输入标签名称,按回车添加。例如:人物、车辆、建筑物"
}
style={{ width: "100%" }}
/>
</Form.Item>
);
}
return null;
}}
</Form.Item>
{/* Row 3: 描述 */}
<Form.Item
{...field}
label="描述"
name={[field.name, "description"]}
style={{ marginBottom: 0 }}
tooltip="向标注人员显示的帮助信息"
>
<Input placeholder="为标注人员提供此控件的使用说明" maxLength={200} />
</Form.Item>
</Space>
</Card>
))}
<Button
type="dashed"
onClick={() =>
add({
fromName: "",
toName: "",
type: "Choices",
required: false,
})
}
block
icon={<PlusOutlined />}
>
</Button>
</>
)}
</Form.List>
</Form>
</Modal>
);
};
export default TemplateForm;
import React, { useState, useEffect } from "react";
import {
Modal,
Form,
Input,
Select,
Button,
Space,
message,
Divider,
Card,
Checkbox,
} from "antd";
import { PlusOutlined, MinusCircleOutlined } from "@ant-design/icons";
import {
createAnnotationTemplateUsingPost,
updateAnnotationTemplateByIdUsingPut,
} from "../annotation.api";
import type { AnnotationTemplate } from "../annotation.model";
import TagSelector from "./components/TagSelector";
const { TextArea } = Input;
const { Option } = Select;
interface TemplateFormProps {
visible: boolean;
mode: "create" | "edit";
template?: AnnotationTemplate;
onSuccess: () => void;
onCancel: () => void;
}
const TemplateForm: React.FC<TemplateFormProps> = ({
visible,
mode,
template,
onSuccess,
onCancel,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
useEffect(() => {
if (visible && template && mode === "edit") {
form.setFieldsValue({
name: template.name,
description: template.description,
dataType: template.dataType,
labelingType: template.labelingType,
style: template.style,
category: template.category,
labels: template.configuration.labels,
objects: template.configuration.objects,
});
} else if (visible && mode === "create") {
form.resetFields();
// Set default values
form.setFieldsValue({
style: "horizontal",
category: "custom",
labels: [],
objects: [{ name: "image", type: "Image", value: "$image" }],
});
}
}, [visible, template, mode, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setLoading(true);
console.log("Form values:", values);
const requestData = {
name: values.name,
description: values.description,
dataType: values.dataType,
labelingType: values.labelingType,
style: values.style,
category: values.category,
configuration: {
labels: values.labels,
objects: values.objects,
},
};
console.log("Request data:", requestData);
let response;
if (mode === "create") {
response = await createAnnotationTemplateUsingPost(requestData);
} else {
response = await updateAnnotationTemplateByIdUsingPut(template!.id, requestData);
}
if (response.code === 200) {
message.success(`模板${mode === "create" ? "创建" : "更新"}成功`);
form.resetFields();
onSuccess();
} else {
message.error(response.message || `模板${mode === "create" ? "创建" : "更新"}失败`);
}
} catch (error: any) {
if (error.errorFields) {
message.error("请填写所有必填字段");
} else {
message.error(`模板${mode === "create" ? "创建" : "更新"}失败`);
console.error(error);
}
} finally {
setLoading(false);
}
};
const needsOptions = (type: string) => {
return ["Choices", "RectangleLabels", "PolygonLabels", "Labels"].includes(type);
};
return (
<Modal
title={mode === "create" ? "创建模板" : "编辑模板"}
open={visible}
onCancel={onCancel}
onOk={handleSubmit}
confirmLoading={loading}
width={900}
destroyOnClose
>
<Form
form={form}
layout="vertical"
style={{ maxHeight: "70vh", overflowY: "auto", paddingRight: 8 }}
>
<Form.Item
label="模板名称"
name="name"
rules={[{ required: true, message: "请输入模板名称" }]}
>
<Input placeholder="例如:产品质量分类" maxLength={100} />
</Form.Item>
<Form.Item label="描述" name="description">
<TextArea
placeholder="描述此模板的用途"
rows={2}
maxLength={500}
/>
</Form.Item>
<Space style={{ width: "100%" }} size="large">
<Form.Item
label="数据类型"
name="dataType"
rules={[{ required: true, message: "请选择数据类型" }]}
style={{ width: 200 }}
>
<Select placeholder="选择数据类型">
<Option value="image"></Option>
<Option value="text"></Option>
<Option value="audio"></Option>
<Option value="video"></Option>
</Select>
</Form.Item>
<Form.Item
label="标注类型"
name="labelingType"
rules={[{ required: true, message: "请选择标注类型" }]}
style={{ width: 220 }}
>
<Select placeholder="选择标注类型">
<Option value="classification"></Option>
<Option value="object-detection"></Option>
<Option value="segmentation"></Option>
<Option value="ner"></Option>
<Option value="multi-stage"></Option>
</Select>
</Form.Item>
<Form.Item
label="样式"
name="style"
style={{ width: 150 }}
>
<Select>
<Option value="horizontal"></Option>
<Option value="vertical"></Option>
</Select>
</Form.Item>
<Form.Item
label="分类"
name="category"
style={{ width: 180 }}
>
<Select>
<Option value="computer-vision"></Option>
<Option value="nlp"></Option>
<Option value="audio"></Option>
<Option value="quality-control"></Option>
<Option value="custom"></Option>
</Select>
</Form.Item>
</Space>
<Divider></Divider>
<Form.List name="objects">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Card key={field.key} size="small" style={{ marginBottom: 8 }}>
<Space align="start" style={{ width: "100%" }}>
<Form.Item
{...field}
label="名称"
name={[field.name, "name"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0, width: 150 }}
>
<Input placeholder="例如:image" />
</Form.Item>
<Form.Item
{...field}
label="类型"
name={[field.name, "type"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0, width: 150 }}
>
<TagSelector type="object" />
</Form.Item>
<Form.Item
{...field}
label="值"
name={[field.name, "value"]}
rules={[
{ required: true, message: "必填" },
{ pattern: /^\$/, message: "必须以 $ 开头" },
]}
style={{ marginBottom: 0, width: 150 }}
>
<Input placeholder="$image" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined
style={{ marginTop: 30, color: "red" }}
onClick={() => remove(field.name)}
/>
)}
</Space>
</Card>
))}
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
</Button>
</>
)}
</Form.List>
<Divider></Divider>
<Form.List name="labels">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Card
key={field.key}
size="small"
style={{ marginBottom: 12 }}
title={
<Space>
<span> {fields.indexOf(field) + 1}</span>
<Form.Item noStyle shouldUpdate>
{() => {
const controlType = form.getFieldValue(["labels", field.name, "type"]);
const fromName = form.getFieldValue(["labels", field.name, "fromName"]);
if (controlType || fromName) {
return (
<span style={{ fontSize: 12, fontWeight: 'normal', color: '#999' }}>
({fromName || '未命名'} - {controlType || '未设置类型'})
</span>
);
}
return null;
}}
</Form.Item>
</Space>
}
extra={
<MinusCircleOutlined
style={{ color: "red" }}
onClick={() => remove(field.name)}
/>
}
>
<Space direction="vertical" style={{ width: "100%" }} size="middle">
{/* Row 1: 控件名称, 标注目标对象, 控件类型 */}
<div style={{ display: 'grid', gridTemplateColumns: '180px 220px 1fr auto', gap: 12, alignItems: 'flex-end' }}>
<Form.Item
{...field}
label="来源名称"
name={[field.name, "fromName"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0 }}
tooltip="此控件的唯一标识符"
>
<Input placeholder="例如:choice" />
</Form.Item>
<Form.Item
{...field}
label="标注目标对象"
name={[field.name, "toName"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0 }}
tooltip="选择此控件将标注哪个数据对象"
dependencies={['objects']}
>
<Select placeholder="选择数据对象">
{(form.getFieldValue("objects") || []).map((obj: any, idx: number) => (
<Option key={idx} value={obj?.name || ''}>
{obj?.name || `对象 ${idx + 1}`} ({obj?.type || '未知类型'})
</Option>
))}
</Select>
</Form.Item>
<Form.Item
{...field}
label="控件类型"
name={[field.name, "type"]}
rules={[{ required: true, message: "必填" }]}
style={{ marginBottom: 0 }}
>
<TagSelector type="control" />
</Form.Item>
<Form.Item
{...field}
label=" "
name={[field.name, "required"]}
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<Checkbox></Checkbox>
</Form.Item>
</div>
{/* Row 2: 取值范围定义(添加选项) - Conditionally rendered based on type */}
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => {
const prevType = prevValues.labels?.[field.name]?.type;
const currType = currentValues.labels?.[field.name]?.type;
return prevType !== currType;
}}
>
{({ getFieldValue }) => {
const controlType = getFieldValue(["labels", field.name, "type"]);
const fieldName = controlType === "Choices" ? "options" : "labels";
if (needsOptions(controlType)) {
return (
<Form.Item
{...field}
label={controlType === "Choices" ? "选项" : "标签"}
name={[field.name, fieldName]}
rules={[{ required: true, message: "至少需要一个选项" }]}
style={{ marginBottom: 0 }}
>
<Select
mode="tags"
open={false}
placeholder={
controlType === "Choices"
? "输入选项内容,按回车添加。例如:是、否、不确定"
: "输入标签名称,按回车添加。例如:人物、车辆、建筑物"
}
style={{ width: "100%" }}
/>
</Form.Item>
);
}
return null;
}}
</Form.Item>
{/* Row 3: 描述 */}
<Form.Item
{...field}
label="描述"
name={[field.name, "description"]}
style={{ marginBottom: 0 }}
tooltip="向标注人员显示的帮助信息"
>
<Input placeholder="为标注人员提供此控件的使用说明" maxLength={200} />
</Form.Item>
</Space>
</Card>
))}
<Button
type="dashed"
onClick={() =>
add({
fromName: "",
toName: "",
type: "Choices",
required: false,
})
}
block
icon={<PlusOutlined />}
>
</Button>
</>
)}
</Form.List>
</Form>
</Modal>
);
};
export default TemplateForm;

View File

@@ -1,311 +1,311 @@
import React, { useState } from "react";
import {
Button,
Table,
Space,
Tag,
message,
Tooltip,
Popconfirm,
Card,
} from "antd";
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
EyeOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
queryAnnotationTemplatesUsingGet,
deleteAnnotationTemplateByIdUsingDelete,
} from "../annotation.api";
import type { AnnotationTemplate } from "../annotation.model";
import TemplateForm from "./TemplateForm.tsx";
import TemplateDetail from "./TemplateDetail.tsx";
import {SearchControls} from "@/components/SearchControls.tsx";
import useFetchData from "@/hooks/useFetchData.ts";
import {
AnnotationTypeMap,
ClassificationMap,
DataTypeMap,
TemplateTypeMap
} from "@/pages/DataAnnotation/annotation.const.tsx";
const TemplateList: React.FC = () => {
const filterOptions = [
{
key: "category",
label: "分类",
options: [...Object.values(ClassificationMap)],
},
{
key: "dataType",
label: "数据类型",
options: [...Object.values(DataTypeMap)],
},
{
key: "labelingType",
label: "标注类型",
options: [...Object.values(AnnotationTypeMap)],
},
{
key: "builtIn",
label: "模板类型",
options: [...Object.values(TemplateTypeMap)],
},
];
// Modals
const [isFormVisible, setIsFormVisible] = useState(false);
const [isDetailVisible, setIsDetailVisible] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<AnnotationTemplate | undefined>();
const [formMode, setFormMode] = useState<"create" | "edit">("create");
const {
loading,
tableData,
pagination,
searchParams,
setSearchParams,
fetchData,
handleFiltersChange,
handleKeywordChange,
} = useFetchData(queryAnnotationTemplatesUsingGet, undefined, undefined, undefined, undefined, 0);
const handleCreate = () => {
setFormMode("create");
setSelectedTemplate(undefined);
setIsFormVisible(true);
};
const handleEdit = (template: AnnotationTemplate) => {
setFormMode("edit");
setSelectedTemplate(template);
setIsFormVisible(true);
};
const handleView = (template: AnnotationTemplate) => {
setSelectedTemplate(template);
setIsDetailVisible(true);
};
const handleDelete = async (templateId: string) => {
try {
const response = await deleteAnnotationTemplateByIdUsingDelete(templateId);
if (response.code === 200) {
message.success("模板删除成功");
fetchData();
} else {
message.error(response.message || "删除模板失败");
}
} catch (error) {
message.error("删除模板失败");
console.error(error);
}
};
const handleFormSuccess = () => {
setIsFormVisible(false);
fetchData();
};
const getCategoryColor = (category: string) => {
const colors: Record<string, string> = {
"computer-vision": "blue",
"nlp": "green",
"audio": "purple",
"quality-control": "orange",
"custom": "default",
};
return colors[category] || "default";
};
const columns: ColumnsType<AnnotationTemplate> = [
{
title: "模板名称",
dataIndex: "name",
key: "name",
width: 200,
ellipsis: true,
onFilter: (value, record) =>
record.name.toLowerCase().includes(value.toString().toLowerCase()) ||
(record.description?.toLowerCase().includes(value.toString().toLowerCase()) ?? false),
},
{
title: "描述",
dataIndex: "description",
key: "description",
ellipsis: {
showTitle: false,
},
render: (description: string) => (
<Tooltip title={description}>
<div
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'normal',
lineHeight: '1.5em',
maxHeight: '3em',
}}
>
{description}
</div>
</Tooltip>
),
},
{
title: "数据类型",
dataIndex: "dataType",
key: "dataType",
width: 120,
render: (dataType: string) => (
<Tag color="cyan">{dataType}</Tag>
),
},
{
title: "标注类型",
dataIndex: "labelingType",
key: "labelingType",
width: 150,
render: (labelingType: string) => (
<Tag color="geekblue">{labelingType}</Tag>
),
},
{
title: "分类",
dataIndex: "category",
key: "category",
width: 150,
render: (category: string) => (
<Tag color={getCategoryColor(category)}>{category}</Tag>
),
},
{
title: "类型",
dataIndex: "builtIn",
key: "builtIn",
width: 100,
render: (builtIn: boolean) => (
<Tag color={builtIn ? "gold" : "default"}>
{builtIn ? "系统内置" : "自定义"}
</Tag>
),
},
{
title: "版本",
dataIndex: "version",
key: "version",
width: 80,
},
{
title: "创建时间",
dataIndex: "createdAt",
key: "createdAt",
width: 180,
render: (date: string) => new Date(date).toLocaleString(),
},
{
title: "操作",
key: "action",
width: 200,
fixed: "right",
render: (_, record) => (
<Space size="small">
<Tooltip title="查看详情">
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => handleView(record)}
/>
</Tooltip>
{!record.builtIn && (
<>
<Tooltip title="编辑">
<Button
type="link"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
/>
</Tooltip>
<Popconfirm
title="确定要删除这个模板吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Tooltip title="删除">
<Button
type="link"
danger
icon={<DeleteOutlined />}
/>
</Tooltip>
</Popconfirm>
</>
)}
</Space>
),
},
];
return (
<div className="flex flex-col gap-4">
{/* Search, Filters and Buttons in one row */}
<div className="flex items-center justify-between gap-2">
{/* Left side: Search and Filters */}
<div className="flex items-center gap-2 flex-wrap">
<SearchControls
searchTerm={searchParams.keyword}
onSearchChange={handleKeywordChange}
searchPlaceholder="搜索任务名称、描述"
filters={filterOptions}
onFiltersChange={handleFiltersChange}
showViewToggle={true}
onReload={fetchData}
onClearFilters={() => setSearchParams({ ...searchParams, filter: {} })}
/>
</div>
{/* Right side: Create button */}
<div className="flex items-center gap-2">
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
</Button>
</div>
</div>
<Card>
<Table
columns={columns}
dataSource={tableData}
rowKey="id"
loading={loading}
pagination={pagination}
scroll={{ x: 1400, y: "calc(100vh - 24rem)" }}
/>
</Card>
<TemplateForm
visible={isFormVisible}
mode={formMode}
template={selectedTemplate}
onSuccess={handleFormSuccess}
onCancel={() => setIsFormVisible(false)}
/>
<TemplateDetail
visible={isDetailVisible}
template={selectedTemplate}
onClose={() => setIsDetailVisible(false)}
/>
</div>
);
};
export default TemplateList;
export { TemplateList };
import React, { useState } from "react";
import {
Button,
Table,
Space,
Tag,
message,
Tooltip,
Popconfirm,
Card,
} from "antd";
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
EyeOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
queryAnnotationTemplatesUsingGet,
deleteAnnotationTemplateByIdUsingDelete,
} from "../annotation.api";
import type { AnnotationTemplate } from "../annotation.model";
import TemplateForm from "./TemplateForm.tsx";
import TemplateDetail from "./TemplateDetail.tsx";
import {SearchControls} from "@/components/SearchControls.tsx";
import useFetchData from "@/hooks/useFetchData.ts";
import {
AnnotationTypeMap,
ClassificationMap,
DataTypeMap,
TemplateTypeMap
} from "@/pages/DataAnnotation/annotation.const.tsx";
const TemplateList: React.FC = () => {
const filterOptions = [
{
key: "category",
label: "分类",
options: [...Object.values(ClassificationMap)],
},
{
key: "dataType",
label: "数据类型",
options: [...Object.values(DataTypeMap)],
},
{
key: "labelingType",
label: "标注类型",
options: [...Object.values(AnnotationTypeMap)],
},
{
key: "builtIn",
label: "模板类型",
options: [...Object.values(TemplateTypeMap)],
},
];
// Modals
const [isFormVisible, setIsFormVisible] = useState(false);
const [isDetailVisible, setIsDetailVisible] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<AnnotationTemplate | undefined>();
const [formMode, setFormMode] = useState<"create" | "edit">("create");
const {
loading,
tableData,
pagination,
searchParams,
setSearchParams,
fetchData,
handleFiltersChange,
handleKeywordChange,
} = useFetchData(queryAnnotationTemplatesUsingGet, undefined, undefined, undefined, undefined, 0);
const handleCreate = () => {
setFormMode("create");
setSelectedTemplate(undefined);
setIsFormVisible(true);
};
const handleEdit = (template: AnnotationTemplate) => {
setFormMode("edit");
setSelectedTemplate(template);
setIsFormVisible(true);
};
const handleView = (template: AnnotationTemplate) => {
setSelectedTemplate(template);
setIsDetailVisible(true);
};
const handleDelete = async (templateId: string) => {
try {
const response = await deleteAnnotationTemplateByIdUsingDelete(templateId);
if (response.code === 200) {
message.success("模板删除成功");
fetchData();
} else {
message.error(response.message || "删除模板失败");
}
} catch (error) {
message.error("删除模板失败");
console.error(error);
}
};
const handleFormSuccess = () => {
setIsFormVisible(false);
fetchData();
};
const getCategoryColor = (category: string) => {
const colors: Record<string, string> = {
"computer-vision": "blue",
"nlp": "green",
"audio": "purple",
"quality-control": "orange",
"custom": "default",
};
return colors[category] || "default";
};
const columns: ColumnsType<AnnotationTemplate> = [
{
title: "模板名称",
dataIndex: "name",
key: "name",
width: 200,
ellipsis: true,
onFilter: (value, record) =>
record.name.toLowerCase().includes(value.toString().toLowerCase()) ||
(record.description?.toLowerCase().includes(value.toString().toLowerCase()) ?? false),
},
{
title: "描述",
dataIndex: "description",
key: "description",
ellipsis: {
showTitle: false,
},
render: (description: string) => (
<Tooltip title={description}>
<div
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'normal',
lineHeight: '1.5em',
maxHeight: '3em',
}}
>
{description}
</div>
</Tooltip>
),
},
{
title: "数据类型",
dataIndex: "dataType",
key: "dataType",
width: 120,
render: (dataType: string) => (
<Tag color="cyan">{dataType}</Tag>
),
},
{
title: "标注类型",
dataIndex: "labelingType",
key: "labelingType",
width: 150,
render: (labelingType: string) => (
<Tag color="geekblue">{labelingType}</Tag>
),
},
{
title: "分类",
dataIndex: "category",
key: "category",
width: 150,
render: (category: string) => (
<Tag color={getCategoryColor(category)}>{category}</Tag>
),
},
{
title: "类型",
dataIndex: "builtIn",
key: "builtIn",
width: 100,
render: (builtIn: boolean) => (
<Tag color={builtIn ? "gold" : "default"}>
{builtIn ? "系统内置" : "自定义"}
</Tag>
),
},
{
title: "版本",
dataIndex: "version",
key: "version",
width: 80,
},
{
title: "创建时间",
dataIndex: "createdAt",
key: "createdAt",
width: 180,
render: (date: string) => new Date(date).toLocaleString(),
},
{
title: "操作",
key: "action",
width: 200,
fixed: "right",
render: (_, record) => (
<Space size="small">
<Tooltip title="查看详情">
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => handleView(record)}
/>
</Tooltip>
{!record.builtIn && (
<>
<Tooltip title="编辑">
<Button
type="link"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
/>
</Tooltip>
<Popconfirm
title="确定要删除这个模板吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Tooltip title="删除">
<Button
type="link"
danger
icon={<DeleteOutlined />}
/>
</Tooltip>
</Popconfirm>
</>
)}
</Space>
),
},
];
return (
<div className="flex flex-col gap-4">
{/* Search, Filters and Buttons in one row */}
<div className="flex items-center justify-between gap-2">
{/* Left side: Search and Filters */}
<div className="flex items-center gap-2 flex-wrap">
<SearchControls
searchTerm={searchParams.keyword}
onSearchChange={handleKeywordChange}
searchPlaceholder="搜索任务名称、描述"
filters={filterOptions}
onFiltersChange={handleFiltersChange}
showViewToggle={true}
onReload={fetchData}
onClearFilters={() => setSearchParams({ ...searchParams, filter: {} })}
/>
</div>
{/* Right side: Create button */}
<div className="flex items-center gap-2">
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
</Button>
</div>
</div>
<Card>
<Table
columns={columns}
dataSource={tableData}
rowKey="id"
loading={loading}
pagination={pagination}
scroll={{ x: 1400, y: "calc(100vh - 24rem)" }}
/>
</Card>
<TemplateForm
visible={isFormVisible}
mode={formMode}
template={selectedTemplate}
onSuccess={handleFormSuccess}
onCancel={() => setIsFormVisible(false)}
/>
<TemplateDetail
visible={isDetailVisible}
template={selectedTemplate}
onClose={() => setIsDetailVisible(false)}
/>
</div>
);
};
export default TemplateList;
export { TemplateList };

View File

@@ -1,161 +1,161 @@
import React, { useState } from "react";
import {
Card,
Button,
Space,
Row,
Col,
Drawer,
Typography,
message,
} from "antd";
import {
PlusOutlined,
EyeOutlined,
CodeOutlined,
AppstoreOutlined,
} from "@ant-design/icons";
import { TagBrowser } from "./components";
const { Paragraph } = Typography;
interface VisualTemplateBuilderProps {
onSave?: (templateCode: string) => void;
}
/**
* Visual Template Builder
* Provides a drag-and-drop interface for building Label Studio templates
*/
const VisualTemplateBuilder: React.FC<VisualTemplateBuilderProps> = ({
onSave,
}) => {
const [drawerVisible, setDrawerVisible] = useState(false);
const [previewVisible, setPreviewVisible] = useState(false);
const [selectedTags, setSelectedTags] = useState<
Array<{ name: string; category: "object" | "control" }>
>([]);
const handleTagSelect = (tagName: string, category: "object" | "control") => {
message.info(`选择了 ${category === "object" ? "对象" : "控件"}: ${tagName}`);
setSelectedTags([...selectedTags, { name: tagName, category }]);
setDrawerVisible(false);
};
const handleSave = () => {
// TODO: Generate template XML from selectedTags
message.success("模板保存成功");
onSave?.("<View><!-- Generated template --></View>");
};
return (
<div style={{ padding: "24px" }}>
<Row gutter={16}>
<Col span={24}>
<Card
title="可视化模板构建器"
extra={
<Space>
<Button
icon={<AppstoreOutlined />}
onClick={() => setDrawerVisible(true)}
>
</Button>
<Button
icon={<CodeOutlined />}
onClick={() => setPreviewVisible(true)}
>
</Button>
<Button
type="primary"
icon={<EyeOutlined />}
onClick={handleSave}
>
</Button>
</Space>
}
>
<div
style={{
minHeight: "400px",
border: "2px dashed #d9d9d9",
borderRadius: "8px",
padding: "24px",
textAlign: "center",
}}
>
{selectedTags.length === 0 ? (
<div>
<Paragraph type="secondary">
"浏览标签"
</Paragraph>
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={() => setDrawerVisible(true)}
>
</Button>
</div>
) : (
<Space direction="vertical" size="large">
{selectedTags.map((tag, index) => (
<Card key={index} size="small">
<div>
{tag.category === "object" ? "对象" : "控件"}: {tag.name}
</div>
</Card>
))}
</Space>
)}
</div>
</Card>
</Col>
</Row>
<Drawer
title="标签浏览器"
placement="right"
width={800}
open={drawerVisible}
onClose={() => setDrawerVisible(false)}
>
<TagBrowser onTagSelect={handleTagSelect} />
</Drawer>
<Drawer
title="模板代码预览"
placement="right"
width={600}
open={previewVisible}
onClose={() => setPreviewVisible(false)}
>
<pre
style={{
background: "#f5f5f5",
padding: "16px",
borderRadius: "4px",
overflow: "auto",
}}
>
<code>
{`<View>
<!-- 根据选择的标签生成的模板代码 -->
${selectedTags
.map(
(tag) =>
`<${tag.name}${tag.category === "object" ? ' name="obj" value="$data"' : ' name="ctrl" toName="obj"'} />`
)
.join("\n ")}
</View>`}
</code>
</pre>
</Drawer>
</div>
);
};
export default VisualTemplateBuilder;
import React, { useState } from "react";
import {
Card,
Button,
Space,
Row,
Col,
Drawer,
Typography,
message,
} from "antd";
import {
PlusOutlined,
EyeOutlined,
CodeOutlined,
AppstoreOutlined,
} from "@ant-design/icons";
import { TagBrowser } from "./components";
const { Paragraph } = Typography;
interface VisualTemplateBuilderProps {
onSave?: (templateCode: string) => void;
}
/**
* Visual Template Builder
* Provides a drag-and-drop interface for building Label Studio templates
*/
const VisualTemplateBuilder: React.FC<VisualTemplateBuilderProps> = ({
onSave,
}) => {
const [drawerVisible, setDrawerVisible] = useState(false);
const [previewVisible, setPreviewVisible] = useState(false);
const [selectedTags, setSelectedTags] = useState<
Array<{ name: string; category: "object" | "control" }>
>([]);
const handleTagSelect = (tagName: string, category: "object" | "control") => {
message.info(`选择了 ${category === "object" ? "对象" : "控件"}: ${tagName}`);
setSelectedTags([...selectedTags, { name: tagName, category }]);
setDrawerVisible(false);
};
const handleSave = () => {
// TODO: Generate template XML from selectedTags
message.success("模板保存成功");
onSave?.("<View><!-- Generated template --></View>");
};
return (
<div style={{ padding: "24px" }}>
<Row gutter={16}>
<Col span={24}>
<Card
title="可视化模板构建器"
extra={
<Space>
<Button
icon={<AppstoreOutlined />}
onClick={() => setDrawerVisible(true)}
>
</Button>
<Button
icon={<CodeOutlined />}
onClick={() => setPreviewVisible(true)}
>
</Button>
<Button
type="primary"
icon={<EyeOutlined />}
onClick={handleSave}
>
</Button>
</Space>
}
>
<div
style={{
minHeight: "400px",
border: "2px dashed #d9d9d9",
borderRadius: "8px",
padding: "24px",
textAlign: "center",
}}
>
{selectedTags.length === 0 ? (
<div>
<Paragraph type="secondary">
"浏览标签"
</Paragraph>
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={() => setDrawerVisible(true)}
>
</Button>
</div>
) : (
<Space direction="vertical" size="large">
{selectedTags.map((tag, index) => (
<Card key={index} size="small">
<div>
{tag.category === "object" ? "对象" : "控件"}: {tag.name}
</div>
</Card>
))}
</Space>
)}
</div>
</Card>
</Col>
</Row>
<Drawer
title="标签浏览器"
placement="right"
width={800}
open={drawerVisible}
onClose={() => setDrawerVisible(false)}
>
<TagBrowser onTagSelect={handleTagSelect} />
</Drawer>
<Drawer
title="模板代码预览"
placement="right"
width={600}
open={previewVisible}
onClose={() => setPreviewVisible(false)}
>
<pre
style={{
background: "#f5f5f5",
padding: "16px",
borderRadius: "4px",
overflow: "auto",
}}
>
<code>
{`<View>
<!-- 根据选择的标签生成的模板代码 -->
${selectedTags
.map(
(tag) =>
`<${tag.name}${tag.category === "object" ? ' name="obj" value="$data"' : ' name="ctrl" toName="obj"'} />`
)
.join("\n ")}
</View>`}
</code>
</pre>
</Drawer>
</div>
);
};
export default VisualTemplateBuilder;

View File

@@ -1,260 +1,260 @@
import React from "react";
import { Card, Tabs, List, Tag, Typography, Space, Empty, Spin } from "antd";
import {
AppstoreOutlined,
ControlOutlined,
InfoCircleOutlined,
} from "@ant-design/icons";
import { useTagConfig } from "../../../../hooks/useTagConfig";
import {
getControlDisplayName,
getObjectDisplayName,
getControlGroups,
} from "../../annotation.tagconfig";
import type { TagOption } from "../../annotation.tagconfig";
const { Title, Paragraph, Text } = Typography;
interface TagBrowserProps {
onTagSelect?: (tagName: string, category: "object" | "control") => void;
}
/**
* Tag Browser Component
* Displays all available Label Studio tags in a browsable interface
*/
const TagBrowser: React.FC<TagBrowserProps> = ({ onTagSelect }) => {
const { config, objectOptions, controlOptions, loading, error } =
useTagConfig();
if (loading) {
return (
<Card>
<div style={{ textAlign: "center", padding: "40px" }}>
<Spin size="large" />
<div style={{ marginTop: 16 }}>...</div>
</div>
</Card>
);
}
if (error) {
return (
<Card>
<Empty
description={
<div>
<div>{error}</div>
<Text type="secondary"></Text>
</div>
}
/>
</Card>
);
}
const renderObjectList = () => (
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 2, lg: 3, xl: 3, xxl: 4 }}
dataSource={objectOptions}
renderItem={(item: TagOption) => {
const objConfig = config?.objects[item.value];
return (
<List.Item>
<Card
hoverable
size="small"
onClick={() => onTagSelect?.(item.value, "object")}
style={{ height: "100%" }}
>
<Space direction="vertical" size="small" style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Text strong>{getObjectDisplayName(item.value)}</Text>
<Tag color="blue">&lt;{item.value}&gt;</Tag>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.description}
</Text>
{objConfig && (
<div style={{ marginTop: 8 }}>
<Text style={{ fontSize: 11, color: "#8c8c8c" }}>
:{" "}
{objConfig.required_attrs.join(", ") || "无"}
</Text>
</div>
)}
</Space>
</Card>
</List.Item>
);
}}
/>
);
const renderControlsByGroup = () => {
const groups = getControlGroups();
return (
<Tabs
defaultActiveKey="classification"
items={Object.entries(groups).map(([groupKey, groupConfig]) => {
const groupControls = controlOptions.filter((opt: TagOption) =>
groupConfig.controls.includes(opt.value)
);
return {
key: groupKey,
label: groupConfig.label,
children: (
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 2, lg: 3, xl: 3, xxl: 4 }}
dataSource={groupControls}
locale={{ emptyText: "此分组暂无控件" }}
renderItem={(item: TagOption) => {
const ctrlConfig = config?.controls[item.value];
return (
<List.Item>
<Card
hoverable
size="small"
onClick={() => onTagSelect?.(item.value, "control")}
style={{ height: "100%" }}
>
<Space direction="vertical" size="small" style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Text strong>
{getControlDisplayName(item.value)}
</Text>
<Tag color="green">&lt;{item.value}&gt;</Tag>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.description}
</Text>
{ctrlConfig && (
<Space
size={4}
wrap
style={{ marginTop: 8 }}
>
{ctrlConfig.requires_children && (
<Tag
color="orange"
style={{ fontSize: 10, margin: 0 }}
>
&lt;{ctrlConfig.child_tag}&gt;
</Tag>
)}
{ctrlConfig.required_attrs.includes("toName") && (
<Tag
color="purple"
style={{ fontSize: 10, margin: 0 }}
>
</Tag>
)}
</Space>
)}
</Space>
</Card>
</List.Item>
);
}}
/>
),
};
})}
/>
);
};
return (
<Card>
<Tabs
defaultActiveKey="controls"
items={[
{
key: "controls",
label: (
<span>
<ControlOutlined />
({controlOptions.length})
</span>
),
children: renderControlsByGroup(),
},
{
key: "objects",
label: (
<span>
<AppstoreOutlined />
({objectOptions.length})
</span>
),
children: renderObjectList(),
},
{
key: "help",
label: (
<span>
<InfoCircleOutlined />
使
</span>
),
children: (
<div style={{ padding: "16px" }}>
<Title level={4}>Label Studio </Title>
<Paragraph>
</Paragraph>
<ul>
<li>
<Text strong></Text>
</li>
<li>
<Text strong></Text>
</li>
</ul>
<Title level={5} style={{ marginTop: 24 }}>
</Title>
<Paragraph>
<pre style={{ background: "#f5f5f5", padding: 12, borderRadius: 4 }}>
{`<View>
<!-- 数据对象 -->
<Image name="image" value="$image" />
<!-- 控件 -->
<RectangleLabels name="label" toName="image">
<Label value="人物" />
<Label value="车辆" />
</RectangleLabels>
</View>`}
</pre>
</Paragraph>
<Title level={5} style={{ marginTop: 24 }}>
</Title>
<ul>
<li>
<Text code>name</Text>
</li>
<li>
<Text code>toName</Text> name
</li>
<li>
<Text code>value</Text> $ $image, $text
</li>
<li>
<Text code>required</Text>
</li>
</ul>
</div>
),
},
]}
/>
</Card>
);
};
export default TagBrowser;
import React from "react";
import { Card, Tabs, List, Tag, Typography, Space, Empty, Spin } from "antd";
import {
AppstoreOutlined,
ControlOutlined,
InfoCircleOutlined,
} from "@ant-design/icons";
import { useTagConfig } from "../../../../hooks/useTagConfig";
import {
getControlDisplayName,
getObjectDisplayName,
getControlGroups,
} from "../../annotation.tagconfig";
import type { TagOption } from "../../annotation.tagconfig";
const { Title, Paragraph, Text } = Typography;
interface TagBrowserProps {
onTagSelect?: (tagName: string, category: "object" | "control") => void;
}
/**
* Tag Browser Component
* Displays all available Label Studio tags in a browsable interface
*/
const TagBrowser: React.FC<TagBrowserProps> = ({ onTagSelect }) => {
const { config, objectOptions, controlOptions, loading, error } =
useTagConfig();
if (loading) {
return (
<Card>
<div style={{ textAlign: "center", padding: "40px" }}>
<Spin size="large" />
<div style={{ marginTop: 16 }}>...</div>
</div>
</Card>
);
}
if (error) {
return (
<Card>
<Empty
description={
<div>
<div>{error}</div>
<Text type="secondary"></Text>
</div>
}
/>
</Card>
);
}
const renderObjectList = () => (
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 2, lg: 3, xl: 3, xxl: 4 }}
dataSource={objectOptions}
renderItem={(item: TagOption) => {
const objConfig = config?.objects[item.value];
return (
<List.Item>
<Card
hoverable
size="small"
onClick={() => onTagSelect?.(item.value, "object")}
style={{ height: "100%" }}
>
<Space direction="vertical" size="small" style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Text strong>{getObjectDisplayName(item.value)}</Text>
<Tag color="blue">&lt;{item.value}&gt;</Tag>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.description}
</Text>
{objConfig && (
<div style={{ marginTop: 8 }}>
<Text style={{ fontSize: 11, color: "#8c8c8c" }}>
:{" "}
{objConfig.required_attrs.join(", ") || "无"}
</Text>
</div>
)}
</Space>
</Card>
</List.Item>
);
}}
/>
);
const renderControlsByGroup = () => {
const groups = getControlGroups();
return (
<Tabs
defaultActiveKey="classification"
items={Object.entries(groups).map(([groupKey, groupConfig]) => {
const groupControls = controlOptions.filter((opt: TagOption) =>
groupConfig.controls.includes(opt.value)
);
return {
key: groupKey,
label: groupConfig.label,
children: (
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 2, lg: 3, xl: 3, xxl: 4 }}
dataSource={groupControls}
locale={{ emptyText: "此分组暂无控件" }}
renderItem={(item: TagOption) => {
const ctrlConfig = config?.controls[item.value];
return (
<List.Item>
<Card
hoverable
size="small"
onClick={() => onTagSelect?.(item.value, "control")}
style={{ height: "100%" }}
>
<Space direction="vertical" size="small" style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Text strong>
{getControlDisplayName(item.value)}
</Text>
<Tag color="green">&lt;{item.value}&gt;</Tag>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.description}
</Text>
{ctrlConfig && (
<Space
size={4}
wrap
style={{ marginTop: 8 }}
>
{ctrlConfig.requires_children && (
<Tag
color="orange"
style={{ fontSize: 10, margin: 0 }}
>
&lt;{ctrlConfig.child_tag}&gt;
</Tag>
)}
{ctrlConfig.required_attrs.includes("toName") && (
<Tag
color="purple"
style={{ fontSize: 10, margin: 0 }}
>
</Tag>
)}
</Space>
)}
</Space>
</Card>
</List.Item>
);
}}
/>
),
};
})}
/>
);
};
return (
<Card>
<Tabs
defaultActiveKey="controls"
items={[
{
key: "controls",
label: (
<span>
<ControlOutlined />
({controlOptions.length})
</span>
),
children: renderControlsByGroup(),
},
{
key: "objects",
label: (
<span>
<AppstoreOutlined />
({objectOptions.length})
</span>
),
children: renderObjectList(),
},
{
key: "help",
label: (
<span>
<InfoCircleOutlined />
使
</span>
),
children: (
<div style={{ padding: "16px" }}>
<Title level={4}>Label Studio </Title>
<Paragraph>
</Paragraph>
<ul>
<li>
<Text strong></Text>
</li>
<li>
<Text strong></Text>
</li>
</ul>
<Title level={5} style={{ marginTop: 24 }}>
</Title>
<Paragraph>
<pre style={{ background: "#f5f5f5", padding: 12, borderRadius: 4 }}>
{`<View>
<!-- 数据对象 -->
<Image name="image" value="$image" />
<!-- 控件 -->
<RectangleLabels name="label" toName="image">
<Label value="人物" />
<Label value="车辆" />
</RectangleLabels>
</View>`}
</pre>
</Paragraph>
<Title level={5} style={{ marginTop: 24 }}>
</Title>
<ul>
<li>
<Text code>name</Text>
</li>
<li>
<Text code>toName</Text> name
</li>
<li>
<Text code>value</Text> $ $image, $text
</li>
<li>
<Text code>required</Text>
</li>
</ul>
</div>
),
},
]}
/>
</Card>
);
};
export default TagBrowser;

View File

@@ -1,301 +1,301 @@
import React, { useState, useEffect } from "react";
import { Select, Tooltip, Spin, Collapse, Tag, Space } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { getTagConfigUsingGet } from "../../annotation.api";
import type {
LabelStudioTagConfig,
TagOption,
} from "../../annotation.tagconfig";
import {
parseTagConfig,
getControlDisplayName,
getObjectDisplayName,
getControlGroups,
} from "../../annotation.tagconfig";
const { Option, OptGroup } = Select;
interface TagSelectorProps {
value?: string;
onChange?: (value: string) => void;
type: "object" | "control";
placeholder?: string;
style?: React.CSSProperties;
disabled?: boolean;
}
/**
* Tag Selector Component
* Dynamically fetches and displays available Label Studio tags from backend config
*/
const TagSelector: React.FC<TagSelectorProps> = ({
value,
onChange,
type,
placeholder,
style,
disabled,
}) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tagOptions, setTagOptions] = useState<TagOption[]>([]);
useEffect(() => {
fetchTagConfig();
}, []);
const fetchTagConfig = async () => {
setLoading(true);
setError(null);
try {
const response = await getTagConfigUsingGet();
if (response.code === 200 && response.data) {
const config: LabelStudioTagConfig = response.data;
const { objectOptions, controlOptions } = parseTagConfig(config);
if (type === "object") {
setTagOptions(objectOptions);
} else {
setTagOptions(controlOptions);
}
} else {
setError(response.message || "获取标签配置失败");
}
} catch (err: any) {
console.error("Failed to fetch tag config:", err);
setError("加载标签配置时出错");
} finally {
setLoading(false);
}
};
if (loading) {
return (
<Select
placeholder="加载中..."
style={style}
disabled
suffixIcon={<Spin size="small" />}
/>
);
}
if (error) {
return (
<Tooltip title={error}>
<Select
placeholder="加载失败,点击重试"
style={style}
disabled={disabled}
status="error"
onClick={() => fetchTagConfig()}
/>
</Tooltip>
);
}
// Group controls by usage pattern
if (type === "control") {
const groups = getControlGroups();
const groupedOptions: Record<string, TagOption[]> = {};
const ungroupedOptions: TagOption[] = [];
// Group the controls
Object.entries(groups).forEach(([groupKey, groupConfig]) => {
groupedOptions[groupKey] = tagOptions.filter((opt) =>
groupConfig.controls.includes(opt.value)
);
});
// Find ungrouped controls
const allGroupedControls = new Set(
Object.values(groups).flatMap((g) => g.controls)
);
tagOptions.forEach((opt) => {
if (!allGroupedControls.has(opt.value)) {
ungroupedOptions.push(opt);
}
});
return (
<Select
value={value}
onChange={onChange}
placeholder={placeholder || "选择控件类型"}
style={style}
disabled={disabled}
showSearch
optionFilterProp="label"
>
{Object.entries(groups).map(([groupKey, groupConfig]) => {
const options = groupedOptions[groupKey];
if (options.length === 0) return null;
return (
<OptGroup key={groupKey} label={groupConfig.label}>
{options.map((opt) => (
<Option key={opt.value} value={opt.value} label={opt.label}>
<div className="flex items-center justify-between">
<span>{getControlDisplayName(opt.value)}</span>
<Tooltip title={opt.description}>
<InfoCircleOutlined
style={{ color: "#8c8c8c", fontSize: 12 }}
/>
</Tooltip>
</div>
</Option>
))}
</OptGroup>
);
})}
{ungroupedOptions.length > 0 && (
<OptGroup label="其他">
{ungroupedOptions.map((opt) => (
<Option key={opt.value} value={opt.value} label={opt.label}>
<div className="flex items-center justify-between">
<span>{getControlDisplayName(opt.value)}</span>
<Tooltip title={opt.description}>
<InfoCircleOutlined
style={{ color: "#8c8c8c", fontSize: 12 }}
/>
</Tooltip>
</div>
</Option>
))}
</OptGroup>
)}
</Select>
);
}
// Objects selector (no grouping)
return (
<Select
value={value}
onChange={onChange}
placeholder={placeholder || "选择数据对象类型"}
style={style}
disabled={disabled}
showSearch
optionFilterProp="label"
>
{tagOptions.map((opt) => (
<Option key={opt.value} value={opt.value} label={opt.label}>
<div className="flex items-center justify-between">
<span>{getObjectDisplayName(opt.value)}</span>
<Tooltip title={opt.description}>
<InfoCircleOutlined style={{ color: "#8c8c8c", fontSize: 12 }} />
</Tooltip>
</div>
</Option>
))}
</Select>
);
};
export default TagSelector;
/**
* Tag Info Panel Component
* Displays detailed information about a selected tag
*/
interface TagInfoPanelProps {
tagConfig: LabelStudioTagConfig | null;
tagType: string;
category: "object" | "control";
}
export const TagInfoPanel: React.FC<TagInfoPanelProps> = ({
tagConfig,
tagType,
category,
}) => {
if (!tagConfig || !tagType) {
return null;
}
const config =
category === "object"
? tagConfig.objects[tagType]
: tagConfig.controls[tagType];
if (!config) {
return null;
}
return (
<Collapse
size="small"
items={[
{
key: "1",
label: "标签配置详情",
children: (
<Space direction="vertical" size="small" style={{ width: "100%" }}>
<div>
<strong></strong>
{config.description}
</div>
<div>
<strong></strong>
<div style={{ marginTop: 4 }}>
{config.required_attrs.map((attr: string) => (
<Tag key={attr} color="red">
{attr}
</Tag>
))}
</div>
</div>
{config.optional_attrs &&
Object.keys(config.optional_attrs).length > 0 && (
<div>
<strong></strong>
<div style={{ marginTop: 4 }}>
{Object.entries(config.optional_attrs).map(
([attrName, attrConfig]: [string, any]) => (
<Tooltip
key={attrName}
title={
<div>
{attrConfig.description && (
<div>{attrConfig.description}</div>
)}
{attrConfig.type && (
<div>: {attrConfig.type}</div>
)}
{attrConfig.default !== undefined && (
<div>: {String(attrConfig.default)}</div>
)}
{attrConfig.values && (
<div>
: {attrConfig.values.join(", ")}
</div>
)}
</div>
}
>
<Tag color="blue" style={{ cursor: "help" }}>
{attrName}
</Tag>
</Tooltip>
)
)}
</div>
</div>
)}
{config.requires_children && (
<div>
<strong></strong>
<Tag color="green"> &lt;{config.child_tag}&gt;</Tag>
</div>
)}
</Space>
),
},
]}
/>
);
};
import React, { useState, useEffect } from "react";
import { Select, Tooltip, Spin, Collapse, Tag, Space } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { getTagConfigUsingGet } from "../../annotation.api";
import type {
LabelStudioTagConfig,
TagOption,
} from "../../annotation.tagconfig";
import {
parseTagConfig,
getControlDisplayName,
getObjectDisplayName,
getControlGroups,
} from "../../annotation.tagconfig";
const { Option, OptGroup } = Select;
interface TagSelectorProps {
value?: string;
onChange?: (value: string) => void;
type: "object" | "control";
placeholder?: string;
style?: React.CSSProperties;
disabled?: boolean;
}
/**
* Tag Selector Component
* Dynamically fetches and displays available Label Studio tags from backend config
*/
const TagSelector: React.FC<TagSelectorProps> = ({
value,
onChange,
type,
placeholder,
style,
disabled,
}) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tagOptions, setTagOptions] = useState<TagOption[]>([]);
useEffect(() => {
fetchTagConfig();
}, []);
const fetchTagConfig = async () => {
setLoading(true);
setError(null);
try {
const response = await getTagConfigUsingGet();
if (response.code === 200 && response.data) {
const config: LabelStudioTagConfig = response.data;
const { objectOptions, controlOptions } = parseTagConfig(config);
if (type === "object") {
setTagOptions(objectOptions);
} else {
setTagOptions(controlOptions);
}
} else {
setError(response.message || "获取标签配置失败");
}
} catch (err: any) {
console.error("Failed to fetch tag config:", err);
setError("加载标签配置时出错");
} finally {
setLoading(false);
}
};
if (loading) {
return (
<Select
placeholder="加载中..."
style={style}
disabled
suffixIcon={<Spin size="small" />}
/>
);
}
if (error) {
return (
<Tooltip title={error}>
<Select
placeholder="加载失败,点击重试"
style={style}
disabled={disabled}
status="error"
onClick={() => fetchTagConfig()}
/>
</Tooltip>
);
}
// Group controls by usage pattern
if (type === "control") {
const groups = getControlGroups();
const groupedOptions: Record<string, TagOption[]> = {};
const ungroupedOptions: TagOption[] = [];
// Group the controls
Object.entries(groups).forEach(([groupKey, groupConfig]) => {
groupedOptions[groupKey] = tagOptions.filter((opt) =>
groupConfig.controls.includes(opt.value)
);
});
// Find ungrouped controls
const allGroupedControls = new Set(
Object.values(groups).flatMap((g) => g.controls)
);
tagOptions.forEach((opt) => {
if (!allGroupedControls.has(opt.value)) {
ungroupedOptions.push(opt);
}
});
return (
<Select
value={value}
onChange={onChange}
placeholder={placeholder || "选择控件类型"}
style={style}
disabled={disabled}
showSearch
optionFilterProp="label"
>
{Object.entries(groups).map(([groupKey, groupConfig]) => {
const options = groupedOptions[groupKey];
if (options.length === 0) return null;
return (
<OptGroup key={groupKey} label={groupConfig.label}>
{options.map((opt) => (
<Option key={opt.value} value={opt.value} label={opt.label}>
<div className="flex items-center justify-between">
<span>{getControlDisplayName(opt.value)}</span>
<Tooltip title={opt.description}>
<InfoCircleOutlined
style={{ color: "#8c8c8c", fontSize: 12 }}
/>
</Tooltip>
</div>
</Option>
))}
</OptGroup>
);
})}
{ungroupedOptions.length > 0 && (
<OptGroup label="其他">
{ungroupedOptions.map((opt) => (
<Option key={opt.value} value={opt.value} label={opt.label}>
<div className="flex items-center justify-between">
<span>{getControlDisplayName(opt.value)}</span>
<Tooltip title={opt.description}>
<InfoCircleOutlined
style={{ color: "#8c8c8c", fontSize: 12 }}
/>
</Tooltip>
</div>
</Option>
))}
</OptGroup>
)}
</Select>
);
}
// Objects selector (no grouping)
return (
<Select
value={value}
onChange={onChange}
placeholder={placeholder || "选择数据对象类型"}
style={style}
disabled={disabled}
showSearch
optionFilterProp="label"
>
{tagOptions.map((opt) => (
<Option key={opt.value} value={opt.value} label={opt.label}>
<div className="flex items-center justify-between">
<span>{getObjectDisplayName(opt.value)}</span>
<Tooltip title={opt.description}>
<InfoCircleOutlined style={{ color: "#8c8c8c", fontSize: 12 }} />
</Tooltip>
</div>
</Option>
))}
</Select>
);
};
export default TagSelector;
/**
* Tag Info Panel Component
* Displays detailed information about a selected tag
*/
interface TagInfoPanelProps {
tagConfig: LabelStudioTagConfig | null;
tagType: string;
category: "object" | "control";
}
export const TagInfoPanel: React.FC<TagInfoPanelProps> = ({
tagConfig,
tagType,
category,
}) => {
if (!tagConfig || !tagType) {
return null;
}
const config =
category === "object"
? tagConfig.objects[tagType]
: tagConfig.controls[tagType];
if (!config) {
return null;
}
return (
<Collapse
size="small"
items={[
{
key: "1",
label: "标签配置详情",
children: (
<Space direction="vertical" size="small" style={{ width: "100%" }}>
<div>
<strong></strong>
{config.description}
</div>
<div>
<strong></strong>
<div style={{ marginTop: 4 }}>
{config.required_attrs.map((attr: string) => (
<Tag key={attr} color="red">
{attr}
</Tag>
))}
</div>
</div>
{config.optional_attrs &&
Object.keys(config.optional_attrs).length > 0 && (
<div>
<strong></strong>
<div style={{ marginTop: 4 }}>
{Object.entries(config.optional_attrs).map(
([attrName, attrConfig]: [string, any]) => (
<Tooltip
key={attrName}
title={
<div>
{attrConfig.description && (
<div>{attrConfig.description}</div>
)}
{attrConfig.type && (
<div>: {attrConfig.type}</div>
)}
{attrConfig.default !== undefined && (
<div>: {String(attrConfig.default)}</div>
)}
{attrConfig.values && (
<div>
: {attrConfig.values.join(", ")}
</div>
)}
</div>
}
>
<Tag color="blue" style={{ cursor: "help" }}>
{attrName}
</Tag>
</Tooltip>
)
)}
</div>
</div>
)}
{config.requires_children && (
<div>
<strong></strong>
<Tag color="green"> &lt;{config.child_tag}&gt;</Tag>
</div>
)}
</Space>
),
},
]}
/>
);
};

View File

@@ -1,3 +1,3 @@
export { default as TagSelector } from "./TagSelector";
export { default as TagBrowser } from "./TagBrowser";
export { TagInfoPanel } from "./TagSelector";
export { default as TagSelector } from "./TagSelector";
export { default as TagBrowser } from "./TagBrowser";
export { TagInfoPanel } from "./TagSelector";

View File

@@ -1,4 +1,4 @@
export { default as TemplateList } from "./TemplateList";
export { default as TemplateForm } from "./TemplateForm";
export { default as TemplateDetail } from "./TemplateDetail";
export { TagBrowser, TagSelector, TagInfoPanel } from "./components";
export { default as TemplateList } from "./TemplateList";
export { default as TemplateForm } from "./TemplateForm";
export { default as TemplateDetail } from "./TemplateDetail";
export { TagBrowser, TagSelector, TagInfoPanel } from "./components";