import { create } from "zustand"; import { ApiRequestError, type CronPreview, type Schedule, type ScheduleArtifact, type ScheduleEdge, type ScheduleNode, type ScheduleRunDetail, type ScheduleRunSummary, type WorkspaceBoundApi, } from "../../../services/api"; import { useScriptWorkspaceStore } from "../../platform/state/scriptWorkspaceStore"; import { useUiStore } from "../../platform/state/uiStore"; type Notice = { tone: "success" | "error" | "info"; message: string; }; // ---- Types ---- 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 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 }; 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", }; // ---- Module-level non-reactive holders ---- let _api: WorkspaceBoundApi | null = null; const positionDrafts = new Map(); export const bindSchedulesApi = (api: WorkspaceBoundApi | null) => { _api = api; }; function requireApi(): WorkspaceBoundApi { if (!_api) throw new Error("schedules API 未绑定"); return _api; } function notify(notice: Notice) { useUiStore.getState().pushToast(notice); } function setApiOnline(online: boolean) { useScriptWorkspaceStore.getState().setApiOnline(online); } // ---- Helpers ---- 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, }; } 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 PythonVersion, }; } function parseObject(text: string, label: string): Record { 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; } catch (error) { if (error instanceof SyntaxError) { throw new Error(`${label} JSON 格式不正确`); } throw error; } } 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; }), }; } function clearPositionDrafts() { positionDrafts.clear(); } // ---- Store ---- type LogState = { loading: boolean; content: string | null; fileName: string | null; error: string | null; }; type State = { schedules: Schedule[]; artifacts: ScheduleArtifact[]; schedule: Schedule | null; loading: boolean; busy: string | null; selectedNodeId: string | null; selectedEdgeId: string | null; linkSourceId: string | null; scheduleKeyword: string; artifactKeyword: string; createDialogOpen: boolean; newScheduleName: string; scheduleForm: ScheduleForm; nodeForm: NodeForm; cronResult: CronPreview | null; contextMenu: ScheduleContextMenu | null; runs: ScheduleRunSummary[]; runsLoading: boolean; expandedRunId: string | null; runDetails: Record; detailLoadingId: string | null; detailErrors: Record; openLogNodeRunIds: Set; logStates: Record; artifactBusyKey: string | null; artifactErrors: Record; positionDraftCount: number; }; type Actions = { bindApi: (api: WorkspaceBoundApi | null) => void; reset: () => void; // pure setters (mostly used by canvas/drag/inspector) 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; setRuns: ( value: ScheduleRunSummary[] | ((current: ScheduleRunSummary[]) => ScheduleRunSummary[]), ) => void; setRunsLoading: (loading: boolean) => void; setPositionDraftCount: (count: number) => void; setRunDetails: ( updater: (current: Record) => Record, ) => void; setOpenLogNodeRunIds: ( value: Set | ((current: Set) => Set), ) => void; setLogStates: ( updater: (current: Record) => Record, ) => void; setArtifactBusyKey: (key: string | null) => void; setArtifactErrors: ( updater: (current: Record) => Record, ) => void; setDetailLoadingId: (id: string | null) => void; setDetailErrors: ( updater: (current: Record) => Record, ) => void; setSelectedNodeId: (id: string | null) => void; setSelectedEdgeId: (id: string | null) => void; setLinkSourceId: (id: string | null) => void; setScheduleKeyword: (k: string) => void; setArtifactKeyword: (k: string) => void; setCreateDialogOpen: (open: boolean) => void; setNewScheduleName: (name: string) => void; setScheduleForm: (form: ScheduleForm) => void; setNodeForm: (form: NodeForm) => void; setCronResult: (result: CronPreview | null) => void; setContextMenu: (menu: ScheduleContextMenu | null) => void; closeContextMenu: () => void; // data loaders loadInitial: () => Promise; refreshLists: (preferredScheduleId?: string | null) => Promise; refreshRuns: (scheduleId: string, showLoading?: boolean) => Promise; chooseSchedule: (scheduleId: string) => Promise; // mutations openCreateDialog: () => void; addSchedule: (name: string) => Promise; removeSchedule: (target?: Schedule) => Promise; renameSchedule: (target: Schedule) => Promise; removeArtifact: (artifact: ScheduleArtifact) => Promise; saveSchedule: () => Promise; runCronPreview: () => Promise; runNow: () => Promise; addArtifactAt: ( artifact: ScheduleArtifact, positionX: number, positionY: number, ) => Promise; saveNode: (selectedNode: ScheduleNode | null) => Promise; removeNode: (target?: ScheduleNode) => Promise; removeEdge: (target?: ScheduleEdge) => Promise; checkDag: () => Promise; connectTo: (targetNodeId: string) => Promise; // runs / log helpers toggleRun: (runId: string) => void; toggleLog: (runId: string, nodeRunId: string) => Promise; downloadResult: (runId: string, nodeRunId: string) => Promise; downloadLog: (runId: string, nodeRunId: string) => Promise; setExpandedRunId: ( id: string | null | ((current: string | null) => string | null), ) => void; // drag helpers setPositionDraft: (nodeId: string, draft: NodePositionDraft) => void; getPositionDrafts: () => Map; clearAllPositionDrafts: () => void; prunePositionDrafts: (validNodeIds: Set) => void; }; const initial: State = { schedules: [], artifacts: [], schedule: null, loading: true, busy: null, selectedNodeId: null, selectedEdgeId: null, linkSourceId: null, scheduleKeyword: "", artifactKeyword: "", createDialogOpen: false, newScheduleName: "", scheduleForm: EMPTY_SCHEDULE_FORM, nodeForm: EMPTY_NODE_FORM, cronResult: null, contextMenu: null, runs: [], runsLoading: false, expandedRunId: null, runDetails: {}, detailLoadingId: null, detailErrors: {}, openLogNodeRunIds: new Set(), logStates: {}, artifactBusyKey: null, artifactErrors: {}, positionDraftCount: 0, }; export const useSchedulesStore = create((set, get) => { // Generic error handler with 412 (concurrent update) awareness async function handleError(error: unknown, fallback: string): Promise { 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, }); } // Generic mutation wrapper: busy + try/catch + 412 + positionDraft cleanup + schedules list update async function withMutation( label: string, action: () => Promise, successMessage: string, ): Promise { const api = requireApi(); if (get().busy) return null; set({ busy: label }); try { const serverUpdated = await action(); 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, })); notify({ tone: "success", message: successMessage }); return updated; } catch (error) { await handleError(error, `${successMessage}失败`); return null; } finally { set({ busy: null }); } } return { ...initial, bindApi: bindSchedulesApi, reset: () => { set({ ...initial, loading: true }); }, // ---- pure setters ---- setSchedule: (value) => set((state) => ({ schedule: typeof value === "function" ? (value as (current: Schedule | null) => Schedule | null)(state.schedule) : value, })), setSchedules: (value) => set((state) => ({ schedules: typeof value === "function" ? (value as (current: Schedule[]) => Schedule[])(state.schedules) : value, })), setArtifacts: (value) => set((state) => ({ artifacts: typeof value === "function" ? (value as (current: ScheduleArtifact[]) => ScheduleArtifact[])( state.artifacts, ) : value, })), setLoading: (loading) => set({ loading }), setBusy: (busy) => set({ busy }), setRuns: (value) => set((state) => ({ runs: typeof value === "function" ? (value as (current: ScheduleRunSummary[]) => ScheduleRunSummary[])( state.runs, ) : value, })), setRunsLoading: (loading) => set({ runsLoading: loading }), setRunDetails: (updater) => set((state) => ({ runDetails: updater(state.runDetails) })), setOpenLogNodeRunIds: (value) => set((state) => ({ openLogNodeRunIds: typeof value === "function" ? (value as (current: Set) => Set)( state.openLogNodeRunIds, ) : value, })), setLogStates: (updater) => set((state) => ({ logStates: updater(state.logStates) })), setArtifactBusyKey: (key) => set({ artifactBusyKey: key }), setArtifactErrors: (updater) => set((state) => ({ artifactErrors: updater(state.artifactErrors) })), setDetailLoadingId: (id) => set({ detailLoadingId: id }), setDetailErrors: (updater) => set((state) => ({ detailErrors: updater(state.detailErrors) })), 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 }), setCreateDialogOpen: (open) => set({ createDialogOpen: open }), setNewScheduleName: (name) => set({ newScheduleName: name }), setScheduleForm: (form) => set({ scheduleForm: form }), setNodeForm: (form) => set({ nodeForm: form }), setCronResult: (result) => set({ cronResult: result }), setContextMenu: (menu) => set({ contextMenu: menu }), closeContextMenu: () => set({ contextMenu: null }), // ---- 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) }); }, 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(error, "运行记录加载失败"); } finally { // 如果用户已切换到另一个调度方案,不能让旧请求结束时覆盖新方案的 // 加载状态;新方案会由自己的请求负责关闭 loading。 if (showLoading && get().schedule?.schedule_id === scheduleId) { set({ runsLoading: false }); } } }, 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) { await handleError(error, "调度详情加载失败"); } finally { set({ busy: null }); } }, // ---- mutations ---- openCreateDialog: () => { if (get().busy) return; set((state) => ({ contextMenu: null, newScheduleName: `新建调度 ${state.schedules.length + 1}`, createDialogOpen: true, })); }, 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((state) => ({ schedules: [created, ...state.schedules], schedule: created, createDialogOpen: false, newScheduleName: "", })); get().clearAllPositionDrafts(); set({ selectedNodeId: null, selectedEdgeId: null }); notify({ tone: "success", message: "调度方案已创建" }); } catch (error) { await handleError(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) { await handleError(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((state) => ({ schedules: state.schedules.map((item) => item.schedule_id === updated.schedule_id ? { ...updated, nodes: [], edges: [] } : item ), schedule: state.schedule?.schedule_id === updated.schedule_id ? updated : state.schedule, })); notify({ tone: "success", message: "调度方案已改名" }); } catch (error) { await handleError(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((state) => ({ artifacts: state.artifacts.filter( (item) => item.versions_id !== artifact.versions_id, ), })); notify({ tone: "success", message: "已移出调度列表,稳定版本和历史记录保持不变", }); } catch (error) { await handleError(error, "移出调度列表失败"); } finally { set({ busy: null }); } }, saveSchedule: async () => { const api = requireApi(); const state = get(); const { schedule, scheduleForm } = state; if (!schedule || state.busy) return; const maxConcurrency = Number(scheduleForm.maxConcurrency); if (!scheduleForm.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 { 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: scheduleForm.scheduleName.trim(), description: scheduleForm.description.trim() || null, trigger_type: scheduleForm.triggerType, cron_expression: scheduleForm.triggerType === "cron" ? scheduleForm.cronExpression.trim() : null, timezone: scheduleForm.timezone.trim(), enabled: scheduleForm.enabled, max_concurrency: maxConcurrency, failure_policy: scheduleForm.failurePolicy, }); get().clearAllPositionDrafts(); set((s) => ({ schedule: updated, schedules: s.schedules.map((item) => item.schedule_id === updated.schedule_id ? { ...updated, nodes: [], edges: [] } : item ), })); notify({ tone: "success", message: "调度配置已保存" }); } catch (error) { const localDraft = applyPositionDrafts(updated); set({ schedule: localDraft }); await handleError(error, "调度配置保存失败"); } finally { set({ busy: null }); } }, 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(error, "Cron 预览失败"); } 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) => ({ runs: [ created, ...s.runs.filter((item) => 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) => 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) { await handleError(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( "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 timeoutSeconds = Number(nodeForm.timeoutSeconds); const retryCount = Number(nodeForm.retryCount); const retryIntervalSec = Number(nodeForm.retryIntervalSec); if (!nodeForm.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(nodeForm.pythonVersion)) { throw new Error("Python 版本必须是 3.8 / 3.10 / 3.12"); } const argumentsJson = parseObject(nodeForm.argumentsJson, "运行参数"); const rawEnv = parseObject(nodeForm.envRefsJson, "环境引用"); const envRefsJson = Object.fromEntries( Object.entries(rawEnv).map(([key, value]) => { if (typeof value !== "string") { throw new Error("环境引用的值必须是字符串"); } return [key, value]; }), ); await withMutation( "save-node", () => api.updateScheduleNode( schedule.schedule_id, selectedNode.node_id, { workflow_version: schedule.workflow_version, node_name: nodeForm.nodeName.trim(), timeout_seconds: timeoutSeconds, retry_count: retryCount, retry_interval_sec: retryIntervalSec, arguments_json: argumentsJson, env_refs_json: envRefsJson, python_version: nodeForm.pythonVersion, }, ), "节点配置已保存", ); } catch (error) { await handleError(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; const updated = await withMutation( "delete-node", () => api.deleteScheduleNode( schedule.schedule_id, node.node_id, schedule.workflow_version, ), "节点已删除", ); if (updated && selectedNodeId === node.node_id) { set({ selectedNodeId: 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( "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((state) => ({ schedule: state.schedule ? { ...state.schedule, dag_validation: result } : state.schedule, })); notify({ tone: result.valid ? "success" : "error", message: result.valid ? "当前 DAG 通过校验" : "当前 DAG 存在校验问题", }); } catch (error) { await handleError(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( "create-edge", () => requireApi().createScheduleEdge(schedule.schedule_id, { workflow_version: schedule.workflow_version, source_node_id: sourceId, target_node_id: targetNodeId, }), "节点连线已创建", ); }, // ---- runs / logs ---- toggleRun: (runId) => { const expanded = get().expandedRunId; if (expanded === runId) { set({ expandedRunId: null }); } else { set({ expandedRunId: runId }); } }, setExpandedRunId: (id) => set((state) => ({ expandedRunId: typeof id === "function" ? (id as (current: string | null) => string | null)( state.expandedRunId, ) : id, })), 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) => ({ 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) => ({ logStates: { ...s.logStates, [nodeRunId]: { loading: false, content, fileName: artifacts.log?.file_name ?? null, error: null, }, }, })); } catch (error) { set((s) => ({ logStates: { ...s.logStates, [nodeRunId]: { loading: false, content: null, fileName: null, error: error instanceof Error ? error.message : "日志读取失败", }, }, })); } }, 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((state) => ({ artifactErrors: { ...state.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((state) => ({ artifactErrors: { ...state.artifactErrors, [nodeRunId]: error instanceof Error ? error.message : "日志下载失败", }, })); } finally { set({ artifactBusyKey: 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 }); }, }; }); // 顶层 pure helper (scheduleNodeKey needs schedule, so it's defined outside the store closure) function artifactNodeKey(artifact: ScheduleArtifact, 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)}`; }