You've already forked DataMate
LSF
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user