update:原生确认框替换为自定义弹窗

This commit is contained in:
xiaozhu
2026-09-02 10:10:41 +08:00
committed by tao.chen
parent 7772938148
commit 14c3d5ba59
15 changed files with 452 additions and 83 deletions
@@ -39,7 +39,7 @@ function ConfirmDialog({
}: ConfirmDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="rounded-[11px] border-[#dce4eb] bg-white">
<AlertDialogContent className="rounded-[11px] border border-[#dce4eb] bg-white ring-0">
<AlertDialogHeader>
<AlertDialogTitle className="text-[#1c2d42]">{title}</AlertDialogTitle>
<AlertDialogDescription className="text-[#56687c]">
+39 -26
View File
@@ -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<HTMLInputElement>) => {
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}
/>
<ScriptsPendingConfirmDialog
pending={pendingConfirm}
onOpenChange={(open) => {
if (!open) setPendingConfirm(null);
}}
onConfirm={() => void handleConfirm()}
/>
<DataResourcePreviewDialog
target={resourcePreview}
onClose={() => setResourcePreview(null)}
@@ -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<void>;
};
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 (
<ConfirmDialog
open={pending !== null}
onOpenChange={onOpenChange}
title={copy?.title ?? ""}
description={copy?.description ?? ""}
confirmLabel={copy?.confirmLabel}
destructive={copy?.destructive}
onConfirm={onConfirm}
/>
);
}
@@ -151,7 +151,10 @@ export function TreeContextMenu({
className={dangerItemClass}
type="button"
role="menuitem"
onClick={() => onRemoveScript(contextMenu.script!)}
onClick={() => {
onRemoveScript(contextMenu.script!);
onClose();
}}
>
<X size={16} />
@@ -204,7 +207,10 @@ export function TreeContextMenu({
className={dangerItemClass}
type="button"
role="menuitem"
onClick={() => onRemoveDirectory(contextMenu.path)}
onClick={() => {
onRemoveDirectory(contextMenu.path);
onClose();
}}
>
<X size={16} />
@@ -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} 的编辑锁已释放`);
@@ -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,
);
@@ -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) => {
@@ -92,7 +92,8 @@ export type SelectionSliceActions = {
closeTab: (
id: string,
event?: { stopPropagation: () => void },
) => Promise<void>;
options?: { discardDirty?: boolean },
) => Promise<boolean>;
switchTab: (id: string) => void;
openPublishDialog: (script: ScriptItem) => void;
};
@@ -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<void>;
deleteDataResource: (resourceId: string) => Promise<void>;
deleteDirectory: (path: string) => Promise<void>;
closeTab: (
id: string,
event?: { stopPropagation: () => void },
options?: { discardDirty?: boolean },
) => Promise<boolean>;
};
export function useScriptsPendingConfirm(
scripts: ScriptItem[],
actions: DeleteActions,
) {
const [pendingConfirm, setPendingConfirm] =
useState<ScriptsPendingConfirm | null>(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,
};
}
@@ -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<HTMLDivElement | null>(null);
const [pendingConfirm, setPendingConfirm] =
useState<SchedulePendingConfirm | null>(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 (
<section className="relative flex min-h-0 flex-1 flex-col gap-[10px] overflow-hidden p-[10px]">
<header className="flex min-h-[56px] items-center justify-between rounded-[7px] border border-line bg-white px-[14px] shadow-md">
@@ -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 });
}}
>
</Button>
@@ -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 })}
/>
) : (
<ScheduleInspector
@@ -722,7 +753,8 @@ export default function SchedulePage({
type="button"
variant="ghost"
role="menuitem"
onClick={() => useSchedulesStore.getState().renameSchedule(contextMenu.schedule)}
onClick={() =>
useSchedulesStore.getState().openRenameDialog(contextMenu.schedule)}
>
<Settings size={14} />
@@ -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,
});
}}
>
<X size={14} />
</Button>
</>
)}
{contextMenu.kind === "artifact" && null}
{contextMenu.kind === "artifact" && (
<Button
className="w-full justify-start gap-[9px] rounded-[5px] p-[9px_10px] text-[12px] text-[#c23b3b] hover:bg-[#fff0f0]"
type="button"
variant="destructive"
role="menuitem"
onClick={() => {
useSchedulesStore.getState().setContextMenu(null);
setPendingConfirm({
kind: "artifact",
artifact: contextMenu.artifact,
});
}}
>
<X size={14} />
</Button>
)}
{contextMenu.kind === "node" && (
<Button
className="w-full justify-start gap-[9px] rounded-[5px] p-[9px_10px] text-[12px] text-[#c23b3b] hover:bg-[#fff0f0]"
type="button"
variant="destructive"
role="menuitem"
onClick={() => useSchedulesStore.getState().removeNode(contextMenu.node)}
onClick={() => {
useSchedulesStore.getState().setContextMenu(null);
setPendingConfirm({ kind: "node", node: contextMenu.node });
}}
>
<X size={14} />
@@ -816,6 +874,16 @@ export default function SchedulePage({
</form>
</AppFormDialog>
<ScheduleRenameDialog />
<SchedulePendingConfirmDialog
pending={pendingConfirm}
onOpenChange={(open) => {
if (!open) setPendingConfirm(null);
}}
onConfirm={() => void handleConfirm()}
/>
{busy && (
<div
className="absolute bottom-[22px] right-[22px] z-[20] flex min-h-[38px] items-center gap-2 rounded-[7px] border border-[#c9dcee] bg-white px-[13px] text-[11px] text-[#4e657d] shadow-lg"
@@ -0,0 +1,67 @@
import { ConfirmDialog } from "~/components/common/ConfirmDialog";
import type {
Schedule,
ScheduleArtifact,
ScheduleNode,
} from "~/services/api";
export type SchedulePendingConfirm =
| { kind: "schedule"; schedule: Schedule }
| { kind: "artifact"; artifact: ScheduleArtifact }
| { kind: "node"; node: ScheduleNode }
| { kind: "node-history"; node: ScheduleNode };
type SchedulePendingConfirmDialogProps = {
pending: SchedulePendingConfirm | null;
onOpenChange: (open: boolean) => void;
onConfirm: () => void | Promise<void>;
};
function copyFor(pending: SchedulePendingConfirm) {
switch (pending.kind) {
case "schedule":
return {
title: "确定删除调度?",
description: `确定删除调度"${pending.schedule.schedule_name}"吗?`,
confirmLabel: "删除",
};
case "artifact":
return {
title: "确定移出调度列表?",
description:
`确定将"${pending.artifact.script_name} ${pending.artifact.version_label}"移出调度列表吗?稳定版本本身和历史运行记录不会被删除。`,
confirmLabel: "移出",
};
case "node":
return {
title: "确定删除节点?",
description: `确定删除节点"${pending.node.node_name}"吗?`,
confirmLabel: "删除",
};
case "node-history":
return {
title: "一并删除运行日志?",
description: "该节点有运行日志,是否一并删除?",
confirmLabel: "删除",
};
}
}
export function SchedulePendingConfirmDialog({
pending,
onOpenChange,
onConfirm,
}: SchedulePendingConfirmDialogProps) {
const copy = pending ? copyFor(pending) : null;
return (
<ConfirmDialog
open={pending !== null}
onOpenChange={onOpenChange}
title={copy?.title ?? ""}
description={copy?.description ?? ""}
confirmLabel={copy?.confirmLabel}
destructive
onConfirm={onConfirm}
/>
);
}
@@ -0,0 +1,87 @@
import { type FormEvent } from "react";
import { Check, Loader2Icon } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
AppFormDialog,
dialogPrimaryButtonClass,
dialogSecondaryButtonClass,
} from "~/components/common/AppFormDialog";
import {
formFieldClass,
formInputClass,
modalFormClass,
} from "~/features/platform/modalUi";
import { useSchedulesStore } from "./state/useSchedulesStore";
export function ScheduleRenameDialog() {
const renameTarget = useSchedulesStore((s) => s.renameTarget);
const renameName = useSchedulesStore((s) => s.renameName);
const busy = useSchedulesStore((s) => s.busy);
const setRenameName = useSchedulesStore((s) => s.setRenameName);
const closeRenameDialog = useSchedulesStore((s) => s.closeRenameDialog);
const renaming = busy === "rename-schedule";
const trimmed = renameName.trim();
const unchanged = Boolean(
renameTarget && trimmed === renameTarget.schedule_name,
);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!renameTarget || !trimmed || unchanged) return;
void useSchedulesStore.getState().renameSchedule(renameTarget, trimmed);
};
return (
<AppFormDialog
open={renameTarget !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) closeRenameDialog();
}}
eyebrow="SCHEDULE"
title="改名调度方案"
titleId="rename-schedule-title"
>
<form className={modalFormClass} onSubmit={handleSubmit}>
<label className={formFieldClass}>
<span></span>
<input
className={formInputClass}
autoFocus
maxLength={255}
placeholder="请输入新的调度方案名称"
value={renameName}
onChange={(event) => setRenameName(event.target.value)}
onFocus={(event) => event.currentTarget.select()}
/>
</label>
<div className="mt-[3px] flex shrink-0 justify-end gap-2 border-t border-line bg-white -mx-[22px] px-[22px] py-3.5">
<Button
type="button"
variant="outline"
size="sm"
disabled={renaming}
onClick={closeRenameDialog}
className={dialogSecondaryButtonClass}
>
</Button>
<Button
type="submit"
variant="default"
size="sm"
disabled={renaming || !trimmed || unchanged}
className={dialogPrimaryButtonClass}
>
{renaming
? <Loader2Icon className="size-4 animate-spin" />
: <Check size={15} />}
{renaming ? "正在保存…" : "保存"}
</Button>
</div>
</form>
</AppFormDialog>
);
}
@@ -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<void>;
};
@@ -35,12 +37,15 @@ export const createDialogSlice: StateCreator<SchedulesStore, [], [], DialogSlice
// ---- initial state ----
createDialogOpen: false,
newScheduleName: "",
renameTarget: null,
renameName: "",
cronResult: null,
// ---- pure setters ----
setCreateDialogOpen: (open) => 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<SchedulesStore, [], [], DialogSlice
}));
},
openRenameDialog: (target) => {
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<SchedulesStore, [], [], DialogSlice
set({ busy: null });
}
},
});
});
@@ -60,7 +60,7 @@ export type ListSliceActions = {
// mutations
addSchedule: (name: string) => Promise<void>;
removeSchedule: (target?: Schedule) => Promise<void>;
renameSchedule: (target: Schedule) => Promise<void>;
renameSchedule: (target: Schedule, scheduleName: string) => Promise<void>;
removeArtifact: (artifact: ScheduleArtifact) => Promise<void>;
saveSchedule: () => Promise<void>;
runNow: () => Promise<void>;
@@ -70,7 +70,10 @@ export type ListSliceActions = {
positionY: number,
) => Promise<void>;
saveNode: (selectedNode: ScheduleNode | null) => Promise<void>;
removeNode: (target?: ScheduleNode) => Promise<void>;
removeNode: (
target?: ScheduleNode,
options?: { deleteExecutionHistory?: boolean },
) => Promise<"ok" | "needs_history_confirm" | "noop">;
removeEdge: (target?: ScheduleEdge) => Promise<void>;
checkDag: () => Promise<void>;
connectTo: (targetNodeId: string) => Promise<void>;
@@ -234,7 +237,6 @@ export const createListSlice: StateCreator<SchedulesStore, [], [], ListSliceStat
const target_ = target ?? get().schedule;
if (!target_ || get().busy) return;
set({ contextMenu: null });
if (!window.confirm(`确定删除调度"${target_.schedule_name}"吗?`)) return;
set({ busy: "delete-schedule" });
try {
await api.deleteSchedule(target_.schedule_id, target_.workflow_version);
@@ -263,19 +265,15 @@ export const createListSlice: StateCreator<SchedulesStore, [], [], ListSliceStat
}
},
renameSchedule: async (target) => {
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<SchedulesStore, [], [], ListSliceStat
s.schedule?.schedule_id === updated.schedule_id
? updated
: s.schedule,
renameTarget: null,
renameName: "",
}));
notify({ tone: "success", message: "调度方案已改名" });
} catch (error) {
@@ -301,12 +301,6 @@ export const createListSlice: StateCreator<SchedulesStore, [], [], ListSliceStat
const api = requireApi();
if (get().busy) return;
set({ contextMenu: null });
if (
!window.confirm(
`确定将"${artifact.script_name} ${artifact.version_label}"移出调度列表吗?\n`
+ "稳定版本本身和历史运行记录不会被删除。",
)
) return;
set({ busy: "delete-artifact" });
try {
await api.hideScheduleArtifact(artifact.versions_id);
@@ -531,16 +525,15 @@ export const createListSlice: StateCreator<SchedulesStore, [], [], ListSliceStat
}
},
removeNode: async (target) => {
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<SchedulesStore, [], [], ListSliceStat
schedule.schedule_id,
node.node_id,
schedule.workflow_version,
options?.deleteExecutionHistory
? { delete_execution_history: true }
: undefined,
);
} catch (error) {
const requiresHistoryConfirmation =
@@ -557,7 +553,10 @@ export const createListSlice: StateCreator<SchedulesStore, [], [], ListSliceStat
&& error.status === 409
&& error.code === "node_execution_history_exists";
if (!requiresHistoryConfirmation) throw error;
if (!window.confirm("该节点有运行日志,是否一并删除?")) return;
// 交给 UI 弹二次确认;已确认则带 delete_execution_history 重试。
if (!options?.deleteExecutionHistory) {
return "needs_history_confirm";
}
updated = await api.deleteScheduleNode(
schedule.schedule_id,
node.node_id,
@@ -568,9 +567,11 @@ export const createListSlice: StateCreator<SchedulesStore, [], [], ListSliceStat
applyServerUpdatedSchedule(set, updated);
if (selectedNodeId === node.node_id) set({ selectedNodeId: null });
notify({ tone: "success", message: "节点及其运行日志已删除" });
return "ok";
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "删除节点失败");
return "noop";
} finally {
set({ busy: null });
}
@@ -64,6 +64,8 @@ const INITIAL: Omit<SchedulesState, "schedule" | "busy"> = {
// Dialog
createDialogOpen: false,
newScheduleName: "",
renameTarget: null,
renameName: "",
cronResult: null,
// Runs
runs: [],