You've already forked DataMate
实现功能: - 替换 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 检查通过 - 事件绑定/解绑结构清晰,幂等性处理合理 - 跨任务消息校验与状态更新路径一致性明显提升
272 lines
7.9 KiB
TypeScript
272 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|