feat(annotation): 添加文本标注编辑器中的段落树导航和自动跳转功能

- 引入 Tree 和 Empty 组件用于段落导航展示
- 实现分段树形结构数据生成和展示功能
- 添加自动跳转到下一个待标注文件或段落的功能
- 优化文件选择逻辑,优先选择未标注的文件
- 实现段落切换时的状态管理和依赖更新
- 添加段落树节点选中和展开状态控制
- 优化界面布局和滚动区域的高度计算
This commit is contained in:
2026-01-26 11:44:33 +08:00
parent 6835511f5a
commit fa160164d2

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { App, Button, Card, List, Spin, Typography, Tag, Switch } from "antd"; import { App, Button, Card, List, Spin, Typography, Tag, Switch, Tree, Empty } from "antd";
import { LeftOutlined, ReloadOutlined, SaveOutlined, MenuFoldOutlined, MenuUnfoldOutlined, CheckOutlined } from "@ant-design/icons"; import { LeftOutlined, ReloadOutlined, SaveOutlined, MenuFoldOutlined, MenuUnfoldOutlined, CheckOutlined } from "@ant-design/icons";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
@@ -151,6 +151,7 @@ export default function LabelStudioTextEditor() {
} | null>(null); } | null>(null);
const exportCheckSeqRef = useRef(0); const exportCheckSeqRef = useRef(0);
const savedSnapshotsRef = useRef<Record<string, string>>({}); const savedSnapshotsRef = useRef<Record<string, string>>({});
const pendingAutoAdvanceRef = useRef(false);
const [loadingProject, setLoadingProject] = useState(true); const [loadingProject, setLoadingProject] = useState(true);
const [loadingTasks, setLoadingTasks] = useState(false); const [loadingTasks, setLoadingTasks] = useState(false);
@@ -208,9 +209,12 @@ export default function LabelStudioTextEditor() {
const content = resp?.data?.content || []; const content = resp?.data?.content || [];
const items = Array.isArray(content) ? content : []; const items = Array.isArray(content) ? content : [];
setTasks(items); setTasks(items);
if (items.length > 0) { const defaultFileId =
setSelectedFileId((prev) => prev || (items[0]?.fileId ?? "")); items.find((item) => !item.hasAnnotation)?.fileId || items[0]?.fileId || "";
} setSelectedFileId((prev) => {
if (prev && items.some((item) => item.fileId === prev)) return prev;
return defaultFileId;
});
} catch (e) { } catch (e) {
console.error(e); console.error(e);
if (!silent) message.error("获取文件列表失败"); if (!silent) message.error("获取文件列表失败");
@@ -318,7 +322,46 @@ export default function LabelStudioTextEditor() {
} }
}, [iframeReady, message, postToIframe, project, projectId]); }, [iframeReady, message, postToIframe, project, projectId]);
const saveFromExport = useCallback(async (payload?: ExportPayload | null) => { const advanceAfterSave = useCallback(async (fileId: string, segmentIndex?: number) => {
if (!fileId) return;
if (segmented && segments.length > 0) {
const sortedSegmentIndices = segments
.map((seg) => seg.idx)
.sort((a, b) => a - b);
const baseIndex = segmentIndex ?? currentSegmentIndex;
const currentPos = sortedSegmentIndices.indexOf(baseIndex);
const nextSegmentIndex =
currentPos >= 0 ? sortedSegmentIndices[currentPos + 1] : sortedSegmentIndices[0];
if (nextSegmentIndex !== undefined) {
await initEditorForFile(fileId, nextSegmentIndex);
return;
}
}
if (tasks.length === 0) {
message.info("暂无可跳转的数据");
return;
}
const currentFileIndex = tasks.findIndex((item) => item.fileId === fileId);
const nextTask = currentFileIndex >= 0 ? tasks[currentFileIndex + 1] : tasks[0];
if (nextTask?.fileId) {
setSelectedFileId(nextTask.fileId);
return;
}
message.info("已是最后一个数据");
}, [
currentSegmentIndex,
initEditorForFile,
message,
segmented,
segments,
tasks,
]);
const saveFromExport = useCallback(async (
payload?: ExportPayload | null,
options?: { autoAdvance?: boolean }
) => {
const payloadTaskId = payload?.taskId; const payloadTaskId = payload?.taskId;
if (expectedTaskIdRef.current && payloadTaskId) { if (expectedTaskIdRef.current && payloadTaskId) {
if (Number(payloadTaskId) !== expectedTaskIdRef.current) { if (Number(payloadTaskId) !== expectedTaskIdRef.current) {
@@ -363,6 +406,9 @@ export default function LabelStudioTextEditor() {
) )
); );
} }
if (options?.autoAdvance) {
await advanceAfterSave(String(fileId), segmentIndex);
}
return true; return true;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -372,6 +418,7 @@ export default function LabelStudioTextEditor() {
setSaving(false); setSaving(false);
} }
}, [ }, [
advanceAfterSave,
currentSegmentIndex, currentSegmentIndex,
loadTasks, loadTasks,
message, message,
@@ -442,11 +489,12 @@ export default function LabelStudioTextEditor() {
message.warning("请先选择文件"); message.warning("请先选择文件");
return; return;
} }
pendingAutoAdvanceRef.current = true;
postToIframe("LS_EXPORT", {}); postToIframe("LS_EXPORT", {});
}; };
// 段落切换处理 // 段落切换处理
const handleSegmentChange = async (newIndex: number) => { const handleSegmentChange = useCallback(async (newIndex: number) => {
if (newIndex === currentSegmentIndex) return; if (newIndex === currentSegmentIndex) return;
if (segmentSwitching || saving || loadingTaskDetail) return; if (segmentSwitching || saving || loadingTaskDetail) return;
if (!iframeReady || !lsReady) { if (!iframeReady || !lsReady) {
@@ -504,7 +552,22 @@ export default function LabelStudioTextEditor() {
} finally { } finally {
setSegmentSwitching(false); setSegmentSwitching(false);
} }
}; }, [
autoSaveOnSwitch,
confirmSaveBeforeSwitch,
currentSegmentIndex,
iframeReady,
initEditorForFile,
loadingTaskDetail,
lsReady,
message,
requestExportForCheck,
saveFromExport,
segmented,
selectedFileId,
segmentSwitching,
saving,
]);
useEffect(() => { useEffect(() => {
setIframeReady(false); setIframeReady(false);
@@ -537,6 +600,51 @@ export default function LabelStudioTextEditor() {
initEditorForFile(selectedFileId); initEditorForFile(selectedFileId);
}, [selectedFileId, iframeReady, initEditorForFile]); }, [selectedFileId, iframeReady, initEditorForFile]);
const segmentTreeData = useMemo(() => {
if (!segmented || segments.length === 0) return [];
const lineMap = new Map<number, SegmentInfo[]>();
segments.forEach((seg) => {
const list = lineMap.get(seg.lineIndex) || [];
list.push(seg);
lineMap.set(seg.lineIndex, list);
});
return Array.from(lineMap.entries())
.sort((a, b) => a[0] - b[0])
.map(([lineIndex, lineSegments]) => ({
key: `line-${lineIndex}`,
title: `${lineIndex + 1}`,
selectable: false,
children: lineSegments
.sort((a, b) => a.chunkIndex - b.chunkIndex)
.map((seg) => ({
key: `seg-${seg.idx}`,
title: (
<span className="flex items-center gap-1">
<span>{`${seg.chunkIndex + 1}`}</span>
{seg.hasAnnotation && (
<CheckOutlined style={{ fontSize: 10, color: "#52c41a" }} />
)}
</span>
),
})),
}));
}, [segmented, segments]);
const segmentLineKeys = useMemo(
() => segmentTreeData.map((item) => String(item.key)),
[segmentTreeData]
);
const handleSegmentSelect = useCallback((keys: Array<string | number>) => {
const [first] = keys;
if (first === undefined || first === null) return;
const key = String(first);
if (!key.startsWith("seg-")) return;
const nextIndex = Number(key.replace("seg-", ""));
if (!Number.isFinite(nextIndex)) return;
handleSegmentChange(nextIndex);
}, [handleSegmentChange]);
useEffect(() => { useEffect(() => {
const handler = (event: MessageEvent<LsfMessage>) => { const handler = (event: MessageEvent<LsfMessage>) => {
if (event.origin !== origin) return; if (event.origin !== origin) return;
@@ -560,7 +668,9 @@ export default function LabelStudioTextEditor() {
} }
if (msg.type === "LS_EXPORT_RESULT") { if (msg.type === "LS_EXPORT_RESULT") {
saveFromExport(payload); const shouldAutoAdvance = pendingAutoAdvanceRef.current;
pendingAutoAdvanceRef.current = false;
saveFromExport(payload, { autoAdvance: shouldAutoAdvance });
return; return;
} }
@@ -579,7 +689,7 @@ export default function LabelStudioTextEditor() {
// 兼容 iframe 内部在 submit 时直接上报(若启用) // 兼容 iframe 内部在 submit 时直接上报(若启用)
if (msg.type === "LS_SUBMIT") { if (msg.type === "LS_SUBMIT") {
saveFromExport(payload); saveFromExport(payload, { autoAdvance: false });
return; return;
} }
@@ -587,6 +697,7 @@ export default function LabelStudioTextEditor() {
const payloadMessage = resolvePayloadMessage(msg.payload); const payloadMessage = resolvePayloadMessage(msg.payload);
message.error(payloadMessage || "编辑器发生错误"); message.error(payloadMessage || "编辑器发生错误");
setLsReady(false); setLsReady(false);
pendingAutoAdvanceRef.current = false;
} }
}; };
@@ -668,13 +779,13 @@ export default function LabelStudioTextEditor() {
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
{/* 左侧文件列表 - 可折叠 */} {/* 左侧文件列表 - 可折叠 */}
<div <div
className="border-r border-gray-200 bg-gray-50 flex flex-col transition-all duration-200" className="border-r border-gray-200 bg-gray-50 flex flex-col transition-all duration-200 min-h-0"
style={{ width: sidebarCollapsed ? 0 : 240, overflow: "hidden" }} 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 className="px-3 py-2 border-b border-gray-200 bg-white font-medium text-sm">
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 min-h-0 overflow-auto">
<List <List
loading={loadingTasks} loading={loadingTasks}
size="small" size="small"
@@ -712,38 +823,39 @@ export default function LabelStudioTextEditor() {
)} )}
/> />
</div> </div>
{segmented && (
<div className="border-t border-gray-200 bg-white flex flex-col min-h-0">
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 font-medium text-sm flex items-center justify-between">
<span>/</span>
<Tag color="blue" style={{ margin: 0 }}>
{currentSegmentIndex + 1} / {segments.length}
</Tag>
</div>
<div className="flex-1 min-h-0 overflow-auto px-2 py-2">
{segments.length > 0 ? (
<Tree
showLine
blockNode
selectedKeys={
segmented ? [`seg-${currentSegmentIndex}`] : []
}
expandedKeys={segmentLineKeys}
onSelect={handleSegmentSelect}
treeData={segmentTreeData}
/>
) : (
<div className="py-6">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无分段"
/>
</div> </div>
{/* 右侧编辑器 - Label Studio iframe */}
<div className="flex-1 flex flex-col min-h-0">
{/* 段落导航栏 */}
{segmented && segments.length > 0 && (
<div className="flex flex-nowrap items-center gap-2 px-3 py-2 bg-gray-50 border-b border-gray-200">
<Typography.Text style={{ fontSize: 12 }} className="shrink-0">
</Typography.Text>
<div className="flex-1 min-w-0">
<div className="flex gap-1 overflow-x-auto whitespace-nowrap">
{segments.map((seg) => (
<Button
key={seg.idx}
size="small"
type={seg.idx === currentSegmentIndex ? "primary" : "default"}
onClick={() => handleSegmentChange(seg.idx)}
disabled={segmentSwitching || saving || loadingTaskDetail || !lsReady}
style={{ minWidth: 64, padding: "0 6px" }}
>
{`${seg.lineIndex + 1}/片${seg.chunkIndex + 1}`}
{seg.hasAnnotation && (
<CheckOutlined style={{ marginLeft: 2, fontSize: 10 }} />
)} )}
</Button>
))}
</div> </div>
</div> <div className="px-3 py-2 border-t border-gray-200 flex items-center justify-between">
<div className="flex items-center gap-2 ml-auto shrink-0 whitespace-nowrap"> <Typography.Text style={{ fontSize: 12 }}>
<Tag color="blue">{currentSegmentIndex + 1} / {segments.length}</Tag>
<Typography.Text style={{ fontSize: 12 }}></Typography.Text> </Typography.Text>
<Switch <Switch
size="small" size="small"
checked={autoSaveOnSwitch} checked={autoSaveOnSwitch}
@@ -753,7 +865,10 @@ export default function LabelStudioTextEditor() {
</div> </div>
</div> </div>
)} )}
</div>
{/* 右侧编辑器 - Label Studio iframe */}
<div className="flex-1 flex flex-col min-h-0">
{/* 编辑器区域 */} {/* 编辑器区域 */}
<div className="flex-1 relative"> <div className="flex-1 relative">
{(!iframeReady || loadingTaskDetail || (selectedFileId && !lsReady)) && ( {(!iframeReady || loadingTaskDetail || (selectedFileId && !lsReady)) && (