diff --git a/src/main/index.ts b/src/main/index.ts index 55009e1..00f71f1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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(); }); } + + diff --git a/src/preload/index.ts b/src/preload/index.ts index 956721a..07f71cf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke("flows:create-demo"), createExamples: (): Promise => ipcRenderer.invoke("flows:create-examples"), get: (id: string): Promise => ipcRenderer.invoke("flows:get", id), - save: (flow: FlowDefinition): Promise => ipcRenderer.invoke("flows:save", flow), + save: (flow: FlowDefinition, opts?: FlowSaveOptions): Promise => ipcRenderer.invoke("flows:save", flow, opts), delete: (id: string): Promise => ipcRenderer.invoke("flows:delete", id) }, dialog: { @@ -46,3 +46,4 @@ const api = { contextBridge.exposeInMainWorld("rpa", api); export type RpaApi = typeof api; + diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f2b298f..477f530 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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() { ); } + diff --git a/src/renderer/src/pages/EditorPage.tsx b/src/renderer/src/pages/EditorPage.tsx index 71ba6e3..b687909 100644 --- a/src/renderer/src/pages/EditorPage.tsx +++ b/src/renderer/src/pages/EditorPage.tsx @@ -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(null); const [validationIssues, setValidationIssues] = useState(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) { ); } + diff --git a/src/renderer/src/store/editorStore.ts b/src/renderer/src/store/editorStore.ts index 4b34c84..7fc31d4 100644 --- a/src/renderer/src/store/editorStore.ts +++ b/src/renderer/src/store/editorStore.ts @@ -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((set, get) => { }, markSaved: () => set({ dirty: false }), + syncSavedFlow: (flow) => set({ flow, dirty: false }), reset: () => set({ flow: null, selectedNodeId: null, dirty: false, past: [], future: [] }) }; }); + diff --git a/src/shared/types.ts b/src/shared/types.ts index b0a7900..55b384c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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; } +