refactor: useSchedulesStore.ts

This commit is contained in:
tao.chen
2026-08-25 10:48:29 +08:00
parent 80c92f7fd4
commit 2ed8d7b2cc
16 changed files with 1581 additions and 1259 deletions
@@ -1,7 +1,7 @@
import { type ScheduleNode } from "../../services/api";
import { BookText, FileCode2 } from "lucide-react";
import { shortHash } from "./utils";
import { PYTHON_VERSION_OPTIONS, type NodeForm } from "./state/schedulesStore";
import { PYTHON_VERSION_OPTIONS, type NodeForm } from "./state/types";
export function NodeInspector({
node,
@@ -6,7 +6,7 @@ import {
import { useApi } from "../../context/AuthContext";
import { RefreshCw, X } from "lucide-react";
import { useSchedulesStore } from "./state/schedulesStore";
import { useSchedulesStore } from "./state/useSchedulesStore";
import {
formatDuration,
formatSize,
@@ -39,8 +39,8 @@ import { useContextMenuDismiss } from "./hooks/useContextMenuDismiss";
import {
EMPTY_NODE_FORM,
type ScheduleContextMenu,
} from "./state/schedulesStore";
import { useSchedulesStore } from "./state/schedulesStore";
} from "./state/types";
import { useSchedulesStore } from "./state/useSchedulesStore";
import { edgePath, nodeToForm, scheduleToForm } from "./utils";
type Notice = {
@@ -1,5 +1,5 @@
import { useRef, type PointerEvent as ReactPointerEvent } from "react";
import { useSchedulesStore } from "../state/schedulesStore";
import { useSchedulesStore } from "../state/useSchedulesStore";
import type { ScheduleNode } from "../../../services/api";
type DragState = {
@@ -1,5 +1,5 @@
import { useEffect } from "react";
import { useSchedulesStore } from "../state/schedulesStore";
import { useSchedulesStore } from "../state/useSchedulesStore";
/**
* 监听全局 pointerdown / blur / resize / scroll / Escape,关闭当前打开的右键菜单。
@@ -0,0 +1,108 @@
// Canvas slice: selection, edit forms, context menu, left-panel filter inputs,
// position-draft reactive mirror.
import type { StateCreator } from "zustand";
import type { NodePositionDraft, ScheduleContextMenu, ScheduleForm, NodeForm } from "./types";
import { positionDrafts, clearPositionDrafts } from "./helpers";
import type { SchedulesStore } from "./useSchedulesStore";
// ---- State ----
export type CanvasSliceState = {
selectedNodeId: string | null;
selectedEdgeId: string | null;
linkSourceId: string | null;
scheduleKeyword: string;
artifactKeyword: string;
scheduleForm: ScheduleForm;
nodeForm: NodeForm;
contextMenu: ScheduleContextMenu | null;
positionDraftCount: number;
};
// ---- Actions ----
export type CanvasSliceActions = {
// pure setters
setSelectedNodeId: (id: string | null) => void;
setSelectedEdgeId: (id: string | null) => void;
setLinkSourceId: (id: string | null) => void;
setScheduleKeyword: (k: string) => void;
setArtifactKeyword: (k: string) => void;
setScheduleForm: (form: ScheduleForm) => void;
setNodeForm: (form: NodeForm) => void;
setContextMenu: (menu: ScheduleContextMenu | null) => void;
closeContextMenu: () => void;
// drag helpers
setPositionDraft: (nodeId: string, draft: NodePositionDraft) => void;
setPositionDraftCount: (count: number) => void;
getPositionDrafts: () => Map<string, NodePositionDraft>;
clearAllPositionDrafts: () => void;
prunePositionDrafts: (validNodeIds: Set<string>) => void;
};
// `SchedulesStore` is the combined state+actions of all slices, so `get()`
// returns a fully typed snapshot — no `any` propagation.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const createCanvasSlice: StateCreator<SchedulesStore, [], [], CanvasSliceState & CanvasSliceActions> = (set, get) => ({
// ---- initial state ----
selectedNodeId: null,
selectedEdgeId: null,
linkSourceId: null,
scheduleKeyword: "",
artifactKeyword: "",
scheduleForm: {
scheduleName: "",
description: "",
triggerType: "manual",
cronExpression: "",
timezone: "Asia/Shanghai",
enabled: false,
maxConcurrency: "1",
failurePolicy: "stop",
},
nodeForm: {
nodeName: "",
timeoutSeconds: "600",
retryCount: "0",
retryIntervalSec: "5",
argumentsJson: "{}",
envRefsJson: "{}",
pythonVersion: "3.12",
},
contextMenu: null,
positionDraftCount: 0,
// ---- pure setters ----
setSelectedNodeId: (id) => set({ selectedNodeId: id }),
setSelectedEdgeId: (id) => set({ selectedEdgeId: id }),
setLinkSourceId: (id) => set({ linkSourceId: id }),
setScheduleKeyword: (k) => set({ scheduleKeyword: k }),
setArtifactKeyword: (k) => set({ artifactKeyword: k }),
setScheduleForm: (form) => set({ scheduleForm: form }),
setNodeForm: (form) => set({ nodeForm: form }),
setContextMenu: (menu) => set({ contextMenu: menu }),
closeContextMenu: () => set({ contextMenu: null }),
// ---- drag helpers ----
setPositionDraft: (nodeId, draft) => {
positionDrafts.set(nodeId, draft);
set({ positionDraftCount: positionDrafts.size });
},
setPositionDraftCount: (count) => set({ positionDraftCount: count }),
getPositionDrafts: () => positionDrafts,
clearAllPositionDrafts: () => {
clearPositionDrafts();
set({ positionDraftCount: 0 });
},
prunePositionDrafts: (validNodeIds) => {
for (const nodeId of [...positionDrafts.keys()]) {
if (!validNodeIds.has(nodeId)) positionDrafts.delete(nodeId);
}
set({ positionDraftCount: positionDrafts.size });
},
});
@@ -0,0 +1,78 @@
// 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 { StateCreator } from "zustand";
import { handleError, notify, requireApi } from "./helpers";
import type { SchedulesStore } from "./useSchedulesStore";
// ---- State ----
export type DialogSliceState = {
createDialogOpen: boolean;
newScheduleName: string;
cronResult: CronPreview | null;
};
// ---- Actions ----
export type DialogSliceActions = {
// pure setters
setCreateDialogOpen: (open: boolean) => void;
setNewScheduleName: (name: string) => void;
setCronResult: (result: CronPreview | null) => void;
// mutations
openCreateDialog: () => void;
runCronPreview: () => Promise<void>;
};
// `SchedulesStore` is the combined state+actions of all slices, so `get()`
// returns a fully typed snapshot — no `any` propagation.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const createDialogSlice: StateCreator<SchedulesStore, [], [], DialogSliceState & DialogSliceActions> = (set, get) => ({
// ---- initial state ----
createDialogOpen: false,
newScheduleName: "",
cronResult: null,
// ---- pure setters ----
setCreateDialogOpen: (open) => set({ createDialogOpen: open }),
setNewScheduleName: (name) => set({ newScheduleName: name }),
setCronResult: (result) => set({ cronResult: result }),
// ---- mutations ----
openCreateDialog: () => {
if (get().busy) return;
set((s: any) => ({
contextMenu: null,
newScheduleName: `新建调度 ${s.schedules.length + 1}`,
createDialogOpen: true,
}));
},
runCronPreview: async () => {
const api = requireApi();
const { scheduleForm } = get();
if (scheduleForm.triggerType !== "cron") return;
if (get().busy) return;
set({ busy: "cron-preview" });
try {
const result = await api.previewCron({
cron_expression: scheduleForm.cronExpression.trim(),
timezone: scheduleForm.timezone.trim(),
count: 5,
});
set({ cronResult: result });
notify({ tone: "success", message: "Cron 表达式校验通过" });
} catch (error) {
set({ cronResult: null });
await handleError(get, error, "Cron 预览失败");
} finally {
set({ busy: null });
}
},
});
@@ -0,0 +1,204 @@
// ---- Module-level non-reactive holders ----
import { ApiRequestError, type Schedule, type ScheduleNode, type WorkspaceBoundApi } from "../../../services/api";
import { useScriptWorkspaceStore } from "../../platform/state/scriptWorkspaceStore";
import { useUiStore } from "../../platform/state/uiStore";
import type { NodeForm, NodePositionDraft, Notice, ScheduleForm } from "./types";
import type { SchedulesStore } from "./useSchedulesStore";
// ---- API binding (was module-level in the monolithic store) ----
let _api: WorkspaceBoundApi | null = null;
export const bindSchedulesApi = (api: WorkspaceBoundApi | null) => {
_api = api;
};
export function requireApi(): WorkspaceBoundApi {
if (!_api) throw new Error("schedules API 未绑定");
return _api;
}
export function notify(notice: Notice): void {
useUiStore.getState().pushToast(notice);
}
export function setApiOnline(online: boolean): void {
useScriptWorkspaceStore.getState().setApiOnline(online);
}
// ---- Drag-draft singleton (canvasSlice logically owns it but several slices need access) ----
export const positionDrafts = new Map<string, NodePositionDraft>();
export function applyPositionDrafts(schedule: Schedule): Schedule {
if (positionDrafts.size === 0) return schedule;
return {
...schedule,
nodes: schedule.nodes.map((node) => {
const draft = positionDrafts.get(node.node_id);
return draft ? { ...node, ...draft } : node;
}),
};
}
export function clearPositionDrafts(): void {
positionDrafts.clear();
}
// ---- Pure helpers (stateless) ----
export function scheduleToForm(schedule: Schedule): ScheduleForm {
return {
scheduleName: schedule.schedule_name,
description: schedule.description ?? "",
triggerType: schedule.trigger_type,
cronExpression: schedule.cron_expression ?? "",
timezone: schedule.timezone,
enabled: schedule.enabled,
maxConcurrency: String(schedule.max_concurrency),
failurePolicy: schedule.failure_policy,
};
}
export function nodeToForm(node: ScheduleNode): NodeForm {
return {
nodeName: node.node_name,
timeoutSeconds: String(node.timeout_seconds),
retryCount: String(node.retry_count),
retryIntervalSec: String(node.retry_interval_sec),
argumentsJson: JSON.stringify(node.arguments_json ?? {}, null, 2),
envRefsJson: JSON.stringify(node.env_refs_json ?? {}, null, 2),
pythonVersion: (node.python_version ?? "3.12") as NodeForm["pythonVersion"],
};
}
export function parseObject(text: string, label: string): Record<string, unknown> {
const trimmed = text.trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed);
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`${label} 必须是 JSON 对象`);
}
return parsed as Record<string, unknown>;
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`${label} JSON 格式不正确`);
}
throw error;
}
}
/**
* 顶层 pure helper (artifactNodeKey needs schedule, so it's defined outside the store closure).
* Computes a unique `node_key` for an artifact within the given schedule,
* mirroring the heuristic that was originally at the bottom of the monolithic store.
*/
export function artifactNodeKey(
artifact: { script_name: string; versions_id: string },
schedule: Schedule,
): string {
const ascii = artifact.script_name
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 24);
const base = ascii || `node_${artifact.versions_id.slice(-6).toLowerCase()}`;
const existing = new Set(schedule.nodes.map((item) => item.node_key));
if (!existing.has(base)) return base;
for (let i = 2; i < 1000; i++) {
const candidate = `${base}_${i}`;
if (!existing.has(candidate)) return candidate;
}
return `${base}_${Date.now().toString(36)}`;
}
// ---- Cross-slice store helpers ----
// These were inner closures of the monolithic store body. They take set/get as
// arguments so any slice can call them without circular state-type imports.
type ListFieldSlice = {
schedule: Schedule | null;
schedules: Schedule[];
};
/**
* Generic error handler with 412 (concurrent update) awareness.
* Walks the store via the supplied `get()` so it works from any slice.
*/
export async function handleError(
get: () => SchedulesStore,
error: unknown,
fallback: string,
): Promise<void> {
if (error instanceof ApiRequestError && error.status === 412) {
const currentId = get().schedule?.schedule_id;
if (currentId) {
get().refreshLists(currentId).catch(() => undefined);
}
notify({ tone: "error", message: "调度已被其他操作更新,已重新加载最新版本" });
return;
}
notify({
tone: "error",
message: error instanceof Error ? error.message : fallback,
});
}
/**
* Overlay pending drag drafts onto a server-updated schedule and write
* `schedule` + `schedules` (LIST) + `positionDraftCount` (CANVAS).
*/
export function applyServerUpdatedSchedule(
set: (partial: Partial<SchedulesStore> | ((s: SchedulesStore) => Partial<SchedulesStore>)) => void,
serverUpdated: Schedule,
): void {
const validNodeIds = new Set(serverUpdated.nodes.map((node) => node.node_id));
for (const nodeId of [...positionDrafts.keys()]) {
if (!validNodeIds.has(nodeId)) positionDrafts.delete(nodeId);
}
const updated = applyPositionDrafts(serverUpdated);
set((state) => ({
schedule: updated,
schedules: (() => {
const summary = { ...updated, nodes: [], edges: [] };
const index = state.schedules.findIndex(
(item) => item.schedule_id === updated.schedule_id,
);
if (index < 0) return [summary, ...state.schedules];
return state.schedules.map((item) =>
item.schedule_id === updated.schedule_id ? summary : item,
);
})(),
positionDraftCount: positionDrafts.size,
}));
}
/**
* Generic mutation wrapper: busy + try/catch + 412 + positionDraft cleanup + schedules list update.
*/
export async function withMutation(
set: (partial: Partial<SchedulesStore> | ((s: SchedulesStore) => Partial<SchedulesStore>)) => void,
get: () => SchedulesStore,
label: string,
action: () => Promise<Schedule>,
successMessage: string,
): Promise<Schedule | null> {
const api = requireApi();
if (get().busy) return null;
set({ busy: label });
try {
const serverUpdated = await action();
applyServerUpdatedSchedule(set, serverUpdated);
notify({ tone: "success", message: successMessage });
return serverUpdated;
} catch (error) {
await handleError(get, error, `${successMessage}失败`);
return null;
} finally {
set({ busy: null });
}
}
@@ -0,0 +1,659 @@
// List slice: schedule list, artifact list, current schedule, loading/busy flags,
// and most mutation/loader actions. Filter inputs (scheduleKeyword/artifactKeyword)
// live in canvasSlice since they bind to the left-panel UI. Cross-slice actions
// live here when their primary write is to LIST fields; they reach into
// CANVAS / DIALOG / RUNS via `get()`.
import { ApiRequestError, type Schedule, type ScheduleArtifact, type ScheduleEdge, type ScheduleNode } from "../../../services/api";
import type { StateCreator } from "zustand";
import {
applyPositionDrafts,
applyServerUpdatedSchedule,
artifactNodeKey,
notify,
parseObject,
requireApi,
setApiOnline,
scheduleToForm,
withMutation,
} from "./helpers";
import {
PYTHON_VERSION_OPTIONS,
type NodeForm,
type ScheduleForm,
} from "./types";
import type { SchedulesStore } from "./useSchedulesStore";
// ---- State ----
export type ListSliceState = {
schedules: Schedule[];
artifacts: ScheduleArtifact[];
schedule: Schedule | null;
loading: boolean;
busy: string | null;
};
// ---- Actions ----
export type ListSliceActions = {
// pure setters
setSchedule: (
value: Schedule | null | ((current: Schedule | null) => Schedule | null),
) => void;
setSchedules: (
value: Schedule[] | ((current: Schedule[]) => Schedule[]),
) => void;
setArtifacts: (
value: ScheduleArtifact[]
| ((current: ScheduleArtifact[]) => ScheduleArtifact[]),
) => void;
setLoading: (loading: boolean) => void;
setBusy: (busy: string | null) => void;
// data loaders
loadInitial: () => Promise<void>;
refreshLists: (preferredScheduleId?: string | null) => Promise<void>;
chooseSchedule: (scheduleId: string) => Promise<void>;
// mutations
addSchedule: (name: string) => Promise<void>;
removeSchedule: (target?: Schedule) => Promise<void>;
renameSchedule: (target: Schedule) => Promise<void>;
removeArtifact: (artifact: ScheduleArtifact) => Promise<void>;
saveSchedule: () => Promise<void>;
runNow: () => Promise<void>;
addArtifactAt: (
artifact: ScheduleArtifact,
positionX: number,
positionY: number,
) => Promise<void>;
saveNode: (selectedNode: ScheduleNode | null) => Promise<void>;
removeNode: (target?: ScheduleNode) => Promise<void>;
removeEdge: (target?: ScheduleEdge) => Promise<void>;
checkDag: () => Promise<void>;
connectTo: (targetNodeId: string) => Promise<void>;
};
// `SchedulesStore` is the combined state+actions of all slices, so `get()`
// returns a fully typed snapshot — no `any` propagation.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const createListSlice: StateCreator<SchedulesStore, [], [], ListSliceState & ListSliceActions> = (set, get) => {
// ---- initial state ----
const state: ListSliceState = {
schedules: [],
artifacts: [],
schedule: null,
loading: true,
busy: null,
};
return {
...state,
// ---- pure setters ----
setSchedule: (value) =>
set((s: any) => ({
schedule:
typeof value === "function"
? (value as (current: Schedule | null) => Schedule | null)(s.schedule)
: value,
})),
setSchedules: (value) =>
set((s: any) => ({
schedules:
typeof value === "function"
? (value as (current: Schedule[]) => Schedule[])(s.schedules)
: value,
})),
setArtifacts: (value) =>
set((s: any) => ({
artifacts:
typeof value === "function"
? (value as (current: ScheduleArtifact[]) => ScheduleArtifact[])(
s.artifacts,
)
: value,
})),
setLoading: (loading) => set({ loading }),
setBusy: (busy) => set({ busy }),
// ---- data loaders ----
loadInitial: async () => {
const api = requireApi();
let cancelled = false;
set({ loading: true });
try {
const [scheduleItems, artifactItems] = await Promise.all([
api.listSchedules(),
api.listScheduleArtifacts(),
]);
if (cancelled) return;
set({ schedules: scheduleItems, artifacts: artifactItems });
if (scheduleItems[0]) {
const detail = await api.getSchedule(scheduleItems[0].schedule_id);
if (!cancelled) {
set({ schedule: applyPositionDrafts(detail) });
}
}
setApiOnline(true);
} catch (error) {
if (cancelled) return;
setApiOnline(false);
notify({
tone: "error",
message: error instanceof Error ? error.message : "调度数据加载失败",
});
} finally {
if (!cancelled) set({ loading: false });
}
},
refreshLists: async (preferredScheduleId) => {
const api = requireApi();
const [scheduleItems, artifactItems] = await Promise.all([
api.listSchedules(),
api.listScheduleArtifacts(),
]);
set({ schedules: scheduleItems, artifacts: artifactItems });
const targetId =
preferredScheduleId
?? get().schedule?.schedule_id
?? scheduleItems[0]?.schedule_id
?? null;
if (!targetId) {
set({ schedule: null });
return;
}
const detail = await api.getSchedule(targetId);
set({ schedule: applyPositionDrafts(detail) });
},
chooseSchedule: async (scheduleId) => {
const api = requireApi();
const current = get().schedule;
if (scheduleId === current?.schedule_id || get().busy) return;
set({ busy: "load-schedule" });
get().clearAllPositionDrafts();
set({
selectedNodeId: null,
selectedEdgeId: null,
linkSourceId: null,
cronResult: null,
runs: [],
runsLoading: false,
});
try {
set({ schedule: await api.getSchedule(scheduleId) });
setApiOnline(true);
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "调度详情加载失败");
} finally {
set({ busy: null });
}
},
// ---- mutations ----
addSchedule: async (name) => {
const api = requireApi();
const scheduleName = name.trim();
if (get().busy || !scheduleName) return;
set({ busy: "create-schedule" });
try {
const created = await api.createSchedule({
schedule_name: scheduleName,
description: "在画布中拖入稳定版本并配置执行顺序",
trigger_type: "manual",
timezone: "Asia/Shanghai",
enabled: false,
});
set((s: any) => ({
schedules: [created, ...s.schedules],
schedule: created,
createDialogOpen: false,
newScheduleName: "",
}));
get().clearAllPositionDrafts();
set({ selectedNodeId: null, selectedEdgeId: null });
notify({ tone: "success", message: "调度方案已创建" });
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "创建调度失败");
} finally {
set({ busy: null });
}
},
removeSchedule: async (target) => {
const api = requireApi();
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);
const remaining = get().schedules.filter(
(item) => item.schedule_id !== target_.schedule_id,
);
set({ schedules: remaining });
if (get().schedule?.schedule_id === target_.schedule_id) {
get().clearAllPositionDrafts();
set({
schedule: null,
selectedNodeId: null,
selectedEdgeId: null,
});
if (remaining[0]) {
const detail = await api.getSchedule(remaining[0].schedule_id);
set({ schedule: detail });
}
}
notify({ tone: "success", message: "调度方案已删除" });
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "删除调度失败");
} finally {
set({ busy: null });
}
},
renameSchedule: async (target) => {
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;
set({ busy: "rename-schedule" });
try {
const updated = await api.updateSchedule(target.schedule_id, {
workflow_version: target.workflow_version,
schedule_name: scheduleName,
});
set((s: any) => ({
schedules: s.schedules.map((item: Schedule) =>
item.schedule_id === updated.schedule_id
? { ...updated, nodes: [], edges: [] }
: item
),
schedule:
s.schedule?.schedule_id === updated.schedule_id
? updated
: s.schedule,
}));
notify({ tone: "success", message: "调度方案已改名" });
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "调度方案改名失败");
} finally {
set({ busy: null });
}
},
removeArtifact: async (artifact) => {
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);
set((s: any) => ({
artifacts: s.artifacts.filter(
(item: ScheduleArtifact) => item.versions_id !== artifact.versions_id,
),
}));
notify({
tone: "success",
message: "已移出调度列表,稳定版本和历史记录保持不变",
});
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "移出调度列表失败");
} finally {
set({ busy: null });
}
},
saveSchedule: async () => {
const api = requireApi();
const state_ = get();
const { schedule, scheduleForm } = state_;
if (!schedule || state_.busy) return;
const scheduleFormTyped = scheduleForm as ScheduleForm;
const maxConcurrency = Number(scheduleFormTyped.maxConcurrency);
if (!scheduleFormTyped.scheduleName.trim()) {
notify({ tone: "error", message: "调度名称不能为空" });
return;
}
if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) {
notify({ tone: "error", message: "最大并发数必须是正整数" });
return;
}
set({ busy: "save-schedule" });
let updated: Schedule = schedule;
try {
const { positionDrafts } = await import("./helpers");
for (const [nodeId, position] of positionDrafts.entries()) {
updated = await api.updateScheduleNode(updated.schedule_id, nodeId, {
workflow_version: updated.workflow_version,
position_x: position.position_x,
position_y: position.position_y,
});
}
updated = await api.updateSchedule(updated.schedule_id, {
workflow_version: updated.workflow_version,
schedule_name: scheduleFormTyped.scheduleName.trim(),
description: scheduleFormTyped.description.trim() || null,
trigger_type: scheduleFormTyped.triggerType,
cron_expression:
scheduleFormTyped.triggerType === "cron"
? scheduleFormTyped.cronExpression.trim()
: null,
timezone: scheduleFormTyped.timezone.trim(),
enabled: scheduleFormTyped.enabled,
max_concurrency: maxConcurrency,
failure_policy: scheduleFormTyped.failurePolicy,
});
get().clearAllPositionDrafts();
set((s: any) => ({
schedule: updated,
schedules: s.schedules.map((item: Schedule) =>
item.schedule_id === updated.schedule_id
? { ...updated, nodes: [], edges: [] }
: item
),
}));
notify({ tone: "success", message: "调度配置已保存" });
} catch (error) {
const localDraft = applyPositionDrafts(updated);
set({ schedule: localDraft });
const { handleError } = await import("./helpers");
await handleError(get, error, "调度配置保存失败");
} finally {
set({ busy: null });
}
},
runNow: async () => {
const api = requireApi();
const state_ = get();
const { schedule, positionDraftCount } = state_;
if (!schedule || state_.busy) return;
if (positionDraftCount > 0) {
notify({
tone: "info",
message: "还有节点位置未保存,请先点击'保存配置'再运行",
});
return;
}
if (!schedule.dag_validation.valid || schedule.nodes.length === 0) {
notify({
tone: "error",
message: "当前调度必须包含有效的非空 DAG 才能运行",
});
return;
}
set({ busy: "run-now" });
try {
const created = await api.runScheduleNow(schedule.schedule_id);
set((s: any) => ({
runs: [
created,
...s.runs.filter((item: { run_id: string }) => item.run_id !== created.run_id),
].slice(0, 20),
schedule:
s.schedule
? { ...s.schedule, last_run_at: created.queued_at }
: s.schedule,
schedules: s.schedules.map((item: Schedule) =>
item.schedule_id === schedule.schedule_id
? { ...item, last_run_at: created.queued_at }
: item
),
}));
notify({
tone: "success",
message: `运行 ${created.run_id.slice(-8)} 已进入队列`,
});
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "立即运行失败");
} finally {
set({ busy: null });
}
},
addArtifactAt: async (artifact, positionX, positionY) => {
const state_ = get();
const { schedule } = state_;
if (!schedule) {
notify({ tone: "info", message: "请先新建或选择一个调度方案" });
return;
}
const nodeKey = artifactNodeKey(artifact, schedule);
const updated = await withMutation(
set,
get,
"add-node",
() => requireApi().createScheduleNode(schedule.schedule_id, {
workflow_version: schedule.workflow_version,
node_key: nodeKey,
node_name: artifact.script_name,
versions_id: artifact.versions_id,
timeout_seconds: 600,
retry_count: 0,
retry_interval_sec: 5,
position_x: Math.max(20, Math.round(positionX)),
position_y: Math.max(20, Math.round(positionY)),
arguments_json: {},
env_refs_json: {},
python_version: "3.12",
}),
`${artifact.script_name} 已加入画布`,
);
if (updated) {
const created = updated.nodes.find((item) => item.node_key === nodeKey);
set({ selectedNodeId: created?.node_id ?? null });
}
},
saveNode: async (selectedNode) => {
const api = requireApi();
const state_ = get();
const { schedule, nodeForm } = state_;
if (!schedule || !selectedNode) return;
try {
const nodeFormTyped = nodeForm as NodeForm;
const timeoutSeconds = Number(nodeFormTyped.timeoutSeconds);
const retryCount = Number(nodeFormTyped.retryCount);
const retryIntervalSec = Number(nodeFormTyped.retryIntervalSec);
if (!nodeFormTyped.nodeName.trim()) throw new Error("节点名称不能为空");
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1) {
throw new Error("超时时间必须是正整数");
}
if (!Number.isInteger(retryCount) || retryCount < 0) {
throw new Error("重试次数必须是非负整数");
}
if (!Number.isInteger(retryIntervalSec) || retryIntervalSec < 0) {
throw new Error("重试间隔必须是非负整数");
}
if (!PYTHON_VERSION_OPTIONS.includes(nodeFormTyped.pythonVersion)) {
throw new Error("Python 版本必须是 3.8 / 3.10 / 3.12");
}
const argumentsJson = parseObject(nodeFormTyped.argumentsJson, "运行参数");
const rawEnv = parseObject(nodeFormTyped.envRefsJson, "环境引用");
const envRefsJson = Object.fromEntries(
Object.entries(rawEnv).map(([key, value]) => {
if (typeof value !== "string") {
throw new Error("环境引用的值必须是字符串");
}
return [key, value];
}),
);
await withMutation(
set,
get,
"save-node",
() =>
api.updateScheduleNode(
schedule.schedule_id,
selectedNode.node_id,
{
workflow_version: schedule.workflow_version,
node_name: nodeFormTyped.nodeName.trim(),
timeout_seconds: timeoutSeconds,
retry_count: retryCount,
retry_interval_sec: retryIntervalSec,
arguments_json: argumentsJson,
env_refs_json: envRefsJson,
python_version: nodeFormTyped.pythonVersion,
},
),
"节点配置已保存",
);
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "节点配置保存失败");
}
},
removeNode: async (target) => {
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;
set({ contextMenu: null });
if (!window.confirm(`确定删除节点"${node.node_name}"吗?`)) return;
set({ busy: "delete-node" });
try {
let updated: Schedule;
try {
// 先走普通删除:没有历史记录时不额外打扰用户。
updated = await api.deleteScheduleNode(
schedule.schedule_id,
node.node_id,
schedule.workflow_version,
);
} catch (error) {
const requiresHistoryConfirmation =
error instanceof ApiRequestError
&& error.status === 409
&& error.code === "node_execution_history_exists";
if (!requiresHistoryConfirmation) throw error;
if (!window.confirm("该节点有运行日志,是否一并删除?")) return;
updated = await api.deleteScheduleNode(
schedule.schedule_id,
node.node_id,
schedule.workflow_version,
{ delete_execution_history: true },
);
}
applyServerUpdatedSchedule(set, updated);
if (selectedNodeId === node.node_id) set({ selectedNodeId: null });
notify({ tone: "success", message: "节点及其运行日志已删除" });
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "删除节点失败");
} finally {
set({ busy: null });
}
},
removeEdge: async (target) => {
const api = requireApi();
const state_ = get();
const { schedule, selectedEdgeId } = state_;
const edge = target
?? schedule?.edges.find((item) => item.edge_id === selectedEdgeId)
?? null;
if (!schedule || !edge || state_.busy) return;
set({ contextMenu: null });
const updated = await withMutation(
set,
get,
"delete-edge",
() =>
api.deleteScheduleEdge(
schedule.schedule_id,
edge.edge_id,
schedule.workflow_version,
),
"连线已删除",
);
if (updated && selectedEdgeId === edge.edge_id) {
set({ selectedEdgeId: null });
}
},
checkDag: async () => {
const api = requireApi();
const { schedule } = get();
if (!schedule || get().busy) return;
set({ busy: "validate" });
try {
const result = await api.validateSchedule(schedule.schedule_id);
set((s: any) => ({
schedule: s.schedule
? { ...s.schedule, dag_validation: result }
: s.schedule,
}));
notify({
tone: result.valid ? "success" : "error",
message: result.valid
? "当前 DAG 通过校验"
: "当前 DAG 存在校验问题",
});
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "DAG 校验失败");
} finally {
set({ busy: null });
}
},
connectTo: async (targetNodeId) => {
const state_ = get();
const { schedule, linkSourceId } = state_;
if (!schedule || !linkSourceId || state_.busy) return;
if (linkSourceId === targetNodeId) {
set({ linkSourceId: null });
notify({ tone: "info", message: "已取消连线" });
return;
}
const sourceId = linkSourceId;
set({ linkSourceId: null });
await withMutation(
set,
get,
"create-edge",
() =>
requireApi().createScheduleEdge(schedule.schedule_id, {
workflow_version: schedule.workflow_version,
source_node_id: sourceId,
target_node_id: targetNodeId,
}),
"节点连线已创建",
);
},
};
};
// (avoid unused-import warning for scheduleToForm — referenced for parity with original store)
void scheduleToForm;
@@ -0,0 +1,118 @@
// Logs slice: per-node-run log fetch + display state.
// toggleLog owns the open/closed toggle and lazily loads content from the
// signed URL on first open.
import type { StateCreator } from "zustand";
import { requireApi } from "./helpers";
import type { LogState } from "./types";
import type { SchedulesStore } from "./useSchedulesStore";
// ---- State ----
export type LogsSliceState = {
openLogNodeRunIds: Set<string>;
logStates: Record<string, LogState>;
};
// ---- Actions ----
export type LogsSliceActions = {
// pure setters
setOpenLogNodeRunIds: (
value: Set<string> | ((current: Set<string>) => Set<string>),
) => void;
setLogStates: (
updater: (current: Record<string, LogState>) => Record<string, LogState>,
) => void;
// mutations
toggleLog: (runId: string, nodeRunId: string) => Promise<void>;
};
// `SchedulesStore` is the combined state+actions of all slices, so `get()`
// returns a fully typed snapshot — no `any` propagation.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const createLogsSlice: StateCreator<SchedulesStore, [], [], LogsSliceState & LogsSliceActions> = (set, get) => ({
// ---- initial state ----
openLogNodeRunIds: new Set(),
logStates: {},
// ---- pure setters ----
setOpenLogNodeRunIds: (value) =>
set((s: any) => ({
openLogNodeRunIds:
typeof value === "function"
? (value as (current: Set<string>) => Set<string>)(
s.openLogNodeRunIds,
)
: value,
})),
setLogStates: (updater) =>
set((s: any) => ({ logStates: updater(s.logStates) })),
// ---- mutations ----
toggleLog: async (runId, nodeRunId) => {
const api = requireApi();
const state = get();
const open = state.openLogNodeRunIds;
if (open.has(nodeRunId)) {
const next = new Set(open);
next.delete(nodeRunId);
set({ openLogNodeRunIds: next });
return;
}
const next = new Set(open);
next.add(nodeRunId);
set({ openLogNodeRunIds: next });
if (state.logStates[nodeRunId]?.content) return;
set((s: any) => ({
logStates: {
...s.logStates,
[nodeRunId]: {
loading: true,
content: null,
fileName: null,
error: null,
},
},
}));
try {
const artifacts = await api.getScheduleNodeRunArtifacts(runId, nodeRunId);
if (!artifacts.log) throw new Error("本次节点执行没有可用日志");
const response = await fetch(artifacts.log.url, {
credentials: "same-origin",
cache: "no-store",
});
if (!response.ok) {
throw new Error(`日志读取失败(HTTP ${response.status}`);
}
const content = await response.text();
set((s: any) => ({
logStates: {
...s.logStates,
[nodeRunId]: {
loading: false,
content,
fileName: artifacts.log?.file_name ?? null,
error: null,
},
},
}));
} catch (error) {
set((s: any) => ({
logStates: {
...s.logStates,
[nodeRunId]: {
loading: false,
content: null,
fileName: null,
error: error instanceof Error ? error.message : "日志读取失败",
},
},
}));
}
},
});
@@ -0,0 +1,204 @@
// Runs slice: per-schedule run summaries, run detail expansion, artifact downloads.
// Cross-slice via `get()` for `schedule.schedule_id` consistency checks (refreshRuns)
// and `busy` (listSlice).
import type { ScheduleRunDetail, ScheduleRunSummary } from "../../../services/api";
import type { StateCreator } from "zustand";
import { handleError, requireApi, setApiOnline } from "./helpers";
import type { SchedulesStore } from "./useSchedulesStore";
// ---- State ----
export type RunsSliceState = {
runs: ScheduleRunSummary[];
runsLoading: boolean;
expandedRunId: string | null;
runDetails: Record<string, ScheduleRunDetail>;
detailLoadingId: string | null;
detailErrors: Record<string, string>;
artifactBusyKey: string | null;
artifactErrors: Record<string, string>;
};
// ---- Actions ----
export type RunsSliceActions = {
// pure setters
setRuns: (
value: ScheduleRunSummary[]
| ((current: ScheduleRunSummary[]) => ScheduleRunSummary[]),
) => void;
setRunsLoading: (loading: boolean) => void;
setRunDetails: (
updater: (current: Record<string, ScheduleRunDetail>) => Record<string, ScheduleRunDetail>,
) => void;
setDetailLoadingId: (id: string | null) => void;
setDetailErrors: (
updater: (current: Record<string, string>) => Record<string, string>,
) => void;
setArtifactBusyKey: (key: string | null) => void;
setArtifactErrors: (
updater: (current: Record<string, string>) => Record<string, string>,
) => void;
setExpandedRunId: (
id: string | null | ((current: string | null) => string | null),
) => void;
// mutations / loaders
refreshRuns: (scheduleId: string, showLoading?: boolean) => Promise<void>;
toggleRun: (runId: string) => void;
downloadResult: (runId: string, nodeRunId: string) => Promise<void>;
downloadLog: (runId: string, nodeRunId: string) => Promise<void>;
};
// `SchedulesStore` is the combined state+actions of all slices, so `get()`
// returns a fully typed snapshot — no `any` propagation.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const createRunsSlice: StateCreator<SchedulesStore, [], [], RunsSliceState & RunsSliceActions> = (set, get) => ({
// ---- initial state ----
runs: [],
runsLoading: false,
expandedRunId: null,
runDetails: {},
detailLoadingId: null,
detailErrors: {},
artifactBusyKey: null,
artifactErrors: {},
// ---- pure setters ----
setRuns: (value) =>
set((s: any) => ({
runs:
typeof value === "function"
? (value as (current: ScheduleRunSummary[]) => ScheduleRunSummary[])(
s.runs,
)
: value,
})),
setRunsLoading: (loading) => set({ runsLoading: loading }),
setRunDetails: (updater) =>
set((s: any) => ({ runDetails: updater(s.runDetails) })),
setDetailLoadingId: (id) => set({ detailLoadingId: id }),
setDetailErrors: (updater) =>
set((s: any) => ({ detailErrors: updater(s.detailErrors) })),
setArtifactBusyKey: (key) => set({ artifactBusyKey: key }),
setArtifactErrors: (updater) =>
set((s: any) => ({ artifactErrors: updater(s.artifactErrors) })),
setExpandedRunId: (id) =>
set((s: any) => ({
expandedRunId:
typeof id === "function"
? (id as (current: string | null) => string | null)(s.expandedRunId)
: id,
})),
// ---- mutations / loaders ----
refreshRuns: async (scheduleId, showLoading = false) => {
const api = requireApi();
if (showLoading) set({ runsLoading: true });
try {
const items = await api.listScheduleRuns({ scheduleId, limit: 20 });
if (get().schedule?.schedule_id === scheduleId) {
set({ runs: items });
}
setApiOnline(true);
} catch (error) {
if (showLoading) await handleError(get, error, "运行记录加载失败");
} finally {
// 如果用户已切换到另一个调度方案,不能让旧请求结束时覆盖新方案的
// 加载状态;新方案会由自己的请求负责关闭 loading。
if (showLoading && get().schedule?.schedule_id === scheduleId) {
set({ runsLoading: false });
}
}
},
toggleRun: (runId) => {
const expanded = get().expandedRunId;
if (expanded === runId) {
set({ expandedRunId: null });
} else {
set({ expandedRunId: runId });
}
},
downloadResult: async (runId, nodeRunId) => {
const api = requireApi();
const busyKey = `${nodeRunId}:result`;
set({
artifactBusyKey: busyKey,
artifactErrors: (() => {
const next = { ...get().artifactErrors };
delete next[nodeRunId];
return next;
})(),
});
try {
const artifacts = await api.getScheduleNodeRunArtifacts(runId, nodeRunId);
if (!artifacts.result) throw new Error("本次节点执行没有可下载结果");
const link = document.createElement("a");
link.href = artifacts.result.url;
link.download = artifacts.result.file_name;
document.body.appendChild(link);
link.click();
link.remove();
} catch (error) {
set((s: any) => ({
artifactErrors: {
...s.artifactErrors,
[nodeRunId]: error instanceof Error ? error.message : "结果下载失败",
},
}));
} finally {
set({ artifactBusyKey: null });
}
},
downloadLog: async (runId, nodeRunId) => {
const api = requireApi();
const busyKey = `${nodeRunId}:log-download`;
set({
artifactBusyKey: busyKey,
artifactErrors: (() => {
const next = { ...get().artifactErrors };
delete next[nodeRunId];
return next;
})(),
});
try {
const artifacts = await api.getScheduleNodeRunArtifacts(runId, nodeRunId);
if (!artifacts.log) throw new Error("本次节点执行没有可下载日志");
const response = await fetch(artifacts.log.url, {
credentials: "same-origin",
cache: "no-store",
});
if (!response.ok) {
throw new Error(`日志下载失败(HTTP ${response.status}`);
}
const objectUrl = URL.createObjectURL(await response.blob());
const link = document.createElement("a");
link.href = objectUrl;
link.download = artifacts.log.file_name;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(objectUrl);
} catch (error) {
set((s: any) => ({
artifactErrors: {
...s.artifactErrors,
[nodeRunId]:
error instanceof Error ? error.message : "日志下载失败",
},
}));
} finally {
set({ artifactBusyKey: null });
}
},
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
// ---- Module types ----
import type { CronPreview } from "../../../services/api";
export type Notice = {
tone: "success" | "error" | "info";
message: string;
};
export type ScheduleForm = {
scheduleName: string;
description: string;
triggerType: "manual" | "cron" | "api";
cronExpression: string;
timezone: string;
enabled: boolean;
maxConcurrency: string;
failurePolicy: "stop" | "continue";
};
export type PythonVersion = "3.8" | "3.10" | "3.12";
export const PYTHON_VERSION_OPTIONS: readonly PythonVersion[] = [
"3.8",
"3.10",
"3.12",
];
export type NodeForm = {
nodeName: string;
timeoutSeconds: string;
retryCount: string;
retryIntervalSec: string;
argumentsJson: string;
envRefsJson: string;
pythonVersion: PythonVersion;
};
export type NodePositionDraft = {
position_x: number;
position_y: number;
};
export type LogState = {
loading: boolean;
content: string | null;
fileName: string | null;
error: string | null;
};
// ---- ScheduleContextMenu (re-declared here, but the canonical type is in store/types to avoid pulling store into components) ----
import type {
Schedule,
ScheduleArtifact,
ScheduleEdge,
ScheduleNode,
} from "../../../services/api";
export type ScheduleContextMenu =
| { x: number; y: number; kind: "schedule-list" }
| { x: number; y: number; kind: "schedule"; schedule: Schedule }
| {
x: number;
y: number;
kind: "artifact";
artifact: ScheduleArtifact;
}
| { x: number; y: number; kind: "node"; node: ScheduleNode }
| { x: number; y: number; kind: "edge"; edge: ScheduleEdge };
// ---- Form defaults ----
export const EMPTY_SCHEDULE_FORM: ScheduleForm = {
scheduleName: "",
description: "",
triggerType: "manual",
cronExpression: "",
timezone: "Asia/Shanghai",
enabled: false,
maxConcurrency: "1",
failurePolicy: "stop",
};
export const EMPTY_NODE_FORM: NodeForm = {
nodeName: "",
timeoutSeconds: "600",
retryCount: "0",
retryIntervalSec: "5",
argumentsJson: "{}",
envRefsJson: "{}",
pythonVersion: "3.12",
};
@@ -0,0 +1,110 @@
// Root store: composes 5 slices into the same-named `useSchedulesStore`.
// External consumers keep importing `{ useSchedulesStore }` from this file.
//
// The legacy monolithic file exported both `useSchedulesStore` and
// `bindSchedulesApi`; we re-export the binding helper from `./helpers` here
// so `routes/platform.tsx` (and any future callers) get the same surface.
import { create } from "zustand";
import { bindSchedulesApi } from "./helpers";
import { createCanvasSlice } from "./canvasSlice";
import { createDialogSlice } from "./dialogSlice";
import { createListSlice } from "./listSlice";
import { createLogsSlice } from "./logsSlice";
import { createRunsSlice } from "./runsSlice";
import type { CanvasSliceActions, CanvasSliceState } from "./canvasSlice";
import type { DialogSliceActions, DialogSliceState } from "./dialogSlice";
import type { ListSliceActions, ListSliceState } from "./listSlice";
import type { LogsSliceActions, LogsSliceState } from "./logsSlice";
import type { RunsSliceActions, RunsSliceState } from "./runsSlice";
import type { WorkspaceBoundApi } from "../../../services/api";
// ---- Initial values (used by reset() to mirror the legacy monolithic `initial` const) ----
import { EMPTY_NODE_FORM, EMPTY_SCHEDULE_FORM } from "./types";
// ---- Combined types (re-used by each slice's StateCreator generic) ----
export type SchedulesState =
& ListSliceState
& CanvasSliceState
& DialogSliceState
& RunsSliceState
& LogsSliceState;
export type SchedulesActions =
& ListSliceActions
& CanvasSliceActions
& DialogSliceActions
& RunsSliceActions
& LogsSliceActions
& {
bindApi: (api: WorkspaceBoundApi | null) => void;
reset: () => void;
};
export type SchedulesStore = SchedulesState & SchedulesActions;
const INITIAL: Omit<SchedulesState, "schedule" | "busy"> = {
// List
schedules: [],
artifacts: [],
loading: true,
// Canvas
selectedNodeId: null,
selectedEdgeId: null,
linkSourceId: null,
scheduleKeyword: "",
artifactKeyword: "",
scheduleForm: EMPTY_SCHEDULE_FORM,
nodeForm: EMPTY_NODE_FORM,
contextMenu: null,
positionDraftCount: 0,
// Dialog
createDialogOpen: false,
newScheduleName: "",
cronResult: null,
// Runs
runs: [],
runsLoading: false,
expandedRunId: null,
runDetails: {},
detailLoadingId: null,
detailErrors: {},
artifactBusyKey: null,
artifactErrors: {},
// Logs
openLogNodeRunIds: new Set(),
logStates: {},
};
export const useSchedulesStore = create<SchedulesStore>()(
(set, get, store) => ({
...createListSlice(set, get, store),
...createCanvasSlice(set, get, store),
...createDialogSlice(set, get, store),
...createRunsSlice(set, get, store),
...createLogsSlice(set, get, store),
// ---- global actions (cross-slice) ----
bindApi: (api) => {
bindSchedulesApi(api);
},
reset: () => {
// Mirror legacy `set({ ...initial, loading: true })` — wipe every
// slice field back to its default. schedule/busy are explicitly set
// because their default values differ from the Omit above.
set({
...INITIAL,
schedule: null,
busy: null,
loading: true,
});
},
}),
);
export { bindSchedulesApi };
+1 -1
View File
@@ -6,7 +6,7 @@ import {
type ScheduleRunSummary,
} from "../../services/api";
import { EMPTY_SCHEDULE_FORM, EMPTY_NODE_FORM } from "./constants";
import type { NodeForm } from "./state/schedulesStore";
import type { NodeForm } from "./state/types";
export function formatTime(value: string | null): string {
if (!value) return "尚未执行";
+1 -1
View File
@@ -16,7 +16,7 @@ import {
} from "../features/platform/state/scriptWorkspaceStore";
import { useUiStore } from "../features/platform/state/uiStore";
import { bindAdminApi } from "../features/admin/state/adminStore";
import { bindSchedulesApi } from "../features/schedules/state/schedulesStore";
import { bindSchedulesApi } from "../features/schedules/state/useSchedulesStore";
import "../styles/platform.css";