655 lines
25 KiB
TypeScript
655 lines
25 KiB
TypeScript
import {
|
|
type DragEvent,
|
|
type FormEvent,
|
|
type MouseEvent as ReactMouseEvent,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
|
|
import {
|
|
type Schedule,
|
|
type ScheduleArtifact,
|
|
type ScheduleEdge,
|
|
type ScheduleNode,
|
|
} from "../../services/api";
|
|
|
|
import { useApi, useAuth } from "../../context/AuthContext";
|
|
import Icon from "../../components/common/Icon";
|
|
import { NodeInspector } from "./NodeInspector";
|
|
import { RunHistory } from "./RunHistory";
|
|
import { ScheduleInspector } from "./ScheduleInspector";
|
|
import { ScheduleList } from "./ScheduleList";
|
|
import { ArtifactList } from "./ArtifactList";
|
|
import { ScheduleCanvasHeader } from "./ScheduleCanvasHeader";
|
|
import { useCanvasNodeDrag } from "./hooks/useCanvasNodeDrag";
|
|
import { useContextMenuDismiss } from "./hooks/useContextMenuDismiss";
|
|
import {
|
|
EMPTY_NODE_FORM,
|
|
type ScheduleContextMenu,
|
|
} from "./state/schedulesStore";
|
|
import { useSchedulesStore } from "./state/schedulesStore";
|
|
import { edgePath, nodeToForm, scheduleToForm } from "./utils";
|
|
import "../../styles/schedule.css";
|
|
|
|
type Notice = {
|
|
tone: "success" | "error" | "info";
|
|
message: string;
|
|
};
|
|
|
|
type ScheduleContextMenuTarget =
|
|
| { kind: "schedule-list" }
|
|
| { kind: "schedule"; schedule: Schedule }
|
|
| { kind: "artifact"; artifact: ScheduleArtifact }
|
|
| { kind: "node"; node: ScheduleNode }
|
|
| { kind: "edge"; edge: ScheduleEdge };
|
|
|
|
const CANVAS_WIDTH = 1400;
|
|
const CANVAS_HEIGHT = 860;
|
|
const NODE_WIDTH = 218;
|
|
const NODE_HEIGHT = 104;
|
|
const ARTIFACT_MIME = "application/x-model-platform-version";
|
|
|
|
export default function SchedulePage({
|
|
onNotify,
|
|
onConnectionChange,
|
|
}: {
|
|
onNotify: (notice: Notice) => void;
|
|
onConnectionChange: (online: boolean) => void;
|
|
}) {
|
|
const { currentWorkspace } = useAuth();
|
|
const workspaceId = currentWorkspace?.workspace_id;
|
|
const api = useApi();
|
|
|
|
// 核心数据 state 迁 store(其余 form/dialog/selected/runs 仍用 useState)
|
|
const schedules = useSchedulesStore((s) => s.schedules);
|
|
const artifacts = useSchedulesStore((s) => s.artifacts);
|
|
const schedule = useSchedulesStore((s) => s.schedule);
|
|
const loading = useSchedulesStore((s) => s.loading);
|
|
const busy = useSchedulesStore((s) => s.busy);
|
|
const setSchedules = useSchedulesStore((s) => s.setSchedules);
|
|
const setArtifacts = useSchedulesStore((s) => s.setArtifacts);
|
|
const setSchedule = useSchedulesStore((s) => s.setSchedule);
|
|
const setLoading = useSchedulesStore((s) => s.setLoading);
|
|
const setBusy = useSchedulesStore((s) => s.setBusy);
|
|
|
|
const selectedNodeId = useSchedulesStore((s) => s.selectedNodeId);
|
|
const setSelectedNodeId = useSchedulesStore((s) => s.setSelectedNodeId);
|
|
const selectedEdgeId = useSchedulesStore((s) => s.selectedEdgeId);
|
|
const setSelectedEdgeId = useSchedulesStore((s) => s.setSelectedEdgeId);
|
|
const linkSourceId = useSchedulesStore((s) => s.linkSourceId);
|
|
const setLinkSourceId = useSchedulesStore((s) => s.setLinkSourceId);
|
|
const scheduleKeyword = useSchedulesStore((s) => s.scheduleKeyword);
|
|
const setScheduleKeyword = useSchedulesStore((s) => s.setScheduleKeyword);
|
|
const artifactKeyword = useSchedulesStore((s) => s.artifactKeyword);
|
|
const setArtifactKeyword = useSchedulesStore((s) => s.setArtifactKeyword);
|
|
const createDialogOpen = useSchedulesStore((s) => s.createDialogOpen);
|
|
const setCreateDialogOpen = useSchedulesStore((s) => s.setCreateDialogOpen);
|
|
const newScheduleName = useSchedulesStore((s) => s.newScheduleName);
|
|
const setNewScheduleName = useSchedulesStore((s) => s.setNewScheduleName);
|
|
const contextMenu = useSchedulesStore((s) => s.contextMenu);
|
|
const setContextMenu = useSchedulesStore((s) => s.setContextMenu);
|
|
const scheduleForm = useSchedulesStore((s) => s.scheduleForm);
|
|
const setScheduleForm = useSchedulesStore((s) => s.setScheduleForm);
|
|
const nodeForm = useSchedulesStore((s) => s.nodeForm);
|
|
const setNodeForm = useSchedulesStore((s) => s.setNodeForm);
|
|
const cronResult = useSchedulesStore((s) => s.cronResult);
|
|
const setCronResult = useSchedulesStore((s) => s.setCronResult);
|
|
const runs = useSchedulesStore((s) => s.runs);
|
|
const setRuns = useSchedulesStore((s) => s.setRuns);
|
|
const runsLoading = useSchedulesStore((s) => s.runsLoading);
|
|
const setRunsLoading = useSchedulesStore((s) => s.setRunsLoading);
|
|
const canvasRef = useRef<HTMLDivElement | null>(null);
|
|
const positionDraftCount = useSchedulesStore((state) => state.positionDraftCount);
|
|
|
|
const selectedNode = schedule?.nodes.find(
|
|
(item) => item.node_id === selectedNodeId,
|
|
) ?? null;
|
|
const selectedEdge = schedule?.edges.find(
|
|
(item) => item.edge_id === selectedEdgeId,
|
|
) ?? null;
|
|
|
|
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]);
|
|
|
|
useContextMenuDismiss();
|
|
|
|
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);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!workspaceId || !api) return;
|
|
setSchedules([]);
|
|
setArtifacts([]);
|
|
setSchedule(null);
|
|
setRuns([]);
|
|
setRunsLoading(true);
|
|
void useSchedulesStore.getState().loadInitial();
|
|
}, [workspaceId, api, setSchedules, setArtifacts, setSchedule]);
|
|
|
|
useEffect(() => {
|
|
const scheduleId = schedule?.schedule_id;
|
|
if (!scheduleId || !api) return;
|
|
if (!runs.some((item) => item.run_status === "queued" || item.run_status === "running")) return;
|
|
const timer = window.setInterval(() => {
|
|
void useSchedulesStore.getState().refreshRuns(scheduleId);
|
|
}, 1500);
|
|
return () => window.clearInterval(timer);
|
|
}, [schedule?.schedule_id, runs, api]);
|
|
|
|
// Form submit wrapper for create-schedule modal — keeps the FormEvent flow out of JSX.
|
|
const handleCreateScheduleSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
|
event.preventDefault();
|
|
void useSchedulesStore.getState().addSchedule(newScheduleName);
|
|
};
|
|
|
|
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 useSchedulesStore.getState().addArtifactAt(artifact, positionX, positionY);
|
|
};
|
|
|
|
const { startDrag, moveDrag, finishDrag } = useCanvasNodeDrag({ busy, linkSourceId });
|
|
|
|
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={() => useSchedulesStore.getState().openCreateDialog()}>
|
|
<Icon name="plus" size={15} />新建
|
|
</button>
|
|
<button
|
|
className="schedule-action--primary"
|
|
type="button"
|
|
disabled={!schedule || Boolean(busy)}
|
|
onClick={() => useSchedulesStore.getState().saveSchedule()}
|
|
>
|
|
<Icon name="check" size={15} />保存配置
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={!schedule || Boolean(busy)}
|
|
onClick={() => useSchedulesStore.getState().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={() => useSchedulesStore.getState().runNow()}
|
|
>
|
|
<Icon name="play" size={14} />立即运行
|
|
</button>
|
|
<button
|
|
className="schedule-action--danger"
|
|
type="button"
|
|
disabled={!schedule || Boolean(busy)}
|
|
onClick={() => useSchedulesStore.getState().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={(id) => useSchedulesStore.getState().chooseSchedule(id)}
|
|
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 useSchedulesStore.getState().addArtifactAt(artifact, x, y)}
|
|
onDoubleClick={(artifact) => void useSchedulesStore.getState().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) => startDrag(event, node)}
|
|
onPointerMove={moveDrag}
|
|
onPointerUp={finishDrag}
|
|
onPointerCancel={finishDrag}
|
|
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 useSchedulesStore.getState().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(
|
|
useSchedulesStore.getState().linkSourceId === 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={() => useSchedulesStore.getState().openCreateDialog()}>
|
|
<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={() => useSchedulesStore.getState().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={() => useSchedulesStore.getState().saveNode(selectedNode)}
|
|
onDelete={() => useSchedulesStore.getState().removeNode(selectedNode)}
|
|
/>
|
|
) : (
|
|
<ScheduleInspector
|
|
schedule={schedule}
|
|
form={scheduleForm}
|
|
cronResult={cronResult}
|
|
busy={Boolean(busy)}
|
|
onChange={setScheduleForm}
|
|
onPreview={() => useSchedulesStore.getState().runCronPreview()}
|
|
onSave={() => useSchedulesStore.getState().saveSchedule()}
|
|
/>
|
|
)}
|
|
</div>
|
|
<RunHistory
|
|
runs={runs}
|
|
nodes={schedule?.nodes ?? []}
|
|
loading={runsLoading}
|
|
disabled={!schedule || Boolean(busy)}
|
|
onRefresh={() => {
|
|
if (schedule) void useSchedulesStore.getState().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={() => useSchedulesStore.getState().openCreateDialog()}
|
|
>
|
|
<Icon name="plus" size={14} />
|
|
新建调度方案
|
|
</button>
|
|
)}
|
|
{contextMenu.kind === "schedule" && (
|
|
<>
|
|
<button
|
|
className="schedule-context-menu__action"
|
|
type="button"
|
|
role="menuitem"
|
|
onClick={() => useSchedulesStore.getState().openCreateDialog()}
|
|
>
|
|
<Icon name="plus" size={14} />
|
|
新建调度方案
|
|
</button>
|
|
<button
|
|
className="schedule-context-menu__action"
|
|
type="button"
|
|
role="menuitem"
|
|
onClick={() => useSchedulesStore.getState().renameSchedule(contextMenu.schedule)}
|
|
>
|
|
<Icon name="settings" size={14} />
|
|
改名
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
onClick={() => useSchedulesStore.getState().removeSchedule(contextMenu.schedule)}
|
|
>
|
|
<Icon name="close" size={14} />
|
|
删除调度方案
|
|
</button>
|
|
</>
|
|
)}
|
|
{contextMenu.kind === "artifact" && (
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
onClick={() => useSchedulesStore.getState().removeArtifact(contextMenu.artifact)}
|
|
>
|
|
<Icon name="close" size={14} />
|
|
移出调度列表
|
|
</button>
|
|
)}
|
|
{contextMenu.kind === "node" && (
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
onClick={() => useSchedulesStore.getState().removeNode(contextMenu.node)}
|
|
>
|
|
<Icon name="close" size={14} />
|
|
删除画布节点
|
|
</button>
|
|
)}
|
|
{contextMenu.kind === "edge" && (
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
onClick={() => useSchedulesStore.getState().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={handleCreateScheduleSubmit}>
|
|
<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>
|
|
);
|
|
}
|