feat(annotation): 添加切换段落时自动保存功能

- 在 LabelStudioTextEditor 组件中新增 Switch 组件用于控制自动保存
- 添加 autoSaveOnSwitch 状态管理自动保存开关
- 修改 confirmSaveBeforeSwitch 函数支持保存、放弃、取消三种决策
- 实现自动保存逻辑,当开关开启时直接保存而不弹出确认对话框
- 在段落导航栏添加自动保存开关和标签显示
- 更新切换段落时的未保存更改处理逻辑
This commit is contained in:
2026-01-22 17:38:25 +08:00
parent 9c9d5ecbe2
commit 389c04b46a

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { App, Button, Card, List, Spin, Typography, Tag } from "antd";
import { App, Button, Card, List, Spin, Typography, Tag, Switch } from "antd";
import { LeftOutlined, ReloadOutlined, SaveOutlined, MenuFoldOutlined, MenuUnfoldOutlined, CheckOutlined } from "@ant-design/icons";
import { useNavigate, useParams } from "react-router";
@@ -71,6 +71,8 @@ type ExportPayload = {
requestId?: string | null;
};
type SwitchDecision = "save" | "discard" | "cancel";
const LSF_IFRAME_SRC = "/lsf/lsf.html";
const resolveSegmentIndex = (value: unknown) => {
@@ -160,6 +162,7 @@ export default function LabelStudioTextEditor() {
const [tasks, setTasks] = useState<EditorTaskListItem[]>([]);
const [selectedFileId, setSelectedFileId] = useState<string>("");
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [autoSaveOnSwitch, setAutoSaveOnSwitch] = useState(false);
// 分段相关状态
const [segmented, setSegmented] = useState(false);
@@ -402,14 +405,32 @@ export default function LabelStudioTextEditor() {
}, [iframeReady, lsReady, postToIframe]);
const confirmSaveBeforeSwitch = useCallback(() => {
return new Promise<boolean>((resolve) => {
modal.confirm({
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: "切换段落前请先保存当前标注。",
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: () => resolve(true),
onCancel: () => resolve(false),
onOk: () => settle("save"),
onCancel: () => settle("cancel"),
});
});
}, [modal]);
@@ -464,10 +485,17 @@ export default function LabelStudioTextEditor() {
const hasUnsavedChange = snapshotKey !== undefined && lastSnapshot !== undefined && latestSnapshot !== lastSnapshot;
if (hasUnsavedChange) {
const shouldSave = await confirmSaveBeforeSwitch();
if (!shouldSave) return;
const saved = await saveFromExport(payload);
if (!saved) return;
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);
@@ -707,9 +735,16 @@ export default function LabelStudioTextEditor() {
</Button>
))}
</div>
<Tag color="blue" style={{ marginLeft: 8 }}>
{currentSegmentIndex + 1} / {segments.length}
</Tag>
<div className="flex items-center gap-2 ml-auto">
<Tag color="blue">{currentSegmentIndex + 1} / {segments.length}</Tag>
<Typography.Text style={{ fontSize: 12 }}></Typography.Text>
<Switch
size="small"
checked={autoSaveOnSwitch}
onChange={(checked) => setAutoSaveOnSwitch(checked)}
disabled={segmentSwitching || saving || loadingTaskDetail || !lsReady}
/>
</div>
</div>
)}