feat(bom-costing): 重构竞品核算结果工作台
This commit is contained in:
parent
60f252571a
commit
0c9c2fccc8
@ -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}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user