You've already forked DataMate
- 实现了 Ctrl+S 保存快捷键检测逻辑 - 添加了 handleSaveShortcut 事件处理函数 - 在窗口上注册键盘事件监听器 - 修改 requestExport 函数支持 autoAdvance 参数 - 更新保存按钮点击事件传递 autoAdvance 参数
429 lines
15 KiB
HTML
429 lines
15 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;
|
|
|
|
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() {
|
|
try {
|
|
if (lsInstance && typeof lsInstance.destroy === "function") {
|
|
lsInstance.destroy();
|
|
}
|
|
} catch (_) {}
|
|
|
|
lsInstance = null;
|
|
currentTask = 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 (_) {}
|
|
|
|
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) });
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
} catch (e) {
|
|
postToParent("LS_ERROR", { message: e?.message || String(e) });
|
|
}
|
|
});
|
|
|
|
postToParent("LS_IFRAME_READY", {});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|