refactor(annotation): 简化文本标注编辑器的段落管理功能

- 移除段落统计相关的数据结构和缓存逻辑
- 删除段落切换确认对话框和自动保存选项
- 简化段落加载和状态管理流程
- 将段落列表视图替换为简单的进度显示
- 更新API接口以支持单段内容获取
- 重构后端服务实现单段内容查询功能
This commit is contained in:
2026-02-04 18:08:14 +08:00
parent 707e65b017
commit fa9e9d9f68
5 changed files with 98 additions and 421 deletions

View File

@@ -1,12 +1,11 @@
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, Tree, Empty } from "antd"; import { App, Button, Card, List, Spin, Typography, Tag, Empty } from "antd";
import { LeftOutlined, ReloadOutlined, SaveOutlined, MenuFoldOutlined, MenuUnfoldOutlined, CheckOutlined } from "@ant-design/icons"; import { LeftOutlined, ReloadOutlined, SaveOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { import {
getEditorProjectInfoUsingGet, getEditorProjectInfoUsingGet,
getEditorTaskUsingGet, getEditorTaskUsingGet,
getEditorTaskSegmentsUsingGet,
listEditorTasksUsingGet, listEditorTasksUsingGet,
upsertEditorAnnotationUsingPut, upsertEditorAnnotationUsingPut,
} from "../annotation.api"; } from "../annotation.api";
@@ -29,7 +28,6 @@ type EditorTaskListItem = {
hasAnnotation: boolean; hasAnnotation: boolean;
annotationUpdatedAt?: string | null; annotationUpdatedAt?: string | null;
annotationStatus?: AnnotationResultStatus | null; annotationStatus?: AnnotationResultStatus | null;
segmentStats?: SegmentStats;
}; };
type LsfMessage = { type LsfMessage = {
@@ -37,18 +35,6 @@ type LsfMessage = {
payload?: unknown; payload?: unknown;
}; };
type SegmentInfo = {
idx: number;
hasAnnotation: boolean;
lineIndex: number;
chunkIndex: number;
};
type SegmentStats = {
done: number;
total: number;
};
type ApiResponse<T> = { type ApiResponse<T> = {
code?: number; code?: number;
message?: string; message?: string;
@@ -68,11 +54,6 @@ type EditorTaskResponse = {
currentSegmentIndex?: number; currentSegmentIndex?: number;
}; };
type EditorTaskSegmentsResponse = {
segmented?: boolean;
segments?: SegmentInfo[];
totalSegments?: number;
};
type EditorTaskListResponse = { type EditorTaskListResponse = {
content?: EditorTaskListItem[]; content?: EditorTaskListItem[];
@@ -95,8 +76,6 @@ type ExportPayload = {
requestId?: string | null; requestId?: string | null;
}; };
type SwitchDecision = "save" | "discard" | "cancel";
const LSF_IFRAME_SRC = "/lsf/lsf.html"; const LSF_IFRAME_SRC = "/lsf/lsf.html";
const TASK_PAGE_START = 0; const TASK_PAGE_START = 0;
const TASK_PAGE_SIZE = 200; const TASK_PAGE_SIZE = 200;
@@ -158,16 +137,6 @@ const isAnnotationResultEmpty = (annotation?: Record<string, unknown>) => {
}; };
const resolveTaskStatusMeta = (item: EditorTaskListItem) => { const resolveTaskStatusMeta = (item: EditorTaskListItem) => {
const segmentSummary = resolveSegmentSummary(item);
if (segmentSummary) {
if (segmentSummary.done >= segmentSummary.total) {
return { text: "已标注", type: "success" as const };
}
if (segmentSummary.done > 0) {
return { text: "标注中", type: "warning" as const };
}
return { text: "未标注", type: "secondary" as const };
}
if (!item.hasAnnotation) { if (!item.hasAnnotation) {
return { text: "未标注", type: "secondary" as const }; return { text: "未标注", type: "secondary" as const };
} }
@@ -220,25 +189,6 @@ const buildAnnotationSnapshot = (annotation?: Record<string, unknown>) => {
const buildSnapshotKey = (fileId: string, segmentIndex?: number) => const buildSnapshotKey = (fileId: string, segmentIndex?: number) =>
`${fileId}::${segmentIndex ?? "full"}`; `${fileId}::${segmentIndex ?? "full"}`;
const buildSegmentStats = (segmentList?: SegmentInfo[] | null): SegmentStats | null => {
if (!Array.isArray(segmentList) || segmentList.length === 0) return null;
const total = segmentList.length;
const done = segmentList.reduce((count, seg) => count + (seg.hasAnnotation ? 1 : 0), 0);
return { done, total };
};
const normalizeSegmentStats = (stats?: SegmentStats | null): SegmentStats | null => {
if (!stats) return null;
const total = Number(stats.total);
const done = Number(stats.done);
if (!Number.isFinite(total) || total <= 0) return null;
const safeDone = Math.min(Math.max(done, 0), total);
return { done: safeDone, total };
};
const resolveSegmentSummary = (item: EditorTaskListItem) =>
normalizeSegmentStats(item.segmentStats);
const mergeTaskItems = (base: EditorTaskListItem[], next: EditorTaskListItem[]) => { const mergeTaskItems = (base: EditorTaskListItem[], next: EditorTaskListItem[]) => {
if (next.length === 0) return base; if (next.length === 0) return base;
const seen = new Set(base.map((item) => item.fileId)); const seen = new Set(base.map((item) => item.fileId));
@@ -286,19 +236,13 @@ export default function LabelStudioTextEditor() {
resolve: (payload?: ExportPayload) => void; resolve: (payload?: ExportPayload) => void;
timer?: number; timer?: number;
} | null>(null); } | null>(null);
const exportCheckSeqRef = useRef(0);
const savedSnapshotsRef = useRef<Record<string, string>>({}); const savedSnapshotsRef = useRef<Record<string, string>>({});
const pendingAutoAdvanceRef = useRef(false); const pendingAutoAdvanceRef = useRef(false);
const segmentStatsCacheRef = useRef<Record<string, SegmentStats>>({});
const segmentStatsSeqRef = useRef(0);
const segmentStatsLoadingRef = useRef<Set<string>>(new Set());
const segmentSummaryFileRef = useRef<string>("");
const [loadingProject, setLoadingProject] = useState(true); const [loadingProject, setLoadingProject] = useState(true);
const [loadingTasks, setLoadingTasks] = useState(false); const [loadingTasks, setLoadingTasks] = useState(false);
const [loadingTaskDetail, setLoadingTaskDetail] = useState(false); const [loadingTaskDetail, setLoadingTaskDetail] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [segmentSwitching, setSegmentSwitching] = useState(false);
const [iframeReady, setIframeReady] = useState(false); const [iframeReady, setIframeReady] = useState(false);
const [lsReady, setLsReady] = useState(false); const [lsReady, setLsReady] = useState(false);
@@ -311,12 +255,11 @@ export default function LabelStudioTextEditor() {
const [prefetching, setPrefetching] = useState(false); const [prefetching, setPrefetching] = useState(false);
const [selectedFileId, setSelectedFileId] = useState<string>(""); const [selectedFileId, setSelectedFileId] = useState<string>("");
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [autoSaveOnSwitch, setAutoSaveOnSwitch] = useState(false);
// 分段相关状态 // 分段相关状态
const [segmented, setSegmented] = useState(false); const [segmented, setSegmented] = useState(false);
const [segments, setSegments] = useState<SegmentInfo[]>([]);
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0); const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0);
const [segmentTotal, setSegmentTotal] = useState(0);
const isTextProject = useMemo( const isTextProject = useMemo(
() => (project?.datasetType || "").toUpperCase() === "TEXT", () => (project?.datasetType || "").toUpperCase() === "TEXT",
[project?.datasetType], [project?.datasetType],
@@ -335,68 +278,6 @@ export default function LabelStudioTextEditor() {
win.postMessage({ type, payload }, origin); win.postMessage({ type, payload }, origin);
}, [origin]); }, [origin]);
const applySegmentStats = useCallback((fileId: string, stats: SegmentStats | null) => {
if (!fileId) return;
const normalized = normalizeSegmentStats(stats);
setTasks((prev) =>
prev.map((item) =>
item.fileId === fileId
? { ...item, segmentStats: normalized || undefined }
: item
)
);
}, []);
const updateSegmentStatsCache = useCallback((fileId: string, stats: SegmentStats | null) => {
if (!fileId) return;
const normalized = normalizeSegmentStats(stats);
if (normalized) {
segmentStatsCacheRef.current[fileId] = normalized;
} else {
delete segmentStatsCacheRef.current[fileId];
}
applySegmentStats(fileId, normalized);
}, [applySegmentStats]);
const fetchSegmentStatsForFile = useCallback(async (fileId: string, seq: number) => {
if (!projectId || !fileId) return;
if (segmentStatsCacheRef.current[fileId] || segmentStatsLoadingRef.current.has(fileId)) return;
segmentStatsLoadingRef.current.add(fileId);
try {
const resp = (await getEditorTaskSegmentsUsingGet(projectId, fileId)) as ApiResponse<EditorTaskSegmentsResponse>;
if (segmentStatsSeqRef.current !== seq) return;
const data = resp?.data;
if (!data?.segmented) return;
const stats = buildSegmentStats(data.segments);
if (!stats) return;
segmentStatsCacheRef.current[fileId] = stats;
applySegmentStats(fileId, stats);
} catch (e) {
console.error(e);
} finally {
segmentStatsLoadingRef.current.delete(fileId);
}
}, [applySegmentStats, projectId]);
const prefetchSegmentStats = useCallback((items: EditorTaskListItem[]) => {
if (!projectId) return;
const fileIds = items
.map((item) => item.fileId)
.filter((fileId) => fileId && !segmentStatsCacheRef.current[fileId]);
if (fileIds.length === 0) return;
const seq = segmentStatsSeqRef.current;
let cursor = 0;
const workerCount = Math.min(3, fileIds.length);
const runWorker = async () => {
while (cursor < fileIds.length && segmentStatsSeqRef.current === seq) {
const fileId = fileIds[cursor];
cursor += 1;
await fetchSegmentStatsForFile(fileId, seq);
}
};
void Promise.all(Array.from({ length: workerCount }, () => runWorker()));
}, [fetchSegmentStatsForFile, projectId]);
const confirmEmptyAnnotationStatus = useCallback(() => { const confirmEmptyAnnotationStatus = useCallback(() => {
return new Promise<AnnotationResultStatus | null>((resolve) => { return new Promise<AnnotationResultStatus | null>((resolve) => {
let resolved = false; let resolved = false;
@@ -599,33 +480,14 @@ export default function LabelStudioTextEditor() {
? resolveSegmentIndex(data.currentSegmentIndex) ?? 0 ? resolveSegmentIndex(data.currentSegmentIndex) ?? 0
: undefined; : undefined;
if (isSegmented) { if (isSegmented) {
let nextSegments: SegmentInfo[] = [];
if (segmentSummaryFileRef.current === fileId && segments.length > 0) {
nextSegments = segments;
} else {
try {
const segmentResp = (await getEditorTaskSegmentsUsingGet(projectId, fileId)) as ApiResponse<EditorTaskSegmentsResponse>;
if (seq !== initSeqRef.current) return;
const segmentData = segmentResp?.data;
if (segmentData?.segmented) {
nextSegments = Array.isArray(segmentData.segments) ? segmentData.segments : [];
}
} catch (e) {
console.error(e);
}
}
const stats = buildSegmentStats(nextSegments);
setSegmented(true); setSegmented(true);
setSegments(nextSegments);
setCurrentSegmentIndex(segmentIndex ?? 0); setCurrentSegmentIndex(segmentIndex ?? 0);
updateSegmentStatsCache(fileId, stats); const totalSegments = Number(data?.totalSegments ?? 0);
segmentSummaryFileRef.current = fileId; setSegmentTotal(Number.isFinite(totalSegments) && totalSegments > 0 ? totalSegments : 0);
} else { } else {
setSegmented(false); setSegmented(false);
setSegments([]);
setCurrentSegmentIndex(0); setCurrentSegmentIndex(0);
updateSegmentStatsCache(fileId, null); setSegmentTotal(0);
segmentSummaryFileRef.current = fileId;
} }
const taskData = { const taskData = {
@@ -685,19 +547,14 @@ export default function LabelStudioTextEditor() {
} finally { } finally {
if (seq === initSeqRef.current) setLoadingTaskDetail(false); if (seq === initSeqRef.current) setLoadingTaskDetail(false);
} }
}, [iframeReady, message, postToIframe, project, projectId, segments, updateSegmentStatsCache]); }, [iframeReady, message, postToIframe, project, projectId]);
const advanceAfterSave = useCallback(async (fileId: string, segmentIndex?: number) => { const advanceAfterSave = useCallback(async (fileId: string, segmentIndex?: number) => {
if (!fileId) return; if (!fileId) return;
if (segmented && segments.length > 0) { if (segmented && segmentTotal > 0) {
const sortedSegmentIndices = segments const baseIndex = Math.max(segmentIndex ?? currentSegmentIndex, 0);
.map((seg) => seg.idx) const nextSegmentIndex = baseIndex + 1;
.sort((a, b) => a - b); if (nextSegmentIndex < segmentTotal) {
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); await initEditorForFile(fileId, nextSegmentIndex);
return; return;
} }
@@ -719,7 +576,7 @@ export default function LabelStudioTextEditor() {
initEditorForFile, initEditorForFile,
message, message,
segmented, segmented,
segments, segmentTotal,
tasks, tasks,
]); ]);
@@ -793,16 +650,6 @@ export default function LabelStudioTextEditor() {
const snapshot = buildAnnotationSnapshot(isRecord(annotation) ? annotation : undefined); const snapshot = buildAnnotationSnapshot(isRecord(annotation) ? annotation : undefined);
savedSnapshotsRef.current[snapshotKey] = snapshot; savedSnapshotsRef.current[snapshotKey] = snapshot;
// 分段模式下更新当前段落的标注状态
if (segmented && segmentIndex !== undefined) {
const nextSegments = segments.map((seg) =>
seg.idx === segmentIndex
? { ...seg, hasAnnotation: true }
: seg
);
setSegments(nextSegments);
updateSegmentStatsCache(String(fileId), buildSegmentStats(nextSegments));
}
if (options?.autoAdvance) { if (options?.autoAdvance) {
await advanceAfterSave(String(fileId), segmentIndex); await advanceAfterSave(String(fileId), segmentIndex);
} }
@@ -821,69 +668,10 @@ export default function LabelStudioTextEditor() {
message, message,
projectId, projectId,
segmented, segmented,
segments,
selectedFileId, selectedFileId,
tasks, tasks,
updateSegmentStatsCache,
]); ]);
const requestExportForCheck = useCallback(() => {
if (!iframeReady || !lsReady) return Promise.resolve(undefined);
if (exportCheckRef.current) {
if (exportCheckRef.current.timer) {
window.clearTimeout(exportCheckRef.current.timer);
}
exportCheckRef.current.resolve(undefined);
exportCheckRef.current = null;
}
const requestId = `check_${Date.now()}_${++exportCheckSeqRef.current}`;
return new Promise<ExportPayload | undefined>((resolve) => {
const timer = window.setTimeout(() => {
if (exportCheckRef.current?.requestId === requestId) {
exportCheckRef.current = null;
}
resolve(undefined);
}, 3000);
exportCheckRef.current = {
requestId,
resolve,
timer,
};
postToIframe("LS_EXPORT_CHECK", { requestId });
});
}, [iframeReady, lsReady, postToIframe]);
const confirmSaveBeforeSwitch = useCallback(() => {
return new Promise<SwitchDecision>((resolve) => {
let resolved = false;
let modalInstance: { destroy: () => void } | null = null;
const settle = (decision: SwitchDecision) => {
if (resolved) return;
resolved = true;
resolve(decision);
};
const handleDiscard = () => {
if (modalInstance) modalInstance.destroy();
settle("discard");
};
modalInstance = modal.confirm({
title: "当前段落有未保存标注",
content: (
<div className="flex flex-col gap-2">
<Typography.Text></Typography.Text>
<Button type="link" danger style={{ padding: 0, height: "auto" }} onClick={handleDiscard}>
</Button>
</div>
),
okText: "保存并切换",
cancelText: "取消",
onOk: () => settle("save"),
onCancel: () => settle("cancel"),
});
});
}, [modal]);
const requestExport = useCallback((autoAdvance: boolean) => { const requestExport = useCallback((autoAdvance: boolean) => {
if (!selectedFileId) { if (!selectedFileId) {
message.warning("请先选择文件"); message.warning("请先选择文件");
@@ -896,7 +684,7 @@ export default function LabelStudioTextEditor() {
useEffect(() => { useEffect(() => {
const handleSaveShortcut = (event: KeyboardEvent) => { const handleSaveShortcut = (event: KeyboardEvent) => {
if (!isSaveShortcut(event) || event.repeat) return; if (!isSaveShortcut(event) || event.repeat) return;
if (saving || loadingTaskDetail || segmentSwitching) return; if (saving || loadingTaskDetail) return;
if (!iframeReady || !lsReady) return; if (!iframeReady || !lsReady) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -904,83 +692,7 @@ export default function LabelStudioTextEditor() {
}; };
window.addEventListener("keydown", handleSaveShortcut); window.addEventListener("keydown", handleSaveShortcut);
return () => window.removeEventListener("keydown", handleSaveShortcut); return () => window.removeEventListener("keydown", handleSaveShortcut);
}, [iframeReady, loadingTaskDetail, lsReady, requestExport, saving, segmentSwitching]); }, [iframeReady, loadingTaskDetail, lsReady, requestExport, saving]);
// 段落切换处理
const handleSegmentChange = useCallback(async (newIndex: number) => {
if (newIndex === currentSegmentIndex) return;
if (segmentSwitching || saving || loadingTaskDetail) return;
if (!iframeReady || !lsReady) {
message.warning("编辑器未就绪,无法切换段落");
return;
}
setSegmentSwitching(true);
try {
const payload = await requestExportForCheck();
if (!payload) {
message.warning("无法读取当前标注,已取消切换");
return;
}
const payloadTaskId = payload.taskId;
if (expectedTaskIdRef.current && payloadTaskId) {
if (Number(payloadTaskId) !== expectedTaskIdRef.current) {
message.warning("已忽略过期的标注数据");
return;
}
}
const payloadFileId = payload.fileId || selectedFileId;
const payloadSegmentIndex = resolveSegmentIndex(payload.segmentIndex);
const resolvedSegmentIndex =
payloadSegmentIndex !== undefined
? payloadSegmentIndex
: segmented
? currentSegmentIndex
: undefined;
const annotation = isRecord(payload.annotation) ? payload.annotation : undefined;
const snapshotKey = payloadFileId
? buildSnapshotKey(String(payloadFileId), resolvedSegmentIndex)
: undefined;
const latestSnapshot = buildAnnotationSnapshot(annotation);
const lastSnapshot = snapshotKey ? savedSnapshotsRef.current[snapshotKey] : undefined;
const hasUnsavedChange = snapshotKey !== undefined && lastSnapshot !== undefined && latestSnapshot !== lastSnapshot;
if (hasUnsavedChange) {
if (autoSaveOnSwitch) {
const saved = await saveFromExport(payload);
if (!saved) return;
} else {
const decision = await confirmSaveBeforeSwitch();
if (decision === "cancel") return;
if (decision === "save") {
const saved = await saveFromExport(payload);
if (!saved) return;
}
}
}
await initEditorForFile(selectedFileId, newIndex);
} finally {
setSegmentSwitching(false);
}
}, [
autoSaveOnSwitch,
confirmSaveBeforeSwitch,
currentSegmentIndex,
iframeReady,
initEditorForFile,
loadingTaskDetail,
lsReady,
message,
requestExportForCheck,
saveFromExport,
segmented,
selectedFileId,
segmentSwitching,
saving,
]);
useEffect(() => { useEffect(() => {
setIframeReady(false); setIframeReady(false);
@@ -998,13 +710,9 @@ export default function LabelStudioTextEditor() {
expectedTaskIdRef.current = null; expectedTaskIdRef.current = null;
// 重置分段状态 // 重置分段状态
setSegmented(false); setSegmented(false);
setSegments([]);
setCurrentSegmentIndex(0); setCurrentSegmentIndex(0);
segmentSummaryFileRef.current = ""; setSegmentTotal(0);
savedSnapshotsRef.current = {}; savedSnapshotsRef.current = {};
segmentStatsSeqRef.current += 1;
segmentStatsCacheRef.current = {};
segmentStatsLoadingRef.current = new Set();
if (exportCheckRef.current?.timer) { if (exportCheckRef.current?.timer) {
window.clearTimeout(exportCheckRef.current.timer); window.clearTimeout(exportCheckRef.current.timer);
} }
@@ -1018,12 +726,6 @@ export default function LabelStudioTextEditor() {
loadTasks({ mode: "reset" }); loadTasks({ mode: "reset" });
}, [project?.supported, loadTasks]); }, [project?.supported, loadTasks]);
useEffect(() => {
if (!segmented) return;
if (tasks.length === 0) return;
prefetchSegmentStats(tasks);
}, [prefetchSegmentStats, segmented, tasks]);
useEffect(() => { useEffect(() => {
if (!selectedFileId) return; if (!selectedFileId) return;
initEditorForFile(selectedFileId); initEditorForFile(selectedFileId);
@@ -1048,60 +750,6 @@ export default function LabelStudioTextEditor() {
return () => window.removeEventListener("focus", handleWindowFocus); return () => window.removeEventListener("focus", handleWindowFocus);
}, [focusIframe, lsReady]); }, [focusIframe, lsReady]);
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 inProgressSegmentedCount = useMemo(() => {
if (tasks.length === 0) return 0;
return tasks.reduce((count, item) => {
const summary = resolveSegmentSummary(item);
if (!summary) return count;
return summary.done < summary.total ? count + 1 : count;
}, 0);
}, [tasks]);
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;
@@ -1170,7 +818,7 @@ export default function LabelStudioTextEditor() {
const canLoadMore = taskTotalPages > 0 && taskPage + 1 < taskTotalPages; const canLoadMore = taskTotalPages > 0 && taskPage + 1 < taskTotalPages;
const saveDisabled = const saveDisabled =
!iframeReady || !selectedFileId || saving || segmentSwitching || loadingTaskDetail; !iframeReady || !selectedFileId || saving || loadingTaskDetail;
const loadMoreNode = canLoadMore ? ( const loadMoreNode = canLoadMore ? (
<div className="p-2 text-center"> <div className="p-2 text-center">
<Button <Button
@@ -1287,11 +935,6 @@ export default function LabelStudioTextEditor() {
> >
<div className="px-3 py-2 border-b border-gray-200 bg-white font-medium text-sm flex items-center justify-between gap-2"> <div className="px-3 py-2 border-b border-gray-200 bg-white font-medium text-sm flex items-center justify-between gap-2">
<span></span> <span></span>
{segmented && (
<Tag color="orange" style={{ margin: 0 }}>
{inProgressSegmentedCount}
</Tag>
)}
</div> </div>
<div className="flex-1 min-h-0 overflow-auto"> <div className="flex-1 min-h-0 overflow-auto">
<List <List
@@ -1300,7 +943,6 @@ export default function LabelStudioTextEditor() {
dataSource={tasks} dataSource={tasks}
loadMore={loadMoreNode} loadMore={loadMoreNode}
renderItem={(item) => { renderItem={(item) => {
const segmentSummary = resolveSegmentSummary(item);
const statusMeta = resolveTaskStatusMeta(item); const statusMeta = resolveTaskStatusMeta(item);
return ( return (
<List.Item <List.Item
@@ -1322,11 +964,6 @@ export default function LabelStudioTextEditor() {
<Typography.Text type={statusMeta.type} style={{ fontSize: 11 }}> <Typography.Text type={statusMeta.type} style={{ fontSize: 11 }}>
{statusMeta.text} {statusMeta.text}
</Typography.Text> </Typography.Text>
{segmentSummary && (
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
{segmentSummary.done}/{segmentSummary.total}
</Typography.Text>
)}
</div> </div>
{item.annotationUpdatedAt && ( {item.annotationUpdatedAt && (
<Typography.Text type="secondary" style={{ fontSize: 10 }}> <Typography.Text type="secondary" style={{ fontSize: 10 }}>
@@ -1345,21 +982,16 @@ export default function LabelStudioTextEditor() {
<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="px-3 py-2 border-b border-gray-200 bg-gray-50 font-medium text-sm flex items-center justify-between">
<span>/</span> <span>/</span>
<Tag color="blue" style={{ margin: 0 }}> <Tag color="blue" style={{ margin: 0 }}>
{currentSegmentIndex + 1} / {segments.length} {segmentTotal > 0 ? currentSegmentIndex + 1 : 0} / {segmentTotal}
</Tag> </Tag>
</div> </div>
<div className="flex-1 min-h-0 overflow-auto px-2 py-2"> <div className="flex-1 min-h-0 overflow-auto px-2 py-2">
{segments.length > 0 ? ( {segmentTotal > 0 ? (
<Tree <div className="py-6">
showLine <Typography.Text type="secondary" style={{ fontSize: 12 }}>
blockNode 使/
selectedKeys={ </Typography.Text>
segmented ? [`seg-${currentSegmentIndex}`] : [] </div>
}
expandedKeys={segmentLineKeys}
onSelect={handleSegmentSelect}
treeData={segmentTreeData}
/>
) : ( ) : (
<div className="py-6"> <div className="py-6">
<Empty <Empty
@@ -1369,17 +1001,6 @@ export default function LabelStudioTextEditor() {
</div> </div>
)} )}
</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
size="small"
checked={autoSaveOnSwitch}
onChange={(checked) => setAutoSaveOnSwitch(checked)}
disabled={segmentSwitching || saving || loadingTaskDetail || !lsReady}
/>
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -80,8 +80,12 @@ export function getEditorTaskUsingGet(
return get(`/api/annotation/editor/projects/${projectId}/tasks/${fileId}`, params); return get(`/api/annotation/editor/projects/${projectId}/tasks/${fileId}`, params);
} }
export function getEditorTaskSegmentsUsingGet(projectId: string, fileId: string) { export function getEditorTaskSegmentUsingGet(
return get(`/api/annotation/editor/projects/${projectId}/tasks/${fileId}/segments`); projectId: string,
fileId: string,
params: { segmentIndex: number }
) {
return get(`/api/annotation/editor/projects/${projectId}/tasks/${fileId}/segments`, params);
} }
export function upsertEditorAnnotationUsingPut( export function upsertEditorAnnotationUsingPut(

View File

@@ -19,8 +19,8 @@ from app.db.session import get_db
from app.module.annotation.schema.editor import ( from app.module.annotation.schema.editor import (
EditorProjectInfo, EditorProjectInfo,
EditorTaskListResponse, EditorTaskListResponse,
EditorTaskSegmentResponse,
EditorTaskResponse, EditorTaskResponse,
EditorTaskSegmentsResponse,
UpsertAnnotationRequest, UpsertAnnotationRequest,
UpsertAnnotationResponse, UpsertAnnotationResponse,
) )
@@ -90,15 +90,16 @@ async def get_editor_task(
@router.get( @router.get(
"/projects/{project_id}/tasks/{file_id}/segments", "/projects/{project_id}/tasks/{file_id}/segments",
response_model=StandardResponse[EditorTaskSegmentsResponse], response_model=StandardResponse[EditorTaskSegmentResponse],
) )
async def list_editor_task_segments( async def get_editor_task_segment(
project_id: str = Path(..., description="标注项目ID(t_dm_labeling_projects.id)"), project_id: str = Path(..., description="标注项目ID(t_dm_labeling_projects.id)"),
file_id: str = Path(..., description="文件ID(t_dm_dataset_files.id)"), file_id: str = Path(..., description="文件ID(t_dm_dataset_files.id)"),
segment_index: int = Query(..., ge=0, alias="segmentIndex", description="段落索引(从0开始)"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
service = AnnotationEditorService(db) service = AnnotationEditorService(db)
result = await service.get_task_segments(project_id, file_id) result = await service.get_task_segment(project_id, file_id, segment_index)
return StandardResponse(code=200, message="success", data=result) return StandardResponse(code=200, message="success", data=result)

View File

@@ -103,12 +103,25 @@ class EditorTaskResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True) model_config = ConfigDict(populate_by_name=True)
class EditorTaskSegmentsResponse(BaseModel): class SegmentDetail(BaseModel):
"""编辑器段落摘要响应""" """段落内容"""
idx: int = Field(..., description="段落索引")
text: str = Field(..., description="段落文本")
has_annotation: bool = Field(False, alias="hasAnnotation", description="该段落是否已有标注")
line_index: int = Field(0, alias="lineIndex", description="JSONL 行索引(从0开始)")
chunk_index: int = Field(0, alias="chunkIndex", description="行内分片索引(从0开始)")
model_config = ConfigDict(populate_by_name=True)
class EditorTaskSegmentResponse(BaseModel):
"""编辑器单段内容响应"""
segmented: bool = Field(False, description="是否启用分段模式") segmented: bool = Field(False, description="是否启用分段模式")
segments: List[SegmentInfo] = Field(default_factory=list, description="段落摘要列表") segment: Optional[SegmentDetail] = Field(None, description="段落内容")
total_segments: int = Field(0, alias="totalSegments", description="总段落数") total_segments: int = Field(0, alias="totalSegments", description="总段落数")
current_segment_index: int = Field(0, alias="currentSegmentIndex", description="当前段落索引")
model_config = ConfigDict(populate_by_name=True) model_config = ConfigDict(populate_by_name=True)

View File

@@ -36,8 +36,9 @@ from app.module.annotation.schema.editor import (
EditorProjectInfo, EditorProjectInfo,
EditorTaskListItem, EditorTaskListItem,
EditorTaskListResponse, EditorTaskListResponse,
EditorTaskSegmentResponse,
EditorTaskResponse, EditorTaskResponse,
EditorTaskSegmentsResponse, SegmentDetail,
SegmentInfo, SegmentInfo,
UpsertAnnotationRequest, UpsertAnnotationRequest,
UpsertAnnotationResponse, UpsertAnnotationResponse,
@@ -713,18 +714,19 @@ class AnnotationEditorService:
return await self._build_text_task(project, file_record, file_id, segment_index) return await self._build_text_task(project, file_record, file_id, segment_index)
async def get_task_segments( async def get_task_segment(
self, self,
project_id: str, project_id: str,
file_id: str, file_id: str,
) -> EditorTaskSegmentsResponse: segment_index: int,
) -> EditorTaskSegmentResponse:
project = await self._get_project_or_404(project_id) project = await self._get_project_or_404(project_id)
dataset_type = self._normalize_dataset_type(await self._get_dataset_type(project.dataset_id)) dataset_type = self._normalize_dataset_type(await self._get_dataset_type(project.dataset_id))
if dataset_type != DATASET_TYPE_TEXT: if dataset_type != DATASET_TYPE_TEXT:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="当前仅支持 TEXT 项目的段落摘要", detail="当前仅支持 TEXT 项目的段落内容",
) )
file_result = await self.db.execute( file_result = await self.db.execute(
@@ -738,7 +740,12 @@ class AnnotationEditorService:
raise HTTPException(status_code=404, detail=f"文件不存在或不属于该项目: {file_id}") raise HTTPException(status_code=404, detail=f"文件不存在或不属于该项目: {file_id}")
if not self._resolve_segmentation_enabled(project): if not self._resolve_segmentation_enabled(project):
return EditorTaskSegmentsResponse(segmented=False, segments=[], totalSegments=0) return EditorTaskSegmentResponse(
segmented=False,
segment=None,
totalSegments=0,
currentSegmentIndex=0,
)
text_content = await self._fetch_text_content_via_download_api(project.dataset_id, file_id) text_content = await self._fetch_text_content_via_download_api(project.dataset_id, file_id)
assert isinstance(text_content, str) assert isinstance(text_content, str)
@@ -768,7 +775,12 @@ class AnnotationEditorService:
len(text or "") > self.SEGMENT_THRESHOLD for text in record_texts len(text or "") > self.SEGMENT_THRESHOLD for text in record_texts
) )
if not needs_segmentation: if not needs_segmentation:
return EditorTaskSegmentsResponse(segmented=False, segments=[], totalSegments=0) return EditorTaskSegmentResponse(
segmented=False,
segment=None,
totalSegments=0,
currentSegmentIndex=0,
)
ann_result = await self.db.execute( ann_result = await self.db.execute(
select(AnnotationResult).where( select(AnnotationResult).where(
@@ -782,16 +794,42 @@ class AnnotationEditorService:
segment_annotations = self._extract_segment_annotations(ann.annotation) segment_annotations = self._extract_segment_annotations(ann.annotation)
segment_annotation_keys = set(segment_annotations.keys()) segment_annotation_keys = set(segment_annotations.keys())
segments, _ = self._build_segment_contexts( segments, segment_contexts = self._build_segment_contexts(
records, records,
record_texts, record_texts,
segment_annotation_keys, segment_annotation_keys,
) )
return EditorTaskSegmentsResponse( total_segments = len(segment_contexts)
if total_segments == 0:
return EditorTaskSegmentResponse(
segmented=False,
segment=None,
totalSegments=0,
currentSegmentIndex=0,
)
if segment_index < 0 or segment_index >= total_segments:
raise HTTPException(
status_code=400,
detail=f"segmentIndex 超出范围: {segment_index}",
)
segment_info = segments[segment_index]
_, _, segment_text, line_index, chunk_index = segment_contexts[segment_index]
segment_detail = SegmentDetail(
idx=segment_info.idx,
text=segment_text,
hasAnnotation=segment_info.has_annotation,
lineIndex=line_index,
chunkIndex=chunk_index,
)
return EditorTaskSegmentResponse(
segmented=True, segmented=True,
segments=segments, segment=segment_detail,
totalSegments=len(segments), totalSegments=total_segments,
currentSegmentIndex=segment_index,
) )
async def _build_text_task( async def _build_text_task(