feat: initialize drainage network frontend
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
ScheduledConditionRecord
|
||||
} from "@/features/workbench/types";
|
||||
import {
|
||||
createVisibleTickerCards,
|
||||
type TickerStackCard
|
||||
} from "./agent-task-ticker-utils";
|
||||
|
||||
type AgentTaskTickerProps = {
|
||||
conditions: ScheduledConditionRecord[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type TickerTransitionDirection = -1 | 1;
|
||||
|
||||
const tickerEase = [0.22, 1, 0.36, 1] as const;
|
||||
const tickerSwitchAnimationMs = 380;
|
||||
const tickerCardSurfaceClassName =
|
||||
"border border-white/75 bg-white/[0.92] ring-1 ring-slate-900/5 backdrop-blur-xl";
|
||||
|
||||
const tickerCardVariants: Variants = {
|
||||
initial: (direction: TickerTransitionDirection) => ({
|
||||
opacity: 0.62,
|
||||
x: 0,
|
||||
y: direction > 0 ? 36 : -28,
|
||||
rotate: 0,
|
||||
filter: "brightness(0.955) saturate(0.95)"
|
||||
}),
|
||||
exit: (direction: TickerTransitionDirection) => ({
|
||||
opacity: [1, 1, 0],
|
||||
x: 0,
|
||||
y: direction > 0 ? 36 : -28,
|
||||
rotate: 0,
|
||||
filter: "brightness(1.02) saturate(1)",
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
times: [0, 0.65, 1],
|
||||
ease: [0.5, 0, 0.2, 1]
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const tickerContentVariants: Variants = {
|
||||
initial: {
|
||||
clipPath: "inset(0 100% 0 0)"
|
||||
},
|
||||
animate: {
|
||||
clipPath: "inset(0 0% 0 0)",
|
||||
transition: {
|
||||
duration: 0.18,
|
||||
ease: tickerEase
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
clipPath: "inset(0 0 0 100%)",
|
||||
transition: {
|
||||
duration: 0.14,
|
||||
ease: [0.5, 0, 0.2, 1]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const tickerIndicatorVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
x: 4,
|
||||
scale: 0.96
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.16,
|
||||
ease: tickerEase
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
x: 4,
|
||||
scale: 0.96,
|
||||
transition: {
|
||||
duration: 0.12,
|
||||
ease: [0.5, 0, 0.2, 1]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function AgentTaskTicker({
|
||||
conditions,
|
||||
className
|
||||
}: AgentTaskTickerProps) {
|
||||
const sectionRef = useRef<HTMLElement | null>(null);
|
||||
const isTaskSwitchLockedRef = useRef(false);
|
||||
const taskSwitchUnlockTimeoutRef = useRef<number | null>(null);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const [rotationIndex, setRotationIndex] = useState(0);
|
||||
const [transitionDirection, setTransitionDirection] = useState<TickerTransitionDirection>(1);
|
||||
const [taskSwitchAnimation, setTaskSwitchAnimation] = useState(() => ({
|
||||
id: 0,
|
||||
active: false
|
||||
}));
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const runningConditions = conditions;
|
||||
const visibleTickerCards = useMemo(
|
||||
() => createVisibleTickerCards(runningConditions, rotationIndex, nowMs),
|
||||
[runningConditions, rotationIndex, nowMs]
|
||||
);
|
||||
const hasMultipleRunningTasks = runningConditions.length > 1;
|
||||
const activeIndicatorIndex = runningConditions.length > 0 ? rotationIndex % runningConditions.length : 0;
|
||||
const tickerLabel = hasMultipleRunningTasks
|
||||
? `工况任务步骤,${runningConditions.length} 个执行中,可滚动切换`
|
||||
: "工况任务步骤";
|
||||
|
||||
const releaseTaskSwitchLock = useCallback(() => {
|
||||
if (taskSwitchUnlockTimeoutRef.current !== null) {
|
||||
window.clearTimeout(taskSwitchUnlockTimeoutRef.current);
|
||||
taskSwitchUnlockTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
isTaskSwitchLockedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const finishTaskSwitchAnimation = useCallback(() => {
|
||||
setTaskSwitchAnimation((current) => (
|
||||
current.active ? { ...current, active: false } : current
|
||||
));
|
||||
}, []);
|
||||
|
||||
const lockTaskSwitch = useCallback(() => {
|
||||
isTaskSwitchLockedRef.current = true;
|
||||
|
||||
if (taskSwitchUnlockTimeoutRef.current !== null) {
|
||||
window.clearTimeout(taskSwitchUnlockTimeoutRef.current);
|
||||
}
|
||||
|
||||
taskSwitchUnlockTimeoutRef.current = window.setTimeout(() => {
|
||||
isTaskSwitchLockedRef.current = false;
|
||||
taskSwitchUnlockTimeoutRef.current = null;
|
||||
finishTaskSwitchAnimation();
|
||||
}, tickerSwitchAnimationMs);
|
||||
}, [finishTaskSwitchAnimation]);
|
||||
|
||||
const shiftTask = useCallback((direction: TickerTransitionDirection) => {
|
||||
if (runningConditions.length <= 1 || isTaskSwitchLockedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
lockTaskSwitch();
|
||||
setTransitionDirection(direction);
|
||||
setTaskSwitchAnimation((current) => ({
|
||||
id: current.id + 1,
|
||||
active: true
|
||||
}));
|
||||
setRotationIndex((current) => (
|
||||
(current + direction + runningConditions.length) % runningConditions.length
|
||||
));
|
||||
}, [lockTaskSwitch, runningConditions.length]);
|
||||
|
||||
const showPreviousTask = useCallback(() => {
|
||||
shiftTask(-1);
|
||||
}, [shiftTask]);
|
||||
|
||||
const showNextTask = useCallback(() => {
|
||||
shiftTask(1);
|
||||
}, [shiftTask]);
|
||||
|
||||
const handleWheel = useCallback((event: WheelEvent) => {
|
||||
if (!hasMultipleRunningTasks || event.deltaY === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (event.deltaY > 0) {
|
||||
showNextTask();
|
||||
return;
|
||||
}
|
||||
|
||||
showPreviousTask();
|
||||
}, [hasMultipleRunningTasks, showNextTask, showPreviousTask]);
|
||||
|
||||
useEffect(() => {
|
||||
setNowMs(Date.now());
|
||||
const intervalId = window.setInterval(() => {
|
||||
setNowMs(Date.now());
|
||||
}, 1_000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setRotationIndex(0);
|
||||
releaseTaskSwitchLock();
|
||||
finishTaskSwitchAnimation();
|
||||
}, [finishTaskSwitchAnimation, releaseTaskSwitchLock, runningConditions.length]);
|
||||
|
||||
useEffect(() => releaseTaskSwitchLock, [releaseTaskSwitchLock]);
|
||||
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current;
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
|
||||
section.addEventListener("wheel", handleWheel, { passive: false });
|
||||
|
||||
return () => section.removeEventListener("wheel", handleWheel);
|
||||
}, [handleWheel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMultipleRunningTasks || isHovered) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
shiftTask(1);
|
||||
}, 6_000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [hasMultipleRunningTasks, isHovered, runningConditions.length, shiftTask]);
|
||||
|
||||
if (visibleTickerCards.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
className={cn(
|
||||
"agent-task-ticker pointer-events-auto relative h-[78px] overflow-visible",
|
||||
className
|
||||
)}
|
||||
aria-label={tickerLabel}
|
||||
title={tickerLabel}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<AnimatePresence custom={transitionDirection} mode="popLayout" initial={false}>
|
||||
{visibleTickerCards
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((card) => (
|
||||
<TickerTaskCard
|
||||
key={`ticker-stack-slot-${card.stackIndex}-${card.stackIndex === 0 ? taskSwitchAnimation.id : 0}`}
|
||||
card={card}
|
||||
isActive={card.stackIndex === 0}
|
||||
animateContentChanges={card.stackIndex === 0 && !taskSwitchAnimation.active}
|
||||
transitionDirection={transitionDirection}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<div className="pointer-events-none absolute right-3 top-8 z-40 -translate-y-1/2" aria-hidden="true">
|
||||
<AnimatePresence initial={false}>
|
||||
{isHovered && hasMultipleRunningTasks ? (
|
||||
<motion.div
|
||||
key="task-indicator"
|
||||
className="agent-task-ticker-indicator flex flex-col gap-1 rounded-full bg-white/80 px-1 py-1 shadow-sm shadow-blue-950/10 backdrop-blur"
|
||||
variants={tickerIndicatorVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
>
|
||||
{runningConditions.map((runningCondition, index) => (
|
||||
<span
|
||||
key={runningCondition.id}
|
||||
className={cn(
|
||||
"block h-1.5 w-1.5 rounded-sm transition-colors duration-150",
|
||||
index === activeIndicatorIndex ? "bg-blue-600" : "bg-slate-300"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TickerTaskCard({
|
||||
card,
|
||||
isActive,
|
||||
animateContentChanges,
|
||||
transitionDirection
|
||||
}: {
|
||||
card: TickerStackCard;
|
||||
isActive: boolean;
|
||||
animateContentChanges: boolean;
|
||||
transitionDirection: TickerTransitionDirection;
|
||||
}) {
|
||||
const { condition, view, stackIndex } = card;
|
||||
const stackStyle = getTickerStackStyle(stackIndex);
|
||||
const contentKey = view.currentStep
|
||||
? `${condition.id}-${view.currentStep.id}`
|
||||
: `${condition.id}-pending`;
|
||||
|
||||
return (
|
||||
<motion.article
|
||||
layout="position"
|
||||
className={cn(
|
||||
"absolute inset-x-0 top-0 h-16 overflow-hidden rounded-[22px] px-3 py-2",
|
||||
tickerCardSurfaceClassName,
|
||||
isActive ? "z-30" : "pointer-events-none z-10"
|
||||
)}
|
||||
style={{ transformOrigin: "center top" }}
|
||||
custom={transitionDirection}
|
||||
variants={tickerCardVariants}
|
||||
initial="initial"
|
||||
animate={{
|
||||
opacity: stackStyle.opacity,
|
||||
x: stackStyle.x,
|
||||
y: stackStyle.y,
|
||||
rotate: stackStyle.rotate,
|
||||
filter: stackStyle.filter,
|
||||
transition: {
|
||||
duration: isActive ? 0.34 : 0.3,
|
||||
ease: tickerEase
|
||||
}
|
||||
}}
|
||||
exit="exit"
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full",
|
||||
isActive ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
>
|
||||
<AnimatePresence key={condition.id} initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={contentKey}
|
||||
className="h-full overflow-hidden"
|
||||
variants={tickerContentVariants}
|
||||
initial={animateContentChanges ? "initial" : false}
|
||||
animate={animateContentChanges ? "animate" : undefined}
|
||||
exit={animateContentChanges ? "exit" : undefined}
|
||||
>
|
||||
<TickerTaskCardContent view={view} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
|
||||
function TickerTaskCardContent({ view }: { view: TickerStackCard["view"] }) {
|
||||
if (!view.currentStep) {
|
||||
return (
|
||||
<div className="grid h-full grid-cols-[14px_minmax(0,1fr)] items-center gap-3 pr-4">
|
||||
<TickerStatusDot progressValue={view.progressValue} />
|
||||
<div className="min-w-0">
|
||||
<span className="block truncate text-[13px] font-semibold leading-5 text-slate-500">
|
||||
{view.title}
|
||||
</span>
|
||||
<TickerProgressBar progressLabel={view.progressLabel} muted />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-cols-[14px_minmax(0,1fr)] items-center gap-3 pr-5">
|
||||
<TickerStatusDot progressValue={view.progressValue} />
|
||||
<div className="flex min-w-0 flex-col justify-center">
|
||||
<div className="min-w-0 pr-1">
|
||||
<span className="block truncate text-[13px] font-semibold leading-4 text-slate-500">
|
||||
{view.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0" title={`${view.currentStep.title}:${view.currentStep.detail}`}>
|
||||
<div className="grid min-w-0 grid-cols-[auto_4px_minmax(0,1fr)] items-center gap-2">
|
||||
<span className="min-w-0 truncate text-[13px] font-bold leading-5 text-slate-950">
|
||||
{view.currentStep.title}
|
||||
</span>
|
||||
<span className="h-1 w-1 rounded-full bg-slate-300" aria-hidden="true" />
|
||||
<span className="min-w-0 truncate text-[13px] font-medium leading-5 text-slate-500">
|
||||
{view.currentStep.detail}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<TickerProgressBar progressLabel={view.progressLabel} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TickerProgressBar({
|
||||
progressLabel,
|
||||
muted = false
|
||||
}: {
|
||||
progressLabel: string;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 h-0.5 overflow-hidden rounded-full",
|
||||
muted ? "bg-slate-200/60" : "bg-slate-200/80"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<motion.span
|
||||
className={cn("block h-full rounded-full", muted ? "bg-blue-300" : "bg-blue-500")}
|
||||
animate={{ width: progressLabel }}
|
||||
initial={false}
|
||||
transition={{ duration: 0.34, ease: tickerEase }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TickerStatusDot({ progressValue }: { progressValue?: number }) {
|
||||
const progress = typeof progressValue === "number" ? Math.min(100, Math.max(0, progressValue)) : 0;
|
||||
const dashOffset = 44 - (44 * progress) / 100;
|
||||
return (
|
||||
<span className="grid h-3.5 w-3.5 place-items-center" aria-hidden="true">
|
||||
<svg className="h-3.5 w-3.5 -rotate-90" viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill="none" strokeWidth="2" className="stroke-blue-100/90" />
|
||||
<motion.circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="7"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
className="stroke-blue-600"
|
||||
strokeDasharray="44"
|
||||
animate={{ strokeDashoffset: dashOffset }}
|
||||
initial={false}
|
||||
transition={{ duration: 0.34, ease: tickerEase }}
|
||||
/>
|
||||
<circle cx="8" cy="8" r="3.5" className="fill-blue-600" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getTickerStackStyle(stackIndex: number) {
|
||||
if (stackIndex === 1) {
|
||||
return {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 7,
|
||||
rotate: 0,
|
||||
filter: "brightness(0.985) saturate(0.98)"
|
||||
};
|
||||
}
|
||||
|
||||
if (stackIndex === 2) {
|
||||
return {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 14,
|
||||
rotate: 0,
|
||||
filter: "brightness(0.965) saturate(0.96)"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotate: 0,
|
||||
filter: "brightness(1) saturate(1)"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user