feat(bom-costing): 使用 GSAP 重构核算动效
This commit is contained in:
parent
70a2d8eb55
commit
2e891c9a95
@ -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. 验证、收口与规范审查
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
493
furniture-3d-system/src/app/bom-costing/BomCostingMotion.tsx
Normal file
493
furniture-3d-system/src/app/bom-costing/BomCostingMotion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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进行复查' }),
|
||||
|
||||
@ -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]" />
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user