merge(main): 发布 BOM 商品图片布局修正

This commit is contained in:
WuYongJie 2026-09-03 17:24:23 +08:00
commit 452c41bc4d
2 changed files with 115 additions and 88 deletions

View File

@ -103,21 +103,25 @@ describe('BomCostingWorkbenchClient', () => {
expect(screen.queryByText('成本构成')).not.toBeInTheDocument()
})
it('左侧图片按主图、当前 SKU 图和详情图分组切换', async () => {
it('主图在左、SKU 图在右、详情图在下并共同切换中间动态图片', async () => {
mockCaseResponse(detail())
render(<BomCostingWorkbenchClient initialCapabilities={capabilities} initialCases={[summary()]} />)
await screen.findByText('约 ¥1,350.00')
expect(screen.getByRole('tab', { name: '主图 1' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: '当前 SKU 图 1' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('tab', { name: '详情图 1' })).toBeInTheDocument()
expect(screen.getByLabelText('主图缩略图')).toBeInTheDocument()
expect(screen.getByLabelText('当前 SKU 图缩略图')).toBeInTheDocument()
expect(screen.getByLabelText('详情图缩略图')).toBeInTheDocument()
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
expect(screen.getByRole('img', { name: '黑胡桃色 120cm SKU 图' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '主图 1' }))
fireEvent.click(screen.getByRole('button', { name: '查看主图 1' }))
expect(screen.getByRole('img', { name: '商品主图' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '详情图 1' }))
fireEvent.click(screen.getByRole('button', { name: '查看详情图 1' }))
expect(screen.getByRole('img', { name: '商品详情图 1' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '查看当前 SKU 图 1' }))
expect(screen.getByRole('img', { name: '黑胡桃色 120cm SKU 图' })).toBeInTheDocument()
})
it('整件临时价的详情只展示结构事实,不展示另一套结构金额', async () => {

View File

@ -54,10 +54,9 @@ type RecalculationMode =
type ProductImageKind = BomCostingSourceAssetDetail['kind']
const PRODUCT_IMAGE_KINDS: ProductImageKind[] = ['MAIN', 'SKU', 'DETAIL']
const PRODUCT_IMAGE_KIND_LABELS: Record<ProductImageKind, string> = {
MAIN: '主图',
SKU: '当前 SKU 图',
SKU: 'SKU 图',
DETAIL: '详情图',
}
@ -477,16 +476,16 @@ function ResultCanvas({
SKU: skuAssets,
DETAIL: detailAssets,
}
const [preferredImageKind, setPreferredImageKind] = useState<ProductImageKind>('SKU')
const activeImageKind = imageGroups[preferredImageKind].length > 0
? preferredImageKind
: PRODUCT_IMAGE_KINDS.find((kind) => imageGroups[kind].length > 0) ?? preferredImageKind
const galleryAssets = imageGroups[activeImageKind]
const [selectedAssetId, setSelectedAssetId] = useState('')
const selectedAsset = galleryAssets.find((asset) => asset.id === selectedAssetId) ??
galleryAssets[0] ?? null
const displayAssets = [...skuAssets, ...mainAssets, ...detailAssets]
const [selectedAssetSelection, setSelectedAssetSelection] = useState({ skuId: '', assetId: '' })
const selectedAssetId = selectedAssetSelection.skuId === selectedSku.id
? selectedAssetSelection.assetId
: ''
const selectedAsset = displayAssets.find((asset) => asset.id === selectedAssetId) ??
skuAssets[0] ?? mainAssets[0] ?? detailAssets[0] ?? null
const selectedAssetGroup = selectedAsset ? imageGroups[selectedAsset.kind] : []
const selectedAssetIndex = selectedAsset
? galleryAssets.findIndex((asset) => asset.id === selectedAsset.id)
? selectedAssetGroup.findIndex((asset) => asset.id === selectedAsset.id)
: -1
const [detailsOpen, setDetailsOpen] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false)
@ -517,6 +516,9 @@ function ResultCanvas({
const latestRun = detail.analysisRuns?.[0] ?? null
const canResume = latestRun?.revision > 1 && latestRun.status === 'FAILED'
const selectedAssetIdSet = new Set(selectedAssetIds)
const selectDisplayedAsset = (assetId: string) => {
setSelectedAssetSelection({ skuId: selectedSku.id, assetId })
}
const toggleAsset = (assetId: string) => {
setSelectedAssetIds((current) => current.includes(assetId)
@ -573,86 +575,107 @@ function ResultCanvas({
) : null}
<div className="grid flex-1 items-center gap-8 lg:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.65fr)] lg:gap-12">
<section id="bom-product-gallery" className="min-w-0">
<div className="mb-3 flex items-end justify-between gap-4 border-b border-black/[0.08]">
<div className="flex min-w-0 gap-4 overflow-x-auto" role="tablist" aria-label="商品图片分类">
{PRODUCT_IMAGE_KINDS.map((kind) => {
const assets = imageGroups[kind]
const active = activeImageKind === kind
return (
<section id="bom-product-gallery" className="min-w-0" aria-label="商品图片">
<div className="grid grid-cols-[48px_minmax(0,1fr)_48px] items-start gap-2 sm:grid-cols-[64px_minmax(0,1fr)_64px] sm:gap-3">
<aside className="min-w-0" aria-label="主图缩略图">
<div className="mb-2 text-center text-[10px] font-semibold text-[#687376] sm:text-[11px]">
主图 <span className="tabular-nums text-[#8a9395]">{mainAssets.length}</span>
</div>
<div className="flex max-h-[280px] flex-col gap-2 overflow-y-auto sm:max-h-[420px]">
{mainAssets.map((asset, index) => (
<button
key={kind}
key={asset.id}
type="button"
role="tab"
aria-selected={active}
aria-controls="bom-product-gallery-panel"
disabled={assets.length === 0}
onClick={() => {
setPreferredImageKind(kind)
setSelectedAssetId(assets[0]?.id ?? '')
}}
onClick={() => selectDisplayedAsset(asset.id)}
aria-label={`查看主图 ${index + 1}`}
aria-pressed={selectedAsset?.id === asset.id}
className={cn(
'flex h-9 shrink-0 items-center gap-1.5 border-b-2 px-0 text-[11px] font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[#526a70] focus-visible:ring-offset-2',
active
? 'border-[#526a70] text-[#2f3d40]'
: 'border-transparent text-[#6f797b] hover:text-[#3f4d50]',
assets.length === 0 && 'cursor-not-allowed text-[#a3aaac] hover:text-[#a3aaac]',
'relative aspect-square w-full shrink-0 overflow-hidden border bg-[#f2f4f3] outline-none focus-visible:ring-2 focus-visible:ring-[#526a70] focus-visible:ring-offset-2',
selectedAsset?.id === asset.id ? 'border-[#526a70]' : 'border-black/[0.08] hover:border-black/25',
)}
>
<span>{PRODUCT_IMAGE_KIND_LABELS[kind]}</span>
<span className="tabular-nums text-[10px] text-current/70">{assets.length}</span>
<Image src={asset.storedUrl!} alt="" fill sizes="64px" unoptimized className="object-contain" />
</button>
)
})}
))}
{mainAssets.length === 0 ? <span className="py-3 text-center text-[11px] text-[#a3aaac]">—</span> : null}
</div>
</aside>
<div className="min-w-0">
<div className="relative aspect-[4/3] min-h-[280px] overflow-hidden bg-[#f2f4f3] sm:min-h-[420px]">
{selectedAsset?.storedUrl ? (
<>
<Image
src={selectedAsset.storedUrl}
alt={selectedAsset.kind === 'MAIN'
? '商品主图'
: selectedAsset.kind === 'SKU'
? `${selectedSku.name} SKU 图`
: `商品详情图 ${selectedAssetIndex + 1}`}
fill
sizes="(min-width: 1024px) 54vw, 74vw"
priority
unoptimized
className="object-contain"
/>
<span className="absolute right-2 top-2 bg-white/90 px-2 py-1 text-[10px] font-semibold text-[#526064] shadow-sm backdrop-blur-sm">
{PRODUCT_IMAGE_KIND_LABELS[selectedAsset.kind]} {selectedAssetIndex + 1}/{selectedAssetGroup.length}
</span>
</>
) : (
<div className="flex h-full items-center justify-center text-[12px] text-[#7a8486]">暂无商品图片</div>
)}
</div>
</div>
{galleryAssets.length > 0 ? (
<span className="mb-2.5 shrink-0 text-[10px] tabular-nums text-[#7a8486]">
{selectedAssetIndex + 1} / {galleryAssets.length}
</span>
) : null}
<aside className="min-w-0" aria-label="当前 SKU 图缩略图">
<div className="mb-2 text-center text-[10px] font-semibold text-[#687376] sm:text-[11px]">
SKU <span className="tabular-nums text-[#8a9395]">{skuAssets.length}</span>
</div>
<div className="flex max-h-[280px] flex-col gap-2 overflow-y-auto sm:max-h-[420px]">
{skuAssets.map((asset, index) => (
<button
key={asset.id}
type="button"
onClick={() => selectDisplayedAsset(asset.id)}
aria-label={`查看当前 SKU 图 ${index + 1}`}
aria-pressed={selectedAsset?.id === asset.id}
className={cn(
'relative aspect-square w-full shrink-0 overflow-hidden border bg-[#f2f4f3] outline-none focus-visible:ring-2 focus-visible:ring-[#526a70] focus-visible:ring-offset-2',
selectedAsset?.id === asset.id ? 'border-[#526a70]' : 'border-black/[0.08] hover:border-black/25',
)}
>
<Image src={asset.storedUrl!} alt="" fill sizes="64px" unoptimized className="object-contain" />
</button>
))}
{skuAssets.length === 0 ? <span className="py-3 text-center text-[11px] text-[#a3aaac]">—</span> : null}
</div>
</aside>
</div>
<div
id="bom-product-gallery-panel"
role="tabpanel"
aria-label={PRODUCT_IMAGE_KIND_LABELS[activeImageKind]}
className="relative aspect-[4/3] min-h-[280px] overflow-hidden bg-[#f2f4f3] sm:min-h-[420px]"
>
{selectedAsset?.storedUrl ? (
<Image
src={selectedAsset.storedUrl}
alt={selectedAsset.kind === 'MAIN'
? '商品主图'
: selectedAsset.kind === 'SKU'
? `${selectedSku.name} SKU 图`
: `商品详情图 ${selectedAssetIndex + 1}`}
fill
sizes="(min-width: 1024px) 62vw, 100vw"
priority
unoptimized
className="object-contain"
/>
) : (
<div className="flex h-full items-center justify-center text-[12px] text-[#7a8486]">暂无商品图片</div>
)}
</div>
{galleryAssets.length > 1 ? (
<div className="mt-3 flex gap-2 overflow-x-auto pb-1" aria-label={`${PRODUCT_IMAGE_KIND_LABELS[activeImageKind]}缩略图`}>
{galleryAssets.map((asset, index) => (
<button
key={asset.id}
type="button"
onClick={() => setSelectedAssetId(asset.id)}
aria-label={`查看${PRODUCT_IMAGE_KIND_LABELS[activeImageKind]} ${index + 1}`}
aria-pressed={selectedAsset?.id === asset.id}
className={cn(
'relative h-14 w-14 shrink-0 overflow-hidden border bg-[#f2f4f3] outline-none focus-visible:ring-2 focus-visible:ring-[#526a70]',
selectedAsset?.id === asset.id ? 'border-[#526a70]' : 'border-black/[0.08]',
)}
>
<Image src={asset.storedUrl!} alt="" fill sizes="56px" unoptimized className="object-contain" />
</button>
))}
{detailAssets.length > 0 ? (
<div className="mt-3" aria-label="详情图缩略图">
<div className="mb-2 flex items-center gap-1.5 text-[10px] font-semibold text-[#687376] sm:text-[11px]">
<span>详情图</span>
<span className="tabular-nums text-[#8a9395]">{detailAssets.length}</span>
</div>
<div className="flex gap-2 overflow-x-auto pb-1">
{detailAssets.map((asset, index) => (
<button
key={asset.id}
type="button"
onClick={() => selectDisplayedAsset(asset.id)}
aria-label={`查看详情图 ${index + 1}`}
aria-pressed={selectedAsset?.id === asset.id}
className={cn(
'relative h-14 w-14 shrink-0 overflow-hidden border bg-[#f2f4f3] outline-none focus-visible:ring-2 focus-visible:ring-[#526a70] focus-visible:ring-offset-2',
selectedAsset?.id === asset.id ? 'border-[#526a70]' : 'border-black/[0.08] hover:border-black/25',
)}
>
<Image src={asset.storedUrl!} alt="" fill sizes="56px" unoptimized className="object-contain" />
</button>
))}
</div>
</div>
) : null}
</section>