475 lines
14 KiB
TypeScript
475 lines
14 KiB
TypeScript
"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 =
|
||
"glass-control border";
|
||
|
||
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-[var(--glass-menu)] px-1 py-1 shadow-sm shadow-blue-950/10"
|
||
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)"
|
||
};
|
||
}
|