feat: polish home assembly and material workflows

This commit is contained in:
WuYongJie 2026-04-16 18:42:37 +08:00
parent 44677da317
commit 56a29b8be2
37 changed files with 3630 additions and 758 deletions

View File

@ -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={() => {

View File

@ -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')
})
})

View File

@ -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()
})
})

View File

@ -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: [],

View File

@ -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) {

View File

@ -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(

View File

@ -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(),
})

View File

@ -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<{

View File

@ -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(

View File

@ -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(),
})

View 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 },
},
}),
)
})
})

View File

@ -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 }
)

View File

@ -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')
})
})

View File

@ -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' && (

View File

@ -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,
}),
)
})
})
})

View File

@ -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()
})
})
})

View File

@ -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 = [

View File

@ -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

View File

@ -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>

View File

@ -14,8 +14,8 @@ interface HomeWorkspaceSwitcherProps {
const tabs: Array<{ key: HomeWorkspaceTab; label: string; hint: string }> = [
{
key: 'products',
label: '正式产品',
hint: '继续承接旧 CAD 日常查看与编辑链路',
label: '已入库产品',
hint: '查看已完成正式入库的产品与后续维护入口',
},
{
key: 'drafts',

View File

@ -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 },
),

View File

@ -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 ||

View File

@ -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('空间冲突')]),
)
})
})

View File

@ -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)),
}
}

View 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)
}

View File

@ -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> {

View File

@ -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)
})
})

View File

@ -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 },
},
},
},
})
})
})

View File

@ -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)
)
}
/**

View File

@ -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
}

View File

@ -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')
})

View File

@ -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)

View File

@ -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')
})
})

View File

@ -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
}

View File

@ -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

View File

@ -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)
}
/**