Files
next-tjwater-drainage-frontend/features/workbench/components/agent-task-ticker.tsx
T

475 lines
14 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.
"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)"
};
}