You've already forked DataMate
LSF
This commit is contained in:
287
frontend/public/lsf/lsf.html
Normal file
287
frontend/public/lsf/lsf.html
Normal file
@@ -0,0 +1,287 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DataMate - Label Studio 编辑器</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
#label-studio {
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!--
|
||||
说明:
|
||||
- 本页面作为 iframe 运行,用于隔离 Label Studio Frontend(避免与 DataMate React/Antd 依赖冲突)。
|
||||
- 当前使用 CDN 加载 LSF 产物;如需离线部署,可改为本地静态资源。
|
||||
- 与父页面通过 postMessage 通信,约定消息类型:
|
||||
- Parent -> Iframe: LS_INIT / LS_EXPORT / LS_RESET / LS_PING
|
||||
- Iframe -> Parent: LS_IFRAME_READY / LS_READY / LS_EXPORT_RESULT / LS_RESET_DONE / LS_PONG / LS_ERROR / LS_SUBMIT
|
||||
-->
|
||||
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/label-studio-frontend@1.7.1/dist/lsf/css/main.css"
|
||||
/>
|
||||
<script src="https://unpkg.com/label-studio-frontend@1.7.1/dist/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);
|
||||
}
|
||||
|
||||
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 exportSelectedAnnotation() {
|
||||
if (!lsInstance) {
|
||||
throw new Error("LabelStudio 未初始化");
|
||||
}
|
||||
|
||||
const store = pickAnnotationStore(lsInstance);
|
||||
if (!store) {
|
||||
throw new Error("无法访问 annotationStore");
|
||||
}
|
||||
|
||||
const selected =
|
||||
store.selected ||
|
||||
store.selectedAnnotation ||
|
||||
(Array.isArray(store.annotations) && store.annotations.length ? store.annotations[0] : null);
|
||||
|
||||
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) || [] };
|
||||
|
||||
// 最小化对齐 Label Studio Server 的字段(DataMate 侧会原样存储)
|
||||
if (!annotationPayload.task) annotationPayload.task = currentTask?.id || null;
|
||||
if (!annotationPayload.created_at) annotationPayload.created_at = new Date().toISOString();
|
||||
annotationPayload.updated_at = new Date().toISOString();
|
||||
|
||||
return {
|
||||
taskId: currentTask?.id || null,
|
||||
annotation: annotationPayload,
|
||||
};
|
||||
}
|
||||
|
||||
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("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_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>
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { App, Button, Card, List, Spin, Typography } from "antd";
|
||||
import { LeftOutlined, ReloadOutlined, SaveOutlined } from "@ant-design/icons";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
|
||||
import {
|
||||
getEditorProjectInfoUsingGet,
|
||||
getEditorTaskUsingGet,
|
||||
listEditorTasksUsingGet,
|
||||
upsertEditorAnnotationUsingPut,
|
||||
} from "../annotation.api";
|
||||
|
||||
type EditorProjectInfo = {
|
||||
projectId: string;
|
||||
datasetId: string;
|
||||
templateId?: string | null;
|
||||
labelConfig?: string | null;
|
||||
supported: boolean;
|
||||
unsupportedReason?: string | null;
|
||||
};
|
||||
|
||||
type EditorTaskListItem = {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
fileType?: string | null;
|
||||
hasAnnotation: boolean;
|
||||
annotationUpdatedAt?: string | null;
|
||||
};
|
||||
|
||||
type LsfMessage = {
|
||||
type?: string;
|
||||
payload?: any;
|
||||
};
|
||||
|
||||
const LSF_IFRAME_SRC = "/lsf/lsf.html";
|
||||
|
||||
export default function LabelStudioTextEditor() {
|
||||
const { projectId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
|
||||
const origin = useMemo(() => window.location.origin, []);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const initSeqRef = useRef(0);
|
||||
|
||||
const [loadingProject, setLoadingProject] = useState(true);
|
||||
const [loadingTasks, setLoadingTasks] = useState(false);
|
||||
const [loadingTaskDetail, setLoadingTaskDetail] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [iframeReady, setIframeReady] = useState(false);
|
||||
const [project, setProject] = useState<EditorProjectInfo | null>(null);
|
||||
const [tasks, setTasks] = useState<EditorTaskListItem[]>([]);
|
||||
const [selectedFileId, setSelectedFileId] = useState<string>("");
|
||||
|
||||
const postToIframe = (type: string, payload?: any) => {
|
||||
const win = iframeRef.current?.contentWindow;
|
||||
if (!win) return;
|
||||
win.postMessage({ type, payload }, origin);
|
||||
};
|
||||
|
||||
const loadProject = async () => {
|
||||
setLoadingProject(true);
|
||||
try {
|
||||
const resp = (await getEditorProjectInfoUsingGet(projectId)) as any;
|
||||
const data = resp?.data as EditorProjectInfo | undefined;
|
||||
if (!data?.projectId) {
|
||||
message.error("获取标注项目信息失败");
|
||||
setProject(null);
|
||||
return;
|
||||
}
|
||||
setProject(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error("获取标注项目信息失败");
|
||||
setProject(null);
|
||||
} finally {
|
||||
setLoadingProject(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTasks = async (silent = false) => {
|
||||
if (!projectId) return;
|
||||
if (!silent) setLoadingTasks(true);
|
||||
try {
|
||||
const resp = (await listEditorTasksUsingGet(projectId, { page: 0, size: 200 })) as any;
|
||||
const content = (resp?.data?.content || []) as EditorTaskListItem[];
|
||||
const items = Array.isArray(content) ? content : [];
|
||||
setTasks(items);
|
||||
if (!selectedFileId && items.length > 0) {
|
||||
setSelectedFileId(items[0].fileId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (!silent) message.error("获取文件列表失败");
|
||||
setTasks([]);
|
||||
} finally {
|
||||
if (!silent) setLoadingTasks(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initEditorForFile = async (fileId: string) => {
|
||||
if (!project?.supported) return;
|
||||
if (!project?.labelConfig) {
|
||||
message.error("该项目未绑定标注模板,无法加载编辑器");
|
||||
return;
|
||||
}
|
||||
if (!iframeReady) return;
|
||||
|
||||
const seq = ++initSeqRef.current;
|
||||
setLoadingTaskDetail(true);
|
||||
|
||||
try {
|
||||
const resp = (await getEditorTaskUsingGet(projectId, fileId)) as any;
|
||||
const task = resp?.data?.task;
|
||||
if (!task) {
|
||||
message.error("获取任务详情失败");
|
||||
return;
|
||||
}
|
||||
if (seq !== initSeqRef.current) return;
|
||||
|
||||
postToIframe("LS_INIT", {
|
||||
labelConfig: project.labelConfig,
|
||||
task,
|
||||
user: { id: "datamate" },
|
||||
interfaces: [
|
||||
"panel",
|
||||
"update",
|
||||
"submit",
|
||||
"controls",
|
||||
"side-column",
|
||||
"annotations:menu",
|
||||
"annotations:add-new",
|
||||
"annotations:delete",
|
||||
],
|
||||
selectedAnnotationIndex: 0,
|
||||
allowCreateEmptyAnnotation: true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error("加载编辑器失败");
|
||||
} finally {
|
||||
if (seq === initSeqRef.current) setLoadingTaskDetail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveFromExport = async (payload: any) => {
|
||||
const taskId = payload?.taskId;
|
||||
const annotation = payload?.annotation;
|
||||
if (!taskId || !annotation) {
|
||||
message.error("导出标注失败:缺少 taskId/annotation");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await upsertEditorAnnotationUsingPut(projectId, String(taskId), { annotation });
|
||||
message.success("标注已保存");
|
||||
await loadTasks(true);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error("保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const requestExport = () => {
|
||||
if (!selectedFileId) {
|
||||
message.warning("请先选择文件");
|
||||
return;
|
||||
}
|
||||
postToIframe("LS_EXPORT", {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIframeReady(false);
|
||||
setProject(null);
|
||||
setTasks([]);
|
||||
setSelectedFileId("");
|
||||
initSeqRef.current = 0;
|
||||
|
||||
if (projectId) loadProject();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project?.supported) return;
|
||||
loadTasks();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [project?.projectId, project?.supported]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFileId) return;
|
||||
initEditorForFile(selectedFileId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedFileId, iframeReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: MessageEvent<LsfMessage>) => {
|
||||
if (event.origin !== origin) return;
|
||||
const msg = event.data || {};
|
||||
if (!msg?.type) return;
|
||||
|
||||
if (msg.type === "LS_IFRAME_READY") {
|
||||
setIframeReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "LS_EXPORT_RESULT") {
|
||||
saveFromExport(msg.payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// 兼容 iframe 内部在 submit 时直接上报(若启用)
|
||||
if (msg.type === "LS_SUBMIT") {
|
||||
saveFromExport(msg.payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "LS_ERROR") {
|
||||
message.error(msg.payload?.message || "编辑器发生错误");
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handler);
|
||||
return () => window.removeEventListener("message", handler);
|
||||
}, [message, origin]);
|
||||
|
||||
if (loadingProject) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Card>
|
||||
<Typography.Text>未找到标注项目</Typography.Text>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button onClick={() => navigate("/data/annotation")}>返回</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project.supported) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Card style={{ maxWidth: 640 }}>
|
||||
<Typography.Title level={4}>暂不支持该数据类型</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{project.unsupportedReason || "当前仅支持文本(TEXT)项目的内嵌编辑器。"}
|
||||
</Typography.Paragraph>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button onClick={() => navigate("/data/annotation")}>返回</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button icon={<LeftOutlined />} onClick={() => navigate("/data/annotation")}>
|
||||
返回
|
||||
</Button>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
标注(内嵌编辑器)
|
||||
</Typography.Title>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button icon={<ReloadOutlined />} loading={loadingTasks} onClick={() => loadTasks()}>
|
||||
刷新文件列表
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={saving}
|
||||
disabled={!iframeReady || !selectedFileId}
|
||||
onClick={requestExport}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 flex-1 min-h-0">
|
||||
<Card title="文件" style={{ width: 320 }} bodyStyle={{ padding: 0, height: "100%", overflow: "auto" }}>
|
||||
<List
|
||||
loading={loadingTasks}
|
||||
dataSource={tasks}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
key={item.fileId}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: item.fileId === selectedFileId ? "#f0f5ff" : undefined,
|
||||
paddingLeft: 12,
|
||||
paddingRight: 12,
|
||||
}}
|
||||
onClick={() => setSelectedFileId(item.fileId)}
|
||||
>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Typography.Text ellipsis>{item.fileName}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ whiteSpace: "nowrap" }}>
|
||||
{item.hasAnnotation ? "已标注" : "未标注"}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{item.annotationUpdatedAt && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
更新: {item.annotationUpdatedAt}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="编辑器" className="flex-1" bodyStyle={{ padding: 0, height: "100%", overflow: "hidden" }}>
|
||||
<div className="relative h-full">
|
||||
{loadingTaskDetail && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70">
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title="Label Studio Frontend"
|
||||
src={LSF_IFRAME_SRC}
|
||||
className="w-full h-full border-0"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, Table, message, Modal, Tabs, Tag, Progress, Tooltip } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SyncOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { SearchControls } from "@/components/SearchControls";
|
||||
import CardView from "@/components/CardView";
|
||||
import type { AnnotationTask } from "../annotation.model";
|
||||
import useFetchData from "@/hooks/useFetchData";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, Table, message, Modal, Tabs, Tag, Progress, Tooltip } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SyncOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router";
|
||||
import { SearchControls } from "@/components/SearchControls";
|
||||
import CardView from "@/components/CardView";
|
||||
import type { AnnotationTask } from "../annotation.model";
|
||||
import useFetchData from "@/hooks/useFetchData";
|
||||
import {
|
||||
deleteAnnotationTaskByIdUsingDelete,
|
||||
queryAnnotationTasksUsingGet,
|
||||
@@ -39,12 +40,13 @@ const AUTO_MODEL_SIZE_LABELS: Record<string, string> = {
|
||||
x: "YOLOv8x (最精确)",
|
||||
};
|
||||
|
||||
export default function DataAnnotation() {
|
||||
// return <DevelopmentInProgress showTime="2025.10.30" />;
|
||||
const [activeTab, setActiveTab] = useState("tasks");
|
||||
const [viewMode, setViewMode] = useState<"list" | "card">("list");
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [autoTasks, setAutoTasks] = useState<any[]>([]);
|
||||
export default function DataAnnotation() {
|
||||
// return <DevelopmentInProgress showTime="2025.10.30" />;
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState("tasks");
|
||||
const [viewMode, setViewMode] = useState<"list" | "card">("list");
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [autoTasks, setAutoTasks] = useState<any[]>([]);
|
||||
|
||||
const {
|
||||
loading,
|
||||
@@ -56,9 +58,8 @@ export default function DataAnnotation() {
|
||||
handleKeywordChange,
|
||||
} = useFetchData(queryAnnotationTasksUsingGet, mapAnnotationTask, 30000, true, [], 0);
|
||||
|
||||
const [labelStudioBase, setLabelStudioBase] = useState<string | null>(null);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<(string | number)[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<(string | number)[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
||||
|
||||
// 拉取自动标注任务(供轮询和创建成功后立即刷新复用)
|
||||
const refreshAutoTasks = async (silent = false) => {
|
||||
@@ -76,71 +77,24 @@ export default function DataAnnotation() {
|
||||
}
|
||||
};
|
||||
|
||||
// prefetch config on mount so clicking annotate is fast and we know whether base URL exists
|
||||
// useEffect ensures this runs once
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const baseUrl = `http://${window.location.hostname}:${parseInt(window.location.port) + 1}`;
|
||||
if (mounted) setLabelStudioBase(baseUrl);
|
||||
} catch (e) {
|
||||
if (mounted) setLabelStudioBase(null);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 自动标注任务轮询(用于在同一表格中展示处理进度)
|
||||
useEffect(() => {
|
||||
refreshAutoTasks();
|
||||
const timer = setInterval(() => refreshAutoTasks(true), 3000);
|
||||
// 自动标注任务轮询(用于在同一表格中展示处理进度)
|
||||
useEffect(() => {
|
||||
refreshAutoTasks();
|
||||
const timer = setInterval(() => refreshAutoTasks(true), 3000);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAnnotate = (task: AnnotationTask) => {
|
||||
// Open Label Studio project page in a new tab
|
||||
(async () => {
|
||||
try {
|
||||
// prefer using labeling project id already present on the task
|
||||
// `mapAnnotationTask` normalizes upstream fields into `labelingProjId`/`projId`,
|
||||
// so prefer those and fall back to the task id if necessary.
|
||||
let labelingProjId = (task as any).labelingProjId || (task as any).projId || undefined;
|
||||
|
||||
// no fallback external mapping lookup; rely on normalized fields from mapAnnotationTask
|
||||
|
||||
// use prefetched base if available
|
||||
const base = labelStudioBase;
|
||||
|
||||
// no debug logging in production
|
||||
|
||||
if (labelingProjId) {
|
||||
// only open external Label Studio when we have a configured base url
|
||||
if (base) {
|
||||
const target = `${base}/projects/${labelingProjId}/data`;
|
||||
window.open(target, "_blank");
|
||||
} else {
|
||||
// no external Label Studio URL configured — do not perform internal redirect in this version
|
||||
message.error("无法跳转到 Label Studio:未配置 Label Studio 基础 URL");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// no labeling project id available — do not attempt internal redirect in this version
|
||||
message.error("无法跳转到 Label Studio:该映射未绑定标注项目");
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// on error, surface a user-friendly message instead of redirecting
|
||||
message.error("无法跳转到 Label Studio:发生错误,请检查配置或控制台日志");
|
||||
return;
|
||||
}
|
||||
})();
|
||||
};
|
||||
const handleAnnotate = (task: AnnotationTask) => {
|
||||
const projectId = (task as any)?.id;
|
||||
if (!projectId) {
|
||||
message.error("无法进入标注:缺少标注项目ID");
|
||||
return;
|
||||
}
|
||||
navigate(`/data/annotation/annotate/${projectId}`);
|
||||
};
|
||||
|
||||
const handleDelete = (task: AnnotationTask) => {
|
||||
Modal.confirm({
|
||||
|
||||
@@ -62,6 +62,30 @@ export function getAutoAnnotationTaskStatusUsingGet(taskId: string) {
|
||||
return get(`/api/annotation/auto/${taskId}/status`);
|
||||
}
|
||||
|
||||
export function downloadAutoAnnotationResultUsingGet(taskId: string) {
|
||||
return download(`/api/annotation/auto/${taskId}/download`);
|
||||
}
|
||||
export function downloadAutoAnnotationResultUsingGet(taskId: string) {
|
||||
return download(`/api/annotation/auto/${taskId}/download`);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Label Studio Editor(内嵌版)
|
||||
// =====================
|
||||
|
||||
export function getEditorProjectInfoUsingGet(projectId: string) {
|
||||
return get(`/api/annotation/editor/projects/${projectId}`);
|
||||
}
|
||||
|
||||
export function listEditorTasksUsingGet(projectId: string, params?: any) {
|
||||
return get(`/api/annotation/editor/projects/${projectId}/tasks`, params);
|
||||
}
|
||||
|
||||
export function getEditorTaskUsingGet(projectId: string, fileId: string) {
|
||||
return get(`/api/annotation/editor/projects/${projectId}/tasks/${fileId}`);
|
||||
}
|
||||
|
||||
export function upsertEditorAnnotationUsingPut(
|
||||
projectId: string,
|
||||
fileId: string,
|
||||
data: any
|
||||
) {
|
||||
return put(`/api/annotation/editor/projects/${projectId}/tasks/${fileId}/annotation`, data);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import CleansingTemplateCreate from "@/pages/DataCleansing/Create/CreateTemplate
|
||||
|
||||
import DataAnnotation from "@/pages/DataAnnotation/Home/DataAnnotation";
|
||||
import AnnotationTaskCreate from "@/pages/DataAnnotation/Create/CreateTask";
|
||||
import LabelStudioTextEditor from "@/pages/DataAnnotation/Annotate/LabelStudioTextEditor";
|
||||
|
||||
import DataSynthesisPage from "@/pages/SynthesisTask/DataSynthesis";
|
||||
import InstructionTemplateCreate from "@/pages/SynthesisTask/CreateTemplate";
|
||||
@@ -145,6 +146,10 @@ const router = createBrowserRouter([
|
||||
path: "create-task",
|
||||
Component: AnnotationTaskCreate,
|
||||
},
|
||||
{
|
||||
path: "annotate/:projectId",
|
||||
Component: LabelStudioTextEditor,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user