1350 lines
45 KiB
TypeScript
1350 lines
45 KiB
TypeScript
import {
|
||
type DragEvent,
|
||
type FormEvent,
|
||
type MouseEvent as ReactMouseEvent,
|
||
type PointerEvent as ReactPointerEvent,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
|
||
import {
|
||
ApiRequestError,
|
||
type CronPreview,
|
||
type Schedule,
|
||
type ScheduleArtifact,
|
||
type ScheduleEdge,
|
||
type ScheduleNode,
|
||
type ScheduleRunSummary,
|
||
} from "../../services/api";
|
||
|
||
import { useApi } from "../../context/AuthContext";
|
||
import Icon from "../../components/common/Icon";
|
||
import {
|
||
CANVAS_WIDTH,
|
||
CANVAS_HEIGHT,
|
||
NODE_WIDTH,
|
||
NODE_HEIGHT,
|
||
ARTIFACT_MIME,
|
||
EMPTY_SCHEDULE_FORM,
|
||
EMPTY_NODE_FORM,
|
||
} from "./constants";
|
||
import {
|
||
formatTime,
|
||
shortHash,
|
||
parseObject,
|
||
scheduleToForm,
|
||
nodeToForm,
|
||
artifactNodeKey,
|
||
edgePath,
|
||
withSuppressedError,
|
||
} from "./utils";
|
||
import { ScheduleList } from "./ScheduleList";
|
||
import { ArtifactList } from "./ArtifactList";
|
||
import { ScheduleCanvasHeader } from "./ScheduleCanvasHeader";
|
||
import { ScheduleInspector } from "./ScheduleInspector";
|
||
import { NodeInspector } from "./NodeInspector";
|
||
import { RunHistory } from "./RunHistory";
|
||
import "../../styles/schedule.css";
|
||
|
||
type Notice = {
|
||
tone: "success" | "error" | "info";
|
||
message: string;
|
||
};
|
||
|
||
type ScheduleForm = {
|
||
scheduleName: string;
|
||
description: string;
|
||
triggerType: "manual" | "cron" | "api";
|
||
cronExpression: string;
|
||
timezone: string;
|
||
enabled: boolean;
|
||
maxConcurrency: string;
|
||
failurePolicy: "stop" | "continue";
|
||
};
|
||
|
||
type NodeForm = {
|
||
nodeName: string;
|
||
timeoutSeconds: string;
|
||
retryCount: string;
|
||
retryIntervalSec: string;
|
||
argumentsJson: string;
|
||
envRefsJson: string;
|
||
};
|
||
|
||
type DragState = {
|
||
nodeId: string;
|
||
pointerId: number;
|
||
startClientX: number;
|
||
startClientY: number;
|
||
originX: number;
|
||
originY: number;
|
||
moved: boolean;
|
||
};
|
||
|
||
type NodePositionDraft = {
|
||
position_x: number;
|
||
position_y: number;
|
||
};
|
||
|
||
type ScheduleContextMenu =
|
||
| { kind: "schedule-list"; x: number; y: number }
|
||
| { kind: "schedule"; x: number; y: number; schedule: Schedule }
|
||
| { kind: "artifact"; x: number; y: number; artifact: ScheduleArtifact }
|
||
| { kind: "node"; x: number; y: number; node: ScheduleNode }
|
||
| { kind: "edge"; x: number; y: number; edge: ScheduleEdge };
|
||
|
||
type ScheduleContextMenuTarget =
|
||
| { kind: "schedule-list" }
|
||
| { kind: "schedule"; schedule: Schedule }
|
||
| { kind: "artifact"; artifact: ScheduleArtifact }
|
||
| { kind: "node"; node: ScheduleNode }
|
||
| { kind: "edge"; edge: ScheduleEdge };
|
||
|
||
export default function SchedulePage({
|
||
onNotify,
|
||
onConnectionChange,
|
||
}: {
|
||
onNotify: (notice: Notice) => void;
|
||
onConnectionChange: (online: boolean) => void;
|
||
}) {
|
||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||
const [artifacts, setArtifacts] = useState<ScheduleArtifact[]>([]);
|
||
const [schedule, setSchedule] = useState<Schedule | null>(null);
|
||
const scheduleRef = useRef<Schedule | null>(null);
|
||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||
const [linkSourceId, setLinkSourceId] = useState<string | null>(null);
|
||
const [scheduleKeyword, setScheduleKeyword] = useState("");
|
||
const [artifactKeyword, setArtifactKeyword] = useState("");
|
||
const [loading, setLoading] = useState(true);
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||
const [newScheduleName, setNewScheduleName] = useState("");
|
||
const [contextMenu, setContextMenu] = useState<ScheduleContextMenu | null>(null);
|
||
const [scheduleForm, setScheduleForm] = useState<ScheduleForm>(EMPTY_SCHEDULE_FORM);
|
||
const [nodeForm, setNodeForm] = useState<NodeForm>(EMPTY_NODE_FORM);
|
||
const [cronResult, setCronResult] = useState<CronPreview | null>(null);
|
||
const [runs, setRuns] = useState<ScheduleRunSummary[]>([]);
|
||
const [runsLoading, setRunsLoading] = useState(false);
|
||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||
const dragRef = useRef<DragState | null>(null);
|
||
const positionDraftsRef = useRef<Record<string, NodePositionDraft>>({});
|
||
const [positionDraftCount, setPositionDraftCount] = useState(0);
|
||
const api = useApi();
|
||
|
||
const selectedNode = schedule?.nodes.find(
|
||
(item) => item.node_id === selectedNodeId,
|
||
) ?? null;
|
||
const selectedEdge = schedule?.edges.find(
|
||
(item) => item.edge_id === selectedEdgeId,
|
||
) ?? null;
|
||
|
||
useEffect(() => {
|
||
scheduleRef.current = schedule;
|
||
}, [schedule]);
|
||
|
||
useEffect(() => {
|
||
if (schedule) setScheduleForm(scheduleToForm(schedule));
|
||
}, [schedule?.schedule_id, schedule?.workflow_version]);
|
||
|
||
useEffect(() => {
|
||
setNodeForm(selectedNode ? nodeToForm(selectedNode) : EMPTY_NODE_FORM);
|
||
}, [selectedNode?.node_id, selectedNode?.updated_at]);
|
||
|
||
useEffect(() => {
|
||
if (!contextMenu) return undefined;
|
||
const close = (): void => setContextMenu(null);
|
||
const onKeyDown = (event: KeyboardEvent): void => {
|
||
if (event.key === "Escape") close();
|
||
};
|
||
window.addEventListener("pointerdown", close);
|
||
window.addEventListener("blur", close);
|
||
window.addEventListener("resize", close);
|
||
window.addEventListener("scroll", close, true);
|
||
window.addEventListener("keydown", onKeyDown);
|
||
return () => {
|
||
window.removeEventListener("pointerdown", close);
|
||
window.removeEventListener("blur", close);
|
||
window.removeEventListener("resize", close);
|
||
window.removeEventListener("scroll", close, true);
|
||
window.removeEventListener("keydown", onKeyDown);
|
||
};
|
||
}, [contextMenu]);
|
||
|
||
const clearPositionDrafts = (): void => {
|
||
positionDraftsRef.current = {};
|
||
setPositionDraftCount(0);
|
||
};
|
||
|
||
const openContextMenu = (
|
||
event: ReactMouseEvent<HTMLElement | SVGElement>,
|
||
target: ScheduleContextMenuTarget,
|
||
): void => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const menuWidth = 176;
|
||
const menuHeight = target.kind === "schedule" ? 132 : 48;
|
||
setContextMenu({
|
||
...target,
|
||
x: Math.min(event.clientX, window.innerWidth - menuWidth - 8),
|
||
y: Math.min(event.clientY, window.innerHeight - menuHeight - 8),
|
||
} as ScheduleContextMenu);
|
||
};
|
||
|
||
const applyPositionDrafts = (serverSchedule: Schedule): Schedule => {
|
||
const drafts = positionDraftsRef.current;
|
||
if (Object.keys(drafts).length === 0) return serverSchedule;
|
||
return {
|
||
...serverSchedule,
|
||
nodes: serverSchedule.nodes.map((node) => {
|
||
const draft = drafts[node.node_id];
|
||
return draft ? { ...node, ...draft } : node;
|
||
}),
|
||
};
|
||
};
|
||
|
||
const refreshRuns = async (
|
||
scheduleId: string,
|
||
showLoading = false,
|
||
): Promise<void> => {
|
||
if (showLoading) setRunsLoading(true);
|
||
try {
|
||
const items = await api.listScheduleRuns({
|
||
scheduleId,
|
||
limit: 20,
|
||
});
|
||
if (scheduleRef.current?.schedule_id === scheduleId) {
|
||
setRuns(items);
|
||
}
|
||
onConnectionChange(true);
|
||
} catch (error) {
|
||
if (showLoading) {
|
||
await handleError(error, "运行记录加载失败");
|
||
}
|
||
} finally {
|
||
if (showLoading) setRunsLoading(false);
|
||
}
|
||
};
|
||
|
||
const refreshLists = async (
|
||
preferredScheduleId?: string | null,
|
||
): Promise<void> => {
|
||
const [scheduleItems, artifactItems] = await Promise.all([
|
||
api.listSchedules(),
|
||
api.listScheduleArtifacts(),
|
||
]);
|
||
setSchedules(scheduleItems);
|
||
setArtifacts(artifactItems);
|
||
const targetId = preferredScheduleId
|
||
?? scheduleRef.current?.schedule_id
|
||
?? scheduleItems[0]?.schedule_id
|
||
?? null;
|
||
if (!targetId) {
|
||
setSchedule(null);
|
||
return;
|
||
}
|
||
const detail = await api.getSchedule(targetId);
|
||
setSchedule(applyPositionDrafts(detail));
|
||
};
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setLoading(true);
|
||
Promise.all([api.listSchedules(), api.listScheduleArtifacts()])
|
||
.then(async ([scheduleItems, artifactItems]) => {
|
||
if (cancelled) return;
|
||
setSchedules(scheduleItems);
|
||
setArtifacts(artifactItems);
|
||
if (scheduleItems[0]) {
|
||
const detail = await api.getSchedule(scheduleItems[0].schedule_id);
|
||
if (!cancelled) setSchedule(applyPositionDrafts(detail));
|
||
}
|
||
onConnectionChange(true);
|
||
})
|
||
.catch((error: unknown) => {
|
||
if (cancelled) return;
|
||
onConnectionChange(false);
|
||
onNotify({
|
||
tone: "error",
|
||
message: error instanceof Error ? error.message : "调度数据加载失败",
|
||
});
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setLoading(false);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const scheduleId = schedule?.schedule_id;
|
||
if (!scheduleId) {
|
||
setRuns([]);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
setRunsLoading(true);
|
||
api.listScheduleRuns({ scheduleId, limit: 20 })
|
||
.then((items) => {
|
||
if (!cancelled) setRuns(items);
|
||
})
|
||
.catch((error: unknown) => {
|
||
if (!cancelled) {
|
||
onNotify({
|
||
tone: "error",
|
||
message: error instanceof Error ? error.message : "运行记录加载失败",
|
||
});
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setRunsLoading(false);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [schedule?.schedule_id]);
|
||
|
||
useEffect(() => {
|
||
const scheduleId = schedule?.schedule_id;
|
||
if (!scheduleId || !runs.some((item) => item.run_status === "queued" || item.run_status === "running")) return;
|
||
const timer = window.setInterval(() => {
|
||
void refreshRuns(scheduleId);
|
||
}, 1500);
|
||
return () => window.clearInterval(timer);
|
||
}, [schedule?.schedule_id, runs]);
|
||
|
||
const handleError = async (
|
||
error: unknown,
|
||
fallback: string,
|
||
): Promise<void> => {
|
||
if (error instanceof ApiRequestError && error.status === 412) {
|
||
const currentId = scheduleRef.current?.schedule_id;
|
||
if (currentId) {
|
||
withSuppressedError(() => refreshLists(currentId));
|
||
}
|
||
onNotify({
|
||
tone: "error",
|
||
message: "调度已被其他操作更新,已重新加载最新版本",
|
||
});
|
||
return;
|
||
}
|
||
onNotify({
|
||
tone: "error",
|
||
message: error instanceof Error ? error.message : fallback,
|
||
});
|
||
};
|
||
|
||
const withMutation = async (
|
||
label: string,
|
||
action: () => Promise<Schedule>,
|
||
successMessage: string,
|
||
): Promise<Schedule | null> => {
|
||
if (busy) return null;
|
||
setBusy(label);
|
||
try {
|
||
const serverUpdated = await action();
|
||
const validNodeIds = new Set(
|
||
serverUpdated.nodes.map((node) => node.node_id),
|
||
);
|
||
positionDraftsRef.current = Object.fromEntries(
|
||
Object.entries(positionDraftsRef.current).filter(([nodeId]) => (
|
||
validNodeIds.has(nodeId)
|
||
)),
|
||
);
|
||
setPositionDraftCount(Object.keys(positionDraftsRef.current).length);
|
||
const updated = applyPositionDrafts(serverUpdated);
|
||
setSchedule(updated);
|
||
setSchedules((current) => {
|
||
const summary = { ...updated, nodes: [], edges: [] };
|
||
const index = current.findIndex(
|
||
(item) => item.schedule_id === updated.schedule_id,
|
||
);
|
||
if (index < 0) return [summary, ...current];
|
||
return current.map((item) => (
|
||
item.schedule_id === updated.schedule_id ? summary : item
|
||
));
|
||
});
|
||
onNotify({ tone: "success", message: successMessage });
|
||
return updated;
|
||
} catch (error) {
|
||
await handleError(error, `${successMessage}失败`);
|
||
return null;
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const chooseSchedule = async (scheduleId: string): Promise<void> => {
|
||
if (scheduleId === schedule?.schedule_id || busy) return;
|
||
setBusy("load-schedule");
|
||
clearPositionDrafts();
|
||
setSelectedNodeId(null);
|
||
setSelectedEdgeId(null);
|
||
setLinkSourceId(null);
|
||
setCronResult(null);
|
||
try {
|
||
setSchedule(await api.getSchedule(scheduleId));
|
||
onConnectionChange(true);
|
||
} catch (error) {
|
||
await handleError(error, "调度详情加载失败");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const openCreateScheduleDialog = (): void => {
|
||
if (busy) return;
|
||
setContextMenu(null);
|
||
setNewScheduleName(`新建调度 ${schedules.length + 1}`);
|
||
setCreateDialogOpen(true);
|
||
};
|
||
|
||
const addSchedule = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||
event.preventDefault();
|
||
const scheduleName = newScheduleName.trim();
|
||
if (busy || !scheduleName) return;
|
||
setBusy("create-schedule");
|
||
try {
|
||
const created = await api.createSchedule({
|
||
schedule_name: scheduleName,
|
||
description: "在画布中拖入稳定版本并配置执行顺序",
|
||
trigger_type: "manual",
|
||
timezone: "Asia/Shanghai",
|
||
enabled: false,
|
||
});
|
||
setSchedules((current) => [created, ...current]);
|
||
setSchedule(created);
|
||
clearPositionDrafts();
|
||
setSelectedNodeId(null);
|
||
setCreateDialogOpen(false);
|
||
setNewScheduleName("");
|
||
onNotify({ tone: "success", message: "调度方案已创建" });
|
||
} catch (error) {
|
||
await handleError(error, "创建调度失败");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const removeSchedule = async (target?: Schedule): Promise<void> => {
|
||
const selectedSchedule = target ?? schedule;
|
||
if (!selectedSchedule || busy) return;
|
||
setContextMenu(null);
|
||
if (!window.confirm(`确定删除调度"${selectedSchedule.schedule_name}"吗?`)) return;
|
||
setBusy("delete-schedule");
|
||
try {
|
||
await api.deleteSchedule(
|
||
selectedSchedule.schedule_id,
|
||
selectedSchedule.workflow_version,
|
||
);
|
||
const remaining = schedules.filter(
|
||
(item) => item.schedule_id !== selectedSchedule.schedule_id,
|
||
);
|
||
setSchedules(remaining);
|
||
if (schedule?.schedule_id === selectedSchedule.schedule_id) {
|
||
setSchedule(null);
|
||
clearPositionDrafts();
|
||
setSelectedNodeId(null);
|
||
setSelectedEdgeId(null);
|
||
if (remaining[0]) {
|
||
setSchedule(await api.getSchedule(remaining[0].schedule_id));
|
||
}
|
||
}
|
||
onNotify({ tone: "success", message: "调度方案已删除" });
|
||
} catch (error) {
|
||
await handleError(error, "删除调度失败");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const renameSchedule = async (target: Schedule): Promise<void> => {
|
||
if (busy) return;
|
||
setContextMenu(null);
|
||
const scheduleName = window.prompt("请输入新的调度方案名称", target.schedule_name)?.trim();
|
||
if (!scheduleName || scheduleName === target.schedule_name) return;
|
||
setBusy("rename-schedule");
|
||
try {
|
||
const updated = await api.updateSchedule(target.schedule_id, {
|
||
workflow_version: target.workflow_version,
|
||
schedule_name: scheduleName,
|
||
});
|
||
setSchedules((current) => current.map((item) => (
|
||
item.schedule_id === updated.schedule_id
|
||
? { ...updated, nodes: [], edges: [] }
|
||
: item
|
||
)));
|
||
if (schedule?.schedule_id === updated.schedule_id) setSchedule(updated);
|
||
onNotify({ tone: "success", message: "调度方案已改名" });
|
||
} catch (error) {
|
||
await handleError(error, "调度方案改名失败");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const removeArtifact = async (
|
||
artifact: ScheduleArtifact,
|
||
): Promise<void> => {
|
||
if (busy) return;
|
||
setContextMenu(null);
|
||
if (
|
||
!window.confirm(
|
||
`确定将"${artifact.script_name} ${artifact.version_label}"移出调度列表吗?\n`
|
||
+ "稳定版本本身和历史运行记录不会被删除。",
|
||
)
|
||
) return;
|
||
setBusy("delete-artifact");
|
||
try {
|
||
await api.hideScheduleArtifact(artifact.versions_id);
|
||
setArtifacts((current) => current.filter(
|
||
(item) => item.versions_id !== artifact.versions_id,
|
||
));
|
||
onNotify({
|
||
tone: "success",
|
||
message: "已移出调度列表,稳定版本和历史记录保持不变",
|
||
});
|
||
} catch (error) {
|
||
await handleError(error, "移出调度列表失败");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const saveSchedule = async (): Promise<void> => {
|
||
if (!schedule || busy) return;
|
||
const maxConcurrency = Number(scheduleForm.maxConcurrency);
|
||
if (!scheduleForm.scheduleName.trim()) {
|
||
onNotify({ tone: "error", message: "调度名称不能为空" });
|
||
return;
|
||
}
|
||
if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) {
|
||
onNotify({ tone: "error", message: "最大并发数必须是正整数" });
|
||
return;
|
||
}
|
||
setBusy("save-schedule");
|
||
let updated = scheduleRef.current ?? schedule;
|
||
try {
|
||
for (const [nodeId, position] of Object.entries(
|
||
positionDraftsRef.current,
|
||
)) {
|
||
updated = await api.updateScheduleNode(updated.schedule_id, nodeId, {
|
||
workflow_version: updated.workflow_version,
|
||
position_x: position.position_x,
|
||
position_y: position.position_y,
|
||
});
|
||
}
|
||
updated = await api.updateSchedule(updated.schedule_id, {
|
||
workflow_version: updated.workflow_version,
|
||
schedule_name: scheduleForm.scheduleName.trim(),
|
||
description: scheduleForm.description.trim() || null,
|
||
trigger_type: scheduleForm.triggerType,
|
||
cron_expression: scheduleForm.triggerType === "cron"
|
||
? scheduleForm.cronExpression.trim()
|
||
: null,
|
||
timezone: scheduleForm.timezone.trim(),
|
||
enabled: scheduleForm.enabled,
|
||
max_concurrency: maxConcurrency,
|
||
failure_policy: scheduleForm.failurePolicy,
|
||
});
|
||
clearPositionDrafts();
|
||
setSchedule(updated);
|
||
setSchedules((current) => current.map((item) => (
|
||
item.schedule_id === updated.schedule_id
|
||
? { ...updated, nodes: [], edges: [] }
|
||
: item
|
||
)));
|
||
onNotify({ tone: "success", message: "调度配置已保存" });
|
||
} catch (error) {
|
||
const localDraft = applyPositionDrafts(updated);
|
||
scheduleRef.current = localDraft;
|
||
setSchedule(localDraft);
|
||
await handleError(error, "调度配置保存失败");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const runCronPreview = async (): Promise<void> => {
|
||
if (scheduleForm.triggerType !== "cron") return;
|
||
if (busy) return;
|
||
setBusy("cron-preview");
|
||
try {
|
||
const result = await api.previewCron({
|
||
cron_expression: scheduleForm.cronExpression.trim(),
|
||
timezone: scheduleForm.timezone.trim(),
|
||
count: 5,
|
||
});
|
||
setCronResult(result);
|
||
onNotify({ tone: "success", message: "Cron 表达式校验通过" });
|
||
} catch (error) {
|
||
setCronResult(null);
|
||
await handleError(error, "Cron 预览失败");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const runNow = async (): Promise<void> => {
|
||
if (!schedule || busy) return;
|
||
if (positionDraftCount > 0) {
|
||
onNotify({
|
||
tone: "info",
|
||
message: "还有节点位置未保存,请先点击'保存配置'再运行",
|
||
});
|
||
return;
|
||
}
|
||
if (!schedule.dag_validation.valid || schedule.nodes.length === 0) {
|
||
onNotify({
|
||
tone: "error",
|
||
message: "当前调度必须包含有效的非空 DAG 才能运行",
|
||
});
|
||
return;
|
||
}
|
||
setBusy("run-now");
|
||
try {
|
||
const created = await api.runScheduleNow(schedule.schedule_id);
|
||
setRuns((current) => [
|
||
created,
|
||
...current.filter((item) => item.run_id !== created.run_id),
|
||
].slice(0, 20));
|
||
setSchedule((current) => (
|
||
current ? { ...current, last_run_at: created.queued_at } : current
|
||
));
|
||
setSchedules((current) => current.map((item) => (
|
||
item.schedule_id === schedule.schedule_id
|
||
? { ...item, last_run_at: created.queued_at }
|
||
: item
|
||
)));
|
||
onNotify({
|
||
tone: "success",
|
||
message: `运行 ${created.run_id.slice(-8)} 已进入队列`,
|
||
});
|
||
} catch (error) {
|
||
await handleError(error, "立即运行失败");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const addArtifactAt = async (
|
||
artifact: ScheduleArtifact,
|
||
positionX: number,
|
||
positionY: number,
|
||
): Promise<void> => {
|
||
if (!schedule) {
|
||
onNotify({ tone: "info", message: "请先新建或选择一个调度方案" });
|
||
return;
|
||
}
|
||
const nodeKey = artifactNodeKey(artifact, schedule);
|
||
const updated = await withMutation(
|
||
"add-node",
|
||
() => api.createScheduleNode(schedule.schedule_id, {
|
||
workflow_version: schedule.workflow_version,
|
||
node_key: nodeKey,
|
||
node_name: artifact.script_name,
|
||
versions_id: artifact.versions_id,
|
||
timeout_seconds: 600,
|
||
retry_count: 0,
|
||
retry_interval_sec: 5,
|
||
position_x: Math.max(20, Math.round(positionX)),
|
||
position_y: Math.max(20, Math.round(positionY)),
|
||
arguments_json: {},
|
||
env_refs_json: {},
|
||
}),
|
||
`${artifact.script_name} 已加入画布`,
|
||
);
|
||
if (updated) {
|
||
const created = updated.nodes.find((item) => item.node_key === nodeKey);
|
||
setSelectedNodeId(created?.node_id ?? null);
|
||
}
|
||
};
|
||
|
||
const onCanvasDrop = (event: DragEvent<HTMLDivElement>): void => {
|
||
event.preventDefault();
|
||
const versionsId = event.dataTransfer.getData(ARTIFACT_MIME)
|
||
|| event.dataTransfer.getData("text/plain");
|
||
const artifact = artifacts.find((item) => item.versions_id === versionsId);
|
||
if (!artifact || !canvasRef.current) return;
|
||
const rect = canvasRef.current.getBoundingClientRect();
|
||
const positionX = event.clientX - rect.left
|
||
+ canvasRef.current.scrollLeft - NODE_WIDTH / 2;
|
||
const positionY = event.clientY - rect.top
|
||
+ canvasRef.current.scrollTop - NODE_HEIGHT / 2;
|
||
void addArtifactAt(artifact, positionX, positionY);
|
||
};
|
||
|
||
const startNodeDrag = (
|
||
event: ReactPointerEvent<HTMLDivElement>,
|
||
node: ScheduleNode,
|
||
): void => {
|
||
if (event.button !== 0 || busy || linkSourceId) return;
|
||
const target = event.target as HTMLElement;
|
||
if (target.closest("button")) return;
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
dragRef.current = {
|
||
nodeId: node.node_id,
|
||
pointerId: event.pointerId,
|
||
startClientX: event.clientX,
|
||
startClientY: event.clientY,
|
||
originX: node.position_x,
|
||
originY: node.position_y,
|
||
moved: false,
|
||
};
|
||
setSelectedNodeId(node.node_id);
|
||
setSelectedEdgeId(null);
|
||
};
|
||
|
||
const moveNode = (
|
||
event: ReactPointerEvent<HTMLDivElement>,
|
||
): void => {
|
||
const drag = dragRef.current;
|
||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||
const deltaX = event.clientX - drag.startClientX;
|
||
const deltaY = event.clientY - drag.startClientY;
|
||
if (Math.abs(deltaX) + Math.abs(deltaY) > 3) drag.moved = true;
|
||
setSchedule((current) => {
|
||
if (!current) return current;
|
||
const next = {
|
||
...current,
|
||
nodes: current.nodes.map((item) => (
|
||
item.node_id === drag.nodeId
|
||
? {
|
||
...item,
|
||
position_x: Math.max(10, drag.originX + deltaX),
|
||
position_y: Math.max(10, drag.originY + deltaY),
|
||
}
|
||
: item
|
||
)),
|
||
};
|
||
scheduleRef.current = next;
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const finishNodeDrag = (
|
||
event: ReactPointerEvent<HTMLDivElement>,
|
||
): void => {
|
||
const drag = dragRef.current;
|
||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||
dragRef.current = null;
|
||
if (!drag.moved) return;
|
||
const current = scheduleRef.current;
|
||
const node = current?.nodes.find((item) => item.node_id === drag.nodeId);
|
||
if (!current || !node) return;
|
||
const position = {
|
||
position_x: Math.round(node.position_x),
|
||
position_y: Math.round(node.position_y),
|
||
};
|
||
positionDraftsRef.current = {
|
||
...positionDraftsRef.current,
|
||
[node.node_id]: position,
|
||
};
|
||
setPositionDraftCount(Object.keys(positionDraftsRef.current).length);
|
||
setSchedule((value) => {
|
||
if (!value) return value;
|
||
const next = {
|
||
...value,
|
||
nodes: value.nodes.map((item) => (
|
||
item.node_id === node.node_id ? { ...item, ...position } : item
|
||
)),
|
||
};
|
||
scheduleRef.current = next;
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const connectTo = async (targetNodeId: string): Promise<void> => {
|
||
if (!schedule || !linkSourceId || busy) return;
|
||
if (linkSourceId === targetNodeId) {
|
||
setLinkSourceId(null);
|
||
onNotify({ tone: "info", message: "已取消连线" });
|
||
return;
|
||
}
|
||
const sourceId = linkSourceId;
|
||
setLinkSourceId(null);
|
||
await withMutation(
|
||
"create-edge",
|
||
() => api.createScheduleEdge(schedule.schedule_id, {
|
||
workflow_version: schedule.workflow_version,
|
||
source_node_id: sourceId,
|
||
target_node_id: targetNodeId,
|
||
}),
|
||
"节点连线已创建",
|
||
);
|
||
};
|
||
|
||
const saveNode = async (): Promise<void> => {
|
||
if (!schedule || !selectedNode) return;
|
||
try {
|
||
const timeoutSeconds = Number(nodeForm.timeoutSeconds);
|
||
const retryCount = Number(nodeForm.retryCount);
|
||
const retryIntervalSec = Number(nodeForm.retryIntervalSec);
|
||
if (!nodeForm.nodeName.trim()) throw new Error("节点名称不能为空");
|
||
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1) {
|
||
throw new Error("超时时间必须是正整数");
|
||
}
|
||
if (!Number.isInteger(retryCount) || retryCount < 0) {
|
||
throw new Error("重试次数必须是非负整数");
|
||
}
|
||
if (!Number.isInteger(retryIntervalSec) || retryIntervalSec < 0) {
|
||
throw new Error("重试间隔必须是非负整数");
|
||
}
|
||
const argumentsJson = parseObject(nodeForm.argumentsJson, "运行参数");
|
||
const rawEnv = parseObject(nodeForm.envRefsJson, "环境引用");
|
||
const envRefsJson = Object.fromEntries(
|
||
Object.entries(rawEnv).map(([key, value]) => {
|
||
if (typeof value !== "string") {
|
||
throw new Error("环境引用的值必须是字符串");
|
||
}
|
||
return [key, value];
|
||
}),
|
||
);
|
||
await withMutation(
|
||
"save-node",
|
||
() => api.updateScheduleNode(schedule.schedule_id, selectedNode.node_id, {
|
||
workflow_version: schedule.workflow_version,
|
||
node_name: nodeForm.nodeName.trim(),
|
||
timeout_seconds: timeoutSeconds,
|
||
retry_count: retryCount,
|
||
retry_interval_sec: retryIntervalSec,
|
||
arguments_json: argumentsJson,
|
||
env_refs_json: envRefsJson,
|
||
}),
|
||
"节点配置已保存",
|
||
);
|
||
} catch (error) {
|
||
await handleError(error, "节点配置保存失败");
|
||
}
|
||
};
|
||
|
||
const removeNode = async (target?: ScheduleNode): Promise<void> => {
|
||
const node = target ?? selectedNode;
|
||
if (!schedule || !node || busy) return;
|
||
setContextMenu(null);
|
||
if (!window.confirm(`确定删除节点"${node.node_name}"吗?`)) return;
|
||
const updated = await withMutation(
|
||
"delete-node",
|
||
() => api.deleteScheduleNode(
|
||
schedule.schedule_id,
|
||
node.node_id,
|
||
schedule.workflow_version,
|
||
),
|
||
"节点已删除",
|
||
);
|
||
if (updated && selectedNodeId === node.node_id) setSelectedNodeId(null);
|
||
};
|
||
|
||
const removeEdge = async (target?: ScheduleEdge): Promise<void> => {
|
||
const edge = target ?? selectedEdge;
|
||
if (!schedule || !edge || busy) return;
|
||
setContextMenu(null);
|
||
const updated = await withMutation(
|
||
"delete-edge",
|
||
() => api.deleteScheduleEdge(
|
||
schedule.schedule_id,
|
||
edge.edge_id,
|
||
schedule.workflow_version,
|
||
),
|
||
"连线已删除",
|
||
);
|
||
if (updated && selectedEdgeId === edge.edge_id) setSelectedEdgeId(null);
|
||
};
|
||
|
||
const checkDag = async (): Promise<void> => {
|
||
if (!schedule || busy) return;
|
||
setBusy("validate");
|
||
try {
|
||
const result = await api.validateSchedule(schedule.schedule_id);
|
||
setSchedule((current) => current
|
||
? { ...current, dag_validation: result }
|
||
: current);
|
||
onNotify({
|
||
tone: result.valid ? "success" : "error",
|
||
message: result.valid
|
||
? `DAG 校验通过,共 ${result.node_count} 个节点`
|
||
: result.errors.map((item) => item.message).join(";"),
|
||
});
|
||
} catch (error) {
|
||
await handleError(error, "DAG 校验失败");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const filteredSchedules = useMemo(() => {
|
||
const keyword = scheduleKeyword.trim().toLowerCase();
|
||
return keyword
|
||
? schedules.filter((item) => item.schedule_name.toLowerCase().includes(keyword))
|
||
: schedules;
|
||
}, [schedules, scheduleKeyword]);
|
||
|
||
const filteredArtifacts = useMemo(() => {
|
||
const keyword = artifactKeyword.trim().toLowerCase();
|
||
return keyword
|
||
? artifacts.filter((item) => (
|
||
item.script_name.toLowerCase().includes(keyword)
|
||
|| item.version_label.toLowerCase().includes(keyword)
|
||
))
|
||
: artifacts;
|
||
}, [artifacts, artifactKeyword]);
|
||
|
||
return (
|
||
<section className="schedule-page">
|
||
<header className="schedule-toolbar">
|
||
<div>
|
||
<strong>图形化调度</strong>
|
||
<span>
|
||
{schedule
|
||
? `版本 ${schedule.workflow_version} · ${schedule.dag_validation.valid ? "DAG 有效" : "待校验"}${
|
||
positionDraftCount > 0
|
||
? ` · ${positionDraftCount} 个节点位置待保存`
|
||
: ""
|
||
}`
|
||
: "创建调度后开始编排"}
|
||
</span>
|
||
</div>
|
||
<div className="schedule-toolbar__actions">
|
||
<button type="button" onClick={openCreateScheduleDialog}>
|
||
<Icon name="plus" size={15} />新建
|
||
</button>
|
||
<button
|
||
className="schedule-action--primary"
|
||
type="button"
|
||
disabled={!schedule || Boolean(busy)}
|
||
onClick={() => void saveSchedule()}
|
||
>
|
||
<Icon name="check" size={15} />保存配置
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!schedule || Boolean(busy)}
|
||
onClick={() => void checkDag()}
|
||
>
|
||
校验 DAG
|
||
</button>
|
||
<button
|
||
className="schedule-action--run"
|
||
type="button"
|
||
disabled={
|
||
!schedule
|
||
|| Boolean(busy)
|
||
|| !schedule.dag_validation.valid
|
||
|| schedule.nodes.length === 0
|
||
}
|
||
title={
|
||
positionDraftCount > 0
|
||
? "请先保存节点位置"
|
||
: "立即执行当前已保存的稳定版本 DAG"
|
||
}
|
||
onClick={() => void runNow()}
|
||
>
|
||
<Icon name="play" size={14} />立即运行
|
||
</button>
|
||
<button
|
||
className="schedule-action--danger"
|
||
type="button"
|
||
disabled={!schedule || Boolean(busy)}
|
||
onClick={() => void removeSchedule(schedule ?? undefined)}
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="schedule-workbench">
|
||
<aside className="schedule-left">
|
||
<ScheduleList
|
||
schedules={filteredSchedules}
|
||
selectedScheduleId={schedule?.schedule_id ?? null}
|
||
keyword={scheduleKeyword}
|
||
loading={loading}
|
||
onSelect={chooseSchedule}
|
||
onContextMenu={(event, item) => openContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, item ? { kind: "schedule", schedule: item } : { kind: "schedule-list" })}
|
||
onKeywordChange={setScheduleKeyword}
|
||
/>
|
||
<ArtifactList
|
||
artifacts={filteredArtifacts}
|
||
keyword={artifactKeyword}
|
||
onDrop={(artifact, x, y) => void addArtifactAt(artifact, x, y)}
|
||
onDoubleClick={(artifact) => void addArtifactAt(artifact, 90 + (schedule?.nodes.length ?? 0) * 245, 130)}
|
||
onContextMenu={(event, artifact) => openContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, { kind: "artifact", artifact })}
|
||
onKeywordChange={setArtifactKeyword}
|
||
/>
|
||
</aside>
|
||
|
||
<main className="schedule-center">
|
||
<ScheduleCanvasHeader
|
||
linkSourceId={linkSourceId}
|
||
onCancelLink={() => setLinkSourceId(null)}
|
||
/>
|
||
<div
|
||
className={`schedule-canvas${linkSourceId ? " is-linking" : ""}`}
|
||
ref={canvasRef}
|
||
onDragOver={(event) => {
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = "copy";
|
||
}}
|
||
onDrop={onCanvasDrop}
|
||
onClick={(event) => {
|
||
if (event.target === event.currentTarget) {
|
||
setSelectedNodeId(null);
|
||
setSelectedEdgeId(null);
|
||
}
|
||
}}
|
||
>
|
||
<div
|
||
className="schedule-canvas__surface"
|
||
style={{ width: CANVAS_WIDTH, height: CANVAS_HEIGHT }}
|
||
>
|
||
{schedule && (
|
||
<svg
|
||
className="schedule-edges"
|
||
viewBox={`0 0 ${CANVAS_WIDTH} ${CANVAS_HEIGHT}`}
|
||
aria-label="调度连线"
|
||
>
|
||
<defs>
|
||
<marker
|
||
id="schedule-arrow"
|
||
markerWidth="8"
|
||
markerHeight="8"
|
||
refX="7"
|
||
refY="4"
|
||
orient="auto"
|
||
>
|
||
<path d="M0,0 L8,4 L0,8 Z" fill="#4e92db" />
|
||
</marker>
|
||
</defs>
|
||
{schedule.edges.map((edge) => {
|
||
const source = schedule.nodes.find(
|
||
(node) => node.node_id === edge.source_node_id,
|
||
);
|
||
const target = schedule.nodes.find(
|
||
(node) => node.node_id === edge.target_node_id,
|
||
);
|
||
if (!source || !target) return null;
|
||
const path = edgePath(source, target);
|
||
return (
|
||
<g
|
||
className={selectedEdgeId === edge.edge_id ? "is-selected" : ""}
|
||
key={edge.edge_id}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setSelectedEdgeId(edge.edge_id);
|
||
setSelectedNodeId(null);
|
||
}}
|
||
onContextMenu={(event) => openContextMenu(event, {
|
||
kind: "edge",
|
||
edge,
|
||
})}
|
||
>
|
||
<path className="schedule-edge-hit" d={path} />
|
||
<path
|
||
className="schedule-edge-line"
|
||
d={path}
|
||
markerEnd="url(#schedule-arrow)"
|
||
/>
|
||
</g>
|
||
);
|
||
})}
|
||
</svg>
|
||
)}
|
||
|
||
{schedule?.nodes.map((node) => (
|
||
<div
|
||
className={`schedule-node${
|
||
selectedNodeId === node.node_id ? " is-selected" : ""
|
||
}`}
|
||
key={node.node_id}
|
||
style={{
|
||
left: node.position_x,
|
||
top: node.position_y,
|
||
width: NODE_WIDTH,
|
||
height: NODE_HEIGHT,
|
||
}}
|
||
onPointerDown={(event) => startNodeDrag(event, node)}
|
||
onPointerMove={moveNode}
|
||
onPointerUp={finishNodeDrag}
|
||
onPointerCancel={finishNodeDrag}
|
||
onContextMenu={(event) => openContextMenu(event, {
|
||
kind: "node",
|
||
node,
|
||
})}
|
||
>
|
||
<button
|
||
className="schedule-node__port schedule-node__port--in"
|
||
type="button"
|
||
aria-label={`连接到${node.node_name}`}
|
||
title={linkSourceId ? "点击完成连线" : "输入端口"}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
void connectTo(node.node_id);
|
||
}}
|
||
/>
|
||
<div className="schedule-node__heading">
|
||
<span className={`schedule-node__type schedule-node__type--${node.version.script_type}`}>
|
||
<Icon
|
||
name={node.version.script_type === "notebook" ? "notebook" : "python"}
|
||
size={14}
|
||
/>
|
||
</span>
|
||
<strong>{node.node_name}</strong>
|
||
<button
|
||
type="button"
|
||
aria-label="删除节点"
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setSelectedNodeId(node.node_id);
|
||
setSelectedEdgeId(null);
|
||
}}
|
||
>
|
||
<Icon name="settings" size={14} />
|
||
</button>
|
||
</div>
|
||
<p>{node.version.script_name}</p>
|
||
<footer>
|
||
<span>{node.node_key}</span>
|
||
<b>{node.version.version_label}</b>
|
||
</footer>
|
||
<button
|
||
className={`schedule-node__port schedule-node__port--out${
|
||
linkSourceId === node.node_id ? " is-active" : ""
|
||
}`}
|
||
type="button"
|
||
aria-label={`从${node.node_name}开始连线`}
|
||
title="输出端口"
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setLinkSourceId((current) => (
|
||
current === node.node_id ? null : node.node_id
|
||
));
|
||
setSelectedNodeId(node.node_id);
|
||
setSelectedEdgeId(null);
|
||
}}
|
||
/>
|
||
</div>
|
||
))}
|
||
|
||
{!schedule ? (
|
||
<div className="schedule-canvas-empty">
|
||
<Icon name="schedule" size={34} />
|
||
<strong>还没有选中调度方案</strong>
|
||
<p>点击"新建"创建一个调度,然后拖入稳定版本脚本。</p>
|
||
<button type="button" onClick={openCreateScheduleDialog}>
|
||
<Icon name="plus" size={15} />新建调度
|
||
</button>
|
||
</div>
|
||
) : schedule.nodes.length === 0 ? (
|
||
<div className="schedule-canvas-empty">
|
||
<Icon name="release" size={34} />
|
||
<strong>从稳定版本开始编排</strong>
|
||
<p>把左侧脚本卡片拖到这里,或双击卡片快速加入。</p>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<footer className="schedule-canvas-status">
|
||
<span className={schedule?.dag_validation.valid ? "is-valid" : ""}>
|
||
{schedule?.dag_validation.valid ? "✓ DAG 有效" : "○ DAG 待校验"}
|
||
</span>
|
||
<span>{schedule?.nodes.length ?? 0} 个节点</span>
|
||
<span>{schedule?.edges.length ?? 0} 条连线</span>
|
||
{selectedEdge && (
|
||
<button type="button" onClick={() => void removeEdge(selectedEdge)}>
|
||
删除选中连线
|
||
</button>
|
||
)}
|
||
</footer>
|
||
</main>
|
||
|
||
<aside className="schedule-right">
|
||
<div className="schedule-inspector-scroll">
|
||
{selectedNode ? (
|
||
<NodeInspector
|
||
node={selectedNode}
|
||
form={nodeForm}
|
||
busy={Boolean(busy)}
|
||
onChange={setNodeForm}
|
||
onSave={() => void saveNode()}
|
||
onDelete={() => void removeNode(selectedNode)}
|
||
/>
|
||
) : (
|
||
<ScheduleInspector
|
||
schedule={schedule}
|
||
form={scheduleForm}
|
||
cronResult={cronResult}
|
||
busy={Boolean(busy)}
|
||
onChange={setScheduleForm}
|
||
onPreview={() => void runCronPreview()}
|
||
onSave={() => void saveSchedule()}
|
||
/>
|
||
)}
|
||
</div>
|
||
<RunHistory
|
||
runs={runs}
|
||
loading={runsLoading}
|
||
disabled={!schedule || Boolean(busy)}
|
||
onRefresh={() => {
|
||
if (schedule) void refreshRuns(schedule.schedule_id, true);
|
||
}}
|
||
/>
|
||
</aside>
|
||
</div>
|
||
|
||
{contextMenu && (
|
||
<div
|
||
className="schedule-context-menu"
|
||
role="menu"
|
||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
>
|
||
{contextMenu.kind === "schedule-list" && (
|
||
<button
|
||
className="schedule-context-menu__action"
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={openCreateScheduleDialog}
|
||
>
|
||
<Icon name="plus" size={14} />
|
||
新建调度方案
|
||
</button>
|
||
)}
|
||
{contextMenu.kind === "schedule" && (
|
||
<>
|
||
<button
|
||
className="schedule-context-menu__action"
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={openCreateScheduleDialog}
|
||
>
|
||
<Icon name="plus" size={14} />
|
||
新建调度方案
|
||
</button>
|
||
<button
|
||
className="schedule-context-menu__action"
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => void renameSchedule(contextMenu.schedule)}
|
||
>
|
||
<Icon name="settings" size={14} />
|
||
改名
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => void removeSchedule(contextMenu.schedule)}
|
||
>
|
||
<Icon name="close" size={14} />
|
||
删除调度方案
|
||
</button>
|
||
</>
|
||
)}
|
||
{contextMenu.kind === "artifact" && (
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => void removeArtifact(contextMenu.artifact)}
|
||
>
|
||
<Icon name="close" size={14} />
|
||
移出调度列表
|
||
</button>
|
||
)}
|
||
{contextMenu.kind === "node" && (
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => void removeNode(contextMenu.node)}
|
||
>
|
||
<Icon name="close" size={14} />
|
||
删除画布节点
|
||
</button>
|
||
)}
|
||
{contextMenu.kind === "edge" && (
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => void removeEdge(contextMenu.edge)}
|
||
>
|
||
<Icon name="close" size={14} />
|
||
删除画布连线
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{createDialogOpen && (
|
||
<div className="modal-backdrop" role="presentation">
|
||
<section
|
||
className="modal modal--compact"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="create-schedule-title"
|
||
>
|
||
<div className="modal__header">
|
||
<div>
|
||
<span className="modal__eyebrow">SCHEDULE</span>
|
||
<h2 id="create-schedule-title">新建调度方案</h2>
|
||
</div>
|
||
<button
|
||
className="icon-button"
|
||
type="button"
|
||
aria-label="关闭"
|
||
disabled={Boolean(busy)}
|
||
onClick={() => setCreateDialogOpen(false)}
|
||
>
|
||
<Icon name="close" />
|
||
</button>
|
||
</div>
|
||
<form onSubmit={(event) => void addSchedule(event)}>
|
||
<label className="form-field">
|
||
<span>调度方案名称</span>
|
||
<input
|
||
autoFocus
|
||
maxLength={255}
|
||
placeholder="请输入调度方案名称"
|
||
value={newScheduleName}
|
||
onChange={(event) => setNewScheduleName(event.target.value)}
|
||
onFocus={(event) => event.currentTarget.select()}
|
||
/>
|
||
</label>
|
||
<div className="modal__footer">
|
||
<button
|
||
className="secondary-button"
|
||
type="button"
|
||
disabled={Boolean(busy)}
|
||
onClick={() => setCreateDialogOpen(false)}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
className="primary-button"
|
||
type="submit"
|
||
disabled={Boolean(busy) || !newScheduleName.trim()}
|
||
>
|
||
{busy === "create-schedule"
|
||
? <span className="button-spinner" />
|
||
: <Icon name="plus" size={15} />}
|
||
{busy === "create-schedule" ? "正在创建…" : "创建调度"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
</div>
|
||
)}
|
||
|
||
{busy && (
|
||
<div className="schedule-busy" aria-live="polite">
|
||
<span />
|
||
{busy === "run-now" ? "正在创建运行…" : "正在同步调度配置…"}
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|