feat: polish home assembly and material workflows
This commit is contained in:
parent
44677da317
commit
56a29b8be2
@ -83,7 +83,7 @@ export default function HomeClient({
|
||||
products: initialProducts,
|
||||
initialHasMore,
|
||||
myDraftGroups,
|
||||
user,
|
||||
user: _user,
|
||||
initialTab = 'products',
|
||||
}: HomeClientProps) {
|
||||
const toast = useToast()
|
||||
@ -100,18 +100,10 @@ export default function HomeClient({
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||
const [editingProduct, setEditingProduct] = useState<ProductWithSkus | null>(null)
|
||||
const [categories, setCategories] = useState<CategoryOption[]>([])
|
||||
const categoriesLoadedRef = useRef(false)
|
||||
|
||||
const [newModelNumber, setNewModelNumber] = useState('')
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newCategoryId, setNewCategoryId] = useState('')
|
||||
const [newDescription, setNewDescription] = useState('')
|
||||
const [newVisibility, setNewVisibility] = useState<ProductVisibility>('TEAM')
|
||||
const [createSubmitting, setCreateSubmitting] = useState(false)
|
||||
|
||||
const [addSKUProductId, setAddSKUProductId] = useState<string | null>(null)
|
||||
const [addSKUProductName, setAddSKUProductName] = useState('')
|
||||
const [editName, setEditName] = useState('')
|
||||
@ -156,7 +148,7 @@ export default function HomeClient({
|
||||
}, [isMounted, restoreScrollY, shouldRestoreFromReturn])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreateModalOpen && !editingProduct) return
|
||||
if (!editingProduct) return
|
||||
if (categoriesLoadedRef.current) return
|
||||
let cancelled = false
|
||||
categoriesLoadedRef.current = true
|
||||
@ -176,7 +168,7 @@ export default function HomeClient({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [editingProduct, isCreateModalOpen])
|
||||
}, [editingProduct])
|
||||
|
||||
const filteredProducts = useFilteredProducts(products, deferredSearchQuery)
|
||||
|
||||
@ -193,13 +185,17 @@ export default function HomeClient({
|
||||
markExpandedProductForRestore()
|
||||
}, [activeTab, markExpandedProductForRestore])
|
||||
|
||||
const buildFormalizedProductsUrl = useCallback((params?: Record<string, string>) => {
|
||||
const searchParams = new URLSearchParams({ formalizedOnly: 'true', ...params })
|
||||
return `/api/products?${searchParams.toString()}`
|
||||
}, [])
|
||||
|
||||
const loadMoreProducts = useCallback(async () => {
|
||||
if (isLoadingMore || !hasMore) return
|
||||
setIsLoadingMore(true)
|
||||
try {
|
||||
const nextPage = currentPage + 1
|
||||
const params = new URLSearchParams({ page: String(nextPage), limit: '20' })
|
||||
const response = await fetch(`/api/products?${params}`)
|
||||
const response = await fetch(buildFormalizedProductsUrl({ page: String(nextPage), limit: '20' }))
|
||||
const responseBody = await readJsonSafely(response)
|
||||
if (!response.ok || isApiFailure(responseBody)) {
|
||||
throw new Error(getApiErrorMessage(parseApiErrorPayload(responseBody), '加载失败'))
|
||||
@ -215,7 +211,7 @@ export default function HomeClient({
|
||||
} finally {
|
||||
setIsLoadingMore(false)
|
||||
}
|
||||
}, [currentPage, hasMore, isLoadingMore])
|
||||
}, [buildFormalizedProductsUrl, currentPage, hasMore, isLoadingMore])
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
@ -227,7 +223,7 @@ export default function HomeClient({
|
||||
|
||||
const reloadProducts = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/products')
|
||||
const response = await fetch(buildFormalizedProductsUrl())
|
||||
const responseBody = await readJsonSafely(response)
|
||||
if (!response.ok || isApiFailure(responseBody)) {
|
||||
throw new Error(getApiErrorMessage(parseApiErrorPayload(responseBody), '刷新产品列表失败'))
|
||||
@ -241,7 +237,7 @@ export default function HomeClient({
|
||||
console.error('刷新产品列表失败:', error)
|
||||
window.location.reload()
|
||||
}
|
||||
}, [])
|
||||
}, [buildFormalizedProductsUrl])
|
||||
|
||||
const handleAddSKU = useCallback((productId: string) => {
|
||||
const product = products.find((item) => item.id === productId)
|
||||
@ -268,49 +264,6 @@ export default function HomeClient({
|
||||
setDeletingSku({ sku, product })
|
||||
}, [])
|
||||
|
||||
const handleCreateProduct = useCallback(async () => {
|
||||
if (!newModelNumber.trim() || !newName.trim()) {
|
||||
toast.error('型号和名称为必填项')
|
||||
return
|
||||
}
|
||||
if (!newCategoryId) {
|
||||
toast.error('请选择产品分类')
|
||||
return
|
||||
}
|
||||
setCreateSubmitting(true)
|
||||
try {
|
||||
const res = await fetch('/api/products', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
modelNumber: newModelNumber.trim(),
|
||||
name: newName.trim(),
|
||||
description: newDescription.trim() || undefined,
|
||||
categoryId: newCategoryId,
|
||||
visibility: newVisibility,
|
||||
}),
|
||||
})
|
||||
const responseBody = await readJsonSafely(res)
|
||||
if (!res.ok || isApiFailure(responseBody)) {
|
||||
toast.error(getApiErrorMessage(parseApiErrorPayload(responseBody), '创建产品失败'))
|
||||
return
|
||||
}
|
||||
await reloadProducts()
|
||||
toast.success(`产品 ${newModelNumber.trim()} 创建成功`)
|
||||
setIsCreateModalOpen(false)
|
||||
setNewModelNumber('')
|
||||
setNewName('')
|
||||
setNewCategoryId('')
|
||||
setNewDescription('')
|
||||
setNewVisibility('TEAM')
|
||||
} catch (error) {
|
||||
console.error('创建产品失败:', error)
|
||||
toast.error('创建产品失败,请稍后重试')
|
||||
} finally {
|
||||
setCreateSubmitting(false)
|
||||
}
|
||||
}, [newCategoryId, newDescription, newModelNumber, newName, newVisibility, reloadProducts, toast])
|
||||
|
||||
const handleEditProduct = useCallback(async () => {
|
||||
if (!editingProduct) return
|
||||
if (!editName.trim()) {
|
||||
@ -367,7 +320,7 @@ export default function HomeClient({
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-7xl flex-col px-4 pb-10 pt-4 sm:px-6 lg:px-8">
|
||||
<main className="flex flex-1 flex-col gap-6 pt-6">
|
||||
<HomeWorkspaceHeader
|
||||
title="工作台首页"
|
||||
title="已入库产品与我的草稿"
|
||||
/>
|
||||
|
||||
<BusinessOverviewSection initialProducts={products.length} initialDraftGroups={myDraftGroups.length} />
|
||||
@ -398,7 +351,6 @@ export default function HomeClient({
|
||||
onAddSKU={handleAddSKU}
|
||||
onDeleteProduct={handleOpenDeleteProduct}
|
||||
onDeleteSKU={handleOpenDeleteSKU}
|
||||
onOpenCreate={() => setIsCreateModalOpen(true)}
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
isMounted={isMounted}
|
||||
@ -430,63 +382,6 @@ export default function HomeClient({
|
||||
productName={addSKUProductName}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => {
|
||||
if (!createSubmitting) setIsCreateModalOpen(false)
|
||||
}}
|
||||
title="新建产品型号"
|
||||
size="medium"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<Input label="型号 (modelNumber)" placeholder="例如:SF-001" value={newModelNumber} onChange={(e) => setNewModelNumber(e.target.value)} required />
|
||||
<Input label="名称 (name)" placeholder="请输入产品名称" value={newName} onChange={(e) => setNewName(e.target.value)} required />
|
||||
<Select
|
||||
id="new-product-category"
|
||||
label="产品分类"
|
||||
value={newCategoryId}
|
||||
onChange={(e) => setNewCategoryId(e.target.value)}
|
||||
placeholder="请选择分类"
|
||||
options={categories.map((category) => ({ value: category.id, label: category.name }))}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
id="new-product-description"
|
||||
label="描述 (可选)"
|
||||
rows={3}
|
||||
value={newDescription}
|
||||
onChange={(e) => setNewDescription(e.target.value)}
|
||||
/>
|
||||
<fieldset>
|
||||
<legend className="mb-2 block text-apple-md font-apple-medium text-apple-label-secondary">可见性</legend>
|
||||
<div className="flex gap-3">
|
||||
{visibilityOptions.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant="gray"
|
||||
size="small"
|
||||
aria-pressed={newVisibility === option.value}
|
||||
onClick={() => setNewVisibility(option.value)}
|
||||
className={cn(
|
||||
'min-w-[108px]',
|
||||
newVisibility === option.value
|
||||
? 'border-apple-blue bg-apple-blue text-white hover:bg-apple-blue hover:opacity-95'
|
||||
: 'bg-apple-bg-primary text-apple-label-secondary',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button variant="plain" onClick={() => !createSubmitting && setIsCreateModalOpen(false)}>取消</Button>
|
||||
<Button variant="filled" onClick={handleCreateProduct} loading={createSubmitting}>创建产品</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={!!editingProduct}
|
||||
onClose={() => {
|
||||
|
||||
@ -111,7 +111,13 @@ jest.mock('../assisted-assembly/AssistedAssemblyClient', () => ({
|
||||
}: {
|
||||
metrics: Array<{ label: string; value: number | string }>
|
||||
families: Array<{ id: string }>
|
||||
variants: Array<{ id: string }>
|
||||
variants: Array<{
|
||||
id: string
|
||||
uvConfig?: {
|
||||
nodes?: Record<string, { textureRepeat?: number }>
|
||||
} | null
|
||||
materialConfig?: { textureRepeat?: number } | null
|
||||
}>
|
||||
initialLaunchParams?: { entryMode?: string | null; sourceSkuId?: string | null } | null
|
||||
initialWorkspace?: {
|
||||
sourceSkuId?: string | null
|
||||
@ -128,6 +134,12 @@ jest.mock('../assisted-assembly/AssistedAssemblyClient', () => ({
|
||||
))}
|
||||
<div data-testid="family-ids">{families.map((family) => family.id).join(',')}</div>
|
||||
<div data-testid="variant-ids">{variants.map((variant) => variant.id).join(',')}</div>
|
||||
<div data-testid="variant-uv-repeat">
|
||||
{String(variants[0]?.uvConfig?.nodes?.__default__?.textureRepeat ?? 'none')}
|
||||
</div>
|
||||
<div data-testid="variant-material-repeat">
|
||||
{String(variants[0]?.materialConfig?.textureRepeat ?? 'none')}
|
||||
</div>
|
||||
<div data-testid="launch-entry-mode">{initialLaunchParams?.entryMode ?? 'direct'}</div>
|
||||
<div data-testid="launch-source-sku-id">{initialLaunchParams?.sourceSkuId ?? 'none'}</div>
|
||||
<div data-testid="workspace-source-sku-id">{initialWorkspace?.sourceSkuId ?? 'none'}</div>
|
||||
@ -413,4 +425,63 @@ describe('AssistedAssemblyPage', () => {
|
||||
expect(screen.getByTestId('workspace-source-sku-id')).toHaveTextContent('sku-source-2')
|
||||
expect(screen.getByTestId('workspace-first-node-coordinate-system')).toHaveTextContent('cad-z-up')
|
||||
})
|
||||
|
||||
it('辅助装配会消费规格款 businessData 里同一份 uvConfig', async () => {
|
||||
jest.mocked(assetCenterService.listVariantsByFamilyIds).mockResolvedValue([
|
||||
{
|
||||
id: 'variant-board-uv',
|
||||
assetFamilyId: 'family-board',
|
||||
assetType: 'BOARD',
|
||||
status: 'AVAILABLE',
|
||||
finishId: 'finish-oak',
|
||||
modelUrl: '/board-uv.glb',
|
||||
thumbnailUrl: null,
|
||||
businessData: {
|
||||
renderConfig: {
|
||||
uv: {
|
||||
version: 1,
|
||||
nodes: {
|
||||
__default__: {
|
||||
textureRepeat: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
])
|
||||
jest.mocked(materialService.findAll).mockResolvedValue([
|
||||
{
|
||||
id: 'finish-oak',
|
||||
name: '白橡木纹',
|
||||
categoryId: 'cat-finish',
|
||||
categoryName: '饰面',
|
||||
thumbnailUrl: '/finish-oak.png',
|
||||
textureUrl: '/finish-oak.jpg',
|
||||
normalMapUrl: null,
|
||||
roughnessMapUrl: null,
|
||||
metalnessMapUrl: null,
|
||||
aoMapUrl: null,
|
||||
previewUrl: null,
|
||||
color: '#ffffff',
|
||||
metalness: 0,
|
||||
roughness: 1,
|
||||
opacity: 1,
|
||||
transmission: null,
|
||||
ior: null,
|
||||
thickness: null,
|
||||
clearcoat: null,
|
||||
clearcoatRoughness: null,
|
||||
envMapIntensity: null,
|
||||
textureRepeat: 1,
|
||||
textureRotation: 0,
|
||||
textureSizeMm: 600,
|
||||
} as never,
|
||||
])
|
||||
|
||||
render(await AssistedAssemblyPage())
|
||||
|
||||
expect(screen.getByTestId('variant-uv-repeat')).toHaveTextContent('2')
|
||||
expect(screen.getByTestId('variant-material-repeat')).toHaveTextContent('2')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ReactNode } from 'react'
|
||||
import HomeClient from '../HomeClient.workspace'
|
||||
@ -88,6 +88,11 @@ jest.mock('@/components/ui/ProductListItem', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('../home/components/BusinessOverviewSection', () => ({
|
||||
__esModule: true,
|
||||
BusinessOverviewSection: () => <div>业务概览</div>,
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/usePerformance', () => ({
|
||||
useDebounce: <T,>(value: T) => value,
|
||||
}))
|
||||
@ -102,6 +107,27 @@ jest.mock('@/hooks/api/useSKUs', () => ({
|
||||
|
||||
const mockedDeleteProductRequest = deleteProductRequest as jest.MockedFunction<typeof deleteProductRequest>
|
||||
const mockedDeleteSKURequest = deleteSKURequest as jest.MockedFunction<typeof deleteSKURequest>
|
||||
let intersectionCallback:
|
||||
| ((entries: Array<Partial<IntersectionObserverEntry>>) => void)
|
||||
| null = null
|
||||
|
||||
class MockIntersectionObserver implements IntersectionObserver {
|
||||
readonly root = null
|
||||
readonly rootMargin = ''
|
||||
readonly thresholds = [0]
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
const observer = this as unknown as IntersectionObserver
|
||||
intersectionCallback = (entries) => {
|
||||
callback(entries as IntersectionObserverEntry[], observer)
|
||||
}
|
||||
}
|
||||
|
||||
disconnect = jest.fn()
|
||||
observe = jest.fn()
|
||||
takeRecords = jest.fn(() => [])
|
||||
unobserve = jest.fn()
|
||||
}
|
||||
|
||||
const mockProduct: ProductWithSkus = {
|
||||
id: 'product-1',
|
||||
@ -152,6 +178,23 @@ const mockDraftGroup: DraftGroupSummary = {
|
||||
updatedAt: '2026-03-31T10:00:00.000Z',
|
||||
}
|
||||
|
||||
const mockNextPageProduct: ProductWithSkus = {
|
||||
...mockProduct,
|
||||
id: 'product-2',
|
||||
modelNumber: 'BI012405',
|
||||
name: '第二个产品',
|
||||
skus: [
|
||||
{
|
||||
id: 'sku-2',
|
||||
skuCode: 'BI012405-A',
|
||||
price: 200,
|
||||
stock: 20,
|
||||
isActive: true,
|
||||
attributes: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function createJsonResponse(payload: unknown) {
|
||||
return {
|
||||
ok: true,
|
||||
@ -159,9 +202,28 @@ function createJsonResponse(payload: unknown) {
|
||||
} as Response
|
||||
}
|
||||
|
||||
function triggerIntersection(isIntersecting = true) {
|
||||
if (!intersectionCallback) {
|
||||
throw new Error('IntersectionObserver 未初始化')
|
||||
}
|
||||
|
||||
act(() => {
|
||||
intersectionCallback?.([{ isIntersecting }])
|
||||
})
|
||||
}
|
||||
|
||||
describe('HomeClient 删除入口', () => {
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(global, 'IntersectionObserver', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: MockIntersectionObserver,
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
intersectionCallback = null
|
||||
|
||||
global.fetch = jest.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
@ -170,7 +232,7 @@ describe('HomeClient 删除入口', () => {
|
||||
return createJsonResponse({ data: { items: [] } })
|
||||
}
|
||||
|
||||
if (url === '/api/products') {
|
||||
if (url === '/api/products?formalizedOnly=true') {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
items: [mockProduct],
|
||||
@ -179,11 +241,20 @@ describe('HomeClient 删除入口', () => {
|
||||
})
|
||||
}
|
||||
|
||||
if (url === '/api/products?formalizedOnly=true&page=2&limit=20') {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
items: [mockNextPageProduct],
|
||||
pagination: { hasMore: false },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return createJsonResponse({})
|
||||
}) as typeof fetch
|
||||
})
|
||||
|
||||
it('默认显示正式产品,并可切换到我的草稿', async () => {
|
||||
it('默认显示已入库产品,并可切换到我的草稿', async () => {
|
||||
render(
|
||||
<HomeClient
|
||||
products={[mockProduct]}
|
||||
@ -193,8 +264,9 @@ describe('HomeClient 删除入口', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /正式产品/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /已入库产品/i })).toBeInTheDocument()
|
||||
expect(screen.getByText('BI012404')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /添加型号/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: '资产中心' })).not.toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /我的草稿/i }))
|
||||
@ -226,7 +298,7 @@ describe('HomeClient 删除入口', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/products')
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/products?formalizedOnly=true')
|
||||
})
|
||||
|
||||
expect(mockedDeleteProductRequest).toHaveBeenCalledTimes(1)
|
||||
@ -257,7 +329,7 @@ describe('HomeClient 删除入口', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/products')
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/products?formalizedOnly=true')
|
||||
})
|
||||
|
||||
expect(mockedDeleteSKURequest).toHaveBeenCalledTimes(1)
|
||||
@ -267,4 +339,23 @@ describe('HomeClient 删除入口', () => {
|
||||
)
|
||||
expect(toastApi.success).toHaveBeenCalledWith('SKU BI012404-A 已移到回收站')
|
||||
})
|
||||
|
||||
it('分页加载时应继续只请求已入库产品口径', async () => {
|
||||
render(
|
||||
<HomeClient
|
||||
products={[mockProduct]}
|
||||
initialHasMore
|
||||
myDraftGroups={[mockDraftGroup]}
|
||||
user={mockUser}
|
||||
/>,
|
||||
)
|
||||
|
||||
triggerIntersection()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/products?formalizedOnly=true&page=2&limit=20')
|
||||
})
|
||||
|
||||
expect(await screen.findByText('BI012405')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import HomePage from '../page'
|
||||
import { productService } from '@/server/services/product.service'
|
||||
|
||||
const mockedHomeClient = jest.fn((props: unknown) => <div>HomeClient</div>)
|
||||
|
||||
@ -107,15 +108,30 @@ jest.mock('@/server/services/draft-group.service', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const mockedGetProducts = productService.getProducts as jest.MockedFunction<typeof productService.getProducts>
|
||||
|
||||
describe('首页工作台数据注入', () => {
|
||||
beforeEach(() => {
|
||||
mockedHomeClient.mockClear()
|
||||
mockedGetProducts.mockClear()
|
||||
})
|
||||
|
||||
it('应只把当前账号的草稿和正式产品注入首页工作台', async () => {
|
||||
render(await HomePage())
|
||||
|
||||
expect(screen.getByText('HomeClient')).toBeInTheDocument()
|
||||
expect(mockedGetProducts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
formalizedOnly: true,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
user: expect.objectContaining({
|
||||
id: 'user-1',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(mockedHomeClient).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
products: [],
|
||||
|
||||
@ -2,7 +2,10 @@ import { NextRequest } from 'next/server'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { assetCenterService } from '@/server/services'
|
||||
import { substrateDictionaryUpdateSchema } from '../../../platform/schemas'
|
||||
import {
|
||||
substrateDictionaryTypeSchema,
|
||||
substrateDictionaryUpdateSchema,
|
||||
} from '../schemas'
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ id: string }>
|
||||
@ -44,19 +47,14 @@ export async function DELETE(request: NextRequest, context: RouteParams) {
|
||||
request,
|
||||
async (ctx) => {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const type = searchParams.get('type')
|
||||
if (
|
||||
type !== 'CATEGORY' &&
|
||||
type !== 'ENVIRONMENT_GRADE' &&
|
||||
type !== 'THICKNESS_SPEC' &&
|
||||
type !== 'SURFACE_OPTION'
|
||||
) {
|
||||
const typeResult = substrateDictionaryTypeSchema.safeParse(searchParams.get('type'))
|
||||
if (!typeResult.success) {
|
||||
return jsonError(ErrorCode.VALIDATION_FAILED, '缺少有效的字典类型', 400, ctx.requestId)
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await context.params
|
||||
await assetCenterService.deleteSubstrateDictionary(type, id)
|
||||
await assetCenterService.deleteSubstrateDictionary(typeResult.data, id)
|
||||
return jsonSuccess({ success: true }, 200, ctx.requestId)
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
|
||||
@ -4,7 +4,7 @@ import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { assetCenterService } from '@/server/services'
|
||||
import {
|
||||
substrateDictionaryCreateSchema,
|
||||
} from '../../platform/schemas'
|
||||
} from './schemas'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const substrateDictionaryTypeSchema = z.enum([
|
||||
'CATEGORY',
|
||||
'ENVIRONMENT_GRADE',
|
||||
'THICKNESS_SPEC',
|
||||
'SURFACE_OPTION',
|
||||
'SYSTEM_NAME_OPTION',
|
||||
])
|
||||
|
||||
export const substrateDictionaryCreateSchema = z.object({
|
||||
type: substrateDictionaryTypeSchema,
|
||||
name: z.string().trim().min(1),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
thicknessMm: z.number().positive().optional().nullable(),
|
||||
sheetLengthMm: z.number().positive().optional().nullable(),
|
||||
sheetWidthMm: z.number().positive().optional().nullable(),
|
||||
})
|
||||
|
||||
export const substrateDictionaryUpdateSchema = z.object({
|
||||
type: substrateDictionaryTypeSchema,
|
||||
name: z.string().trim().min(1).optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
thicknessMm: z.number().positive().optional().nullable(),
|
||||
sheetLengthMm: z.number().positive().optional().nullable(),
|
||||
sheetWidthMm: z.number().positive().optional().nullable(),
|
||||
})
|
||||
@ -2,7 +2,7 @@ import { NextRequest } from 'next/server'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { assetCenterService } from '@/server/services'
|
||||
import { substrateUpdateSchema } from '../../../platform/schemas'
|
||||
import { substrateUpdateSchema } from '../schemas'
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
|
||||
@ -2,7 +2,7 @@ import { NextRequest } from 'next/server'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { assetCenterService } from '@/server/services'
|
||||
import { substrateCreateSchema } from '../../platform/schemas'
|
||||
import { substrateCreateSchema } from './schemas'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const substrateCreateSchema = z.object({
|
||||
code: z.string().trim().min(1).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
systemNameOptionId: z.string().trim().min(1).optional().nullable(),
|
||||
categoryId: z.string().trim().min(1),
|
||||
environmentGradeId: z.string().trim().min(1),
|
||||
thicknessSpecId: z.string().trim().min(1),
|
||||
surfaceOptionId: z.string().trim().min(1).optional().nullable(),
|
||||
suggestedPrice: z.number().nonnegative().optional().nullable(),
|
||||
weightKg: z.number().nonnegative().optional().nullable(),
|
||||
finishIds: z.array(z.string().trim().min(1)).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
})
|
||||
|
||||
export const substrateUpdateSchema = z.object({
|
||||
code: z.string().trim().min(1).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
systemNameOptionId: z.string().trim().min(1).optional().nullable(),
|
||||
categoryId: z.string().trim().min(1).optional(),
|
||||
environmentGradeId: z.string().trim().min(1).optional(),
|
||||
thicknessSpecId: z.string().trim().min(1).optional(),
|
||||
surfaceOptionId: z.string().trim().min(1).optional().nullable(),
|
||||
suggestedPrice: z.number().nonnegative().optional().nullable(),
|
||||
weightKg: z.number().nonnegative().optional().nullable(),
|
||||
finishIds: z.array(z.string().trim().min(1)).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
107
furniture-3d-system/src/app/api/products/route.test.ts
Normal file
107
furniture-3d-system/src/app/api/products/route.test.ts
Normal file
@ -0,0 +1,107 @@
|
||||
export {}
|
||||
|
||||
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
||||
|
||||
const mockHandleRequest = jest.fn()
|
||||
const mockJsonSuccess = jest.fn()
|
||||
const mockGetPaginationFromParams = jest.fn()
|
||||
const mockGetProducts = jest.fn<(...args: unknown[]) => Promise<unknown>>()
|
||||
const mockSetUserId = jest.fn()
|
||||
const mockComplete = jest.fn()
|
||||
const mockTimeAsync = jest.fn<
|
||||
(metric: string, fn: () => Promise<unknown>) => Promise<{ result: unknown; duration: number }>
|
||||
>()
|
||||
|
||||
jest.mock('@/server/core/response', () => ({
|
||||
handleRequest: mockHandleRequest,
|
||||
jsonSuccess: mockJsonSuccess,
|
||||
getPaginationFromParams: mockGetPaginationFromParams,
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/product.service', () => ({
|
||||
productService: {
|
||||
getProducts: mockGetProducts,
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/server/middleware/request-logger', () => ({
|
||||
createRequestContext: () => ({
|
||||
setUserId: mockSetUserId,
|
||||
complete: mockComplete,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/server/monitoring/performance', () => ({
|
||||
timeAsync: mockTimeAsync,
|
||||
}))
|
||||
|
||||
const { GET } = require('./route')
|
||||
|
||||
describe('GET /api/products', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) =>
|
||||
handler({
|
||||
requestId: 'req-products',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
role: 'SUPER_ADMIN',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
mockJsonSuccess.mockImplementation((data, status = 200, requestId = 'req-products') => ({
|
||||
success: true,
|
||||
data,
|
||||
status,
|
||||
requestId,
|
||||
}))
|
||||
|
||||
mockGetPaginationFromParams.mockReturnValue({ page: 2, limit: 40 })
|
||||
mockTimeAsync.mockImplementation(async (_metric, fn) => ({
|
||||
result: await fn(),
|
||||
duration: 0,
|
||||
}))
|
||||
mockGetProducts.mockResolvedValue({
|
||||
items: [{ id: 'product-1' }],
|
||||
pagination: { hasMore: false },
|
||||
} as const)
|
||||
})
|
||||
|
||||
it('应把 formalizedOnly 查询参数透传给产品列表服务', async () => {
|
||||
const response = await GET({
|
||||
url: 'http://localhost/api/products?search=桌&category=cat-1&formalizedOnly=true&sortField=name&sortOrder=asc',
|
||||
} as any)
|
||||
|
||||
expect(mockSetUserId).toHaveBeenCalledWith('user-1')
|
||||
expect(mockGetProducts).toHaveBeenCalledWith(
|
||||
{
|
||||
pagination: { page: 2, limit: 40 },
|
||||
sort: { field: 'name', order: 'asc' },
|
||||
filter: {
|
||||
search: '桌',
|
||||
category: 'cat-1',
|
||||
formalizedOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
user: {
|
||||
id: 'user-1',
|
||||
role: 'SUPER_ADMIN',
|
||||
},
|
||||
requestId: 'req-products',
|
||||
},
|
||||
)
|
||||
expect(mockComplete).toHaveBeenCalledWith(200)
|
||||
expect(response).toEqual(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
data: {
|
||||
items: [{ id: 'product-1' }],
|
||||
pagination: { hasMore: false },
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -34,6 +34,7 @@ export async function GET(request: NextRequest) {
|
||||
const { page, limit } = getPaginationFromParams(searchParams)
|
||||
const search = searchParams.get('search') || undefined
|
||||
const category = searchParams.get('category') || undefined
|
||||
const formalizedOnly = searchParams.get('formalizedOnly') === 'true' ? true : undefined
|
||||
const sortField = (searchParams.get('sortField') as 'createdAt' | 'updatedAt' | 'name' | 'modelNumber') || 'createdAt'
|
||||
const sortOrder = (searchParams.get('sortOrder') as 'asc' | 'desc') || 'desc'
|
||||
|
||||
@ -43,7 +44,7 @@ export async function GET(request: NextRequest) {
|
||||
{
|
||||
pagination: { page, limit },
|
||||
sort: { field: sortField, order: sortOrder },
|
||||
filter: { search, category },
|
||||
filter: { search, category, formalizedOnly },
|
||||
},
|
||||
{ user: reqCtx.user, requestId: reqCtx.requestId }
|
||||
)
|
||||
|
||||
@ -9,7 +9,10 @@ import type {
|
||||
SeriesTemplateDTO,
|
||||
SubstrateDTO,
|
||||
} from '@/server/services'
|
||||
import { regenerateAssetVariantThumbnail } from '@/lib/open-product/platform-api'
|
||||
import {
|
||||
regenerateAssetVariantThumbnail,
|
||||
updateAssetVariant,
|
||||
} from '@/lib/open-product/platform-api'
|
||||
|
||||
jest.mock('next/link', () => ({
|
||||
__esModule: true,
|
||||
@ -67,11 +70,21 @@ jest.mock('@/components/platform/ModelPreviewPanel', () => ({
|
||||
__esModule: true,
|
||||
ModelPreviewPanel: ({
|
||||
titleAside,
|
||||
materialAssignments,
|
||||
onModelNodesChange,
|
||||
}: {
|
||||
titleAside?: ReactNode
|
||||
materialAssignments?: Array<{ material?: { textureRepeat?: number } | null }>
|
||||
onModelNodesChange?: (nodeNames: string[]) => void
|
||||
}) => (
|
||||
<div>
|
||||
<div>ModelPreviewPanel</div>
|
||||
<div data-testid="preview-material-repeat">
|
||||
{String(materialAssignments?.[0]?.material?.textureRepeat ?? 'none')}
|
||||
</div>
|
||||
<button type="button" onClick={() => onModelNodesChange?.(['panel-shell'])}>
|
||||
同步预览节点
|
||||
</button>
|
||||
<div>{titleAside}</div>
|
||||
</div>
|
||||
),
|
||||
@ -123,6 +136,8 @@ const mockedRegenerateAssetVariantThumbnail =
|
||||
regenerateAssetVariantThumbnail as jest.MockedFunction<
|
||||
typeof regenerateAssetVariantThumbnail
|
||||
>
|
||||
const mockedUpdateAssetVariant =
|
||||
updateAssetVariant as jest.MockedFunction<typeof updateAssetVariant>
|
||||
|
||||
function createFamily(overrides?: Partial<AssetFamilyDTO>): AssetFamilyDTO {
|
||||
return {
|
||||
@ -180,6 +195,7 @@ const seriesTemplates = [
|
||||
describe('AssetCenterClient', () => {
|
||||
beforeEach(() => {
|
||||
mockedRegenerateAssetVariantThumbnail.mockReset()
|
||||
mockedUpdateAssetVariant.mockReset()
|
||||
})
|
||||
|
||||
it('草稿资产在列表里不显示草稿状态,但仍保留提审操作', () => {
|
||||
@ -544,4 +560,256 @@ describe('AssetCenterClient', () => {
|
||||
'variant-board-thumb',
|
||||
)
|
||||
})
|
||||
|
||||
it('UV 保存会通过 uvConfig 字段提交,并沿用同一份 uvConfig 回显', async () => {
|
||||
const actor = userEvent.setup()
|
||||
mockedUpdateAssetVariant.mockResolvedValue({
|
||||
id: 'variant-board-uv',
|
||||
assetFamilyId: 'family-board-uv',
|
||||
assetFamilyCode: 'BOARD-030',
|
||||
assetFamilyName: 'UV 桌面板',
|
||||
assetType: 'BOARD',
|
||||
code: 'BOARD-030-A',
|
||||
name: 'UV 桌面板 A',
|
||||
identityKey: 'board-030-a',
|
||||
status: 'AVAILABLE',
|
||||
sizeLabel: '1200x600x18',
|
||||
substrateId: 'substrate-1',
|
||||
substrateCode: 'PB18',
|
||||
substrateName: '18mm 颗粒板',
|
||||
finishId: 'finish-1',
|
||||
finishName: '白橡木纹',
|
||||
dimensions: { length: 1200, width: 600, thickness: 18 },
|
||||
modelUrl: '/models/board-uv.glb',
|
||||
thumbnailUrl: null,
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
assemblyBasis: { origin: 'center' },
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData: {
|
||||
renderConfig: {
|
||||
uv: {
|
||||
version: 1,
|
||||
nodes: {
|
||||
__default__: {
|
||||
textureRepeat: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
lossFactor: 1,
|
||||
estimatedCostPrice: 88,
|
||||
inheritedFromVariantId: null,
|
||||
createdBy: 'user-1',
|
||||
creatorName: 'Test User',
|
||||
workflow: {
|
||||
submittedAt: null,
|
||||
submittedById: null,
|
||||
submittedByName: null,
|
||||
reviewedAt: null,
|
||||
reviewedById: null,
|
||||
reviewedByName: null,
|
||||
rejectedAt: null,
|
||||
rejectedById: null,
|
||||
rejectedByName: null,
|
||||
rejectionReason: null,
|
||||
lastModifiedById: null,
|
||||
lastModifiedByName: null,
|
||||
},
|
||||
createdAt: new Date('2026-04-03T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-03T10:00:00.000Z'),
|
||||
})
|
||||
mockedRegenerateAssetVariantThumbnail.mockResolvedValue({
|
||||
id: 'variant-board-uv',
|
||||
assetFamilyId: 'family-board-uv',
|
||||
assetFamilyCode: 'BOARD-030',
|
||||
assetFamilyName: 'UV 桌面板',
|
||||
assetType: 'BOARD',
|
||||
code: 'BOARD-030-A',
|
||||
name: 'UV 桌面板 A',
|
||||
identityKey: 'board-030-a',
|
||||
status: 'AVAILABLE',
|
||||
sizeLabel: '1200x600x18',
|
||||
substrateId: 'substrate-1',
|
||||
substrateCode: 'PB18',
|
||||
substrateName: '18mm 颗粒板',
|
||||
finishId: 'finish-1',
|
||||
finishName: '白橡木纹',
|
||||
dimensions: { length: 1200, width: 600, thickness: 18 },
|
||||
modelUrl: '/models/board-uv.glb',
|
||||
thumbnailUrl: '/asset-center/thumbnails/board-uv.png',
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
assemblyBasis: { origin: 'center' },
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData: {
|
||||
renderConfig: {
|
||||
uv: {
|
||||
version: 1,
|
||||
nodes: {
|
||||
__default__: {
|
||||
textureRepeat: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
lossFactor: 1,
|
||||
estimatedCostPrice: 88,
|
||||
inheritedFromVariantId: null,
|
||||
createdBy: 'user-1',
|
||||
creatorName: 'Test User',
|
||||
workflow: {
|
||||
submittedAt: null,
|
||||
submittedById: null,
|
||||
submittedByName: null,
|
||||
reviewedAt: null,
|
||||
reviewedById: null,
|
||||
reviewedByName: null,
|
||||
rejectedAt: null,
|
||||
rejectedById: null,
|
||||
rejectedByName: null,
|
||||
rejectionReason: null,
|
||||
lastModifiedById: null,
|
||||
lastModifiedByName: null,
|
||||
},
|
||||
createdAt: new Date('2026-04-03T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-03T10:00:00.000Z'),
|
||||
})
|
||||
|
||||
render(
|
||||
<AssetCenterClient
|
||||
initialFamilies={[
|
||||
createFamily({
|
||||
id: 'family-board-uv',
|
||||
type: 'BOARD',
|
||||
code: 'BOARD-030',
|
||||
name: 'UV 桌面板',
|
||||
applicableSeriesCodes: ['single-board-desk'],
|
||||
status: 'AVAILABLE',
|
||||
}),
|
||||
]}
|
||||
initialVariants={[
|
||||
{
|
||||
id: 'variant-board-uv',
|
||||
assetFamilyId: 'family-board-uv',
|
||||
assetFamilyCode: 'BOARD-030',
|
||||
assetFamilyName: 'UV 桌面板',
|
||||
assetType: 'BOARD',
|
||||
code: 'BOARD-030-A',
|
||||
name: 'UV 桌面板 A',
|
||||
identityKey: 'board-030-a',
|
||||
status: 'AVAILABLE',
|
||||
sizeLabel: '1200x600x18',
|
||||
substrateId: 'substrate-1',
|
||||
substrateCode: 'PB18',
|
||||
substrateName: '18mm 颗粒板',
|
||||
finishId: 'finish-1',
|
||||
finishName: '白橡木纹',
|
||||
dimensions: { length: 1200, width: 600, thickness: 18 },
|
||||
modelUrl: '/models/board-uv.glb',
|
||||
thumbnailUrl: null,
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
assemblyBasis: { origin: 'center' },
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData: null,
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
lossFactor: 1,
|
||||
estimatedCostPrice: 88,
|
||||
inheritedFromVariantId: null,
|
||||
createdBy: 'user-1',
|
||||
creatorName: 'Test User',
|
||||
workflow: {
|
||||
submittedAt: null,
|
||||
submittedById: null,
|
||||
submittedByName: null,
|
||||
reviewedAt: null,
|
||||
reviewedById: null,
|
||||
reviewedByName: null,
|
||||
rejectedAt: null,
|
||||
rejectedById: null,
|
||||
rejectedByName: null,
|
||||
rejectionReason: null,
|
||||
lastModifiedById: null,
|
||||
lastModifiedByName: null,
|
||||
},
|
||||
createdAt: new Date('2026-04-03T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-03T10:00:00.000Z'),
|
||||
},
|
||||
]}
|
||||
initialSubstrates={[]}
|
||||
initialSeriesTemplates={seriesTemplates}
|
||||
finishes={[
|
||||
{
|
||||
id: 'finish-1',
|
||||
name: '白橡木纹',
|
||||
code: 'FINISH-OAK-WHITE',
|
||||
categoryId: 'cat-finish',
|
||||
categoryName: '饰面',
|
||||
thumbnailUrl: '/finish-oak.png',
|
||||
textureUrl: '/finish-oak.jpg',
|
||||
normalMapUrl: null,
|
||||
roughnessMapUrl: null,
|
||||
metalnessMapUrl: null,
|
||||
aoMapUrl: null,
|
||||
color: '#ffffff',
|
||||
metalness: 0,
|
||||
roughness: 1,
|
||||
opacity: 1,
|
||||
transmission: null,
|
||||
ior: null,
|
||||
thickness: null,
|
||||
clearcoat: null,
|
||||
clearcoatRoughness: null,
|
||||
envMapIntensity: null,
|
||||
textureRepeat: 1,
|
||||
textureRotation: 0,
|
||||
textureSizeMm: 600,
|
||||
isPreset: true,
|
||||
isActive: true,
|
||||
} as unknown as MaterialDTO,
|
||||
]}
|
||||
materialCategories={[]}
|
||||
metrics={[]}
|
||||
user={user}
|
||||
/>,
|
||||
)
|
||||
|
||||
await actor.click(screen.getByText('UV 桌面板'))
|
||||
await actor.click(screen.getByText(/UV 桌面板 A/))
|
||||
await actor.click(screen.getByRole('button', { name: '同步预览节点' }))
|
||||
|
||||
expect(screen.getByTestId('preview-material-repeat')).toHaveTextContent('1')
|
||||
|
||||
await actor.clear(screen.getByLabelText('纹理重复'))
|
||||
await actor.type(screen.getByLabelText('纹理重复'), '2')
|
||||
await actor.click(screen.getByRole('button', { name: '保存 UV' }))
|
||||
|
||||
expect(mockedUpdateAssetVariant).toHaveBeenCalledWith(
|
||||
'variant-board-uv',
|
||||
expect.objectContaining({
|
||||
businessData: {
|
||||
renderConfig: {
|
||||
uv: {
|
||||
version: 1,
|
||||
nodes: {
|
||||
'panel-shell': {
|
||||
textureRepeat: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(mockedRegenerateAssetVariantThumbnail).toHaveBeenCalledWith('variant-board-uv')
|
||||
expect(screen.getByTestId('preview-material-repeat')).toHaveTextContent('2')
|
||||
})
|
||||
})
|
||||
|
||||
@ -37,8 +37,16 @@ import { useToast, ToastContainer } from '@/components/ui/Toast'
|
||||
import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel'
|
||||
import { PlatformSplitLayout } from '@/components/platform/PlatformSplitLayout'
|
||||
import type { PlatformMetricItem } from '@/components/platform/types'
|
||||
import {
|
||||
applyAssetVariantUvConfigToMaterial,
|
||||
getAssetVariantUvSurfaceConfig,
|
||||
normalizeAssetVariantUvConfig,
|
||||
updateAssetVariantUvConfig,
|
||||
type AssetVariantUvSurfaceConfig,
|
||||
} from '@/lib/asset-variant-render-config'
|
||||
import { inferCadImportDefaults } from '@/lib/asset-cad-import-defaults'
|
||||
import type { NodePositionOffset, NodeRotationState } from '@/lib/3d/engine'
|
||||
import { toMaterialConfig } from '@/lib/material-config'
|
||||
import {
|
||||
buildConnectionRoleMetadata,
|
||||
createCustomConnectionRoleValue,
|
||||
@ -49,6 +57,11 @@ import {
|
||||
type ConnectionRoleDefinition,
|
||||
} from '@/lib/open-product/connection-role-catalog'
|
||||
import { buildManualAssemblyLaunchHref } from '@/lib/open-product/manual-assembly-launch'
|
||||
import {
|
||||
normalizeAssetVariantWithUv,
|
||||
normalizeAssetVariantsWithUv,
|
||||
writeAssetVariantUvConfigToVariantBusinessData,
|
||||
} from '@/lib/open-product/asset-variant-uv'
|
||||
import { calculateEstimatedCost } from '@/lib/open-product/substrate-rules'
|
||||
import { getLevel1FromFamily } from '@/lib/packaging-materials/catalog'
|
||||
import { cn } from '@/lib/utils'
|
||||
@ -107,12 +120,64 @@ type PreviewNodeTransformState = {
|
||||
rotations: PreviewNodeRotationMap
|
||||
}
|
||||
|
||||
type VariantUvEditorFormState = {
|
||||
textureRepeat: string
|
||||
textureRotation: string
|
||||
textureSizeMm: string
|
||||
}
|
||||
|
||||
// ---- 工具函数 ----
|
||||
|
||||
function sortByName<T extends { name: string }>(items: T[]): T[] {
|
||||
return [...items].sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
|
||||
}
|
||||
|
||||
function formatUvNumber(value: number | null | undefined) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? String(value) : ''
|
||||
}
|
||||
|
||||
function createVariantUvEditorFormState(
|
||||
config: AssetVariantUvSurfaceConfig | null | undefined,
|
||||
): VariantUvEditorFormState {
|
||||
return {
|
||||
textureRepeat: formatUvNumber(config?.textureRepeat),
|
||||
textureRotation: formatUvNumber(config?.textureRotation),
|
||||
textureSizeMm: formatUvNumber(config?.textureSizeMm),
|
||||
}
|
||||
}
|
||||
|
||||
function parseVariantUvEditorFormState(
|
||||
state: VariantUvEditorFormState,
|
||||
): AssetVariantUvSurfaceConfig | null {
|
||||
const parseField = (value: string) => {
|
||||
const normalized = value.trim()
|
||||
if (!normalized) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const parsed = Number(normalized)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
const textureRepeat = parseField(state.textureRepeat)
|
||||
const textureRotation = parseField(state.textureRotation)
|
||||
const textureSizeMm = parseField(state.textureSizeMm)
|
||||
|
||||
if (
|
||||
textureRepeat === undefined &&
|
||||
textureRotation === undefined &&
|
||||
textureSizeMm === undefined
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...(textureRepeat !== undefined ? { textureRepeat } : {}),
|
||||
...(textureRotation !== undefined ? { textureRotation } : {}),
|
||||
...(textureSizeMm !== undefined ? { textureSizeMm } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function sortMaterialCategories(items: MaterialCategoryDTO[]): MaterialCategoryDTO[] {
|
||||
return [...items].sort((left, right) => {
|
||||
const orderDiff = (left.sortOrder ?? 0) - (right.sortOrder ?? 0)
|
||||
@ -609,7 +674,7 @@ export default function AssetCenterClient({
|
||||
|
||||
// 数据状态
|
||||
const [families, setFamilies] = useState(sortByName(initialFamilies))
|
||||
const [variants, setVariants] = useState(initialVariants)
|
||||
const [variants, setVariants] = useState(() => normalizeAssetVariantsWithUv(initialVariants))
|
||||
const [substrates, setSubstrates] = useState(sortByName(initialSubstrates))
|
||||
const [finishes, setFinishes] = useState(initialFinishes)
|
||||
const [materialCategories, setMaterialCategories] = useState(
|
||||
@ -701,6 +766,9 @@ export default function AssetCenterClient({
|
||||
const previewNodeRotationMapRef = useRef<PreviewNodeRotationMap>({})
|
||||
const previewTransformBaselineRef = useRef<PreviewNodeTransformState | null>(null)
|
||||
const previewTransformNodeNameRef = useRef<string | null>(null)
|
||||
const [variantUvEditorForm, setVariantUvEditorForm] = useState<VariantUvEditorFormState>(
|
||||
createVariantUvEditorFormState(null),
|
||||
)
|
||||
|
||||
// ---- 派生数据 ----
|
||||
|
||||
@ -731,13 +799,56 @@ export default function AssetCenterClient({
|
||||
() => finishes.find((finish) => finish.id === selectedVariant?.finishId) ?? null,
|
||||
[finishes, selectedVariant?.finishId],
|
||||
)
|
||||
const selectedFinishMaterialConfig = useMemo(
|
||||
() => toMaterialConfig(selectedFinish),
|
||||
[selectedFinish],
|
||||
)
|
||||
const activeVariantUvNodeName = useMemo(() => {
|
||||
if (selectedPreviewNodeName && previewNodeNames.includes(selectedPreviewNodeName)) {
|
||||
return selectedPreviewNodeName
|
||||
}
|
||||
|
||||
return null
|
||||
}, [previewNodeNames, selectedPreviewNodeName])
|
||||
const activeVariantUvNodeLabel = activeVariantUvNodeName ?? '默认全部节点'
|
||||
const savedVariantUvEditorForm = useMemo(
|
||||
() =>
|
||||
createVariantUvEditorFormState(
|
||||
getAssetVariantUvSurfaceConfig(selectedVariant?.uvConfig, {
|
||||
nodeName: activeVariantUvNodeName,
|
||||
}),
|
||||
),
|
||||
[activeVariantUvNodeName, selectedVariant?.id, selectedVariant?.uvConfig],
|
||||
)
|
||||
const draftVariantUvConfig = useMemo(() => {
|
||||
if (!selectedVariant) {
|
||||
return null
|
||||
}
|
||||
|
||||
return updateAssetVariantUvConfig(
|
||||
selectedVariant.uvConfig,
|
||||
{ nodeName: activeVariantUvNodeName },
|
||||
() => parseVariantUvEditorFormState(variantUvEditorForm),
|
||||
)
|
||||
}, [activeVariantUvNodeName, selectedVariant, variantUvEditorForm])
|
||||
const variantUvConfigDirty = useMemo(
|
||||
() =>
|
||||
JSON.stringify(normalizeAssetVariantUvConfig(draftVariantUvConfig)) !==
|
||||
JSON.stringify(normalizeAssetVariantUvConfig(selectedVariant?.uvConfig ?? null)),
|
||||
[draftVariantUvConfig, selectedVariant?.uvConfig],
|
||||
)
|
||||
const previewMaterialAssignments = useMemo(() => {
|
||||
if (!selectedFinish || previewNodeNames.length === 0) return []
|
||||
if (!selectedFinishMaterialConfig || previewNodeNames.length === 0) return []
|
||||
return previewNodeNames.map((nodeName) => ({
|
||||
nodeName,
|
||||
material: selectedFinish,
|
||||
material:
|
||||
applyAssetVariantUvConfigToMaterial(
|
||||
selectedFinishMaterialConfig,
|
||||
draftVariantUvConfig,
|
||||
{ nodeName },
|
||||
) ?? selectedFinishMaterialConfig,
|
||||
}))
|
||||
}, [previewNodeNames, selectedFinish])
|
||||
}, [draftVariantUvConfig, previewNodeNames, selectedFinishMaterialConfig])
|
||||
const previewNodeOffsets = useMemo<NodePositionOffset[]>(
|
||||
() =>
|
||||
Object.entries(previewNodeOffsetMap).map(([nodeName, offset]) => ({
|
||||
@ -763,6 +874,10 @@ export default function AssetCenterClient({
|
||||
previewNodeRotationMapRef.current = previewNodeRotationMap
|
||||
}, [previewNodeRotationMap])
|
||||
|
||||
useEffect(() => {
|
||||
setVariantUvEditorForm(savedVariantUvEditorForm)
|
||||
}, [savedVariantUvEditorForm])
|
||||
|
||||
const variantsByFamilyId = useMemo(() => {
|
||||
const map = new Map<string, AssetVariantDTO[]>()
|
||||
variants.forEach((variant) => {
|
||||
@ -1348,7 +1463,7 @@ export default function AssetCenterClient({
|
||||
lossFactor: toOptionalNumber(variantForm.lossFactor),
|
||||
inheritedFromVariantId: variantForm.inheritedFromVariantId || null,
|
||||
})
|
||||
const created = createdResult.variant
|
||||
const created = normalizeAssetVariantWithUv(createdResult.variant)
|
||||
|
||||
startTransition(() => {
|
||||
setVariants((current) => [created, ...current])
|
||||
@ -1374,7 +1489,7 @@ export default function AssetCenterClient({
|
||||
}
|
||||
} else {
|
||||
if (!editingVariantId) return
|
||||
const updated = await updateAssetVariant(editingVariantId, {
|
||||
const updated = normalizeAssetVariantWithUv(await updateAssetVariant(editingVariantId, {
|
||||
code: variantForm.code,
|
||||
name: variantForm.name,
|
||||
sizeLabel: variantForm.sizeLabel || null,
|
||||
@ -1393,7 +1508,7 @@ export default function AssetCenterClient({
|
||||
},
|
||||
],
|
||||
lossFactor: toOptionalNumber(variantForm.lossFactor),
|
||||
})
|
||||
}))
|
||||
startTransition(() => {
|
||||
setVariants((current) =>
|
||||
current.map((v) => (v.id === editingVariantId ? updated : v)),
|
||||
@ -1469,11 +1584,11 @@ export default function AssetCenterClient({
|
||||
): Promise<boolean> {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const updated = await reviewAssetVariant(
|
||||
variant.id,
|
||||
action,
|
||||
reason ? { reason } : undefined,
|
||||
)
|
||||
const updated = normalizeAssetVariantWithUv(await reviewAssetVariant(
|
||||
variant.id,
|
||||
action,
|
||||
reason ? { reason } : undefined,
|
||||
))
|
||||
startTransition(() => {
|
||||
setVariants((current) =>
|
||||
current.map((item) => (item.id === updated.id ? updated : item)),
|
||||
@ -1543,7 +1658,7 @@ export default function AssetCenterClient({
|
||||
if (!selectedVariantId) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const updated = await updateAssetVariant(selectedVariantId, input)
|
||||
const updated = normalizeAssetVariantWithUv(await updateAssetVariant(selectedVariantId, input))
|
||||
startTransition(() => {
|
||||
setVariants((current) => current.map((variant) => (
|
||||
variant.id === selectedVariantId ? updated : variant
|
||||
@ -1569,7 +1684,7 @@ export default function AssetCenterClient({
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const updated = await regenerateAssetVariantThumbnail(selectedVariant.id)
|
||||
const updated = normalizeAssetVariantWithUv(await regenerateAssetVariantThumbnail(selectedVariant.id))
|
||||
startTransition(() => {
|
||||
setVariants((current) =>
|
||||
current.map((variant) => (variant.id === updated.id ? updated : variant)),
|
||||
@ -1583,6 +1698,70 @@ export default function AssetCenterClient({
|
||||
}
|
||||
}, [selectedVariant, toast])
|
||||
|
||||
const handleResetVariantUvEditor = useCallback(() => {
|
||||
setVariantUvEditorForm(savedVariantUvEditorForm)
|
||||
}, [savedVariantUvEditorForm])
|
||||
|
||||
const handleClearVariantUvEditor = useCallback(() => {
|
||||
setVariantUvEditorForm(createVariantUvEditorFormState(null))
|
||||
}, [])
|
||||
|
||||
const handleSaveVariantUvConfig = useCallback(async () => {
|
||||
if (!selectedVariant) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const nextUvConfig = normalizeAssetVariantUvConfig(draftVariantUvConfig)
|
||||
const businessData = writeAssetVariantUvConfigToVariantBusinessData(
|
||||
selectedVariant,
|
||||
nextUvConfig,
|
||||
)
|
||||
const savedVariant = normalizeAssetVariantWithUv(
|
||||
await updateAssetVariant(selectedVariant.id, {
|
||||
businessData,
|
||||
}),
|
||||
)
|
||||
let updated = savedVariant
|
||||
let thumbnailRegenerated = false
|
||||
|
||||
if (savedVariant.modelUrl) {
|
||||
try {
|
||||
updated = normalizeAssetVariantWithUv(
|
||||
await regenerateAssetVariantThumbnail(savedVariant.id),
|
||||
)
|
||||
thumbnailRegenerated = true
|
||||
} catch (thumbnailError) {
|
||||
updated = savedVariant
|
||||
toast.error(
|
||||
thumbnailError instanceof Error
|
||||
? `UV 已保存,但缩略图再生失败:${thumbnailError.message}`
|
||||
: 'UV 已保存,但缩略图再生失败,请稍后再试',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setVariants((current) =>
|
||||
current.map((variant) => (variant.id === updated.id ? updated : variant)),
|
||||
)
|
||||
})
|
||||
|
||||
toast.success(
|
||||
savedVariant.modelUrl
|
||||
? thumbnailRegenerated
|
||||
? 'UV 已保存,缩略图已同步更新'
|
||||
: 'UV 已保存'
|
||||
: 'UV 已保存',
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'UV 保存失败,请稍后再试')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [draftVariantUvConfig, selectedVariant, toast])
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewNodeNames([])
|
||||
setSelectedPreviewNodeName(null)
|
||||
@ -1864,7 +2043,7 @@ export default function AssetCenterClient({
|
||||
]),
|
||||
)
|
||||
setVariants((current) => [
|
||||
result.variant,
|
||||
normalizeAssetVariantWithUv(result.variant),
|
||||
...current.filter((variant) => variant.id !== result.variant.id),
|
||||
])
|
||||
setExpandedFamilyIds((prev) => new Set(prev).add(result.family.id))
|
||||
@ -2279,6 +2458,106 @@ export default function AssetCenterClient({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedVariant ? (
|
||||
<section className="rounded-apple-xl border border-apple-separator bg-apple-bg-primary/92 px-4 py-4 shadow-apple-xs">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-apple-semibold text-apple-label-primary">
|
||||
UV 收口
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-apple-label-secondary">
|
||||
当前规格款:{selectedVariant.name}
|
||||
</p>
|
||||
<p className="text-[11px] leading-5 text-apple-label-tertiary">
|
||||
当前节点:{activeVariantUvNodeLabel}
|
||||
{previewNodeNames.length > 0 && !activeVariantUvNodeName
|
||||
? ',未点选板件时默认收口到全部节点'
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<Badge type={variantUvConfigDirty ? 'warning' : 'default'} variant="subtle" size="small">
|
||||
{variantUvConfigDirty ? '待保存' : '已同步'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 grid-cols-3">
|
||||
<Input
|
||||
label="纹理重复"
|
||||
inputMode="decimal"
|
||||
value={variantUvEditorForm.textureRepeat}
|
||||
onChange={(event) =>
|
||||
setVariantUvEditorForm((current) => ({
|
||||
...current,
|
||||
textureRepeat: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="例如 1.2"
|
||||
size="small"
|
||||
/>
|
||||
<Input
|
||||
label="纹理旋转"
|
||||
inputMode="decimal"
|
||||
value={variantUvEditorForm.textureRotation}
|
||||
onChange={(event) =>
|
||||
setVariantUvEditorForm((current) => ({
|
||||
...current,
|
||||
textureRotation: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="例如 90"
|
||||
size="small"
|
||||
/>
|
||||
<Input
|
||||
label="纹理尺寸(mm)"
|
||||
inputMode="decimal"
|
||||
value={variantUvEditorForm.textureSizeMm}
|
||||
onChange={(event) =>
|
||||
setVariantUvEditorForm((current) => ({
|
||||
...current,
|
||||
textureSizeMm: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="例如 600"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="filled"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
void handleSaveVariantUvConfig()
|
||||
}}
|
||||
disabled={submitting || !variantUvConfigDirty}
|
||||
>
|
||||
保存 UV
|
||||
</Button>
|
||||
<Button
|
||||
variant="plain"
|
||||
size="small"
|
||||
onClick={handleResetVariantUvEditor}
|
||||
disabled={submitting || !variantUvConfigDirty}
|
||||
>
|
||||
还原
|
||||
</Button>
|
||||
<Button
|
||||
variant="plain"
|
||||
size="small"
|
||||
onClick={handleClearVariantUvEditor}
|
||||
disabled={submitting}
|
||||
>
|
||||
清空当前节点
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-[11px] leading-5 text-apple-label-tertiary">
|
||||
保存后会把 UV 写回当前规格款的业务数据,并立即回显;有模型时会同步触发缩略图再生。
|
||||
{!selectedFinishMaterialConfig ? ' 当前还没绑定饰面 / 花色,预览里暂时看不到贴图变化。' : ''}
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* 资产族列表 */}
|
||||
<div className="space-y-1">
|
||||
{activeTab === 'PACKAGING' && (
|
||||
|
||||
@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ReactNode } from 'react'
|
||||
import MaterialLibraryClient from './MaterialLibraryClient'
|
||||
import { createSubstrate } from '@/lib/open-product/platform-api'
|
||||
import { createMaterial, createSubstrate } from '@/lib/open-product/platform-api'
|
||||
import type { MaterialCategoryDTO, MaterialDTO, SubstrateDTO, SubstrateDictionaryBundleDTO } from '@/server/services'
|
||||
|
||||
jest.mock('next/link', () => ({
|
||||
@ -37,23 +37,39 @@ jest.mock('@/components/platform/PlatformSplitLayout', () => ({
|
||||
jest.mock('@/lib/open-product/platform-api', () => ({
|
||||
__esModule: true,
|
||||
createMaterial: jest.fn(),
|
||||
createMaterialCategory: jest.fn(),
|
||||
createSubstrate: jest.fn(),
|
||||
createSubstrateDictionary: jest.fn(),
|
||||
deleteMaterial: jest.fn(),
|
||||
deleteMaterialCategory: jest.fn(),
|
||||
deleteSubstrate: jest.fn(),
|
||||
deleteSubstrateDictionary: jest.fn(),
|
||||
updateMaterial: jest.fn(),
|
||||
updateMaterialCategory: jest.fn(),
|
||||
updateSubstrate: jest.fn(),
|
||||
updateSubstrateDictionary: jest.fn(),
|
||||
uploadMaterialAsset: jest.fn(),
|
||||
}))
|
||||
|
||||
const mockedCreateMaterial = jest.mocked(createMaterial)
|
||||
const mockedCreateSubstrate = jest.mocked(createSubstrate)
|
||||
|
||||
const materialCategories: MaterialCategoryDTO[] = [
|
||||
{
|
||||
id: 'category-wood',
|
||||
name: '木纹贴面花色',
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
materialCount: 0,
|
||||
},
|
||||
]
|
||||
|
||||
const dictionaries: SubstrateDictionaryBundleDTO = {
|
||||
categories: [{ id: 'cat-board', type: 'CATEGORY', name: '密度板', sortOrder: 0, isActive: true, createdAt: new Date(), updatedAt: new Date() }],
|
||||
environmentGrades: [{ id: 'grade-e1', type: 'ENVIRONMENT_GRADE', name: 'E1', sortOrder: 0, isActive: true, createdAt: new Date(), updatedAt: new Date() }],
|
||||
thicknessSpecs: [{ id: 'th-18', type: 'THICKNESS_SPEC', name: '18', sortOrder: 0, isActive: true, thicknessMm: 18, sheetLengthMm: 2440, sheetWidthMm: 1220, createdAt: new Date(), updatedAt: new Date() }],
|
||||
surfaceOptions: [{ id: 'surface-double', type: 'SURFACE_OPTION', name: '双贴', sortOrder: 0, isActive: true, createdAt: new Date(), updatedAt: new Date() }],
|
||||
systemNameOptions: [{ id: 'sys-board', type: 'SYSTEM_NAME_OPTION', name: '板材规则名', sortOrder: 0, isActive: true, createdAt: new Date(), updatedAt: new Date() }],
|
||||
}
|
||||
|
||||
function renderClient() {
|
||||
@ -64,6 +80,8 @@ function renderClient() {
|
||||
code: 'PB18',
|
||||
name: '18E1级刨花板',
|
||||
description: null,
|
||||
systemNameOptionId: 'sys-board',
|
||||
systemNameOptionName: '板材规则名',
|
||||
categoryId: 'cat-board',
|
||||
categoryName: '密度板',
|
||||
environmentGradeId: 'grade-e1',
|
||||
@ -84,7 +102,7 @@ function renderClient() {
|
||||
updatedAt: new Date(),
|
||||
}] as SubstrateDTO[]}
|
||||
initialFinishes={[] as MaterialDTO[]}
|
||||
initialMaterialCategories={[] as MaterialCategoryDTO[]}
|
||||
initialMaterialCategories={materialCategories}
|
||||
initialSubstrateDictionaries={dictionaries}
|
||||
metrics={[]}
|
||||
/>,
|
||||
@ -104,6 +122,8 @@ describe('MaterialLibraryClient', () => {
|
||||
code: 'PB25',
|
||||
name: '18E1级刨花板',
|
||||
description: null,
|
||||
systemNameOptionId: 'sys-board',
|
||||
systemNameOptionName: '板材规则名',
|
||||
categoryId: 'cat-board',
|
||||
categoryName: '密度板',
|
||||
environmentGradeId: 'grade-e1',
|
||||
@ -127,10 +147,9 @@ describe('MaterialLibraryClient', () => {
|
||||
|
||||
renderClient()
|
||||
|
||||
// Switch to substrate tab first
|
||||
await actor.click(screen.getByRole('button', { name: /基材录入/ }))
|
||||
await actor.click(screen.getByRole('button', { name: /新建/ }))
|
||||
await actor.type(screen.getByLabelText('售价'), '200')
|
||||
await actor.type(screen.getByLabelText('采购价'), '200')
|
||||
await actor.click(screen.getByRole('button', { name: '创建' }))
|
||||
|
||||
await waitFor(() => {
|
||||
@ -145,4 +164,55 @@ describe('MaterialLibraryClient', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('新建花色时会提交颜色纹理和 PBR 参数', async () => {
|
||||
const actor = userEvent.setup()
|
||||
mockedCreateMaterial.mockResolvedValue({
|
||||
id: 'finish-1',
|
||||
name: '北美橡木',
|
||||
code: 'HS-0001',
|
||||
categoryId: 'category-wood',
|
||||
categoryName: '木纹贴面花色',
|
||||
categoryIcon: null,
|
||||
thumbnailUrl: null,
|
||||
previewUrl: '/preview.png',
|
||||
textureUrl: '/texture.png',
|
||||
normalMapUrl: '/normal.png',
|
||||
color: '#d2b48c',
|
||||
metalness: 0.2,
|
||||
roughness: 0.6,
|
||||
opacity: 1,
|
||||
textureRepeat: 1.5,
|
||||
textureRotation: 0.1,
|
||||
textureSizeMm: 800,
|
||||
isPreset: false,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
renderClient()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: /饰面花色/ }))
|
||||
await actor.click(screen.getByRole('button', { name: /新建/ }))
|
||||
await actor.type(screen.getByLabelText('花色名称'), '北美橡木')
|
||||
await actor.clear(screen.getByLabelText('金属度'))
|
||||
await actor.type(screen.getByLabelText('金属度'), '0.2')
|
||||
await actor.clear(screen.getByLabelText('粗糙度'))
|
||||
await actor.type(screen.getByLabelText('粗糙度'), '0.6')
|
||||
await actor.clear(screen.getByLabelText('纹理实物宽度(mm)'))
|
||||
await actor.type(screen.getByLabelText('纹理实物宽度(mm)'), '800')
|
||||
await actor.click(screen.getByRole('button', { name: '创建' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedCreateMaterial).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: '北美橡木',
|
||||
categoryId: 'category-wood',
|
||||
color: '#cccccc',
|
||||
metalness: 0.2,
|
||||
roughness: 0.6,
|
||||
textureSizeMm: 800,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -274,4 +274,73 @@ describe('AssistedAssemblyClient', () => {
|
||||
expect(screen.getByText('首个规格款:variant-board-draft')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('源 SKU 新增进入时,只预选来源装配涉及的资产族,不会回退成全量默认选中', async () => {
|
||||
mockSearchParams = new URLSearchParams(
|
||||
'entryMode=source-sku&source=assisted-assembly&sourceSkuId=sku-source-1&desktopFamilyIds=family-board-source',
|
||||
)
|
||||
|
||||
render(
|
||||
<AssistedAssemblyClient
|
||||
families={[
|
||||
{
|
||||
id: 'family-board-source',
|
||||
type: 'BOARD',
|
||||
code: 'BOARD-SOURCE',
|
||||
name: '源桌面',
|
||||
status: 'AVAILABLE',
|
||||
productCategoryId: 'category-desk',
|
||||
productCategoryName: '书桌',
|
||||
} as never,
|
||||
{
|
||||
id: 'family-board-extra',
|
||||
type: 'BOARD',
|
||||
code: 'BOARD-EXTRA',
|
||||
name: '额外桌面',
|
||||
status: 'AVAILABLE',
|
||||
productCategoryId: 'category-desk',
|
||||
productCategoryName: '书桌',
|
||||
} as never,
|
||||
]}
|
||||
variants={[
|
||||
{
|
||||
id: 'variant-board-source',
|
||||
assetFamilyId: 'family-board-source',
|
||||
assetType: 'BOARD',
|
||||
code: 'BOARD-SOURCE-001',
|
||||
name: '源桌面规格款',
|
||||
status: 'AVAILABLE',
|
||||
modelUrl: '/board-source.glb',
|
||||
thumbnailUrl: null,
|
||||
dimensions: {},
|
||||
mountPoints: [],
|
||||
assemblyBasis: { anchor: 'desktop-center' },
|
||||
compatibilityRules: null,
|
||||
} as never,
|
||||
{
|
||||
id: 'variant-board-extra',
|
||||
assetFamilyId: 'family-board-extra',
|
||||
assetType: 'BOARD',
|
||||
code: 'BOARD-EXTRA-001',
|
||||
name: '额外桌面规格款',
|
||||
status: 'AVAILABLE',
|
||||
modelUrl: '/board-extra.glb',
|
||||
thumbnailUrl: null,
|
||||
dimensions: {},
|
||||
mountPoints: [],
|
||||
assemblyBasis: { anchor: 'desktop-center' },
|
||||
compatibilityRules: null,
|
||||
} as never,
|
||||
]}
|
||||
metrics={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('库存:1')).toBeInTheDocument()
|
||||
expect(screen.getByText('首个规格款:variant-board-source')).toBeInTheDocument()
|
||||
expect(screen.getByRole('checkbox', { name: /源桌面/ })).toBeChecked()
|
||||
expect(screen.getByRole('checkbox', { name: /额外桌面/ })).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -140,6 +140,21 @@ function buildDefaultSelectedIds(families: AssetFamilyDTO[]) {
|
||||
return families.map((family) => family.id)
|
||||
}
|
||||
|
||||
function buildSeedSelectedIds(
|
||||
families: AssetFamilyDTO[],
|
||||
preferredIds: string[],
|
||||
options: {
|
||||
respectPreferred: boolean
|
||||
},
|
||||
) {
|
||||
if (!options.respectPreferred) {
|
||||
return buildDefaultSelectedIds(families)
|
||||
}
|
||||
|
||||
const preferredIdSet = new Set(preferredIds)
|
||||
return families.filter((family) => preferredIdSet.has(family.id)).map((family) => family.id)
|
||||
}
|
||||
|
||||
function toOpenProductFamily(family: AssetFamilyDTO): OpenProductAssetFamily {
|
||||
return {
|
||||
id: family.id,
|
||||
@ -203,6 +218,7 @@ export default function AssistedAssemblyClient({
|
||||
)
|
||||
const launchParams = initialLaunchParams ?? searchLaunchParams
|
||||
const entryMode = launchParams?.entryMode ?? 'direct'
|
||||
const shouldRespectLaunchSelection = entryMode !== 'direct' && Boolean(launchParams)
|
||||
const pinnedFamilyIdSet = useMemo(
|
||||
() =>
|
||||
new Set([
|
||||
@ -326,6 +342,9 @@ export default function AssistedAssemblyClient({
|
||||
useEffect(() => {
|
||||
const seedKey = [
|
||||
entryMode,
|
||||
launchParams?.selectedFamilyIdsByType.desktopFamilyIds.join(',') ?? '',
|
||||
launchParams?.selectedFamilyIdsByType.legFamilyIds.join(',') ?? '',
|
||||
launchParams?.selectedFamilyIdsByType.connectorFamilyIds.join(',') ?? '',
|
||||
...desktopFamilies.map((family) => family.id),
|
||||
...legFamilies.map((family) => family.id),
|
||||
...connectorFamilies.map((family) => family.id),
|
||||
@ -336,10 +355,37 @@ export default function AssistedAssemblyClient({
|
||||
}
|
||||
|
||||
selectionSeedRef.current = seedKey
|
||||
setDesktopIds(buildDefaultSelectedIds(desktopFamilies))
|
||||
setLegIds(buildDefaultSelectedIds(legFamilies))
|
||||
setConnectorIds(buildDefaultSelectedIds(connectorFamilies))
|
||||
}, [connectorFamilies, desktopFamilies, entryMode, legFamilies])
|
||||
setDesktopIds(
|
||||
buildSeedSelectedIds(
|
||||
desktopFamilies,
|
||||
launchParams?.selectedFamilyIdsByType.desktopFamilyIds ?? [],
|
||||
{ respectPreferred: shouldRespectLaunchSelection },
|
||||
),
|
||||
)
|
||||
setLegIds(
|
||||
buildSeedSelectedIds(
|
||||
legFamilies,
|
||||
launchParams?.selectedFamilyIdsByType.legFamilyIds ?? [],
|
||||
{ respectPreferred: shouldRespectLaunchSelection },
|
||||
),
|
||||
)
|
||||
setConnectorIds(
|
||||
buildSeedSelectedIds(
|
||||
connectorFamilies,
|
||||
launchParams?.selectedFamilyIdsByType.connectorFamilyIds ?? [],
|
||||
{ respectPreferred: shouldRespectLaunchSelection },
|
||||
),
|
||||
)
|
||||
}, [
|
||||
connectorFamilies,
|
||||
desktopFamilies,
|
||||
entryMode,
|
||||
launchParams?.selectedFamilyIdsByType.connectorFamilyIds,
|
||||
launchParams?.selectedFamilyIdsByType.desktopFamilyIds,
|
||||
launchParams?.selectedFamilyIdsByType.legFamilyIds,
|
||||
legFamilies,
|
||||
shouldRespectLaunchSelection,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const selectedFamilies = [
|
||||
|
||||
@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import {
|
||||
buildAssetThumbnailImageUrl,
|
||||
buildAssetThumbnailExpiryTimestamp,
|
||||
type AssetThumbnailRenderAssetType,
|
||||
} from '@/lib/asset-thumbnail-auth'
|
||||
import { toMaterialConfig } from '@/lib/material-config'
|
||||
@ -17,11 +18,11 @@ import {
|
||||
type AssemblyWorkspaceSnapshot,
|
||||
} from '@/lib/open-product/assembly-workspace'
|
||||
import type { AssistedAssemblyVariant } from '@/lib/open-product/assisted-assembly-variant'
|
||||
import { normalizeAssetVariantWithUv } from '@/lib/open-product/asset-variant-uv'
|
||||
import type { OpenProductMountPoint } from '@/lib/open-product/types'
|
||||
import {
|
||||
getAssemblyWorkspaceSourceFromEntryMode,
|
||||
parseManualAssemblyLaunchParams,
|
||||
type ManualAssemblyLaunchParams,
|
||||
type ManualAssemblySelectedFamilyIdsByType,
|
||||
} from '@/lib/open-product/manual-assembly-launch'
|
||||
import { assetCenterService, materialService } from '@/server/services'
|
||||
@ -532,7 +533,8 @@ export default async function AssistedAssemblyPage({
|
||||
const finishMaterialById = new Map(
|
||||
finishes.map((finish) => [finish.id, toMaterialConfig(finish)]),
|
||||
)
|
||||
const runtimeSelectableVariants: AssistedAssemblyVariant[] = selectableVariants.map((variant) => {
|
||||
const runtimeSelectableVariants: AssistedAssemblyVariant[] = selectableVariants.map((rawVariant) => {
|
||||
const variant = normalizeAssetVariantWithUv(rawVariant)
|
||||
const finishMaterialConfig = variant.finishId
|
||||
? finishMaterialById.get(variant.finishId) ?? null
|
||||
: null
|
||||
@ -545,7 +547,7 @@ export default async function AssistedAssemblyPage({
|
||||
? buildAssetThumbnailImageUrl(requestOrigin, {
|
||||
modelUrl: variant.modelUrl,
|
||||
assetType: variant.assetType as AssetThumbnailRenderAssetType,
|
||||
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
|
||||
expiresAt: buildAssetThumbnailExpiryTimestamp(),
|
||||
materialConfig,
|
||||
})
|
||||
: variant.thumbnailUrl
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Loader2, Plus, Search } from 'lucide-react'
|
||||
import { Loader2, Search } from 'lucide-react'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Input from '@/components/ui/Input'
|
||||
import { Body, Title2 } from '@/components/ui/Typography'
|
||||
import ProductListItem from '@/components/ui/ProductListItem'
|
||||
@ -19,7 +18,6 @@ interface HomeModelsSectionProps {
|
||||
onAddSKU: (productId: string) => void
|
||||
onDeleteProduct: (product: ProductWithSkus) => void
|
||||
onDeleteSKU: (sku: ProductWithSkus['skus'][number], product: ProductWithSkus) => void
|
||||
onOpenCreate: () => void
|
||||
isLoadingMore: boolean
|
||||
hasMore: boolean
|
||||
isMounted: boolean
|
||||
@ -38,7 +36,6 @@ export function HomeModelsSection({
|
||||
onAddSKU,
|
||||
onDeleteProduct,
|
||||
onDeleteSKU,
|
||||
onOpenCreate,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
isMounted,
|
||||
@ -50,8 +47,8 @@ export function HomeModelsSection({
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div className="w-full md:max-w-[520px]">
|
||||
<Input
|
||||
aria-label="搜索产品"
|
||||
placeholder="搜索产品型号、名称或描述..."
|
||||
aria-label="搜索已入库产品"
|
||||
placeholder="搜索已入库产品型号、名称或描述..."
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
leftIcon={<Search className="h-5 w-5" />}
|
||||
@ -59,23 +56,14 @@ export function HomeModelsSection({
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 md:justify-end">
|
||||
<p className="text-sm text-apple-label-secondary">
|
||||
当前显示 {filteredProducts.length} / {totalProducts} 个产品
|
||||
当前显示 {filteredProducts.length} / {totalProducts} 个已入库产品
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card variant="standard" className="overflow-hidden bg-apple-bg-primary border border-apple-separator shadow-xs">
|
||||
<div className="px-6 py-5 border-b border-apple-separator flex items-center justify-between">
|
||||
<Title2 className="text-apple-label-primary">产品列表</Title2>
|
||||
<Button
|
||||
variant="gray"
|
||||
size="small"
|
||||
className="gap-1.5"
|
||||
onClick={onOpenCreate}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
添加型号
|
||||
</Button>
|
||||
<div className="px-6 py-5 border-b border-apple-separator">
|
||||
<Title2 className="text-apple-label-primary">已入库产品</Title2>
|
||||
</div>
|
||||
|
||||
{filteredProducts.length === 0 ? (
|
||||
@ -84,10 +72,10 @@ export function HomeModelsSection({
|
||||
<Search className="w-8 h-8 text-apple-label-tertiary" />
|
||||
</div>
|
||||
<Title2 className="mb-2 text-apple-label-primary">
|
||||
{searchQuery ? '未找到匹配的产品' : '暂无产品'}
|
||||
{searchQuery ? '未找到匹配的已入库产品' : '暂无已入库产品'}
|
||||
</Title2>
|
||||
<Body className="text-apple-label-secondary">
|
||||
{searchQuery ? '尝试使用其他关键词搜索' : '还没有添加任何产品,开始创建您的第一个3D产品吧!'}
|
||||
{searchQuery ? '尝试使用其他关键词搜索' : '完成正式入库后的产品会显示在这里,方便继续查看和管理。'}
|
||||
</Body>
|
||||
</div>
|
||||
) : (
|
||||
@ -113,12 +101,12 @@ export function HomeModelsSection({
|
||||
{isLoadingMore && (
|
||||
<div className="flex items-center justify-center gap-3 text-apple-label-secondary font-apple-system tracking-apple-tighter">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="text-apple-md">加载更多产品...</span>
|
||||
<span className="text-apple-md">加载更多已入库产品...</span>
|
||||
</div>
|
||||
)}
|
||||
{isMounted && !hasMore && totalProducts > 0 && (
|
||||
<div className="text-center text-apple-md font-apple-system tracking-apple-tighter text-apple-label-tertiary">
|
||||
已加载全部 {totalProducts} 个产品
|
||||
已加载全部 {totalProducts} 个已入库产品
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -14,8 +14,8 @@ interface HomeWorkspaceSwitcherProps {
|
||||
const tabs: Array<{ key: HomeWorkspaceTab; label: string; hint: string }> = [
|
||||
{
|
||||
key: 'products',
|
||||
label: '正式产品',
|
||||
hint: '继续承接旧 CAD 日常查看与编辑链路',
|
||||
label: '已入库产品',
|
||||
hint: '查看已完成正式入库的产品与后续维护入口',
|
||||
},
|
||||
{
|
||||
key: 'drafts',
|
||||
|
||||
@ -23,7 +23,7 @@ export default async function HomePage({
|
||||
{
|
||||
pagination: { page: 1, limit: 20 },
|
||||
sort: { field: 'createdAt', order: 'desc' },
|
||||
filter: {},
|
||||
filter: { formalizedOnly: true },
|
||||
},
|
||||
{ user: authUser },
|
||||
),
|
||||
|
||||
@ -15,6 +15,13 @@ export interface AssetThumbnailRenderPayload {
|
||||
materialConfig?: MaterialConfig | null
|
||||
}
|
||||
|
||||
export function buildAssetThumbnailExpiryTimestamp(
|
||||
ttlMs: number = 24 * 60 * 60 * 1000,
|
||||
nowMs: number = Date.now(),
|
||||
): number {
|
||||
return nowMs + ttlMs
|
||||
}
|
||||
|
||||
function getThumbnailSigningSecret(): string {
|
||||
const secret =
|
||||
process.env.ASSET_THUMBNAIL_SECRET ||
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
deriveAssemblyActiveSnapCandidate,
|
||||
deriveAssemblyAutoSnapCandidate,
|
||||
deriveAssemblySaveValidation,
|
||||
deriveAssemblyValidation,
|
||||
instantiateAssemblyNode,
|
||||
moveAssemblyNodes,
|
||||
snapNodesIntoMainAssembly,
|
||||
@ -497,6 +498,32 @@ describe('assembly-workspace', () => {
|
||||
expect(synced.nodes[0]?.assetFamilyId).toBe('family-desktop')
|
||||
})
|
||||
|
||||
it('手动拖进主装配区但未核验时,会标记 manual-unverified 并阻止保存', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
variant: desktopVariant,
|
||||
})
|
||||
const withLeg = instantiateAssemblyNode(withDesktop, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: legVariant,
|
||||
})
|
||||
|
||||
const moved = moveAssemblyNodes(withLeg, [withLeg.nodes[1]!.nodeId], 'main')
|
||||
const liveValidation = deriveAssemblyValidation({
|
||||
nodes: moved.nodes,
|
||||
relations: moved.relations,
|
||||
})
|
||||
const saveValidation = deriveAssemblySaveValidation(moved)
|
||||
|
||||
expect(moved.nodes[1]?.snapStatus).toBe('manual-unverified')
|
||||
expect(liveValidation.warnings).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('手动未核验状态')]),
|
||||
)
|
||||
expect(saveValidation.blockingIssues).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('手动未核验状态')]),
|
||||
)
|
||||
})
|
||||
|
||||
it('mountPattern / holeGroup 会先生成预吸附绑定,再在松手后固化 snapStatus 与 snapBinding', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
@ -570,4 +597,52 @@ describe('assembly-workspace', () => {
|
||||
expect(candidate?.sourceMountPatternId).toBe('leg-pattern-cad-1')
|
||||
expect(candidate?.targetMountPatternId).toBe('board-pattern-1')
|
||||
})
|
||||
|
||||
it('两个支撑件抢同一安装孔位 / 图样时,会判定空间冲突并阻止保存', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
variant: desktopVariant,
|
||||
})
|
||||
const firstLeg = instantiateAssemblyNode(withDesktop, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: legVariant,
|
||||
})
|
||||
const firstSnapped = attemptSnapNode(
|
||||
applyAutomaticAssemblySnapPreview(
|
||||
updateAssemblyNodeTransform(firstLeg, firstLeg.nodes[1]!.nodeId, {
|
||||
position: [0, 720, 0],
|
||||
}),
|
||||
firstLeg.nodes[1]!.nodeId,
|
||||
{ fallbackZone: 'staging' },
|
||||
),
|
||||
firstLeg.nodes[1]!.nodeId,
|
||||
)
|
||||
const secondLegVariant: OpenProductAssetVariant = {
|
||||
...legVariant,
|
||||
id: 'variant-leg-duplicate',
|
||||
code: 'LEG-A-002',
|
||||
name: '桌腿B',
|
||||
}
|
||||
const withSecondLeg = instantiateAssemblyNode(firstSnapped, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: secondLegVariant,
|
||||
})
|
||||
const secondSnapped = attemptSnapNode(
|
||||
applyAutomaticAssemblySnapPreview(
|
||||
updateAssemblyNodeTransform(withSecondLeg, withSecondLeg.nodes[2]!.nodeId, {
|
||||
position: [0, 720, 0],
|
||||
}),
|
||||
withSecondLeg.nodes[2]!.nodeId,
|
||||
{ fallbackZone: 'staging' },
|
||||
),
|
||||
withSecondLeg.nodes[2]!.nodeId,
|
||||
)
|
||||
const saveValidation = deriveAssemblySaveValidation(secondSnapped)
|
||||
|
||||
expect(secondSnapped.nodes[1]?.snapBinding?.targetHoleId).toBe('board-hole-1')
|
||||
expect(secondSnapped.nodes[2]?.snapBinding?.targetHoleId).toBe('board-hole-1')
|
||||
expect(saveValidation.blockingIssues).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('空间冲突')]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -203,6 +203,7 @@ const SNAP_DISTANCE_THRESHOLD = 180
|
||||
const HOLE_SNAP_DISTANCE_THRESHOLD_MM = 120
|
||||
const HOLE_DIAMETER_TOLERANCE_MM = 2
|
||||
const HOLE_NORMAL_ALIGNMENT_DOT_MAX = -0.45
|
||||
const SPACE_CONFLICT_DISTANCE_THRESHOLD_MM = 60
|
||||
|
||||
const DEFAULT_DESKTOP_POSITION: [number, number, number] = [0, 720, 0]
|
||||
|
||||
@ -980,6 +981,62 @@ function getRelationType(source: AssemblyNodeSource): AssemblyRelationType {
|
||||
return source === 'connector' ? 'connector' : 'hole-group'
|
||||
}
|
||||
|
||||
function deriveAssemblySpaceConflictIssues(nodes: AssemblyWorkspaceNode[]) {
|
||||
const mainNodes = nodes.filter((node) => node.zone === 'main' && node.source !== 'desktop')
|
||||
const issues = new Set<string>()
|
||||
|
||||
for (let index = 0; index < mainNodes.length; index += 1) {
|
||||
const currentNode = mainNodes[index]
|
||||
if (!currentNode) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (let nextIndex = index + 1; nextIndex < mainNodes.length; nextIndex += 1) {
|
||||
const otherNode = mainNodes[nextIndex]
|
||||
if (!otherNode) {
|
||||
continue
|
||||
}
|
||||
|
||||
const sharesSnapTarget =
|
||||
Boolean(currentNode.snapBinding) &&
|
||||
Boolean(otherNode.snapBinding) &&
|
||||
currentNode.snapBinding?.targetNodeId === otherNode.snapBinding?.targetNodeId &&
|
||||
(
|
||||
currentNode.snapBinding?.targetHoleId === otherNode.snapBinding?.targetHoleId ||
|
||||
(
|
||||
currentNode.snapBinding?.targetMountPatternId &&
|
||||
currentNode.snapBinding.targetMountPatternId ===
|
||||
otherNode.snapBinding?.targetMountPatternId
|
||||
)
|
||||
)
|
||||
|
||||
if (sharesSnapTarget) {
|
||||
issues.add(`${currentNode.name} 与 ${otherNode.name} 占用了同一安装孔位 / 图样,存在空间冲突`)
|
||||
continue
|
||||
}
|
||||
|
||||
const requiresManualConflictCheck =
|
||||
currentNode.snapStatus === 'manual-unverified' ||
|
||||
otherNode.snapStatus === 'manual-unverified' ||
|
||||
!currentNode.isSnapped ||
|
||||
!otherNode.isSnapped
|
||||
|
||||
if (!requiresManualConflictCheck) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
distanceBetween(currentNode.position, otherNode.position) <=
|
||||
SPACE_CONFLICT_DISTANCE_THRESHOLD_MM
|
||||
) {
|
||||
issues.add(`${currentNode.name} 与 ${otherNode.name} 在主装配区位置重叠,存在空间冲突`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(issues)
|
||||
}
|
||||
|
||||
function getSourceLabel(source: AssemblyNodeSource) {
|
||||
if (source === 'desktop') return '桌面板'
|
||||
if (source === 'leg') return '支撑件'
|
||||
@ -1394,18 +1451,28 @@ export function deriveAssemblyValidation(input: {
|
||||
if (!hasNodeAssemblyRule(node)) {
|
||||
warnings.push(`${node.name} 缺少装配规则,当前只能预览,不能稳定保存`)
|
||||
}
|
||||
if (!node.isSnapped) {
|
||||
if (node.snapStatus === 'manual-unverified') {
|
||||
warnings.push(`${node.name} 仍处于手动未核验状态,需要完成孔位 / 安装图样核验`)
|
||||
} else if (node.snapStatus === 'hole-preview') {
|
||||
warnings.push(`${node.name} 当前只是孔位预吸附预览,松手确认后才算连接`)
|
||||
} else if (!node.isSnapped) {
|
||||
warnings.push(`${node.name} 已进入主装配区,但还没有完成连接`)
|
||||
}
|
||||
if (!connectedRelations.has(node.nodeId)) {
|
||||
if (
|
||||
!connectedRelations.has(node.nodeId) &&
|
||||
node.snapStatus !== 'manual-unverified' &&
|
||||
node.snapStatus !== 'hole-preview'
|
||||
) {
|
||||
warnings.push(`${node.name} 仍处于待连接状态`)
|
||||
}
|
||||
})
|
||||
|
||||
warnings.push(...deriveAssemblySpaceConflictIssues(input.nodes))
|
||||
|
||||
return {
|
||||
level: warnings.length > 0 ? 'WARNING' : 'PASSED',
|
||||
blockingIssues: [],
|
||||
warnings,
|
||||
warnings: Array.from(new Set(warnings)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1436,11 +1503,17 @@ export function deriveAssemblySaveValidation(
|
||||
if (!hasNodeAssemblyRule(node)) {
|
||||
blockingIssues.push(`${node.name} 缺少装配规则,不能保存到草稿组`)
|
||||
}
|
||||
if (!connectedRelations.has(node.nodeId)) {
|
||||
if (node.snapStatus === 'manual-unverified') {
|
||||
blockingIssues.push(`${node.name} 还处于手动未核验状态,不能保存到草稿组`)
|
||||
} else if (node.snapStatus === 'hole-preview') {
|
||||
blockingIssues.push(`${node.name} 还在孔位预吸附预览中,不能保存到草稿组`)
|
||||
} else if (!connectedRelations.has(node.nodeId)) {
|
||||
blockingIssues.push(`${node.name} 还没有形成有效连接,不能保存到草稿组`)
|
||||
}
|
||||
})
|
||||
|
||||
blockingIssues.push(...deriveAssemblySpaceConflictIssues(snapshot.nodes))
|
||||
|
||||
if (mainNodes.length === 0) {
|
||||
warnings.push('主装配区还没有任何已装配零件')
|
||||
}
|
||||
@ -1448,8 +1521,8 @@ export function deriveAssemblySaveValidation(
|
||||
return {
|
||||
level:
|
||||
blockingIssues.length > 0 ? 'BLOCKED' : warnings.length > 0 ? 'WARNING' : 'PASSED',
|
||||
blockingIssues,
|
||||
warnings,
|
||||
blockingIssues: Array.from(new Set(blockingIssues)),
|
||||
warnings: Array.from(new Set(warnings)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
38
furniture-3d-system/src/lib/open-product/asset-variant-uv.ts
Normal file
38
furniture-3d-system/src/lib/open-product/asset-variant-uv.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import {
|
||||
readAssetVariantUvConfig,
|
||||
writeAssetVariantUvConfig,
|
||||
type AssetVariantUvConfig,
|
||||
} from '@/lib/asset-variant-render-config'
|
||||
|
||||
type AssetVariantUvCarrier = {
|
||||
businessData?: Record<string, unknown> | null
|
||||
uvConfig?: AssetVariantUvConfig | null
|
||||
}
|
||||
|
||||
export function resolveAssetVariantUvConfig(
|
||||
variant: AssetVariantUvCarrier | null | undefined,
|
||||
): AssetVariantUvConfig | null {
|
||||
return variant?.uvConfig ?? readAssetVariantUvConfig(variant?.businessData)
|
||||
}
|
||||
|
||||
export function normalizeAssetVariantWithUv<T extends AssetVariantUvCarrier>(
|
||||
variant: T,
|
||||
): T & { uvConfig: AssetVariantUvConfig | null } {
|
||||
return {
|
||||
...variant,
|
||||
uvConfig: resolveAssetVariantUvConfig(variant),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAssetVariantsWithUv<T extends AssetVariantUvCarrier>(
|
||||
variants: T[],
|
||||
): Array<T & { uvConfig: AssetVariantUvConfig | null }> {
|
||||
return variants.map((variant) => normalizeAssetVariantWithUv(variant))
|
||||
}
|
||||
|
||||
export function writeAssetVariantUvConfigToVariantBusinessData(
|
||||
variant: AssetVariantUvCarrier | null | undefined,
|
||||
uvConfig: AssetVariantUvConfig | null | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
return writeAssetVariantUvConfig(variant?.businessData, uvConfig)
|
||||
}
|
||||
@ -11,9 +11,7 @@ import type {
|
||||
CreateAssetVariantResultDTO,
|
||||
CreateAssetFamilyInput,
|
||||
CreateAssetVariantInput,
|
||||
CreateDraftGroupFromAutoCombinationInput,
|
||||
CreateDraftGroupFromManualAssemblyInput,
|
||||
CombinationGenerationResult,
|
||||
CreateSubstrateDictionaryInput,
|
||||
UpdateSubstrateInput,
|
||||
MaterialCategoryDTO,
|
||||
@ -169,11 +167,24 @@ export async function uploadModelFile(file: File): Promise<string> {
|
||||
}
|
||||
|
||||
export async function uploadMaterialThumbnail(file: File, category?: string): Promise<string> {
|
||||
return uploadMaterialAsset(file, {
|
||||
type: 'thumbnail',
|
||||
category,
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadMaterialAsset(
|
||||
file: File,
|
||||
options?: {
|
||||
type?: 'thumbnail' | 'texture' | 'normal'
|
||||
category?: string
|
||||
},
|
||||
): Promise<string> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('type', 'thumbnail')
|
||||
if (category) {
|
||||
formData.append('category', category)
|
||||
formData.append('type', options?.type ?? 'texture')
|
||||
if (options?.category) {
|
||||
formData.append('category', options.category)
|
||||
}
|
||||
|
||||
const response = await fetch('/api/admin/materials/upload-texture', {
|
||||
@ -368,6 +379,16 @@ export async function createMaterial(input: {
|
||||
categoryId: string
|
||||
color?: string
|
||||
thumbnailUrl?: string | null
|
||||
textureUrl?: string | null
|
||||
normalMapUrl?: string | null
|
||||
metalness?: number
|
||||
roughness?: number
|
||||
opacity?: number
|
||||
textureRepeat?: number
|
||||
textureRotation?: number
|
||||
textureSizeMm?: number
|
||||
isPreset?: boolean
|
||||
isActive?: boolean
|
||||
}): Promise<MaterialDTO> {
|
||||
return requestPlatform('/api/materials', {
|
||||
method: 'POST',
|
||||
@ -383,6 +404,15 @@ export async function updateMaterial(
|
||||
categoryId?: string
|
||||
color?: string
|
||||
thumbnailUrl?: string | null
|
||||
textureUrl?: string | null
|
||||
normalMapUrl?: string | null
|
||||
metalness?: number
|
||||
roughness?: number
|
||||
opacity?: number
|
||||
textureRepeat?: number
|
||||
textureRotation?: number
|
||||
textureSizeMm?: number
|
||||
isPreset?: boolean
|
||||
isActive?: boolean
|
||||
},
|
||||
): Promise<MaterialDTO> {
|
||||
|
||||
@ -3,6 +3,7 @@ jest.mock('@/lib/prisma', () => ({
|
||||
material: {
|
||||
count: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
@ -14,6 +15,7 @@ const mockPrisma = prisma as unknown as {
|
||||
material: {
|
||||
count: jest.Mock
|
||||
findFirst: jest.Mock
|
||||
findUnique: jest.Mock
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,4 +51,30 @@ describe('materialRepository', () => {
|
||||
})
|
||||
expect(result).toBe('HS-0012')
|
||||
})
|
||||
|
||||
it('材质使用计数会合并板件、资产规格和基材引用', async () => {
|
||||
mockPrisma.material.findUnique.mockResolvedValue({
|
||||
_count: {
|
||||
panelMaterials: 2,
|
||||
assetVariants: 1,
|
||||
substrateFinishes: 3,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await materialRepository.getUsageCount('finish-oak')
|
||||
|
||||
expect(mockPrisma.material.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'finish-oak' },
|
||||
select: {
|
||||
_count: {
|
||||
select: {
|
||||
panelMaterials: true,
|
||||
assetVariants: true,
|
||||
substrateFinishes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(result).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
product: {
|
||||
findMany: jest.fn(),
|
||||
count: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { productRepository } from '../product.repository'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
product: {
|
||||
findMany: jest.Mock
|
||||
count: jest.Mock
|
||||
}
|
||||
}
|
||||
|
||||
describe('productRepository formalizedOnly', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockPrisma.product.findMany.mockResolvedValue([])
|
||||
mockPrisma.product.count.mockResolvedValue(0)
|
||||
})
|
||||
|
||||
it('只筛选来自准 SKU 正式入库且未删除的产品 SKU', async () => {
|
||||
await productRepository.findManyWithRelations({
|
||||
pagination: { page: 1, limit: 20 },
|
||||
filter: {
|
||||
createdBy: 'user-1',
|
||||
formalizedOnly: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockPrisma.product.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
createdBy: 'user-1',
|
||||
skus: {
|
||||
some: {
|
||||
deletedAt: null,
|
||||
sourcePreSkuId: { not: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: 0,
|
||||
take: 20,
|
||||
}),
|
||||
)
|
||||
expect(mockPrisma.product.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
createdBy: 'user-1',
|
||||
skus: {
|
||||
some: {
|
||||
deletedAt: null,
|
||||
sourcePreSkuId: { not: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -207,11 +207,19 @@ class MaterialRepositoryClass extends BaseRepository<
|
||||
where: { id },
|
||||
select: {
|
||||
_count: {
|
||||
select: { panelMaterials: true },
|
||||
select: {
|
||||
panelMaterials: true,
|
||||
assetVariants: true,
|
||||
substrateFinishes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return result?._count.panelMaterials ?? 0
|
||||
return (
|
||||
(result?._count.panelMaterials ?? 0) +
|
||||
(result?._count.assetVariants ?? 0) +
|
||||
(result?._count.substrateFinishes ?? 0)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -18,6 +18,7 @@ export interface ProductFilter {
|
||||
visibility?: string
|
||||
createdBy?: string
|
||||
departmentId?: string
|
||||
formalizedOnly?: boolean
|
||||
includeDeleted?: boolean
|
||||
}
|
||||
|
||||
@ -717,6 +718,15 @@ class ProductRepositoryClass extends BaseRepository<
|
||||
where.departmentId = filter.departmentId
|
||||
}
|
||||
|
||||
if (filter.formalizedOnly) {
|
||||
where.skus = {
|
||||
some: {
|
||||
deletedAt: null,
|
||||
sourcePreSkuId: { not: null },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return where
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { assetCenterService } from '../asset-center.service'
|
||||
import { assetVariantThumbnailService } from '../asset-variant-thumbnail.service'
|
||||
import { materialService } from '../material.service'
|
||||
import {
|
||||
assetFamilyRepository,
|
||||
assetVariantRepository,
|
||||
@ -46,6 +47,12 @@ jest.mock('../asset-variant-thumbnail.service', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('../material.service', () => ({
|
||||
materialService: {
|
||||
findById: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('assetCenterService', () => {
|
||||
const mockedAssetFamilyRepository = jest.mocked(assetFamilyRepository)
|
||||
const mockedAssetVariantRepository = jest.mocked(assetVariantRepository)
|
||||
@ -53,6 +60,7 @@ describe('assetCenterService', () => {
|
||||
const mockedMaterialRepository = jest.mocked(materialRepository)
|
||||
const mockedMaterialCategoryRepository = jest.mocked(materialCategoryRepository)
|
||||
const mockedAssetVariantThumbnailService = jest.mocked(assetVariantThumbnailService)
|
||||
const mockedMaterialService = jest.mocked(materialService)
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
@ -90,6 +98,10 @@ describe('assetCenterService', () => {
|
||||
id: 'finish-white-oak',
|
||||
name: '白橡',
|
||||
} as never)
|
||||
mockedMaterialService.findById.mockResolvedValue({
|
||||
id: 'finish-white-oak',
|
||||
name: '白橡',
|
||||
} as never)
|
||||
mockedAssetVariantThumbnailService.generateForVariant.mockResolvedValue(
|
||||
'https://cdn.example.com/generated-thumbnail.png' as never,
|
||||
)
|
||||
@ -421,12 +433,14 @@ describe('assetCenterService', () => {
|
||||
{ renderBaseUrl: 'http://localhost:3000' },
|
||||
)
|
||||
|
||||
expect(mockedAssetVariantThumbnailService.generateForVariant).toHaveBeenCalledWith({
|
||||
variantId: 'variant-board-top-render',
|
||||
assetType: 'BOARD',
|
||||
modelUrl: '/models/board-render.glb',
|
||||
renderBaseUrl: 'http://localhost:3000',
|
||||
})
|
||||
expect(mockedAssetVariantThumbnailService.generateForVariant).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variantId: 'variant-board-top-render',
|
||||
assetType: 'BOARD',
|
||||
modelUrl: '/models/board-render.glb',
|
||||
renderBaseUrl: 'http://localhost:3000',
|
||||
}),
|
||||
)
|
||||
expect(mockedAssetVariantRepository.updateWithRelations).toHaveBeenCalledWith(
|
||||
'variant-board-top-render',
|
||||
expect.objectContaining({
|
||||
@ -701,12 +715,258 @@ describe('assetCenterService', () => {
|
||||
},
|
||||
)
|
||||
|
||||
expect(mockedAssetVariantThumbnailService.generateForVariant).toHaveBeenCalledWith({
|
||||
variantId: 'variant-board-top-manual',
|
||||
expect(mockedAssetVariantThumbnailService.generateForVariant).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variantId: 'variant-board-top-manual',
|
||||
assetType: 'BOARD',
|
||||
modelUrl: '/models/board-manual.glb',
|
||||
renderBaseUrl: 'http://localhost:3000',
|
||||
}),
|
||||
)
|
||||
expect(result.thumbnailUrl).toBe('https://cdn.example.com/new-thumbnail.png')
|
||||
})
|
||||
|
||||
it('更新规格款 UV 时会写回 uvConfig,并按当前材质参数重生成缩略图', async () => {
|
||||
mockedAssetVariantRepository.findByIdWithRelations.mockResolvedValue({
|
||||
id: 'variant-board-top-uv',
|
||||
assetFamilyId: 'family-board-top',
|
||||
code: 'BOARD-TOP-UV',
|
||||
name: '桌面板 UV',
|
||||
identityKey:
|
||||
'BOARD|code=BOARD-TOP-UV|length=1200|thickness=25|width=600|substrate=substrate-particle-board|finish=finish-white-oak',
|
||||
assetType: 'BOARD',
|
||||
modelUrl: '/models/board-manual.glb',
|
||||
renderBaseUrl: 'http://localhost:3000',
|
||||
})
|
||||
status: 'DRAFT',
|
||||
sizeLabel: null,
|
||||
substrateId: 'substrate-particle-board',
|
||||
finishId: 'finish-white-oak',
|
||||
dimensions: { length: 1200, width: 600, thickness: 25 },
|
||||
modelUrl: '/models/board-uv.glb',
|
||||
thumbnailUrl: 'https://cdn.example.com/old-thumbnail.png',
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
assemblyBasis: null,
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData: null,
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
lossFactor: 1,
|
||||
estimatedCostPrice: 21.79,
|
||||
inheritedFromVariantId: null,
|
||||
createdBy: 'user-1',
|
||||
creator: { 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-04-11T09:30:00.000Z'),
|
||||
updatedAt: new Date('2026-04-11T09:30:00.000Z'),
|
||||
} as never)
|
||||
mockedMaterialService.findById.mockResolvedValueOnce({
|
||||
id: 'finish-white-oak',
|
||||
name: '白橡',
|
||||
categoryId: 'cat-finish',
|
||||
categoryName: '饰面',
|
||||
thumbnailUrl: '/finish-oak.png',
|
||||
textureUrl: '/finish-oak.jpg',
|
||||
normalMapUrl: null,
|
||||
roughnessMapUrl: null,
|
||||
metalnessMapUrl: null,
|
||||
aoMapUrl: null,
|
||||
previewUrl: null,
|
||||
color: '#ffffff',
|
||||
metalness: 0,
|
||||
roughness: 1,
|
||||
opacity: 1,
|
||||
transmission: null,
|
||||
ior: null,
|
||||
thickness: null,
|
||||
clearcoat: null,
|
||||
clearcoatRoughness: null,
|
||||
envMapIntensity: null,
|
||||
textureRepeat: 1,
|
||||
textureRotation: 0,
|
||||
textureSizeMm: 600,
|
||||
} as never)
|
||||
mockedAssetVariantRepository.updateWithRelations
|
||||
.mockResolvedValueOnce({
|
||||
id: 'variant-board-top-uv',
|
||||
assetFamilyId: 'family-board-top',
|
||||
code: 'BOARD-TOP-UV',
|
||||
name: '桌面板 UV',
|
||||
identityKey:
|
||||
'BOARD|code=BOARD-TOP-UV|length=1200|thickness=25|width=600|substrate=substrate-particle-board|finish=finish-white-oak',
|
||||
assetType: 'BOARD',
|
||||
status: 'DRAFT',
|
||||
sizeLabel: null,
|
||||
substrateId: 'substrate-particle-board',
|
||||
finishId: 'finish-white-oak',
|
||||
dimensions: { length: 1200, width: 600, thickness: 25 },
|
||||
modelUrl: '/models/board-uv.glb',
|
||||
thumbnailUrl: 'https://cdn.example.com/old-thumbnail.png',
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
assemblyBasis: null,
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData: {
|
||||
renderConfig: {
|
||||
uv: {
|
||||
version: 1,
|
||||
nodes: {
|
||||
'board-top': {
|
||||
textureRepeat: 2,
|
||||
textureRotation: Math.PI / 2,
|
||||
textureSizeMm: 900,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
lossFactor: 1,
|
||||
estimatedCostPrice: 21.79,
|
||||
inheritedFromVariantId: null,
|
||||
createdBy: 'user-1',
|
||||
creator: { 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-04-11T09:30:00.000Z'),
|
||||
updatedAt: new Date('2026-04-11T09:30:00.000Z'),
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
id: 'variant-board-top-uv',
|
||||
assetFamilyId: 'family-board-top',
|
||||
code: 'BOARD-TOP-UV',
|
||||
name: '桌面板 UV',
|
||||
identityKey:
|
||||
'BOARD|code=BOARD-TOP-UV|length=1200|thickness=25|width=600|substrate=substrate-particle-board|finish=finish-white-oak',
|
||||
assetType: 'BOARD',
|
||||
status: 'DRAFT',
|
||||
sizeLabel: null,
|
||||
substrateId: 'substrate-particle-board',
|
||||
finishId: 'finish-white-oak',
|
||||
dimensions: { length: 1200, width: 600, thickness: 25 },
|
||||
modelUrl: '/models/board-uv.glb',
|
||||
thumbnailUrl: 'https://cdn.example.com/new-thumbnail.png',
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
assemblyBasis: null,
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData: {
|
||||
renderConfig: {
|
||||
uv: {
|
||||
version: 1,
|
||||
nodes: {
|
||||
'board-top': {
|
||||
textureRepeat: 2,
|
||||
textureRotation: Math.PI / 2,
|
||||
textureSizeMm: 900,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
lossFactor: 1,
|
||||
estimatedCostPrice: 21.79,
|
||||
inheritedFromVariantId: null,
|
||||
createdBy: 'user-1',
|
||||
creator: { 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-04-11T09:30:00.000Z'),
|
||||
updatedAt: new Date('2026-04-11T09:30:00.000Z'),
|
||||
} as never)
|
||||
mockedAssetVariantThumbnailService.generateForVariant.mockResolvedValueOnce(
|
||||
'https://cdn.example.com/new-thumbnail.png' as never,
|
||||
)
|
||||
|
||||
const result = await assetCenterService.updateVariant(
|
||||
'variant-board-top-uv',
|
||||
{
|
||||
uvConfig: {
|
||||
version: 1,
|
||||
nodes: {
|
||||
'board-top': {
|
||||
textureRepeat: 2,
|
||||
textureRotation: Math.PI / 2,
|
||||
textureSizeMm: 900,
|
||||
},
|
||||
},
|
||||
},
|
||||
updatedBy: 'user-1',
|
||||
},
|
||||
{ renderBaseUrl: 'http://localhost:3000' },
|
||||
)
|
||||
|
||||
expect(mockedAssetVariantRepository.updateWithRelations).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'variant-board-top-uv',
|
||||
expect.objectContaining({
|
||||
businessData: {
|
||||
renderConfig: {
|
||||
uv: {
|
||||
version: 1,
|
||||
nodes: {
|
||||
'board-top': {
|
||||
textureRepeat: 2,
|
||||
textureRotation: Math.PI / 2,
|
||||
textureSizeMm: 900,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(mockedAssetVariantThumbnailService.generateForVariant).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variantId: 'variant-board-top-uv',
|
||||
materialConfig: expect.objectContaining({
|
||||
textureRepeat: 2,
|
||||
textureRotation: Math.PI / 2,
|
||||
textureSizeMm: 900,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(result.thumbnailUrl).toBe('https://cdn.example.com/new-thumbnail.png')
|
||||
})
|
||||
|
||||
|
||||
@ -36,6 +36,13 @@ jest.mock('../../repositories', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('../asset-variant-thumbnail.service', () => ({
|
||||
assetVariantThumbnailService: {
|
||||
generateForVariant: jest.fn(),
|
||||
getPlaceholderUrl: jest.fn(() => '/asset-thumbnails/placeholder.svg'),
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { substrateRepository } from '../../repositories'
|
||||
import { assetCenterService } from '../asset-center.service'
|
||||
@ -89,6 +96,8 @@ describe('assetCenterService.createSubstrate', () => {
|
||||
code: 'BC-0008',
|
||||
name: '18MM E1 实木 北美橡木 胡桃木',
|
||||
description: null,
|
||||
systemNameOptionId: 'sys-solid',
|
||||
systemNameOption: { id: 'sys-solid', name: '实木板' },
|
||||
categoryId: 'cat-solid',
|
||||
category: { id: 'cat-solid', name: '实木' },
|
||||
environmentGradeId: 'grade-e1',
|
||||
@ -118,6 +127,7 @@ describe('assetCenterService.createSubstrate', () => {
|
||||
|
||||
it('支持无贴面类型的实木基材,并自动生成 BC 编码与整板重量', async () => {
|
||||
const result = await assetCenterService.createSubstrate({
|
||||
systemNameOptionId: 'sys-solid',
|
||||
categoryId: 'cat-solid',
|
||||
environmentGradeId: 'grade-e1',
|
||||
thicknessSpecId: 'th-18',
|
||||
@ -132,6 +142,7 @@ describe('assetCenterService.createSubstrate', () => {
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
code: 'BC-0008',
|
||||
systemNameOption: { connect: { id: 'sys-solid' } },
|
||||
weightKg: 32,
|
||||
finishes: {
|
||||
create: [
|
||||
@ -143,6 +154,8 @@ describe('assetCenterService.createSubstrate', () => {
|
||||
}),
|
||||
)
|
||||
expect(result.code).toBe('BC-0008')
|
||||
expect(result.systemNameOptionId).toBe('sys-solid')
|
||||
expect(result.systemNameOptionName).toBe('实木板')
|
||||
expect(result.surfaceOptionId).toBeNull()
|
||||
expect(result.weightKg).toBe(32)
|
||||
expect(result.finishes).toHaveLength(2)
|
||||
|
||||
@ -5,9 +5,15 @@ jest.mock('@/server/repositories/material.repository', () => ({
|
||||
findByCode: jest.fn(),
|
||||
create: jest.fn(),
|
||||
findByIdWithCategory: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
getUsageCount: jest.fn(),
|
||||
deactivate: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
materialCategoryRepository: {
|
||||
findById: jest.fn(),
|
||||
getMaterialCount: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
@ -38,10 +44,16 @@ const mockMaterialRepository = materialRepository as unknown as {
|
||||
findByCode: jest.Mock
|
||||
create: jest.Mock
|
||||
findByIdWithCategory: jest.Mock
|
||||
findById: jest.Mock
|
||||
getUsageCount: jest.Mock
|
||||
deactivate: jest.Mock
|
||||
delete: jest.Mock
|
||||
}
|
||||
|
||||
const mockMaterialCategoryRepository = materialCategoryRepository as unknown as {
|
||||
findById: jest.Mock
|
||||
getMaterialCount: jest.Mock
|
||||
delete: jest.Mock
|
||||
}
|
||||
|
||||
describe('materialService', () => {
|
||||
@ -52,7 +64,9 @@ describe('materialService', () => {
|
||||
name: '木纹',
|
||||
icon: null,
|
||||
})
|
||||
mockMaterialCategoryRepository.getMaterialCount.mockResolvedValue(0)
|
||||
mockMaterialRepository.nameExistsInCategory.mockResolvedValue(false)
|
||||
mockMaterialRepository.getUsageCount.mockResolvedValue(0)
|
||||
})
|
||||
|
||||
it('创建花色时会自动生成 HS 编码', async () => {
|
||||
@ -145,4 +159,22 @@ describe('materialService', () => {
|
||||
expect(result.textureUrl).toBe('/materials/pbr/polyhaven/marble_01/marble_01_diff_1k.jpg')
|
||||
expect(result.previewUrl).toBe('/api/materials/thumbnail?name=test')
|
||||
})
|
||||
|
||||
it('删除材质时会拦截系统预设', async () => {
|
||||
mockMaterialRepository.findById.mockResolvedValue({
|
||||
id: 'preset-finish',
|
||||
isPreset: true,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
await expect(materialService.delete('preset-finish')).rejects.toThrow('系统预设材质不允许删除')
|
||||
expect(mockMaterialRepository.deactivate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('删除分类时会硬删除未引用的分类', async () => {
|
||||
await materialService.deleteCategory('category-wood')
|
||||
|
||||
expect(mockMaterialCategoryRepository.getMaterialCount).toHaveBeenCalledWith('category-wood', false)
|
||||
expect(mockMaterialCategoryRepository.delete).toHaveBeenCalledWith('category-wood')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import {
|
||||
Prisma,
|
||||
type AssetStatus,
|
||||
type Substrate,
|
||||
type SubstrateCategory,
|
||||
type SubstrateEnvironmentGrade,
|
||||
type SubstrateSurfaceOption,
|
||||
@ -23,6 +22,13 @@ import {
|
||||
findSimilarBoardCandidates,
|
||||
type AssetSimilaritySource,
|
||||
} from '@/lib/open-product/asset-similarity'
|
||||
import {
|
||||
applyAssetVariantUvConfigToMaterial,
|
||||
normalizeAssetVariantUvConfig,
|
||||
readAssetVariantUvConfig,
|
||||
writeAssetVariantUvConfig,
|
||||
type AssetVariantUvConfig,
|
||||
} from '@/lib/asset-variant-render-config'
|
||||
import {
|
||||
buildSubstrateDisplayName,
|
||||
calculateEstimatedCost,
|
||||
@ -99,6 +105,56 @@ function trimOrNull(value: string | null | undefined): string | null {
|
||||
return next ? next : null
|
||||
}
|
||||
|
||||
function normalizeBusinessDataRecord(
|
||||
value: Record<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
return isRecord(value) ? { ...value } : null
|
||||
}
|
||||
|
||||
function areVariantUvConfigsEqual(
|
||||
left: AssetVariantUvConfig | null | undefined,
|
||||
right: AssetVariantUvConfig | null | undefined,
|
||||
): boolean {
|
||||
const normalizedLeft = normalizeAssetVariantUvConfig(left)
|
||||
const normalizedRight = normalizeAssetVariantUvConfig(right)
|
||||
return JSON.stringify(normalizedLeft ?? null) === JSON.stringify(normalizedRight ?? null)
|
||||
}
|
||||
|
||||
function resolveBusinessDataForVariantCreate(input: {
|
||||
businessData?: Record<string, unknown> | null
|
||||
uvConfig?: AssetVariantUvConfig | null
|
||||
}): Record<string, unknown> | null | undefined {
|
||||
if (input.businessData === undefined && input.uvConfig === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return writeAssetVariantUvConfig(
|
||||
normalizeBusinessDataRecord(input.businessData),
|
||||
input.uvConfig,
|
||||
)
|
||||
}
|
||||
|
||||
function resolveBusinessDataForVariantUpdate(input: {
|
||||
existingBusinessData: Record<string, unknown> | null
|
||||
businessData?: Record<string, unknown> | null
|
||||
uvConfig?: AssetVariantUvConfig | null
|
||||
}): Record<string, unknown> | null | undefined {
|
||||
if (input.businessData === undefined && input.uvConfig === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const nextBase =
|
||||
input.businessData !== undefined
|
||||
? normalizeBusinessDataRecord(input.businessData)
|
||||
: normalizeBusinessDataRecord(input.existingBusinessData)
|
||||
|
||||
if (input.uvConfig !== undefined) {
|
||||
return writeAssetVariantUvConfig(nextBase, input.uvConfig)
|
||||
}
|
||||
|
||||
return nextBase
|
||||
}
|
||||
|
||||
function ensureDraftCreationStatus(status?: AssetStatus): AssetStatus {
|
||||
if (!status) return 'DRAFT'
|
||||
if (status !== 'DRAFT') {
|
||||
@ -176,6 +232,9 @@ function decimalToNumber(value: Prisma.Decimal | number | null | undefined): num
|
||||
}
|
||||
|
||||
function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO {
|
||||
const businessData = isRecord(record.businessData) ? record.businessData : null
|
||||
const uvConfig = readAssetVariantUvConfig(businessData)
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
assetFamilyId: record.assetFamilyId,
|
||||
@ -201,7 +260,8 @@ function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO {
|
||||
assemblyBasis: isRecord(record.assemblyBasis) ? record.assemblyBasis : null,
|
||||
compatibilityRules: isRecord(record.compatibilityRules) ? record.compatibilityRules : null,
|
||||
namingFields: isRecord(record.namingFields) ? record.namingFields : null,
|
||||
businessData: isRecord(record.businessData) ? record.businessData : null,
|
||||
businessData,
|
||||
...(uvConfig ? { uvConfig } : {}),
|
||||
weightKg: record.weightKg ?? null,
|
||||
costPrice: decimalToNumber(record.costPrice),
|
||||
lossFactor: record.lossFactor ?? null,
|
||||
@ -237,6 +297,8 @@ function toSubstrateDTO(record: SubstrateWithRelations): SubstrateDTO {
|
||||
code: record.code,
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
systemNameOptionId: record.systemNameOptionId,
|
||||
systemNameOptionName: record.systemNameOption?.name ?? null,
|
||||
categoryId: record.categoryId,
|
||||
categoryName: record.category.name,
|
||||
environmentGradeId: record.environmentGradeId,
|
||||
@ -291,6 +353,8 @@ function getDictionaryLabel(type: SubstrateDictionaryType): string {
|
||||
return '厚度规格'
|
||||
case 'SURFACE_OPTION':
|
||||
return '贴面类型'
|
||||
case 'SYSTEM_NAME_OPTION':
|
||||
return '系统名称'
|
||||
}
|
||||
}
|
||||
|
||||
@ -359,8 +423,9 @@ async function loadSubstrateParts(input: {
|
||||
environmentGradeId: string
|
||||
thicknessSpecId: string
|
||||
surfaceOptionId: string | null
|
||||
systemNameOptionId?: string | null
|
||||
}) {
|
||||
const [category, environmentGrade, thicknessSpec, surfaceOption] =
|
||||
const [category, environmentGrade, thicknessSpec, surfaceOption, systemNameOption] =
|
||||
await Promise.all([
|
||||
prisma.substrateCategory.findUnique({ where: { id: input.categoryId } }),
|
||||
prisma.substrateEnvironmentGrade.findUnique({ where: { id: input.environmentGradeId } }),
|
||||
@ -368,6 +433,9 @@ async function loadSubstrateParts(input: {
|
||||
input.surfaceOptionId
|
||||
? prisma.substrateSurfaceOption.findUnique({ where: { id: input.surfaceOptionId } })
|
||||
: Promise.resolve(null),
|
||||
input.systemNameOptionId
|
||||
? prisma.substrateSystemNameOption.findUnique({ where: { id: input.systemNameOptionId } })
|
||||
: Promise.resolve(null),
|
||||
])
|
||||
|
||||
if (!category) {
|
||||
@ -382,31 +450,16 @@ async function loadSubstrateParts(input: {
|
||||
if (input.surfaceOptionId && !surfaceOption) {
|
||||
throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '贴面类型不存在')
|
||||
}
|
||||
if (input.systemNameOptionId && !systemNameOption) {
|
||||
throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '系统名称不存在')
|
||||
}
|
||||
|
||||
return {
|
||||
category,
|
||||
environmentGrade,
|
||||
thicknessSpec,
|
||||
surfaceOption,
|
||||
}
|
||||
}
|
||||
|
||||
async function buildSubstratePayload(input: {
|
||||
categoryId: string
|
||||
environmentGradeId: string
|
||||
thicknessSpecId: string
|
||||
surfaceOptionId: string | null
|
||||
}) {
|
||||
const parts = await loadSubstrateParts(input)
|
||||
const generatedName = buildSubstrateDisplayName({
|
||||
thicknessMm: parts.thicknessSpec.thicknessMm,
|
||||
environmentGradeLabel: parts.environmentGrade.name,
|
||||
categoryLabel: parts.category.name,
|
||||
})
|
||||
|
||||
return {
|
||||
generatedName,
|
||||
parts,
|
||||
systemNameOption,
|
||||
}
|
||||
}
|
||||
|
||||
@ -441,6 +494,7 @@ async function buildSubstratePayloadWithFinishes(input: {
|
||||
environmentGradeId: string
|
||||
thicknessSpecId: string
|
||||
surfaceOptionId: string | null
|
||||
systemNameOptionId?: string | null
|
||||
finishIds: string[]
|
||||
}) {
|
||||
const parts = await loadSubstrateParts(input)
|
||||
@ -531,12 +585,13 @@ class AssetCenterService {
|
||||
const materialConfig = variant.finishId
|
||||
? toMaterialConfig(await materialService.findById(variant.finishId).catch(() => null))
|
||||
: null
|
||||
const renderMaterialConfig = applyAssetVariantUvConfigToMaterial(materialConfig, variant.uvConfig)
|
||||
const thumbnailUrl = await assetVariantThumbnailService.generateForVariant({
|
||||
variantId: variant.id,
|
||||
assetType: variant.assetType as AssetThumbnailRenderAssetType,
|
||||
modelUrl,
|
||||
renderBaseUrl,
|
||||
...(materialConfig ? { materialConfig } : {}),
|
||||
...(renderMaterialConfig ? { materialConfig: renderMaterialConfig } : {}),
|
||||
})
|
||||
return this.persistVariantThumbnail(variant, thumbnailUrl, updatedBy)
|
||||
} catch (error) {
|
||||
@ -589,12 +644,13 @@ class AssetCenterService {
|
||||
}
|
||||
|
||||
async getSubstrateDictionaryBundle(): Promise<SubstrateDictionaryBundleDTO> {
|
||||
const [categories, environmentGrades, thicknessSpecs, surfaceOptions] =
|
||||
const [categories, environmentGrades, thicknessSpecs, surfaceOptions, systemNameOptions] =
|
||||
await Promise.all([
|
||||
prisma.substrateCategory.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }),
|
||||
prisma.substrateEnvironmentGrade.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }),
|
||||
prisma.substrateThicknessSpec.findMany({ orderBy: [{ sortOrder: 'asc' }, { thicknessMm: 'asc' }] }),
|
||||
prisma.substrateSurfaceOption.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }),
|
||||
prisma.substrateSystemNameOption.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }),
|
||||
])
|
||||
|
||||
return {
|
||||
@ -602,6 +658,7 @@ class AssetCenterService {
|
||||
environmentGrades: environmentGrades.map((item) => toDictionaryOptionDTO('ENVIRONMENT_GRADE', item)),
|
||||
thicknessSpecs: thicknessSpecs.map((item) => toDictionaryOptionDTO('THICKNESS_SPEC', item)),
|
||||
surfaceOptions: surfaceOptions.map((item) => toDictionaryOptionDTO('SURFACE_OPTION', item)),
|
||||
systemNameOptions: systemNameOptions.map((item) => toDictionaryOptionDTO('SYSTEM_NAME_OPTION', item)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -680,6 +737,7 @@ class AssetCenterService {
|
||||
|
||||
const existing = await prisma.substrate.findFirst({
|
||||
where: {
|
||||
systemNameOptionId: input.systemNameOptionId ?? null,
|
||||
categoryId: input.categoryId,
|
||||
environmentGradeId: input.environmentGradeId,
|
||||
thicknessSpecId: input.thicknessSpecId,
|
||||
@ -703,6 +761,7 @@ class AssetCenterService {
|
||||
|
||||
const mergedFinishIds = [...existingFinishIds, ...newFinishIds]
|
||||
const payload = await buildSubstratePayloadWithFinishes({
|
||||
systemNameOptionId: existing.systemNameOptionId ?? null,
|
||||
categoryId: existing.categoryId,
|
||||
environmentGradeId: existing.environmentGradeId,
|
||||
thicknessSpecId: existing.thicknessSpecId,
|
||||
@ -735,6 +794,7 @@ class AssetCenterService {
|
||||
}
|
||||
|
||||
const payload = await buildSubstratePayloadWithFinishes({
|
||||
systemNameOptionId: input.systemNameOptionId ?? null,
|
||||
categoryId: input.categoryId,
|
||||
environmentGradeId: input.environmentGradeId,
|
||||
thicknessSpecId: input.thicknessSpecId,
|
||||
@ -747,6 +807,9 @@ class AssetCenterService {
|
||||
code,
|
||||
name: payload.generatedName,
|
||||
description: trimOrNull(input.description),
|
||||
systemNameOption: input.systemNameOptionId
|
||||
? { connect: { id: input.systemNameOptionId } }
|
||||
: undefined,
|
||||
category: { connect: { id: input.categoryId } },
|
||||
environmentGrade: { connect: { id: input.environmentGradeId } },
|
||||
thicknessSpec: { connect: { id: input.thicknessSpecId } },
|
||||
@ -809,6 +872,10 @@ class AssetCenterService {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '基材编码已存在')
|
||||
}
|
||||
|
||||
const nextSystemNameOptionId =
|
||||
input.systemNameOptionId !== undefined
|
||||
? input.systemNameOptionId
|
||||
: existing.systemNameOptionId
|
||||
const nextCategoryId = input.categoryId ?? existing.categoryId
|
||||
const nextEnvironmentGradeId = input.environmentGradeId ?? existing.environmentGradeId
|
||||
const nextThicknessSpecId = input.thicknessSpecId ?? existing.thicknessSpecId
|
||||
@ -823,6 +890,7 @@ class AssetCenterService {
|
||||
const duplicate = await prisma.substrate.findFirst({
|
||||
where: {
|
||||
id: { not: id },
|
||||
systemNameOptionId: nextSystemNameOptionId ?? null,
|
||||
categoryId: nextCategoryId,
|
||||
environmentGradeId: nextEnvironmentGradeId,
|
||||
thicknessSpecId: nextThicknessSpecId,
|
||||
@ -836,6 +904,7 @@ class AssetCenterService {
|
||||
}
|
||||
|
||||
const payload = await buildSubstratePayloadWithFinishes({
|
||||
systemNameOptionId: nextSystemNameOptionId ?? null,
|
||||
categoryId: nextCategoryId,
|
||||
environmentGradeId: nextEnvironmentGradeId,
|
||||
thicknessSpecId: nextThicknessSpecId,
|
||||
@ -850,6 +919,12 @@ class AssetCenterService {
|
||||
name: payload.generatedName,
|
||||
description:
|
||||
input.description !== undefined ? trimOrNull(input.description) : undefined,
|
||||
systemNameOption:
|
||||
input.systemNameOptionId === undefined
|
||||
? undefined
|
||||
: input.systemNameOptionId
|
||||
? { connect: { id: input.systemNameOptionId } }
|
||||
: { disconnect: true },
|
||||
category:
|
||||
input.categoryId === undefined ? undefined : { connect: { id: input.categoryId } },
|
||||
environmentGrade:
|
||||
@ -960,6 +1035,11 @@ class AssetCenterService {
|
||||
'SURFACE_OPTION',
|
||||
await prisma.substrateSurfaceOption.create({ data: dataBase }),
|
||||
)
|
||||
case 'SYSTEM_NAME_OPTION':
|
||||
return toDictionaryOptionDTO(
|
||||
'SYSTEM_NAME_OPTION',
|
||||
await prisma.substrateSystemNameOption.create({ data: dataBase }),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
@ -1030,6 +1110,18 @@ class AssetCenterService {
|
||||
},
|
||||
}),
|
||||
)
|
||||
case 'SYSTEM_NAME_OPTION':
|
||||
return toDictionaryOptionDTO(
|
||||
'SYSTEM_NAME_OPTION',
|
||||
await prisma.substrateSystemNameOption.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: input.name?.trim(),
|
||||
sortOrder: input.sortOrder,
|
||||
isActive: input.isActive,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
@ -1054,6 +1146,7 @@ class AssetCenterService {
|
||||
ENVIRONMENT_GRADE: { environmentGradeId: id },
|
||||
THICKNESS_SPEC: { thicknessSpecId: id },
|
||||
SURFACE_OPTION: { surfaceOptionId: id },
|
||||
SYSTEM_NAME_OPTION: { systemNameOptionId: id },
|
||||
}
|
||||
const referenceCount = await prisma.substrate.count({ where: whereByType[type] })
|
||||
if (referenceCount > 0) {
|
||||
@ -1074,6 +1167,9 @@ class AssetCenterService {
|
||||
case 'SURFACE_OPTION':
|
||||
await prisma.substrateSurfaceOption.delete({ where: { id } })
|
||||
return
|
||||
case 'SYSTEM_NAME_OPTION':
|
||||
await prisma.substrateSystemNameOption.delete({ where: { id } })
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
@ -1158,6 +1254,10 @@ class AssetCenterService {
|
||||
dimensions: input.dimensions,
|
||||
lossFactor: resolvedLossFactor,
|
||||
})
|
||||
const resolvedBusinessData = resolveBusinessDataForVariantCreate({
|
||||
businessData: input.businessData,
|
||||
uvConfig: input.uvConfig,
|
||||
})
|
||||
|
||||
const created = await assetVariantRepository.createWithRelations({
|
||||
assetFamily: { connect: { id: input.assetFamilyId } },
|
||||
@ -1176,7 +1276,7 @@ class AssetCenterService {
|
||||
assemblyBasis: toJsonValue(input.assemblyBasis),
|
||||
compatibilityRules: toJsonValue(input.compatibilityRules),
|
||||
namingFields: toJsonValue(input.namingFields),
|
||||
businessData: toJsonValue(input.businessData),
|
||||
businessData: toJsonValue(resolvedBusinessData),
|
||||
weightKg: input.weightKg ?? null,
|
||||
costPrice: input.costPrice ?? null,
|
||||
lossFactor: resolvedLossFactor,
|
||||
@ -1311,8 +1411,25 @@ class AssetCenterService {
|
||||
const nextFinishId = input.finishId !== undefined ? input.finishId : existing.finishId
|
||||
const nextModelUrl = input.modelUrl?.trim() ?? existing.modelUrl
|
||||
const nextLossFactor = input.lossFactor !== undefined ? input.lossFactor : existing.lossFactor
|
||||
const existingBusinessData = isRecord(existing.businessData) ? existing.businessData : null
|
||||
const nextBusinessData =
|
||||
input.businessData !== undefined || input.uvConfig !== undefined
|
||||
? resolveBusinessDataForVariantUpdate({
|
||||
existingBusinessData,
|
||||
businessData: input.businessData,
|
||||
uvConfig: input.uvConfig,
|
||||
})
|
||||
: undefined
|
||||
const existingUvConfig = readAssetVariantUvConfig(existingBusinessData)
|
||||
const nextUvConfig =
|
||||
input.uvConfig !== undefined
|
||||
? normalizeAssetVariantUvConfig(input.uvConfig)
|
||||
: existingUvConfig
|
||||
const didModelUrlChange =
|
||||
input.modelUrl !== undefined && trimOrNull(input.modelUrl) !== trimOrNull(existing.modelUrl)
|
||||
const didRenderConfigChange =
|
||||
nextFinishId !== existing.finishId ||
|
||||
!areVariantUvConfigsEqual(existingUvConfig, nextUvConfig)
|
||||
|
||||
if (
|
||||
existing.status === 'AVAILABLE' &&
|
||||
@ -1419,7 +1536,7 @@ class AssetCenterService {
|
||||
namingFields:
|
||||
input.namingFields !== undefined ? toJsonValue(input.namingFields) : undefined,
|
||||
businessData:
|
||||
input.businessData !== undefined ? toJsonValue(input.businessData) : undefined,
|
||||
nextBusinessData !== undefined ? toJsonValue(nextBusinessData) : undefined,
|
||||
weightKg: input.weightKg === undefined ? undefined : input.weightKg,
|
||||
costPrice: input.costPrice === undefined ? undefined : input.costPrice,
|
||||
lossFactor: input.lossFactor === undefined ? undefined : input.lossFactor,
|
||||
@ -1439,6 +1556,16 @@ class AssetCenterService {
|
||||
})
|
||||
}
|
||||
|
||||
if (didRenderConfigChange) {
|
||||
return this.maybeGenerateVariantThumbnail({
|
||||
variant: updatedVariant,
|
||||
renderBaseUrl: options?.renderBaseUrl,
|
||||
updatedBy: input.updatedBy ?? null,
|
||||
force: true,
|
||||
skip: Boolean(trimOrNull(input.thumbnailUrl)),
|
||||
})
|
||||
}
|
||||
|
||||
return updatedVariant
|
||||
}
|
||||
|
||||
|
||||
@ -91,6 +91,8 @@ export interface SubstrateDTO {
|
||||
code: string
|
||||
name: string
|
||||
description: string | null
|
||||
systemNameOptionId: string | null
|
||||
systemNameOptionName: string | null
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
environmentGradeId: string
|
||||
@ -121,6 +123,7 @@ export type SubstrateDictionaryType =
|
||||
| 'ENVIRONMENT_GRADE'
|
||||
| 'THICKNESS_SPEC'
|
||||
| 'SURFACE_OPTION'
|
||||
| 'SYSTEM_NAME_OPTION'
|
||||
|
||||
export interface SubstrateDictionaryOptionDTO {
|
||||
id: string
|
||||
@ -140,6 +143,7 @@ export interface SubstrateDictionaryBundleDTO {
|
||||
environmentGrades: SubstrateDictionaryOptionDTO[]
|
||||
thicknessSpecs: SubstrateDictionaryOptionDTO[]
|
||||
surfaceOptions: SubstrateDictionaryOptionDTO[]
|
||||
systemNameOptions: SubstrateDictionaryOptionDTO[]
|
||||
}
|
||||
|
||||
export interface CreateAssetFamilyInput {
|
||||
@ -219,6 +223,7 @@ export interface UpdateAssetVariantInput {
|
||||
export interface CreateSubstrateInput {
|
||||
code?: string
|
||||
description?: string | null
|
||||
systemNameOptionId?: string | null
|
||||
categoryId: string
|
||||
environmentGradeId: string
|
||||
thicknessSpecId: string
|
||||
@ -233,6 +238,7 @@ export interface CreateSubstrateInput {
|
||||
export interface UpdateSubstrateInput {
|
||||
code?: string
|
||||
description?: string | null
|
||||
systemNameOptionId?: string | null
|
||||
categoryId?: string
|
||||
environmentGradeId?: string
|
||||
thicknessSpecId?: string
|
||||
|
||||
@ -375,17 +375,20 @@ class MaterialService {
|
||||
throw new AppError(ErrorCode.FILE_NOT_FOUND, '材质不存在')
|
||||
}
|
||||
|
||||
if (material.isPreset) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '系统预设材质不允许删除')
|
||||
}
|
||||
|
||||
// 检查使用情况
|
||||
const usageCount = await materialRepository.getUsageCount(id)
|
||||
if (usageCount > 0) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
`该材质仍被 ${usageCount} 处业务引用,无法删除`
|
||||
)
|
||||
}
|
||||
|
||||
if (hardDelete) {
|
||||
// 硬删除时,检查是否有关联
|
||||
if (usageCount > 0) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
`该材质已被 ${usageCount} 个板件使用,无法删除`
|
||||
)
|
||||
}
|
||||
await materialRepository.delete(id)
|
||||
} else {
|
||||
// 软删除
|
||||
@ -550,17 +553,19 @@ class MaterialService {
|
||||
// 检查分类下是否有材质
|
||||
const materialCount = await materialCategoryRepository.getMaterialCount(id, false)
|
||||
|
||||
if (hardDelete) {
|
||||
if (materialCount > 0) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
`该分类下还有 ${materialCount} 个材质,无法删除`
|
||||
)
|
||||
}
|
||||
await materialCategoryRepository.delete(id)
|
||||
} else {
|
||||
await materialCategoryRepository.deactivate(id)
|
||||
if (materialCount > 0) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
`该分类下还有 ${materialCount} 个材质,无法删除`
|
||||
)
|
||||
}
|
||||
|
||||
if (hardDelete) {
|
||||
await materialCategoryRepository.delete(id)
|
||||
return
|
||||
}
|
||||
|
||||
await materialCategoryRepository.delete(id)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user