Files
DataMate/frontend/src/pages/DataAnnotation/Annotate/LabelStudioTextEditor.tsx
Jerry Yan 4df48e08c8 feat(annotation): 移除标注历史功能
- 移除了 annotations:history 权限配置项
- 简化了权限控制列表结构
2026-01-19 16:11:58 +08:00

407 lines
13 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { App, Button, Card, List, Spin, Typography } from "antd";
import { LeftOutlined, ReloadOutlined, SaveOutlined, MenuFoldOutlined, MenuUnfoldOutlined } 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 expectedTaskIdRef = useRef<number | null>(null);
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 [lsReady, setLsReady] = useState(false);
const [project, setProject] = useState<EditorProjectInfo | null>(null);
const [tasks, setTasks] = useState<EditorTaskListItem[]>([]);
const [selectedFileId, setSelectedFileId] = useState<string>("");
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
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);
setLsReady(false);
expectedTaskIdRef.current = null;
try {
const resp = (await getEditorTaskUsingGet(projectId, fileId)) as any;
const task = resp?.data?.task;
if (!task) {
message.error("获取任务详情失败");
return;
}
if (seq !== initSeqRef.current) return;
expectedTaskIdRef.current = Number(task?.id) || null;
postToIframe("LS_INIT", {
labelConfig: project.labelConfig,
task,
user: { id: "datamate" },
// 完整的 Label Studio 原生界面配置
interfaces: [
// 核心面板
"panel", // 导航面板(undo/redo/reset)
"update", // 更新按钮
"submit", // 提交按钮
"controls", // 控制面板
// 侧边栏(包含 Outliner 和 Details)
"side-column",
// 标注管理
"annotations:tabs",
"annotations:menu",
"annotations:current",
"annotations:add-new",
"annotations:delete",
"annotations:view-all",
// 预测
"predictions:tabs",
"predictions:menu",
// 其他
"auto-annotation",
"edit-history",
],
selectedAnnotationIndex: 0,
allowCreateEmptyAnnotation: true,
});
} catch (e) {
console.error(e);
message.error("加载编辑器失败");
} finally {
if (seq === initSeqRef.current) setLoadingTaskDetail(false);
}
};
const saveFromExport = async (payload: any) => {
const fileId = payload?.fileId;
const annotation = payload?.annotation;
if (!fileId || !annotation) {
message.error("导出标注失败:缺少 fileId/annotation");
return;
}
setSaving(true);
try {
await upsertEditorAnnotationUsingPut(projectId, String(fileId), { 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;
setLsReady(false);
expectedTaskIdRef.current = null;
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_READY") {
const readyTaskId = msg.payload?.taskId;
if (expectedTaskIdRef.current && readyTaskId) {
if (Number(readyTaskId) !== expectedTaskIdRef.current) return;
}
setLsReady(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 || "编辑器发生错误");
setLsReady(false);
}
};
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">
{/* 顶部工具栏 */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200 bg-white">
<div className="flex items-center gap-2">
<Button icon={<LeftOutlined />} onClick={() => navigate("/data/annotation")}>
</Button>
<Button
icon={sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
title={sidebarCollapsed ? "展开文件列表" : "收起文件列表"}
/>
<Typography.Title level={5} 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 flex-1 min-h-0">
{/* 左侧文件列表 - 可折叠 */}
<div
className="border-r border-gray-200 bg-gray-50 flex flex-col transition-all duration-200"
style={{ width: sidebarCollapsed ? 0 : 240, overflow: "hidden" }}
>
<div className="px-3 py-2 border-b border-gray-200 bg-white font-medium text-sm">
</div>
<div className="flex-1 overflow-auto">
<List
loading={loadingTasks}
size="small"
dataSource={tasks}
renderItem={(item) => (
<List.Item
key={item.fileId}
className="cursor-pointer hover:bg-blue-50"
style={{
background: item.fileId === selectedFileId ? "#e6f4ff" : undefined,
padding: "8px 12px",
borderBottom: "1px solid #f0f0f0",
}}
onClick={() => setSelectedFileId(item.fileId)}
>
<div className="flex flex-col w-full gap-1">
<Typography.Text ellipsis style={{ fontSize: 13 }}>
{item.fileName}
</Typography.Text>
<div className="flex items-center justify-between">
<Typography.Text
type={item.hasAnnotation ? "success" : "secondary"}
style={{ fontSize: 11 }}
>
{item.hasAnnotation ? "已标注" : "未标注"}
</Typography.Text>
{item.annotationUpdatedAt && (
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
{item.annotationUpdatedAt}
</Typography.Text>
)}
</div>
</div>
</List.Item>
)}
/>
</div>
</div>
{/* 右侧编辑器 - Label Studio iframe */}
<div className="flex-1 relative">
{(!iframeReady || loadingTaskDetail || (selectedFileId && !lsReady)) && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/80">
<Spin
tip={
!iframeReady
? "编辑器资源加载中..."
: loadingTaskDetail
? "任务数据加载中..."
: "编辑器初始化中..."
}
/>
</div>
)}
<iframe
ref={iframeRef}
title="Label Studio Frontend"
src={LSF_IFRAME_SRC}
className="w-full h-full border-0"
/>
</div>
</div>
</div>
);
}