feat: restore material library and assembly workflows

This commit is contained in:
WuYongJie 2026-04-16 23:13:16 +08:00
parent 602e2fd33f
commit c8d3d3d9e7
18 changed files with 1637 additions and 170 deletions

View File

@ -57,7 +57,7 @@ jest.mock('../assisted-assembly/AssistedAssemblyClient', () => ({
}))
describe('AssistedAssemblyPage legacy feedback', () => {
it('会给旧产品装配入口的跳转提供最小必要反馈', async () => {
it('会给旧产品装配入口的多 source-sku 冲突提供更明确反馈', async () => {
render(
await AssistedAssemblyPage({
searchParams: Promise.resolve({
@ -65,13 +65,34 @@ describe('AssistedAssemblyPage legacy feedback', () => {
source: 'assisted-assembly',
sourceProductId: 'product-1',
redirectedFrom: 'product-assembly',
productRecovery: 'ambiguous-source-sku',
recoverableSourceSkuCount: '2',
}),
}),
)
expect(screen.getByTestId('redirected-from')).toHaveTextContent('product-assembly')
expect(screen.getByTestId('feedback-text')).toHaveTextContent(
'旧产品装配入口已下线,请改用辅助装配继续处理。',
'当前产品下找到 2 个可恢复 SKU,暂时无法自动判断该恢复哪一个,已先按产品级可推导资产打开辅助装配。建议从具体 SKU 入口进入。',
)
})
it('会给旧产品装配入口缺失恢复上下文的情况提供更明确反馈', async () => {
render(
await AssistedAssemblyPage({
searchParams: Promise.resolve({
entryMode: 'direct',
source: 'assisted-assembly',
sourceProductId: 'product-1',
redirectedFrom: 'product-assembly',
productRecovery: 'missing-source-context',
}),
}),
)
expect(screen.getByTestId('redirected-from')).toHaveTextContent('product-assembly')
expect(screen.getByTestId('feedback-text')).toHaveTextContent(
'当前产品还没有可恢复的源 SKU 或装配快照,已打开普通辅助装配入口。',
)
})
})

View File

@ -69,6 +69,30 @@ describe('legacy assembly entrypoints', () => {
{
id: 'sku-source-1',
productId: 'product-1',
isActive: true,
sourcePreSku: {
previewNodes: [],
variantSelection: {
slots: [
{
slotKey: 'desktop-1',
role: 'desktop',
assetFamilyId: 'family-board',
assetType: 'BOARD',
variantId: 'variant-board',
},
],
hardwareSelections: [
{
selectionKey: 'connector-1',
assetFamilyId: 'family-hardware',
assetType: 'HARDWARE',
variantId: 'variant-hardware',
},
],
},
displaySummary: null,
},
},
],
})
@ -78,11 +102,139 @@ describe('legacy assembly entrypoints', () => {
params: Promise.resolve({ id: 'product-1' }),
}),
).rejects.toThrow(
'NEXT_REDIRECT:/assisted-assembly?entryMode=source-sku&source=assisted-assembly&sourceSkuId=sku-source-1&sourceProductId=product-1&redirectedFrom=product-assembly',
'NEXT_REDIRECT:/assisted-assembly?entryMode=source-sku&source=assisted-assembly&sourceSkuId=sku-source-1&sourceProductId=product-1&redirectedFrom=product-assembly&desktopFamilyIds=family-board&connectorFamilyIds=family-hardware',
)
})
it('产品级旧装配页在没有可恢复 source-sku 时,会退回普通辅助装配入口', async () => {
it('产品级旧装配页在有多个可恢复 source-sku 时,不会盲选,而是带着产品级上下文退回辅助装配', async () => {
mockProductFindUnique.mockResolvedValue({
id: 'product-1',
createdBy: 'user-1',
departmentId: null,
visibility: 'TEAM',
deletedAt: null,
collaboratorUsers: [],
creator: null,
skus: [
{
id: 'sku-source-1',
productId: 'product-1',
isActive: false,
sourcePreSku: {
previewNodes: [],
variantSelection: {
slots: [
{
slotKey: 'desktop-1',
role: 'desktop',
assetFamilyId: 'family-board',
assetType: 'BOARD',
variantId: 'variant-board',
},
],
hardwareSelections: [],
},
displaySummary: null,
},
},
{
id: 'sku-source-2',
productId: 'product-1',
isActive: false,
sourcePreSku: {
previewNodes: [],
variantSelection: {
slots: [
{
slotKey: 'leg-1',
role: 'leg',
assetFamilyId: 'family-leg',
assetType: 'COMPONENT',
variantId: 'variant-leg',
},
],
hardwareSelections: [],
},
displaySummary: null,
},
},
],
})
await expect(
ProductAssemblyPage({
params: Promise.resolve({ id: 'product-1' }),
}),
).rejects.toThrow(
'NEXT_REDIRECT:/assisted-assembly?entryMode=direct&source=assisted-assembly&sourceProductId=product-1&redirectedFrom=product-assembly&productRecovery=ambiguous-source-sku&recoverableSourceSkuCount=2&desktopFamilyIds=family-board&legFamilyIds=family-leg',
)
})
it('产品级旧装配页在多个候选里只有一个活跃 source-sku 时,会优先恢复这个活跃候选', async () => {
mockProductFindUnique.mockResolvedValue({
id: 'product-1',
createdBy: 'user-1',
departmentId: null,
visibility: 'TEAM',
deletedAt: null,
collaboratorUsers: [],
creator: null,
skus: [
{
id: 'sku-source-1',
productId: 'product-1',
isActive: true,
sourcePreSku: {
previewNodes: [],
variantSelection: {
slots: [
{
slotKey: 'desktop-1',
role: 'desktop',
assetFamilyId: 'family-board',
assetType: 'BOARD',
variantId: 'variant-board',
},
],
hardwareSelections: [],
},
displaySummary: null,
},
},
{
id: 'sku-source-2',
productId: 'product-1',
isActive: false,
sourcePreSku: {
previewNodes: [],
variantSelection: {
slots: [
{
slotKey: 'leg-1',
role: 'leg',
assetFamilyId: 'family-leg',
assetType: 'COMPONENT',
variantId: 'variant-leg',
},
],
hardwareSelections: [],
},
displaySummary: null,
},
},
],
})
await expect(
ProductAssemblyPage({
params: Promise.resolve({ id: 'product-1' }),
}),
).rejects.toThrow(
'NEXT_REDIRECT:/assisted-assembly?entryMode=source-sku&source=assisted-assembly&sourceSkuId=sku-source-1&sourceProductId=product-1&redirectedFrom=product-assembly&desktopFamilyIds=family-board',
)
})
it('产品级旧装配页在没有可恢复 source-sku 时,会给辅助装配更明确的产品级恢复反馈', async () => {
mockProductFindUnique.mockResolvedValue({
id: 'product-1',
createdBy: 'user-1',
@ -99,7 +251,7 @@ describe('legacy assembly entrypoints', () => {
params: Promise.resolve({ id: 'product-1' }),
}),
).rejects.toThrow(
'NEXT_REDIRECT:/assisted-assembly?entryMode=direct&source=assisted-assembly&sourceProductId=product-1&redirectedFrom=product-assembly',
'NEXT_REDIRECT:/assisted-assembly?entryMode=direct&source=assisted-assembly&sourceProductId=product-1&redirectedFrom=product-assembly&productRecovery=missing-source-context',
)
})

View File

@ -1,6 +1,6 @@
'use client'
import { startTransition, useMemo, useRef, useState, type ReactNode } from 'react'
import { startTransition, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
import {
ArrowLeft,
Check,
@ -269,6 +269,106 @@ function materialPreviewUrl(material: MaterialDTO): string | null {
return material.thumbnailUrl ?? material.textureUrl ?? material.previewUrl ?? null
}
function FinishPreviewHeader({
form,
categoryName,
isEdit = false,
}: {
form: FinishFormState
categoryName: string
isEdit?: boolean
}) {
const displayColor = form.color || '#cccccc'
const sphereSurfaceStyle = useMemo<CSSProperties>(() => ({
backgroundColor: displayColor,
backgroundImage: form.texturePreview
? `url(${form.texturePreview})`
: form.textureUrl
? `url(${form.textureUrl})`
: form.thumbnailPreview
? `url(${form.thumbnailPreview})`
: 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
}), [displayColor, form.texturePreview, form.textureUrl, form.thumbnailPreview])
const sphereHighlightStyle = useMemo<CSSProperties>(() => ({
background:
'radial-gradient(circle at 30% 30%, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 25%, transparent 60%)',
}), [])
return (
<div className="shrink-0 border-b border-apple-separator/10 bg-white/55 p-6 backdrop-blur-xl">
<div className="flex items-center gap-6">
<div className="group relative h-24 w-24 shrink-0 overflow-hidden rounded-full border border-white/60 bg-white shadow-apple-lg">
<div className="pointer-events-none absolute inset-0 z-10 rounded-full shadow-[inset_0_4px_12px_rgba(0,0,0,0.1)]" />
<div
className="absolute inset-[-10%] transition-transform duration-700 group-hover:scale-110 group-hover:rotate-6"
style={sphereSurfaceStyle}
/>
<div className="absolute inset-0 z-20" style={sphereHighlightStyle} />
</div>
<div className="min-w-0 flex-1">
<div className="mb-1.5 flex items-center gap-2">
<span className="rounded bg-apple-blue/10 px-2 py-0.5 text-[9px] font-apple-bold uppercase tracking-widest text-apple-blue">
{isEdit ? '材质编辑中' : '花色详细规格'}
</span>
<span className="text-[10px] font-apple-bold uppercase tracking-tight text-apple-label-tertiary">
{categoryName}
</span>
</div>
<h3 className="truncate text-apple-xl font-apple-bold leading-tight tracking-tighter text-apple-label-primary">
{form.name.trim() || '未命名材质'}
</h3>
<div className="mt-2 flex items-center gap-3">
<div className="flex items-center gap-1.5">
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: displayColor }} />
<code className="text-[10px] font-apple-bold text-apple-label-secondary">{displayColor}</code>
</div>
<div className="h-3 w-px bg-apple-separator/20" />
<span className="text-[10px] font-apple-bold uppercase tracking-tight text-apple-label-tertiary">
STUDIO VERIFIED
</span>
</div>
</div>
</div>
</div>
)
}
function SubstratePreviewHeader({
title,
badge,
subtitle,
}: {
title: string
badge: string
subtitle: string
}) {
return (
<div className="shrink-0 border-b border-apple-separator/10 bg-white/55 p-6 backdrop-blur-xl">
<div className="flex items-center gap-6">
<div className="flex h-24 w-24 shrink-0 items-center justify-center rounded-[28px] border border-white/60 bg-[linear-gradient(180deg,#f8fafc_0%,#e7edf5_100%)] shadow-apple-lg">
<Package2 className="h-10 w-10 text-apple-label-secondary" />
</div>
<div className="min-w-0 flex-1">
<div className="mb-1.5 flex items-center gap-2">
<span className="rounded bg-amber-100 px-2 py-0.5 text-[9px] font-apple-bold uppercase tracking-widest text-amber-700">
{badge}
</span>
</div>
<h3 className="truncate text-apple-xl font-apple-bold leading-tight tracking-tighter text-apple-label-primary">
{title}
</h3>
<p className="mt-2 text-sm text-apple-label-secondary">{subtitle}</p>
</div>
</div>
</div>
)
}
function buildLiveMetrics(
metrics: PlatformMetricItem[],
counts: { substrates: number; finishes: number; categories: number },
@ -1076,18 +1176,49 @@ export default function MaterialLibraryClient({
}
let rightContent: ReactNode
let inspectorHeader: ReactNode = null
if (panel.mode === 'empty') {
rightContent = (
<div className="flex h-full items-center justify-center rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/40 p-8">
<div className="text-center">
<p className="text-sm text-apple-label-tertiary">从左侧选择一项查看详情</p>
<p className="mt-1 text-xs text-apple-label-tertiary">或点击「新建」开始维护</p>
<div className="flex h-full items-center justify-center p-12">
<div className="animate-in zoom-in-95 text-center duration-500">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full border border-apple-separator/10 bg-apple-bg-secondary">
<SwatchBook className="h-8 w-8 text-apple-label-tertiary opacity-50" />
</div>
<p className="text-apple-sm font-apple-bold uppercase tracking-widest text-apple-label-secondary">
选择资料
</p>
<p className="mt-2 max-w-[240px] text-apple-xs leading-relaxed text-apple-label-tertiary">
从左侧选择一个基材或饰面查看详情,也可以直接点击「新建」开始维护。
</p>
</div>
</div>
)
} else if (panel.mode === 'view-finish' && selectedFinish) {
const finishPreview = materialPreviewUrl(selectedFinish)
inspectorHeader = (
<FinishPreviewHeader
form={{
...finishForm,
name: selectedFinish.name,
categoryId: selectedFinish.categoryId ?? '',
thumbnailUrl: selectedFinish.thumbnailUrl ?? '',
thumbnailPreview: selectedFinish.thumbnailUrl ?? '',
textureUrl: selectedFinish.textureUrl ?? '',
texturePreview: selectedFinish.textureUrl ?? selectedFinish.previewUrl ?? '',
normalMapUrl: selectedFinish.normalMapUrl ?? '',
normalMapPreview: selectedFinish.normalMapUrl ?? '',
color: selectedFinish.color || '#cccccc',
metalness: String(selectedFinish.metalness),
roughness: String(selectedFinish.roughness),
opacity: String(selectedFinish.opacity),
textureRepeat: String(selectedFinish.textureRepeat ?? 1),
textureRotation: String(selectedFinish.textureRotation ?? 0),
textureSizeMm: String(selectedFinish.textureSizeMm ?? 600),
}}
categoryName={selectedFinish.categoryName ?? '未分类'}
/>
)
rightContent = (
<div className="flex h-full flex-col gap-5 overflow-y-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-5">
@ -1205,6 +1336,8 @@ export default function MaterialLibraryClient({
)
} else if (panel.mode === 'create-finish' || panel.mode === 'edit-finish') {
const isCreate = panel.mode === 'create-finish'
const categoryName = categories.find((item) => item.id === finishForm.categoryId)?.name ?? '未分类'
inspectorHeader = <FinishPreviewHeader form={finishForm} categoryName={categoryName} isEdit />
rightContent = (
<div className="flex h-full flex-col gap-5 overflow-y-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-5">
@ -1472,6 +1605,13 @@ export default function MaterialLibraryClient({
</div>
)
} else if (panel.mode === 'view-substrate' && selectedSubstrate) {
inspectorHeader = (
<SubstratePreviewHeader
title={selectedSubstrate.name}
badge="基材规格定义"
subtitle={`${selectedSubstrate.environmentGradeName} · ${selectedSubstrate.thicknessMm}MM · ${selectedSubstrate.systemNameOptionName ?? '未设置系统名称'}`}
/>
)
rightContent = (
<div className="flex h-full flex-col gap-5 overflow-y-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-5">
<div className="flex items-center justify-between">
@ -1567,6 +1707,13 @@ export default function MaterialLibraryClient({
)
} else if (panel.mode === 'create-substrate' || panel.mode === 'edit-substrate') {
const isCreate = panel.mode === 'create-substrate'
inspectorHeader = (
<SubstratePreviewHeader
title={previewSubstrateName || '新建基材定义'}
badge={isCreate ? '基材新建中' : '基材编辑中'}
subtitle={previewSystemName || '系统名称会在这里实时预览'}
/>
)
rightContent = (
<div className="flex h-full flex-col gap-5 overflow-y-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-5">
@ -1872,7 +2019,7 @@ export default function MaterialLibraryClient({
)
} else {
rightContent = (
<div className="flex h-full items-center justify-center rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/40 p-8">
<div className="flex h-full items-center justify-center p-8">
<p className="text-sm text-apple-label-tertiary">当前项目不存在或已被删除</p>
</div>
)
@ -1884,15 +2031,18 @@ export default function MaterialLibraryClient({
title="基础资料库"
description="饰面花色、花色分类、板材基材和基材字典的统一维护工作台。"
headerActions={
<Button variant="plain" size="small" onClick={() => window.history.back()}>
<ArrowLeft className="h-3.5 w-3.5" /> 返回
<Button variant="plain" size="small" onClick={() => window.history.back()} className="rounded-apple-lg border border-apple-separator/20">
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" /> 返回中心
</Button>
}
metrics={liveMetrics}
rightPanelClassName="w-[720px] max-w-[60vw]"
rightPanelClassName="w-[760px] max-w-[65vw]"
leftContent={
<div className="flex h-full flex-col gap-3">
<div className="flex items-center gap-1 rounded-apple-lg bg-apple-bg-tertiary p-1">
<div className="flex h-full flex-col gap-6">
<div className="space-y-1.5">
<p className="mb-3 px-3 text-[10px] font-apple-black uppercase tracking-[0.2em] text-apple-label-tertiary">
资料分区 Library Sections
</p>
<button
type="button"
onClick={() => {
@ -1900,13 +2050,22 @@ export default function MaterialLibraryClient({
setPanel({ mode: 'empty' })
}}
className={cn(
'flex flex-1 items-center justify-center gap-1.5 rounded-apple-md px-3 py-2 text-xs font-apple-medium transition',
'group flex w-full items-center justify-between rounded-apple-lg px-4 py-3.5 transition-all duration-300',
activeTab === 'substrate'
? 'bg-apple-bg-primary text-apple-label-primary shadow-apple-xs'
: 'text-apple-label-secondary',
? 'bg-white text-apple-blue shadow-apple-sm'
: 'text-apple-label-secondary hover:bg-white/50',
)}
>
<Package2 className="h-3.5 w-3.5" /> 基材录入
<div className="flex items-center gap-3">
<Package2 className={cn('h-4 w-4', activeTab === 'substrate' ? 'text-apple-blue' : 'text-apple-label-tertiary')} />
<span className="text-apple-sm font-apple-bold tracking-tight">基材录入</span>
</div>
<ChevronDown
className={cn(
'h-3 w-3 rotate-[-90deg] transition-transform',
activeTab === 'substrate' ? 'translate-x-0 opacity-100' : '-translate-x-2 opacity-0 group-hover:translate-x-0 group-hover:opacity-40',
)}
/>
</button>
<button
type="button"
@ -1915,56 +2074,41 @@ export default function MaterialLibraryClient({
setPanel({ mode: 'empty' })
}}
className={cn(
'flex flex-1 items-center justify-center gap-1.5 rounded-apple-md px-3 py-2 text-xs font-apple-medium transition',
'group flex w-full items-center justify-between rounded-apple-lg px-4 py-3.5 transition-all duration-300',
activeTab === 'finish'
? 'bg-apple-bg-primary text-apple-label-primary shadow-apple-xs'
: 'text-apple-label-secondary',
? 'bg-white text-apple-blue shadow-apple-sm'
: 'text-apple-label-secondary hover:bg-white/50',
)}
>
<SwatchBook className="h-3.5 w-3.5" /> 饰面花色
<div className="flex items-center gap-3">
<SwatchBook className={cn('h-4 w-4', activeTab === 'finish' ? 'text-apple-blue' : 'text-apple-label-tertiary')} />
<span className="text-apple-sm font-apple-bold tracking-tight">饰面花色</span>
</div>
<ChevronDown
className={cn(
'h-3 w-3 rotate-[-90deg] transition-transform',
activeTab === 'finish' ? 'translate-x-0 opacity-100' : '-translate-x-2 opacity-0 group-hover:translate-x-0 group-hover:opacity-40',
)}
/>
</button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-apple-label-tertiary" />
<input
type="text"
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
placeholder={activeTab === 'finish' ? '搜索花色 / 分类 / 编码' : '搜索基材 / 字典 / 花色'}
className="w-full rounded-apple-lg border border-apple-separator bg-apple-bg-primary py-2 pl-9 pr-8 text-sm text-apple-label-primary"
/>
{searchText && (
<button
type="button"
onClick={() => setSearchText('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-apple-label-secondary hover:text-apple-red"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<Button
variant="tinted"
size="small"
onClick={activeTab === 'finish' ? startCreateFinish : startCreateSubstrate}
>
<Plus className="h-3.5 w-3.5" /> 新建
</Button>
</div>
<div className="h-px w-full bg-apple-separator/10" />
{activeTab === 'finish' && (
<div className="space-y-2">
<div className="flex flex-wrap gap-1.5">
<div className="space-y-3 px-1">
<p className="text-[9px] font-apple-black uppercase tracking-[0.2em] text-apple-label-tertiary">
花色分类 Finish Categories
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setSelectedCategoryId('all')}
className={cn(
'rounded-full border px-2.5 py-1 text-[11px]',
'rounded-full border px-2.5 py-1.5 text-[10px] font-apple-bold transition-all',
selectedCategoryId === 'all'
? 'border-apple-blue/30 bg-apple-blue/10 text-apple-blue'
: 'border-apple-separator text-apple-label-secondary',
? 'border-apple-blue/20 bg-apple-blue/10 text-apple-blue'
: 'border-apple-separator/20 bg-white/40 text-apple-label-secondary hover:border-apple-blue/20 hover:bg-white hover:text-apple-blue',
)}
>
全部
@ -1975,10 +2119,10 @@ export default function MaterialLibraryClient({
type="button"
onClick={() => setSelectedCategoryId(category.id)}
className={cn(
'rounded-full border px-2.5 py-1 text-[11px]',
'rounded-full border px-2.5 py-1.5 text-[10px] font-apple-bold transition-all',
selectedCategoryId === category.id
? 'border-apple-blue/30 bg-apple-blue/10 text-apple-blue'
: 'border-apple-separator text-apple-label-secondary',
? 'border-apple-blue/20 bg-apple-blue/10 text-apple-blue'
: 'border-apple-separator/20 bg-white/40 text-apple-label-secondary hover:border-apple-blue/20 hover:bg-white hover:text-apple-blue',
)}
>
{category.name}
@ -1992,89 +2136,156 @@ export default function MaterialLibraryClient({
)}
{activeTab === 'substrate' && (
<div className="flex gap-2 overflow-x-auto whitespace-nowrap pb-1">
{(
['CATEGORY', 'ENVIRONMENT_GRADE', 'THICKNESS_SPEC', 'SURFACE_OPTION', 'SYSTEM_NAME_OPTION'] as SubstrateDictionaryType[]
).map((type) => (
<Button key={type} variant="plain" size="small" onClick={() => openDictionaryModal(type)}>
<Settings2 className="h-3.5 w-3.5" /> 管理{dictionaryLabel(type)}
</Button>
))}
<div className="space-y-3 px-1">
<p className="text-[9px] font-apple-black uppercase tracking-[0.2em] text-apple-label-tertiary">
标准字典 Technical Dictionaries
</p>
<div className="flex flex-wrap gap-2">
{(
['CATEGORY', 'ENVIRONMENT_GRADE', 'THICKNESS_SPEC', 'SURFACE_OPTION', 'SYSTEM_NAME_OPTION'] as SubstrateDictionaryType[]
).map((type) => (
<button
key={type}
type="button"
onClick={() => openDictionaryModal(type)}
className="flex items-center gap-1.5 rounded-apple-md border border-apple-separator/20 bg-white/40 px-2.5 py-1.5 text-[10px] font-apple-bold text-apple-label-secondary transition-all hover:border-apple-blue/20 hover:bg-white hover:text-apple-blue"
>
<Settings2 className="h-3 w-3 opacity-60" />
{dictionaryLabel(type)}
</button>
))}
</div>
</div>
)}
<div className="flex-1 space-y-2 overflow-y-auto">
<div className="space-y-4">
<div className="group relative px-1">
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-apple-label-tertiary transition-colors group-focus-within:text-apple-blue" />
<input
type="text"
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
placeholder={activeTab === 'finish' ? '快捷搜索花色名称、分类、编码...' : '快捷搜索基材名称、编码、系统名称...'}
className="w-full rounded-apple-xl border border-apple-separator/30 bg-white/60 py-2.5 pl-10 pr-10 text-sm text-apple-label-primary outline-none transition-all focus:bg-white focus:ring-2 focus:ring-apple-blue/5"
/>
{searchText && (
<button
type="button"
onClick={() => setSearchText('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-apple-label-tertiary hover:text-apple-red"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="flex items-center justify-between px-1">
<p className="text-[10px] font-apple-bold uppercase tracking-widest text-apple-label-tertiary">
{activeTab === 'finish' ? '饰面列表 Finishes' : '基材列表 Substrates'} (
{activeTab === 'finish' ? filteredFinishes.length : filteredSubstrates.length})
</p>
<Button
variant="filled"
size="small"
onClick={activeTab === 'finish' ? startCreateFinish : startCreateSubstrate}
className="h-8 rounded-apple-lg px-4 text-[10px] font-apple-black uppercase tracking-wider"
>
<Plus className="mr-1 h-3 w-3" strokeWidth={3} />
新建
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto pr-1">
{activeTab === 'finish' ? (
filteredFinishes.length === 0 ? (
<p className="py-4 text-center text-sm text-apple-label-tertiary">暂无花色</p>
<p className="py-6 text-center text-sm text-apple-label-tertiary">未找到花色</p>
) : (
filteredFinishes.map((item) => (
<button
key={item.id}
type="button"
onClick={() => selectFinish(item)}
className={cn(
'flex w-full items-center gap-2.5 rounded-apple-md border px-3 py-2.5 text-left transition',
isFinishSelected(item.id)
? 'border-apple-blue/40 bg-apple-blue/5'
: 'border-apple-separator bg-apple-bg-primary hover:bg-apple-bg-secondary',
)}
>
{materialPreviewUrl(item) ? (
<img
src={materialPreviewUrl(item)!}
alt={item.name}
className="h-10 w-10 rounded border border-apple-separator object-cover"
/>
) : (
<div
className="h-10 w-10 rounded border border-apple-separator"
style={{ backgroundColor: item.color }}
/>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-apple-medium text-apple-label-primary">{item.name}</p>
<p className="text-[11px] text-apple-label-tertiary">
{item.code} · {item.categoryName ?? '未分类'}
</p>
</div>
</button>
))
<div className="grid grid-cols-1 gap-2">
{filteredFinishes.map((item) => (
<button
key={item.id}
type="button"
onClick={() => selectFinish(item)}
className={cn(
'group flex items-center gap-4 rounded-apple-lg p-3 text-left transition-all duration-300',
isFinishSelected(item.id)
? 'translate-x-1 bg-white shadow-apple-md ring-1 ring-apple-blue/10'
: 'bg-transparent hover:bg-white/40',
)}
>
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-apple-md shadow-apple-xs transition-transform duration-300 group-hover:scale-105">
{materialPreviewUrl(item) ? (
<img src={materialPreviewUrl(item)!} alt={item.name} className="h-full w-full object-cover" />
) : (
<div className="h-full w-full" style={{ backgroundColor: item.color }} />
)}
</div>
<div className="min-w-0 flex-1">
<p className={cn('truncate text-sm font-apple-bold tracking-tight', isFinishSelected(item.id) ? 'text-apple-blue' : 'text-apple-label-primary')}>
{item.name}
</p>
<p className="text-[10px] font-apple-medium uppercase tracking-tighter text-apple-label-tertiary">
{item.code || '无编码'} · {item.categoryName ?? '未分类'}
</p>
</div>
</button>
))}
</div>
)
) : filteredSubstrates.length === 0 ? (
<p className="py-6 text-center text-sm text-apple-label-tertiary">未找到基材</p>
) : (
filteredSubstrates.length === 0 ? (
<p className="py-4 text-center text-sm text-apple-label-tertiary">暂无基材</p>
) : (
filteredSubstrates.map((item) => (
<div className="grid grid-cols-1 gap-2">
{filteredSubstrates.map((item) => (
<button
key={item.id}
type="button"
onClick={() => selectSubstrate(item)}
className={cn(
'flex w-full flex-col gap-1 rounded-apple-md border px-3 py-2.5 text-left transition',
'group flex flex-col gap-2 rounded-apple-lg p-4 text-left transition-all duration-300',
isSubstrateSelected(item.id)
? 'border-apple-blue/40 bg-apple-blue/5'
: 'border-apple-separator bg-apple-bg-primary hover:bg-apple-bg-secondary',
? 'translate-x-1 bg-white shadow-apple-md ring-1 ring-apple-blue/10'
: 'bg-transparent hover:bg-white/40',
)}
>
<p className="text-sm font-apple-medium text-apple-label-primary">{item.name}</p>
<p className="text-[11px] text-apple-label-tertiary">
{item.code}
{' · '}
{item.systemNameOptionName ?? item.categoryName}
{' · '}
{item.environmentGradeName}
{' · '}
{item.thicknessMm}MM
</p>
<div className="flex items-center justify-between">
<p className={cn('text-sm font-apple-bold tracking-tight', isSubstrateSelected(item.id) ? 'text-apple-blue' : 'text-apple-label-primary')}>
{item.name}
</p>
<span className="text-[9px] font-apple-black uppercase tracking-widest text-apple-label-tertiary opacity-60">
{item.environmentGradeName || 'STD'}
</span>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<span className="text-[10px] font-apple-black uppercase text-apple-label-tertiary opacity-40">厚度 THK</span>
<span className="text-[11px] font-apple-bold text-apple-label-secondary">{item.thicknessMm}MM</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] font-apple-black uppercase text-apple-label-tertiary opacity-40">系统 SYS</span>
<span className="max-w-[96px] truncate text-[11px] font-apple-bold text-apple-label-secondary">
{item.systemNameOptionName ?? item.categoryName}
</span>
</div>
</div>
</button>
))
)
))}
</div>
)}
</div>
</div>
}
rightContent={rightContent}
rightContent={
<div className="h-full pl-2">
<div className="flex h-full flex-col overflow-hidden rounded-[24px] border border-white/40 bg-white/75 shadow-apple-xl backdrop-blur-xl">
{inspectorHeader}
<div className="flex-1 overflow-y-auto bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.28),transparent_50%),linear-gradient(180deg,rgba(248,250,252,0.9)_0%,rgba(240,244,248,0.82)_100%)]">
{rightContent}
</div>
</div>
</div>
}
/>
<Modal

View File

@ -503,7 +503,7 @@ export default async function AssistedAssemblyPage({
let initialFeedback: {
tone: 'success' | 'error'
text: string
} | null = buildManualAssemblyLegacyFeedback(rawLaunchParams?.redirectedFrom)
} | null = buildManualAssemblyLegacyFeedback(rawLaunchParams)
if (rawLaunchParams?.entryMode === 'source-sku' && rawLaunchParams.sourceSkuId) {
const sourceSkuContext = await resolveSourceSkuContext({

View File

@ -1,13 +1,225 @@
import { notFound, redirect } from 'next/navigation'
import { buildManualAssemblyLaunchHref } from '@/lib/open-product/manual-assembly-launch'
import {
buildManualAssemblyLaunchHref,
type ManualAssemblyLaunchParams,
type ManualAssemblySelectedFamilyIdsByType,
} from '@/lib/open-product/manual-assembly-launch'
import { canViewProduct } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { requireSession } from '@/lib/server-session'
import { normalizeAssemblyWorkspace, normalizeVariantSelection } from '@/server/services/draft-group.mapper'
interface PageProps {
params: Promise<{ id: string }>
}
interface RecoverableProductSkuContext {
id: string
productId: string
isActive: boolean
selectedFamilyIdsByType: ManualAssemblySelectedFamilyIdsByType
}
function createEmptySelectedFamilyIds(): ManualAssemblySelectedFamilyIdsByType {
return {
desktopFamilyIds: [],
legFamilyIds: [],
connectorFamilyIds: [],
}
}
function pushUnique(target: string[], value: string | null | undefined) {
if (!value || target.includes(value)) {
return
}
target.push(value)
}
function mergeSelectedFamilyIds(
target: ManualAssemblySelectedFamilyIdsByType,
incoming: ManualAssemblySelectedFamilyIdsByType,
) {
incoming.desktopFamilyIds.forEach((value) => pushUnique(target.desktopFamilyIds, value))
incoming.legFamilyIds.forEach((value) => pushUnique(target.legFamilyIds, value))
incoming.connectorFamilyIds.forEach((value) => pushUnique(target.connectorFamilyIds, value))
}
function hasSelectedFamilyIds(value: ManualAssemblySelectedFamilyIdsByType) {
return (
value.desktopFamilyIds.length > 0 ||
value.legFamilyIds.length > 0 ||
value.connectorFamilyIds.length > 0
)
}
function addFamilyIdByRole(
target: ManualAssemblySelectedFamilyIdsByType,
input: {
familyId: string | null | undefined
assetType?: unknown
source?: unknown
role?: unknown
},
) {
if (!input.familyId) {
return
}
if (
input.assetType === 'BOARD' ||
input.source === 'desktop' ||
input.role === 'desktop'
) {
pushUnique(target.desktopFamilyIds, input.familyId)
return
}
if (
input.assetType === 'HARDWARE' ||
input.source === 'connector' ||
input.role === 'connector'
) {
pushUnique(target.connectorFamilyIds, input.familyId)
return
}
pushUnique(target.legFamilyIds, input.familyId)
}
function buildSelectedFamilyIdsFromSourcePreSku(sourcePreSku: {
previewNodes: unknown
variantSelection: unknown
displaySummary: unknown
} | null): ManualAssemblySelectedFamilyIdsByType {
const selectedFamilyIds = createEmptySelectedFamilyIds()
if (!sourcePreSku) {
return selectedFamilyIds
}
const normalizedVariantSelection = normalizeVariantSelection(sourcePreSku.variantSelection)
normalizedVariantSelection.slots.forEach((slot) => {
addFamilyIdByRole(selectedFamilyIds, {
familyId: slot.assetFamilyId,
assetType: slot.assetType,
role: slot.role,
})
})
normalizedVariantSelection.hardwareSelections.forEach((selection) => {
addFamilyIdByRole(selectedFamilyIds, {
familyId: selection.assetFamilyId,
assetType: selection.assetType,
role: 'connector',
})
})
const workspace = normalizeAssemblyWorkspace(
typeof sourcePreSku.displaySummary === 'object' && sourcePreSku.displaySummary !== null
? (sourcePreSku.displaySummary as Record<string, unknown>).assemblyWorkspace
: null,
)
workspace?.nodes.forEach((node) => {
addFamilyIdByRole(selectedFamilyIds, {
familyId: node.assetFamilyId,
source: node.source,
role: node.role,
})
})
if (Array.isArray(sourcePreSku.previewNodes)) {
sourcePreSku.previewNodes.forEach((node) => {
if (!node || typeof node !== 'object') {
return
}
const record = node as Record<string, unknown>
addFamilyIdByRole(selectedFamilyIds, {
familyId: typeof record.assetFamilyId === 'string' ? record.assetFamilyId : null,
assetType: record.assetType,
source: record.source,
role: record.role,
})
})
}
return selectedFamilyIds
}
function resolveProductAssemblyRedirectContext(input: {
productId: string
skus: Array<{
id: string
productId: string
isActive: boolean
sourcePreSku: {
previewNodes: unknown
variantSelection: unknown
displaySummary: unknown
} | null
}>
}): Pick<
ManualAssemblyLaunchParams,
'entryMode' | 'sourceSkuId' | 'sourceProductId' | 'selectedFamilyIdsByType'
> & {
productRecovery?: ManualAssemblyLaunchParams['productRecovery']
recoverableSourceSkuCount?: ManualAssemblyLaunchParams['recoverableSourceSkuCount']
} {
const recoverableSkuContexts: RecoverableProductSkuContext[] = input.skus
.map((sku) => ({
id: sku.id,
productId: sku.productId,
isActive: sku.isActive,
selectedFamilyIdsByType: buildSelectedFamilyIdsFromSourcePreSku(sku.sourcePreSku),
}))
.filter((sku) => hasSelectedFamilyIds(sku.selectedFamilyIdsByType))
const mergedSelectedFamilyIds = recoverableSkuContexts.reduce<ManualAssemblySelectedFamilyIdsByType>(
(accumulator, sku) => {
mergeSelectedFamilyIds(accumulator, sku.selectedFamilyIdsByType)
return accumulator
},
createEmptySelectedFamilyIds(),
)
const activeRecoverableSkus = recoverableSkuContexts.filter((sku) => sku.isActive)
const resolvedSourceSku =
recoverableSkuContexts.length === 1
? recoverableSkuContexts[0]
: activeRecoverableSkus.length === 1
? activeRecoverableSkus[0]
: null
if (resolvedSourceSku) {
return {
entryMode: 'source-sku',
sourceSkuId: resolvedSourceSku.id,
sourceProductId: resolvedSourceSku.productId,
selectedFamilyIdsByType: resolvedSourceSku.selectedFamilyIdsByType,
}
}
if (recoverableSkuContexts.length > 1) {
return {
entryMode: 'direct',
sourceSkuId: null,
sourceProductId: input.productId,
selectedFamilyIdsByType: mergedSelectedFamilyIds,
productRecovery: 'ambiguous-source-sku',
recoverableSourceSkuCount: recoverableSkuContexts.length,
}
}
return {
entryMode: 'direct',
sourceSkuId: null,
sourceProductId: input.productId,
selectedFamilyIdsByType: mergedSelectedFamilyIds,
productRecovery: 'missing-source-context',
recoverableSourceSkuCount: null,
}
}
export default async function AssemblyEditorPage({ params }: PageProps) {
const { authUser } = await requireSession('/login')
const { id } = await params
@ -39,14 +251,19 @@ export default async function AssemblyEditorPage({ params }: PageProps) {
not: null,
},
},
orderBy: {
updatedAt: 'desc',
},
orderBy: [{ isActive: 'desc' }, { updatedAt: 'desc' }],
select: {
id: true,
productId: true,
isActive: true,
sourcePreSku: {
select: {
previewNodes: true,
variantSelection: true,
displaySummary: true,
},
},
},
take: 1,
},
},
})
@ -76,20 +293,21 @@ export default async function AssemblyEditorPage({ params }: PageProps) {
redirect('/products')
}
const recoverableSourceSku = product.skus[0] ?? null
const recoveryContext = resolveProductAssemblyRedirectContext({
productId: product.id,
skus: product.skus,
})
redirect(
buildManualAssemblyLaunchHref({
entryMode: recoverableSourceSku ? 'source-sku' : 'direct',
entryMode: recoveryContext.entryMode,
source: 'assisted-assembly',
sourceSkuId: recoverableSourceSku?.id ?? null,
sourceProductId: recoverableSourceSku?.productId ?? product.id,
sourceSkuId: recoveryContext.sourceSkuId,
sourceProductId: recoveryContext.sourceProductId,
redirectedFrom: 'product-assembly',
selectedFamilyIdsByType: {
desktopFamilyIds: [],
legFamilyIds: [],
connectorFamilyIds: [],
},
selectedFamilyIdsByType: recoveryContext.selectedFamilyIdsByType,
productRecovery: recoveryContext.productRecovery,
recoverableSourceSkuCount: recoveryContext.recoverableSourceSkuCount,
}),
)
}

View File

@ -478,6 +478,7 @@ function ModelViewerComponent({
const onGizmoInteractionStartRef = useRef(onGizmoInteractionStart);
const onGizmoInteractionEndRef = useRef(onGizmoInteractionEnd);
const appliedMaterialSignatureRef = useRef<Map<string, string>>(new Map());
const lastMaterialReplaySceneVersionRef = useRef<number | null>(null);
const lastHighlightedPartIdsRef = useRef<string[]>([]);
const loadSessionRef = useRef(0);
const transformInteractionActiveRef = useRef(false);
@ -1497,6 +1498,8 @@ function ModelViewerComponent({
};
const applyAssignments = async () => {
const shouldForceReplay =
lastMaterialReplaySceneVersionRef.current !== sceneLoadVersion;
const assignmentByNode = new Map(
materialAssignments.map((assignment) => [
assignment.nodeName,
@ -1508,13 +1511,26 @@ function ModelViewerComponent({
const signature = toMaterialSignature(assignment.material);
nextSignatureMap.set(assignment.nodeName, signature);
return (
shouldForceReplay ||
appliedMaterialSignatureRef.current.get(assignment.nodeName) !==
signature
);
});
const unresolvedNodeNames = new Set<string>();
const applyMaterialToNode = (
nodeName: string,
material: THREE.Material,
): boolean => {
if (cancelled || !engineRef.current) {
return false;
}
return engineRef.current.applyMaterialToMesh(nodeName, material);
};
if (changedAssignments.length === 0) {
appliedMaterialSignatureRef.current = nextSignatureMap;
lastMaterialReplaySceneVersionRef.current = sceneLoadVersion;
return;
}
@ -1522,11 +1538,9 @@ function ModelViewerComponent({
const immediateMaterial = materialManager.createImmediateMaterial(
assignment.material,
);
if (cancelled || !engineRef.current) return;
engineRef.current.applyMaterialToMesh(
assignment.nodeName,
immediateMaterial,
);
if (!applyMaterialToNode(assignment.nodeName, immediateMaterial)) {
unresolvedNodeNames.add(assignment.nodeName);
}
});
await Promise.all(
@ -1534,17 +1548,45 @@ function ModelViewerComponent({
const material = await materialManager.createMaterial(
assignment.material,
);
if (cancelled || !engineRef.current) return;
engineRef.current.applyMaterialToMesh(assignment.nodeName, material);
if (cancelled) return;
if (applyMaterialToNode(assignment.nodeName, material)) {
unresolvedNodeNames.delete(assignment.nodeName);
return;
}
unresolvedNodeNames.add(assignment.nodeName);
}),
);
if (cancelled) return;
if (shouldForceReplay && unresolvedNodeNames.size > 0) {
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => resolve());
});
await Promise.all(
changedAssignments
.filter((assignment) => unresolvedNodeNames.has(assignment.nodeName))
.map(async (assignment) => {
const material = await materialManager.createMaterial(
assignment.material,
);
if (cancelled) return;
if (applyMaterialToNode(assignment.nodeName, material)) {
unresolvedNodeNames.delete(assignment.nodeName);
}
}),
);
}
if (cancelled) return;
assignmentByNode.forEach((_material, nodeName) => {
const signature = nextSignatureMap.get(nodeName);
if (signature) {
if (signature && !unresolvedNodeNames.has(nodeName)) {
appliedMaterialSignatureRef.current.set(nodeName, signature);
} else {
appliedMaterialSignatureRef.current.delete(nodeName);
}
});
@ -1555,6 +1597,8 @@ function ModelViewerComponent({
appliedMaterialSignatureRef.current.delete(nodeName);
}
}
lastMaterialReplaySceneVersionRef.current = sceneLoadVersion;
};
applyAssignments().catch((assignmentError) => {

View File

@ -1,4 +1,5 @@
import { act, render } from "@testing-library/react";
import * as THREE from "three";
import { ModelViewer } from "@/components/3d/ModelViewer";
const loadModelMock = jest.fn(async () => undefined);
@ -12,6 +13,31 @@ const selectPartMock = jest.fn();
const resetExplodedViewMock = jest.fn();
const setExplodedViewMock = jest.fn();
jest.mock("three/examples/jsm/controls/TransformControls.js", () => ({
TransformControls: class {
object: THREE.Object3D | null = null;
helper = new THREE.Group();
listeners = new Map<string, (event: unknown) => void>();
setSpace = jest.fn();
setSize = jest.fn();
setMode = jest.fn();
attach = jest.fn((object: THREE.Object3D) => {
this.object = object;
});
detach = jest.fn(() => {
this.object = null;
});
addEventListener = jest.fn((eventName: string, handler: (event: unknown) => void) => {
this.listeners.set(eventName, handler);
});
removeEventListener = jest.fn((eventName: string) => {
this.listeners.delete(eventName);
});
getHelper = jest.fn(() => this.helper);
dispose = jest.fn();
},
}));
jest.mock("@/lib/3d/engine", () => ({
ThreeDEngine: jest.fn().mockImplementation(() => ({
loadModel: loadModelMock,

View File

@ -5,6 +5,7 @@ import { ModelViewer } from '../ModelViewer'
const mockEngineInstances: Array<{
loadWorldNodes: jest.Mock
applyMaterialToMesh: jest.Mock
resolveObjectByNodeName: jest.Mock
}> = []
const mockTransformControlsInstances: Array<{
@ -212,4 +213,79 @@ describe('ModelViewer', () => {
expect(attachedObject?.name).toBe('node-1')
})
})
it('世界节点重载后即使材质配置没变也会重新应用材质', async () => {
const nodeMaterial = {
id: 'finish-oak',
name: '橡木',
categoryId: 'finish',
color: '#c59a6b',
metalness: 0,
roughness: 0.62,
opacity: 1,
textureUrl: '/oak.png',
}
const { rerender } = render(
<ModelViewer
worldNodes={[
{
nodeName: 'node-1',
modelUrl: '/desktop.glb',
source: 'desktop',
},
]}
materialAssignments={[
{
nodeName: 'node-1',
material: nodeMaterial,
},
]}
worldFrameMode="initial"
showSceneGrid
/>,
)
await waitFor(() => {
expect(mockEngineInstances[0]?.applyMaterialToMesh).toHaveBeenCalled()
})
const initialApplyCount =
mockEngineInstances[0]?.applyMaterialToMesh.mock.calls.length ?? 0
rerender(
<ModelViewer
worldNodes={[
{
nodeName: 'node-1',
modelUrl: '/desktop.glb',
source: 'desktop',
},
{
nodeName: 'node-2',
modelUrl: '/leg.glb',
source: 'leg',
},
]}
materialAssignments={[
{
nodeName: 'node-1',
material: nodeMaterial,
},
]}
worldFrameMode="initial"
showSceneGrid
/>,
)
await waitFor(() => {
expect(mockEngineInstances[0]?.loadWorldNodes).toHaveBeenCalledTimes(2)
})
await waitFor(() => {
expect(
mockEngineInstances[0]?.applyMaterialToMesh.mock.calls.length ?? 0,
).toBeGreaterThan(initialApplyCount)
})
})
})

View File

@ -22,6 +22,7 @@ import {
} from '@/lib/open-product/assisted-assembly-variant'
import {
deriveAssemblyActiveSnapCandidate,
getAssemblyAutoSnapSupportStatus,
moveAssemblyNodes,
removeAssemblyNodes,
instantiateAssemblyNode,
@ -66,13 +67,6 @@ function buildSnapHolePanelId(nodeId: string, holeId: string) {
return `${nodeId}::hole::${holeId}`
}
function getNodeStatus(node: { isSnapped?: boolean; zone: 'main' | 'staging' | 'subassembly' }) {
if (node.isSnapped) return '已连接'
if (node.zone === 'main') return '待连接'
if (node.zone === 'subassembly') return '模块处理中'
return '待装配'
}
function toOpenProductVariant(variant: AssistedAssemblyVariant) {
return {
id: variant.id,
@ -147,7 +141,7 @@ export function AssemblyWorkbenchV2({
workspaceKey,
candidateName,
statusTag: _statusTag,
warnings: _warnings,
warnings,
availableVariants,
onChange,
className,
@ -271,6 +265,30 @@ export function AssemblyWorkbenchV2({
const selectedNodes = presentWorkspace.nodes.filter((node) => selectedNodeIds.includes(node.nodeId))
const selectedSingleNode = selectedNodes.length === 1 ? selectedNodes[0] : null
const selectedNodeSnapSupport = useMemo(
() => (selectedSingleNode ? getAssemblyAutoSnapSupportStatus(selectedSingleNode) : null),
[selectedSingleNode],
)
const selectedNodeAutoSnapNotice = useMemo(() => {
if (
!selectedSingleNode ||
selectedSingleNode.source === 'desktop' ||
selectedNodeSnapSupport?.canAutoSnap
) {
return null
}
if (selectedNodeSnapSupport?.missingMountPatterns && selectedNodeSnapSupport?.missingHoles) {
return '当前资产缺少孔位与安装图样语义,无法自动吸附。'
}
if (selectedNodeSnapSupport?.missingMountPatterns) {
return '当前资产缺少安装图样语义,无法自动吸附。'
}
if (selectedNodeSnapSupport?.missingHoles) {
return '当前资产缺少孔位语义,无法自动吸附。'
}
return null
}, [selectedNodeSnapSupport, selectedSingleNode])
const activeSnapCandidate = useMemo(
() =>
selectedSingleNode
@ -816,7 +834,26 @@ export function AssemblyWorkbenchV2({
: 'pointer-events-none translate-x-[calc(100%+16px)] opacity-0',
)}
>
<div className="h-full max-h-full overflow-y-auto p-4">{rightDrawerContent}</div>
<div className="h-full max-h-full overflow-y-auto p-4">
{warnings.length > 0 || selectedNodeAutoSnapNotice ? (
<div className="mb-3 space-y-2">
{selectedNodeAutoSnapNotice ? (
<p className="rounded-2xl border border-amber-200/70 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-700">
{selectedNodeAutoSnapNotice}
</p>
) : null}
{warnings.map((warning) => (
<p
key={warning}
className="rounded-2xl border border-apple-separator/20 bg-white/70 px-3 py-2 text-xs leading-5 text-apple-label-secondary"
>
{warning}
</p>
))}
</div>
) : null}
{rightDrawerContent}
</div>
</div>
</div>
) : null

View File

@ -402,6 +402,86 @@ function buildHoleSnapWorkspace() {
})
}
function buildMissingSnapSemanticWorkspace() {
const baseWorkspace = buildWorkspace()
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
inventoryId: 'desktop:family-board-1:1',
variant: {
...AVAILABLE_VARIANTS[0],
assemblyBasis: {
anchor: 'desktop-center',
holes: [
{
id: 'board-hole-1',
panelId: 'board-panel',
position: [0, 0, 0],
normal: [0, -1, 0],
diameter: 8,
depth: 12,
holeGroupId: 'board-hole-group',
surfaceFaces: ['top'],
},
],
holeGroups: [
{
id: 'board-hole-group',
panelId: 'board-panel',
groupType: 'pair',
mountFace: 'top',
holeIds: ['board-hole-1'],
surfaceFaces: ['top'],
},
],
mountPatterns: [
{
id: 'board-pattern-1',
panelId: 'board-panel',
mountFace: 'top',
patternType: 'single',
holeIds: ['board-hole-1'],
requiredHoleIds: ['board-hole-1'],
optionalHoleIds: [],
minRequiredHoles: 1,
preferredHoleCount: 1,
},
],
},
} as never,
})
return instantiateAssemblyNode(withDesktop, {
inventoryId: 'leg:family-leg-1:2',
variant: {
...AVAILABLE_VARIANTS[1],
assemblyBasis: {
attachTo: 'desktop',
holes: [
{
id: 'leg-hole-1',
panelId: 'leg-panel',
position: [0, 0, 0],
normal: [0, 1, 0],
diameter: 8,
depth: 12,
holeGroupId: 'leg-hole-group',
surfaceFaces: ['bottom'],
},
],
holeGroups: [
{
id: 'leg-hole-group',
panelId: 'leg-panel',
groupType: 'pair',
mountFace: 'bottom',
holeIds: ['leg-hole-1'],
surfaceFaces: ['bottom'],
},
],
},
} as never,
})
}
describe('AssemblyWorkbenchV2', () => {
it('默认显示统一 3D 世界,并通过悬停展开左右浮层', async () => {
const actor = userEvent.setup()
@ -533,6 +613,23 @@ describe('AssemblyWorkbenchV2', () => {
})
})
it('选中缺少安装图样语义的节点时,会明确提示无法自动吸附', async () => {
const actor = userEvent.setup()
render(
<ControlledWorkbench
initialWorkspace={buildMissingSnapSemanticWorkspace()}
rightDrawerContent={<div>右侧业务区</div>}
/>,
)
await actor.click(screen.getByRole('button', { name: '模拟选中第二节点' }))
await waitFor(() => {
expect(screen.getByText('当前资产缺少安装图样语义,无法自动吸附。')).toBeInTheDocument()
})
})
it('选中节点后不再显示底部快捷浮条,操作统一走右键菜单', async () => {
render(<ControlledWorkbench />)

View File

@ -530,8 +530,27 @@ function getNodeAssemblyHoles(node: AssemblyWorkspaceNode): AssemblyBasisHoleRec
.filter((item): item is AssemblyBasisHoleRecord => item !== null)
}
export interface AssemblyAutoSnapSupportStatus {
canAutoSnap: boolean
missingHoles: boolean
missingMountPatterns: boolean
}
export function getAssemblyAutoSnapSupportStatus(
node: AssemblyWorkspaceNode,
): AssemblyAutoSnapSupportStatus {
const holeCount = getNodeAssemblyHoles(node).length
const mountPatternCount = getNodeAssemblyMountPatterns(node).length
return {
canAutoSnap: holeCount > 0 && mountPatternCount > 0,
missingHoles: holeCount === 0,
missingMountPatterns: mountPatternCount === 0,
}
}
function canUseHoleAutoSnap(node: AssemblyWorkspaceNode) {
return getNodeAssemblyMountPatterns(node).length > 0 && getNodeAssemblyHoles(node).length > 0
return getAssemblyAutoSnapSupportStatus(node).canAutoSnap
}
function areHoleDiametersCompatible(

View File

@ -21,22 +21,24 @@ describe('manual-assembly-launch', () => {
source: 'assisted-assembly',
sourceProductId: 'product-1',
redirectedFrom: 'product-assembly',
productRecovery: 'ambiguous-source-sku',
recoverableSourceSkuCount: 2,
selectedFamilyIdsByType: {
desktopFamilyIds: [],
legFamilyIds: [],
desktopFamilyIds: ['family-board'],
legFamilyIds: ['family-leg'],
connectorFamilyIds: [],
},
})
expect(href).toBe(
'/assisted-assembly?entryMode=direct&source=assisted-assembly&sourceProductId=product-1&redirectedFrom=product-assembly',
'/assisted-assembly?entryMode=direct&source=assisted-assembly&sourceProductId=product-1&redirectedFrom=product-assembly&productRecovery=ambiguous-source-sku&recoverableSourceSkuCount=2&desktopFamilyIds=family-board&legFamilyIds=family-leg',
)
})
it('会保留旧产品装配页的重定向参数,而不是把无选中资产的 direct 入口丢掉', () => {
it('会保留旧产品装配页的恢复上下文参数,而不是把 direct 入口丢掉', () => {
const launchParams = parseManualAssemblyLaunchParams(
createSearchParamReader(
'entryMode=direct&source=assisted-assembly&sourceProductId=product-1&redirectedFrom=product-assembly',
'entryMode=direct&source=assisted-assembly&sourceProductId=product-1&redirectedFrom=product-assembly&productRecovery=ambiguous-source-sku&recoverableSourceSkuCount=2&desktopFamilyIds=family-board&legFamilyIds=family-leg',
),
)
@ -46,20 +48,41 @@ describe('manual-assembly-launch', () => {
sourceSkuId: null,
sourceProductId: 'product-1',
redirectedFrom: 'product-assembly',
productRecovery: 'ambiguous-source-sku',
recoverableSourceSkuCount: 2,
selectedFamilyIdsByType: {
desktopFamilyIds: [],
legFamilyIds: [],
desktopFamilyIds: ['family-board'],
legFamilyIds: ['family-leg'],
connectorFamilyIds: [],
},
})
})
it('会为旧入口收敛生成最小必要反馈文案', () => {
expect(buildManualAssemblyLegacyFeedback('product-assembly')).toEqual({
it('会为产品级旧入口生成更明确的恢复反馈文案', () => {
expect(
buildManualAssemblyLegacyFeedback({
redirectedFrom: 'product-assembly',
productRecovery: 'ambiguous-source-sku',
recoverableSourceSkuCount: 2,
}),
).toEqual({
tone: 'error',
text: '当前产品下找到 2 个可恢复 SKU,暂时无法自动判断该恢复哪一个,已先按产品级可推导资产打开辅助装配。建议从具体 SKU 入口进入。',
})
expect(
buildManualAssemblyLegacyFeedback({
redirectedFrom: 'product-assembly',
productRecovery: 'missing-source-context',
}),
).toEqual({
tone: 'error',
text: '当前产品还没有可恢复的源 SKU 或装配快照,已打开普通辅助装配入口。',
})
expect(buildManualAssemblyLegacyFeedback({ redirectedFrom: 'product-assembly' })).toEqual({
tone: 'success',
text: '旧产品装配入口已下线,请改用辅助装配继续处理。',
})
expect(buildManualAssemblyLegacyFeedback('sku-assembly')).toEqual({
expect(buildManualAssemblyLegacyFeedback({ redirectedFrom: 'sku-assembly' })).toEqual({
tone: 'success',
text: '旧 SKU 装配入口已迁移到辅助装配。',
})

View File

@ -10,6 +10,9 @@ export interface ManualAssemblySelectedFamilyIdsByType {
}
export type ManualAssemblyLegacyEntry = 'product-assembly' | 'sku-assembly'
export type ManualAssemblyProductRecovery =
| 'missing-source-context'
| 'ambiguous-source-sku'
export interface ManualAssemblyLaunchParams {
entryMode: AssemblyWorkspaceEntryMode
@ -18,6 +21,8 @@ export interface ManualAssemblyLaunchParams {
sourceProductId?: string | null
selectedFamilyIdsByType: ManualAssemblySelectedFamilyIdsByType
redirectedFrom?: ManualAssemblyLegacyEntry | null
productRecovery?: ManualAssemblyProductRecovery | null
recoverableSourceSkuCount?: number | null
}
type SearchParamReader = {
@ -45,6 +50,25 @@ function normalizeLegacyEntry(
return null
}
function normalizeProductRecovery(
value: string | null | undefined,
): ManualAssemblyProductRecovery | null {
if (value === 'missing-source-context' || value === 'ambiguous-source-sku') {
return value
}
return null
}
function normalizePositiveInteger(value: string | null | undefined): number | null {
if (!value) {
return null
}
const parsed = Number.parseInt(value, 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
export function buildManualAssemblyLaunchHref(
params: ManualAssemblyLaunchParams,
) {
@ -64,6 +88,18 @@ export function buildManualAssemblyLaunchHref(
searchParams.set('redirectedFrom', params.redirectedFrom)
}
if (params.productRecovery) {
searchParams.set('productRecovery', params.productRecovery)
}
if (
typeof params.recoverableSourceSkuCount === 'number' &&
Number.isFinite(params.recoverableSourceSkuCount) &&
params.recoverableSourceSkuCount > 0
) {
searchParams.set('recoverableSourceSkuCount', String(params.recoverableSourceSkuCount))
}
if (params.selectedFamilyIdsByType.desktopFamilyIds.length > 0) {
searchParams.set('desktopFamilyIds', params.selectedFamilyIdsByType.desktopFamilyIds.join(','))
}
@ -96,6 +132,10 @@ export function parseManualAssemblyLaunchParams(
const sourceSkuId = searchParams.get('sourceSkuId')?.trim() ?? null
const sourceProductId = searchParams.get('sourceProductId')?.trim() ?? null
const redirectedFrom = normalizeLegacyEntry(searchParams.get('redirectedFrom')?.trim())
const productRecovery = normalizeProductRecovery(searchParams.get('productRecovery')?.trim())
const recoverableSourceSkuCount = normalizePositiveInteger(
searchParams.get('recoverableSourceSkuCount')?.trim(),
)
const entryMode: AssemblyWorkspaceEntryMode =
entryModeValue === 'asset-center' || entryModeValue === 'source-sku'
? entryModeValue
@ -118,20 +158,46 @@ export function parseManualAssemblyLaunchParams(
sourceProductId,
selectedFamilyIdsByType,
redirectedFrom,
productRecovery,
recoverableSourceSkuCount,
}
}
export function buildManualAssemblyLegacyFeedback(
redirectedFrom: ManualAssemblyLegacyEntry | null | undefined,
): { tone: 'success'; text: string } | null {
if (redirectedFrom === 'product-assembly') {
launchParams:
| Pick<
ManualAssemblyLaunchParams,
'redirectedFrom' | 'productRecovery' | 'recoverableSourceSkuCount'
>
| null
| undefined,
): { tone: 'success' | 'error'; text: string } | null {
if (launchParams?.redirectedFrom === 'product-assembly') {
if (launchParams.productRecovery === 'ambiguous-source-sku') {
const count = launchParams.recoverableSourceSkuCount
return {
tone: 'error',
text:
typeof count === 'number' && count > 1
? `当前产品下找到 ${count} 个可恢复 SKU,暂时无法自动判断该恢复哪一个,已先按产品级可推导资产打开辅助装配。建议从具体 SKU 入口进入。`
: '当前产品下存在多个可恢复 SKU,暂时无法自动判断该恢复哪一个,已先按产品级可推导资产打开辅助装配。建议从具体 SKU 入口进入。',
}
}
if (launchParams.productRecovery === 'missing-source-context') {
return {
tone: 'error',
text: '当前产品还没有可恢复的源 SKU 或装配快照,已打开普通辅助装配入口。',
}
}
return {
tone: 'success',
text: '旧产品装配入口已下线,请改用辅助装配继续处理。',
}
}
if (redirectedFrom === 'sku-assembly') {
if (launchParams?.redirectedFrom === 'sku-assembly') {
return {
tone: 'success',
text: '旧 SKU 装配入口已迁移到辅助装配。',

View File

@ -169,6 +169,7 @@ describe('assetCadImportService', () => {
modelFile: 'desktop.glb',
panelCount: 1,
units: 'mm',
coordinateSystem: 'cad-local',
originOffset: [10, 20, 30],
modelBounds: [0, 0, 0, 1200, 600, 25],
panels: [
@ -193,6 +194,7 @@ describe('assetCadImportService', () => {
holeGroupId: 'group-1',
holeGroupType: 'pair',
holeGroupRole: 'mount',
surfaceFaces: ['bottom'],
suggestedFasteners: [
{
category: 'dowel',
@ -213,6 +215,24 @@ describe('assetCadImportService', () => {
holeIds: ['hole-1'],
position: [100, 50, 0],
normal: [0, 0, 1],
mountFace: 'bottom',
surfaceFaces: ['bottom'],
},
],
mountPatterns: [
{
id: 'pattern-1',
panelId: 'panel-1',
mountFace: 'bottom',
patternType: 'single',
holeIds: ['hole-1'],
requiredHoleIds: ['hole-1'],
optionalHoleIds: [],
minRequiredHoles: 1,
preferredHoleCount: 1,
symmetry: false,
position: [100, 50, 0],
normal: [0, 0, 1],
},
],
} as never,
@ -271,12 +291,15 @@ describe('assetCadImportService', () => {
expect(createVariantInput.assemblyBasis).toEqual(
expect.objectContaining({
source: 'CAD_SINGLE_ASSET_IMPORT',
coordinateSystem: 'cad-local',
holeCount: 1,
mountPatternCount: 1,
suggestedFastenerCount: 1,
holes: [
expect.objectContaining({
id: 'hole-1',
holeGroupId: 'group-1',
surfaceFaces: ['bottom'],
suggestedFasteners: [
expect.objectContaining({
category: 'dowel',
@ -289,6 +312,16 @@ describe('assetCadImportService', () => {
expect.objectContaining({
id: 'group-1',
panelId: 'panel-1',
mountFace: 'bottom',
surfaceFaces: ['bottom'],
}),
],
mountPatterns: [
expect.objectContaining({
id: 'pattern-1',
panelId: 'panel-1',
mountFace: 'bottom',
holeIds: ['hole-1'],
}),
],
}),
@ -315,6 +348,12 @@ describe('assetCadImportService', () => {
id: 'group-1',
}),
],
mountPatterns: [
expect.objectContaining({
id: 'pattern-1',
mountFace: 'bottom',
}),
],
}),
}),
)

View File

@ -1366,4 +1366,167 @@ describe('assetCenterService', () => {
}),
])
})
it('老资产 assemblyBasis 缺少安装图样时,会从 businessData.cadData 运行时恢复完整装配语义', async () => {
mockedAssetVariantRepository.findManyByFamilyIds.mockResolvedValue([
{
id: 'variant-board-top-legacy',
assetFamilyId: 'family-board-top',
code: 'BOARD-TOP-LEGACY',
name: '桌面板历史款',
identityKey: 'BOARD|code=BOARD-TOP-LEGACY',
assetType: 'BOARD',
status: 'AVAILABLE',
sizeLabel: '1200',
substrateId: 'substrate-particle-board',
finishId: 'finish-white-oak',
dimensions: { length: 1200, width: 600, thickness: 25 },
modelUrl: '/models/board-top-legacy.glb',
thumbnailUrl: '/thumbs/board-top-legacy.png',
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
assemblyBasis: {
source: 'CAD_SINGLE_ASSET_IMPORT',
holes: [
{
id: 'hole-1',
panelId: 'panel-1',
position: [100, 0, 50],
normal: [0, -1, 0],
diameter: 8,
depth: 12,
holeGroupId: 'group-1',
},
],
holeGroups: [
{
id: 'group-1',
panelId: 'panel-1',
groupType: 'pair',
holeIds: ['hole-1'],
},
],
},
compatibilityRules: null,
namingFields: { panelType: 'top_panel' },
businessData: {
cadData: {
source: 'hardware.json',
pluginVersion: '2.2.15',
units: 'mm',
coordinateSystem: 'cad-local',
panelCount: 1,
panels: [],
holes: [
{
id: 'hole-1',
panelId: 'panel-1',
position: [100, 0, 50],
normal: [0, -1, 0],
diameter: 8,
depth: 12,
holeGroupId: 'group-1',
surfaceFaces: ['bottom'],
},
],
holeGroups: [
{
id: 'group-1',
panelId: 'panel-1',
groupType: 'pair',
holeIds: ['hole-1'],
position: [100, 0, 50],
normal: [0, -1, 0],
mountFace: 'bottom',
surfaceFaces: ['bottom'],
},
],
mountPatterns: [
{
id: 'pattern-1',
panelId: 'panel-1',
mountFace: 'bottom',
patternType: 'single',
holeIds: ['hole-1'],
requiredHoleIds: ['hole-1'],
optionalHoleIds: [],
minRequiredHoles: 1,
preferredHoleCount: 1,
position: [100, 0, 50],
normal: [0, -1, 0],
},
],
hardwareTypes: [],
hardwareSummary: [],
},
},
weightKg: 18.5,
costPrice: 199,
inheritedFromVariantId: null,
createdBy: 'user-1',
creator: { id: 'user-1', name: '测试用户' },
submittedAt: null,
submittedById: null,
submitter: null,
reviewedAt: new Date('2026-03-31T10:00:00.000Z'),
reviewedById: 'reviewer-1',
reviewer: { id: 'reviewer-1', name: '审核员' },
rejectedAt: null,
rejectedById: null,
rejecter: null,
rejectionReason: null,
lastModifiedById: 'user-1',
lastModifier: { id: 'user-1', name: '测试用户' },
assetFamily: {
id: 'family-board-top',
code: 'BOARD-TOP',
name: '桌面板资产族',
type: 'BOARD',
},
substrate: {
id: 'substrate-particle-board',
code: 'PB18',
name: '颗粒板',
},
finish: {
id: 'finish-white-oak',
name: '白橡',
},
createdAt: new Date('2026-03-31T10:00:00.000Z'),
updatedAt: new Date('2026-03-31T10:00:00.000Z'),
},
] as never)
const result = await assetCenterService.listVariantsByFamilyIds(
['family-board-top'],
{ status: 'AVAILABLE' },
)
expect(result).toEqual([
expect.objectContaining({
id: 'variant-board-top-legacy',
assemblyBasis: expect.objectContaining({
coordinateSystem: 'cad-local',
holes: [
expect.objectContaining({
id: 'hole-1',
surfaceFaces: ['bottom'],
}),
],
holeGroups: [
expect.objectContaining({
id: 'group-1',
mountFace: 'bottom',
surfaceFaces: ['bottom'],
}),
],
mountPatterns: [
expect.objectContaining({
id: 'pattern-1',
mountFace: 'bottom',
}),
],
}),
}),
])
})
})

View File

@ -0,0 +1,158 @@
import {
normalizeAssemblyWorkspace,
normalizeVariantSelection,
} from '../draft-group.mapper'
describe('draft-group.mapper', () => {
it('会在装配工作区快照里保留完整 CAD 装配语义', () => {
const result = normalizeAssemblyWorkspace({
source: 'assisted-assembly',
entryMode: 'direct',
inventory: [],
nodes: [
{
nodeId: 'node-1',
role: 'leg',
label: '桌腿A',
assetFamilyId: 'family-leg-1',
variantId: 'variant-leg-1',
code: 'LEG-A-001',
name: '桌腿A',
modelUrl: '/leg.glb',
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
zone: 'main',
source: 'leg',
inventoryId: 'leg:family-leg-1:1',
slotKey: 'leg-1',
roleRecommendation: 'leg',
roleConfirmed: 'leg',
weakSlot: { role: 'leg', parentRolePath: null, instanceIndex: 1 },
targetPosition: [0, 0, 0],
targetRotation: [0, 0, 0],
isSnapped: false,
subassemblyId: null,
mountPoints: [],
assemblyBasis: {
coordinateSystem: 'cad-local',
holes: [
{
id: 'hole-1',
panelId: 'panel-1',
position: [0, 0, 0],
normal: [0, 1, 0],
diameter: 8,
depth: 12,
surfaceFaces: ['bottom'],
},
],
holeGroups: [
{
id: 'group-1',
panelId: 'panel-1',
groupType: 'pair',
holeIds: ['hole-1'],
mountFace: 'bottom',
surfaceFaces: ['bottom'],
},
],
mountPatterns: [
{
id: 'pattern-1',
panelId: 'panel-1',
mountFace: 'bottom',
patternType: 'single',
holeIds: ['hole-1'],
requiredHoleIds: ['hole-1'],
optionalHoleIds: [],
minRequiredHoles: 1,
preferredHoleCount: 1,
},
],
},
},
],
subassemblies: [],
relations: [],
validation: {
level: 'PASSED',
blockingIssues: [],
warnings: [],
},
})
expect(result?.nodes[0]?.assemblyBasis).toEqual(
expect.objectContaining({
coordinateSystem: 'cad-local',
holes: [
expect.objectContaining({
id: 'hole-1',
surfaceFaces: ['bottom'],
}),
],
holeGroups: [
expect.objectContaining({
id: 'group-1',
mountFace: 'bottom',
surfaceFaces: ['bottom'],
}),
],
mountPatterns: [
expect.objectContaining({
id: 'pattern-1',
mountFace: 'bottom',
holeIds: ['hole-1'],
}),
],
}),
)
})
it('会在规格选择快照里保留完整 assemblyBasis,避免草稿组回读时掉装配语义', () => {
const result = normalizeVariantSelection({
slots: [
{
slotKey: 'desktop-1',
role: 'desktop',
assetFamilyId: 'family-board-1',
variantId: 'variant-board-1',
code: 'BOARD-A-001',
name: '桌面A',
assetType: 'BOARD',
dimensions: { length: 1200, width: 600, thickness: 25 },
modelUrl: '/board.glb',
mountPoints: [],
assemblyBasis: {
coordinateSystem: 'cad-local',
mountPatterns: [
{
id: 'pattern-1',
panelId: 'panel-1',
mountFace: 'top',
patternType: 'single',
holeIds: ['hole-1'],
minRequiredHoles: 1,
preferredHoleCount: 1,
},
],
},
},
],
hardwareSelections: [],
})
expect(result.slots[0]?.assemblyBasis).toEqual(
expect.objectContaining({
coordinateSystem: 'cad-local',
mountPatterns: [
expect.objectContaining({
id: 'pattern-1',
mountFace: 'top',
holeIds: ['hole-1'],
}),
],
}),
)
})
})

View File

@ -235,6 +235,7 @@ function buildAssemblyBasis(params: {
}): Record<string, unknown> {
const holes = params.assetType === 'HARDWARE' ? [] : (params.cadData?.holes ?? [])
const holeGroups = params.assetType === 'HARDWARE' ? [] : (params.cadData?.holeGroups ?? [])
const mountPatterns = params.assetType === 'HARDWARE' ? [] : (params.cadData?.mountPatterns ?? [])
const edgeFeatures = params.assetType === 'BOARD' ? (params.cadExportData.edgeFeatures ?? []) : []
const suggestedFastenerCount = holes.reduce(
(sum, hole) => sum + (hole.suggestedFasteners?.length ?? 0),
@ -250,9 +251,12 @@ function buildAssemblyBasis(params: {
panelCount: params.cadData?.panels.length ?? 0,
holeCount: holes.length,
hardwareTypeCount: params.cadData?.hardwareTypes.length ?? 0,
coordinateSystem: params.cadData?.coordinateSystem ?? params.cadExportData.coordinateSystem ?? null,
mountPatternCount: mountPatterns.length,
suggestedFastenerCount,
holes,
holeGroups,
mountPatterns,
edgeFeatureCount: edgeFeatures.length,
edgeFeatures,
}
@ -368,9 +372,11 @@ class AssetCadImportService {
const cadData = buildPersistedSkuCadData({
pluginVersion: parseResult.cadExportData.pluginVersion,
units: parseResult.cadExportData.units,
coordinateSystem: parseResult.cadExportData.coordinateSystem,
panels: parseResult.cadExportData.panels,
holes: parseResult.cadExportData.holes,
holeGroups: (parseResult.cadExportData as unknown as { holeGroups?: unknown }).holeGroups,
mountPatterns: (parseResult.cadExportData as unknown as { mountPatterns?: unknown }).mountPatterns,
hardwareTypes: parseResult.cadExportData.hardwareTypes,
hardwareSummary: parseResult.cadExportData.hardwareSummary,
})

View File

@ -29,6 +29,7 @@ import {
writeAssetVariantUvConfig,
type AssetVariantUvConfig,
} from '@/lib/asset-variant-render-config'
import type { PersistedSkuCadData } from '@/lib/sku-cad-data'
import {
buildSubstrateDisplayName,
calculateEstimatedCost,
@ -231,9 +232,119 @@ function decimalToNumber(value: Prisma.Decimal | number | null | undefined): num
return value === null || value === undefined ? null : Number(value)
}
function asRecordArray(value: unknown): Record<string, unknown>[] {
return Array.isArray(value)
? value.filter((item): item is Record<string, unknown> => isRecord(item))
: []
}
function mergeAssemblyBasisRecordArray(
primaryValue: unknown,
fallbackValue: unknown,
): Record<string, unknown>[] | undefined {
const primary = asRecordArray(primaryValue)
const fallback = asRecordArray(fallbackValue)
if (primary.length === 0 && fallback.length === 0) {
return undefined
}
if (primary.length === 0) {
return fallback.map((item) => ({ ...item }))
}
const fallbackById = new Map(
fallback
.filter((item) => typeof item.id === 'string' && item.id.length > 0)
.map((item) => [item.id as string, item]),
)
const merged = primary.map((item) => {
const id = typeof item.id === 'string' ? item.id : null
const fallbackItem = id ? fallbackById.get(id) ?? null : null
return fallbackItem ? { ...fallbackItem, ...item } : { ...item }
})
const seenIds = new Set(
primary
.map((item) => (typeof item.id === 'string' ? item.id : null))
.filter((item): item is string => Boolean(item)),
)
fallback.forEach((item) => {
const id = typeof item.id === 'string' ? item.id : null
if (id && seenIds.has(id)) {
return
}
merged.push({ ...item })
})
return merged
}
function readCadDataFromBusinessData(
businessData: Record<string, unknown> | null,
): PersistedSkuCadData | null {
if (!businessData || !isRecord(businessData.cadData)) {
return null
}
return businessData.cadData as unknown as PersistedSkuCadData
}
function recoverAssemblyBasis(
assemblyBasis: Record<string, unknown> | null,
businessData: Record<string, unknown> | null,
): Record<string, unknown> | null {
const cadData = readCadDataFromBusinessData(businessData)
if (!cadData) {
return assemblyBasis
}
const recoveredHoles = mergeAssemblyBasisRecordArray(assemblyBasis?.holes, cadData.holes)
const recoveredHoleGroups = mergeAssemblyBasisRecordArray(
assemblyBasis?.holeGroups,
cadData.holeGroups,
)
const recoveredMountPatterns = mergeAssemblyBasisRecordArray(
assemblyBasis?.mountPatterns,
cadData.mountPatterns,
)
if (
!assemblyBasis &&
!cadData.coordinateSystem &&
!recoveredHoles &&
!recoveredHoleGroups &&
!recoveredMountPatterns
) {
return null
}
const recoveredBasis = assemblyBasis ? { ...assemblyBasis } : {}
if (!('coordinateSystem' in recoveredBasis) && cadData.coordinateSystem) {
recoveredBasis.coordinateSystem = cadData.coordinateSystem
}
if (recoveredHoles) {
recoveredBasis.holes = recoveredHoles
}
if (recoveredHoleGroups) {
recoveredBasis.holeGroups = recoveredHoleGroups
}
if (recoveredMountPatterns) {
recoveredBasis.mountPatterns = recoveredMountPatterns
}
return recoveredBasis
}
function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO {
const businessData = isRecord(record.businessData) ? record.businessData : null
const uvConfig = readAssetVariantUvConfig(businessData)
const assemblyBasis = recoverAssemblyBasis(
isRecord(record.assemblyBasis) ? record.assemblyBasis : null,
businessData,
)
return {
id: record.id,
@ -257,7 +368,7 @@ function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO {
mountPoints: Array.isArray(record.mountPoints)
? (record.mountPoints as unknown as OpenProductMountPoint[])
: [],
assemblyBasis: isRecord(record.assemblyBasis) ? record.assemblyBasis : null,
assemblyBasis,
compatibilityRules: isRecord(record.compatibilityRules) ? record.compatibilityRules : null,
namingFields: isRecord(record.namingFields) ? record.namingFields : null,
businessData,