You've already forked DataMate
feat(annotation): 添加文本标注编辑器中的段落树导航和自动跳转功能
- 引入 Tree 和 Empty 组件用于段落导航展示 - 实现分段树形结构数据生成和展示功能 - 添加自动跳转到下一个待标注文件或段落的功能 - 优化文件选择逻辑,优先选择未标注的文件 - 实现段落切换时的状态管理和依赖更新 - 添加段落树节点选中和展开状态控制 - 优化界面布局和滚动区域的高度计算
This commit is contained in:
@@ -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>
|
||||||
</div>
|
{segmented && (
|
||||||
|
<div className="border-t border-gray-200 bg-white flex flex-col min-h-0">
|
||||||
{/* 右侧编辑器 - Label Studio iframe */}
|
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 font-medium text-sm flex items-center justify-between">
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<span>段落/分段</span>
|
||||||
{/* 段落导航栏 */}
|
<Tag color="blue" style={{ margin: 0 }}>
|
||||||
{segmented && segments.length > 0 && (
|
{currentSegmentIndex + 1} / {segments.length}
|
||||||
<div className="flex flex-nowrap items-center gap-2 px-3 py-2 bg-gray-50 border-b border-gray-200">
|
</Tag>
|
||||||
<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="flex items-center gap-2 ml-auto shrink-0 whitespace-nowrap">
|
<div className="flex-1 min-h-0 overflow-auto px-2 py-2">
|
||||||
<Tag color="blue">{currentSegmentIndex + 1} / {segments.length}</Tag>
|
{segments.length > 0 ? (
|
||||||
<Typography.Text style={{ fontSize: 12 }}>切段自动保存</Typography.Text>
|
<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>
|
||||||
|
<div className="px-3 py-2 border-t border-gray-200 flex items-center justify-between">
|
||||||
|
<Typography.Text style={{ fontSize: 12 }}>
|
||||||
|
切段自动保存
|
||||||
|
</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)) && (
|
||||||
|
|||||||
Reference in New Issue
Block a user