feat: 任务保存冲突检测——磁盘被外部修改时提示选择而非静默覆盖
This commit is contained in:
parent
97fda1696a
commit
876e4969bd
@ -1,6 +1,6 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
|
||||
import path from "node:path";
|
||||
import type { AppInfo, AppSettings, FlowDefinition } from "../shared/types";
|
||||
import type { AppInfo, AppSettings, FlowDefinition, FlowSaveOptions, FlowSaveResult } from "../shared/types";
|
||||
import { BrowserSession, hasBrowserSession, setBrowserElementWaitMs } from "./browser/session";
|
||||
import "./capabilities";
|
||||
import { catalogPath, loadCatalog } from "./catalog";
|
||||
@ -128,7 +128,15 @@ function registerIpc(): void {
|
||||
return listFlows();
|
||||
});
|
||||
ipcMain.handle("flows:get", (_event, id: string) => getFlow(id));
|
||||
ipcMain.handle("flows:save", (_event, flow: FlowDefinition) => saveFlow(flow));
|
||||
ipcMain.handle("flows:save", (_event, flow: FlowDefinition, opts?: FlowSaveOptions): FlowSaveResult => {
|
||||
if (!opts?.force && opts?.expectedUpdatedAt) {
|
||||
const disk = getFlow(flow.id);
|
||||
if (disk && disk.meta?.updatedAt && disk.meta.updatedAt !== opts.expectedUpdatedAt) {
|
||||
return { conflict: true, diskFlow: disk };
|
||||
}
|
||||
}
|
||||
return { conflict: false, flow: saveFlow(flow) };
|
||||
});
|
||||
ipcMain.handle("flows:delete", (_event, id: string) => {
|
||||
deleteFlow(id);
|
||||
return true;
|
||||
@ -190,3 +198,5 @@ if (!gotLock) {
|
||||
void stopFixtureServer();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { AppInfo, AppSettings, Catalog, FlowDefinition, FlowSummary, RunEvent } from "../shared/types";
|
||||
import type { AppInfo, AppSettings, Catalog, FlowDefinition, FlowSaveOptions, FlowSaveResult, FlowSummary, RunEvent } from "../shared/types";
|
||||
|
||||
const api = {
|
||||
app: {
|
||||
@ -14,7 +14,7 @@ const api = {
|
||||
createDemo: (): Promise<FlowDefinition> => ipcRenderer.invoke("flows:create-demo"),
|
||||
createExamples: (): Promise<FlowSummary[]> => ipcRenderer.invoke("flows:create-examples"),
|
||||
get: (id: string): Promise<FlowDefinition> => ipcRenderer.invoke("flows:get", id),
|
||||
save: (flow: FlowDefinition): Promise<FlowDefinition> => ipcRenderer.invoke("flows:save", flow),
|
||||
save: (flow: FlowDefinition, opts?: FlowSaveOptions): Promise<FlowSaveResult> => ipcRenderer.invoke("flows:save", flow, opts),
|
||||
delete: (id: string): Promise<boolean> => ipcRenderer.invoke("flows:delete", id)
|
||||
},
|
||||
dialog: {
|
||||
@ -46,3 +46,4 @@ const api = {
|
||||
contextBridge.exposeInMainWorld("rpa", api);
|
||||
|
||||
export type RpaApi = typeof api;
|
||||
|
||||
|
||||
@ -128,7 +128,7 @@ export function App() {
|
||||
try {
|
||||
const loaded = await window.rpa.flows.get(id);
|
||||
const flow = normalizeLegacyLinearLayout(loaded);
|
||||
if (flow !== loaded) await window.rpa.flows.save(flow);
|
||||
if (flow !== loaded) await window.rpa.flows.save(flow, { force: true });
|
||||
loadFlow(flow);
|
||||
setActiveFlowId(id);
|
||||
setPage("editor");
|
||||
@ -276,3 +276,4 @@ export function App() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -25,6 +25,8 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
|
||||
const redo = useEditorStore((state) => state.redo);
|
||||
const renameFlow = useEditorStore((state) => state.renameFlow);
|
||||
const markSaved = useEditorStore((state) => state.markSaved);
|
||||
const loadFlow = useEditorStore((state) => state.loadFlow);
|
||||
const syncSavedFlow = useEditorStore((state) => state.syncSavedFlow);
|
||||
const selectNode = useEditorStore((state) => state.selectNode);
|
||||
const canUndo = useEditorStore((state) => state.past.length > 0);
|
||||
const canRedo = useEditorStore((state) => state.future.length > 0);
|
||||
@ -32,29 +34,63 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
|
||||
const startRun = useRunStore((state) => state.start);
|
||||
const stopRun = useRunStore((state) => state.stop);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveConflict, setSaveConflict] = useState(false);
|
||||
const [savedAt, setSavedAt] = useState<string | null>(null);
|
||||
const [validationIssues, setValidationIssues] = useState<FlowValidationIssue[] | null>(null);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const save = useCallback(async (opts?: { interactive?: boolean }) => {
|
||||
const current = useEditorStore.getState().flow;
|
||||
if (!current || saving) return;
|
||||
if (!current || saving) return null;
|
||||
setSaving(true);
|
||||
try {
|
||||
await window.rpa.flows.save(current);
|
||||
const result = await window.rpa.flows.save(current, {
|
||||
expectedUpdatedAt: current.meta?.updatedAt || undefined
|
||||
});
|
||||
if (result.conflict) {
|
||||
if (opts?.interactive) {
|
||||
const loadDisk = window.confirm(
|
||||
"任务已在磁盘上被外部修改(可能被其他工具保存过)。\n\n" +
|
||||
"「确定」加载磁盘上的最新版本(放弃当前编辑)\n" +
|
||||
"「取消」以当前编辑内容覆盖磁盘"
|
||||
);
|
||||
if (loadDisk) {
|
||||
loadFlow(result.diskFlow);
|
||||
markSaved();
|
||||
setSaveConflict(false);
|
||||
onToast("已加载磁盘上的最新版本");
|
||||
return null;
|
||||
}
|
||||
const forced = await window.rpa.flows.save(current, { force: true });
|
||||
if (forced.conflict) return null;
|
||||
syncSavedFlow(forced.flow);
|
||||
markSaved();
|
||||
setSaveConflict(false);
|
||||
setSavedAt(new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }));
|
||||
onToast("已覆盖磁盘上的旧版本");
|
||||
return forced.flow;
|
||||
}
|
||||
onToast("检测到磁盘上的任务已被外部修改,自动保存已暂停;请按 Ctrl+S 处理", "error");
|
||||
setSaveConflict(true);
|
||||
return null;
|
||||
}
|
||||
syncSavedFlow(result.flow);
|
||||
markSaved();
|
||||
setSaveConflict(false);
|
||||
setSavedAt(new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }));
|
||||
return result.flow;
|
||||
} catch (error) {
|
||||
onToast(error instanceof Error ? error.message : String(error), "error");
|
||||
return null;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [markSaved, onToast, saving]);
|
||||
}, [loadFlow, markSaved, onToast, saving, syncSavedFlow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
if (!dirty || saveConflict) return;
|
||||
const timer = window.setTimeout(() => void save(), 900);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [dirty, flow, save]);
|
||||
}, [dirty, flow, save, saveConflict]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
@ -62,7 +98,7 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
|
||||
if (!mod) return;
|
||||
if (event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
void save();
|
||||
void save({ interactive: true });
|
||||
} else if (event.key.toLowerCase() === "z" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
undo();
|
||||
@ -76,16 +112,10 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
|
||||
}, [redo, save, undo]);
|
||||
|
||||
const executeCurrentFlow = useCallback(async () => {
|
||||
const current = useEditorStore.getState().flow;
|
||||
if (!current) return;
|
||||
try {
|
||||
const saved = await window.rpa.flows.save(current);
|
||||
markSaved();
|
||||
await startRun(saved);
|
||||
} catch (error) {
|
||||
onToast(error instanceof Error ? error.message : String(error), "error");
|
||||
}
|
||||
}, [markSaved, onToast, startRun]);
|
||||
const saved = await save({ interactive: true });
|
||||
if (!saved) return;
|
||||
await startRun(saved);
|
||||
}, [save, startRun]);
|
||||
|
||||
const runCurrentFlow = useCallback(async () => {
|
||||
const current = useEditorStore.getState().flow;
|
||||
@ -218,3 +248,4 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -81,6 +81,8 @@ interface EditorState {
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
markSaved: () => void;
|
||||
/** 保存成功后用服务端返回的流程同步内存(刷新 updatedAt 基准),不触发 dirty */
|
||||
syncSavedFlow: (flow: FlowDefinition) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@ -332,7 +334,9 @@ export const useEditorStore = create<EditorState>((set, get) => {
|
||||
},
|
||||
|
||||
markSaved: () => set({ dirty: false }),
|
||||
syncSavedFlow: (flow) => set({ flow, dirty: false }),
|
||||
|
||||
reset: () => set({ flow: null, selectedNodeId: null, dirty: false, past: [], future: [] })
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -145,6 +145,16 @@ export interface FlowDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
export interface FlowSaveOptions {
|
||||
/** 编辑器加载任务时磁盘上的 updatedAt,用于外部修改冲突检测 */
|
||||
expectedUpdatedAt?: string;
|
||||
/** 为 true 时跳过冲突检测,强制覆盖磁盘 */
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export type FlowSaveResult =
|
||||
| { conflict: false; flow: FlowDefinition }
|
||||
| { conflict: true; diskFlow: FlowDefinition };
|
||||
export interface FlowSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -230,3 +240,4 @@ export interface RunEvent {
|
||||
attempt?: number;
|
||||
startedAt?: string;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user