feat(bom-costing): 使用 GSAP 重构核算动效

This commit is contained in:
WuYongJie 2026-08-01 21:57:03 +08:00
parent 70a2d8eb55
commit 2e891c9a95
6 changed files with 734 additions and 239 deletions

View File

@ -148,6 +148,7 @@
- 现有 `apple-*` token、深色 `ObsidianControlShell`、历史主题 CSS 只能作为兼容遗留;新功能不得以它们作为视觉方向。
- 前端改造不得把现有后台习惯、普通 SaaS 审美或共享组件默认皮肤当成最终依据;如果组件库默认视觉和 Stitch/资产中心标准冲突,应在组件库体系内做专用变体、重写皮肤或局部重构。
- 没有出现在设计稿、组件规范或业务必要路径里的说明文案、辅助卡片、重复统计块、解释区,不要为了“更易懂”自行添加;工作区必须抓重点,图片类功能要让图片和关键动作成为第一层级。
- 涉及前端动画、入退场、滚动联动、时间线、拖拽、SVG 或动效性能时,先判断是否有助于理解和操作;需要时优先按技术栈调用对应 GSAP Skill,并继续遵守 Impeccable。React 必须使用 `useGSAP` 或带 `gsap.context().revert()` 的完整清理,滚动相关实例必须销毁并在布局变化时正确刷新;优先动画 `transform/opacity`,尊重 `prefers-reduced-motion`,不得为装饰滥用动效。
- 前端修改后优先用真实页面、截图、1920×1080 视口和必要的移动端视口验证,不只看代码。
## 8. 验证、收口与规范审查

View File

@ -1,91 +0,0 @@
.processingSurface {
position: relative;
isolation: isolate;
}
.processingSurface::after {
position: absolute;
z-index: 3;
right: 0;
left: 0;
top: 0;
height: 2px;
content: '';
background: linear-gradient(
90deg,
transparent,
rgba(78, 100, 105, 0.22) 18%,
rgba(78, 100, 105, 0.92) 50%,
rgba(78, 100, 105, 0.22) 82%,
transparent
);
box-shadow: 0 0 10px rgba(78, 100, 105, 0.28);
animation: bom-image-scan 2.2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
pointer-events: none;
}
.processingSurface::before {
position: absolute;
z-index: 2;
inset: 0;
content: '';
border: 1px solid rgba(78, 100, 105, 0.58);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.64);
animation: bom-image-signal 1.8s ease-in-out infinite;
pointer-events: none;
}
.processingDot {
box-shadow: 0 0 0 0 rgba(78, 100, 105, 0.34);
animation: bom-processing-dot 1.7s ease-out infinite;
}
@keyframes bom-image-scan {
0% {
transform: translateY(0);
opacity: 0;
}
12% {
opacity: 1;
}
88% {
opacity: 1;
}
100% {
transform: translateY(var(--bom-scan-distance, 250px));
opacity: 0;
}
}
@keyframes bom-image-signal {
0%,
100% {
opacity: 0.48;
}
50% {
opacity: 1;
}
}
@keyframes bom-processing-dot {
0% {
box-shadow: 0 0 0 0 rgba(78, 100, 105, 0.34);
}
72%,
100% {
box-shadow: 0 0 0 7px rgba(78, 100, 105, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.processingSurface::after,
.processingSurface::before,
.processingDot {
animation: none;
}
.processingSurface::after {
top: 50%;
opacity: 0.72;
}
}

View File

@ -9,7 +9,6 @@ import {
X,
} from 'lucide-react'
import {
type CSSProperties,
useEffect,
useMemo,
useRef,
@ -20,7 +19,12 @@ import type {
BomCostingCaseDetail,
BomCostingSourceAssetDetail,
} from '@/lib/bom-costing/client-types'
import styles from './BomCostingAssetWorkspace.module.css'
import {
BomGsapProcessingSurface,
BomGsapPulse,
BomGsapReveal,
useBomGsapScrollIntoView,
} from './BomCostingMotion'
type AssetKindFilter = 'ALL' | BomCostingSourceAssetDetail['kind']
@ -370,11 +374,9 @@ function AssetStatusIcon({
}) {
if (activity.activeAssetIds.has(asset.id)) {
return (
<span
className={cn(
'h-2 w-2 rounded-full bg-[#4e6469]',
styles.processingDot,
)}
<BomGsapPulse
className="h-2 w-2 rounded-full bg-[#4e6469]"
aria-hidden="true"
/>
)
}
@ -413,6 +415,8 @@ export function BomCostingAssetWorkspace({
initialAssetId ?? activity.focusAssetId ?? assets[0]?.id ?? null,
)
const thumbnailRefs = useRef(new Map<string, HTMLButtonElement>())
const thumbnailRailRef = useRef<HTMLDivElement>(null)
const scrollIntoView = useBomGsapScrollIntoView()
const visibleAssets = useMemo(
() => filter === 'ALL'
@ -439,14 +443,14 @@ export function BomCostingAssetWorkspace({
? 'ALL'
: currentFilter,
)
thumbnailRefs.current.get(autoFocusAssetId)?.scrollIntoView?.({
behavior: 'smooth',
scrollIntoView(thumbnailRefs.current.get(autoFocusAssetId) ?? null, {
container: thumbnailRailRef.current,
block: 'nearest',
inline: 'nearest',
})
}, 0)
return () => window.clearTimeout(timer)
}, [autoFocusAssetId, autoFocusAssetKind])
}, [autoFocusAssetId, autoFocusAssetKind, scrollIntoView])
useEffect(() => {
if (selectedAsset) onSelectedAssetChange?.(selectedAsset)
@ -472,40 +476,41 @@ export function BomCostingAssetWorkspace({
return (
<section className="flex min-h-0 flex-col bg-[#e8ebea] xl:grid xl:h-full xl:grid-cols-[minmax(0,1fr)_224px]">
<div
className={cn(
'relative h-[360px] min-h-0 overflow-hidden bg-[#e8ebea] sm:h-[460px] xl:h-full',
selectedActive && styles.processingSurface,
)}
style={{ '--bom-scan-distance': '720px' } as CSSProperties}
>
{selectedAsset?.storedUrl ? (
<Image
src={selectedAsset.storedUrl}
alt={`${KIND_LABELS[selectedAsset.kind]}大图`}
fill
sizes="(min-width: 1280px) 60vw, 100vw"
loading="eager"
unoptimized
className="object-contain"
/>
) : selectedFailed ? (
<div className="flex h-full items-center justify-center text-[#8c5a57]">
<div className="text-center">
<CircleAlert size={24} className="mx-auto" />
<p className="mt-2 text-[12px] font-bold">
{selectedAsset?.errorMessage || '这张图片处理失败'}
</p>
<div className="relative h-[360px] min-h-0 overflow-hidden bg-[#e8ebea] sm:h-[460px] xl:h-full">
<BomGsapReveal
key={selectedAsset?.id ?? 'empty-asset'}
className="absolute inset-0"
>
{selectedAsset?.storedUrl ? (
<Image
src={selectedAsset.storedUrl}
alt={`${KIND_LABELS[selectedAsset.kind]}大图`}
fill
sizes="(min-width: 1280px) 60vw, 100vw"
loading="eager"
unoptimized
className="object-contain"
/>
) : selectedFailed ? (
<div className="flex h-full items-center justify-center text-[#8c5a57]">
<div className="text-center">
<CircleAlert size={24} className="mx-auto" />
<p className="mt-2 text-[12px] font-bold">
{selectedAsset?.errorMessage || '这张图片处理失败'}
</p>
</div>
</div>
</div>
) : (
<div className="flex h-full items-center justify-center text-[#7d8789]">
<div className="text-center">
<ImageIcon size={24} className="mx-auto" />
<p className="mt-2 text-[12px] font-bold">等待图片归档</p>
) : (
<div className="flex h-full items-center justify-center text-[#7d8789]">
<div className="text-center">
<ImageIcon size={24} className="mx-auto" />
<p className="mt-2 text-[12px] font-bold">等待图片归档</p>
</div>
</div>
</div>
)}
)}
</BomGsapReveal>
{selectedActive ? <BomGsapProcessingSurface /> : null}
{selectedAsset ? (
<>
@ -586,6 +591,7 @@ export function BomCostingAssetWorkspace({
</div>
<div
ref={thumbnailRailRef}
className="flex min-h-[116px] gap-2 overflow-x-auto px-3 pb-3 xl:min-h-0 xl:flex-1 xl:flex-col xl:overflow-x-hidden xl:overflow-y-auto"
aria-label="竞品图片列表"
data-layout="desktop-thumbnail-rail"
@ -608,9 +614,7 @@ export function BomCostingAssetWorkspace({
selected
? 'ring-2 ring-[#4e6469] ring-offset-1'
: 'hover:ring-1 hover:ring-black/20',
active && styles.processingSurface,
)}
style={{ '--bom-scan-distance': '96px' } as CSSProperties}
aria-label={`${KIND_LABELS[asset.kind]} ${asset.sortOrder + 1},${assetStatusLabel(asset, activity)}`}
aria-pressed={selected}
>
@ -628,6 +632,7 @@ export function BomCostingAssetWorkspace({
<ImageIcon size={16} className="text-[#929b9d]" />
</span>
)}
{active ? <BomGsapProcessingSurface /> : null}
<span className="absolute left-1.5 top-1.5 z-[4] rounded-[4px] bg-black/[0.65] px-1.5 py-0.5 text-[9px] font-bold text-white">
{KIND_LABELS[asset.kind]}
</span>

View File

@ -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<gsap.core.Tween | null>(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<HTMLDivElement>(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 (
<div
ref={progressRef}
data-motion="progress"
className={cn("h-full w-full origin-left rounded-full", className)}
style={{ transform: `scaleX(${normalizedValue / 100})` }}
/>
);
}
export function BomGsapPulse({
children,
className,
...props
}: HTMLAttributes<HTMLSpanElement> & { children?: ReactNode }) {
const pulseRef = useRef<HTMLSpanElement>(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 (
<span
ref={pulseRef}
data-motion="pulse"
className={cn("inline-flex", className)}
{...props}
>
{children}
</span>
);
}
export function BomGsapSpinner({
children,
className,
...props
}: HTMLAttributes<HTMLSpanElement> & { children: ReactNode }) {
const spinnerRef = useRef<HTMLSpanElement>(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 (
<span
ref={spinnerRef}
data-motion="spinner"
className={cn("inline-flex", className)}
{...props}
>
{children}
</span>
);
}
export function BomGsapRotate({
active,
children,
className,
degrees = 90,
}: {
active: boolean;
children: ReactNode;
className?: string;
degrees?: number;
}) {
const rotateRef = useRef<HTMLSpanElement>(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 (
<span
ref={rotateRef}
data-motion="rotate"
className={cn("inline-flex shrink-0", className)}
style={{ transform: `rotate(${active ? degrees : 0}deg)` }}
>
{children}
</span>
);
}
export function BomGsapReveal({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const revealRef = useRef<HTMLDivElement>(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 (
<div ref={revealRef} data-motion="reveal" className={className}>
{children}
</div>
);
}
export function BomGsapProcessingSurface() {
const rootRef = useRef<HTMLDivElement>(null);
const scanRef = useRef<HTMLDivElement>(null);
const borderRef = useRef<HTMLDivElement>(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 (
<div
ref={rootRef}
data-motion="processing-surface"
className="pointer-events-none absolute inset-0 z-[3] overflow-hidden"
aria-hidden="true"
>
<div
ref={borderRef}
className="absolute inset-0 border border-[#4e6469]/60 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.64)]"
/>
<div
ref={scanRef}
className="absolute left-[8%] right-[8%] top-0 h-0.5 bg-[#4e6469] shadow-[0_0_10px_rgba(78,100,105,0.32)]"
/>
</div>
);
}

View File

@ -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进行复查' }),

View File

@ -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}
>
<div
className={cn(
'h-full rounded-full transition-[width] duration-200 motion-reduce:transition-none',
detail.status === 'FAILED' ? 'bg-[#a94d49]' : 'bg-[#354b50]',
)}
style={{ width: `${Math.max(2, detail.progress)}%` }}
<BomGsapProgress
value={Math.max(2, detail.progress)}
className={
detail.status === 'FAILED' ? 'bg-[#a94d49]' : 'bg-[#354b50]'
}
/>
</div>
</div>
@ -417,6 +424,7 @@ function ProcessInspector({
detail: BomCostingCaseDetail
now: number
}) {
const [recordsOpen, setRecordsOpen] = useState(false)
const taskEvents = getCurrentTaskEvents(detail)
const modelStarted = [...taskEvents].reverse().find(
(event) => event.eventType === 'MODEL_ANALYSIS_STARTED',
@ -466,25 +474,34 @@ function ProcessInspector({
<ol className="relative">
{PROCESS_STAGES.map((stage, index) => {
const state = getStageState(detail, stage)
const indicatorClassName = cn(
'relative z-10 mt-0.5 flex h-[19px] w-[19px] shrink-0 items-center justify-center rounded-full border',
state === 'done' && 'border-[#46645f] bg-[#46645f] text-white',
state === 'active' && 'border-[#405b61] bg-white text-[#405b61]',
state === 'failed' && 'border-[#a94d49] bg-[#a94d49] text-white',
state === 'waiting' && 'border-black/[0.12] bg-[#eef1f1] text-transparent',
)
const indicatorContent = (
<>
{state === 'done' ? <Check size={11} strokeWidth={2.4} /> : null}
{state === 'failed' ? <X size={11} strokeWidth={2.4} /> : null}
{state === 'active' ? (
<span className="h-1.5 w-1.5 rounded-full bg-current" />
) : null}
</>
)
return (
<li key={stage.key} className="relative flex gap-3 pb-4 last:pb-0">
{index < PROCESS_STAGES.length - 1 ? (
<span className="absolute left-[9px] top-5 h-[calc(100%-8px)] w-px bg-black/[0.09]" />
) : null}
<span
className={cn(
'relative z-10 mt-0.5 flex h-[19px] w-[19px] shrink-0 items-center justify-center rounded-full border',
state === 'done' && 'border-[#46645f] bg-[#46645f] text-white',
state === 'active' &&
'animate-pulse border-[#405b61] bg-white text-[#405b61] motion-reduce:animate-none',
state === 'failed' && 'border-[#a94d49] bg-[#a94d49] text-white',
state === 'waiting' && 'border-black/[0.12] bg-[#eef1f1] text-transparent',
)}
>
{state === 'done' ? <Check size={11} strokeWidth={2.4} /> : null}
{state === 'failed' ? <X size={11} strokeWidth={2.4} /> : null}
{state === 'active' ? <span className="h-1.5 w-1.5 rounded-full bg-current" /> : null}
</span>
{state === 'active' ? (
<BomGsapPulse className={indicatorClassName}>
{indicatorContent}
</BomGsapPulse>
) : (
<span className={indicatorClassName}>{indicatorContent}</span>
)}
<div className="min-w-0 flex-1">
<div
className={cn(
@ -510,7 +527,10 @@ function ProcessInspector({
<h3 className="text-[11px] font-bold">当前处理</h3>
{imageActivity.activeAssetIds.size > 0 ? (
<span className="flex items-center gap-1.5 text-[9px] font-bold text-[#506368]">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-[#506368] motion-reduce:animate-none" />
<BomGsapPulse
className="h-1.5 w-1.5 rounded-full bg-[#506368]"
aria-hidden="true"
/>
进行中
</span>
) : null}
@ -554,13 +574,15 @@ function ProcessInspector({
) : null}
{recentEvents.length > 0 ? (
<details className="group mt-4 border-t border-black/[0.07] pt-3">
<details
className="mt-4 border-t border-black/[0.07] pt-3"
onToggle={(event) => setRecordsOpen(event.currentTarget.open)}
>
<summary className="flex cursor-pointer list-none items-center justify-between text-[11px] font-bold">
<span>处理记录</span>
<ChevronRight
size={13}
className="text-[#8a9294] transition-transform group-open:rotate-90"
/>
<BomGsapRotate active={recordsOpen} className="text-[#8a9294]">
<ChevronRight size={13} />
</BomGsapRotate>
</summary>
<ol className="mt-3 space-y-2.5">
{recentEvents.map((event) => (
@ -668,11 +690,13 @@ function ResultCanvas({
)
const [priceEditorError, setPriceEditorError] = useState('')
const [recalculationOpen, setRecalculationOpen] = useState(false)
const [reviewArchiveOpen, setReviewArchiveOpen] = useState(false)
const [recalculationMode, setRecalculationMode] =
useState<RecalculationMode>('REUSE_FACTS')
const [selectedReanalysisAssetIds, setSelectedReanalysisAssetIds] =
useState<string[]>([])
const [recalculationInstruction, setRecalculationInstruction] = useState('')
const scrollIntoView = useBomGsapScrollIntoView()
const assetIdByStoredUrl = new Map<string, string>()
for (const asset of sourceAssets) {
if (asset.storedUrl && !assetIdByStoredUrl.has(asset.storedUrl)) {
@ -749,22 +773,16 @@ function ResultCanvas({
if (galleryAsset) {
setSelectedAssetId(galleryAsset.id)
const gallery = document.getElementById('bom-product-gallery')
if (typeof gallery?.scrollIntoView === 'function') {
gallery.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
scrollIntoView(gallery, { block: 'start' })
return
}
const detailAsset = document.getElementById(`bom-detail-asset-${assetId}`)
if (typeof detailAsset?.scrollIntoView === 'function') {
detailAsset.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
scrollIntoView(detailAsset, { block: 'center' })
}
const scrollToBom = () => {
const bomLines = document.getElementById('bom-costing-lines')
if (typeof bomLines?.scrollIntoView === 'function') {
bomLines.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
scrollIntoView(bomLines, { block: 'start' })
}
const openPriceEditor = async (line: BomCostingLineDetail) => {
@ -966,6 +984,8 @@ function ResultCanvas({
},
]
const currentSkuReviewInputs = (detail.reviewInputs ?? []).slice(0, 8)
const recalculationPanelOpen =
recalculationOpen && detail.status === 'READY'
return (
<div className="h-full min-h-[580px] overflow-y-auto bg-white">
@ -976,10 +996,9 @@ function ResultCanvas({
aria-live="polite"
>
<div className="mx-auto flex max-w-[1500px] items-center gap-3">
<RefreshCw
size={14}
className="shrink-0 animate-spin text-[#50686d] motion-reduce:animate-none"
/>
<BomGsapSpinner className="shrink-0 text-[#50686d]" aria-hidden="true">
<RefreshCw size={14} />
</BomGsapSpinner>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3 text-[11px]">
<span className="truncate font-bold">{detail.statusMessage}</span>
@ -988,9 +1007,9 @@ function ResultCanvas({
</span>
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-black/[0.07]">
<div
className="h-full rounded-full bg-[#526a70] transition-[width] duration-300"
style={{ width: `${Math.max(2, detail.progress)}%` }}
<BomGsapProgress
value={Math.max(2, detail.progress)}
className="bg-[#526a70]"
/>
</div>
</div>
@ -1071,21 +1090,26 @@ function ResultCanvas({
</div>
<div className="relative order-1 min-h-[420px] overflow-hidden bg-[#eef1f0] lg:order-2 lg:min-h-[640px]">
{selectedAsset?.storedUrl ? (
<Image
src={selectedAsset.storedUrl}
alt={selectedAsset.kind === 'MAIN' ? '选中的商品主图' : '选中的 SKU 图片'}
fill
sizes="(min-width: 1280px) 52vw, 100vw"
priority
unoptimized
className="object-contain"
/>
) : (
<div className="flex h-full items-center justify-center text-[12px] font-semibold text-[#788183]">
当前 SKU 暂无可展示图片
</div>
)}
<BomGsapReveal
key={selectedAsset?.id ?? `${selectedSku.id}-empty`}
className="absolute inset-0"
>
{selectedAsset?.storedUrl ? (
<Image
src={selectedAsset.storedUrl}
alt={selectedAsset.kind === 'MAIN' ? '选中的商品主图' : '选中的 SKU 图片'}
fill
sizes="(min-width: 1280px) 52vw, 100vw"
priority
unoptimized
className="object-contain"
/>
) : (
<div className="flex h-full items-center justify-center text-[12px] font-semibold text-[#788183]">
当前 SKU 暂无可展示图片
</div>
)}
</BomGsapReveal>
{selectedAsset ? (
<span className="absolute left-3 top-3 rounded-[4px] bg-black/65 px-2 py-1 text-[10px] font-bold text-white">
{selectedAsset.kind === 'MAIN' ? '主图' : 'SKU 图'}
@ -1288,13 +1312,12 @@ function ResultCanvas({
? '确认价格'
: '匹配价格'}
</span>
<ChevronDown
size={12}
className={cn(
'shrink-0 transition-transform',
editing && editingLineMode === 'LIBRARY' && 'rotate-180',
)}
/>
<BomGsapRotate
active={editing && editingLineMode === 'LIBRARY'}
degrees={180}
>
<ChevronDown size={12} />
</BomGsapRotate>
</button>
) : null}
{line.resolvedUnitPrice !== null ? (
@ -1431,7 +1454,10 @@ function ResultCanvas({
{loadingPriceLineId === line.id ? (
<div className="mt-3 flex h-16 items-center gap-2 text-[10px] text-[#687375]">
<span className="h-2 w-2 animate-pulse rounded-full bg-[#536b70] motion-reduce:animate-none" />
<BomGsapPulse
className="h-2 w-2 rounded-full bg-[#536b70]"
aria-hidden="true"
/>
正在读取资料库价格
</div>
) : priceEditorError ? (
@ -1521,7 +1547,20 @@ function ResultCanvas({
</section>
</div>
<aside className="order-2 min-w-0 self-start border-t border-black/[0.08] pt-4 xl:sticky xl:top-0 xl:col-start-2 xl:row-span-3 xl:row-start-1 xl:border-l xl:border-t-0 xl:pl-5 xl:pt-0">
<aside
className={cn(
'order-2 min-w-0 self-start border-t border-black/[0.08] pt-4 xl:sticky xl:top-0 xl:col-start-2 xl:row-span-3 xl:row-start-1 xl:border-l xl:border-t-0 xl:pl-5 xl:pt-0',
recalculationPanelOpen &&
'flex max-h-[calc(100dvh-144px)] flex-col overflow-hidden xl:h-[calc(100dvh-96px)] xl:max-h-[calc(100dvh-96px)]',
)}
>
<div
className={cn(
'min-w-0',
recalculationPanelOpen &&
'min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1',
)}
>
<div className="flex min-w-0 items-start gap-2">
<div className="min-w-0 flex-1">
<h2 className="text-[16px] font-bold leading-6">{detail.title}</h2>
@ -1586,12 +1625,17 @@ function ResultCanvas({
)}
</section>
<details className="group border-b border-black/[0.07] py-4">
<details
className="border-b border-black/[0.07] py-4"
onToggle={(event) => setReviewArchiveOpen(event.currentTarget.open)}
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-[11px] font-bold outline-none focus-visible:ring-2 focus-visible:ring-[#40585d]">
<span>核算留档</span>
<span className="flex items-center gap-2 text-[9px] font-semibold text-[#7a8385]">
{detail.reviewInputs?.length ?? 0} 条
<ChevronRight size={13} className="transition-transform group-open:rotate-90" />
<BomGsapRotate active={reviewArchiveOpen}>
<ChevronRight size={13} />
</BomGsapRotate>
</span>
</summary>
<div className="mt-3 space-y-2">
@ -1652,13 +1696,13 @@ function ResultCanvas({
</span>
</span>
</span>
<ChevronDown
size={13}
className={cn(
'shrink-0 text-[#7c8587] transition-transform',
recalculationOpen && 'rotate-180',
)}
/>
<BomGsapRotate
active={recalculationOpen}
degrees={180}
className="text-[#7c8587]"
>
<ChevronDown size={13} />
</BomGsapRotate>
</button>
{failedRecalculationRun ? (
@ -1681,8 +1725,8 @@ function ResultCanvas({
</div>
) : null}
{recalculationOpen && detail.status === 'READY' ? (
<div className="mt-3 space-y-3">
{recalculationPanelOpen ? (
<BomGsapReveal className="mt-3 space-y-3">
<div className="space-y-1">
{RECALCULATION_OPTIONS.map((option) => {
const active = option.value === recalculationMode
@ -1763,28 +1807,7 @@ function ResultCanvas({
/>
</label>
<Button
size="small"
onClick={() => void submitRecalculation()}
disabled={
recalculating ||
(
recalculationMode === 'REANALYZE_SELECTED' &&
selectedReanalysisAssetIds.length === 0
)
}
className="h-9 w-full rounded-[6px] text-[10px]"
>
<RefreshCw
size={13}
className={cn(
recalculating &&
'animate-spin motion-reduce:animate-none',
)}
/>
{recalculating ? '正在发起' : '开始重新核算'}
</Button>
</div>
</BomGsapReveal>
) : null}
</section>
@ -1882,6 +1905,36 @@ function ResultCanvas({
>
查看 BOM 明细
</Button>
</div>
{recalculationPanelOpen ? (
<div
className="shrink-0 border-t border-black/[0.08] bg-white py-3 pr-1"
data-testid="bom-recalculation-action-dock"
>
<Button
size="small"
onClick={() => void submitRecalculation()}
disabled={
recalculating ||
(
recalculationMode === 'REANALYZE_SELECTED' &&
selectedReanalysisAssetIds.length === 0
)
}
className="h-10 w-full rounded-[6px] text-[11px]"
>
{recalculating ? (
<BomGsapSpinner aria-hidden="true">
<RefreshCw size={14} />
</BomGsapSpinner>
) : (
<RefreshCw size={14} />
)}
{recalculating ? '正在发起' : '开始重新核算'}
</Button>
</div>
) : null}
</aside>
</div>
</div>
@ -2404,7 +2457,10 @@ export default function BomCostingWorkbenchClient({
</div>
</div>
{loadingCaseId === item.id ? (
<span className="mt-1 h-2 w-2 animate-pulse rounded-full bg-[#55686d] motion-reduce:animate-none" />
<BomGsapPulse
className="mt-1 h-2 w-2 rounded-full bg-[#55686d]"
aria-hidden="true"
/>
) : (
<ChevronRight
size={14}
@ -2432,9 +2488,9 @@ export default function BomCostingWorkbenchClient({
</div>
{processing ? (
<div className="mt-2 h-1 overflow-hidden rounded-full bg-black/[0.06]">
<div
className="h-full rounded-full bg-[#4e6469] transition-[width] duration-200"
style={{ width: `${Math.max(2, item.progress)}%` }}
<BomGsapProgress
value={Math.max(2, item.progress)}
className="bg-[#4e6469]"
/>
</div>
) : null}
@ -2477,7 +2533,10 @@ export default function BomCostingWorkbenchClient({
<div className="flex min-h-[320px] items-center justify-center px-6 py-12 xl:h-full xl:min-h-0">
<div className="flex max-w-[320px] flex-col items-center text-center">
{loadingCaseId ? (
<span className="h-3 w-3 animate-pulse rounded-full bg-[#536367] motion-reduce:animate-none" />
<BomGsapPulse
className="h-3 w-3 rounded-full bg-[#536367]"
aria-hidden="true"
/>
) : (
<PackageSearch size={24} strokeWidth={1.7} className="text-[#536367]" />
)}