feat(bom-costing): 重构竞品核算结果工作台

This commit is contained in:
WuYongJie 2026-07-29 15:06:16 +08:00
parent 60f252571a
commit 0c9c2fccc8
3 changed files with 593 additions and 385 deletions

View File

@ -360,7 +360,7 @@ export function BomCostingAssetWorkspace({
: false
return (
<section className="flex min-h-0 flex-col bg-[#e8ebea] xl:grid xl:h-full xl:grid-cols-[minmax(0,1fr)_264px]">
<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',
@ -470,7 +470,7 @@ export function BomCostingAssetWorkspace({
</div>
<div
className="flex min-h-[116px] gap-2 overflow-x-auto px-3 pb-3 xl:grid xl:min-h-0 xl:flex-1 xl:grid-cols-2 xl:content-start xl:overflow-x-hidden xl:overflow-y-auto"
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"
>
@ -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"
/>
) : (
<span className="absolute inset-0 flex items-center justify-center">
@ -527,7 +527,7 @@ export function BomCostingAssetWorkspace({
)
})}
{visibleAssets.length === 0 ? (
<div className="flex h-[96px] min-w-[220px] items-center justify-center text-[11px] text-[#7d8587] xl:col-span-2 xl:min-w-0">
<div className="flex h-[96px] min-w-[220px] items-center justify-center text-[11px] text-[#7d8587] xl:min-w-0">
这一分类暂时没有图片
</div>
) : null}

View File

@ -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
}

View File

@ -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<string | null>(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<string, string>
>({})
const assetIdByStoredUrl = new Map<string, string>()
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 (
<div className="flex min-h-[580px] flex-col xl:h-full xl:min-h-0">
<div className="shrink-0 border-b border-black/[0.06] px-4 py-3">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
{previewAssets[0]?.storedUrl ? (
<Image
src={previewAssets[0].storedUrl}
alt="竞品预览"
width={48}
height={48}
unoptimized
className="h-12 w-12 shrink-0 rounded-[6px] object-cover"
/>
) : null}
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-[15px] font-bold">{detail.title}</h2>
<a
href={detail.sourceUrl}
target="_blank"
rel="noreferrer"
aria-label="打开竞品链接"
title="打开竞品链接"
className="text-[#778083] transition hover:text-[#2d3335]"
<div className="h-full min-h-[580px] overflow-y-auto bg-white">
<div className="mx-auto grid w-full max-w-[1500px] gap-5 px-4 py-4 xl:grid-cols-[minmax(0,1fr)_360px] xl:px-5">
<div className="contents">
<section
id="bom-product-gallery"
className="order-1 grid min-h-[520px] min-w-0 gap-3 lg:grid-cols-[88px_minmax(0,1fr)] xl:col-start-1 xl:row-start-1"
>
<div
className="order-2 flex gap-2 overflow-x-auto lg:order-1 lg:max-h-[640px] lg:flex-col lg:overflow-x-hidden lg:overflow-y-auto"
aria-label="商品主图与当前 SKU 图片"
>
{galleryAssets.map((asset) => (
<button
key={asset.id}
type="button"
onClick={() => setSelectedAssetId(asset.id)}
aria-label={`查看${asset.kind === 'MAIN' ? '主图' : '当前 SKU 图片'} ${asset.sortOrder + 1}`}
aria-pressed={asset.id === selectedAsset?.id}
className={cn(
'relative h-[82px] w-[82px] shrink-0 overflow-hidden rounded-[6px] bg-[#edf0ef] outline-none transition',
asset.id === selectedAsset?.id
? 'ring-2 ring-[#40585d] ring-offset-1'
: 'hover:ring-1 hover:ring-black/20 focus-visible:ring-2 focus-visible:ring-[#40585d]',
)}
>
<ExternalLink size={14} />
</a>
<Image
src={asset.storedUrl!}
alt=""
fill
sizes="82px"
unoptimized
className="object-cover"
/>
<span className="absolute bottom-1 left-1 rounded-[3px] bg-black/65 px-1.5 py-0.5 text-[9px] font-bold text-white">
{asset.kind === 'MAIN' ? '主图' : 'SKU 图'}
</span>
</button>
))}
{galleryAssets.length === 0 ? (
<div className="flex h-[82px] w-[82px] shrink-0 items-center justify-center rounded-[6px] bg-[#edf0ef] text-[10px] text-[#778082]">
暂无图片
</div>
) : null}
</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>
)}
{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 图'}
</span>
) : null}
</div>
</section>
<section className="order-3 min-w-0 border-t border-black/[0.07] pt-6 xl:col-start-1 xl:row-start-2">
<div className="mb-4 flex items-end justify-between gap-4">
<div>
<h3 className="text-[15px] font-bold">商品详情图</h3>
<p className="mt-1 text-[11px] text-[#747c7e]">
共 {detailAssets.length} 张,按竞品页面原顺序展示
</p>
</div>
<p className="mt-1 text-[11px] text-[#8a9092]">
{selectedSku.externalSkuId}
{selectedSku.structureGroupKey
? ` · 结构组 ${selectedSku.structureGroupKey}`
: ''}
</div>
{detailAssets.length > 0 ? (
<div className="mx-auto max-w-[920px] space-y-2">
{detailAssets.map((asset, index) => (
<figure
key={asset.id}
id={`bom-detail-asset-${asset.id}`}
className="scroll-mt-4 overflow-hidden bg-[#f1f3f2]"
>
<Image
src={asset.storedUrl!}
alt={`商品详情图 ${index + 1}`}
width={1200}
height={1200}
sizes="(min-width: 1280px) 760px, 100vw"
unoptimized
className="h-auto w-full object-contain"
/>
</figure>
))}
</div>
) : (
<div className="flex min-h-32 items-center justify-center bg-[#f4f6f5] text-[11px] text-[#788183]">
该竞品没有归档详情图
</div>
)}
</section>
<section
id="bom-costing-lines"
className="order-4 min-w-0 scroll-mt-4 border-t border-black/[0.07] pt-6 xl:col-start-1 xl:row-start-3"
>
<div className="mb-4 flex items-end justify-between gap-4">
<div>
<h3 className="text-[15px] font-bold">BOM 核算明细</h3>
<p className="mt-1 text-[11px] text-[#747c7e]">
当前 SKU:{selectedSku.name}
</p>
</div>
<span className="text-[11px] font-semibold text-[#657073]">
{selectedSku.bomLines.length} 项
</span>
</div>
<div className="overflow-x-auto border-y border-black/[0.07]">
<table className="w-full min-w-[820px] border-collapse text-left">
<thead className="bg-[#f7f8f8] text-[10px] font-bold text-[#71787a]">
<tr>
<th className="w-[82px] px-4 py-2.5">分类</th>
<th className="px-3 py-2.5">部件 / 材质</th>
<th className="w-[132px] px-3 py-2.5">尺寸</th>
<th className="w-[90px] px-3 py-2.5">数量</th>
<th className="w-[118px] px-3 py-2.5">价格来源</th>
<th className="w-[90px] px-3 py-2.5 text-right">小计</th>
</tr>
</thead>
<tbody className="divide-y divide-black/[0.05]">
{selectedSku.bomLines.map((line) => {
const evidence = (line.evidence ?? [])[0]
const evidenceAssetId = evidence?.imageUrl
? assetIdByStoredUrl.get(evidence.imageUrl) ?? null
: null
return (
<tr key={line.id} className="text-[11px] hover:bg-[#fafbfb]">
<td className="px-4 py-3">
<span className="rounded-[5px] bg-[#edf1f1] px-2 py-1 font-bold text-[#566466]">
{line.categoryLabel}
</span>
</td>
<td className="px-3 py-3">
<div className="font-semibold text-[#353b3d]">
{line.partName}
</div>
<div className="mt-0.5 text-[10px] text-[#878d8f]">
{[line.materialName, line.materialSpec, line.processName]
.filter(Boolean)
.join(' · ') || '--'}
</div>
{evidence?.note ? (
<div className="mt-1 flex max-w-[560px] items-start gap-1.5 text-[9px] leading-4 text-[#758082]">
<ScanSearch size={11} className="mt-0.5 shrink-0" />
<span className="line-clamp-2">{evidence.note}</span>
{evidenceAssetId ? (
<button
type="button"
onClick={() => selectEvidenceAsset(evidenceAssetId)}
className="shrink-0 rounded-[4px] px-1.5 py-0.5 font-bold text-[#4d6267] hover:bg-[#edf1f1]"
>
查看图像
</button>
) : null}
</div>
) : null}
</td>
<td className="px-3 py-3 text-[#687174]">
{[line.lengthMm, line.widthMm, line.heightMm]
.filter((value) => value !== null)
.join(' × ') || '--'}
</td>
<td className="px-3 py-3">
{line.quantity} {line.unit}
</td>
<td className="px-3 py-3">
{line.matchStatus === 'MATCHED' ? (
<span className="text-[#4c625f]">
{line.matchedLibraryName || '分析估算'}
</span>
) : (
<span className="text-[#9a6941]">
{line.matchStatus === 'NEEDS_CONFIRMATION'
? '存在歧义'
: '待确认'}
</span>
)}
</td>
<td className="px-3 py-3 text-right font-bold">
{formatMoney(line.subtotal)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</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">
<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>
<p className="mt-1 text-[10px] text-[#7b8385]">
{detail.sourceSnapshot?.shopName || detail.sourceHost}
</p>
</div>
<a
href={detail.sourceUrl}
target="_blank"
rel="noreferrer"
aria-label="打开竞品链接"
title="打开竞品链接"
className="mt-0.5 shrink-0 rounded-[5px] p-1.5 text-[#6e797b] transition hover:bg-[#edf1f1] hover:text-[#2d3335]"
>
<ExternalLink size={15} />
</a>
</div>
<div className="shrink-0 text-right">
<div className="text-[18px] font-black">
<section className="mt-5 bg-[#2d3335] px-4 py-4 text-white">
<p className="text-[10px] font-semibold text-white/70">预计出厂成本</p>
<div className="mt-1 text-[30px] font-black leading-none tabular-nums">
{formatMoney(selectedSku.estimatedCost)}
</div>
<div className="text-[10px] text-[#909597]">
{selectedSku.costSummary.unresolvedLineCount
? `${selectedSku.costSummary.unresolvedLineCount} 项待确认`
: '预计出厂成本'}
<p className="mt-2 text-[10px] leading-4 text-white/68">
含材料、加工、人工、包材和损耗;不含税费、物流、平台佣金及营销费用
</p>
</section>
<section className="border-b border-black/[0.07] py-4">
<div className="grid grid-cols-2 gap-x-5 gap-y-2 text-[10px]">
{[
['材料', selectedSku.costSummary.material],
['加工', selectedSku.costSummary.processing],
['人工', selectedSku.costSummary.labor],
['包材', selectedSku.costSummary.packaging],
].map(([label, value]) => (
<div key={String(label)} className="flex items-center justify-between gap-2">
<span className="text-[#747c7e]">{label}</span>
<span className="font-bold">{formatMoney(value as number | null)}</span>
</div>
))}
</div>
</div>
</div>
{selectedSku.costSummary.unresolvedLineCount > 0 ? (
<p className="mt-3 text-[10px] font-semibold text-[#9a6941]">
{selectedSku.costSummary.unresolvedLineCount} 项价格待确认
</p>
) : null}
</section>
<div className="mt-3 flex flex-col gap-2 xl:flex-row xl:items-end xl:justify-between">
<div className="flex min-w-0 gap-1 overflow-x-auto">
{detail.skus.map((sku) => (
<button
key={sku.id}
type="button"
onClick={() => onSelectSku(sku.id)}
className={cn(
'shrink-0 rounded-[6px] px-3 py-2 text-[11px] font-semibold transition duration-200',
sku.id === selectedSku.id
? 'bg-[#2d3335] text-white'
: 'bg-[#f1f3f3] text-[#62696b] hover:bg-[#e8ebeb]',
)}
>
{sku.name}
</button>
))}
</div>
<div className="flex shrink-0 rounded-[6px] bg-[#eef1f1] p-0.5">
<button
type="button"
onClick={() => setResultView('BOM')}
className={cn(
'rounded-[5px] px-2.5 py-1.5 text-[10px] font-bold transition',
resultView === 'BOM'
? 'bg-white text-[#2d3335] shadow-sm'
: 'text-[#70797b]',
)}
>
BOM 明细
</button>
<button
type="button"
onClick={() => setResultView('IMAGES')}
className={cn(
'rounded-[5px] px-2.5 py-1.5 text-[10px] font-bold transition',
resultView === 'IMAGES'
? 'bg-white text-[#2d3335] shadow-sm'
: 'text-[#70797b]',
)}
>
竞品图片 {sourceAssets.length}
</button>
</div>
</div>
</div>
<section className="border-b border-black/[0.07] py-4">
<h3 className="text-[11px] font-bold">选择 SKU</h3>
<div className="mt-2 max-h-[250px] space-y-1.5 overflow-y-auto pr-1">
{detail.skus.map((sku) => {
const skuAsset = sourceAssets.find(
(asset) =>
asset.kind === 'SKU' &&
(asset.externalSkuIds.includes(sku.externalSkuId) ||
(sku.imageUrl && asset.storedUrl === sku.imageUrl)),
)
return (
<button
key={sku.id}
type="button"
onClick={() => onSelectSku(sku.id)}
aria-pressed={sku.id === selectedSku.id}
className={cn(
'flex w-full items-center gap-2 rounded-[6px] border px-2 py-2 text-left transition',
sku.id === selectedSku.id
? 'border-[#40585d] bg-[#edf1f1]'
: 'border-black/[0.07] hover:border-black/20 hover:bg-[#f7f8f8]',
)}
>
{skuAsset?.storedUrl ? (
<span className="relative h-10 w-10 shrink-0 overflow-hidden rounded-[4px] bg-[#e8ebea]">
<Image
src={skuAsset.storedUrl}
alt=""
fill
sizes="40px"
unoptimized
className="object-cover"
/>
</span>
) : (
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[4px] bg-[#edf0ef] text-[#899193]">
<PackageSearch size={15} />
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-[10px] font-bold">
{sku.name}
</span>
<span className="mt-0.5 block truncate text-[9px] text-[#7a8385]">
{sku.externalSkuId}
</span>
</span>
<span className="shrink-0 text-[10px] font-black">
{formatMoney(sku.estimatedCost)}
</span>
</button>
)
})}
</div>
</section>
{resultView === 'IMAGES' ? (
<div className="min-h-0 flex-1 overflow-auto">
<BomCostingAssetWorkspace
detail={detail}
activity={imageActivity}
initialAssetId={evidenceAssetId}
/>
</div>
) : (
<div className="min-h-0 flex-1 overflow-auto">
<table className="w-full min-w-[820px] border-collapse text-left">
<thead className="sticky top-0 z-10 bg-[#f7f8f8] text-[10px] font-bold text-[#71787a]">
<tr>
<th className="w-[82px] px-4 py-2.5">分类</th>
<th className="px-3 py-2.5">部件 / 材质</th>
<th className="w-[132px] px-3 py-2.5">尺寸</th>
<th className="w-[90px] px-3 py-2.5">数量</th>
<th className="w-[118px] px-3 py-2.5">价格来源</th>
<th className="w-[90px] px-3 py-2.5 text-right">小计</th>
</tr>
</thead>
<tbody className="divide-y divide-black/[0.05]">
{selectedSku.bomLines.map((line) => {
const evidence = (line.evidence ?? [])[0]
const evidenceAssetId = evidence?.imageUrl
? assetIdByStoredUrl.get(evidence.imageUrl) ?? null
: null
return (
<tr key={line.id} className="text-[11px] hover:bg-[#fafbfb]">
<td className="px-4 py-3">
<span className="rounded-[5px] bg-[#edf1f1] px-2 py-1 font-bold text-[#566466]">
{line.categoryLabel}
</span>
</td>
<td className="px-3 py-3">
<div className="font-semibold text-[#353b3d]">{line.partName}</div>
<div className="mt-0.5 text-[10px] text-[#878d8f]">
{[line.materialName, line.materialSpec, line.processName]
.filter(Boolean)
.join(' · ') || '--'}
</div>
{evidence?.note ? (
<div className="mt-1 flex max-w-[560px] items-start gap-1.5 text-[9px] leading-4 text-[#758082]">
<ScanSearch size={11} className="mt-0.5 shrink-0" />
<span className="line-clamp-2">{evidence.note}</span>
{evidenceAssetId ? (
<button
type="button"
onClick={() => {
setEvidenceAssetId(evidenceAssetId)
setResultView('IMAGES')
}}
className="shrink-0 rounded-[4px] px-1.5 py-0.5 font-bold text-[#4d6267] hover:bg-[#edf1f1]"
>
查看图像
</button>
) : null}
</div>
) : null}
</td>
<td className="px-3 py-3 text-[#687174]">
{[line.lengthMm, line.widthMm, line.heightMm]
.filter((value) => value !== null)
.join(' × ') || '--'}
</td>
<td className="px-3 py-3">
{line.quantity} {line.unit}
</td>
<td className="px-3 py-3">
{line.matchStatus === 'MATCHED' ? (
<span className="text-[#4c625f]">
{line.matchedLibraryName || '分析估算'}
</span>
) : (
<span className="text-[#9a6941]">
{line.matchStatus === 'NEEDS_CONFIRMATION'
? '存在歧义'
: '待确认'}
</span>
)}
</td>
<td className="px-3 py-3 text-right font-bold">
{formatMoney(line.subtotal)}
</td>
</tr>
)
})}
</tbody>
</table>
<section className="border-b border-black/[0.07] py-4">
<h3 className="text-[11px] font-bold">规格摘要</h3>
<dl className="mt-3 grid grid-cols-[72px_minmax(0,1fr)] gap-y-2 text-[10px]">
<dt className="text-[#747c7e]">成品尺寸</dt>
<dd className="text-right font-semibold">
{[selectedSku.lengthMm, selectedSku.widthMm, selectedSku.heightMm]
.map((value) => value ?? '--')
.join(' × ')}
</dd>
<dt className="text-[#747c7e]">产品重量</dt>
<dd className="text-right font-semibold">
{formatMetric(selectedSku.weightKg, 'kg')}
</dd>
<dt className="text-[#747c7e]">包装尺寸</dt>
<dd className="text-right font-semibold">
{[
selectedSku.packageLengthMm,
selectedSku.packageWidthMm,
selectedSku.packageHeightMm,
]
.map((value) => value ?? '--')
.join(' × ')}
</dd>
<dt className="text-[#747c7e]">包装重量</dt>
<dd className="text-right font-semibold">
{formatMetric(selectedSku.packageWeightKg, 'kg')}
</dd>
</dl>
</section>
<Button
size="small"
variant="gray"
onClick={scrollToBom}
className="mt-4 h-9 w-full rounded-[6px] text-[11px]"
>
查看 BOM 明细
</Button>
</aside>
</div>
)}
</div>
)
}
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 (
<aside className="min-w-0 border-t border-black/[0.07] bg-[#f6f7f7] p-3 xl:min-h-0 xl:overflow-y-auto xl:border-l xl:border-t-0">
<section className="border-b border-black/[0.07] px-1 pb-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[12px] font-bold">成本汇总</h3>
<ShieldCheck size={15} className="text-[#586669]" strokeWidth={1.7} />
</div>
<div className="space-y-2 text-[11px]">
{[
['材料', selectedSku.costSummary.material],
['加工', selectedSku.costSummary.processing],
['人工', selectedSku.costSummary.labor],
['包材', selectedSku.costSummary.packaging],
].map(([label, value]) => (
<div key={String(label)} className="flex items-center justify-between">
<span className="text-[#687174]">{label}</span>
<span className="font-semibold">{formatMoney(value as number | null)}</span>
</div>
))}
</div>
<div className="mt-3 flex items-end justify-between border-t border-black/[0.07] pt-3">
<span className="text-[11px] font-bold">预计出厂成本</span>
<span className="text-[20px] font-black">
{formatMoney(selectedSku.estimatedCost)}
</span>
</div>
<p className="mt-2 text-[10px] leading-4 text-[#747c7e]">
含材料、加工、人工、包材和损耗,不含税费、物流、平台佣金及营销费用。
</p>
</section>
<section className="border-b border-black/[0.07] px-1 py-4">
<h3 className="mb-3 text-[12px] font-bold">规格摘要</h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-[10px]">
<span className="text-[#747c7e]">成品尺寸</span>
<span className="text-right font-semibold">
{[selectedSku.lengthMm, selectedSku.widthMm, selectedSku.heightMm]
.map((value) => value ?? '--')
.join(' × ')}
</span>
<span className="text-[#747c7e]">产品重量</span>
<span className="text-right font-semibold">
{formatMetric(selectedSku.weightKg, 'kg')}
</span>
<span className="text-[#747c7e]">包装尺寸</span>
<span className="text-right font-semibold">
{[
selectedSku.packageLengthMm,
selectedSku.packageWidthMm,
selectedSku.packageHeightMm,
]
.map((value) => value ?? '--')
.join(' × ')}
</span>
<span className="text-[#747c7e]">包装重量</span>
<span className="text-right font-semibold">
{formatMetric(selectedSku.packageWeightKg, 'kg')}
</span>
</div>
</section>
<section className="px-1 pt-4">
<h3 className="mb-3 text-[12px] font-bold">概念模型</h3>
<div className="flex items-center gap-2 rounded-[7px] border border-black/[0.07] bg-[#edf1f1] px-3 py-2.5">
<Box size={17} strokeWidth={1.7} className="text-[#586669]" />
<span className="text-[11px] font-semibold text-[#687174]">
概念模型服务未接入
</span>
</div>
</section>
<section className="mt-4 border-t border-black/[0.07] px-1 pt-4">
<h3 className="mb-3 text-[12px] font-bold">核算记录</h3>
<dl className="grid grid-cols-[76px_minmax(0,1fr)] gap-y-2 text-[10px]">
<dt className="text-[#747c7e]">归档图片</dt>
<dd className="text-right font-semibold">
{detail.sourceSnapshot?.storedImageCount ?? 0} 张
</dd>
<dt className="text-[#747c7e]">分析模型</dt>
<dd className="truncate text-right font-semibold" title={modelName ?? undefined}>
{modelName ?? '--'}
</dd>
<dt className="text-[#747c7e]">模型耗时</dt>
<dd className="text-right font-semibold">{formatDuration(durationMs)}</dd>
</dl>
</section>
</aside>
)
}
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 (
<div className="flex h-full min-h-0 flex-col bg-[#eef1f1] text-[#252b2d]">
<ToastContainer toasts={toasts} onClose={removeToast} position="top" />
<header className="shrink-0 border-b border-black/[0.06] bg-white px-4 py-2 lg:px-5">
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-center">
<div className="flex min-w-0 items-center gap-2.5 lg:min-w-[280px]">
<div className="flex h-8 w-8 items-center justify-center rounded-[6px] bg-[#2d3335] text-white">
<Calculator size={18} strokeWidth={1.8} />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h1 className="shrink-0 text-[16px] font-bold">BOM核算</h1>
<span
className={cn(
'inline-flex min-w-0 items-center gap-1 rounded-[4px] px-1.5 py-1 text-[10px] font-bold',
initialCapabilities.analysisConfigured
? 'bg-emerald-50 text-emerald-700'
: 'bg-amber-50 text-amber-700',
)}
>
{initialCapabilities.analysisConfigured ? (
<CheckCircle2 size={12} strokeWidth={2} />
) : (
<CircleAlert size={12} strokeWidth={2} />
)}
<span className="truncate">
{initialCapabilities.analysisConfigured
? '分析服务可用'
: '分析服务未接入'}
</span>
</span>
</div>
<p className="truncate text-[11px] text-[#747c7e]">
出厂成本预估 · 目标误差 ≤{initialCapabilities.targetErrorPercent}%
</p>
</div>
</div>
<div className="flex min-w-0 flex-1 items-start gap-2 lg:max-w-[900px]">
<Input
value={sourceUrl}
onChange={(event) => setSourceUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void handleCreateCase()
}}
disabled={creating}
placeholder="粘贴竞品商品链接"
leftIcon={<Link2 size={16} strokeWidth={1.7} />}
containerClassName="min-w-0 flex-1 !space-y-0"
className="!min-h-[38px] !rounded-[7px] !bg-[#f6f7f7] !py-2"
aria-label="竞品商品链接"
/>
<Button
size="small"
onClick={() => void handleCreateCase()}
disabled={
!initialCapabilities.analysisConfigured ||
!sourceUrl.trim() ||
creating
}
className="h-[38px] shrink-0 rounded-[7px] px-4"
>
<PackageSearch size={16} strokeWidth={1.8} />
{creating ? '创建任务' : '开始核算'}
</Button>
</div>
</div>
</header>
<main
className={cn(
'grid auto-rows-max min-h-0 flex-1 overflow-y-auto xl:auto-rows-auto xl:overflow-hidden',
workbenchGridClassName,
)}
>
{hasCases ? (
<aside className="min-h-0 min-w-0 border-b border-black/[0.07] bg-[#f6f7f7] p-2 xl:overflow-y-auto xl:border-b-0 xl:border-r xl:p-2.5">
<aside className="min-h-0 min-w-0 border-b border-black/[0.07] bg-[#f6f7f7] p-2.5 xl:overflow-y-auto xl:border-b-0 xl:border-r xl:p-3">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[6px] bg-[#2d3335] text-white">
<Calculator size={17} strokeWidth={1.8} />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<h1 className="shrink-0 text-[15px] font-bold">BOM核算</h1>
<span
className={cn(
'inline-flex min-w-0 items-center gap-1 rounded-[4px] px-1.5 py-0.5 text-[9px] font-bold',
initialCapabilities.analysisConfigured
? 'bg-emerald-50 text-emerald-700'
: 'bg-amber-50 text-amber-700',
)}
>
{initialCapabilities.analysisConfigured ? (
<CheckCircle2 size={11} strokeWidth={2} />
) : (
<CircleAlert size={11} strokeWidth={2} />
)}
<span className="truncate">
{initialCapabilities.analysisConfigured
? '服务可用'
: '分析服务未接入'}
</span>
</span>
</div>
<p className="mt-0.5 truncate text-[10px] text-[#747c7e]">
目标误差 ≤{initialCapabilities.targetErrorPercent}%
</p>
</div>
</div>
<section className="mt-3 border-b border-black/[0.07] pb-3 xl:mt-4 xl:pb-4">
<label
htmlFor="bom-source-url"
className="sr-only mb-2 text-[11px] font-bold xl:not-sr-only xl:block"
>
新建核算
</label>
<div className="flex items-start gap-2 xl:block">
<Input
id="bom-source-url"
value={sourceUrl}
onChange={(event) => setSourceUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void handleCreateCase()
}}
disabled={creating}
placeholder="粘贴竞品商品链接"
leftIcon={<Link2 size={14} strokeWidth={1.7} />}
containerClassName="min-w-0 flex-1 !space-y-0"
className="!min-h-[38px] !rounded-[6px] !bg-white !py-2 text-[11px]"
aria-label="竞品商品链接"
/>
<Button
size="small"
onClick={() => void handleCreateCase()}
disabled={
!initialCapabilities.analysisConfigured ||
!sourceUrl.trim() ||
creating
}
className="h-[38px] w-[104px] shrink-0 rounded-[6px] px-2 text-[10px] xl:mt-2 xl:w-full xl:px-3 xl:text-[11px]"
>
<PackageSearch size={15} strokeWidth={1.8} />
{creating ? '创建中' : '开始核算'}
</Button>
</div>
</section>
{selectedCase ? (
<section className="border-b border-black/[0.07] py-3 xl:py-4">
<div className="flex items-center justify-between gap-2">
<h2 className="truncate text-[11px] font-bold">当前案例</h2>
<span
className={cn(
'shrink-0 text-[9px] font-bold',
selectedCase.status === 'FAILED'
? 'text-[#9b4c48]'
: 'text-[#526a65]',
)}
>
{STATUS_LABELS[selectedCase.status]}
</span>
</div>
<p className="mt-1 truncate text-[10px] leading-4 text-[#697275] xl:line-clamp-2 xl:whitespace-normal">
{selectedCase.title}
</p>
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-[10px]">
<div>
<dt className="text-[#858d8f]">SKU</dt>
<dd className="mt-0.5 font-bold">{selectedCase.skus.length}</dd>
</div>
<div>
<dt className="text-[#858d8f]">归档图片</dt>
<dd className="mt-0.5 font-bold">
{selectedCase.sourceSnapshot?.storedImageCount ?? 0}
</dd>
</div>
<div className="hidden xl:block">
<dt className="text-[#858d8f]">资料来源</dt>
<dd className="mt-0.5 truncate font-bold">
{selectedCase.sourceHost}
</dd>
</div>
<div className="hidden xl:block">
<dt className="text-[#858d8f]">复用资料</dt>
<dd className="mt-0.5 font-bold">
{selectedCase.sourceCacheHit ? '是' : '否'}
</dd>
</div>
</dl>
</section>
) : null}
{hasCases ? (
<section className="pt-3 xl:pt-4">
<div className="mb-2 flex items-center justify-between px-1">
<span className="text-[12px] font-bold">历史记录</span>
<span className="text-[11px] font-semibold text-[#767e80]">
@ -1139,7 +1309,7 @@ export default function BomCostingWorkbenchClient({
: `${visibleCases.length}/${cases.length}`}
</span>
</div>
<div className="mb-2 flex gap-1.5">
<div className="mb-2 hidden gap-1.5 xl:flex">
<Input
value={historyQuery}
onChange={(event) => setHistoryQuery(event.target.value)}
@ -1244,8 +1414,9 @@ export default function BomCostingWorkbenchClient({
</div>
) : null}
</div>
</aside>
) : null}
</section>
) : null}
</aside>
<section className="min-w-0 bg-white xl:min-h-0 xl:overflow-hidden">
{selectedCase && selectedCase.status !== 'READY' ? (
@ -1297,8 +1468,6 @@ export default function BomCostingWorkbenchClient({
{selectedCase && selectedCase.status !== 'READY' ? (
<ProcessInspector detail={selectedCase} now={now} />
) : selectedSku ? (
<CostInspector detail={selectedCase!} selectedSku={selectedSku} />
) : null}
</main>
</div>