// ---- Module-level non-reactive holders ---- import { ApiRequestError, type Schedule, type ScheduleNode, type WorkspaceBoundApi } from "../../../services/api"; import { useScriptWorkspaceStore } from "../../platform/state/scriptWorkspaceStore"; import { toast } from "sonner"; 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 { if (notice.tone === "error") toast.error(notice.message); else if (notice.tone === "info") toast.info(notice.message); else toast.success(notice.message); } 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(); 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 { 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; } } /** * 顶层 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 { 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 | ((s: SchedulesStore) => Partial)) => 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 | ((s: SchedulesStore) => Partial)) => void, get: () => SchedulesStore, label: string, action: () => Promise, successMessage: string, ): Promise { 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 }); } }