Merge branch 'feat/schedule-cleanup-handover' into develop

This commit is contained in:
tao.chen
2026-08-20 19:27:32 +08:00
7 changed files with 306 additions and 63 deletions
+8 -2
View File
@@ -279,8 +279,14 @@ export function useApi(): WorkspaceBoundApi {
rawApi.createScheduleNode(workspaceId, scheduleId, input),
updateScheduleNode: (scheduleId, nodeId, input) =>
rawApi.updateScheduleNode(workspaceId, scheduleId, nodeId, input),
deleteScheduleNode: (scheduleId, nodeId, workflowVersion) =>
rawApi.deleteScheduleNode(workspaceId, scheduleId, nodeId, workflowVersion),
deleteScheduleNode: (scheduleId, nodeId, workflowVersion, options) =>
rawApi.deleteScheduleNode(
workspaceId,
scheduleId,
nodeId,
workflowVersion,
options,
),
createScheduleEdge: (scheduleId, input) =>
rawApi.createScheduleEdge(workspaceId, scheduleId, input),
deleteScheduleEdge: (scheduleId, edgeId, workflowVersion) =>
@@ -234,9 +234,6 @@ export default function SchedulePage({
void useSchedulesStore.getState().loadInitial();
}, [workspaceId, api, setSchedules, setArtifacts, setSchedule]);
// 进入调度页或切换调度方案后,主动加载该方案已经存在的运行记录。
// 原先只有「手动运行」和运行中的轮询会更新列表,因此从其他页面返回时会
// 一直停留在加载状态,直到产生新的运行记录。
useEffect(() => {
const scheduleId = schedule?.schedule_id;
if (!scheduleId || !api) {
@@ -244,35 +241,32 @@ export default function SchedulePage({
setRunsLoading(false);
return;
}
void useSchedulesStore.getState().refreshRuns(scheduleId, true);
}, [schedule?.schedule_id, api, setRuns, setRunsLoading]);
useEffect(() => {
const scheduleId = schedule?.schedule_id;
if (!scheduleId || !api) return;
let timer: number | undefined;
let cancelled = false;
const tick = (): void => {
const tick = async (showLoading = false): Promise<void> => {
if (cancelled) return;
void useSchedulesStore.getState().refreshRuns(scheduleId);
// 每次 tick 重新读最新 runs,不再把 runs 放进依赖数组,避免每次 setRuns
// 都触发 effect cleanup + 重建 setInterval。
// 必须等待请求完成再判断状态;旧实现会在首个请求返回前看到空数组,
// 因而错误地停止轮询,导致运行记录必须手工刷新才出现。
await useSchedulesStore.getState().refreshRuns(scheduleId, showLoading);
if (cancelled || useSchedulesStore.getState().schedule?.schedule_id !== scheduleId) {
return;
}
const runs = useSchedulesStore.getState().runs;
const hasActiveRun = runs.some(
(item) => item.run_status === "queued" || item.run_status === "running",
);
const isEnabledCron = schedule.trigger_type === "cron" && schedule.enabled;
// Cron 会由后端在未来某个整分钟创建新记录。即使当前没有运行中的
// 记录,也要持续刷新,才能让新一轮运行自动出现在右侧列表中。
if (!hasActiveRun && !isEnabledCron) return;
timer = window.setTimeout(tick, hasActiveRun ? 1500 : 3000);
// 页面停留期间持续刷新:运行中更快,空闲时较慢,既能自动显示 Cron
// 新记录,也不会因频繁请求影响其他调度页面操作。
timer = window.setTimeout(() => {
void tick();
}, hasActiveRun ? 1500 : 5000);
};
tick();
void tick(true);
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [schedule?.schedule_id, schedule?.trigger_type, schedule?.enabled, api]);
}, [schedule?.schedule_id, api, setRuns, setRunsLoading]);
// Form submit wrapper for create-schedule modal — keeps the FormEvent flow out of JSX.
const handleCreateScheduleSubmit = (event: FormEvent<HTMLFormElement>): void => {
@@ -368,6 +368,30 @@ export const useSchedulesStore = create<State & Actions>((set, get) => {
});
}
function applyServerUpdatedSchedule(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
async function withMutation(
label: string,
@@ -379,29 +403,9 @@ export const useSchedulesStore = create<State & Actions>((set, get) => {
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,
}));
applyServerUpdatedSchedule(serverUpdated);
notify({ tone: "success", message: successMessage });
return updated;
return serverUpdated;
} catch (error) {
await handleError(error, `${successMessage}失败`);
return null;
@@ -946,18 +950,37 @@ export const useSchedulesStore = create<State & Actions>((set, get) => {
if (!schedule || !node || state.busy) return;
set({ contextMenu: null });
if (!window.confirm(`确定删除节点"${node.node_name}"吗?`)) return;
const updated = await withMutation(
"delete-node",
() =>
api.deleteScheduleNode(
set({ busy: "delete-node" });
try {
let updated: Schedule;
try {
// 先走普通删除:没有历史记录时不额外打扰用户。
updated = await api.deleteScheduleNode(
schedule.schedule_id,
node.node_id,
schedule.workflow_version,
),
"节点已删除",
);
if (updated && selectedNodeId === node.node_id) {
set({ selectedNodeId: null });
);
} catch (error) {
const requiresHistoryConfirmation =
error instanceof ApiRequestError
&& error.status === 409
&& error.code === "node_execution_history_exists";
if (!requiresHistoryConfirmation) throw error;
if (!window.confirm("该节点有运行日志,是否一并删除?")) return;
updated = await api.deleteScheduleNode(
schedule.schedule_id,
node.node_id,
schedule.workflow_version,
{ delete_execution_history: true },
);
}
applyServerUpdatedSchedule(updated);
if (selectedNodeId === node.node_id) set({ selectedNodeId: null });
notify({ tone: "success", message: "节点及其运行日志已删除" });
} catch (error) {
await handleError(error, "删除节点失败");
} finally {
set({ busy: null });
}
},
+6 -1
View File
@@ -1314,10 +1314,14 @@ export async function deleteScheduleNode(
scheduleId: string,
nodeId: string,
workflowVersion: number,
options: { delete_execution_history?: boolean } = {},
): Promise<Schedule> {
return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}/nodes/${nodeId}`,
{ method: "DELETE", body: JSON.stringify({ workflow_version: workflowVersion }) },
{
method: "DELETE",
body: JSON.stringify({ workflow_version: workflowVersion, ...options }),
},
workspaceId,
);
}
@@ -1586,6 +1590,7 @@ export type WorkspaceBoundApi = {
scheduleId: string,
nodeId: string,
workflowVersion: number,
options?: { delete_execution_history?: boolean },
) => Promise<Schedule>;
createScheduleEdge: (
scheduleId: string,