// 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; detailLoadingId: string | null; detailErrors: Record; artifactBusyKey: string | null; artifactErrors: Record; }; // ---- Actions ---- export type RunsSliceActions = { // pure setters setRuns: ( value: ScheduleRunSummary[] | ((current: ScheduleRunSummary[]) => ScheduleRunSummary[]), ) => void; setRunsLoading: (loading: boolean) => void; setRunDetails: ( updater: (current: Record) => Record, ) => void; setDetailLoadingId: (id: string | null) => void; setDetailErrors: ( updater: (current: Record) => Record, ) => void; setArtifactBusyKey: (key: string | null) => void; setArtifactErrors: ( updater: (current: Record) => Record, ) => void; setExpandedRunId: ( id: string | null | ((current: string | null) => string | null), ) => void; // mutations / loaders refreshRuns: (scheduleId: string, showLoading?: boolean) => Promise; toggleRun: (runId: string) => void; downloadResult: (runId: string, nodeRunId: string) => Promise; downloadLog: (runId: string, nodeRunId: string) => Promise; }; // `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 = (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 }); } }, });