This commit is contained in:
2026-01-07 00:00:16 +08:00
parent 7d4dcb756b
commit d5b75fee0d
14 changed files with 1267 additions and 124 deletions

View File

@@ -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>
);
}

View File

@@ -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({

View File

@@ -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);
}