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