You've already forked DataMate
feat(annotation): 实现自定义数据标注结果面板
实现功能: - 替换 Label Studio 自带的侧边栏,使用自定义结果面板 - 支持通用区域关系标注(任意标注区域之间建立关系) - 实时同步 Label Studio 的标注变更 - 双向联动:点击面板区域可高亮 LS 内对应区域,反之亦然 - 快捷标注关系:关系拾取模式、CRUD 操作、自动切换 Tab - 保存联动:自动合并面板关系到标注结果,判断是否已标注 技术实现: - 新增 5 个组件: - annotation-result.types.ts: TypeScript 类型定义 - RegionList.tsx: 区域列表组件 - RelationEditor.tsx: 关系编辑弹窗 - RelationList.tsx: 关系列表组件 - AnnotationResultPanel.tsx: 主面板组件(300px,可折叠,Tabs 切换) - 修改 2 个文件: - lsf.html: 消息协议扩展、防抖广播、区域选择监听、事件绑定/解绑 - LabelStudioTextEditor.tsx: 移除 LS 侧边栏、集成自定义面板、消息处理、taskId 校验 关键设计: - 单向读取 + 保存时合并:避免复杂的双向同步 - _source: 'panel' 标记:区分面板创建的关系和 LS 原生关系 - 150ms 防抖广播:避免消息洪泛 - 幂等事件绑定:避免监听器累积 - taskId 校验:防止跨任务消息混乱 代码审查: - 经过 3 轮 codex 审查,所有问题已修复 - 构建成功,Lint 检查通过 - 事件绑定/解绑结构清晰,幂等性处理合理 - 跨任务消息校验与状态更新路径一致性明显提升
This commit is contained in:
@@ -14,6 +14,13 @@ import {
|
||||
type UseNewVersionResponse,
|
||||
} from "../annotation.api";
|
||||
import { AnnotationResultStatus } from "../annotation.model";
|
||||
import AnnotationResultPanel from "./components/AnnotationResultPanel";
|
||||
import type {
|
||||
LSResultItem,
|
||||
LSRegionInfo,
|
||||
LSAnnotationChangedPayload,
|
||||
PanelRelation,
|
||||
} from "./annotation-result.types";
|
||||
|
||||
type EditorProjectInfo = {
|
||||
projectId: string;
|
||||
@@ -225,6 +232,34 @@ const normalizeTaskListResponse = (
|
||||
};
|
||||
};
|
||||
|
||||
/** 合并面板管理的关系到标注 annotation 中 */
|
||||
const mergePanelRelations = (
|
||||
annotation: Record<string, unknown>,
|
||||
relations: PanelRelation[],
|
||||
): Record<string, unknown> => {
|
||||
const result = Array.isArray(annotation.result) ? [...(annotation.result as LSResultItem[])] : [];
|
||||
// 始终移除旧的面板关系(即使 relations 为空也要清除,否则删光关系后保存不生效)
|
||||
const filtered = result.filter(
|
||||
(item) => !(item.type === "relation" && item._source === "panel"),
|
||||
);
|
||||
// 添加当前面板关系
|
||||
for (const rel of relations) {
|
||||
filtered.push({
|
||||
id: rel.id,
|
||||
from_name: "relation",
|
||||
to_name: "relation",
|
||||
type: "relation",
|
||||
from_id: rel.fromRegionId,
|
||||
to_id: rel.toRegionId,
|
||||
direction: rel.direction,
|
||||
labels: rel.labels,
|
||||
value: {},
|
||||
_source: "panel",
|
||||
});
|
||||
}
|
||||
return { ...annotation, result: filtered };
|
||||
};
|
||||
|
||||
export default function LabelStudioTextEditor() {
|
||||
const { projectId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -280,6 +315,13 @@ export default function LabelStudioTextEditor() {
|
||||
const [, setCheckingFileVersion] = useState(false);
|
||||
const [usingNewVersion, setUsingNewVersion] = useState(false);
|
||||
|
||||
// 自定义标注结果面板状态
|
||||
const [annotationResult, setAnnotationResult] = useState<LSResultItem[]>([]);
|
||||
const [lsRegions, setLsRegions] = useState<LSRegionInfo[]>([]);
|
||||
const [selectedRegionId, setSelectedRegionId] = useState<string | null>(null);
|
||||
const [panelRelations, setPanelRelations] = useState<PanelRelation[]>([]);
|
||||
const [resultPanelCollapsed, setResultPanelCollapsed] = useState(false);
|
||||
|
||||
const focusIframe = useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
@@ -293,6 +335,22 @@ export default function LabelStudioTextEditor() {
|
||||
win.postMessage({ type, payload }, origin);
|
||||
}, [origin]);
|
||||
|
||||
const handleSelectRegionInIframe = useCallback((regionId: string) => {
|
||||
postToIframe("LS_SELECT_REGION", { regionId });
|
||||
setSelectedRegionId(regionId);
|
||||
}, [postToIframe]);
|
||||
|
||||
// 从 labelConfig 解析可用关系标签
|
||||
const availableRelationLabels = useMemo(() => {
|
||||
if (!project?.labelConfig) return [];
|
||||
const labels: string[] = [];
|
||||
const matches = project.labelConfig.matchAll(/<Relation\s[^>]*value="([^"]+)"/gi);
|
||||
for (const m of matches) {
|
||||
if (m[1]) labels.push(m[1]);
|
||||
}
|
||||
return labels;
|
||||
}, [project?.labelConfig]);
|
||||
|
||||
const confirmEmptyAnnotationStatus = useCallback(() => {
|
||||
return new Promise<AnnotationResultStatus | null>((resolve) => {
|
||||
let resolved = false;
|
||||
@@ -520,6 +578,25 @@ export default function LabelStudioTextEditor() {
|
||||
savedSnapshotsRef.current[buildSnapshotKey(fileId, segmentIndex)] =
|
||||
buildAnnotationSnapshot(initialAnnotation);
|
||||
|
||||
// 重置自定义面板状态
|
||||
setAnnotationResult([]);
|
||||
setLsRegions([]);
|
||||
setSelectedRegionId(null);
|
||||
// 从已有标注中提取面板管理的关系
|
||||
const existingResult = Array.isArray(initialAnnotation?.result) ? initialAnnotation.result as LSResultItem[] : [];
|
||||
setPanelRelations(
|
||||
existingResult
|
||||
.filter((item) => item.type === "relation" && item._source === "panel" && item.from_id && item.to_id)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
fromRegionId: item.from_id!,
|
||||
toRegionId: item.to_id!,
|
||||
direction: item.direction || "right",
|
||||
labels: item.labels || [],
|
||||
source: "panel" as const,
|
||||
})),
|
||||
);
|
||||
|
||||
expectedTaskIdRef.current = Number(taskForIframe?.id) || null;
|
||||
postToIframe("LS_INIT", {
|
||||
labelConfig: project.labelConfig,
|
||||
@@ -532,8 +609,8 @@ export default function LabelStudioTextEditor() {
|
||||
"update", // 更新按钮
|
||||
"submit", // 提交按钮
|
||||
"controls", // 控制面板
|
||||
// 侧边栏(包含 Outliner 和 Details)
|
||||
"side-column",
|
||||
// 侧边栏已由自定义面板替代,不再启用
|
||||
// "side-column",
|
||||
// 标注管理
|
||||
"annotations:tabs",
|
||||
"annotations:menu",
|
||||
@@ -716,7 +793,9 @@ export default function LabelStudioTextEditor() {
|
||||
const currentTask = tasks.find((item) => item.fileId === String(fileId));
|
||||
const currentStatus = currentTask?.annotationStatus;
|
||||
let resolvedStatus: AnnotationResultStatus;
|
||||
if (isAnnotationResultEmpty(annotationRecord)) {
|
||||
// 判断是否为空标注:LS 结果为空且面板关系也为空
|
||||
const isEmpty = isAnnotationResultEmpty(annotationRecord) && panelRelations.length === 0;
|
||||
if (isEmpty) {
|
||||
if (
|
||||
currentStatus === AnnotationResultStatus.NO_ANNOTATION ||
|
||||
currentStatus === AnnotationResultStatus.NOT_APPLICABLE
|
||||
@@ -733,8 +812,11 @@ export default function LabelStudioTextEditor() {
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// 合并面板管理的关系到标注结果中
|
||||
const mergedAnnotation = mergePanelRelations(annotationRecord, panelRelations);
|
||||
|
||||
const resp = (await upsertEditorAnnotationUsingPut(projectId, String(fileId), {
|
||||
annotation,
|
||||
annotation: mergedAnnotation,
|
||||
segmentIndex,
|
||||
annotationStatus: resolvedStatus,
|
||||
})) as ApiResponse<UpsertAnnotationResponse>;
|
||||
@@ -773,6 +855,7 @@ export default function LabelStudioTextEditor() {
|
||||
confirmEmptyAnnotationStatus,
|
||||
currentSegmentIndex,
|
||||
message,
|
||||
panelRelations,
|
||||
projectId,
|
||||
segmented,
|
||||
selectedFileId,
|
||||
@@ -819,6 +902,11 @@ export default function LabelStudioTextEditor() {
|
||||
setSegmented(false);
|
||||
setCurrentSegmentIndex(0);
|
||||
setSegmentTotal(0);
|
||||
// 重置自定义面板状态
|
||||
setAnnotationResult([]);
|
||||
setLsRegions([]);
|
||||
setSelectedRegionId(null);
|
||||
setPanelRelations([]);
|
||||
savedSnapshotsRef.current = {};
|
||||
if (exportCheckRef.current?.timer) {
|
||||
window.clearTimeout(exportCheckRef.current.timer);
|
||||
@@ -911,6 +999,31 @@ export default function LabelStudioTextEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 自定义面板:标注变更同步
|
||||
if (msg.type === "LS_ANNOTATION_CHANGED") {
|
||||
const changed = msg.payload as LSAnnotationChangedPayload | undefined;
|
||||
if (changed) {
|
||||
// 校验 taskId:只要当前有预期 taskId,就要求消息携带且匹配,否则丢弃
|
||||
if (expectedTaskIdRef.current) {
|
||||
if (!changed.taskId || Number(changed.taskId) !== expectedTaskIdRef.current) return;
|
||||
}
|
||||
setAnnotationResult(changed.result || []);
|
||||
setLsRegions(changed.regions || []);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 自定义面板:区域选中同步
|
||||
if (msg.type === "LS_REGION_SELECTED") {
|
||||
const regionPayload = msg.payload as { regionId?: string | null; taskId?: number | null } | undefined;
|
||||
// 校验 taskId,防止旧任务的延迟消息影响当前面板
|
||||
if (expectedTaskIdRef.current) {
|
||||
if (!regionPayload?.taskId || Number(regionPayload.taskId) !== expectedTaskIdRef.current) return;
|
||||
}
|
||||
setSelectedRegionId(regionPayload?.regionId || null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "LS_ERROR") {
|
||||
const payloadMessage = resolvePayloadMessage(msg.payload);
|
||||
message.error(payloadMessage || "编辑器发生错误");
|
||||
@@ -1058,6 +1171,11 @@ export default function LabelStudioTextEditor() {
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
icon={resultPanelCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={() => setResultPanelCollapsed(!resultPanelCollapsed)}
|
||||
title={resultPanelCollapsed ? "展开标注面板" : "收起标注面板"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1177,6 +1295,18 @@ export default function LabelStudioTextEditor() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧自定义标注结果面板 */}
|
||||
<AnnotationResultPanel
|
||||
annotationResult={annotationResult}
|
||||
lsRegions={lsRegions}
|
||||
selectedRegionId={selectedRegionId}
|
||||
onSelectRegion={handleSelectRegionInIframe}
|
||||
availableRelationLabels={availableRelationLabels}
|
||||
panelRelations={panelRelations}
|
||||
onPanelRelationsChange={setPanelRelations}
|
||||
collapsed={resultPanelCollapsed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Label Studio annotation result types and custom panel types.
|
||||
*/
|
||||
|
||||
/** A single item in Label Studio's annotation result[] array. */
|
||||
export interface LSResultItem {
|
||||
id: string;
|
||||
from_name: string;
|
||||
to_name: string;
|
||||
type: string;
|
||||
value: Record<string, unknown>;
|
||||
|
||||
// Relation-specific fields (type === "relation")
|
||||
from_id?: string;
|
||||
to_id?: string;
|
||||
direction?: "right" | "left" | "bi";
|
||||
labels?: string[];
|
||||
|
||||
// Meta fields
|
||||
origin?: string;
|
||||
score?: number;
|
||||
readonly?: boolean;
|
||||
/** Marker for panel-created relations */
|
||||
_source?: "panel";
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Region info sent from iframe for display in the panel. */
|
||||
export interface LSRegionInfo {
|
||||
id: string;
|
||||
type: string;
|
||||
from_name: string;
|
||||
to_name: string;
|
||||
value: Record<string, unknown>;
|
||||
displayText: string;
|
||||
displayLabel: string;
|
||||
}
|
||||
|
||||
/** Region representation for the panel UI. */
|
||||
export interface PanelRegion {
|
||||
id: string;
|
||||
type: string;
|
||||
fromName: string;
|
||||
toName: string;
|
||||
displayText: string;
|
||||
displayLabel: string;
|
||||
value: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Relation representation for the panel UI. */
|
||||
export interface PanelRelation {
|
||||
id: string;
|
||||
fromRegionId: string;
|
||||
toRegionId: string;
|
||||
direction: "right" | "left" | "bi";
|
||||
labels: string[];
|
||||
source: "ls" | "panel";
|
||||
}
|
||||
|
||||
/** Payload of LS_ANNOTATION_CHANGED message (iframe -> parent). */
|
||||
export interface LSAnnotationChangedPayload {
|
||||
taskId: number | null;
|
||||
result: LSResultItem[];
|
||||
regions: LSRegionInfo[];
|
||||
}
|
||||
|
||||
/** Payload of LS_SELECT_REGION message (parent -> iframe). */
|
||||
export interface LSSelectRegionPayload {
|
||||
regionId: string;
|
||||
}
|
||||
|
||||
/** Payload of LS_REGION_SELECTED message (iframe -> parent). */
|
||||
export interface LSRegionSelectedPayload {
|
||||
regionId: string | null;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Badge, Tabs, Typography, Alert } from "antd";
|
||||
import RegionList from "./RegionList";
|
||||
import RelationList from "./RelationList";
|
||||
import RelationEditor from "./RelationEditor";
|
||||
import type {
|
||||
LSResultItem,
|
||||
LSRegionInfo,
|
||||
PanelRegion,
|
||||
PanelRelation,
|
||||
} from "../annotation-result.types";
|
||||
|
||||
interface AnnotationResultPanelProps {
|
||||
annotationResult: LSResultItem[];
|
||||
lsRegions: LSRegionInfo[];
|
||||
selectedRegionId: string | null;
|
||||
onSelectRegion: (regionId: string) => void;
|
||||
availableRelationLabels: string[];
|
||||
panelRelations: PanelRelation[];
|
||||
onPanelRelationsChange: (relations: PanelRelation[]) => void;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
let relationIdCounter = 0;
|
||||
const genRelationId = () => `panel_rel_${Date.now()}_${++relationIdCounter}`;
|
||||
|
||||
/** Convert LSRegionInfo from iframe to PanelRegion for display. */
|
||||
const toPanelRegion = (info: LSRegionInfo): PanelRegion => ({
|
||||
id: info.id,
|
||||
type: info.type,
|
||||
fromName: info.from_name,
|
||||
toName: info.to_name,
|
||||
displayText: info.displayText,
|
||||
displayLabel: info.displayLabel,
|
||||
value: info.value,
|
||||
});
|
||||
|
||||
/** Extract LS-native relations from result array (excluding panel-managed ones). */
|
||||
const extractLsRelations = (result: LSResultItem[], panelRelationIds: Set<string>): PanelRelation[] =>
|
||||
result
|
||||
.filter((item) =>
|
||||
item.type === "relation" &&
|
||||
item.from_id &&
|
||||
item.to_id &&
|
||||
item._source !== "panel" &&
|
||||
!panelRelationIds.has(item.id),
|
||||
)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
fromRegionId: item.from_id!,
|
||||
toRegionId: item.to_id!,
|
||||
direction: item.direction || "right",
|
||||
labels: item.labels || [],
|
||||
source: "ls" as const,
|
||||
}));
|
||||
|
||||
export default function AnnotationResultPanel({
|
||||
annotationResult,
|
||||
lsRegions,
|
||||
selectedRegionId,
|
||||
onSelectRegion,
|
||||
availableRelationLabels,
|
||||
panelRelations,
|
||||
onPanelRelationsChange,
|
||||
collapsed,
|
||||
}: AnnotationResultPanelProps) {
|
||||
const [relationPickMode, setRelationPickMode] = useState(false);
|
||||
const [pickedRegionIds, setPickedRegionIds] = useState<string[]>([]);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingRelation, setEditingRelation] = useState<PanelRelation | null>(null);
|
||||
const [activeTabKey, setActiveTabKey] = useState("regions");
|
||||
|
||||
const regions = useMemo(
|
||||
() => lsRegions.map(toPanelRegion),
|
||||
[lsRegions],
|
||||
);
|
||||
|
||||
const panelRelationIds = useMemo(
|
||||
() => new Set(panelRelations.map((r) => r.id)),
|
||||
[panelRelations],
|
||||
);
|
||||
|
||||
const lsRelations = useMemo(
|
||||
() => extractLsRelations(annotationResult, panelRelationIds),
|
||||
[annotationResult, panelRelationIds],
|
||||
);
|
||||
|
||||
const allRelations = useMemo(
|
||||
() => [...lsRelations, ...panelRelations],
|
||||
[lsRelations, panelRelations],
|
||||
);
|
||||
|
||||
// --- Relation pick mode ---
|
||||
const handleStartAddRelation = useCallback(() => {
|
||||
if (regions.length < 2) {
|
||||
// Not enough regions, open editor directly
|
||||
setEditingRelation(null);
|
||||
setEditorOpen(true);
|
||||
return;
|
||||
}
|
||||
setRelationPickMode(true);
|
||||
setPickedRegionIds([]);
|
||||
// 自动切到区域 Tab,让用户看到拾取提示
|
||||
setActiveTabKey("regions");
|
||||
}, [regions.length]);
|
||||
|
||||
const handlePickRegion = useCallback(
|
||||
(regionId: string) => {
|
||||
setPickedRegionIds((prev) => {
|
||||
if (prev.includes(regionId)) {
|
||||
return prev.filter((id) => id !== regionId);
|
||||
}
|
||||
const next = [...prev, regionId];
|
||||
if (next.length >= 2) {
|
||||
// Two regions picked, open editor
|
||||
setTimeout(() => {
|
||||
setRelationPickMode(false);
|
||||
setEditingRelation(null);
|
||||
setEditorOpen(true);
|
||||
}, 0);
|
||||
}
|
||||
return next.slice(0, 2);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCancelPick = useCallback(() => {
|
||||
setRelationPickMode(false);
|
||||
setPickedRegionIds([]);
|
||||
}, []);
|
||||
|
||||
// --- Relation CRUD ---
|
||||
const handleConfirmRelation = useCallback(
|
||||
(data: { fromRegionId: string; toRegionId: string; direction: "right" | "left" | "bi"; labels: string[] }) => {
|
||||
if (editingRelation) {
|
||||
onPanelRelationsChange(
|
||||
panelRelations.map((r) =>
|
||||
r.id === editingRelation.id
|
||||
? { ...r, ...data }
|
||||
: r,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
onPanelRelationsChange([
|
||||
...panelRelations,
|
||||
{
|
||||
id: genRelationId(),
|
||||
...data,
|
||||
source: "panel",
|
||||
},
|
||||
]);
|
||||
}
|
||||
setEditorOpen(false);
|
||||
setEditingRelation(null);
|
||||
setPickedRegionIds([]);
|
||||
},
|
||||
[editingRelation, onPanelRelationsChange, panelRelations],
|
||||
);
|
||||
|
||||
const handleDeleteRelation = useCallback(
|
||||
(relationId: string) => {
|
||||
onPanelRelationsChange(panelRelations.filter((r) => r.id !== relationId));
|
||||
},
|
||||
[onPanelRelationsChange, panelRelations],
|
||||
);
|
||||
|
||||
const handleEditRelation = useCallback((relation: PanelRelation) => {
|
||||
setEditingRelation(relation);
|
||||
setEditorOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditorCancel = useCallback(() => {
|
||||
setEditorOpen(false);
|
||||
setEditingRelation(null);
|
||||
}, []);
|
||||
|
||||
if (collapsed) return null;
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: "regions",
|
||||
label: (
|
||||
<span>
|
||||
区域 <Badge count={regions.length} size="small" color="#999" style={{ marginLeft: 4 }} />
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div className="overflow-auto" style={{ maxHeight: "calc(100vh - 200px)" }}>
|
||||
{relationPickMode && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
closable
|
||||
onClose={handleCancelPick}
|
||||
message="请依次点击两个区域来建立关系"
|
||||
className="mb-2 mx-1"
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
)}
|
||||
<RegionList
|
||||
regions={regions}
|
||||
selectedRegionId={selectedRegionId}
|
||||
onSelectRegion={onSelectRegion}
|
||||
relationPickMode={relationPickMode}
|
||||
pickedRegionIds={pickedRegionIds}
|
||||
onPickRegion={handlePickRegion}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "relations",
|
||||
label: (
|
||||
<span>
|
||||
关系 <Badge count={allRelations.length} size="small" color="#999" style={{ marginLeft: 4 }} />
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div className="overflow-auto" style={{ maxHeight: "calc(100vh - 200px)" }}>
|
||||
<RelationList
|
||||
relations={allRelations}
|
||||
regions={regions}
|
||||
onDeleteRelation={handleDeleteRelation}
|
||||
onEditRelation={handleEditRelation}
|
||||
onStartAddRelation={handleStartAddRelation}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-l border-gray-200 bg-white flex flex-col transition-all duration-200 min-h-0"
|
||||
style={{ width: 300 }}
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-gray-200 font-medium text-sm flex items-center justify-between">
|
||||
<Typography.Text strong style={{ fontSize: 13 }}>
|
||||
标注结果
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{regions.length} 区域 / {allRelations.length} 关系
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<Tabs
|
||||
activeKey={activeTabKey}
|
||||
onChange={setActiveTabKey}
|
||||
size="small"
|
||||
className="px-1"
|
||||
items={tabItems}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RelationEditor
|
||||
open={editorOpen}
|
||||
mode={editingRelation ? "edit" : "add"}
|
||||
regions={regions}
|
||||
initialFromRegionId={editingRelation?.fromRegionId || pickedRegionIds[0]}
|
||||
initialToRegionId={editingRelation?.toRegionId || pickedRegionIds[1]}
|
||||
initialDirection={editingRelation?.direction}
|
||||
initialLabels={editingRelation?.labels}
|
||||
availableRelationLabels={availableRelationLabels}
|
||||
onConfirm={handleConfirmRelation}
|
||||
onCancel={handleEditorCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useMemo } from "react";
|
||||
import { Empty, Tag, Typography } from "antd";
|
||||
import { AimOutlined } from "@ant-design/icons";
|
||||
import type { PanelRegion } from "../annotation-result.types";
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
labels: "blue",
|
||||
rectanglelabels: "green",
|
||||
polygonlabels: "purple",
|
||||
brushlabels: "orange",
|
||||
ellipselabels: "cyan",
|
||||
keypointlabels: "magenta",
|
||||
choices: "geekblue",
|
||||
textarea: "gold",
|
||||
taxonomy: "lime",
|
||||
rating: "volcano",
|
||||
};
|
||||
|
||||
interface RegionListProps {
|
||||
regions: PanelRegion[];
|
||||
selectedRegionId: string | null;
|
||||
onSelectRegion: (regionId: string) => void;
|
||||
relationPickMode: boolean;
|
||||
pickedRegionIds: string[];
|
||||
onPickRegion?: (regionId: string) => void;
|
||||
}
|
||||
|
||||
export default function RegionList({
|
||||
regions,
|
||||
selectedRegionId,
|
||||
onSelectRegion,
|
||||
relationPickMode,
|
||||
pickedRegionIds,
|
||||
onPickRegion,
|
||||
}: RegionListProps) {
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, PanelRegion[]>();
|
||||
for (const r of regions) {
|
||||
const key = r.fromName;
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(r);
|
||||
}
|
||||
return map;
|
||||
}, [regions]);
|
||||
|
||||
if (regions.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无标注区域"
|
||||
className="py-6"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleClick = (regionId: string) => {
|
||||
if (relationPickMode && onPickRegion) {
|
||||
onPickRegion(regionId);
|
||||
} else {
|
||||
onSelectRegion(regionId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{Array.from(grouped.entries()).map(([fromName, items]) => (
|
||||
<div key={fromName}>
|
||||
{grouped.size > 1 && (
|
||||
<div className="px-2 py-1 text-xs text-gray-400 font-medium uppercase">
|
||||
{fromName}
|
||||
</div>
|
||||
)}
|
||||
{items.map((region) => {
|
||||
const isSelected = region.id === selectedRegionId;
|
||||
const isPicked = pickedRegionIds.includes(region.id);
|
||||
const color = TYPE_COLORS[region.type] || "default";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={region.id}
|
||||
className={[
|
||||
"flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors",
|
||||
isSelected ? "bg-blue-50 border border-blue-200" : "hover:bg-gray-50 border border-transparent",
|
||||
relationPickMode ? "ring-1 ring-inset ring-orange-200" : "",
|
||||
isPicked ? "bg-orange-50 border-orange-300" : "",
|
||||
].join(" ")}
|
||||
onClick={() => handleClick(region.id)}
|
||||
>
|
||||
{relationPickMode && (
|
||||
<div
|
||||
className={[
|
||||
"w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center",
|
||||
isPicked ? "border-orange-500 bg-orange-500" : "border-gray-300",
|
||||
].join(" ")}
|
||||
>
|
||||
{isPicked && (
|
||||
<span className="text-white text-[10px] font-bold">
|
||||
{pickedRegionIds.indexOf(region.id) + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!relationPickMode && isSelected && (
|
||||
<AimOutlined className="text-blue-500 flex-shrink-0" style={{ fontSize: 12 }} />
|
||||
)}
|
||||
<Tag color={color} className="m-0 text-xs" style={{ maxWidth: 80 }}>
|
||||
<Typography.Text ellipsis style={{ fontSize: 11, maxWidth: 64, color: "inherit" }}>
|
||||
{region.displayLabel || region.type}
|
||||
</Typography.Text>
|
||||
</Tag>
|
||||
<Typography.Text
|
||||
ellipsis
|
||||
className="flex-1 text-xs"
|
||||
style={{ fontSize: 12, color: "#555" }}
|
||||
>
|
||||
{region.displayText || `#${region.id.slice(0, 6)}`}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Form, Modal, Radio, Select, Typography } from "antd";
|
||||
import { SwapRightOutlined, SwapLeftOutlined, SwapOutlined } from "@ant-design/icons";
|
||||
import type { PanelRegion } from "../annotation-result.types";
|
||||
|
||||
interface RelationEditorProps {
|
||||
open: boolean;
|
||||
mode: "add" | "edit";
|
||||
regions: PanelRegion[];
|
||||
initialFromRegionId?: string;
|
||||
initialToRegionId?: string;
|
||||
initialDirection?: "right" | "left" | "bi";
|
||||
initialLabels?: string[];
|
||||
availableRelationLabels: string[];
|
||||
onConfirm: (data: {
|
||||
fromRegionId: string;
|
||||
toRegionId: string;
|
||||
direction: "right" | "left" | "bi";
|
||||
labels: string[];
|
||||
}) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function RelationEditor({
|
||||
open,
|
||||
mode,
|
||||
regions,
|
||||
initialFromRegionId,
|
||||
initialToRegionId,
|
||||
initialDirection,
|
||||
initialLabels,
|
||||
availableRelationLabels,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: RelationEditorProps) {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.setFieldsValue({
|
||||
fromRegionId: initialFromRegionId || undefined,
|
||||
toRegionId: initialToRegionId || undefined,
|
||||
direction: initialDirection || "right",
|
||||
labels: initialLabels || [],
|
||||
});
|
||||
}
|
||||
}, [open, form, initialFromRegionId, initialToRegionId, initialDirection, initialLabels]);
|
||||
|
||||
const regionOptions = useMemo(
|
||||
() =>
|
||||
regions.map((r) => ({
|
||||
value: r.id,
|
||||
label: `${r.displayLabel || r.type} - ${r.displayText || r.id.slice(0, 8)}`,
|
||||
})),
|
||||
[regions],
|
||||
);
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
onConfirm({
|
||||
fromRegionId: values.fromRegionId,
|
||||
toRegionId: values.toRegionId,
|
||||
direction: values.direction,
|
||||
labels: values.labels || [],
|
||||
});
|
||||
} catch {
|
||||
// validation failed
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={mode === "add" ? "添加关系" : "编辑关系"}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
destroyOnClose
|
||||
width={480}
|
||||
>
|
||||
<Form form={form} layout="vertical" className="mt-4">
|
||||
<Form.Item
|
||||
name="fromRegionId"
|
||||
label="源区域"
|
||||
rules={[{ required: true, message: "请选择源区域" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择源区域"
|
||||
options={regionOptions}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="direction"
|
||||
label="方向"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio.Button value="right">
|
||||
<SwapRightOutlined /> 正向
|
||||
</Radio.Button>
|
||||
<Radio.Button value="left">
|
||||
<SwapLeftOutlined /> 反向
|
||||
</Radio.Button>
|
||||
<Radio.Button value="bi">
|
||||
<SwapOutlined /> 双向
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="toRegionId"
|
||||
label="目标区域"
|
||||
rules={[
|
||||
{ required: true, message: "请选择目标区域" },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue("fromRegionId") !== value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error("目标区域不能与源区域相同"));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择目标区域"
|
||||
options={regionOptions}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="labels" label="关系标签">
|
||||
{availableRelationLabels.length > 0 ? (
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder="选择或输入关系标签"
|
||||
options={availableRelationLabels.map((l) => ({ value: l, label: l }))}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Select mode="tags" placeholder="输入关系标签(回车确认)" />
|
||||
<Typography.Text type="secondary" className="text-xs">
|
||||
当前模板未配置预定义关系标签,可手动输入
|
||||
</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Button, Empty, Popconfirm, Tag, Typography } from "antd";
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
SwapRightOutlined,
|
||||
SwapLeftOutlined,
|
||||
SwapOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { PanelRegion, PanelRelation } from "../annotation-result.types";
|
||||
|
||||
interface RelationListProps {
|
||||
relations: PanelRelation[];
|
||||
regions: PanelRegion[];
|
||||
onDeleteRelation: (relationId: string) => void;
|
||||
onEditRelation: (relation: PanelRelation) => void;
|
||||
onStartAddRelation: () => void;
|
||||
}
|
||||
|
||||
const directionIcon = (dir: string) => {
|
||||
if (dir === "left") return <SwapLeftOutlined />;
|
||||
if (dir === "bi") return <SwapOutlined />;
|
||||
return <SwapRightOutlined />;
|
||||
};
|
||||
|
||||
const findRegion = (regions: PanelRegion[], id: string) =>
|
||||
regions.find((r) => r.id === id);
|
||||
|
||||
export default function RelationList({
|
||||
relations,
|
||||
regions,
|
||||
onDeleteRelation,
|
||||
onEditRelation,
|
||||
onStartAddRelation,
|
||||
}: RelationListProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between px-2 py-1.5">
|
||||
<Typography.Text className="text-xs text-gray-500">
|
||||
{relations.length} 个关系
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onStartAddRelation}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{relations.length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无关系"
|
||||
className="py-6"
|
||||
>
|
||||
<Button
|
||||
type="dashed"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onStartAddRelation}
|
||||
>
|
||||
添加关系
|
||||
</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{relations.map((rel) => {
|
||||
const fromRegion = findRegion(regions, rel.fromRegionId);
|
||||
const toRegion = findRegion(regions, rel.toRegionId);
|
||||
const editable = rel.source === "panel";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rel.id}
|
||||
className="flex items-center gap-1.5 px-2 py-1.5 rounded hover:bg-gray-50 group"
|
||||
>
|
||||
{/* From region */}
|
||||
<Tag color="blue" className="m-0 text-xs flex-shrink-0" style={{ maxWidth: 80 }}>
|
||||
<Typography.Text ellipsis style={{ fontSize: 11, maxWidth: 64, color: "inherit" }}>
|
||||
{fromRegion?.displayLabel || rel.fromRegionId.slice(0, 6)}
|
||||
</Typography.Text>
|
||||
</Tag>
|
||||
|
||||
{/* Direction */}
|
||||
<span className="text-gray-400 flex-shrink-0">
|
||||
{directionIcon(rel.direction)}
|
||||
</span>
|
||||
|
||||
{/* To region */}
|
||||
<Tag color="green" className="m-0 text-xs flex-shrink-0" style={{ maxWidth: 80 }}>
|
||||
<Typography.Text ellipsis style={{ fontSize: 11, maxWidth: 64, color: "inherit" }}>
|
||||
{toRegion?.displayLabel || rel.toRegionId.slice(0, 6)}
|
||||
</Typography.Text>
|
||||
</Tag>
|
||||
|
||||
{/* Relation labels */}
|
||||
<div className="flex-1 flex items-center gap-0.5 overflow-hidden">
|
||||
{rel.labels.map((label) => (
|
||||
<Tag key={label} className="m-0 text-xs" style={{ fontSize: 10 }}>
|
||||
{label}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{editable && (
|
||||
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined style={{ fontSize: 12 }} />}
|
||||
onClick={() => onEditRelation(rel)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除该关系?"
|
||||
onConfirm={() => onDeleteRelation(rel.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined style={{ fontSize: 12 }} />}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)}
|
||||
{!editable && (
|
||||
<Tag className="m-0 flex-shrink-0" style={{ fontSize: 9 }}>LS</Tag>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user