Files
model-platform/frontend/app/features/schedules/SchedulePage.tsx
T

805 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type DragEvent,
type FormEvent,
memo,
type MouseEvent as ReactMouseEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
BookText,
CalendarClock,
Check,
FileCode2,
PackageOpen,
Play,
Plus,
Settings,
X,
} from 'lucide-react';
import {
type Schedule,
type ScheduleArtifact,
type ScheduleEdge,
type ScheduleNode,
} from "~/services/api";
import { useApi, useAuth } from "~/context/AuthContext";
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/types";
import { useSchedulesStore } from "./state/useSchedulesStore";
import { edgePath, nodeToForm, scheduleToForm } from "./utils";
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";
/**
* 顶部状态文案里 “N 个节点位置待保存” 那段抽出来,
* 让它独立订阅 positionDraftCount,避免每次 setPositionDraftCount 都让
* SchedulePage 整树重渲染。
*/
function PositionDraftBadge() {
const count = useSchedulesStore((s) => s.positionDraftCount);
if (count <= 0) return null;
return <> · {count} 个节点位置待保存</>;
}
/**
* “立即运行” 按钮的 disabled / title 都依赖 positionDraftCount,
* 抽成子组件独立订阅该字段。
*/
function RunNowButton({
schedule,
busy,
}: {
schedule: import("../../services/api").Schedule | null;
busy: string | null | undefined;
}) {
const positionDraftCount = useSchedulesStore((s) => s.positionDraftCount);
return (
<button
className="inline-flex min-h-[32px] cursor-pointer items-center justify-center gap-[5px] rounded-[6px] border border-emerald-600 bg-emerald-600 px-2 text-[12px] font-semibold text-white enabled:hover:border-emerald-700 enabled:hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50 min-[1281px]:px-3"
type="button"
disabled={
!schedule
|| Boolean(busy)
|| !schedule.dag_validation.valid
|| schedule.nodes.length === 0
}
title={
positionDraftCount > 0
? "请先保存节点位置"
: "立即执行当前已保存的稳定版本 DAG"
}
onClick={() => useSchedulesStore.getState().runNow()}
>
<Play size={14} />立即运行
</button>
);
}
/**
* 把右键菜单打开逻辑提到模块级,使 ScheduleEdge 这类 memo 化的子组件
* 也能直接调用,避免通过 props 传入每渲染都新建的箭头函数。
*/
function openScheduleContextMenu(
event: ReactMouseEvent<HTMLElement | SVGElement>,
target: ScheduleContextMenuTarget,
): void {
event.preventDefault();
event.stopPropagation();
const menuWidth = 176;
const menuHeight = target.kind === "schedule" ? 132 : 48;
useSchedulesStore.getState().setContextMenu({
...target,
x: Math.min(event.clientX, window.innerWidth - menuWidth - 8),
y: Math.min(event.clientY, window.innerHeight - menuHeight - 8),
} as ScheduleContextMenu);
}
/**
* 单条调度连线。React.memo 包裹后,selectedEdgeId / nodes 引用未变就不
* 重渲染,避免切换选中状态时所有边重算 edgePath。
*/
const ScheduleEdge = memo(function ScheduleEdge({
edge,
nodes,
selectedEdgeId,
}: {
edge: ScheduleEdge;
nodes: ScheduleNode[];
selectedEdgeId: string | null;
}) {
const path = useMemo(() => {
const source = nodes.find((node) => node.node_id === edge.source_node_id);
const target = nodes.find((node) => node.node_id === edge.target_node_id);
if (!source || !target) return null;
return edgePath(source, target);
}, [nodes, edge.source_node_id, edge.target_node_id]);
if (!path) return null;
return (
<g
data-state={selectedEdgeId === edge.edge_id ? "selected" : "idle"}
onClick={(event) => {
event.stopPropagation();
useSchedulesStore.getState().setSelectedEdgeId(edge.edge_id);
useSchedulesStore.getState().setSelectedNodeId(null);
}}
onContextMenu={(event) => openScheduleContextMenu(event, {
kind: "edge",
edge,
})}
>
<path
className="schedule-edge-hit fill-none stroke-transparent [stroke-width:14] cursor-pointer [pointer-events:stroke]"
d={path}
/>
<path
className={`schedule-edge-line fill-none ${
selectedEdgeId === edge.edge_id
? "stroke-[#df8142] [stroke-width:3.4]"
: "stroke-[#4e92db] [stroke-width:2.2]"
}`}
d={path}
markerEnd="url(#schedule-arrow)"
/>
</g>
);
});
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 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 setRuns = useSchedulesStore((s) => s.setRuns);
const setRunsLoading = useSchedulesStore((s) => s.setRunsLoading);
const canvasRef = useRef<HTMLDivElement | null>(null);
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();
useEffect(() => {
if (!workspaceId || !api) return;
setSchedules([]);
setArtifacts([]);
setSchedule(null);
setRuns([]);
setRunsLoading(false);
void useSchedulesStore.getState().loadInitial();
}, [workspaceId, api, setSchedules, setArtifacts, setSchedule]);
useEffect(() => {
const scheduleId = schedule?.schedule_id;
if (!scheduleId || !api) {
setRuns([]);
setRunsLoading(false);
return;
}
let timer: number | undefined;
let cancelled = false;
const tick = async (showLoading = false): Promise<void> => {
if (cancelled) return;
// 必须等待请求完成再判断状态;旧实现会在首个请求返回前看到空数组,
// 因而错误地停止轮询,导致运行记录必须手工刷新才出现。
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",
);
// 页面停留期间持续刷新:运行中更快,空闲时较慢,既能自动显示 Cron
// 新记录,也不会因频繁请求影响其他调度页面操作。
timer = window.setTimeout(() => {
void tick();
}, hasActiveRun ? 1500 : 5000);
};
void tick(true);
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [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 => {
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]);
// 稳定传给 ArtifactList 的 3 个回调,让 memo 真正生效 —— 否则父级
// (SchedulePage) 任何 store 字段变化都会让卡片列表重新绑定拖拽事件,
// 在拖动/快速 hover 时放大体感卡顿。
const handleArtifactDrop = useCallback(
(artifact: ScheduleArtifact, x: number, y: number) => {
useSchedulesStore.getState().addArtifactAt(artifact, x, y);
},
[],
);
const handleArtifactDoubleClick = useCallback(
(artifact: ScheduleArtifact) => {
useSchedulesStore.getState().addArtifactAt(
artifact,
90 + (schedule?.nodes.length ?? 0) * 245,
130,
);
},
[schedule],
);
const handleArtifactContextMenu = useCallback(
(event: ReactMouseEvent, artifact: ScheduleArtifact) => {
openScheduleContextMenu(
event as ReactMouseEvent<HTMLElement | SVGElement>,
{ kind: "artifact", artifact },
);
},
[],
);
return (
<section className="relative flex min-h-0 flex-1 flex-col gap-[10px] overflow-hidden p-[10px]">
<header className="flex min-h-[56px] items-center justify-between rounded-[7px] border border-line bg-white px-[14px] shadow-[0_2px_7px_rgb(25_46_73_/_3%)]">
<div className="flex items-baseline gap-[10px]">
<strong className="text-[16px] text-[#1c2d42]">图形化调度</strong>
<span className="hidden text-[11px] text-[#8b99a9] 2xl:inline">
{schedule
? `版本 ${schedule.workflow_version} · ${schedule.dag_validation.valid ? "DAG 有效" : "待校验"}`
: "创建调度后开始编排"}
<PositionDraftBadge />
</span>
</div>
<div className="flex items-center gap-2">
<button
className="inline-flex h-[32px] cursor-pointer items-center justify-center gap-[5px] rounded-[6px] border border-[#dce5ee] bg-white px-2 text-[12px] font-semibold text-[#526477] enabled:hover:border-[#aac6e5] enabled:hover:bg-[#f6faff] enabled:hover:text-[#126aca] disabled:cursor-not-allowed disabled:opacity-50 min-[1281px]:px-3"
type="button"
onClick={() => useSchedulesStore.getState().openCreateDialog()}
>
<Plus size={14}/>
<span>新建</span>
</button>
<button
className="inline-flex min-h-[32px] cursor-pointer items-center justify-center gap-[5px] rounded-[6px] border border-brand bg-brand px-2 text-[12px] font-semibold text-white enabled:hover:border-brand-strong enabled:hover:bg-brand-strong disabled:cursor-not-allowed disabled:opacity-50 min-[1281px]:px-3"
type="button"
disabled={!schedule || Boolean(busy)}
onClick={() => useSchedulesStore.getState().saveSchedule()}
>
<Check size={15} />保存配置
</button>
<button
className="inline-flex min-h-[32px] cursor-pointer items-center justify-center gap-[5px] rounded-[6px] border border-[#dce5ee] bg-white px-2 text-[12px] font-semibold text-[#526477] enabled:hover:border-[#aac6e5] enabled:hover:bg-[#f6faff] enabled:hover:text-[#126aca] disabled:cursor-not-allowed disabled:opacity-50 min-[1281px]:px-3"
type="button"
disabled={!schedule || Boolean(busy)}
onClick={() => useSchedulesStore.getState().checkDag()}
>
校验 DAG
</button>
<RunNowButton schedule={schedule} busy={busy} />
<button
className="inline-flex min-h-[32px] cursor-pointer items-center justify-center gap-[5px] rounded-[6px] border border-red-200 bg-white px-2 text-[12px] font-semibold text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50 min-[1281px]:px-3"
type="button"
disabled={!schedule || Boolean(busy)}
onClick={() => useSchedulesStore.getState().removeSchedule(schedule ?? undefined)}
>
删除
</button>
</div>
</header>
<div className="grid min-h-0 flex-1 gap-[10px] [grid-template-columns:225px_minmax(360px,1fr)_270px] min-[1281px]:[grid-template-columns:250px_minmax(380px,1fr)_292px] 2xl:[grid-template-columns:278px_minmax(420px,1fr)_316px]">
<aside className="grid min-h-0 overflow-hidden rounded-[7px] border border-line bg-white shadow-[0_2px_7px_rgb(25_46_73_/_3%)] [grid-template-rows:minmax(210px,42%)_minmax(260px,58%)]">
<ScheduleList
schedules={filteredSchedules}
selectedScheduleId={schedule?.schedule_id ?? null}
keyword={scheduleKeyword}
loading={loading}
onSelect={(id) => useSchedulesStore.getState().chooseSchedule(id)}
onContextMenu={(event, item) => openScheduleContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, item ? { kind: "schedule", schedule: item } : { kind: "schedule-list" })}
onKeywordChange={setScheduleKeyword}
/>
<ArtifactList
artifacts={filteredArtifacts}
keyword={artifactKeyword}
onDrop={handleArtifactDrop}
onDoubleClick={handleArtifactDoubleClick}
onContextMenu={handleArtifactContextMenu}
onKeywordChange={setArtifactKeyword}
/>
</aside>
<main className="flex min-h-0 flex-col overflow-hidden rounded-[7px] border border-line bg-white shadow-[0_2px_7px_rgb(25_46_73_/_3%)]">
<ScheduleCanvasHeader
linkSourceId={linkSourceId}
onCancelLink={() => setLinkSourceId(null)}
/>
<div
className="relative min-h-0 flex-1 overflow-auto bg-bg-canvas data-[state=linking]:cursor-crosshair"
data-state={linkSourceId ? "linking" : "idle"}
ref={canvasRef}
style={{
backgroundImage:
"linear-gradient(#e7edf3 1px, transparent 1px), linear-gradient(90deg, #e7edf3 1px, transparent 1px), linear-gradient(#f1f4f7 1px, transparent 1px), linear-gradient(90deg, #f1f4f7 1px, transparent 1px)",
backgroundSize: "80px 80px, 80px 80px, 16px 16px, 16px 16px",
backgroundPosition: "-1px -1px",
}}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDrop={onCanvasDrop}
onClick={(event) => {
// 画布实际内容位于 surface/SVG 子元素中,不能只比较 currentTarget
// 点击节点或连线时保留选择,点击其余空白位置则返回调度方案基本信息。
const target = event.target as Element;
if (
target.closest(".schedule-node") ||
target.matches(".schedule-edge-line, .schedule-edge-hit")
) return;
setSelectedNodeId(null);
setSelectedEdgeId(null);
}}
>
<div
className="relative"
style={{ width: CANVAS_WIDTH, height: CANVAS_HEIGHT }}
>
{schedule && (
<svg
className="absolute inset-0 z-[1] h-full w-full overflow-visible"
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) => (
<ScheduleEdge
key={edge.edge_id}
edge={edge}
nodes={schedule.nodes}
selectedEdgeId={selectedEdgeId}
/>
))}
</svg>
)}
{schedule?.nodes.map((node) => (
<div
className={`schedule-node absolute z-[2] flex cursor-grab touch-none select-none flex-col rounded-[8px] border border-[#91b7de] bg-white p-[11px_12px_9px] text-[#34516f] shadow-[0_7px_18px_rgb(36_75_117_/_11%)] active:cursor-grabbing data-[state=selected]:border-brand data-[state=selected]:shadow-[0_0_0_3px_rgb(25_120_212_/_11%),0_8px_22px_rgb(36_75_117_/_13%)]`}
data-state={selectedNodeId === node.node_id ? "selected" : "idle"}
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) => openScheduleContextMenu(event, {
kind: "node",
node,
})}
>
<button
className="absolute left-[-8px] top-[calc(50%-7px)] z-[4] h-[14px] w-[14px] cursor-crosshair rounded-full border-2 border-[#4e92db] bg-white p-0 hover:border-[#176ec5] hover:bg-[#4e92db] hover:shadow-[0_0_0_4px_rgb(45_124_207_/_16%)]"
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="flex items-center gap-[7px]">
<span
className={`grid h-[25px] w-[25px] flex-none place-items-center rounded-[5px] ${
node.version.script_type === "notebook"
? "bg-[#fff0ef] text-[#da5c52]"
: "bg-[#eaf3ff] text-[#316fbf]"
}`}
>
{node.version.script_type === "notebook" ? (
<BookText size={14} />
) : (
<FileCode2 size={14} />
)}
</span>
<strong className="min-w-0 flex-1 truncate text-[12px] text-[#243b53]">
{node.node_name}
</strong>
<button
className="grid h-[23px] w-[23px] cursor-pointer place-items-center rounded-[4px] border-0 bg-transparent p-0 text-[#8b99a7] hover:bg-[#edf5fd] hover:text-brand"
type="button"
aria-label="删除节点"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
setSelectedNodeId(node.node_id);
setSelectedEdgeId(null);
}}
>
<Settings size={14} />
</button>
</div>
<p className="mt-2 mb-1.5 truncate text-[10px] text-[#7e8d9d]">
{node.version.script_name}
</p>
<footer className="flex items-center justify-between border-t border-line-soft pt-1.5">
<span className="max-w-[145px] truncate font-mono text-[9px] text-[#8d9baa]">
{node.node_key}
</span>
<b className="text-[10px] text-brand">{node.version.version_label}</b>
</footer>
<button
className="absolute right-[-8px] top-[calc(50%-7px)] z-[4] h-[14px] w-[14px] cursor-crosshair rounded-full border-2 border-[#4e92db] bg-white p-0 hover:border-[#176ec5] hover:bg-[#4e92db] hover:shadow-[0_0_0_4px_rgb(45_124_207_/_16%)] data-[state=active]:border-[#176ec5] data-[state=active]:bg-[#4e92db] data-[state=active]:shadow-[0_0_0_4px_rgb(45_124_207_/_16%)]"
data-state={linkSourceId === node.node_id ? "active" : "idle"}
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="sticky left-1/2 top-[46%] z-0 mx-auto mt-[190px] flex w-[350px] flex-col items-center text-center text-[#9aa8b6] [&>svg]:mb-3 [&>svg]:text-[#8cb4de]">
<CalendarClock size={34} />
<strong className="text-[14px] text-[#567089]">还没有选中调度方案</strong>
<p className="my-[7px_13px] text-[11px]">点击"新建"创建一个调度,然后拖入稳定版本脚本。</p>
<button
className="inline-flex min-h-[32px] cursor-pointer items-center gap-1 rounded-[6px] border border-[#2580da] bg-[#2580da] px-3 text-[11px] text-white"
type="button"
onClick={() => useSchedulesStore.getState().openCreateDialog()}
>
<Plus size={15} />新建调度
</button>
</div>
) : schedule.nodes.length === 0 ? (
<div className="sticky left-1/2 top-[46%] z-0 mx-auto mt-[190px] flex w-[350px] flex-col items-center text-center text-[#9aa8b6] [&>svg]:mb-3 [&>svg]:text-[#8cb4de]">
<PackageOpen size={34} />
<strong className="text-[14px] text-[#567089]">从稳定版本开始编排</strong>
<p className="my-[7px_13px] text-[11px]">把左侧脚本卡片拖到这里,或双击卡片快速加入。</p>
</div>
) : null}
</div>
</div>
<footer className="flex min-h-[35px] items-center gap-[14px] border-t border-[#e4eaf0] bg-white px-3 text-[10px] text-[#8b99a9]">
<span
data-state={schedule?.dag_validation.valid ? "valid" : "invalid"}
className="text-[#b17a48] data-[state=valid]:text-[#16875b]"
>
{schedule?.dag_validation.valid ? "✓ DAG 有效" : "○ DAG 待校验"}
</span>
<span>{schedule?.nodes.length ?? 0} 个节点</span>
<span>{schedule?.edges.length ?? 0} 条连线</span>
{selectedEdge && (
<button
className="ml-auto inline-flex min-h-[24px] cursor-pointer items-center justify-center gap-[5px] rounded-[6px] border border-[#dce5ee] bg-white px-2 text-[10px] font-semibold text-[#bd504b] hover:border-[#aac6e5] hover:bg-[#f6faff] hover:text-[#126aca]"
type="button"
onClick={() => useSchedulesStore.getState().removeEdge(selectedEdge)}
>
删除选中连线
</button>
)}
</footer>
</main>
<aside className="grid min-h-0 overflow-hidden rounded-[7px] border border-line bg-white shadow-[0_2px_7px_rgb(25_46_73_/_3%)] [grid-template-rows:repeat(2,minmax(0,1fr))]">
<div className="min-h-0 overflow-auto">
{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
nodes={schedule?.nodes ?? []}
disabled={!schedule || Boolean(busy)}
onRefresh={() => {
if (schedule) void useSchedulesStore.getState().refreshRuns(schedule.schedule_id, true);
}}
/>
</aside>
</div>
{contextMenu && (
<div
className="fixed z-[60] min-w-[168px] rounded-[8px] border border-[#d9e3ec] bg-white p-[5px] shadow-[0_14px_36px_rgb(14_35_57_/_22%)]"
role="menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
onPointerDown={(event) => event.stopPropagation()}
>
{contextMenu.kind === "schedule-list" && (
<button
className="flex w-full cursor-pointer items-center gap-[9px] rounded-[5px] border-0 bg-transparent p-[9px_10px] text-left text-[12px] text-[#173a5e] hover:bg-[#eef6ff]"
type="button"
role="menuitem"
onClick={() => useSchedulesStore.getState().openCreateDialog()}
>
<Plus size={14} />
新建调度方案
</button>
)}
{contextMenu.kind === "schedule" && (
<>
<button
className="flex w-full cursor-pointer items-center gap-[9px] rounded-[5px] border-0 bg-transparent p-[9px_10px] text-left text-[12px] text-[#173a5e] hover:bg-[#eef6ff]"
type="button"
role="menuitem"
onClick={() => useSchedulesStore.getState().openCreateDialog()}
>
<Plus size={14} />
新建调度方案
</button>
<button
className="flex w-full cursor-pointer items-center gap-[9px] rounded-[5px] border-0 bg-transparent p-[9px_10px] text-left text-[12px] text-[#173a5e] hover:bg-[#eef6ff]"
type="button"
role="menuitem"
onClick={() => useSchedulesStore.getState().renameSchedule(contextMenu.schedule)}
>
<Settings size={14} />
改名
</button>
<button
className="flex w-full cursor-pointer items-center gap-[9px] rounded-[5px] border-0 bg-transparent p-[9px_10px] text-left text-[12px] text-[#c23b3b] hover:bg-[#fff0f0]"
type="button"
role="menuitem"
onClick={() => useSchedulesStore.getState().removeSchedule(contextMenu.schedule)}
>
<X size={14} />
删除调度方案
</button>
</>
)}
{contextMenu.kind === "artifact" && null}
{contextMenu.kind === "node" && (
<button
className="flex w-full cursor-pointer items-center gap-[9px] rounded-[5px] border-0 bg-transparent p-[9px_10px] text-left text-[12px] text-[#c23b3b] hover:bg-[#fff0f0]"
type="button"
role="menuitem"
onClick={() => useSchedulesStore.getState().removeNode(contextMenu.node)}
>
<X size={14} />
删除画布节点
</button>
)}
{contextMenu.kind === "edge" && (
<button
className="flex w-full cursor-pointer items-center gap-[9px] rounded-[5px] border-0 bg-transparent p-[9px_10px] text-left text-[12px] text-[#c23b3b] hover:bg-[#fff0f0]"
type="button"
role="menuitem"
onClick={() => useSchedulesStore.getState().removeEdge(contextMenu.edge)}
>
<X 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="grid h-[34px] w-[34px] cursor-pointer place-items-center rounded-[7px] border border-[#dfe6ed] bg-white hover:border-[#b9c9da] hover:bg-[#f7faff]"
type="button"
aria-label="关闭"
disabled={Boolean(busy)}
onClick={() => setCreateDialogOpen(false)}
>
<X size={14} />
</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" />
: <Plus size={15} />}
{busy === "create-schedule" ? "正在创建…" : "创建调度"}
</button>
</div>
</form>
</section>
</div>
)}
{busy && (
<div
className="absolute bottom-[22px] right-[22px] z-[20] flex min-h-[38px] items-center gap-2 rounded-[7px] border border-[#c9dcee] bg-white px-[13px] text-[11px] text-[#4e657d] shadow-[0_8px_25px_rgb(28_56_86_/_15%)]"
aria-live="polite"
>
<span className="h-[15px] w-[15px] rounded-full border-2 border-[#b9d4ef] border-t-brand" />
{busy === "run-now" ? "正在创建运行…" : "正在同步调度配置…"}
</div>
)}
</section>
);
}