@@ -628,6 +632,7 @@ export function BomCostingAssetWorkspace({
)}
+ {active ?
: null}
{KIND_LABELS[asset.kind]}
diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingMotion.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingMotion.tsx
new file mode 100644
index 00000000..6e4a3840
--- /dev/null
+++ b/furniture-3d-system/src/app/bom-costing/BomCostingMotion.tsx
@@ -0,0 +1,493 @@
+"use client";
+
+import gsap from "gsap";
+import {
+ type HTMLAttributes,
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useRef,
+} from "react";
+import { cn } from "@/lib/utils";
+
+type ScrollAlignment = "start" | "center" | "end" | "nearest";
+
+interface BomScrollOptions {
+ block?: ScrollAlignment;
+ inline?: ScrollAlignment;
+ container?: HTMLElement | null;
+}
+
+function prefersReducedMotion() {
+ return (
+ typeof window === "undefined" ||
+ typeof window.matchMedia !== "function" ||
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ );
+}
+
+function isScrollableValue(value: string) {
+ return /auto|scroll|overlay/.test(value);
+}
+
+function findScrollContainer(target: HTMLElement) {
+ let parent = target.parentElement;
+ while (parent && parent !== document.body) {
+ const style = window.getComputedStyle(parent);
+ const scrollsVertically =
+ isScrollableValue(style.overflowY) &&
+ parent.scrollHeight > parent.clientHeight + 1;
+ const scrollsHorizontally =
+ isScrollableValue(style.overflowX) &&
+ parent.scrollWidth > parent.clientWidth + 1;
+ if (scrollsVertically || scrollsHorizontally) return parent;
+ parent = parent.parentElement;
+ }
+ return (document.scrollingElement ?? document.documentElement) as HTMLElement;
+}
+
+function resolveScrollDestination({
+ alignment,
+ current,
+ targetStart,
+ targetEnd,
+ viewportStart,
+ viewportEnd,
+}: {
+ alignment: ScrollAlignment;
+ current: number;
+ targetStart: number;
+ targetEnd: number;
+ viewportStart: number;
+ viewportEnd: number;
+}) {
+ const targetSize = targetEnd - targetStart;
+ const viewportSize = viewportEnd - viewportStart;
+ if (alignment === "center") {
+ return (
+ current + targetStart - viewportStart - (viewportSize - targetSize) / 2
+ );
+ }
+ if (alignment === "end") {
+ return current + targetEnd - viewportEnd;
+ }
+ if (alignment === "nearest") {
+ if (targetStart < viewportStart) {
+ return current + targetStart - viewportStart;
+ }
+ if (targetEnd > viewportEnd) {
+ return current + targetEnd - viewportEnd;
+ }
+ return current;
+ }
+ return current + targetStart - viewportStart;
+}
+
+function getScrollDestination(
+ target: HTMLElement,
+ container: HTMLElement,
+ block: ScrollAlignment,
+ inline: ScrollAlignment,
+) {
+ const documentScroller =
+ container === document.scrollingElement ||
+ container === document.documentElement ||
+ container === document.body;
+ const targetRect = target.getBoundingClientRect();
+ const containerRect = documentScroller
+ ? {
+ top: 0,
+ right: window.innerWidth,
+ bottom: window.innerHeight,
+ left: 0,
+ }
+ : container.getBoundingClientRect();
+ const currentTop = documentScroller ? window.scrollY : container.scrollTop;
+ const currentLeft = documentScroller ? window.scrollX : container.scrollLeft;
+ const maxTop = Math.max(
+ 0,
+ container.scrollHeight -
+ (documentScroller ? window.innerHeight : container.clientHeight),
+ );
+ const maxLeft = Math.max(
+ 0,
+ container.scrollWidth -
+ (documentScroller ? window.innerWidth : container.clientWidth),
+ );
+
+ return {
+ top: Math.min(
+ maxTop,
+ Math.max(
+ 0,
+ resolveScrollDestination({
+ alignment: block,
+ current: currentTop,
+ targetStart: targetRect.top,
+ targetEnd: targetRect.bottom,
+ viewportStart: containerRect.top,
+ viewportEnd: containerRect.bottom,
+ }),
+ ),
+ ),
+ left: Math.min(
+ maxLeft,
+ Math.max(
+ 0,
+ resolveScrollDestination({
+ alignment: inline,
+ current: currentLeft,
+ targetStart: targetRect.left,
+ targetEnd: targetRect.right,
+ viewportStart: containerRect.left,
+ viewportEnd: containerRect.right,
+ }),
+ ),
+ ),
+ };
+}
+
+export function useBomGsapScrollIntoView() {
+ const activeTween = useRef
(null);
+ const removeInterruptListeners = useRef<(() => void) | null>(null);
+
+ const stopActiveScroll = useCallback(() => {
+ activeTween.current?.kill();
+ activeTween.current = null;
+ removeInterruptListeners.current?.();
+ removeInterruptListeners.current = null;
+ }, []);
+
+ useEffect(() => stopActiveScroll, [stopActiveScroll]);
+
+ return useCallback(
+ (target: HTMLElement | null, options: BomScrollOptions = {}) => {
+ if (!target || typeof window === "undefined") return;
+ stopActiveScroll();
+
+ const container = options.container ?? findScrollContainer(target);
+ const destination = getScrollDestination(
+ target,
+ container,
+ options.block ?? "nearest",
+ options.inline ?? "nearest",
+ );
+
+ if (prefersReducedMotion()) {
+ container.scrollTop = destination.top;
+ container.scrollLeft = destination.left;
+ return;
+ }
+
+ const interruptTarget: EventTarget =
+ container === document.scrollingElement ||
+ container === document.documentElement ||
+ container === document.body
+ ? window
+ : container;
+ const interrupt = () => stopActiveScroll();
+ const removeListeners = () => {
+ interruptTarget.removeEventListener("wheel", interrupt);
+ interruptTarget.removeEventListener("touchstart", interrupt);
+ };
+ interruptTarget.addEventListener("wheel", interrupt, { passive: true });
+ interruptTarget.addEventListener("touchstart", interrupt, {
+ passive: true,
+ });
+ removeInterruptListeners.current = removeListeners;
+
+ activeTween.current = gsap.to(container, {
+ scrollTop: destination.top,
+ scrollLeft: destination.left,
+ duration: 0.32,
+ ease: "power2.out",
+ overwrite: "auto",
+ onComplete: () => {
+ removeListeners();
+ removeInterruptListeners.current = null;
+ activeTween.current = null;
+ },
+ onInterrupt: () => {
+ removeListeners();
+ removeInterruptListeners.current = null;
+ activeTween.current = null;
+ },
+ });
+ },
+ [stopActiveScroll],
+ );
+}
+
+export function BomGsapProgress({
+ value,
+ className,
+}: {
+ value: number;
+ className?: string;
+}) {
+ const progressRef = useRef(null);
+ const normalizedValue = Math.min(100, Math.max(0, value));
+
+ useEffect(() => {
+ const node = progressRef.current;
+ if (!node) return;
+ const context = gsap.context(() => {
+ if (prefersReducedMotion()) {
+ gsap.set(node, { scaleX: normalizedValue / 100 });
+ return;
+ }
+ gsap.to(node, {
+ scaleX: normalizedValue / 100,
+ duration: 0.28,
+ ease: "power2.out",
+ overwrite: "auto",
+ });
+ }, node);
+ return () => context.revert();
+ }, [normalizedValue]);
+
+ return (
+
+ );
+}
+
+export function BomGsapPulse({
+ children,
+ className,
+ ...props
+}: HTMLAttributes & { children?: ReactNode }) {
+ const pulseRef = useRef(null);
+
+ useEffect(() => {
+ const node = pulseRef.current;
+ if (!node) return;
+ const context = gsap.context(() => {
+ if (prefersReducedMotion()) {
+ gsap.set(node, { autoAlpha: 1, scale: 1 });
+ return;
+ }
+ gsap.fromTo(
+ node,
+ { autoAlpha: 0.55, scale: 0.9 },
+ {
+ autoAlpha: 1,
+ scale: 1.08,
+ duration: 0.72,
+ ease: "sine.inOut",
+ repeat: -1,
+ yoyo: true,
+ transformOrigin: "50% 50%",
+ },
+ );
+ }, node);
+ return () => context.revert();
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function BomGsapSpinner({
+ children,
+ className,
+ ...props
+}: HTMLAttributes & { children: ReactNode }) {
+ const spinnerRef = useRef(null);
+
+ useEffect(() => {
+ const node = spinnerRef.current;
+ if (!node) return;
+ const context = gsap.context(() => {
+ if (prefersReducedMotion()) {
+ gsap.set(node, { rotation: 0 });
+ return;
+ }
+ gsap.to(node, {
+ rotation: 360,
+ duration: 0.9,
+ ease: "none",
+ repeat: -1,
+ transformOrigin: "50% 50%",
+ });
+ }, node);
+ return () => context.revert();
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function BomGsapRotate({
+ active,
+ children,
+ className,
+ degrees = 90,
+}: {
+ active: boolean;
+ children: ReactNode;
+ className?: string;
+ degrees?: number;
+}) {
+ const rotateRef = useRef(null);
+
+ useEffect(() => {
+ const node = rotateRef.current;
+ if (!node) return;
+ const context = gsap.context(() => {
+ if (prefersReducedMotion()) {
+ gsap.set(node, { rotation: active ? degrees : 0 });
+ return;
+ }
+ gsap.to(node, {
+ rotation: active ? degrees : 0,
+ duration: 0.18,
+ ease: "power2.out",
+ overwrite: "auto",
+ transformOrigin: "50% 50%",
+ });
+ }, node);
+ return () => context.revert();
+ }, [active, degrees]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function BomGsapReveal({
+ children,
+ className,
+}: {
+ children: ReactNode;
+ className?: string;
+}) {
+ const revealRef = useRef(null);
+
+ useEffect(() => {
+ const node = revealRef.current;
+ if (!node) return;
+ const context = gsap.context(() => {
+ if (prefersReducedMotion()) {
+ gsap.set(node, { autoAlpha: 1, y: 0 });
+ return;
+ }
+ gsap.fromTo(
+ node,
+ { autoAlpha: 0.72, y: 6 },
+ {
+ autoAlpha: 1,
+ y: 0,
+ duration: 0.22,
+ ease: "power2.out",
+ clearProps: "transform,opacity,visibility",
+ },
+ );
+ }, node);
+ return () => context.revert();
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function BomGsapProcessingSurface() {
+ const rootRef = useRef(null);
+ const scanRef = useRef(null);
+ const borderRef = useRef(null);
+
+ useEffect(() => {
+ const root = rootRef.current;
+ const scan = scanRef.current;
+ const border = borderRef.current;
+ if (!root || !scan || !border) return;
+
+ const context = gsap.context(() => {
+ if (prefersReducedMotion()) {
+ gsap.set(scan, {
+ autoAlpha: 0.68,
+ y: Math.max((root.clientHeight - 2) / 2, 0),
+ });
+ gsap.set(border, { autoAlpha: 0.72 });
+ return;
+ }
+
+ gsap
+ .timeline({ repeat: -1, repeatDelay: 0.18, repeatRefresh: true })
+ .set(scan, { autoAlpha: 0, y: 0 })
+ .to(scan, { autoAlpha: 1, duration: 0.16, ease: "power1.out" }, 0)
+ .to(
+ scan,
+ {
+ y: () => Math.max(root.clientHeight - 2, 0),
+ duration: 1.45,
+ ease: "none",
+ },
+ 0,
+ )
+ .to(scan, { autoAlpha: 0, duration: 0.18, ease: "power1.in" }, 1.27);
+
+ gsap.fromTo(
+ border,
+ { autoAlpha: 0.45 },
+ {
+ autoAlpha: 0.92,
+ duration: 0.78,
+ ease: "sine.inOut",
+ repeat: -1,
+ yoyo: true,
+ },
+ );
+ }, root);
+
+ return () => context.revert();
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx
index 83ca01f9..c65b1a56 100644
--- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx
+++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx
@@ -234,6 +234,15 @@ describe('BomCostingWorkbenchClient', () => {
expect(screen.getByText('grok-4.5')).toBeInTheDocument()
expect(screen.getByText('3 张')).toBeInTheDocument()
expect(screen.getByText('3 张参考图联合分析')).toBeInTheDocument()
+ expect(
+ screen.getByRole('progressbar').querySelector('[data-motion="progress"]'),
+ ).toBeInTheDocument()
+ expect(
+ document.querySelectorAll('[data-motion="processing-surface"]'),
+ ).toHaveLength(4)
+ expect(
+ document.querySelector('[data-motion="pulse"]'),
+ ).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: 'SKU 图 1' }))
expect(
screen.queryByRole('button', { name: '主图 1,联合分析中' }),
@@ -836,6 +845,25 @@ describe('BomCostingWorkbenchClient', () => {
expect(await screen.findByText('预计出厂成本')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /^重新核算/ }))
+ expect(
+ screen.getByTestId('bom-recalculation-action-dock'),
+ ).toBeInTheDocument()
+ const actionDock = screen.getByTestId('bom-recalculation-action-dock')
+ expect(actionDock.closest('aside')).toHaveClass(
+ 'flex',
+ 'overflow-hidden',
+ 'xl:h-[calc(100dvh-96px)]',
+ )
+ expect(actionDock.previousElementSibling).toHaveClass(
+ 'flex-1',
+ 'overflow-y-auto',
+ )
+ expect(
+ screen.getByRole('button', { name: '开始重新核算' }),
+ ).toBeEnabled()
+ expect(
+ screen.getByText('按现有事实重算').closest('[data-motion="reveal"]'),
+ ).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /补充分析图片/ }))
fireEvent.click(
screen.getByRole('button', { name: '选择商品详情图 1进行复查' }),
diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx
index 029cf5b6..5832e914 100644
--- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx
+++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx
@@ -46,6 +46,14 @@ import {
BomCostingAssetWorkspace,
deriveBomCostingImageActivity,
} from './BomCostingAssetWorkspace'
+import {
+ BomGsapProgress,
+ BomGsapPulse,
+ BomGsapReveal,
+ BomGsapRotate,
+ BomGsapSpinner,
+ useBomGsapScrollIntoView,
+} from './BomCostingMotion'
interface BomCostingWorkbenchClientProps {
initialCapabilities: BomCostingCapabilities
@@ -390,12 +398,11 @@ function ProcessingCanvas({
aria-valuemax={100}
aria-valuenow={detail.progress}
>
-