refactor(bom-costing): 收紧空态工作台布局

This commit is contained in:
WuYongJie 2026-07-27 10:22:59 +08:00
parent 026fab35c9
commit e909cbdc94
2 changed files with 183 additions and 137 deletions

View File

@ -6,7 +6,7 @@ jest.mock('@/components/platform/AssetWorkbenchEmptyBackdrop', () => ({
}))
describe('BomCostingWorkbenchClient', () => {
it('生产服务未接入时保留完整工作台,但明确禁用分析按钮', () => {
it('生产服务未接入且没有案例时只展示紧凑入口', () => {
render(
<BomCostingWorkbenchClient
initialCapabilities={{
@ -25,7 +25,11 @@ describe('BomCostingWorkbenchClient', () => {
expect(screen.getByText('分析服务未接入')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /开始核算/ })).toBeDisabled()
expect(screen.getByText(/目标误差 ≤10%/)).toBeInTheDocument()
expect(screen.getByText('概念模型服务未接入')).toBeInTheDocument()
expect(screen.getByText('竞品分析服务尚未接入')).toBeInTheDocument()
expect(screen.queryByText('核算记录')).not.toBeInTheDocument()
expect(screen.queryByText('成本汇总')).not.toBeInTheDocument()
expect(screen.queryByText('概念模型服务未接入')).not.toBeInTheDocument()
expect(screen.queryByTestId('empty-backdrop')).not.toBeInTheDocument()
})
it('开发 fixture 结果支持 SKU 切换并联动 BOM 与成本', async () => {
@ -196,6 +200,8 @@ describe('BomCostingWorkbenchClient', () => {
)
expect(await screen.findByText('桌面板')).toBeInTheDocument()
expect(screen.getByText('成本汇总')).toBeInTheDocument()
expect(screen.getByText('概念模型服务未接入')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '白色 120cm' }))
expect(screen.getByText('白色桌面板')).toBeInTheDocument()
expect(screen.getAllByText('¥92.00').length).toBeGreaterThan(0)

View File

@ -7,7 +7,6 @@ import {
CheckCircle2,
ChevronRight,
CircleAlert,
Clock3,
ExternalLink,
Link2,
PackageSearch,
@ -173,12 +172,16 @@ export default function BomCostingWorkbenchClient({
}
}
const modelStatus = initialCapabilities.modelingConfigured
? '概念模型服务可用'
: '概念模型服务未接入'
const hasCases = cases.length > 0
const hasSelectedSku = selectedSku !== null
const workbenchGridClassName = hasSelectedSku
? 'lg:grid-cols-[248px_minmax(0,1fr)_310px]'
: hasCases
? 'lg:grid-cols-[248px_minmax(0,1fr)]'
: 'grid-cols-1'
return (
<div className="flex h-full min-h-0 flex-col bg-[#f3f5f5] text-[#2d3335]">
<div className="flex h-full min-h-0 flex-col bg-[#eef1f1] text-[#252b2d]">
<ToastContainer
toasts={toasts}
onClose={removeToast}
@ -186,20 +189,41 @@ export default function BomCostingWorkbenchClient({
/>
<header className="shrink-0 border-b border-black/[0.06] bg-white px-4 py-3 lg:px-6">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center">
<div className="flex min-w-[190px] items-center gap-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex min-w-0 items-center gap-3 lg:min-w-[300px]">
<div className="flex h-9 w-9 items-center justify-center rounded-[8px] bg-[#2d3335] text-white">
<Calculator size={18} strokeWidth={1.8} />
</div>
<div>
<h1 className="text-[16px] font-bold">BOM核算</h1>
<p className="text-[11px] text-[#8a9092]">
预计出厂成本 · 目标误差 ≤{initialCapabilities.targetErrorPercent}%
<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-[5px] 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">
<div className="flex min-w-0 flex-1 items-start gap-2 lg:max-w-[860px]">
<Input
value={sourceUrl}
onChange={(event) => setSourceUrl(event.target.value)}
@ -223,35 +247,24 @@ export default function BomCostingWorkbenchClient({
开始核算
</Button>
</div>
<div
className={cn(
'flex shrink-0 items-center gap-2 text-[12px] font-semibold',
initialCapabilities.analysisConfigured
? 'text-emerald-700'
: 'text-amber-700',
)}
>
{initialCapabilities.analysisConfigured ? (
<CheckCircle2 size={16} strokeWidth={1.8} />
) : (
<CircleAlert size={16} strokeWidth={1.8} />
)}
{initialCapabilities.analysisConfigured
? '分析服务已接入'
: '分析服务未接入'}
</div>
</div>
</header>
<main className="grid min-h-0 flex-1 grid-cols-1 overflow-y-auto lg:grid-cols-[248px_minmax(0,1fr)_310px] lg:overflow-hidden">
<aside className="min-h-[180px] border-b border-black/[0.06] bg-[#f8f9f9] p-3 lg:min-h-0 lg:overflow-y-auto lg:border-b-0 lg:border-r">
<div className="mb-3 flex items-center justify-between px-1">
<span className="text-[12px] font-bold">核算记录</span>
<span className="text-[11px] text-[#929799]">{cases.length}</span>
</div>
{cases.length ? (
<div className="space-y-1.5">
<main
className={cn(
'grid min-h-0 flex-1 overflow-y-auto lg:overflow-hidden',
workbenchGridClassName,
)}
>
{hasCases ? (
<aside className="min-h-0 border-b border-black/[0.07] bg-[#f6f7f7] p-2 lg:overflow-y-auto lg:border-b-0 lg:border-r lg:p-3">
<div className="mb-2 flex items-center justify-between px-1 lg:mb-3">
<span className="text-[12px] font-bold">核算记录</span>
<span className="text-[11px] font-semibold text-[#767e80]">
{cases.length}
</span>
</div>
<div className="flex gap-2 overflow-x-auto pb-1 lg:block lg:space-y-1.5 lg:overflow-x-visible lg:pb-0">
{cases.map((item) => {
const active = item.id === selectedCaseId
return (
@ -263,10 +276,10 @@ export default function BomCostingWorkbenchClient({
setSelectedSkuId(null)
}}
className={cn(
'w-full rounded-[8px] border px-3 py-3 text-left transition',
'min-w-[220px] rounded-[8px] border px-3 py-2.5 text-left transition lg:w-full lg:min-w-0 lg:py-3',
active
? 'border-[#47646b]/25 bg-white shadow-sm'
: 'border-transparent bg-transparent hover:bg-white/80',
? 'border-[#47646b]/30 bg-white shadow-sm'
: 'border-transparent bg-[#eef1f1] hover:bg-white',
)}
>
<div className="flex items-start gap-2">
@ -299,17 +312,12 @@ export default function BomCostingWorkbenchClient({
)
})}
</div>
) : (
<div className="flex min-h-[130px] flex-col items-center justify-center text-center">
<Clock3 size={22} className="mb-2 text-[#9aa0a2]" strokeWidth={1.5} />
<span className="text-[12px] font-semibold text-[#747a7c]">暂无核算记录</span>
</div>
)}
</aside>
</aside>
) : null}
<section className="min-w-0 bg-white lg:min-h-0 lg:overflow-hidden">
{selectedSku ? (
<div className="flex h-full min-h-[620px] flex-col lg:min-h-0">
<div className="flex min-h-[560px] flex-col lg:h-full lg: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="min-w-0">
@ -428,101 +436,133 @@ export default function BomCostingWorkbenchClient({
</table>
</div>
</div>
) : hasCases ? (
<div className="flex min-h-[320px] items-center justify-center px-6 py-12 lg:h-full lg:min-h-0">
<div className="flex max-w-[320px] flex-col items-center text-center">
{loadingCaseId ? (
<RefreshCw
size={24}
strokeWidth={1.7}
className="animate-spin text-[#536367]"
/>
) : (
<PackageSearch size={24} strokeWidth={1.7} className="text-[#536367]" />
)}
<h2 className="mt-3 text-[14px] font-bold">
{loadingCaseId ? '正在加载核算结果' : '选择核算记录'}
</h2>
<p className="mt-1 text-[11px] leading-5 text-[#747c7e]">
{loadingCaseId
? '正在读取 SKU、BOM 与成本数据'
: '从记录中选择一项,查看对应 SKU 的 BOM 与成本'}
</p>
</div>
</div>
) : (
<div className="relative flex h-full min-h-[480px] items-center justify-center overflow-hidden">
<AssetWorkbenchEmptyBackdrop className="opacity-70" />
<div className="relative z-10 flex max-w-[360px] flex-col items-center px-6 text-center">
<PackageSearch size={30} strokeWidth={1.5} className="text-[#647174]" />
<h2 className="mt-3 text-[15px] font-bold">选择核算记录</h2>
<p className="mt-1 text-[12px] leading-5 text-[#7f8587]">
分析完成后,这里按 SKU 展开部件、用量与资料库价格匹配结果。
<div className="flex h-full min-h-[320px] items-center justify-center px-6 py-12">
<div className="flex max-w-[420px] flex-col items-center text-center">
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-[#e8eded] text-[#46575a]">
<PackageSearch size={22} strokeWidth={1.7} />
</div>
<h2 className="mt-4 text-[15px] font-bold">
{initialCapabilities.analysisConfigured
? '粘贴竞品链接开始核算'
: '竞品分析服务尚未接入'}
</h2>
<p className="mt-1.5 text-[12px] leading-5 text-[#6f7779]">
{initialCapabilities.analysisConfigured
? '完成后将按 SKU 展开部件用量、资料库价格匹配与预计出厂成本'
: '服务接入后,可按 SKU 核算材料、加工、人工与包材成本'}
</p>
</div>
</div>
)}
</section>
<aside className="border-t border-black/[0.06] bg-[#f8f9f9] p-3 lg:min-h-0 lg:overflow-y-auto lg:border-l lg:border-t-0">
<section className="border-b border-black/[0.06] 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-[#657174]" strokeWidth={1.7} />
</div>
<div className="space-y-2 text-[11px]">
{[
['材料', selectedSku?.costSummary.material ?? null],
['加工', selectedSku?.costSummary.processing ?? null],
['人工', selectedSku?.costSummary.labor ?? null],
['包材', selectedSku?.costSummary.packaging ?? null],
].map(([label, value]) => (
<div key={String(label)} className="flex items-center justify-between">
<span className="text-[#747b7d]">{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.06] 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-[#909597]">
含材料、加工、人工、包材和损耗,不含税费、物流、平台佣金及营销费用。
</p>
</section>
<section className="border-b border-black/[0.06] 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-[#8b9193]">成品尺寸</span>
<span className="text-right font-semibold">
{selectedSku
? [selectedSku.lengthMm, selectedSku.widthMm, selectedSku.heightMm]
.map((value) => value ?? '--')
.join(' × ')
: '--'}
</span>
<span className="text-[#8b9193]">产品重量</span>
<span className="text-right font-semibold">
{formatMetric(selectedSku?.weightKg ?? null, 'kg')}
</span>
<span className="text-[#8b9193]">包装尺寸</span>
<span className="text-right font-semibold">
{selectedSku
? [
selectedSku.packageLengthMm,
selectedSku.packageWidthMm,
selectedSku.packageHeightMm,
]
.map((value) => value ?? '--')
.join(' × ')
: '--'}
</span>
<span className="text-[#8b9193]">包装重量</span>
<span className="text-right font-semibold">
{formatMetric(selectedSku?.packageWeightKg ?? null, 'kg')}
</span>
</div>
</section>
<section className="px-1 pt-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[12px] font-bold">概念模型</h3>
<span className="text-[10px] text-[#8b9193]">{modelStatus}</span>
</div>
<div className="relative h-[210px] overflow-hidden rounded-[8px] border border-black/[0.06] bg-[#f1f4f4]">
<AssetWorkbenchEmptyBackdrop className="opacity-55" />
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
<Box size={28} strokeWidth={1.4} className="text-[#647174]" />
<span className="mt-2 text-[11px] font-bold">概念模型待接入</span>
<span className="mt-1 max-w-[190px] text-[10px] leading-4 text-[#8b9193]">
仅用于运营决策预览,不作为工程尺寸或生产依据
{selectedSku ? (
<aside className="border-t border-black/[0.07] bg-[#f6f7f7] p-3 lg:min-h-0 lg:overflow-y-auto lg:border-l lg: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>
</div>
</section>
</aside>
<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>
{initialCapabilities.modelingConfigured ? (
<div className="relative h-[170px] overflow-hidden rounded-[8px] border border-black/[0.07] bg-[#edf1f1]">
<AssetWorkbenchEmptyBackdrop className="opacity-45" />
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
<Box size={26} strokeWidth={1.5} className="text-[#586669]" />
<span className="mt-2 text-[11px] font-bold">概念模型待生成</span>
</div>
</div>
) : (
<div className="flex items-center gap-2 rounded-[8px] 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>
</aside>
) : null}
</main>
</div>
)