diff --git a/frontend/app/components/common/ConfirmDialog.tsx b/frontend/app/components/common/ConfirmDialog.tsx index b568603..a1653eb 100644 --- a/frontend/app/components/common/ConfirmDialog.tsx +++ b/frontend/app/components/common/ConfirmDialog.tsx @@ -39,7 +39,7 @@ function ConfirmDialog({ }: ConfirmDialogProps) { return ( - + {title} diff --git a/frontend/app/features/platform/ScriptsPage.tsx b/frontend/app/features/platform/ScriptsPage.tsx index ed95983..d33655b 100644 --- a/frontend/app/features/platform/ScriptsPage.tsx +++ b/frontend/app/features/platform/ScriptsPage.tsx @@ -13,9 +13,10 @@ import { import { WelcomePanel } from "./WelcomePanel"; import { PublishModal } from "./PublishModal"; import { ScriptExplorer } from "./ScriptExplorer"; +import { ScriptsPendingConfirmDialog } from "./ScriptsPendingConfirm"; import { TreeContextMenu } from "./TreeContextMenu"; import { VersionReceiptModal } from "./VersionReceiptModal"; - +import { useScriptsPendingConfirm } from "./useScriptsPendingConfirm"; import { getSessionCache, useScriptWorkspaceStore } from "./state/scriptWorkspaceStore"; import { useUiStore } from "./state/uiStore"; import { toast } from "sonner"; @@ -75,6 +76,18 @@ export default function ScriptsPage() { const submitPublish = useScriptWorkspaceStore((s) => s.submitPublish); const refreshReadOnlyContent = useScriptWorkspaceStore((s) => s.refreshReadOnlyContent); + const { + pendingConfirm, + setPendingConfirm, + requestCloseTab, + handleConfirm, + } = useScriptsPendingConfirm(scripts, { + deleteScript, + deleteDataResource, + deleteDirectory, + closeTab, + }); + // ui store const pushToast = (notice: { tone: "success" | "error" | "info"; message: string }) => { if (notice.tone === "error") toast.error(notice.message); @@ -206,27 +219,11 @@ export default function ScriptsPage() { }; }, [contextMenu, closeContextMenu]); - // python editor handlers with scriptId forwarding - const handleSetPythonEditorContent = (scriptId: string, value: string) => { - setPythonEditorContent(scriptId, value); - }; - const handleSavePythonEditor = (scriptId: string) => { - void savePythonEditor(scriptId); - }; - const handleExitPythonEditor = (scriptId: string) => { - exitPythonEditor(scriptId); - }; - const handleClosePythonTab = (scriptId: string) => { - void closeTab(scriptId); - }; - - // 5) handlers + // 5) handlers const SCRIPT_EXTS = [".py", ".ipynb"]; const DATA_EXTS = [".csv", ".xlsx", ".xls", ".tsv", ".json", ".parquet", ".txt"]; - const matchesExt = (name: string, exts: string[]) => exts.some((ext) => name.toLowerCase().endsWith(ext)); - const handleUpload = (event: React.ChangeEvent) => { const files = Array.from(event.target.files ?? []); event.target.value = ""; @@ -381,17 +378,17 @@ export default function ScriptsPage() { void endEditing(); } }} - onClose={(scriptId, event) => void closeTab(scriptId, event)} + onClose={(scriptId, event) => void requestCloseTab(scriptId, event)} onSwitchTab={switchTab} onNewTab={() => openCreateDialog("")} onPublish={() => openPublishDialog(selected)} scripts={scripts} pythonEditorBuffers={pythonEditorBuffers} onOpenPythonEditor={() => void openPythonEditor(selected)} - onSetPythonEditorContent={handleSetPythonEditorContent} - onSavePythonEditor={handleSavePythonEditor} - onExitPythonEditor={handleExitPythonEditor} - onClosePythonTab={handleClosePythonTab} + onSetPythonEditorContent={setPythonEditorContent} + onSavePythonEditor={(scriptId) => void savePythonEditor(scriptId)} + onExitPythonEditor={exitPythonEditor} + onClosePythonTab={(scriptId) => void requestCloseTab(scriptId)} onInfo={(t) => pushToast(t)} /> ) : ( @@ -429,19 +426,35 @@ export default function ScriptsPage() { selectScript(scriptId); closeContextMenu(); }} - onRemoveScript={(s) => void deleteScript(s)} + onRemoveScript={(s) => setPendingConfirm({ kind: "script", script: s })} onToggleLock={(s) => void toggleScriptLock(s)} onOpenCreateDialog={(parentPath, scriptType) => openCreateDialog(parentPath, scriptType)} onOpenFolderDialog={(parentPath) => openFolderDialog(parentPath)} onChooseUpload={(parentPath) => triggerUpload(parentPath ?? "")} - onRemoveDirectory={(p) => void deleteDirectory(p)} + onRemoveDirectory={(p) => + setPendingConfirm({ kind: "directory", path: p })} onCopyResourcePath={(p) => void handleCopyResourcePath(p)} onPreviewResource={openResourcePreview} - onRemoveResource={(id) => void deleteDataResource(id)} + onRemoveResource={(id) => { + const resource = dataResources.find((item) => item.resource_id === id); + setPendingConfirm({ + kind: "resource", + id, + name: resource?.resource_name ?? id, + }); + }} onClose={closeContextMenu} /> + { + if (!open) setPendingConfirm(null); + }} + onConfirm={() => void handleConfirm()} + /> + setResourcePreview(null)} diff --git a/frontend/app/features/platform/ScriptsPendingConfirm.tsx b/frontend/app/features/platform/ScriptsPendingConfirm.tsx new file mode 100644 index 0000000..a873eaa --- /dev/null +++ b/frontend/app/features/platform/ScriptsPendingConfirm.tsx @@ -0,0 +1,66 @@ +import { ConfirmDialog } from "~/components/common/ConfirmDialog"; +import type { ScriptItem } from "~/services/api"; + +export type ScriptsPendingConfirm = + | { kind: "script"; script: ScriptItem } + | { kind: "resource"; id: string; name: string } + | { kind: "directory"; path: string } + | { kind: "close-tab"; id: string; name: string }; + +type ScriptsPendingConfirmDialogProps = { + pending: ScriptsPendingConfirm | null; + onOpenChange: (open: boolean) => void; + onConfirm: () => void | Promise; +}; + +function copyFor(pending: ScriptsPendingConfirm) { + switch (pending.kind) { + case "script": + return { + title: "确定删除文件?", + description: `确定删除文件"${pending.script.script_name}"吗?稳定版本会保留。`, + confirmLabel: "删除", + destructive: true, + }; + case "resource": + return { + title: "确定删除数据资源?", + description: `确定删除数据资源"${pending.name}"吗?稳定版本会保留。`, + confirmLabel: "删除", + destructive: true, + }; + case "directory": + return { + title: "确定删除文件夹?", + description: `确定递归删除文件夹"${pending.path}"及其内容吗?稳定版本会保留。`, + confirmLabel: "删除", + destructive: true, + }; + case "close-tab": + return { + title: "确定关闭标签?", + description: `当前脚本有未保存修改,确定关闭 "${pending.name}" 吗?`, + confirmLabel: "关闭", + destructive: false, + }; + } +} + +export function ScriptsPendingConfirmDialog({ + pending, + onOpenChange, + onConfirm, +}: ScriptsPendingConfirmDialogProps) { + const copy = pending ? copyFor(pending) : null; + return ( + + ); +} diff --git a/frontend/app/features/platform/TreeContextMenu.tsx b/frontend/app/features/platform/TreeContextMenu.tsx index 8f96c7f..7e4fe66 100644 --- a/frontend/app/features/platform/TreeContextMenu.tsx +++ b/frontend/app/features/platform/TreeContextMenu.tsx @@ -151,7 +151,10 @@ export function TreeContextMenu({ className={dangerItemClass} type="button" role="menuitem" - onClick={() => onRemoveScript(contextMenu.script!)} + onClick={() => { + onRemoveScript(contextMenu.script!); + onClose(); + }} > 删除文件 @@ -204,7 +207,10 @@ export function TreeContextMenu({ className={dangerItemClass} type="button" role="menuitem" - onClick={() => onRemoveDirectory(contextMenu.path)} + onClick={() => { + onRemoveDirectory(contextMenu.path); + onClose(); + }} > 删除文件夹 diff --git a/frontend/app/features/platform/state/editSessionSlice.ts b/frontend/app/features/platform/state/editSessionSlice.ts index 376c80d..58c48db 100644 --- a/frontend/app/features/platform/state/editSessionSlice.ts +++ b/frontend/app/features/platform/state/editSessionSlice.ts @@ -164,7 +164,7 @@ export const createEditSessionSlice: StateCreator< const scriptId = active?.script_id; if (!active) { if (closeTabFlag && scriptId) { - await get().closeTab(scriptId); + await get().closeTab(scriptId, undefined, { discardDirty: true }); } return; } @@ -174,7 +174,7 @@ export const createEditSessionSlice: StateCreator< applyEditSessionState((p) => set(p), null, null); if (scriptId) sessionCache.delete(scriptId); if (closeTabFlag && scriptId) { - await get().closeTab(scriptId); + await get().closeTab(scriptId, undefined, { discardDirty: true }); } if (showToast) { pushToast("success", `${active.script_name} 的编辑锁已释放`); diff --git a/frontend/app/features/platform/state/mutationsSlice.ts b/frontend/app/features/platform/state/mutationsSlice.ts index a4b1d54..d47ca27 100644 --- a/frontend/app/features/platform/state/mutationsSlice.ts +++ b/frontend/app/features/platform/state/mutationsSlice.ts @@ -215,9 +215,6 @@ export const createMutationsSlice: StateCreator< deleteDataResource: async (resourceId) => { const api = requireApi(); useUiStore.getState().closeContextMenu(); - if (!window.confirm(`确定删除数据资源吗?稳定版本会保留。`)) { - return; - } try { await api.deleteResource(resourceId); set((state) => ({ @@ -287,11 +284,6 @@ export const createMutationsSlice: StateCreator< deleteScript: async (script: ScriptItem) => { const api = requireApi(); useUiStore.getState().closeContextMenu(); - if ( - !window.confirm(`确定删除文件"${script.script_name}"吗?稳定版本会保留。`) - ) { - return; - } const editSession = getEditSession(); if (editSession?.script_id === script.script_id) { await get().endEditing(false, false); @@ -323,11 +315,6 @@ export const createMutationsSlice: StateCreator< deleteDirectory: async (path) => { const api = requireApi(); useUiStore.getState().closeContextMenu(); - if ( - !window.confirm(`确定递归删除文件夹"${path}"及其内容吗?稳定版本会保留。`) - ) { - return; - } const activeScript = get().scripts.find( (item) => item.script_id === getEditSession()?.script_id, ); diff --git a/frontend/app/features/platform/state/selectionSlice.ts b/frontend/app/features/platform/state/selectionSlice.ts index 8cf83af..2bbfa56 100644 --- a/frontend/app/features/platform/state/selectionSlice.ts +++ b/frontend/app/features/platform/state/selectionSlice.ts @@ -62,14 +62,11 @@ export const createSelectionSlice: StateCreator< })); }, - closeTab: async (id, event) => { + closeTab: async (id, event, options) => { event?.stopPropagation(); const buffer = get().pythonEditorBuffers[id]; - if (buffer?.dirty && !buffer.saving) { - const name = - get().scripts.find((s) => s.script_id === id)?.script_name ?? "该脚本"; - const ok = window.confirm(`当前脚本有未保存修改,确定关闭 "${name}" 吗?`); - if (!ok) return; + if (buffer?.dirty && !buffer.saving && !options?.discardDirty) { + return false; } if (buffer) { get().exitPythonEditor(id); @@ -91,6 +88,7 @@ export const createSelectionSlice: StateCreator< } return { openTabIds: newTabs, selectedId: nextSelected }; }); + return true; }, switchTab: (id) => { diff --git a/frontend/app/features/platform/state/types.ts b/frontend/app/features/platform/state/types.ts index df21320..1036eb3 100644 --- a/frontend/app/features/platform/state/types.ts +++ b/frontend/app/features/platform/state/types.ts @@ -92,7 +92,8 @@ export type SelectionSliceActions = { closeTab: ( id: string, event?: { stopPropagation: () => void }, - ) => Promise; + options?: { discardDirty?: boolean }, + ) => Promise; switchTab: (id: string) => void; openPublishDialog: (script: ScriptItem) => void; }; diff --git a/frontend/app/features/platform/useScriptsPendingConfirm.ts b/frontend/app/features/platform/useScriptsPendingConfirm.ts new file mode 100644 index 0000000..89ed4af --- /dev/null +++ b/frontend/app/features/platform/useScriptsPendingConfirm.ts @@ -0,0 +1,54 @@ +import { useState } from "react"; +import type { ScriptItem } from "~/services/api"; +import type { ScriptsPendingConfirm } from "./ScriptsPendingConfirm"; + +type DeleteActions = { + deleteScript: (script: ScriptItem) => Promise; + deleteDataResource: (resourceId: string) => Promise; + deleteDirectory: (path: string) => Promise; + closeTab: ( + id: string, + event?: { stopPropagation: () => void }, + options?: { discardDirty?: boolean }, + ) => Promise; +}; + +export function useScriptsPendingConfirm( + scripts: ScriptItem[], + actions: DeleteActions, +) { + const [pendingConfirm, setPendingConfirm] = + useState(null); + + const requestCloseTab = async ( + scriptId: string, + event?: { stopPropagation: () => void }, + ) => { + const closed = await actions.closeTab(scriptId, event); + if (closed) return; + const name = + scripts.find((s) => s.script_id === scriptId)?.script_name ?? "该脚本"; + setPendingConfirm({ kind: "close-tab", id: scriptId, name }); + }; + + const handleConfirm = async () => { + if (!pendingConfirm) return; + const target = pendingConfirm; + setPendingConfirm(null); + if (target.kind === "script") await actions.deleteScript(target.script); + else if (target.kind === "resource") { + await actions.deleteDataResource(target.id); + } else if (target.kind === "directory") { + await actions.deleteDirectory(target.path); + } else { + await actions.closeTab(target.id, undefined, { discardDirty: true }); + } + }; + + return { + pendingConfirm, + setPendingConfirm, + requestCloseTab, + handleConfirm, + }; +} diff --git a/frontend/app/features/schedules/SchedulePage.tsx b/frontend/app/features/schedules/SchedulePage.tsx index bee3ac3..d492957 100644 --- a/frontend/app/features/schedules/SchedulePage.tsx +++ b/frontend/app/features/schedules/SchedulePage.tsx @@ -28,6 +28,8 @@ import { type ScheduleNode, } from "~/services/api"; +import { SchedulePendingConfirmDialog, type SchedulePendingConfirm } from "./SchedulePendingConfirm"; +import { ScheduleRenameDialog } from "./ScheduleRenameDialog"; import { useApi, useAuth } from "~/context/AuthContext"; import { formFieldClass, @@ -236,6 +238,8 @@ export default function SchedulePage({ const setRuns = useSchedulesStore((s) => s.setRuns); const setRunsLoading = useSchedulesStore((s) => s.setRunsLoading); const canvasRef = useRef(null); + const [pendingConfirm, setPendingConfirm] = + useState(null); const selectedNode = schedule?.nodes.find( (item) => item.node_id === selectedNodeId, @@ -366,6 +370,30 @@ export default function SchedulePage({ [], ); + const requestRemoveNode = async (node: ScheduleNode) => { + const result = await useSchedulesStore.getState().removeNode(node); + if (result === "needs_history_confirm") { + setPendingConfirm({ kind: "node-history", node }); + } + }; + + const handleConfirm = async () => { + if (!pendingConfirm) return; + const target = pendingConfirm; + setPendingConfirm(null); + if (target.kind === "schedule") { + await useSchedulesStore.getState().removeSchedule(target.schedule); + } else if (target.kind === "artifact") { + await useSchedulesStore.getState().removeArtifact(target.artifact); + } else if (target.kind === "node") { + await requestRemoveNode(target.node); + } else { + await useSchedulesStore + .getState() + .removeNode(target.node, { deleteExecutionHistory: true }); + } + }; + return (
@@ -416,7 +444,9 @@ export default function SchedulePage({ variant="destructive" size="sm" disabled={!schedule || Boolean(busy)} - onClick={() => useSchedulesStore.getState().removeSchedule(schedule ?? undefined)} + onClick={() => { + if (schedule) setPendingConfirm({ kind: "schedule", schedule }); + }} > 删除 @@ -662,7 +692,8 @@ export default function SchedulePage({ busy={Boolean(busy)} onChange={setNodeForm} onSave={() => useSchedulesStore.getState().saveNode(selectedNode)} - onDelete={() => useSchedulesStore.getState().removeNode(selectedNode)} + onDelete={() => + setPendingConfirm({ kind: "node", node: selectedNode })} /> ) : ( useSchedulesStore.getState().renameSchedule(contextMenu.schedule)} + onClick={() => + useSchedulesStore.getState().openRenameDialog(contextMenu.schedule)} > 改名 @@ -732,21 +764,47 @@ export default function SchedulePage({ type="button" variant="destructive" role="menuitem" - onClick={() => useSchedulesStore.getState().removeSchedule(contextMenu.schedule)} + onClick={() => { + useSchedulesStore.getState().setContextMenu(null); + setPendingConfirm({ + kind: "schedule", + schedule: contextMenu.schedule, + }); + }} > 删除调度方案 )} - {contextMenu.kind === "artifact" && null} + {contextMenu.kind === "artifact" && ( + + )} {contextMenu.kind === "node" && ( + + + + + ); +} diff --git a/frontend/app/features/schedules/state/dialogSlice.ts b/frontend/app/features/schedules/state/dialogSlice.ts index 8b899ac..eaecac8 100644 --- a/frontend/app/features/schedules/state/dialogSlice.ts +++ b/frontend/app/features/schedules/state/dialogSlice.ts @@ -1,7 +1,4 @@ -// Dialog slice: create-schedule dialog state + cron preview. -// Pure setters + small mutations that read/write only dialog-owned fields. - -import type { CronPreview } from "../../../services/api"; +import type { CronPreview, Schedule } from "../../../services/api"; import type { StateCreator } from "zustand"; import { handleError, notify, requireApi } from "./helpers"; @@ -12,6 +9,8 @@ import type { SchedulesStore } from "./useSchedulesStore"; export type DialogSliceState = { createDialogOpen: boolean; newScheduleName: string; + renameTarget: Schedule | null; + renameName: string; cronResult: CronPreview | null; }; @@ -21,10 +20,13 @@ export type DialogSliceActions = { // pure setters setCreateDialogOpen: (open: boolean) => void; setNewScheduleName: (name: string) => void; + setRenameName: (name: string) => void; setCronResult: (result: CronPreview | null) => void; // mutations openCreateDialog: () => void; + openRenameDialog: (target: Schedule) => void; + closeRenameDialog: () => void; runCronPreview: () => Promise; }; @@ -35,12 +37,15 @@ export const createDialogSlice: StateCreator set({ createDialogOpen: open }), setNewScheduleName: (name) => set({ newScheduleName: name }), + setRenameName: (name) => set({ renameName: name }), setCronResult: (result) => set({ cronResult: result }), // ---- mutations ---- @@ -54,6 +59,20 @@ export const createDialogSlice: StateCreator { + if (get().busy) return; + set({ + contextMenu: null, + renameTarget: target, + renameName: target.schedule_name, + }); + }, + + closeRenameDialog: () => { + if (get().busy === "rename-schedule") return; + set({ renameTarget: null, renameName: "" }); + }, + runCronPreview: async () => { const api = requireApi(); const { scheduleForm } = get(); @@ -75,4 +94,4 @@ export const createDialogSlice: StateCreator Promise; removeSchedule: (target?: Schedule) => Promise; - renameSchedule: (target: Schedule) => Promise; + renameSchedule: (target: Schedule, scheduleName: string) => Promise; removeArtifact: (artifact: ScheduleArtifact) => Promise; saveSchedule: () => Promise; runNow: () => Promise; @@ -70,7 +70,10 @@ export type ListSliceActions = { positionY: number, ) => Promise; saveNode: (selectedNode: ScheduleNode | null) => Promise; - removeNode: (target?: ScheduleNode) => Promise; + removeNode: ( + target?: ScheduleNode, + options?: { deleteExecutionHistory?: boolean }, + ) => Promise<"ok" | "needs_history_confirm" | "noop">; removeEdge: (target?: ScheduleEdge) => Promise; checkDag: () => Promise; connectTo: (targetNodeId: string) => Promise; @@ -234,7 +237,6 @@ export const createListSlice: StateCreator { + renameSchedule: async (target, scheduleName) => { const api = requireApi(); - if (get().busy) return; - set({ contextMenu: null }); - const scheduleName = window - .prompt("请输入新的调度方案名称", target.schedule_name) - ?.trim(); - if (!scheduleName || scheduleName === target.schedule_name) return; + const trimmed = scheduleName.trim(); + if (get().busy || !trimmed || trimmed === target.schedule_name) return; set({ busy: "rename-schedule" }); try { const updated = await api.updateSchedule(target.schedule_id, { workflow_version: target.workflow_version, - schedule_name: scheduleName, + schedule_name: trimmed, }); set((s: any) => ({ schedules: s.schedules.map((item: Schedule) => @@ -287,6 +285,8 @@ export const createListSlice: StateCreator { + removeNode: async (target, options) => { const api = requireApi(); const state_ = get(); const { schedule, selectedNodeId } = state_; const node = target ?? schedule?.nodes.find((item) => item.node_id === selectedNodeId) ?? null; - if (!schedule || !node || state_.busy) return; + if (!schedule || !node || state_.busy) return "noop"; set({ contextMenu: null }); - if (!window.confirm(`确定删除节点"${node.node_name}"吗?`)) return; set({ busy: "delete-node" }); try { let updated: Schedule; @@ -550,6 +543,9 @@ export const createListSlice: StateCreator = { // Dialog createDialogOpen: false, newScheduleName: "", + renameTarget: null, + renameName: "", cronResult: null, // Runs runs: [],