feat: initialize TJWater WebGIS frontend

This commit is contained in:
2026-07-21 18:31:08 +08:00
commit ad5e5d3133
191 changed files with 37963 additions and 0 deletions
@@ -0,0 +1,464 @@
"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 tickerCardVariants: Variants = {
initial: (direction: TickerTransitionDirection) => ({
opacity: 0.62,
x: 0,
y: direction > 0 ? 36 : -28,
rotate: 0
}),
exit: (direction: TickerTransitionDirection) => ({
opacity: [1, 1, 0],
x: 0,
y: direction > 0 ? 36 : -28,
rotate: 0,
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 surface-control flex flex-col gap-1 rounded-full border px-1 py-1"
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",
isActive ? "glass-transient z-30" : "surface-control pointer-events-none z-10 border"
)}
style={{ transformOrigin: "center top" }}
custom={transitionDirection}
variants={tickerCardVariants}
initial="initial"
animate={{
opacity: stackStyle.opacity,
x: stackStyle.x,
y: stackStyle.y,
rotate: stackStyle.rotate,
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
};
}
if (stackIndex === 2) {
return {
opacity: 1,
x: 0,
y: 14,
rotate: 0
};
}
return {
opacity: 1,
x: 0,
y: 0,
rotate: 0
};
}