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:
2026-02-17 19:30:48 +08:00
parent f707ce9dae
commit 8f21798d57
7 changed files with 1095 additions and 5 deletions

View File

@@ -59,6 +59,151 @@
let lsInstance = null;
let currentTask = null;
let broadcastTimer = null;
let lastSelectedRegionId = null;
let regionSelectionBound = false;
/**
* Build display-friendly region info from a serialized result item.
*/
function buildRegionDisplay(item) {
if (!item || typeof item !== "object") return { displayText: "", displayLabel: "" };
var val = item.value || {};
var displayLabel = "";
var displayText = "";
// Extract label
if (Array.isArray(val.labels) && val.labels.length > 0) {
displayLabel = val.labels.join(", ");
} else if (Array.isArray(val.choices) && val.choices.length > 0) {
displayLabel = val.choices.join(", ");
} else if (Array.isArray(val.taxonomy) && val.taxonomy.length > 0) {
displayLabel = val.taxonomy.map(function(t) { return Array.isArray(t) ? t.join("/") : String(t); }).join(", ");
} else if (val.rating !== undefined) {
displayLabel = "Rating: " + val.rating;
} else {
displayLabel = item.type || "";
}
// Extract text
if (typeof val.text === "string") {
displayText = val.text.length > 80 ? val.text.substring(0, 80) + "..." : val.text;
} else if (typeof val.textarea === "string") {
displayText = val.textarea.length > 80 ? val.textarea.substring(0, 80) + "..." : val.textarea;
} else if (Array.isArray(val.text)) {
displayText = val.text.join(" ").substring(0, 80);
} else if (val.x !== undefined && val.y !== undefined) {
displayText = "(" + Math.round(val.x) + ", " + Math.round(val.y) + ")";
if (val.width !== undefined) displayText += " " + Math.round(val.width) + "x" + Math.round(val.height);
} else if (val.start !== undefined && val.end !== undefined) {
displayText = "[" + val.start + ":" + val.end + "]";
}
return { displayText: displayText, displayLabel: displayLabel };
}
/**
* Check and broadcast region selection changes.
* LS 1.7.1 has no onSelectRegion callback, so we poll the store.
*/
function checkRegionSelection() {
try {
if (!lsInstance) return;
var store = pickAnnotationStore(lsInstance);
var annotation = resolveSelectedAnnotation(store);
if (!annotation) return;
// LS stores the selected region on the annotation object
var selected = annotation.highlightedNode || null;
var regionId = (selected && selected.id) ? String(selected.id) : null;
var taskId = typeof currentTask?.id === "number" ? currentTask.id : Number(currentTask?.id) || null;
if (regionId !== lastSelectedRegionId) {
lastSelectedRegionId = regionId;
postToParent("LS_REGION_SELECTED", { regionId: regionId, taskId: taskId });
}
} catch (_) {}
}
function handleLsClick() {
setTimeout(checkRegionSelection, 50);
}
function handleLsMouseup() {
setTimeout(checkRegionSelection, 100);
}
function bindRegionSelectionListeners() {
if (regionSelectionBound) return;
var lsRoot = document.getElementById("label-studio");
if (!lsRoot) return;
lsRoot.addEventListener("click", handleLsClick);
lsRoot.addEventListener("mouseup", handleLsMouseup);
regionSelectionBound = true;
}
function unbindRegionSelectionListeners() {
var lsRoot = document.getElementById("label-studio");
if (lsRoot) {
lsRoot.removeEventListener("click", handleLsClick);
lsRoot.removeEventListener("mouseup", handleLsMouseup);
}
regionSelectionBound = false;
}
/**
* Broadcast current annotation state to parent (debounced).
*/
function broadcastAnnotationState() {
if (broadcastTimer) clearTimeout(broadcastTimer);
broadcastTimer = setTimeout(function() {
broadcastTimer = null;
try {
if (!lsInstance) return;
var store = pickAnnotationStore(lsInstance);
if (!store) return;
var selected = resolveSelectedAnnotation(store);
if (!selected) return;
var serialized = null;
if (typeof selected.serializeAnnotation === "function") {
serialized = selected.serializeAnnotation();
} else if (typeof selected.serialize === "function") {
serialized = selected.serialize();
}
var result = [];
if (Array.isArray(serialized)) {
result = serialized;
} else if (serialized && typeof serialized === "object") {
result = Array.isArray(serialized.result) ? serialized.result : [];
}
var taskId = typeof currentTask?.id === "number" ? currentTask.id : Number(currentTask?.id) || null;
// Build region info (exclude relations)
var regions = [];
for (var i = 0; i < result.length; i++) {
var item = result[i];
if (item && item.type !== "relation") {
var display = buildRegionDisplay(item);
regions.push({
id: item.id || ("r" + i),
type: item.type || "",
from_name: item.from_name || "",
to_name: item.to_name || "",
value: item.value || {},
displayText: display.displayText,
displayLabel: display.displayLabel,
});
}
}
postToParent("LS_ANNOTATION_CHANGED", {
taskId: taskId,
result: result,
regions: regions,
});
} catch (_) {}
}, 150);
}
function postToParent(type, payload) {
window.parent.postMessage({ type, payload }, ORIGIN);
@@ -78,6 +223,8 @@
});
function destroyLabelStudio() {
unbindRegionSelectionListeners();
try {
if (lsInstance && typeof lsInstance.destroy === "function") {
lsInstance.destroy();
@@ -86,6 +233,7 @@
lsInstance = null;
currentTask = null;
lastSelectedRegionId = null;
const root = document.getElementById("label-studio");
if (root) root.innerHTML = "";
@@ -359,9 +507,15 @@
}
} catch (_) {}
// Broadcast initial annotation state to parent panel
broadcastAnnotationState();
// Bind region selection listeners (idempotent - only binds once)
bindRegionSelectionListeners();
postToParent("LS_READY", { taskId: task?.id || null });
},
// 让内嵌编辑器的提交/保存按钮也能触发父页面保存
// 让内嵌编辑器的"提交/保存"按钮也能触发父页面保存
onSubmitAnnotation: function () {
try {
const raw = exportSelectedAnnotation();
@@ -370,6 +524,23 @@
postToParent("LS_ERROR", { message: e?.message || String(e) });
}
},
// Annotation change callbacks -> broadcast to parent panel
onUpdateAnnotation: function () {
broadcastAnnotationState();
setTimeout(checkRegionSelection, 50);
},
onEntityCreate: function () {
broadcastAnnotationState();
setTimeout(checkRegionSelection, 50);
},
onEntityDelete: function () {
broadcastAnnotationState();
setTimeout(checkRegionSelection, 50);
},
onSelectAnnotation: function () {
broadcastAnnotationState();
setTimeout(checkRegionSelection, 50);
},
});
}
@@ -415,6 +586,26 @@
postToParent("LS_PONG", {});
return;
}
if (msg.type === "LS_SELECT_REGION") {
var regionId = msg.payload && msg.payload.regionId;
if (regionId && lsInstance) {
var regionStore = pickAnnotationStore(lsInstance);
var annotation = resolveSelectedAnnotation(regionStore);
if (annotation && annotation.regionStore) {
var regions = annotation.regionStore.regions || [];
for (var ri = 0; ri < regions.length; ri++) {
if (String(regions[ri].id) === String(regionId)) {
if (typeof regions[ri].selectRegion === "function") {
regions[ri].selectRegion();
}
break;
}
}
}
}
return;
}
} catch (e) {
postToParent("LS_ERROR", { message: e?.message || String(e) });
}

View File

@@ -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>
);

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}