805 lines
35 KiB
TypeScript
805 lines
35 KiB
TypeScript
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>
|
||
);
|
||
}
|