205 lines
6.6 KiB
TypeScript
205 lines
6.6 KiB
TypeScript
// ---- 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 });
|
|
}
|
|
}
|