Files
DataMate/frontend/public/lsf/lsf.html
Jerry Yan 8f21798d57 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 检查通过
- 事件绑定/解绑结构清晰,幂等性处理合理
- 跨任务消息校验与状态更新路径一致性明显提升
2026-02-17 19:30:48 +08:00

620 lines
23 KiB
HTML

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DataBuilder知识应用管理系统 - 数据标注</title>
<style>
:root {
--ls-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--ls-primary-color: #1677ff;
}
html,
body {
height: 100%;
margin: 0;
overflow: hidden;
font-family: var(--ls-font-family);
background: #fff;
}
#label-studio {
height: 100vh;
overflow: auto;
}
/* Beautify overrides */
.lsf-topbar {
display: none !important; /* Hide default top bar as we have external controls */
}
.ls-common {
font-family: var(--ls-font-family) !important;
}
</style>
<!--
本地静态资源(推荐生产环境离线/内网部署):
- 当前锁定版本:label-studio-frontend@1.7.1
- 资源来源(示例):
- https://unpkg.com/label-studio-frontend@1.7.1/dist/lsf/css/main.css
- https://unpkg.com/label-studio-frontend@1.7.1/dist/lsf/js/main.js
- 放置目录:frontend/public/lsf/lsf/
- TEXT(DataMate 当前默认只用 TEXT)通常只需要:
- css/main.css
- js/main.js
- 如果后续启用音频/更多功能导致按需加载 404,再补齐 dist/lsf 全量目录;常见额外文件:
- version.json
- js/478.chunk.js(可选:478.chunk.js.map)
- js/decode-audio.wasm
- *.map、main.js.LICENSE.txt(可选)
-->
<link rel="stylesheet" href="./lsf/css/main.css" />
<script src="./lsf/js/main.js"></script>
</head>
<body>
<div id="label-studio"></div>
<script>
(function () {
const ORIGIN = window.location.origin;
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);
}
window.addEventListener("error", (event) => {
try {
postToParent("LS_ERROR", { message: event?.message || "iframe 内发生脚本错误" });
} catch (_) {}
});
window.addEventListener("unhandledrejection", (event) => {
try {
const reason = event?.reason;
postToParent("LS_ERROR", { message: reason?.message || String(reason || "iframe 内发生未处理异常") });
} catch (_) {}
});
function destroyLabelStudio() {
unbindRegionSelectionListeners();
try {
if (lsInstance && typeof lsInstance.destroy === "function") {
lsInstance.destroy();
}
} catch (_) {}
lsInstance = null;
currentTask = null;
lastSelectedRegionId = null;
const root = document.getElementById("label-studio");
if (root) root.innerHTML = "";
}
function pickAnnotationStore(ls) {
return ls?.annotationStore || ls?.store?.annotationStore || null;
}
function normalizeUser(rawUser) {
const user = rawUser && typeof rawUser === "object" ? rawUser : { id: "anonymous" };
const userId = user.id || user.userId || user.username || user.email || "anonymous";
let pk = user.pk;
if (!pk) {
let h = 0;
for (let i = 0; i < String(userId).length; i++) {
h = (h * 31 + String(userId).charCodeAt(i)) | 0;
}
pk = Math.abs(h) || 1;
}
return {
...user,
id: userId,
pk,
firstName: user.firstName || user.name || String(userId),
};
}
function normalizeTask(task, extra) {
const t = task && typeof task === "object" ? { ...task } : null;
if (!t) return null;
const annotations = Array.isArray(extra?.annotations)
? extra.annotations
: Array.isArray(t.annotations)
? t.annotations
: [];
const predictions = Array.isArray(extra?.predictions)
? extra.predictions
: Array.isArray(t.predictions)
? t.predictions
: [];
return {
...t,
annotations,
predictions,
};
}
function ensureSelectedAnnotation(store, prefer) {
if (!store) return;
const annotations = Array.isArray(store.annotations) ? store.annotations : [];
if (prefer) {
const byId = annotations.find((a) => String(a.id) === String(prefer.id));
if (byId && typeof store.selectAnnotation === "function") {
store.selectAnnotation(byId.id);
return;
}
const idx = Number.isFinite(prefer.index) ? Number(prefer.index) : -1;
if (idx >= 0 && idx < annotations.length && typeof store.selectAnnotation === "function") {
store.selectAnnotation(annotations[idx].id);
return;
}
}
if (annotations.length > 0 && typeof store.selectAnnotation === "function") {
store.selectAnnotation(annotations[0].id);
return;
}
if (typeof store.addAnnotation === "function" && typeof store.selectAnnotation === "function") {
const ann = store.addAnnotation({ userGenerate: true });
if (ann && ann.id) store.selectAnnotation(ann.id);
}
}
function isAnnotationObject(value) {
if (!value || typeof value !== "object") return false;
return typeof value.serializeAnnotation === "function" || typeof value.serialize === "function";
}
function resolveSelectedAnnotation(store) {
if (!store) return null;
const annotations = Array.isArray(store.annotations) ? store.annotations : [];
if (isAnnotationObject(store.selectedAnnotation)) {
return store.selectedAnnotation;
}
if (isAnnotationObject(store.selected)) {
return store.selected;
}
const selectedId = store.selected;
if (selectedId !== undefined && selectedId !== null && annotations.length) {
const matched = annotations.find((ann) => ann && String(ann.id) === String(selectedId));
if (isAnnotationObject(matched)) {
return matched;
}
}
if (annotations.length && isAnnotationObject(annotations[0])) {
return annotations[0];
}
return null;
}
function exportSelectedAnnotation() {
if (!lsInstance) {
throw new Error("LabelStudio 未初始化");
}
const store = pickAnnotationStore(lsInstance);
if (!store) {
throw new Error("无法访问 annotationStore");
}
const selected = resolveSelectedAnnotation(store);
if (!selected) {
throw new Error("未找到可导出的标注对象");
}
let serialized = null;
if (selected && typeof selected.serializeAnnotation === "function") {
serialized = selected.serializeAnnotation();
} else if (selected && typeof selected.serialize === "function") {
serialized = selected.serialize();
}
const annotationPayload = Array.isArray(serialized)
? { id: selected?.id || "draft", result: serialized }
: serialized && typeof serialized === "object"
? { id: selected?.id || serialized.id || "draft", ...serialized }
: { id: selected?.id || "draft", result: (selected && selected.result) || [] };
if (!Array.isArray(annotationPayload.result) && Array.isArray(annotationPayload.results)) {
annotationPayload.result = annotationPayload.results;
}
// 最小化对齐 Label Studio Server 的字段(DataMate 侧会原样存储)
const taskId = typeof currentTask?.id === "number" ? currentTask.id : Number(currentTask?.id) || null;
const fileId = currentTask?.data?.file_id || currentTask?.data?.fileId || null;
const segmentIndexValue =
currentTask?.data?.segment_index ??
currentTask?.data?.segmentIndex ??
currentTask?.data?.dm_segment_index ??
currentTask?.data?.dmSegmentIndex ??
null;
const segmentIndex =
segmentIndexValue === null || segmentIndexValue === undefined
? null
: Number.isFinite(Number(segmentIndexValue))
? Number(segmentIndexValue)
: null;
annotationPayload.id = typeof annotationPayload.id === "number" ? annotationPayload.id : taskId || 1;
annotationPayload.task = taskId;
if (!annotationPayload.created_at) annotationPayload.created_at = new Date().toISOString();
annotationPayload.updated_at = new Date().toISOString();
return {
taskId,
fileId,
segmentIndex,
annotation: annotationPayload,
};
}
function isSaveAndNextShortcut(event) {
if (!event || event.defaultPrevented || event.isComposing) return false;
const key = event.key;
const code = event.code;
const isEnter = key === "Enter" || code === "Enter" || code === "NumpadEnter";
if (!isEnter) return false;
if (!(event.ctrlKey || event.metaKey)) return false;
if (event.shiftKey || event.altKey) return false;
return true;
}
function isSaveShortcut(event) {
if (!event || event.defaultPrevented || event.isComposing) return false;
const key = event.key;
const code = event.code;
const isS = key === "s" || key === "S" || code === "KeyS";
if (!isS) return false;
if (!(event.ctrlKey || event.metaKey)) return false;
if (event.shiftKey || event.altKey) return false;
return true;
}
function handleSaveAndNextShortcut(event) {
if (!isSaveAndNextShortcut(event) || event.repeat) return;
event.preventDefault();
event.stopPropagation();
try {
const raw = exportSelectedAnnotation();
postToParent("LS_SAVE_AND_NEXT", raw);
} catch (e) {
postToParent("LS_ERROR", { message: e?.message || String(e) });
}
}
function handleSaveShortcut(event) {
if (!isSaveShortcut(event) || event.repeat) return;
event.preventDefault();
event.stopPropagation();
try {
const raw = exportSelectedAnnotation();
postToParent("LS_EXPORT_RESULT", raw);
} catch (e) {
postToParent("LS_ERROR", { message: e?.message || String(e) });
}
}
function initLabelStudio(payload) {
if (!window.LabelStudio) {
throw new Error("LabelStudio 未加载(请检查静态资源/网络)");
}
if (!payload || !payload.labelConfig || !payload.task) {
throw new Error("初始化参数缺失:labelConfig/task");
}
destroyLabelStudio();
const interfaces =
payload.interfaces ||
[
"panel",
"update",
"controls",
"side-column",
"annotations",
"infobar",
"instruction",
];
const task = normalizeTask(payload.task, payload);
if (!task) {
throw new Error("task 参数非法");
}
currentTask = task;
const user = normalizeUser(payload.user);
lsInstance = new window.LabelStudio("label-studio", {
config: payload.labelConfig,
interfaces,
user,
task,
onLabelStudioLoad: function (LS) {
try {
const store = pickAnnotationStore(LS);
ensureSelectedAnnotation(store, {
id: payload.selectedAnnotationId,
index: payload.selectedAnnotationIndex,
});
// 允许在没有任何 annotation 的情况下,自动创建一个可编辑的 annotation
if (payload.allowCreateEmptyAnnotation !== false) {
try {
const store2 = pickAnnotationStore(LS);
const selected = store2?.selected || store2?.selectedAnnotation || null;
if (!selected && typeof store2?.addAnnotation === "function" && typeof store2?.selectAnnotation === "function") {
const ann = store2.addAnnotation({ userGenerate: true });
if (ann?.id) store2.selectAnnotation(ann.id);
}
} catch (_) {}
}
} 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();
postToParent("LS_SUBMIT", raw);
} catch (e) {
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);
},
});
}
window.addEventListener("keydown", handleSaveAndNextShortcut);
window.addEventListener("keydown", handleSaveShortcut);
window.addEventListener("message", (event) => {
if (event.origin !== ORIGIN) return;
const msg = event.data || {};
if (!msg.type) return;
try {
if (msg.type === "LS_INIT") {
initLabelStudio(msg.payload || {});
return;
}
if (msg.type === "LS_EXPORT") {
const raw = exportSelectedAnnotation();
postToParent("LS_EXPORT_RESULT", raw);
return;
}
if (msg.type === "LS_EXPORT_CHECK") {
const raw = exportSelectedAnnotation();
const requestId =
msg.payload && typeof msg.payload === "object" ? msg.payload.requestId : null;
if (requestId) {
raw.requestId = requestId;
}
postToParent("LS_EXPORT_CHECK_RESULT", raw);
return;
}
if (msg.type === "LS_RESET") {
destroyLabelStudio();
postToParent("LS_RESET_DONE", {});
return;
}
if (msg.type === "LS_PING") {
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) });
}
});
postToParent("LS_IFRAME_READY", {});
})();
</script>
</body>
</html>