feat: restore material library and assembly workflows
This commit is contained in:
parent
602e2fd33f
commit
c8d3d3d9e7
@ -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 或装配快照,已打开普通辅助装配入口。',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 />)
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 装配入口已迁移到辅助装配。',
|
||||
})
|
||||
|
||||
@ -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 装配入口已迁移到辅助装配。',
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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'],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user