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

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