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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user