Files
model-platform/frontend/app/features/schedules/state/helpers.ts
T

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 });
}
}