Merge branch 'editor_next' into lsf

This commit is contained in:
2026-01-21 13:28:01 +08:00
6 changed files with 395 additions and 41 deletions

View File

@@ -1,6 +1,6 @@
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 { App, Button, Card, List, Spin, Typography, Tag } from "antd";
import { LeftOutlined, ReloadOutlined, SaveOutlined, MenuFoldOutlined, MenuUnfoldOutlined, CheckOutlined } from "@ant-design/icons";
import { useNavigate, useParams } from "react-router";
import {
@@ -32,6 +32,14 @@ type LsfMessage = {
payload?: any;
};
type SegmentInfo = {
idx: number;
text: string;
start: number;
end: number;
hasAnnotation: boolean;
};
const LSF_IFRAME_SRC = "/lsf/lsf.html";
export default function LabelStudioTextEditor() {
@@ -56,6 +64,11 @@ export default function LabelStudioTextEditor() {
const [selectedFileId, setSelectedFileId] = useState<string>("");
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// 分段相关状态
const [segmented, setSegmented] = useState(false);
const [segments, setSegments] = useState<SegmentInfo[]>([]);
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0);
const postToIframe = (type: string, payload?: any) => {
const win = iframeRef.current?.contentWindow;
if (!win) return;
@@ -102,7 +115,7 @@ export default function LabelStudioTextEditor() {
}
};
const initEditorForFile = async (fileId: string) => {
const initEditorForFile = async (fileId: string, segmentIdx?: number) => {
if (!project?.supported) return;
if (!project?.labelConfig) {
message.error("该项目未绑定标注模板,无法加载编辑器");
@@ -116,14 +129,28 @@ export default function LabelStudioTextEditor() {
expectedTaskIdRef.current = null;
try {
const resp = (await getEditorTaskUsingGet(projectId, fileId)) as any;
const task = resp?.data?.task;
const resp = (await getEditorTaskUsingGet(projectId, fileId, {
segmentIndex: segmentIdx,
})) as any;
const data = resp?.data;
const task = data?.task;
if (!task) {
message.error("获取任务详情失败");
return;
}
if (seq !== initSeqRef.current) return;
// 更新分段状态
if (data?.segmented) {
setSegmented(true);
setSegments(data.segments || []);
setCurrentSegmentIndex(data.currentSegmentIndex || 0);
} else {
setSegmented(false);
setSegments([]);
setCurrentSegmentIndex(0);
}
expectedTaskIdRef.current = Number(task?.id) || null;
postToIframe("LS_INIT", {
labelConfig: project.labelConfig,
@@ -173,9 +200,23 @@ export default function LabelStudioTextEditor() {
setSaving(true);
try {
await upsertEditorAnnotationUsingPut(projectId, String(fileId), { annotation });
await upsertEditorAnnotationUsingPut(projectId, String(fileId), {
annotation,
segmentIndex: segmented ? currentSegmentIndex : undefined,
});
message.success("标注已保存");
await loadTasks(true);
// 分段模式下更新当前段落的标注状态
if (segmented) {
setSegments((prev) =>
prev.map((seg) =>
seg.idx === currentSegmentIndex
? { ...seg, hasAnnotation: true }
: seg
)
);
}
} catch (e) {
console.error(e);
message.error("保存失败");
@@ -192,6 +233,13 @@ export default function LabelStudioTextEditor() {
postToIframe("LS_EXPORT", {});
};
// 段落切换处理
const handleSegmentChange = async (newIndex: number) => {
if (newIndex === currentSegmentIndex) return;
setCurrentSegmentIndex(newIndex);
await initEditorForFile(selectedFileId, newIndex);
};
useEffect(() => {
setIframeReady(false);
setProject(null);
@@ -200,6 +248,10 @@ export default function LabelStudioTextEditor() {
initSeqRef.current = 0;
setLsReady(false);
expectedTaskIdRef.current = null;
// 重置分段状态
setSegmented(false);
setSegments([]);
setCurrentSegmentIndex(0);
if (projectId) loadProject();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -379,26 +431,55 @@ export default function LabelStudioTextEditor() {
</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 className="flex-1 flex flex-col min-h-0">
{/* 段落导航栏 */}
{segmented && segments.length > 0 && (
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50 border-b border-gray-200">
<Typography.Text style={{ fontSize: 12 }}></Typography.Text>
<div className="flex gap-1 flex-wrap">
{segments.map((seg) => (
<Button
key={seg.idx}
size="small"
type={seg.idx === currentSegmentIndex ? "primary" : "default"}
onClick={() => handleSegmentChange(seg.idx)}
style={{ minWidth: 32, padding: "0 8px" }}
>
{seg.idx + 1}
{seg.hasAnnotation && (
<CheckOutlined style={{ marginLeft: 2, fontSize: 10 }} />
)}
</Button>
))}
</div>
<Tag color="blue" style={{ marginLeft: 8 }}>
{currentSegmentIndex + 1} / {segments.length}
</Tag>
</div>
)}
<iframe
ref={iframeRef}
title="Label Studio Frontend"
src={LSF_IFRAME_SRC}
className="w-full h-full border-0"
/>
{/* 编辑器区域 */}
<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>
</div>

View File

@@ -69,14 +69,22 @@ 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 getEditorTaskUsingGet(
projectId: string,
fileId: string,
params?: { segmentIndex?: number }
) {
return get(`/api/annotation/editor/projects/${projectId}/tasks/${fileId}`, params);
}
export function upsertEditorAnnotationUsingPut(
projectId: string,
fileId: string,
data: any
data: {
annotation: any;
expectedUpdatedAt?: string;
segmentIndex?: number;
}
) {
return put(`/api/annotation/editor/projects/${projectId}/tasks/${fileId}/annotation`, data);
}