diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingAssetWorkspace.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingAssetWorkspace.tsx index b0b5402f..711b6199 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingAssetWorkspace.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingAssetWorkspace.tsx @@ -360,7 +360,7 @@ export function BomCostingAssetWorkspace({ : false return ( -
+
@@ -488,7 +488,7 @@ export function BomCostingAssetWorkspace({ onClick={() => selectAsset(asset)} data-testid={`bom-source-asset-${asset.id}`} className={cn( - 'relative h-[96px] w-[128px] shrink-0 overflow-hidden rounded-[6px] bg-[#e1e5e4] text-left outline-none transition duration-200 xl:aspect-[4/3] xl:h-auto xl:w-auto', + 'relative h-[96px] w-[128px] shrink-0 overflow-hidden rounded-[6px] bg-[#e1e5e4] text-left outline-none transition duration-200 xl:h-[124px] xl:w-full', selected ? 'ring-2 ring-[#4e6469] ring-offset-1' : 'hover:ring-1 hover:ring-black/20', @@ -505,7 +505,7 @@ export function BomCostingAssetWorkspace({ fill sizes="128px" unoptimized - className="object-cover" + className="object-cover xl:object-contain" /> ) : ( @@ -527,7 +527,7 @@ export function BomCostingAssetWorkspace({ ) })} {visibleAssets.length === 0 ? ( -
+
这一分类暂时没有图片
) : null} 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 a953d3b7..401c0454 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx @@ -208,16 +208,19 @@ describe('BomCostingWorkbenchClient', () => { 'src', '/api/storage/object?key=bom-costing%2Fmain.webp', ) - expect(screen.getByLabelText('竞品图片列表')).toHaveAttribute( + const assetList = screen.getByLabelText('竞品图片列表') + expect(assetList).toHaveAttribute( 'data-layout', 'desktop-thumbnail-rail', ) + expect(assetList).toHaveClass('xl:flex-col') + expect(assetList).not.toHaveClass('xl:grid-cols-2') expect(screen.getByRole('tab', { name: '主图 1' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'SKU 图 1' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: '详情图 1' })).toBeInTheDocument() expect( screen.getByRole('button', { name: '主图 1,联合分析中' }), - ).toBeInTheDocument() + ).toHaveClass('xl:h-[124px]', 'xl:w-full') expect( screen.getByRole('button', { name: 'SKU 图 2,联合分析中' }), ).toBeInTheDocument() @@ -390,7 +393,7 @@ describe('BomCostingWorkbenchClient', () => { } }) - it('开发 fixture 结果支持 SKU 切换并联动 BOM 与成本', async () => { + it('完成态按 SKU 联动大图、预计成本与 BOM,并向下展示详情图', async () => { const originalFetch = global.fetch global.fetch = jest.fn(async () => ({ ok: true, @@ -421,8 +424,8 @@ describe('BomCostingWorkbenchClient', () => { title: '竞品书桌', shopName: '测试店铺', skuCount: 2, - imageCount: 2, - storedImageCount: 2, + imageCount: 4, + storedImageCount: 4, failedImageCount: 0, errorMessage: '', fetchedAt: new Date().toISOString(), @@ -450,6 +453,28 @@ describe('BomCostingWorkbenchClient', () => { sortOrder: 1, errorMessage: '', }, + { + id: 'asset-sku-white', + kind: 'SKU', + status: 'STORED', + storedUrl: '/api/storage/object?key=bom-costing%2Fsku-white.webp', + contentType: 'image/webp', + byteSize: 4096, + externalSkuIds: ['sku-white'], + sortOrder: 2, + errorMessage: '', + }, + { + id: 'asset-detail', + kind: 'DETAIL', + status: 'STORED', + storedUrl: '/api/storage/object?key=bom-costing%2Fdetail.webp', + contentType: 'image/webp', + byteSize: 8192, + externalSkuIds: [], + sortOrder: 3, + errorMessage: '', + }, ], }, skus: [ @@ -604,18 +629,32 @@ describe('BomCostingWorkbenchClient', () => { expect(await screen.findByText('桌面板')).toBeInTheDocument() expect(screen.getByText('主图可见桌面板尺寸与材质')).toBeInTheDocument() - expect(screen.getByText('成本汇总')).toBeInTheDocument() - expect(screen.getByText('概念模型服务未接入')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '查看图像' })) - expect(screen.getByRole('tab', { name: '主图 1' })).toBeInTheDocument() - expect(screen.getByAltText('主图大图')).toHaveAttribute( + expect(screen.getByText('预计出厂成本')).toBeInTheDocument() + expect(screen.getByText('选择 SKU')).toBeInTheDocument() + expect(screen.getByText('商品详情图')).toBeInTheDocument() + expect(screen.getByAltText('商品详情图 1')).toHaveAttribute( + 'src', + '/api/storage/object?key=bom-costing%2Fdetail.webp', + ) + expect(await screen.findByAltText('选中的 SKU 图片')).toHaveAttribute( 'src', '/api/storage/object?key=bom-costing%2Fevidence.webp', ) - fireEvent.click(screen.getByRole('button', { name: 'BOM 明细' })) - fireEvent.click(screen.getByRole('button', { name: '白色 120cm' })) + fireEvent.click(screen.getByRole('button', { name: '查看图像' })) + expect(screen.getByAltText('选中的商品主图')).toHaveAttribute( + 'src', + '/api/storage/object?key=bom-costing%2Fevidence.webp', + ) + fireEvent.click(screen.getByRole('button', { name: /白色 120cm/ })) expect(screen.getByText('白色桌面板')).toBeInTheDocument() expect(screen.getAllByText('¥92.00').length).toBeGreaterThan(0) + await waitFor(() => { + expect(screen.getByAltText('选中的 SKU 图片')).toHaveAttribute( + 'src', + '/api/storage/object?key=bom-costing%2Fsku-white.webp', + ) + }) + expect(screen.getByRole('button', { name: '查看 BOM 明细' })).toBeInTheDocument() } finally { global.fetch = originalFetch } diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx index 9c1c6950..835e61c1 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx @@ -10,7 +10,6 @@ import { } from 'react' import { Bot, - Box, Calculator, Check, CheckCircle2, @@ -25,7 +24,6 @@ import { Play, ScanSearch, Search, - ShieldCheck, X, } from 'lucide-react' import { AssetWorkbenchEmptyBackdrop } from '@/components/platform/AssetWorkbenchEmptyBackdrop' @@ -517,316 +515,437 @@ function ResultCanvas({ selectedSku: BomCostingSkuDetail onSelectSku: (skuId: string) => void }) { - const [resultView, setResultView] = useState<'BOM' | 'IMAGES'>('BOM') - const [evidenceAssetId, setEvidenceAssetId] = useState(null) - const previewAssets = - detail.sourceSnapshot?.assets - .filter((asset) => asset.status === 'STORED' && asset.storedUrl) - .slice(0, 4) ?? [] - const sourceAssets = detail.sourceSnapshot?.assets ?? [] + const sourceAssets = useMemo( + () => + [...(detail.sourceSnapshot?.assets ?? [])] + .filter((asset) => asset.status === 'STORED' && asset.storedUrl) + .sort((left, right) => left.sortOrder - right.sortOrder), + [detail.sourceSnapshot?.assets], + ) + const mainAssets = useMemo( + () => sourceAssets.filter((asset) => asset.kind === 'MAIN'), + [sourceAssets], + ) + const selectedSkuAssets = useMemo( + () => + sourceAssets.filter( + (asset) => + asset.kind === 'SKU' && + (asset.externalSkuIds.includes(selectedSku.externalSkuId) || + (selectedSku.imageUrl && asset.storedUrl === selectedSku.imageUrl)), + ), + [selectedSku.externalSkuId, selectedSku.imageUrl, sourceAssets], + ) + const galleryAssets = useMemo( + () => [...mainAssets, ...selectedSkuAssets], + [mainAssets, selectedSkuAssets], + ) + const detailAssets = useMemo( + () => sourceAssets.filter((asset) => asset.kind === 'DETAIL'), + [sourceAssets], + ) + const [selectedAssetIdBySku, setSelectedAssetIdBySku] = useState< + Record + >({}) const assetIdByStoredUrl = new Map() for (const asset of sourceAssets) { if (asset.storedUrl && !assetIdByStoredUrl.has(asset.storedUrl)) { assetIdByStoredUrl.set(asset.storedUrl, asset.id) } } - const imageActivity = deriveBomCostingImageActivity(detail) + const selectedAsset = + galleryAssets.find( + (asset) => asset.id === selectedAssetIdBySku[selectedSku.id], + ) ?? + selectedSkuAssets[0] ?? + galleryAssets[0] ?? + null + + const setSelectedAssetId = (assetId: string) => { + setSelectedAssetIdBySku((current) => ({ + ...current, + [selectedSku.id]: assetId, + })) + } + + const selectEvidenceAsset = (assetId: string) => { + const galleryAsset = galleryAssets.find((asset) => asset.id === assetId) + if (galleryAsset) { + setSelectedAssetId(galleryAsset.id) + const gallery = document.getElementById('bom-product-gallery') + if (typeof gallery?.scrollIntoView === 'function') { + gallery.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + return + } + const detailAsset = document.getElementById(`bom-detail-asset-${assetId}`) + if (typeof detailAsset?.scrollIntoView === 'function') { + detailAsset.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + } + + const scrollToBom = () => { + const bomLines = document.getElementById('bom-costing-lines') + if (typeof bomLines?.scrollIntoView === 'function') { + bomLines.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + } return ( -
-
-
-
- {previewAssets[0]?.storedUrl ? ( - 竞品预览 - ) : null} -
-
-

{detail.title}

- +
+
+ + +
+
+
+

商品详情图

+

+ 共 {detailAssets.length} 张,按竞品页面原顺序展示 +

-

- {selectedSku.externalSkuId} - {selectedSku.structureGroupKey - ? ` · 结构组 ${selectedSku.structureGroupKey}` - : ''} +

+ {detailAssets.length > 0 ? ( +
+ {detailAssets.map((asset, index) => ( +
+ {`商品详情图 +
+ ))} +
+ ) : ( +
+ 该竞品没有归档详情图 +
+ )} +
+ +
+
+
+

BOM 核算明细

+

+ 当前 SKU:{selectedSku.name} +

+
+ + {selectedSku.bomLines.length} 项 + +
+
+ + + + + + + + + + + + + {selectedSku.bomLines.map((line) => { + const evidence = (line.evidence ?? [])[0] + const evidenceAssetId = evidence?.imageUrl + ? assetIdByStoredUrl.get(evidence.imageUrl) ?? null + : null + return ( + + + + + + + + + ) + })} + +
分类部件 / 材质尺寸数量价格来源小计
+ + {line.categoryLabel} + + +
+ {line.partName} +
+
+ {[line.materialName, line.materialSpec, line.processName] + .filter(Boolean) + .join(' · ') || '--'} +
+ {evidence?.note ? ( +
+ + {evidence.note} + {evidenceAssetId ? ( + + ) : null} +
+ ) : null} +
+ {[line.lengthMm, line.widthMm, line.heightMm] + .filter((value) => value !== null) + .join(' × ') || '--'} + + {line.quantity} {line.unit} + + {line.matchStatus === 'MATCHED' ? ( + + {line.matchedLibraryName || '分析估算'} + + ) : ( + + {line.matchStatus === 'NEEDS_CONFIRMATION' + ? '存在歧义' + : '待确认'} + + )} + + {formatMoney(line.subtotal)} +
+
+
+
+ +
-
-
- {detail.skus.map((sku) => ( - - ))} -
-
- - -
-
- +
+

选择 SKU

+
+ {detail.skus.map((sku) => { + const skuAsset = sourceAssets.find( + (asset) => + asset.kind === 'SKU' && + (asset.externalSkuIds.includes(sku.externalSkuId) || + (sku.imageUrl && asset.storedUrl === sku.imageUrl)), + ) + return ( + + ) + })} +
+
- {resultView === 'IMAGES' ? ( -
- -
- ) : ( -
- - - - - - - - - - - - - {selectedSku.bomLines.map((line) => { - const evidence = (line.evidence ?? [])[0] - const evidenceAssetId = evidence?.imageUrl - ? assetIdByStoredUrl.get(evidence.imageUrl) ?? null - : null - return ( - - - - - - - - - ) - })} - -
分类部件 / 材质尺寸数量价格来源小计
- - {line.categoryLabel} - - -
{line.partName}
-
- {[line.materialName, line.materialSpec, line.processName] - .filter(Boolean) - .join(' · ') || '--'} -
- {evidence?.note ? ( -
- - {evidence.note} - {evidenceAssetId ? ( - - ) : null} -
- ) : null} -
- {[line.lengthMm, line.widthMm, line.heightMm] - .filter((value) => value !== null) - .join(' × ') || '--'} - - {line.quantity} {line.unit} - - {line.matchStatus === 'MATCHED' ? ( - - {line.matchedLibraryName || '分析估算'} - - ) : ( - - {line.matchStatus === 'NEEDS_CONFIRMATION' - ? '存在歧义' - : '待确认'} - - )} - - {formatMoney(line.subtotal)} -
+
+

规格摘要

+
+
成品尺寸
+
+ {[selectedSku.lengthMm, selectedSku.widthMm, selectedSku.heightMm] + .map((value) => value ?? '--') + .join(' × ')} +
+
产品重量
+
+ {formatMetric(selectedSku.weightKg, 'kg')} +
+
包装尺寸
+
+ {[ + selectedSku.packageLengthMm, + selectedSku.packageWidthMm, + selectedSku.packageHeightMm, + ] + .map((value) => value ?? '--') + .join(' × ')} +
+
包装重量
+
+ {formatMetric(selectedSku.packageWeightKg, 'kg')} +
+
+
+ + +
- )} ) } -function CostInspector({ - detail, - selectedSku, -}: { - detail: BomCostingCaseDetail - selectedSku: BomCostingSkuDetail -}) { - const modelEvent = [...detail.taskEvents].reverse().find( - (event) => event.eventType === 'MODEL_ANALYSIS_COMPLETED', - ) - const modelPayload = readRecord(modelEvent?.payload) - const modelName = readString(modelPayload?.model) - const durationMs = readNumber(modelPayload?.durationMs) - - return ( - - ) -} - export default function BomCostingWorkbenchClient({ initialCapabilities, initialCases, @@ -1046,91 +1165,142 @@ export default function BomCostingWorkbenchClient({ } const hasCases = cases.length > 0 - const showInspector = Boolean(selectedCase) - const workbenchGridClassName = showInspector - ? 'xl:grid-cols-[220px_minmax(0,1fr)_288px]' - : hasCases - ? 'xl:grid-cols-[220px_minmax(0,1fr)]' - : 'grid-cols-1' + const showProcessInspector = Boolean( + selectedCase && selectedCase.status !== 'READY', + ) + const workbenchGridClassName = showProcessInspector + ? 'xl:grid-cols-[248px_minmax(0,1fr)_288px]' + : 'xl:grid-cols-[248px_minmax(0,1fr)]' return (
-
-
-
-
- -
-
-
-

BOM核算

- - {initialCapabilities.analysisConfigured ? ( - - ) : ( - - )} - - {initialCapabilities.analysisConfigured - ? '分析服务可用' - : '分析服务未接入'} - - -
-

- 出厂成本预估 · 目标误差 ≤{initialCapabilities.targetErrorPercent}% -

-
-
- -
- setSourceUrl(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') void handleCreateCase() - }} - disabled={creating} - placeholder="粘贴竞品商品链接" - leftIcon={} - containerClassName="min-w-0 flex-1 !space-y-0" - className="!min-h-[38px] !rounded-[7px] !bg-[#f6f7f7] !py-2" - aria-label="竞品商品链接" - /> - -
-
-
-
- {hasCases ? ( -
+ ) : null} +
{selectedCase && selectedCase.status !== 'READY' ? ( @@ -1297,8 +1468,6 @@ export default function BomCostingWorkbenchClient({ {selectedCase && selectedCase.status !== 'READY' ? ( - ) : selectedSku ? ( - ) : null}