diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma
index 8f4f1933..35a307b5 100644
--- a/furniture-3d-system/prisma/schema.prisma
+++ b/furniture-3d-system/prisma/schema.prisma
@@ -436,9 +436,20 @@ model PlanningDraft {
model Substrate {
id String @id @default(cuid())
code String @unique
- name String @unique
+ name String
description String? @db.Text
- densityKgM3 Float?
+ categoryId String
+ category SubstrateCategory @relation(fields: [categoryId], references: [id])
+ environmentGradeId String
+ environmentGrade SubstrateEnvironmentGrade @relation(fields: [environmentGradeId], references: [id])
+ thicknessSpecId String
+ thicknessSpec SubstrateThicknessSpec @relation(fields: [thicknessSpecId], references: [id])
+ surfaceOptionId String?
+ surfaceOption SubstrateSurfaceOption? @relation(fields: [surfaceOptionId], references: [id])
+ systemNameOptionId String
+ systemNameOption SubstrateSystemNameOption @relation(fields: [systemNameOptionId], references: [id])
+ suggestedPrice Decimal? @db.Decimal(10, 2)
+ weightKg Float?
metadata Json?
isActive Boolean @default(true)
@@ -446,12 +457,111 @@ model Substrate {
creator User? @relation("SubstrateCreator", fields: [createdBy], references: [id], onDelete: SetNull)
assetVariants AssetVariant[]
+ finishes SubstrateFinish[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
@@index([createdBy])
+ @@index([categoryId])
+ @@index([environmentGradeId])
+ @@index([thicknessSpecId])
+ @@index([surfaceOptionId])
+ @@index([systemNameOptionId])
+ // 5字段唯一约束已移除:同一板材规格+不同花色=不同基材,各自有独立BC-XXXX编码
+}
+
+model SubstrateFinish {
+ id String @id @default(cuid())
+ substrateId String
+ substrate Substrate @relation(fields: [substrateId], references: [id], onDelete: Cascade)
+ finishId String
+ finish Material @relation(fields: [finishId], references: [id], onDelete: Cascade)
+
+ createdAt DateTime @default(now())
+
+ @@unique([substrateId, finishId])
+ @@index([substrateId])
+ @@index([finishId])
+}
+
+model SubstrateCategory {
+ id String @id @default(cuid())
+ name String @unique
+ sortOrder Int @default(0)
+ isActive Boolean @default(true)
+
+ substrates Substrate[]
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([sortOrder])
+ @@index([isActive])
+}
+
+model SubstrateEnvironmentGrade {
+ id String @id @default(cuid())
+ name String @unique
+ sortOrder Int @default(0)
+ isActive Boolean @default(true)
+
+ substrates Substrate[]
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([sortOrder])
+ @@index([isActive])
+}
+
+model SubstrateThicknessSpec {
+ id String @id @default(cuid())
+ name String @unique
+ thicknessMm Float
+ sheetLengthMm Float @default(2440)
+ sheetWidthMm Float @default(1220)
+ sortOrder Int @default(0)
+ isActive Boolean @default(true)
+
+ substrates Substrate[]
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([sortOrder])
+ @@index([isActive])
+}
+
+model SubstrateSurfaceOption {
+ id String @id @default(cuid())
+ name String @unique
+ sortOrder Int @default(0)
+ isActive Boolean @default(true)
+
+ substrates Substrate[]
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([sortOrder])
+ @@index([isActive])
+}
+
+model SubstrateSystemNameOption {
+ id String @id @default(cuid())
+ name String @unique
+ sortOrder Int @default(0)
+ isActive Boolean @default(true)
+
+ substrates Substrate[]
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([sortOrder])
+ @@index([isActive])
}
model AssetFamily {
@@ -521,6 +631,8 @@ model AssetVariant {
businessData Json?
weightKg Float?
costPrice Decimal? @db.Decimal(10, 2)
+ lossFactor Float?
+ estimatedCostPrice Decimal? @db.Decimal(10, 2)
inheritedFromVariantId String?
inheritedFromVariant AssetVariant? @relation("AssetVariantInheritance", fields: [inheritedFromVariantId], references: [id], onDelete: SetNull)
@@ -554,6 +666,7 @@ model AssetVariant {
@@index([reviewedById])
@@index([rejectedById])
@@index([lastModifiedById])
+ @@index([inheritedFromVariantId])
}
model SeriesTemplate {
@@ -1070,6 +1183,7 @@ model Material {
// 基本信息
name String // 材质名称,如 "橡木纹-自然色"
+ code String @unique // 花色编码
categoryId String // 分类 ID
category MaterialCategory @relation(fields: [categoryId], references: [id])
@@ -1101,6 +1215,7 @@ model Material {
// 关联板件材质
panelMaterials PanelMaterial[]
assetVariants AssetVariant[] @relation("AssetVariantFinish")
+ substrateFinishes SubstrateFinish[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
diff --git a/furniture-3d-system/src/app/api/platform/schemas.ts b/furniture-3d-system/src/app/api/platform/schemas.ts
index 015f3a7c..e69d92f3 100644
--- a/furniture-3d-system/src/app/api/platform/schemas.ts
+++ b/furniture-3d-system/src/app/api/platform/schemas.ts
@@ -57,6 +57,8 @@ export const assetVariantCreateSchema = z.object({
businessData: z.record(z.string(), z.unknown()).optional().nullable(),
weightKg: z.number().optional().nullable(),
costPrice: z.number().optional().nullable(),
+ lossFactor: z.number().positive().optional().nullable(),
+ estimatedCostPrice: z.number().optional().nullable(),
inheritedFromVariantId: z.string().trim().min(1).optional().nullable(),
})
@@ -86,6 +88,8 @@ export const assetVariantUpdateSchema = z.object({
businessData: z.record(z.string(), z.unknown()).optional().nullable(),
weightKg: z.number().optional().nullable(),
costPrice: z.number().optional().nullable(),
+ lossFactor: z.number().positive().optional().nullable(),
+ estimatedCostPrice: z.number().optional().nullable(),
})
export const assetReviewActionSchema = z.object({
@@ -94,22 +98,62 @@ export const assetReviewActionSchema = z.object({
})
export const substrateCreateSchema = z.object({
- code: z.string().trim().min(1),
- name: z.string().trim().min(1),
+ code: z.string().trim().min(1).optional(),
description: z.string().optional().nullable(),
- densityKgM3: z.number().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(),
+ systemNameOptionId: z.string().trim().min(1),
+ suggestedPrice: z.number().nonnegative().optional().nullable(),
+ weightKg: z.number().positive().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(),
- name: z.string().trim().min(1).optional(),
description: z.string().optional().nullable(),
- densityKgM3: z.number().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(),
+ systemNameOptionId: z.string().trim().min(1).optional(),
+ suggestedPrice: z.number().nonnegative().optional().nullable(),
+ weightKg: z.number().positive().optional().nullable(),
+ finishIds: z.array(z.string().trim().min(1)).optional(),
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
isActive: z.boolean().optional(),
})
+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(),
+})
+
export const assetCadImportPayloadSchema = z.object({
assetType: z.enum(['BOARD', 'COMPONENT', 'HARDWARE']),
familyMode: assetCadImportFamilyModeSchema,
@@ -123,8 +167,7 @@ export const assetCadImportPayloadSchema = z.object({
sizeLabel: z.string().trim().optional().nullable(),
substrateId: z.string().trim().min(1).optional().nullable(),
finishId: z.string().trim().min(1).optional().nullable(),
- weightKg: z.number().optional().nullable(),
- costPrice: z.number().optional().nullable(),
+ lossFactor: z.number().positive().optional().nullable(),
}).superRefine((value, ctx) => {
if (value.familyMode === 'existing' && !value.familyId) {
ctx.addIssue({
@@ -198,6 +241,8 @@ export const createDraftGroupSchema = z.object({
z.object({
key: z.string().trim().min(1),
defaultName: z.string().trim().min(1),
+ candidateSnapshot: z.record(z.string(), z.unknown()).optional().nullable(),
+ assemblyWorkspace: z.record(z.string(), z.unknown()).optional().nullable(),
familySelection: z.object({
desktopFamilyId: z.string().trim().min(1),
legGroupFamilyId: z.string().trim().min(1).optional().nullable(),
diff --git a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.test.tsx b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.test.tsx
index 30d281af..67e38c10 100644
--- a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.test.tsx
+++ b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.test.tsx
@@ -1,46 +1,21 @@
-import { render, screen, waitFor, within } from '@testing-library/react'
+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, updateMaterialCategory } from '@/lib/open-product/platform-api'
-import type { MaterialCategoryDTO, MaterialDTO, SubstrateDTO } from '@/server/services'
+import { createSubstrate } from '@/lib/open-product/platform-api'
+import type { MaterialCategoryDTO, MaterialDTO, SubstrateDTO, SubstrateDictionaryBundleDTO } from '@/server/services'
jest.mock('next/link', () => ({
__esModule: true,
- default: ({
- href,
- children,
- ...props
- }: {
- href: string
- children: ReactNode
- }) => (
-
- {children}
-
+ default: ({ href, children, ...props }: { href: string; children: ReactNode }) => (
+ {children}
),
}))
jest.mock('@/components/ui/Modal', () => ({
__esModule: true,
- default: ({
- isOpen,
- title,
- children,
- footer,
- }: {
- isOpen: boolean
- title?: ReactNode
- children: ReactNode
- footer?: ReactNode
- }) =>
- isOpen ? (
-
-
{title}
-
{children}
-
{footer}
-
- ) : null,
+ default: ({ isOpen, title, children, footer }: { isOpen: boolean; title?: ReactNode; children: ReactNode; footer?: ReactNode }) =>
+ isOpen ? : null,
}))
jest.mock('@/components/ui/DeleteConfirmDialog', () => ({
@@ -50,77 +25,67 @@ jest.mock('@/components/ui/DeleteConfirmDialog', () => ({
jest.mock('@/components/ui/Toast', () => ({
__esModule: true,
- useToast: () => ({
- toasts: [],
- success: jest.fn(),
- error: jest.fn(),
- warning: jest.fn(),
- info: jest.fn(),
- removeToast: jest.fn(),
- }),
+ useToast: () => ({ toasts: [], success: jest.fn(), error: jest.fn(), warning: jest.fn(), info: jest.fn(), removeToast: jest.fn() }),
ToastContainer: () => null,
}))
jest.mock('@/components/platform/PlatformSplitLayout', () => ({
__esModule: true,
- PlatformSplitLayout: ({
- leftContent,
- rightContent,
- }: {
- leftContent: ReactNode
- rightContent: ReactNode
- }) => (
-
-
{leftContent}
-
{rightContent}
-
- ),
+ PlatformSplitLayout: ({ leftContent, rightContent }: { leftContent: ReactNode; rightContent: ReactNode }) => {leftContent}
{rightContent}
,
}))
jest.mock('@/lib/open-product/platform-api', () => ({
__esModule: true,
createMaterial: jest.fn(),
- createMaterialCategory: jest.fn(),
createSubstrate: jest.fn(),
deleteMaterial: jest.fn(),
- deleteMaterialCategory: jest.fn(),
deleteSubstrate: jest.fn(),
updateMaterial: jest.fn(),
- updateMaterialCategory: jest.fn(),
updateSubstrate: jest.fn(),
}))
const mockedCreateSubstrate = jest.mocked(createSubstrate)
-const mockedUpdateMaterialCategory = jest.mocked(updateMaterialCategory)
+
+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-18e1', type: 'SYSTEM_NAME_OPTION', name: '18E1级刨花板', sortOrder: 0, isActive: true, createdAt: new Date(), updatedAt: new Date() }],
+}
function renderClient() {
return render(
,
)
@@ -131,97 +96,57 @@ describe('MaterialLibraryClient', () => {
jest.clearAllMocks()
})
- it('新建基材后使用后端返回的完整字段刷新列表', async () => {
+ it('新建基材时按规则字段提交', async () => {
const actor = userEvent.setup()
mockedCreateSubstrate.mockResolvedValue({
substrate: {
id: 'substrate-2',
code: 'PB25',
- name: '25mm 颗粒板',
- description: '后端标准说明',
- densityKgM3: 720,
- metadata: { source: 'server' },
+ name: '18E1级刨花板',
+ description: null,
+ categoryId: 'cat-board',
+ categoryName: '密度板',
+ environmentGradeId: 'grade-e1',
+ environmentGradeName: 'E1',
+ thicknessSpecId: 'th-18',
+ thicknessSpecName: '18',
+ thicknessMm: 18,
+ sheetLengthMm: 2440,
+ sheetWidthMm: 1220,
+ surfaceOptionId: 'surface-double',
+ surfaceOptionName: '双贴',
+ systemNameOptionId: 'sys-18e1',
+ systemNameOptionName: '18E1级刨花板',
+ suggestedPrice: 200,
+ weightKg: null,
+ finishes: [],
+ metadata: null,
isActive: true,
- createdAt: new Date('2026-04-08T10:00:00.000Z'),
- updatedAt: new Date('2026-04-08T10:00:00.000Z'),
+ createdAt: new Date(),
+ updatedAt: new Date(),
},
})
renderClient()
+ // Switch to substrate tab first
+ await actor.click(screen.getByRole('button', { name: /基材录入/ }))
await actor.click(screen.getByRole('button', { name: '新建基材' }))
-
- const dialog = screen.getByRole('dialog', { name: '新建基材' })
- const inputs = within(dialog).getAllByRole('textbox')
- await actor.type(inputs[0], 'PB25')
- await actor.type(inputs[1], '25mm 颗粒板')
- await actor.type(inputs[2], '前端输入的旧说明')
- await actor.type(within(dialog).getByPlaceholderText('例如 680'), '680')
- await actor.click(within(dialog).getByRole('button', { name: '创建基材' }))
+ await actor.type(screen.getByLabelText('板材编码'), 'PB25')
+ await actor.type(screen.getByLabelText('售价'), '200')
+ await actor.click(screen.getByRole('button', { name: '创建' }))
await waitFor(() => {
expect(mockedCreateSubstrate).toHaveBeenCalledWith(
expect.objectContaining({
code: 'PB25',
- name: '25mm 颗粒板',
+ categoryId: 'cat-board',
+ environmentGradeId: 'grade-e1',
+ thicknessSpecId: 'th-18',
+ surfaceOptionId: 'surface-double',
+ systemNameOptionId: 'sys-18e1',
}),
)
})
-
- await waitFor(() => {
- expect(screen.getByText('25mm 颗粒板')).toBeInTheDocument()
- expect(screen.getByText('PB25')).toBeInTheDocument()
- expect(screen.getByText('720 kg/m³')).toBeInTheDocument()
- })
- })
-
- it('编辑材质分类后,饰面搜索会立即使用新分类名', async () => {
- const actor = userEvent.setup()
- mockedUpdateMaterialCategory.mockResolvedValue({
- category: {
- id: 'cat-1',
- name: '深木纹',
- icon: 'Texture',
- sortOrder: 1,
- materialCount: 1,
- },
- })
-
- renderClient()
-
- await actor.click(screen.getByRole('button', { name: '饰面花色' }))
-
- const categoryTitle = screen.getAllByText('木纹').find((element) => element.tagName === 'P')
- if (!categoryTitle) {
- throw new Error('未找到分类标题')
- }
-
- const categoryBlock = categoryTitle.closest('div')?.parentElement
- if (!categoryBlock) {
- throw new Error('未找到分类区块')
- }
-
- const categoryButtons = within(categoryBlock).getAllByRole('button')
- await actor.click(categoryButtons[0])
-
- const dialog = screen.getByRole('dialog', { name: '编辑材质分类' })
- const categoryNameInput = within(dialog).getByPlaceholderText('例如 木纹、石纹、纯色')
- await actor.clear(categoryNameInput)
- await actor.type(categoryNameInput, '深木纹')
- await actor.click(within(dialog).getByRole('button', { name: '保存修改' }))
-
- await waitFor(() => {
- expect(mockedUpdateMaterialCategory).toHaveBeenCalledWith(
- 'cat-1',
- expect.objectContaining({ name: '深木纹' }),
- )
- })
-
- const searchInput = screen.getByPlaceholderText('搜索分类或饰面名称...')
- await actor.clear(searchInput)
- await actor.type(searchInput, '深木纹')
-
- expect(screen.getByText('白橡木纹')).toBeInTheDocument()
})
})
-
diff --git a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx
index 951769fe..ce0d34e7 100644
--- a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx
+++ b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx
@@ -1,9 +1,11 @@
'use client'
import Link from 'next/link'
-import { startTransition, useMemo, useState } from 'react'
+import { startTransition, useMemo, useRef, useState } from 'react'
import {
ArrowLeft,
+ Check,
+ ImagePlus,
Package2,
Pencil,
Plus,
@@ -15,7 +17,6 @@ import {
import Button from '@/components/ui/Button'
import { DeleteConfirmDialog } from '@/components/ui/DeleteConfirmDialog'
import Input from '@/components/ui/Input'
-import Modal from '@/components/ui/Modal'
import Select from '@/components/ui/Select'
import { ToastContainer, useToast } from '@/components/ui/Toast'
import { PlatformSplitLayout } from '@/components/platform/PlatformSplitLayout'
@@ -23,387 +24,390 @@ import type { PlatformMetricItem } from '@/components/platform/types'
import { cn } from '@/lib/utils'
import {
createMaterial,
- createMaterialCategory,
createSubstrate,
+ createSubstrateDictionary,
deleteMaterial,
- deleteMaterialCategory,
deleteSubstrate,
updateMaterial,
- updateMaterialCategory,
updateSubstrate,
+ uploadMaterialThumbnail,
} from '@/lib/open-product/platform-api'
-import type { MaterialCategoryDTO, MaterialDTO, SubstrateDTO } from '@/server/services'
+import type {
+ MaterialCategoryDTO,
+ MaterialDTO,
+ SubstrateDTO,
+ SubstrateDictionaryBundleDTO,
+} from '@/server/services'
-type LibraryTab = 'substrate' | 'finish'
+type LibraryTab = 'finish' | 'substrate'
+
+type RightPanel =
+ | { mode: 'empty' }
+ | { mode: 'create-finish' }
+ | { mode: 'view-finish'; id: string }
+ | { mode: 'edit-finish'; id: string }
+ | { mode: 'create-substrate' }
+ | { mode: 'view-substrate'; id: string }
+ | { mode: 'edit-substrate'; id: string }
interface MaterialLibraryClientProps {
initialSubstrates: SubstrateDTO[]
initialFinishes: MaterialDTO[]
initialMaterialCategories: MaterialCategoryDTO[]
+ initialSubstrateDictionaries: SubstrateDictionaryBundleDTO
metrics: PlatformMetricItem[]
}
type SubstrateFormState = {
- code: string
- name: string
- description: string
- densityKgM3: string
-}
-
-type CategoryFormState = {
- name: string
- icon: string
- sortOrder: string
+ categoryId: string
+ categoryText: string
+ environmentGradeId: string
+ thicknessSpecId: string
+ surfaceOptionId: string
+ systemNameOptionId: string
+ suggestedPrice: string
+ weightKg: string
+ finishIds: string[]
+ newFinishName: string
}
type FinishFormState = {
name: string
categoryId: string
- color: string
+ thumbnailUrl: string
+ thumbnailPreview: string
}
type DeleteTarget =
| { type: 'substrate'; id: string; name: string }
- | { type: 'category'; id: string; name: string }
| { type: 'finish'; id: string; name: string }
function sortByName(items: T[]): T[] {
- return [...items].sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
+ return [...items].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
}
-function sortMaterialCategories(items: MaterialCategoryDTO[]): MaterialCategoryDTO[] {
- return [...items].sort((left, right) => {
- const orderDiff = (left.sortOrder ?? 0) - (right.sortOrder ?? 0)
- if (orderDiff !== 0) return orderDiff
- return left.name.localeCompare(right.name, 'zh-CN')
+function sortCategories(items: MaterialCategoryDTO[]): MaterialCategoryDTO[] {
+ return [...items].sort((a, b) => {
+ const diff = (a.sortOrder ?? 0) - (b.sortOrder ?? 0)
+ return diff !== 0 ? diff : a.name.localeCompare(b.name, 'zh-CN')
})
}
-function groupFinishesByCategory(
- finishes: MaterialDTO[],
- categories: MaterialCategoryDTO[],
- activeCategoryId: string,
-) {
- const categoryMap = new Map(categories.map((item) => [item.id, item]))
- const grouped = categories
- .map((category) => ({
- category,
- finishes: sortByName(finishes.filter((finish) => finish.categoryId === category.id)),
- }))
- .filter(({ category, finishes }) => {
- if (activeCategoryId === 'all') {
- return finishes.length > 0 || (category.materialCount ?? 0) > 0
- }
- return category.id === activeCategoryId
- })
-
- const uncategorized = sortByName(
- finishes.filter((finish) => !finish.categoryId || !categoryMap.has(finish.categoryId)),
- )
-
- if (activeCategoryId === 'all' && uncategorized.length > 0) {
- grouped.push({
- category: {
- id: 'uncategorized',
- name: '未分类',
- icon: null,
- sortOrder: Number.MAX_SAFE_INTEGER,
- materialCount: uncategorized.length,
- },
- finishes: uncategorized,
- })
- }
-
- return grouped
-}
-
-function toOptionalNumber(value: string): number | null {
+function toNum(value: string): number | null {
if (!value.trim()) return null
- const parsed = Number(value)
- return Number.isFinite(parsed) ? parsed : null
+ const n = Number(value)
+ return Number.isFinite(n) ? n : null
}
-function emptySubstrateForm(): SubstrateFormState {
- return { code: '', name: '', description: '', densityKgM3: '' }
-}
-
-function emptyCategoryForm(): CategoryFormState {
- return { name: '', icon: '', sortOrder: '' }
-}
-
-function emptyFinishForm(categoryId = ''): FinishFormState {
- return { name: '', categoryId, color: '#CCCCCC' }
+function emptySubstrateForm(dict: SubstrateDictionaryBundleDTO): SubstrateFormState {
+ return {
+ categoryId: dict.categories[0]?.id ?? '',
+ categoryText: dict.categories[0]?.name ?? '',
+ environmentGradeId: dict.environmentGrades[0]?.id ?? '',
+ thicknessSpecId: dict.thicknessSpecs[0]?.id ?? '',
+ surfaceOptionId: dict.surfaceOptions[0]?.id ?? '',
+ systemNameOptionId: dict.systemNameOptions[0]?.id ?? '',
+ suggestedPrice: '',
+ weightKg: '',
+ finishIds: [],
+ newFinishName: '',
+ }
}
export default function MaterialLibraryClient({
initialSubstrates,
initialFinishes,
initialMaterialCategories,
+ initialSubstrateDictionaries,
metrics,
}: MaterialLibraryClientProps) {
const toast = useToast()
- const [activeTab, setActiveTab] = useState('substrate')
+ const [activeTab, setActiveTab] = useState('finish')
const [searchText, setSearchText] = useState('')
const [selectedCategoryId, setSelectedCategoryId] = useState('all')
const [substrates, setSubstrates] = useState(sortByName(initialSubstrates))
const [finishes, setFinishes] = useState(sortByName(initialFinishes))
- const [materialCategories, setMaterialCategories] = useState(
- sortMaterialCategories(initialMaterialCategories),
- )
+ const [categories] = useState(sortCategories(initialMaterialCategories))
+ const [dictionaries] = useState(initialSubstrateDictionaries)
const [submitting, setSubmitting] = useState(false)
const [deleteTarget, setDeleteTarget] = useState(null)
+ const [previewUrl, setPreviewUrl] = useState(null)
+ const [uploading, setUploading] = useState(false)
+ const fileInputRef = useRef(null)
- const [substrateModalOpen, setSubstrateModalOpen] = useState(false)
- const [substrateModalMode, setSubstrateModalMode] = useState<'create' | 'edit'>('create')
- const [substrateForm, setSubstrateForm] = useState(emptySubstrateForm())
- const [editingSubstrateId, setEditingSubstrateId] = useState(null)
+ // Right panel state
+ const [panel, setPanel] = useState({ mode: 'empty' })
- const [categoryModalOpen, setCategoryModalOpen] = useState(false)
- const [categoryModalMode, setCategoryModalMode] = useState<'create' | 'edit'>('create')
- const [categoryForm, setCategoryForm] = useState(emptyCategoryForm())
- const [editingCategoryId, setEditingCategoryId] = useState(null)
+ // Form states
+ const [finishForm, setFinishForm] = useState({
+ name: '', categoryId: '', thumbnailUrl: '', thumbnailPreview: '',
+ })
+ const [substrateForm, setSubstrateForm] = useState(
+ emptySubstrateForm(initialSubstrateDictionaries),
+ )
+ const [finishPickerOpen, setFinishPickerOpen] = useState(false)
- const [finishModalOpen, setFinishModalOpen] = useState(false)
- const [finishModalMode, setFinishModalMode] = useState<'create' | 'edit'>('create')
- const [finishForm, setFinishForm] = useState(emptyFinishForm())
- const [editingFinishId, setEditingFinishId] = useState(null)
-
- const filteredSubstrates = useMemo(() => {
- if (!searchText.trim()) return substrates
- const keyword = searchText.trim().toLowerCase()
- return substrates.filter((item) =>
- [item.name, item.code, item.description ?? ''].some((value) =>
- value.toLowerCase().includes(keyword),
- ),
- )
- }, [searchText, substrates])
+ // Dict lookups
+ const dictById = useMemo(() => {
+ const all = [
+ ...dictionaries.categories,
+ ...dictionaries.environmentGrades,
+ ...dictionaries.thicknessSpecs,
+ ...dictionaries.surfaceOptions,
+ ...dictionaries.systemNameOptions,
+ ]
+ const map = new Map()
+ for (const item of all) map.set(item.id, item)
+ return map
+ }, [dictionaries])
+ // Filtered lists
const filteredFinishes = useMemo(() => {
- const keyword = searchText.trim().toLowerCase()
- return finishes.filter((item) => {
- const matchesCategory = selectedCategoryId === 'all' || item.categoryId === selectedCategoryId
- if (!matchesCategory) return false
- if (!keyword) return true
- return [item.name, item.categoryName ?? ''].some((value) =>
- value.toLowerCase().includes(keyword),
- )
+ const kw = searchText.trim().toLowerCase()
+ return finishes.filter((f) => {
+ if (selectedCategoryId !== 'all' && f.categoryId !== selectedCategoryId) return false
+ if (!kw) return true
+ return [f.name, f.code, f.categoryName ?? ''].some((v) => v.toLowerCase().includes(kw))
})
}, [finishes, searchText, selectedCategoryId])
- const groupedFinishes = useMemo(
- () => groupFinishesByCategory(filteredFinishes, materialCategories, selectedCategoryId),
- [filteredFinishes, materialCategories, selectedCategoryId],
- )
+ const filteredSubstrates = useMemo(() => {
+ if (!searchText.trim()) return substrates
+ const kw = searchText.trim().toLowerCase()
+ return substrates.filter((s) =>
+ [s.name, s.code, s.categoryName, s.environmentGradeName].some((v) =>
+ v.toLowerCase().includes(kw),
+ ),
+ )
+ }, [substrates, searchText])
- function openCreateSubstrateModal() {
- setSubstrateModalMode('create')
- setEditingSubstrateId(null)
- setSubstrateForm(emptySubstrateForm())
- setSubstrateModalOpen(true)
+ const isSolidWood = substrateForm.categoryId === 'cat-solid' || substrateForm.categoryText === '实木'
+
+ // Filter finishes by material type
+ const filteredFinishesForSubstrate = useMemo(() => {
+ if (isSolidWood) {
+ return finishes.filter((f) => f.categoryName === '实木花色')
+ }
+ return finishes.filter((f) => f.categoryName !== '实木花色')
+ }, [finishes, isSolidWood])
+
+ // Get the default finish category for inline creation
+ const defaultFinishCategoryId = useMemo(() => {
+ if (isSolidWood) {
+ return categories.find((c) => c.name === '实木花色')?.id ?? ''
+ }
+ return categories.find((c) => c.name === '木纹贴面花色')?.id ?? categories[0]?.id ?? ''
+ }, [categories, isSolidWood])
+
+ const previewSubstrateName = useMemo(() => {
+ const cat = substrateForm.categoryText.trim() || dictById.get(substrateForm.categoryId)?.name || ''
+ const env = dictById.get(substrateForm.environmentGradeId)?.name ?? ''
+ const thick = dictionaries.thicknessSpecs.find((t) => t.id === substrateForm.thicknessSpecId)
+ const surface = isSolidWood ? '' : (dictById.get(substrateForm.surfaceOptionId)?.name ?? '')
+ const finishNames = substrateForm.finishIds
+ .map((fid) => finishes.find((f) => f.id === fid)?.name)
+ .filter(Boolean) as string[]
+ const newFinish = substrateForm.newFinishName.trim()
+ if (newFinish) finishNames.push(newFinish)
+ if (!cat || !env || !thick) return ''
+ const thickness = thick.name.toUpperCase()
+ const finishPart = finishNames.length > 0 ? finishNames.join(' ') : ''
+ return [thickness, env, cat, finishPart, surface].filter(Boolean).join(' ')
+ }, [substrateForm, dictById, dictionaries.thicknessSpecs, finishes, isSolidWood])
+
+ // Selected items
+ const selectedFinish = useMemo(() => {
+ if (panel.mode === 'view-finish' || panel.mode === 'edit-finish') {
+ return finishes.find((f) => f.id === panel.id) ?? null
+ }
+ return null
+ }, [panel, finishes])
+
+ const selectedSubstrate = useMemo(() => {
+ if (panel.mode === 'view-substrate' || panel.mode === 'edit-substrate') {
+ return substrates.find((s) => s.id === panel.id) ?? null
+ }
+ return null
+ }, [panel, substrates])
+
+ // ---- Actions ----
+
+ function selectFinish(f: MaterialDTO) {
+ setPanel({ mode: 'view-finish', id: f.id })
}
- function openEditSubstrateModal(substrate: SubstrateDTO) {
- setSubstrateModalMode('edit')
- setEditingSubstrateId(substrate.id)
- setSubstrateForm({
- code: substrate.code,
- name: substrate.name,
- description: substrate.description ?? '',
- densityKgM3: substrate.densityKgM3 != null ? String(substrate.densityKgM3) : '',
- })
- setSubstrateModalOpen(true)
+ function selectSubstrate(s: SubstrateDTO) {
+ setPanel({ mode: 'view-substrate', id: s.id })
}
- function openCreateCategoryModal() {
- setCategoryModalMode('create')
- setEditingCategoryId(null)
- setCategoryForm(emptyCategoryForm())
- setCategoryModalOpen(true)
+ function startCreateFinish() {
+ const defaultCat = selectedCategoryId !== 'all' ? selectedCategoryId : categories[0]?.id ?? ''
+ setFinishForm({ name: '', categoryId: defaultCat, thumbnailUrl: '', thumbnailPreview: '' })
+ setPanel({ mode: 'create-finish' })
}
- function openEditCategoryModal(category: MaterialCategoryDTO) {
- setCategoryModalMode('edit')
- setEditingCategoryId(category.id)
- setCategoryForm({
- name: category.name,
- icon: category.icon ?? '',
- sortOrder: String(category.sortOrder ?? 0),
- })
- setCategoryModalOpen(true)
- }
-
- function openCreateFinishModal() {
- const preferredCategoryId =
- selectedCategoryId !== 'all' ? selectedCategoryId : materialCategories[0]?.id ?? ''
- setFinishModalMode('create')
- setEditingFinishId(null)
- setFinishForm(emptyFinishForm(preferredCategoryId))
- setFinishModalOpen(true)
- }
-
- function openEditFinishModal(finish: MaterialDTO) {
- setFinishModalMode('edit')
- setEditingFinishId(finish.id)
+ function startEditFinish(f: MaterialDTO) {
setFinishForm({
- name: finish.name,
- categoryId: finish.categoryId,
- color: finish.color || '#CCCCCC',
+ name: f.name,
+ categoryId: f.categoryId,
+ thumbnailUrl: f.thumbnailUrl ?? '',
+ thumbnailPreview: f.thumbnailUrl ?? '',
})
- setFinishModalOpen(true)
+ setPanel({ mode: 'edit-finish', id: f.id })
+ }
+
+ function startCreateSubstrate() {
+ setSubstrateForm(emptySubstrateForm(dictionaries))
+ setPanel({ mode: 'create-substrate' })
+ }
+
+ function startEditSubstrate(s: SubstrateDTO) {
+ setSubstrateForm({
+ categoryId: s.categoryId,
+ categoryText: s.categoryName,
+ environmentGradeId: s.environmentGradeId,
+ thicknessSpecId: s.thicknessSpecId,
+ surfaceOptionId: s.surfaceOptionId,
+ systemNameOptionId: s.systemNameOptionId,
+ suggestedPrice: s.suggestedPrice != null ? String(s.suggestedPrice) : '',
+ weightKg: s.weightKg != null ? String(s.weightKg) : '',
+ finishIds: s.finishes.map((f) => f.id),
+ newFinishName: '',
+ })
+ setPanel({ mode: 'edit-substrate', id: s.id })
+ }
+
+ async function handleSaveFinish() {
+ if (!finishForm.name.trim()) { toast.error('请填写花色名称'); return }
+ if (!finishForm.categoryId) { toast.error('请选择花色分类'); return }
+
+ setSubmitting(true)
+ try {
+ if (panel.mode === 'create-finish') {
+ const result = await createMaterial({
+ name: finishForm.name.trim(),
+ categoryId: finishForm.categoryId,
+ thumbnailUrl: finishForm.thumbnailUrl.trim() || null,
+ color: '#CCCCCC',
+ })
+ startTransition(() => {
+ setFinishes((cur) => sortByName([...cur, result]))
+ setPanel({ mode: 'view-finish', id: result.id })
+ toast.success('花色已创建')
+ })
+ return
+ }
+ if (panel.mode !== 'edit-finish') return
+ const result = await updateMaterial(panel.id, {
+ name: finishForm.name.trim(),
+ categoryId: finishForm.categoryId,
+ thumbnailUrl: finishForm.thumbnailUrl.trim() || null,
+ })
+ startTransition(() => {
+ setFinishes((cur) => sortByName(cur.map((f) => (f.id === panel.id ? result : f))))
+ setPanel({ mode: 'view-finish', id: panel.id })
+ toast.success('花色已更新')
+ })
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : '保存失败')
+ } finally {
+ setSubmitting(false)
+ }
}
async function handleSaveSubstrate() {
- if (!substrateForm.code.trim() || !substrateForm.name.trim()) {
- toast.error('请先补齐基材编码和名称')
- return
+ const solidWood = substrateForm.categoryId === 'cat-solid' || substrateForm.categoryText.trim() === '实木'
+ if (!substrateForm.categoryText.trim() || !substrateForm.environmentGradeId || !substrateForm.thicknessSpecId || !substrateForm.systemNameOptionId) {
+ toast.error('请补齐必填选项'); return
+ }
+ if (!solidWood && !substrateForm.surfaceOptionId) {
+ toast.error('请选择贴面类型'); return
}
setSubmitting(true)
try {
- if (substrateModalMode === 'create') {
- const result = await createSubstrate({
- code: substrateForm.code.trim(),
- name: substrateForm.name.trim(),
- description: substrateForm.description.trim() || null,
- densityKgM3: toOptionalNumber(substrateForm.densityKgM3),
+ // Auto-create new material category if user typed a new name
+ let categoryId = substrateForm.categoryId
+ if (!categoryId && substrateForm.categoryText.trim()) {
+ const newCat = await createSubstrateDictionary({
+ type: 'CATEGORY',
+ name: substrateForm.categoryText.trim(),
})
+ categoryId = newCat.id
+ }
+ if (!categoryId) {
+ toast.error('请输入或选择材质'); setSubmitting(false); return
+ }
+ // Create inline finish if name provided and not empty
+ let finishIds = [...substrateForm.finishIds]
+ const newFinishName = substrateForm.newFinishName.trim()
+ if (newFinishName) {
+ // Check if a finish with this name already exists locally
+ const existingFinish = finishes.find((f) => f.name === newFinishName)
+ if (existingFinish) {
+ if (!finishIds.includes(existingFinish.id)) {
+ finishIds = [...finishIds, existingFinish.id]
+ }
+ } else {
+ const newFinish = await createMaterial({
+ name: newFinishName,
+ categoryId: defaultFinishCategoryId,
+ color: '#CCCCCC',
+ })
+ startTransition(() => {
+ setFinishes((cur) => sortByName([...cur, newFinish]))
+ })
+ finishIds = [...finishIds, newFinish.id]
+ }
+ }
+
+ const payload = {
+ categoryId,
+ environmentGradeId: substrateForm.environmentGradeId,
+ thicknessSpecId: substrateForm.thicknessSpecId,
+ surfaceOptionId: solidWood ? undefined : substrateForm.surfaceOptionId,
+ systemNameOptionId: substrateForm.systemNameOptionId,
+ suggestedPrice: toNum(substrateForm.suggestedPrice),
+ weightKg: toNum(substrateForm.weightKg),
+ finishIds: finishIds.length > 0 ? finishIds : undefined,
+ }
+
+ if (panel.mode === 'create-substrate') {
+ const result = await createSubstrate(payload)
startTransition(() => {
- setSubstrates((current) => sortByName([...current, result.substrate]))
- setSubstrateModalOpen(false)
+ setSubstrates((cur) => sortByName([...cur, result.substrate]))
+ setPanel({ mode: 'view-substrate', id: result.substrate.id })
toast.success('基材已创建')
})
return
}
-
- if (!editingSubstrateId) return
- const result = await updateSubstrate(editingSubstrateId, {
- code: substrateForm.code.trim(),
- name: substrateForm.name.trim(),
- description: substrateForm.description.trim() || null,
- densityKgM3: toOptionalNumber(substrateForm.densityKgM3),
- })
-
+ if (panel.mode !== 'edit-substrate') return
+ const result = await updateSubstrate(panel.id, payload)
startTransition(() => {
- setSubstrates((current) =>
- sortByName(current.map((item) => (item.id === editingSubstrateId ? result.substrate : item))),
- )
- setSubstrateModalOpen(false)
+ setSubstrates((cur) => sortByName(cur.map((s) => (s.id === panel.id ? result.substrate : s))))
+ setPanel({ mode: 'view-substrate', id: panel.id })
toast.success('基材已更新')
})
} catch (error) {
- toast.error(error instanceof Error ? error.message : '基材保存失败,请稍后再试')
+ toast.error(error instanceof Error ? error.message : '保存失败')
} finally {
setSubmitting(false)
}
}
- async function handleSaveCategory() {
- if (!categoryForm.name.trim()) {
- toast.error('请填写分类名称')
- return
- }
-
- setSubmitting(true)
+ async function handleThumbnailUpload(file: File) {
+ if (!file) return
+ setUploading(true)
try {
- if (categoryModalMode === 'create') {
- const result = await createMaterialCategory({
- name: categoryForm.name.trim(),
- icon: categoryForm.icon.trim() || null,
- sortOrder: toOptionalNumber(categoryForm.sortOrder) ?? undefined,
- })
-
- startTransition(() => {
- setMaterialCategories((current) => sortMaterialCategories([...current, result.category]))
- setSelectedCategoryId(result.category.id)
- setCategoryModalOpen(false)
- toast.success('材质分类已创建')
- })
- return
- }
-
- if (!editingCategoryId) return
- const result = await updateMaterialCategory(editingCategoryId, {
- name: categoryForm.name.trim(),
- icon: categoryForm.icon.trim() || null,
- sortOrder: toOptionalNumber(categoryForm.sortOrder) ?? undefined,
- })
-
- startTransition(() => {
- setMaterialCategories((current) =>
- sortMaterialCategories(
- current.map((item) => (item.id === editingCategoryId ? result.category : item)),
- ),
- )
- setFinishes((current) =>
- sortByName(
- current.map((item) =>
- item.categoryId === editingCategoryId
- ? {
- ...item,
- categoryName: result.category.name,
- }
- : item,
- ),
- ),
- )
- setCategoryModalOpen(false)
- toast.success('材质分类已更新')
- })
+ const cat = categories.find((c) => c.id === finishForm.categoryId)
+ const url = await uploadMaterialThumbnail(file, cat?.name)
+ setFinishForm((c) => ({ ...c, thumbnailUrl: url, thumbnailPreview: url }))
+ toast.success('贴图已上传')
} catch (error) {
- toast.error(error instanceof Error ? error.message : '材质分类保存失败,请稍后再试')
+ toast.error(error instanceof Error ? error.message : '上传失败')
} finally {
- setSubmitting(false)
- }
- }
-
- async function handleSaveFinish() {
- if (!finishForm.name.trim()) {
- toast.error('请填写饰面/花色名称')
- return
- }
- if (!finishForm.categoryId) {
- toast.error('请先选择材质分类')
- return
- }
-
- setSubmitting(true)
- try {
- if (finishModalMode === 'create') {
- const material = await createMaterial({
- name: finishForm.name.trim(),
- categoryId: finishForm.categoryId,
- color: finishForm.color || '#CCCCCC',
- })
-
- startTransition(() => {
- setFinishes((current) => sortByName([...current, material]))
- setSelectedCategoryId(material.categoryId)
- setFinishModalOpen(false)
- toast.success('饰面/花色已创建')
- })
- return
- }
-
- if (!editingFinishId) return
- const material = await updateMaterial(editingFinishId, {
- name: finishForm.name.trim(),
- categoryId: finishForm.categoryId,
- color: finishForm.color || '#CCCCCC',
- })
-
- startTransition(() => {
- setFinishes((current) =>
- sortByName(current.map((item) => (item.id === editingFinishId ? material : item))),
- )
- setSelectedCategoryId(material.categoryId)
- setFinishModalOpen(false)
- toast.success('饰面/花色已更新')
- })
- } catch (error) {
- toast.error(error instanceof Error ? error.message : '饰面/花色保存失败,请稍后再试')
- } finally {
- setSubmitting(false)
+ setUploading(false)
}
}
@@ -414,455 +418,457 @@ export default function MaterialLibraryClient({
if (deleteTarget.type === 'substrate') {
await deleteSubstrate(deleteTarget.id)
startTransition(() => {
- setSubstrates((current) => current.filter((item) => item.id !== deleteTarget.id))
+ setSubstrates((cur) => cur.filter((s) => s.id !== deleteTarget.id))
+ if (panel.mode !== 'empty' && 'id' in panel && panel.id === deleteTarget.id) setPanel({ mode: 'empty' })
setDeleteTarget(null)
toast.success('基材已删除')
})
return
}
-
- if (deleteTarget.type === 'category') {
- await deleteMaterialCategory(deleteTarget.id)
- startTransition(() => {
- setMaterialCategories((current) => current.filter((item) => item.id !== deleteTarget.id))
- setFinishes((current) =>
- sortByName(
- current.map((item) =>
- item.categoryId === deleteTarget.id
- ? {
- ...item,
- categoryName: undefined,
- }
- : item,
- ),
- ),
- )
- if (selectedCategoryId === deleteTarget.id) {
- setSelectedCategoryId('all')
- }
- setDeleteTarget(null)
- toast.success('材质分类已删除')
- })
- return
- }
-
await deleteMaterial(deleteTarget.id)
startTransition(() => {
- setFinishes((current) => current.filter((item) => item.id !== deleteTarget.id))
+ setFinishes((cur) => cur.filter((f) => f.id !== deleteTarget.id))
+ if (panel.mode !== 'empty' && 'id' in panel && panel.id === deleteTarget.id) setPanel({ mode: 'empty' })
setDeleteTarget(null)
- toast.success('饰面/花色已删除')
+ toast.success('花色已删除')
})
} catch (error) {
- toast.error(error instanceof Error ? error.message : '删除失败,请稍后再试')
+ toast.error(error instanceof Error ? error.message : '删除失败')
} finally {
setSubmitting(false)
}
}
+ // ---- Helpers ----
+ function isFinishSelected(id: string) {
+ return (panel.mode === 'view-finish' || panel.mode === 'edit-finish') && panel.id === id
+ }
+ function isSubstrateSelected(id: string) {
+ return (panel.mode === 'view-substrate' || panel.mode === 'edit-substrate') && panel.id === id
+ }
+
+ // ============ Render ============
+
+ // ----- Right panel content -----
+ let rightContent: React.ReactNode
+
+ if (panel.mode === 'empty') {
+ rightContent = (
+
+
+
从左侧选择一项查看详情
+
或点击「新建」添加
+
+
+ )
+ } else if (panel.mode === 'view-finish' && selectedFinish) {
+ const f = selectedFinish
+ rightContent = (
+
+
+
花色详情
+
+
+
+
+
+ {/* Thumbnail preview */}
+ {f.thumbnailUrl ? (
+

setPreviewUrl(f.thumbnailUrl)}
+ />
+ ) : (
+
+ 无贴图
+
+ )}
+ {/* Detail fields */}
+
+
+
+
+
+
分类
+
{f.categoryName ?? '-'}
+
+
+
+
+ )
+ } else if (panel.mode === 'create-finish' || panel.mode === 'edit-finish') {
+ const isCreate = panel.mode === 'create-finish'
+ rightContent = (
+
+
+
+ {isCreate ? '新建花色' : '编辑花色'}
+
+
+
+
+
setFinishForm((c) => ({ ...c, name: e.target.value }))} placeholder="例如 橡木纹-自然色" />
+
+
+ )
+ } else if (panel.mode === 'view-substrate' && selectedSubstrate) {
+ const s = selectedSubstrate
+ rightContent = (
+
+
+
基材详情
+
+
+
+
+
+
+
+
+
+
+
环保等级
{s.environmentGradeName}
+
+
+
板尺寸
{s.sheetLengthMm}×{s.sheetWidthMm}
+
售价
{s.suggestedPrice != null ? `¥${s.suggestedPrice}` : '-'}
+
重量
{s.weightKg != null ? `${s.weightKg} kg` : '-'}
+
+ {s.finishes.length > 0 && (
+
+
关联花色
+
+ {s.finishes.map((f) => (
+
+ {f.thumbnailUrl &&
}
+ {f.name}
+
+ ))}
+
+
+ )}
+
+
+ )
+ } else if (panel.mode === 'create-substrate' || panel.mode === 'edit-substrate') {
+ const isCreate = panel.mode === 'create-substrate'
+ rightContent = (
+
+
+
+ {isCreate ? '新建基材' : '编辑基材'}
+
+
+
+
+ {/* Auto-generated name preview */}
+
+
+
+
材质
+
{
+ const text = e.target.value
+ const match = dictionaries.categories.find((d) => d.name === text)
+ setSubstrateForm((c) => ({
+ ...c,
+ categoryText: text,
+ categoryId: match ? match.id : '',
+ }))
+ }}
+ placeholder="选择或输入材质名称"
+ className="w-full rounded-apple-md border border-apple-separator bg-apple-bg-primary px-3 py-1.5 text-sm text-apple-label-primary placeholder:text-apple-label-tertiary"
+ />
+
+
+
+ {/* Finish multi-select */}
+
+
+
花色
+
+
+
+
+ {/* Inline new finish input */}
+
+ setSubstrateForm((c) => ({ ...c, newFinishName: e.target.value }))}
+ placeholder="输入新花色名称,保存时自动创建"
+ className="flex-1 rounded-apple-md border border-apple-separator bg-apple-bg-primary px-3 py-1.5 text-sm text-apple-label-primary placeholder:text-apple-label-tertiary"
+ />
+ {substrateForm.newFinishName.trim() && (
+
+ )}
+
+ {/* Pending new finish badge */}
+ {substrateForm.newFinishName.trim() && (
+
+
+ + {substrateForm.newFinishName.trim()}(新建)
+
+
+
+ )}
+ {substrateForm.finishIds.length > 0 && (
+
+ {substrateForm.finishIds.map((fid) => {
+ const f = finishes.find((x) => x.id === fid)
+ if (!f) return null
+ return (
+
+ {f.name}
+
+
+ )
+ })}
+
+ )}
+ {finishPickerOpen && (
+
+ {filteredFinishesForSubstrate.length === 0 ? (
+
暂无花色,请在上方输入框新建
+ ) : (
+ filteredFinishesForSubstrate.map((f) => {
+ const selected = substrateForm.finishIds.includes(f.id)
+ return (
+
+ )
+ })
+ )}
+
+ )}
+
+
+
+
+
+
+ )
+ } else {
+ rightContent = (
+
+ )
+ }
+
return (
<>
-
-
-
- 基础资料库
-
-
- 维护基材、分类和饰面花色,补录后返回资产中心继续操作。
-
-
-
-
-
-
-
-
- {substrates.length} 基材
- ·
- {materialCategories.length} 分类
- ·
- {finishes.length} 饰面
-
-
-
- 缺基材或饰面时先来这里补录,保存后回 CAD 导入或规格款弹窗继续操作。
-
-
- }
- rightContent={
- <>
+ /* ---- LEFT: List panel ---- */
+
+ {/* Tab switcher */}
-
+ {/* Search + new button */}
- setSearchText(event.target.value)}
- placeholder={activeTab === 'substrate' ? '搜索基材名称、编码...' : '搜索分类或饰面名称...'}
- className="w-full rounded-apple-lg border border-apple-separator bg-apple-bg-primary py-2 pl-9 pr-3 text-sm text-apple-label-primary placeholder:text-apple-label-tertiary focus:border-apple-blue focus:outline-none focus:ring-1 focus:ring-apple-blue/30 transition"
- />
- {searchText ? (
- setSearchText('')}
- className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-label-primary"
- >
-
-
- ) : null}
+ setSearchText(e.target.value)}
+ placeholder={activeTab === 'finish' ? '搜索花色...' : '搜索基材...'}
+ className="w-full rounded-apple-lg border border-apple-separator bg-apple-bg-primary py-2 pl-9 pr-3 text-sm text-apple-label-primary" />
+ {searchText && (
+ setSearchText('')} className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-apple-label-secondary">
+ )}
- {activeTab === 'substrate' ? (
-
-
- 新建基材
-
+ {activeTab === 'finish' ? (
+
新建
) : (
-
-
-
- 新建分类
-
-
-
- 新建饰面
-
-
+
新建
)}
- {activeTab === 'substrate' ? (
- filteredSubstrates.length === 0 ? (
-
-
-
暂无基材
-
点击上方「新建基材」添加第一条记录
-
- ) : (
-
- {filteredSubstrates.map((substrate) => (
-
-
- {substrate.name}
-
-
- {substrate.code}
- {substrate.densityKgM3 ? {substrate.densityKgM3} kg/m³ : null}
-
-
openEditSubstrateModal(substrate)}
- className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-label-primary transition"
- >
-
-
-
setDeleteTarget({ type: 'substrate', id: substrate.id, name: substrate.name })}
- className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-red transition"
- >
-
-
-
- ))}
-
- )
- ) : (
- <>
-
-
setSelectedCategoryId('all')}
- className={cn(
- 'rounded-full border px-3 py-1.5 text-xs transition',
- selectedCategoryId === 'all'
- ? 'border-apple-blue/30 bg-apple-blue/10 text-apple-blue'
- : 'border-apple-separator bg-transparent text-apple-label-secondary hover:text-apple-label-primary',
- )}
- >
- 全部
+ {/* Category filter (finish tab only) */}
+ {activeTab === 'finish' && (
+
+ setSelectedCategoryId('all')}
+ className={cn('rounded-full border px-2.5 py-1 text-[11px]', selectedCategoryId === 'all' ? 'border-apple-blue/30 bg-apple-blue/10 text-apple-blue' : 'border-apple-separator text-apple-label-secondary')}>
+ 全部
+
+ {categories.map((cat) => (
+ setSelectedCategoryId(cat.id)}
+ className={cn('rounded-full border px-2.5 py-1 text-[11px]', selectedCategoryId === cat.id ? 'border-apple-blue/30 bg-apple-blue/10 text-apple-blue' : 'border-apple-separator text-apple-label-secondary')}>
+ {cat.name}
- {materialCategories.map((category) => (
-
- setSelectedCategoryId((current) => (current === category.id ? 'all' : category.id))
- }
- className={cn(
- 'rounded-full border px-3 py-1.5 text-xs transition',
- selectedCategoryId === category.id
- ? 'border-apple-blue/30 bg-apple-blue/10 text-apple-blue'
- : 'border-apple-separator bg-transparent text-apple-label-secondary hover:text-apple-label-primary',
- )}
- >
- {category.name}
-
- ))}
-
-
- {groupedFinishes.length === 0 ? (
-
-
-
暂无饰面花色
-
点击上方「新建饰面」添加第一条记录
-
- ) : (
-
- {groupedFinishes.map((group, groupIndex) => (
-
0 && 'border-t border-apple-separator pt-4')}
- >
-
-
-
- {group.category.name}
-
-
- {group.finishes.length}
-
-
- {group.category.id !== 'uncategorized' ? (
-
-
openEditCategoryModal(group.category)}
- className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-label-primary transition"
- >
-
-
-
setDeleteTarget({ type: 'category', id: group.category.id, name: group.category.name })}
- className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-red transition"
- >
-
-
-
- ) : null}
-
-
- {group.finishes.map((finish) => (
-
-
-
- {finish.name}
-
-
openEditFinishModal(finish)}
- className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-label-primary transition"
- >
-
-
-
setDeleteTarget({ type: 'finish', id: finish.id, name: finish.name })}
- className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-red transition"
- >
-
-
-
- ))}
-
-
- ))}
-
- )}
- >
+ ))}
+
)}
- >
+
+ {/* Scrollable list */}
+
+ {activeTab === 'finish' ? (
+ filteredFinishes.length === 0 ? (
+
暂无花色
+ ) : (
+ filteredFinishes.map((f) => (
+
selectFinish(f)}
+ className={cn('flex w-full items-center gap-2.5 rounded-apple-md border px-3 py-2.5 text-left transition', isFinishSelected(f.id) ? 'border-apple-blue/40 bg-apple-blue/5' : 'border-apple-separator bg-apple-bg-primary hover:bg-apple-bg-secondary')}>
+ {f.thumbnailUrl ? (
+
+ ) : (
+
+ )}
+
+
{f.name}
+
{f.code} · {f.categoryName ?? ''}
+
+
+ ))
+ )
+ ) : (
+ filteredSubstrates.length === 0 ? (
+
暂无基材
+ ) : (
+ filteredSubstrates.map((s) => (
+
selectSubstrate(s)}
+ className={cn('flex w-full flex-col gap-1 rounded-apple-md border px-3 py-2.5 text-left transition', isSubstrateSelected(s.id) ? 'border-apple-blue/40 bg-apple-blue/5' : 'border-apple-separator bg-apple-bg-primary hover:bg-apple-bg-secondary')}>
+ {s.name}
+
+ {s.code} · {s.categoryName} · {s.environmentGradeName} · {s.thicknessMm}MM
+
+
+ ))
+ )
+ )}
+
+
}
+ rightContent={rightContent}
/>
- setSubstrateModalOpen(false)}
- title={substrateModalMode === 'create' ? '新建基材' : '编辑基材'}
- size="medium"
- footer={
-
- setSubstrateModalOpen(false)}>
- 取消
-
-
- {substrateModalMode === 'create' ? '创建基材' : '保存修改'}
-
-
- }
- >
-
-
-
- setCategoryModalOpen(false)}
- title={categoryModalMode === 'create' ? '新建材质分类' : '编辑材质分类'}
- size="medium"
- footer={
-
- setCategoryModalOpen(false)}>
- 取消
-
-
- {categoryModalMode === 'create' ? '创建分类' : '保存修改'}
-
-
- }
- >
-
-
-
- setFinishModalOpen(false)}
- title={finishModalMode === 'create' ? '新建饰面 / 花色' : '编辑饰面 / 花色'}
- size="medium"
- footer={
-
- setFinishModalOpen(false)}>
- 取消
-
-
- {finishModalMode === 'create' ? '创建饰面' : '保存修改'}
-
-
- }
- >
-
-
setFinishForm((current) => ({ ...current, name: event.target.value }))}
- placeholder="例如 白橡木纹"
- />
-
setFinishForm((current) => ({ ...current, categoryId: event.target.value }))}
- options={materialCategories.map((category) => ({ value: category.id, label: category.name }))}
- placeholder="请选择分类"
- />
-
-
setFinishForm((current) => ({ ...current, color: event.target.value }))}
- placeholder="#CCCCCC"
- className="flex-1"
- />
-
-
-
-
-
+ {/* Delete confirm */}
setDeleteTarget(null)}
onConfirm={handleConfirmDelete}
- title={
- deleteTarget?.type === 'substrate'
- ? '删除基材'
- : deleteTarget?.type === 'category'
- ? '删除材质分类'
- : '删除饰面 / 花色'
- }
- description={`确认删除「${deleteTarget?.name ?? ''}」吗?删除后已引用规格款会自动回落为空值。`}
+ title={deleteTarget?.type === 'substrate' ? '删除基材' : '删除饰面花色'}
+ description={`确认删除「${deleteTarget?.name ?? ''}」吗?`}
confirmText="确认删除"
/>
+ {/* Image preview overlay */}
+ {previewUrl && (
+ setPreviewUrl(null)}>
+
+
setPreviewUrl(null)} className="absolute -right-2 -top-2 z-10 rounded-full bg-white p-1 shadow-apple-md hover:bg-apple-bg-secondary">
+

e.stopPropagation()} />
+
+
+ )}
+
>
)
diff --git a/furniture-3d-system/src/app/asset-center/material-library/page.tsx b/furniture-3d-system/src/app/asset-center/material-library/page.tsx
index 09183f54..e48f8556 100644
--- a/furniture-3d-system/src/app/asset-center/material-library/page.tsx
+++ b/furniture-3d-system/src/app/asset-center/material-library/page.tsx
@@ -6,10 +6,11 @@ import MaterialLibraryClient from './MaterialLibraryClient'
export default async function MaterialLibraryPage() {
await requireSession('/login')
- const [substrates, finishes, materialCategories] = await Promise.all([
+ const [substrates, finishes, materialCategories, substrateDictionaries] = await Promise.all([
assetCenterService.listSubstrates(),
materialService.findAll({ limit: 500 }),
materialService.findAllCategories(),
+ assetCenterService.getSubstrateDictionaryBundle(),
])
return (
@@ -17,21 +18,22 @@ export default async function MaterialLibraryPage() {
initialSubstrates={substrates}
initialFinishes={finishes}
initialMaterialCategories={materialCategories}
+ initialSubstrateDictionaries={substrateDictionaries}
metrics={[
{
- label: '基材',
- value: substrates.length,
- icon: ,
+ label: '饰面花色',
+ value: finishes.length,
+ icon: ,
},
{
- label: '材质分类',
+ label: '花色分类',
value: materialCategories.length,
icon: ,
},
{
- label: '饰面/花色',
- value: finishes.length,
- icon: ,
+ label: '板材基材',
+ value: substrates.length,
+ icon: ,
},
]}
/>
diff --git a/furniture-3d-system/src/lib/open-product/platform-api.ts b/furniture-3d-system/src/lib/open-product/platform-api.ts
index 9b1c8801..d72fd971 100644
--- a/furniture-3d-system/src/lib/open-product/platform-api.ts
+++ b/furniture-3d-system/src/lib/open-product/platform-api.ts
@@ -8,10 +8,14 @@ import type {
CreateAssetFamilyInput,
CreateAssetVariantInput,
CreateDraftGroupFromAutoCombinationInput,
+ CreateSubstrateDictionaryInput,
UpdateSubstrateInput,
MaterialCategoryDTO,
CreateSubstrateInput,
SubstrateDTO,
+ SubstrateDictionaryBundleDTO,
+ SubstrateDictionaryOptionDTO,
+ UpdateSubstrateDictionaryInput,
DraftGroupDetailDTO,
FormalizeDraftGroupInput,
FormalizeDraftGroupResult,
@@ -128,8 +132,7 @@ export interface ImportAssetCadPayload {
sizeLabel?: string | null
substrateId?: string | null
finishId?: string | null
- weightKg?: number | null
- costPrice?: number | null
+ lossFactor?: number | null
file: File
}
@@ -149,8 +152,7 @@ export async function importAssetCad(input: ImportAssetCadPayload): Promise {
})
}
+export async function fetchSubstrateDictionaries(): Promise {
+ const data = await requestPlatform<{ dictionaries: SubstrateDictionaryBundleDTO }>(
+ '/api/asset-center/substrate-dictionaries',
+ )
+ return data.dictionaries
+}
+
+export async function createSubstrateDictionary(
+ input: CreateSubstrateDictionaryInput,
+): Promise {
+ const data = await requestPlatform<{ option: SubstrateDictionaryOptionDTO }>(
+ '/api/asset-center/substrate-dictionaries',
+ {
+ method: 'POST',
+ body: JSON.stringify(input),
+ },
+ )
+ return data.option
+}
+
+export async function updateSubstrateDictionary(
+ id: string,
+ input: UpdateSubstrateDictionaryInput & { type: CreateSubstrateDictionaryInput['type'] },
+): Promise {
+ const data = await requestPlatform<{ option: SubstrateDictionaryOptionDTO }>(
+ `/api/asset-center/substrate-dictionaries/${id}`,
+ {
+ method: 'PUT',
+ body: JSON.stringify(input),
+ },
+ )
+ return data.option
+}
+
+export async function deleteSubstrateDictionary(
+ id: string,
+ type: CreateSubstrateDictionaryInput['type'],
+): Promise {
+ await requestPlatform<{ success: boolean }>(
+ `/api/asset-center/substrate-dictionaries/${id}?type=${encodeURIComponent(type)}`,
+ {
+ method: 'DELETE',
+ },
+ )
+}
+
export async function createMaterialCategory(input: {
name: string
icon?: string | null
@@ -229,6 +277,7 @@ export async function deleteMaterialCategory(id: string): Promise {
export async function createMaterial(input: {
name: string
+ code?: string
categoryId: string
color?: string
thumbnailUrl?: string | null
@@ -239,10 +288,29 @@ export async function createMaterial(input: {
})
}
+export async function uploadMaterialThumbnail(file: File, category?: string): Promise {
+ const formData = new FormData()
+ formData.append('file', file)
+ formData.append('type', 'thumbnail')
+ if (category) formData.append('category', category)
+
+ const response = await fetch('/api/admin/materials/upload-texture', {
+ method: 'POST',
+ body: formData,
+ })
+
+ const payload = (await response.json().catch(() => null)) as ApiEnvelope<{ url: string }> | null
+ if (!response.ok || !payload?.success || payload.data === undefined) {
+ throw new Error(payload?.error?.message || '图片上传失败')
+ }
+ return payload.data.url
+}
+
export async function updateMaterial(
id: string,
input: {
name?: string
+ code?: string
categoryId?: string
color?: string
thumbnailUrl?: string | null
@@ -261,7 +329,7 @@ export async function deleteMaterial(id: string): Promise {
})
}
-export async function generateAutoCombinations(
+export async function generateAssistedAssemblyCandidates(
input: {
seriesTemplateId: string
desktopFamilyIds: string[]
@@ -270,7 +338,7 @@ export async function generateAutoCombinations(
},
): Promise {
const data = await requestPlatform<{ result: CombinationGenerationResult }>(
- '/api/auto-combinations/generate',
+ '/api/assisted-assembly/generate',
{
method: 'POST',
body: JSON.stringify(input),
@@ -279,6 +347,8 @@ export async function generateAutoCombinations(
return data.result
}
+export const generateAutoCombinations = generateAssistedAssemblyCandidates
+
export async function createDraftGroup(
input: CreateDraftGroupFromAutoCombinationInput,
): Promise {
diff --git a/furniture-3d-system/src/lib/standard-finish-sync.ts b/furniture-3d-system/src/lib/standard-finish-sync.ts
index f3ab5eab..bb9d8e63 100644
--- a/furniture-3d-system/src/lib/standard-finish-sync.ts
+++ b/furniture-3d-system/src/lib/standard-finish-sync.ts
@@ -49,6 +49,7 @@ export function buildStandardFinishMaterialData(params: {
return {
name: material.name,
+ code: material.name,
categoryId,
thumbnailUrl: merged.thumbnailUrl || defaultThumbnailUrl,
color: merged.color ?? material.color,
diff --git a/furniture-3d-system/src/server/repositories/asset-center.repository.ts b/furniture-3d-system/src/server/repositories/asset-center.repository.ts
index 4bfe3840..13a3a5a8 100644
--- a/furniture-3d-system/src/server/repositories/asset-center.repository.ts
+++ b/furniture-3d-system/src/server/repositories/asset-center.repository.ts
@@ -35,6 +35,15 @@ export interface AssetVariantWithRelations extends AssetVariant {
lastModifier: { id: string; name: string } | null
}
+export interface SubstrateWithRelations extends Substrate {
+ category: { id: string; name: string }
+ environmentGrade: { id: string; name: string }
+ thicknessSpec: { id: string; name: string; thicknessMm: number; sheetLengthMm: number; sheetWidthMm: number }
+ surfaceOption: { id: string; name: string } | null
+ systemNameOption: { id: string; name: string }
+ finishes: { id: string; finish: { id: string; name: string; code: string; thumbnailUrl: string } }[]
+}
+
export interface SubstrateFilter {
isActive?: boolean
search?: string
@@ -60,6 +69,23 @@ const assetVariantRelationInclude = {
lastModifier: { select: { id: true, name: true } },
} satisfies Prisma.AssetVariantInclude
+const substrateRelationInclude = {
+ category: { select: { id: true, name: true } },
+ environmentGrade: { select: { id: true, name: true } },
+ thicknessSpec: {
+ select: {
+ id: true,
+ name: true,
+ thicknessMm: true,
+ sheetLengthMm: true,
+ sheetWidthMm: true,
+ },
+ },
+ surfaceOption: { select: { id: true, name: true } },
+ systemNameOption: { select: { id: true, name: true } },
+ finishes: { include: { finish: { select: { id: true, name: true, code: true, thumbnailUrl: true } } } },
+} satisfies Prisma.SubstrateInclude
+
class AssetFamilyRepositoryClass extends BaseRepository<
AssetFamily,
Prisma.AssetFamilyCreateInput,
@@ -271,6 +297,36 @@ class SubstrateRepositoryClass extends BaseRepository<
return this.prisma.substrate.findUnique({ where: { code } })
}
+ async findByIdWithRelations(id: string): Promise {
+ return this.prisma.substrate.findUnique({
+ where: { id },
+ include: substrateRelationInclude,
+ }) as Promise
+ }
+
+ async findManyWithRelations(
+ options: FindManyOptions = {},
+ ): Promise<{ items: SubstrateWithRelations[]; total: number }> {
+ const { filter, pagination, sort } = options
+ const where = this.buildWhereClause(filter)
+ const orderBy = sort ? { [sort.field]: sort.order } : [{ name: 'asc' as const }]
+ const skip = pagination ? (pagination.page - 1) * pagination.limit : 0
+ const take = pagination?.limit
+
+ const [items, total] = await Promise.all([
+ this.prisma.substrate.findMany({
+ where,
+ orderBy,
+ skip,
+ take,
+ include: substrateRelationInclude,
+ }),
+ this.prisma.substrate.count({ where }),
+ ])
+
+ return { items: items as SubstrateWithRelations[], total }
+ }
+
protected buildWhereClause(filter?: SubstrateFilter): Prisma.SubstrateWhereInput {
if (!filter) return {}
diff --git a/furniture-3d-system/src/server/repositories/material.repository.ts b/furniture-3d-system/src/server/repositories/material.repository.ts
index f7e67ca2..5a0591e4 100644
--- a/furniture-3d-system/src/server/repositories/material.repository.ts
+++ b/furniture-3d-system/src/server/repositories/material.repository.ts
@@ -74,6 +74,7 @@ class MaterialRepositoryClass extends BaseRepository<
where: {
name,
categoryId,
+ isActive: true,
id: excludeId ? { not: excludeId } : undefined,
},
})
@@ -164,6 +165,18 @@ class MaterialRepositoryClass extends BaseRepository<
})
}
+ /**
+ * 获取当前最大 HS-XXXX 编号
+ */
+ async findMaxCode(): Promise {
+ const result = await this.prisma.material.findFirst({
+ where: { code: { startsWith: 'HS-' } },
+ orderBy: { code: 'desc' },
+ select: { code: true },
+ })
+ return result?.code ?? null
+ }
+
/**
* 获取材质使用数量(被多少个板件使用)
*/
diff --git a/furniture-3d-system/src/server/services/asset-center.service.ts b/furniture-3d-system/src/server/services/asset-center.service.ts
index d425d6e1..9004d1a0 100644
--- a/furniture-3d-system/src/server/services/asset-center.service.ts
+++ b/furniture-3d-system/src/server/services/asset-center.service.ts
@@ -1,4 +1,14 @@
-import { Prisma, type AssetStatus, type Substrate } from '@prisma/client'
+import {
+ Prisma,
+ type AssetStatus,
+ type Substrate,
+ type SubstrateCategory,
+ type SubstrateEnvironmentGrade,
+ type SubstrateSurfaceOption,
+ type SubstrateSystemNameOption,
+ type SubstrateThicknessSpec,
+} from '@prisma/client'
+import { prisma } from '@/lib/prisma'
import {
assertNotSelfReview,
assertUserCanReviewAsset,
@@ -9,25 +19,24 @@ import {
validateAssetVariantForReview,
} from '@/lib/open-product/asset-validation'
import { buildAssetVariantIdentityKey } from '@/lib/open-product/asset-identity'
-import type { OpenProductMountPoint } from '@/lib/open-product/types'
import {
- BOARD_LIBRARY_DEFAULTS,
- BOARD_REGULAR_FINISHES,
- BOARD_RUBBER_WOOD_FINISHES,
-} from '@/lib/board-substrate-rules'
+ buildSubstrateDisplayName,
+ calculateEstimatedCost,
+} from '@/lib/open-product/substrate-rules'
+import type { OpenProductMountPoint } from '@/lib/open-product/types'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import {
assetFamilyRepository,
assetVariantRepository,
categoryRepository,
- materialCategoryRepository,
materialRepository,
substrateRepository,
type AssetFamilyFilter,
type AssetVariantFilter,
type AssetFamilyWithRelations,
type AssetVariantWithRelations,
+ type SubstrateWithRelations,
} from '../repositories'
import type {
AssetFamilyDTO,
@@ -36,14 +45,25 @@ import type {
AssetWorkflowAuditDTO,
CreateAssetFamilyInput,
CreateAssetVariantInput,
+ CreateSubstrateDictionaryInput,
CreateSubstrateInput,
- EnsureBoardLibraryDefaultsResult,
SubstrateDTO,
- UpdateSubstrateInput,
+ SubstrateDictionaryBundleDTO,
+ SubstrateDictionaryOptionDTO,
+ SubstrateDictionaryType,
+ UpdateSubstrateDictionaryInput,
UpdateAssetFamilyInput,
UpdateAssetVariantInput,
+ UpdateSubstrateInput,
} from './asset-center.service.types'
+type DictionaryRecord =
+ | SubstrateCategory
+ | SubstrateEnvironmentGrade
+ | SubstrateThicknessSpec
+ | SubstrateSurfaceOption
+ | SubstrateSystemNameOption
+
function isRecord(value: unknown): value is Record {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -80,10 +100,6 @@ function ensureUniqueMessages(messages: string[]): string[] {
return Array.from(new Set(messages))
}
-function isUniqueConstraintError(error: unknown): error is Prisma.PrismaClientKnownRequestError {
- return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002'
-}
-
function toWorkflowDTO(record: {
submittedAt: Date | null
submittedById: string | null
@@ -135,10 +151,11 @@ function toAssetFamilyDTO(record: AssetFamilyWithRelations): AssetFamilyDTO {
}
}
-function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO {
- const costPrice =
- record.costPrice === null || record.costPrice === undefined ? null : Number(record.costPrice)
+function decimalToNumber(value: Prisma.Decimal | number | null | undefined): number | null {
+ return value === null || value === undefined ? null : Number(value)
+}
+function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO {
return {
id: record.id,
assetFamilyId: record.assetFamilyId,
@@ -166,7 +183,9 @@ function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO {
namingFields: isRecord(record.namingFields) ? record.namingFields : null,
businessData: isRecord(record.businessData) ? record.businessData : null,
weightKg: record.weightKg ?? null,
- costPrice,
+ costPrice: decimalToNumber(record.costPrice),
+ lossFactor: record.lossFactor ?? null,
+ estimatedCostPrice: decimalToNumber(record.estimatedCostPrice),
inheritedFromVariantId: record.inheritedFromVariantId,
createdBy: record.createdBy,
creatorName: record.creator?.name ?? null,
@@ -176,13 +195,33 @@ function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO {
}
}
-function toSubstrateDTO(record: Substrate): SubstrateDTO {
+function toSubstrateDTO(record: SubstrateWithRelations): SubstrateDTO {
return {
id: record.id,
code: record.code,
name: record.name,
description: record.description,
- densityKgM3: record.densityKgM3 ?? null,
+ categoryId: record.categoryId,
+ categoryName: record.category.name,
+ environmentGradeId: record.environmentGradeId,
+ environmentGradeName: record.environmentGrade.name,
+ thicknessSpecId: record.thicknessSpecId,
+ thicknessSpecName: record.thicknessSpec.name,
+ thicknessMm: record.thicknessSpec.thicknessMm,
+ sheetLengthMm: record.thicknessSpec.sheetLengthMm,
+ sheetWidthMm: record.thicknessSpec.sheetWidthMm,
+ surfaceOptionId: record.surfaceOptionId,
+ surfaceOptionName: record.surfaceOption?.name ?? null,
+ systemNameOptionId: record.systemNameOptionId,
+ systemNameOptionName: record.systemNameOption.name,
+ suggestedPrice: decimalToNumber(record.suggestedPrice),
+ weightKg: record.weightKg ?? null,
+ finishes: (record.finishes ?? []).map((sf) => ({
+ id: sf.finish.id,
+ name: sf.finish.name,
+ code: sf.finish.code,
+ thumbnailUrl: sf.finish.thumbnailUrl,
+ })),
metadata: isRecord(record.metadata) ? record.metadata : null,
isActive: record.isActive,
createdAt: record.createdAt,
@@ -190,114 +229,58 @@ function toSubstrateDTO(record: Substrate): SubstrateDTO {
}
}
-const BOARD_FINISH_CATEGORY_NAME = '板材花色'
-
-const BOARD_FINISH_COLOR_MAP: Record = {
- 胡桃木色: '#8d6543',
- 北美原木纹色: '#c7a070',
- 经典北美黑胡桃色: '#5f4635',
- 大象灰色: '#8b8a86',
- 荷花白色: '#f5f1ea',
- 烟熏黑色: '#34302f',
- 石墨灰色: '#686668',
- 云霜灰色: '#d3d4d6',
- 珍珠白色: '#f8f7f2',
- 炭黑色: '#1d1d1d',
- 榆木黑色: '#393230',
- 浅黄橡木色: '#d8b178',
- 莫兰橡木色: '#b38d62',
- 柚木色: '#9c734e',
- 胡桃色: '#73533c',
- 原木色: '#d5b07d',
- 黑色: '#202020',
- 冰川白石纹色: '#efefec',
-}
-
-function buildBoardFinishCreateData(name: string, categoryId: string) {
+function toDictionaryOptionDTO(
+ type: SubstrateDictionaryType,
+ record: DictionaryRecord,
+): SubstrateDictionaryOptionDTO {
return {
- name,
- category: { connect: { id: categoryId } },
- thumbnailUrl: '/materials/thumbnails/default.jpg',
- textureUrl: null,
- normalMapUrl: null,
- color: BOARD_FINISH_COLOR_MAP[name] ?? '#CCCCCC',
- metalness: 0,
- roughness: 0.6,
- opacity: 1,
- textureRepeat: 1,
- textureRotation: 0,
- textureSizeMm: 600,
- isPreset: false,
- isActive: true,
- } satisfies Prisma.MaterialCreateInput
-}
-
-async function ensureBoardFinishCategory() {
- const existingCategory = await materialCategoryRepository.findByName(BOARD_FINISH_CATEGORY_NAME)
- if (existingCategory) {
- return existingCategory
- }
-
- try {
- return await materialCategoryRepository.create({
- name: BOARD_FINISH_CATEGORY_NAME,
- icon: 'Palette',
- sortOrder: 10,
- isActive: true,
- })
- } catch (error) {
- if (isUniqueConstraintError(error)) {
- const category = await materialCategoryRepository.findByName(BOARD_FINISH_CATEGORY_NAME)
- if (category) {
- return category
- }
- }
- throw error
+ id: record.id,
+ type,
+ name: record.name,
+ sortOrder: record.sortOrder,
+ isActive: record.isActive,
+ thicknessMm: 'thicknessMm' in record ? record.thicknessMm : null,
+ sheetLengthMm: 'sheetLengthMm' in record ? record.sheetLengthMm : null,
+ sheetWidthMm: 'sheetWidthMm' in record ? record.sheetWidthMm : null,
+ createdAt: record.createdAt,
+ updatedAt: record.updatedAt,
}
}
-async function ensureBoardFinish(name: string, categoryId: string) {
- const existingFinish = await materialRepository.findByNameAndCategory(name, categoryId)
- if (existingFinish) {
- return existingFinish
- }
-
- try {
- return await materialRepository.create(buildBoardFinishCreateData(name, categoryId))
- } catch (error) {
- if (isUniqueConstraintError(error)) {
- const finish = await materialRepository.findByNameAndCategory(name, categoryId)
- if (finish) {
- return finish
- }
- }
- throw error
+function getDictionaryLabel(type: SubstrateDictionaryType): string {
+ switch (type) {
+ case 'CATEGORY':
+ return '材质主类'
+ case 'ENVIRONMENT_GRADE':
+ return '环保等级'
+ case 'THICKNESS_SPEC':
+ return '厚度规格'
+ case 'SURFACE_OPTION':
+ return '贴面类型'
+ case 'SYSTEM_NAME_OPTION':
+ return '系统名称'
}
}
-async function ensureBoardSubstrate(rule: (typeof BOARD_LIBRARY_DEFAULTS)[number]) {
- const existingSubstrate = await substrateRepository.findByCode(rule.code)
- if (existingSubstrate) {
- return existingSubstrate
- }
+function extractPlanarSize(dimensions: Record): { lengthMm: number | null; widthMm: number | null } {
+ const lengthMm =
+ typeof dimensions.length === 'number'
+ ? dimensions.length
+ : typeof dimensions.width === 'number'
+ ? dimensions.width
+ : null
+ const widthMm =
+ typeof dimensions.width === 'number'
+ ? dimensions.width
+ : typeof dimensions.depth === 'number'
+ ? dimensions.depth
+ : typeof dimensions.height === 'number'
+ ? dimensions.height
+ : null
- try {
- return await substrateRepository.create({
- code: rule.code,
- name: rule.name,
- description: `${rule.materialName} ${rule.environmentalGrade} ${rule.thicknessMm}mm`,
- densityKgM3: null,
- metadata: toJsonValue(rule),
- isActive: true,
- })
- } catch (error) {
- if (isUniqueConstraintError(error)) {
- const substrate = await substrateRepository.findByCode(rule.code)
- if (substrate) {
- return substrate
- }
- }
- throw error
+ return {
+ lengthMm: Number.isFinite(lengthMm ?? NaN) ? lengthMm : null,
+ widthMm: Number.isFinite(widthMm ?? NaN) ? widthMm : null,
}
}
@@ -339,25 +322,127 @@ async function ensureVariantCodeAvailable(code: string, currentId?: string): Pro
}
}
-class AssetCenterService {
- async ensureBoardLibraryDefaults(): Promise {
- const finishCategory = await ensureBoardFinishCategory()
+async function loadSubstrateParts(input: {
+ categoryId: string
+ environmentGradeId: string
+ thicknessSpecId: string
+ surfaceOptionId: string
+ systemNameOptionId: string
+}) {
+ const [category, environmentGrade, thicknessSpec, surfaceOption, systemNameOption] =
+ await Promise.all([
+ prisma.substrateCategory.findUnique({ where: { id: input.categoryId } }),
+ prisma.substrateEnvironmentGrade.findUnique({ where: { id: input.environmentGradeId } }),
+ prisma.substrateThicknessSpec.findUnique({ where: { id: input.thicknessSpecId } }),
+ prisma.substrateSurfaceOption.findUnique({ where: { id: input.surfaceOptionId } }),
+ prisma.substrateSystemNameOption.findUnique({ where: { id: input.systemNameOptionId } }),
+ ])
- const finishNames = Array.from(
- new Set([...BOARD_REGULAR_FINISHES, ...BOARD_RUBBER_WOOD_FINISHES, '冰川白石纹色']),
- )
-
- await Promise.all(finishNames.map((finishName) => ensureBoardFinish(finishName, finishCategory.id)))
-
- const substrates = await Promise.all(BOARD_LIBRARY_DEFAULTS.map((rule) => ensureBoardSubstrate(rule)))
-
- return {
- finishCategoryId: finishCategory.id,
- finishNames,
- substrates: substrates.map((item) => toSubstrateDTO(item)),
- }
+ if (!category) {
+ throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '材质主类不存在')
+ }
+ if (!environmentGrade) {
+ throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '环保等级不存在')
+ }
+ if (!thicknessSpec) {
+ throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '厚度规格不存在')
+ }
+ if (!surfaceOption) {
+ throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '贴面类型不存在')
+ }
+ if (!systemNameOption) {
+ throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '系统名称不存在')
}
+ return {
+ category,
+ environmentGrade,
+ thicknessSpec,
+ surfaceOption,
+ systemNameOption,
+ }
+}
+
+async function buildSubstratePayload(input: {
+ categoryId: string
+ environmentGradeId: string
+ thicknessSpecId: string
+ surfaceOptionId: string
+ systemNameOptionId: string
+}) {
+ const parts = await loadSubstrateParts(input)
+ const generatedName = buildSubstrateDisplayName({
+ thicknessLabel: parts.thicknessSpec.name,
+ environmentGradeLabel: parts.environmentGrade.name,
+ systemNameLabel: parts.systemNameOption.name,
+ })
+
+ return {
+ generatedName,
+ parts,
+ }
+}
+
+async function buildSubstratePayloadWithFinishes(input: {
+ categoryId: string
+ environmentGradeId: string
+ thicknessSpecId: string
+ surfaceOptionId: string
+ systemNameOptionId: string
+ finishIds: string[]
+}) {
+ const parts = await loadSubstrateParts(input)
+
+ // Load finish names
+ const finishNames: string[] = []
+ if (input.finishIds.length > 0) {
+ const finishes = await prisma.material.findMany({
+ where: { id: { in: input.finishIds } },
+ select: { name: true },
+ })
+ finishNames.push(...finishes.map((f) => f.name))
+ }
+
+ // Build name: 18MM E1 刨花板 北美原木色 双贴
+ const thickness = parts.thicknessSpec.name.toUpperCase().replace('MM', 'MM')
+ const envGrade = parts.environmentGrade.name
+ const category = parts.category.name
+ const surface = parts.surfaceOption.name
+ const finishPart = finishNames.length > 0 ? finishNames.join(' ') : ''
+
+ const generatedName = [thickness, envGrade, category, finishPart, surface]
+ .filter(Boolean)
+ .join(' ')
+
+ return { generatedName, parts }
+}
+
+async function computeEstimatedCostPrice(params: {
+ substrateId: string | null
+ dimensions: Record
+ lossFactor: number | null
+}): Promise {
+ if (!params.substrateId) {
+ return null
+ }
+
+ const substrate = await substrateRepository.findByIdWithRelations(params.substrateId)
+ if (!substrate) {
+ throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '基材不存在')
+ }
+
+ const size = extractPlanarSize(params.dimensions)
+ return calculateEstimatedCost({
+ lengthMm: size.lengthMm,
+ widthMm: size.widthMm,
+ sheetLengthMm: substrate.thicknessSpec.sheetLengthMm,
+ sheetWidthMm: substrate.thicknessSpec.sheetWidthMm,
+ suggestedPrice: decimalToNumber(substrate.suggestedPrice),
+ lossFactor: params.lossFactor,
+ })
+}
+
+class AssetCenterService {
async listFamilies(filter?: Pick) {
const result = await assetFamilyRepository.findManyWithRelations(filter ? { filter } : undefined)
return result.items.map((item) => toAssetFamilyDTO(item))
@@ -372,12 +457,43 @@ class AssetCenterService {
}
async listSubstrates(): Promise {
- const result = await substrateRepository.findMany({
+ const result = await substrateRepository.findManyWithRelations({
filter: { isActive: true },
})
return result.items.map((item) => toSubstrateDTO(item))
}
+ private async generateNextSubstrateCode(): Promise {
+ const result = await prisma.substrate.findFirst({
+ where: { code: { startsWith: 'BC-' } },
+ orderBy: { code: 'desc' },
+ select: { code: true },
+ })
+ if (!result) return 'BC-0001'
+ const numPart = parseInt(result.code.split('-')[1], 10)
+ if (Number.isNaN(numPart)) return 'BC-0001'
+ return `BC-${String(numPart + 1).padStart(4, '0')}`
+ }
+
+ async getSubstrateDictionaryBundle(): Promise {
+ 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 {
+ categories: categories.map((item) => toDictionaryOptionDTO('CATEGORY', item)),
+ 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)),
+ }
+ }
+
async createFamily(input: CreateAssetFamilyInput): Promise {
const code = input.code.trim()
const name = input.name.trim()
@@ -448,40 +564,113 @@ class AssetCenterService {
}
async createSubstrate(input: CreateSubstrateInput): Promise {
- const code = input.code.trim()
- const name = input.name.trim()
- if (!code || !name) {
- throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '基材编码和名称不能为空')
+ // Check for exact duplicate: same 5 fields + same finishes
+ const inputFinishIds = new Set(input.finishIds ?? [])
+ const existingWithSameSpec = await prisma.substrate.findMany({
+ where: {
+ categoryId: input.categoryId,
+ environmentGradeId: input.environmentGradeId,
+ thicknessSpecId: input.thicknessSpecId,
+ surfaceOptionId: input.surfaceOptionId,
+ systemNameOptionId: input.systemNameOptionId,
+ isActive: true,
+ },
+ include: { finishes: { select: { finishId: true } } },
+ })
+ for (const existing of existingWithSameSpec) {
+ const existingFinishIds = new Set(existing.finishes.map((f) => f.finishId))
+ if (
+ existingFinishIds.size === inputFinishIds.size &&
+ [...inputFinishIds].every((id) => existingFinishIds.has(id))
+ ) {
+ throw new AppError(ErrorCode.VALIDATION_FAILED, '基材库已有相同规格的基材,不可重复创建')
+ }
}
+ // Auto-generate BC-XXXX code
+ const code = input.code?.trim() || await this.generateNextSubstrateCode()
+
if (await substrateRepository.findByCode(code)) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '基材编码已存在')
}
- const created = await substrateRepository.create({
- code,
- name,
- description: trimOrNull(input.description),
- densityKgM3: input.densityKgM3 ?? null,
- metadata: toJsonValue(input.metadata),
- isActive: true,
- creator: input.createdBy ? { connect: { id: input.createdBy } } : undefined,
- })
+ // Build display name including finish names
+ const payload = input.surfaceOptionId
+ ? await buildSubstratePayloadWithFinishes({
+ categoryId: input.categoryId,
+ environmentGradeId: input.environmentGradeId,
+ thicknessSpecId: input.thicknessSpecId,
+ surfaceOptionId: input.surfaceOptionId,
+ systemNameOptionId: input.systemNameOptionId,
+ finishIds: input.finishIds ?? [],
+ })
+ : await buildSubstratePayload({
+ categoryId: input.categoryId,
+ environmentGradeId: input.environmentGradeId,
+ thicknessSpecId: input.thicknessSpecId,
+ surfaceOptionId: input.surfaceOptionId!,
+ systemNameOptionId: input.systemNameOptionId,
+ }).then(async (p) => {
+ // For solid wood (no surface option), build name without surface
+ const finishNames: string[] = []
+ if ((input.finishIds ?? []).length > 0) {
+ const finishRows = await prisma.material.findMany({
+ where: { id: { in: input.finishIds } },
+ select: { name: true },
+ })
+ finishNames.push(...finishRows.map((f) => f.name))
+ }
+ const thickness = p.parts.thicknessSpec.name.toUpperCase()
+ const envGrade = p.parts.environmentGrade.name
+ const category = p.parts.category.name
+ const finishPart = finishNames.length > 0 ? finishNames.join(' ') : ''
+ const generatedName = [thickness, envGrade, category, finishPart].filter(Boolean).join(' ')
+ return { generatedName, parts: p.parts }
+ })
+
+ const created = await prisma.substrate.create({
+ data: {
+ code,
+ name: payload.generatedName,
+ description: trimOrNull(input.description),
+ category: { connect: { id: input.categoryId } },
+ environmentGrade: { connect: { id: input.environmentGradeId } },
+ thicknessSpec: { connect: { id: input.thicknessSpecId } },
+ surfaceOption: input.surfaceOptionId ? { connect: { id: input.surfaceOptionId } } : undefined,
+ systemNameOption: { connect: { id: input.systemNameOptionId } },
+ suggestedPrice: input.suggestedPrice ?? null,
+ weightKg: input.weightKg ?? null,
+ finishes: input.finishIds?.length
+ ? { create: input.finishIds.map((finishId) => ({ finish: { connect: { id: finishId } } })) }
+ : undefined,
+ metadata: toJsonValue(input.metadata),
+ isActive: true,
+ creator: input.createdBy ? { connect: { id: input.createdBy } } : undefined,
+ },
+ include: {
+ category: { select: { id: true, name: true } },
+ environmentGrade: { select: { id: true, name: true } },
+ thicknessSpec: {
+ select: { id: true, name: true, thicknessMm: true, sheetLengthMm: true, sheetWidthMm: true },
+ },
+ surfaceOption: { select: { id: true, name: true } },
+ systemNameOption: { select: { id: true, name: true } },
+ finishes: { include: { finish: { select: { id: true, name: true, code: true, thumbnailUrl: true } } } },
+ },
+ }) as SubstrateWithRelations
return toSubstrateDTO(created)
}
async updateSubstrate(id: string, input: UpdateSubstrateInput): Promise {
- const existing = await substrateRepository.findById(id)
+ const existing = await substrateRepository.findByIdWithRelations(id)
if (!existing) {
throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '基材不存在')
}
const nextCode = input.code?.trim() ?? existing.code
- const nextName = input.name?.trim() ?? existing.name
-
- if (!nextCode || !nextName) {
- throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '基材编码和名称不能为空')
+ if (!nextCode) {
+ throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '基材编码不能为空')
}
const duplicateByCode = await substrateRepository.findByCode(nextCode)
@@ -489,33 +678,98 @@ class AssetCenterService {
throw new AppError(ErrorCode.VALIDATION_FAILED, '基材编码已存在')
}
- const duplicateByName = await substrateRepository.findMany({
- filter: {
- search: nextName,
- },
- })
- const matchedName = duplicateByName.items.find(
- (item) => item.name === nextName && item.id !== id,
- )
- if (matchedName) {
- throw new AppError(ErrorCode.VALIDATION_FAILED, '基材名称已存在')
+ const nextCategoryId = input.categoryId ?? existing.categoryId
+ const nextEnvironmentGradeId = input.environmentGradeId ?? existing.environmentGradeId
+ const nextThicknessSpecId = input.thicknessSpecId ?? existing.thicknessSpecId
+ const nextSurfaceOptionId = input.surfaceOptionId !== undefined ? input.surfaceOptionId : existing.surfaceOptionId
+ const nextSystemNameOptionId = input.systemNameOptionId ?? existing.systemNameOptionId
+ const nextFinishIds = input.finishIds ?? existing.finishes.map((f) => f.finishId)
+
+ // Build name — with or without surface option (solid wood has no surface)
+ let generatedName: string
+ if (nextSurfaceOptionId) {
+ const payload = await buildSubstratePayloadWithFinishes({
+ categoryId: nextCategoryId,
+ environmentGradeId: nextEnvironmentGradeId,
+ thicknessSpecId: nextThicknessSpecId,
+ surfaceOptionId: nextSurfaceOptionId,
+ systemNameOptionId: nextSystemNameOptionId,
+ finishIds: nextFinishIds,
+ })
+ generatedName = payload.generatedName
+ } else {
+ const parts = await loadSubstrateParts({
+ categoryId: nextCategoryId,
+ environmentGradeId: nextEnvironmentGradeId,
+ thicknessSpecId: nextThicknessSpecId,
+ surfaceOptionId: existing.surfaceOptionId ?? dictionaries.surfaceOptions[0]?.id ?? '',
+ systemNameOptionId: nextSystemNameOptionId,
+ })
+ const finishNames: string[] = []
+ if (nextFinishIds.length > 0) {
+ const finishRows = await prisma.material.findMany({
+ where: { id: { in: nextFinishIds } },
+ select: { name: true },
+ })
+ finishNames.push(...finishRows.map((f) => f.name))
+ }
+ const thickness = parts.thicknessSpec.name.toUpperCase()
+ const finishPart = finishNames.length > 0 ? finishNames.join(' ') : ''
+ generatedName = [thickness, parts.environmentGrade.name, parts.category.name, finishPart].filter(Boolean).join(' ')
}
- const updated = await substrateRepository.update(id, {
- code: nextCode,
- name: nextName,
- description:
- input.description !== undefined ? trimOrNull(input.description) : undefined,
- densityKgM3: input.densityKgM3 === undefined ? undefined : input.densityKgM3,
- metadata: input.metadata !== undefined ? toJsonValue(input.metadata) : undefined,
- isActive: input.isActive,
- })
+ const updated = await prisma.substrate.update({
+ where: { id },
+ data: {
+ code: nextCode,
+ name: generatedName,
+ description:
+ input.description !== undefined ? trimOrNull(input.description) : undefined,
+ category:
+ input.categoryId === undefined ? undefined : { connect: { id: input.categoryId } },
+ environmentGrade:
+ input.environmentGradeId === undefined
+ ? undefined
+ : { connect: { id: input.environmentGradeId } },
+ thicknessSpec:
+ input.thicknessSpecId === undefined
+ ? undefined
+ : { connect: { id: input.thicknessSpecId } },
+ surfaceOption:
+ input.surfaceOptionId === undefined
+ ? undefined
+ : input.surfaceOptionId
+ ? { connect: { id: input.surfaceOptionId } }
+ : { disconnect: true },
+ systemNameOption:
+ input.systemNameOptionId === undefined
+ ? undefined
+ : { connect: { id: input.systemNameOptionId } },
+ suggestedPrice: input.suggestedPrice === undefined ? undefined : input.suggestedPrice,
+ weightKg: input.weightKg === undefined ? undefined : input.weightKg,
+ finishes: input.finishIds !== undefined
+ ? { deleteMany: {}, create: input.finishIds.map((finishId) => ({ finish: { connect: { id: finishId } } })) }
+ : undefined,
+ metadata: input.metadata !== undefined ? toJsonValue(input.metadata) : undefined,
+ isActive: input.isActive,
+ },
+ include: {
+ category: { select: { id: true, name: true } },
+ environmentGrade: { select: { id: true, name: true } },
+ thicknessSpec: {
+ select: { id: true, name: true, thicknessMm: true, sheetLengthMm: true, sheetWidthMm: true },
+ },
+ surfaceOption: { select: { id: true, name: true } },
+ systemNameOption: { select: { id: true, name: true } },
+ finishes: { include: { finish: { select: { id: true, name: true, code: true, thumbnailUrl: true } } } },
+ },
+ }) as SubstrateWithRelations
return toSubstrateDTO(updated)
}
async deleteSubstrate(id: string): Promise {
- const existing = await substrateRepository.findById(id)
+ const existing = await substrateRepository.findByIdWithRelations(id)
if (!existing) {
throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '基材不存在')
}
@@ -524,6 +778,197 @@ class AssetCenterService {
return toSubstrateDTO(existing)
}
+ async createSubstrateDictionary(input: CreateSubstrateDictionaryInput): Promise {
+ const name = input.name.trim()
+ if (!name) {
+ throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `${getDictionaryLabel(input.type)}名称不能为空`)
+ }
+
+ const dataBase = {
+ name,
+ sortOrder: input.sortOrder ?? 0,
+ isActive: input.isActive ?? true,
+ }
+
+ try {
+ switch (input.type) {
+ case 'CATEGORY':
+ return toDictionaryOptionDTO(
+ 'CATEGORY',
+ await prisma.substrateCategory.create({ data: dataBase }),
+ )
+ case 'ENVIRONMENT_GRADE':
+ return toDictionaryOptionDTO(
+ 'ENVIRONMENT_GRADE',
+ await prisma.substrateEnvironmentGrade.create({ data: dataBase }),
+ )
+ case 'THICKNESS_SPEC':
+ if (!input.thicknessMm || input.thicknessMm <= 0) {
+ throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '厚度规格必须填写厚度 mm')
+ }
+ return toDictionaryOptionDTO(
+ 'THICKNESS_SPEC',
+ await prisma.substrateThicknessSpec.create({
+ data: {
+ ...dataBase,
+ thicknessMm: input.thicknessMm,
+ sheetLengthMm: input.sheetLengthMm ?? 2440,
+ sheetWidthMm: input.sheetWidthMm ?? 1220,
+ },
+ }),
+ )
+ case 'SURFACE_OPTION':
+ return toDictionaryOptionDTO(
+ '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 (
+ error instanceof Prisma.PrismaClientKnownRequestError &&
+ error.code === 'P2002'
+ ) {
+ throw new AppError(ErrorCode.VALIDATION_FAILED, `${getDictionaryLabel(input.type)}名称已存在`)
+ }
+ throw error
+ }
+ }
+
+ async updateSubstrateDictionary(
+ type: SubstrateDictionaryType,
+ id: string,
+ input: UpdateSubstrateDictionaryInput,
+ ): Promise {
+ try {
+ switch (type) {
+ case 'CATEGORY':
+ return toDictionaryOptionDTO(
+ 'CATEGORY',
+ await prisma.substrateCategory.update({
+ where: { id },
+ data: {
+ name: input.name?.trim(),
+ sortOrder: input.sortOrder,
+ isActive: input.isActive,
+ },
+ }),
+ )
+ case 'ENVIRONMENT_GRADE':
+ return toDictionaryOptionDTO(
+ 'ENVIRONMENT_GRADE',
+ await prisma.substrateEnvironmentGrade.update({
+ where: { id },
+ data: {
+ name: input.name?.trim(),
+ sortOrder: input.sortOrder,
+ isActive: input.isActive,
+ },
+ }),
+ )
+ case 'THICKNESS_SPEC':
+ return toDictionaryOptionDTO(
+ 'THICKNESS_SPEC',
+ await prisma.substrateThicknessSpec.update({
+ where: { id },
+ data: {
+ name: input.name?.trim(),
+ sortOrder: input.sortOrder,
+ isActive: input.isActive,
+ thicknessMm: input.thicknessMm ?? undefined,
+ sheetLengthMm: input.sheetLengthMm ?? undefined,
+ sheetWidthMm: input.sheetWidthMm ?? undefined,
+ },
+ }),
+ )
+ case 'SURFACE_OPTION':
+ return toDictionaryOptionDTO(
+ 'SURFACE_OPTION',
+ await prisma.substrateSurfaceOption.update({
+ where: { id },
+ data: {
+ name: input.name?.trim(),
+ sortOrder: input.sortOrder,
+ isActive: input.isActive,
+ },
+ }),
+ )
+ 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 (
+ error instanceof Prisma.PrismaClientKnownRequestError &&
+ error.code === 'P2025'
+ ) {
+ throw new AppError(ErrorCode.VALIDATION_FAILED, `${getDictionaryLabel(type)}不存在`)
+ }
+ if (
+ error instanceof Prisma.PrismaClientKnownRequestError &&
+ error.code === 'P2002'
+ ) {
+ throw new AppError(ErrorCode.VALIDATION_FAILED, `${getDictionaryLabel(type)}名称已存在`)
+ }
+ throw error
+ }
+ }
+
+ async deleteSubstrateDictionary(type: SubstrateDictionaryType, id: string): Promise {
+ const whereByType: Record = {
+ CATEGORY: { categoryId: id },
+ 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) {
+ throw new AppError(ErrorCode.VALIDATION_FAILED, `${getDictionaryLabel(type)}已被基材使用,不能删除`)
+ }
+
+ try {
+ switch (type) {
+ case 'CATEGORY':
+ await prisma.substrateCategory.delete({ where: { id } })
+ return
+ case 'ENVIRONMENT_GRADE':
+ await prisma.substrateEnvironmentGrade.delete({ where: { id } })
+ return
+ case 'THICKNESS_SPEC':
+ await prisma.substrateThicknessSpec.delete({ where: { id } })
+ return
+ case 'SURFACE_OPTION':
+ await prisma.substrateSurfaceOption.delete({ where: { id } })
+ return
+ case 'SYSTEM_NAME_OPTION':
+ await prisma.substrateSystemNameOption.delete({ where: { id } })
+ return
+ }
+ } catch (error) {
+ if (
+ error instanceof Prisma.PrismaClientKnownRequestError &&
+ error.code === 'P2025'
+ ) {
+ throw new AppError(ErrorCode.VALIDATION_FAILED, `${getDictionaryLabel(type)}不存在`)
+ }
+ throw error
+ }
+ }
+
async createVariant(input: CreateAssetVariantInput): Promise {
const family = await assetFamilyRepository.findById(input.assetFamilyId)
if (!family) {
@@ -554,6 +999,15 @@ class AssetCenterService {
)
}
+ const resolvedLossFactor = input.lossFactor ?? null
+ const resolvedEstimatedCostPrice =
+ input.estimatedCostPrice ??
+ await computeEstimatedCostPrice({
+ substrateId: input.substrateId ?? null,
+ dimensions: input.dimensions,
+ lossFactor: resolvedLossFactor,
+ })
+
const created = await assetVariantRepository.createWithRelations({
assetFamily: { connect: { id: input.assetFamilyId } },
code,
@@ -574,6 +1028,8 @@ class AssetCenterService {
businessData: toJsonValue(input.businessData),
weightKg: input.weightKg ?? null,
costPrice: input.costPrice ?? null,
+ lossFactor: resolvedLossFactor,
+ estimatedCostPrice: resolvedEstimatedCostPrice,
inheritedFromVariant: input.inheritedFromVariantId
? { connect: { id: input.inheritedFromVariantId } }
: undefined,
@@ -639,6 +1095,7 @@ class AssetCenterService {
input.substrateId !== undefined ? input.substrateId : existing.substrateId
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
if (
existing.status === 'AVAILABLE' &&
@@ -657,6 +1114,7 @@ class AssetCenterService {
compatibilityRules: isRecord(existing.compatibilityRules)
? existing.compatibilityRules
: null,
+ lossFactor: existing.lossFactor ?? null,
},
{
dimensions: nextDimensions,
@@ -666,6 +1124,7 @@ class AssetCenterService {
mountPoints: nextMountPoints,
assemblyBasis: nextAssemblyBasis,
compatibilityRules: nextCompatibilityRules,
+ lossFactor: nextLossFactor ?? null,
},
)
) {
@@ -690,6 +1149,15 @@ class AssetCenterService {
)
}
+ const resolvedEstimatedCostPrice =
+ input.estimatedCostPrice !== undefined
+ ? input.estimatedCostPrice
+ : await computeEstimatedCostPrice({
+ substrateId: nextSubstrateId ?? null,
+ dimensions: nextDimensions,
+ lossFactor: nextLossFactor ?? null,
+ })
+
const updated = await assetVariantRepository.updateWithRelations(id, {
assetFamily:
input.assetFamilyId === undefined
@@ -736,6 +1204,9 @@ class AssetCenterService {
input.businessData !== undefined ? toJsonValue(input.businessData) : undefined,
weightKg: input.weightKg === undefined ? undefined : input.weightKg,
costPrice: input.costPrice === undefined ? undefined : input.costPrice,
+ lossFactor: input.lossFactor === undefined ? undefined : input.lossFactor,
+ estimatedCostPrice:
+ input.estimatedCostPrice === undefined ? resolvedEstimatedCostPrice : input.estimatedCostPrice,
lastModifier: input.updatedBy ? { connect: { id: input.updatedBy } } : undefined,
})
@@ -868,8 +1339,11 @@ class AssetCenterService {
: [],
substrateId: existing.substrateId,
finishId: existing.finishId,
- weightKg: existing.weightKg,
- costPrice: existing.costPrice === null || existing.costPrice === undefined ? null : Number(existing.costPrice),
+ estimatedCostPrice:
+ existing.estimatedCostPrice === null || existing.estimatedCostPrice === undefined
+ ? null
+ : Number(existing.estimatedCostPrice),
+ lossFactor: existing.lossFactor ?? null,
assemblyBasis: isRecord(existing.assemblyBasis) ? existing.assemblyBasis : null,
compatibilityRules: isRecord(existing.compatibilityRules)
? existing.compatibilityRules
@@ -913,8 +1387,11 @@ class AssetCenterService {
: [],
substrateId: existing.substrateId,
finishId: existing.finishId,
- weightKg: existing.weightKg,
- costPrice: existing.costPrice === null || existing.costPrice === undefined ? null : Number(existing.costPrice),
+ estimatedCostPrice:
+ existing.estimatedCostPrice === null || existing.estimatedCostPrice === undefined
+ ? null
+ : Number(existing.estimatedCostPrice),
+ lossFactor: existing.lossFactor ?? null,
assemblyBasis: isRecord(existing.assemblyBasis) ? existing.assemblyBasis : null,
compatibilityRules: isRecord(existing.compatibilityRules)
? existing.compatibilityRules
@@ -977,10 +1454,14 @@ export type {
AssetVariantDTO,
CreateAssetFamilyInput,
CreateAssetVariantInput,
+ CreateSubstrateDictionaryInput,
CreateSubstrateInput,
- EnsureBoardLibraryDefaultsResult,
SubstrateDTO,
+ SubstrateDictionaryBundleDTO,
+ SubstrateDictionaryOptionDTO,
+ SubstrateDictionaryType,
UpdateAssetFamilyInput,
UpdateAssetVariantInput,
+ UpdateSubstrateDictionaryInput,
UpdateSubstrateInput,
} from './asset-center.service.types'
diff --git a/furniture-3d-system/src/server/services/asset-center.service.types.ts b/furniture-3d-system/src/server/services/asset-center.service.types.ts
index 95092d91..65e2741a 100644
--- a/furniture-3d-system/src/server/services/asset-center.service.types.ts
+++ b/furniture-3d-system/src/server/services/asset-center.service.types.ts
@@ -61,6 +61,8 @@ export interface AssetVariantDTO {
businessData: Record | null
weightKg: number | null
costPrice: number | null
+ lossFactor: number | null
+ estimatedCostPrice: number | null
inheritedFromVariantId: string | null
createdBy: string | null
creatorName: string | null
@@ -74,13 +76,56 @@ export interface SubstrateDTO {
code: string
name: string
description: string | null
- densityKgM3: number | null
+ categoryId: string
+ categoryName: string
+ environmentGradeId: string
+ environmentGradeName: string
+ thicknessSpecId: string
+ thicknessSpecName: string
+ thicknessMm: number
+ sheetLengthMm: number
+ sheetWidthMm: number
+ surfaceOptionId: string | null
+ surfaceOptionName: string | null
+ systemNameOptionId: string
+ systemNameOptionName: string
+ suggestedPrice: number | null
+ weightKg: number | null
+ finishes: { id: string; name: string; code: string; thumbnailUrl: string }[]
metadata: Record | null
isActive: boolean
createdAt: Date
updatedAt: Date
}
+export type SubstrateDictionaryType =
+ | 'CATEGORY'
+ | 'ENVIRONMENT_GRADE'
+ | 'THICKNESS_SPEC'
+ | 'SURFACE_OPTION'
+ | 'SYSTEM_NAME_OPTION'
+
+export interface SubstrateDictionaryOptionDTO {
+ id: string
+ type: SubstrateDictionaryType
+ name: string
+ sortOrder: number
+ isActive: boolean
+ thicknessMm?: number | null
+ sheetLengthMm?: number | null
+ sheetWidthMm?: number | null
+ createdAt: Date
+ updatedAt: Date
+}
+
+export interface SubstrateDictionaryBundleDTO {
+ categories: SubstrateDictionaryOptionDTO[]
+ environmentGrades: SubstrateDictionaryOptionDTO[]
+ thicknessSpecs: SubstrateDictionaryOptionDTO[]
+ surfaceOptions: SubstrateDictionaryOptionDTO[]
+ systemNameOptions: SubstrateDictionaryOptionDTO[]
+}
+
export interface CreateAssetFamilyInput {
type: AssetType
code: string
@@ -124,6 +169,8 @@ export interface CreateAssetVariantInput {
businessData?: Record | null
weightKg?: number | null
costPrice?: number | null
+ lossFactor?: number | null
+ estimatedCostPrice?: number | null
inheritedFromVariantId?: string | null
createdBy?: string | null
}
@@ -146,33 +193,60 @@ export interface UpdateAssetVariantInput {
businessData?: Record | null
weightKg?: number | null
costPrice?: number | null
+ lossFactor?: number | null
+ estimatedCostPrice?: number | null
updatedBy?: string | null
}
export interface CreateSubstrateInput {
- code: string
- name: string
+ code?: string
description?: string | null
- densityKgM3?: number | null
+ categoryId: string
+ environmentGradeId: string
+ thicknessSpecId: string
+ surfaceOptionId?: string | null | null
+ systemNameOptionId: string
+ suggestedPrice?: number | null
+ weightKg?: number | null
+ finishIds?: string[]
metadata?: Record | null
createdBy?: string | null
}
-export interface EnsureBoardLibraryDefaultsResult {
- finishCategoryId: string
- finishNames: string[]
- substrates: SubstrateDTO[]
-}
-
export interface UpdateSubstrateInput {
code?: string
- name?: string
description?: string | null
- densityKgM3?: number | null
+ categoryId?: string
+ environmentGradeId?: string
+ thicknessSpecId?: string
+ surfaceOptionId?: string | null
+ systemNameOptionId?: string
+ suggestedPrice?: number | null
+ weightKg?: number | null
+ finishIds?: string[]
metadata?: Record | null
isActive?: boolean
}
+export interface CreateSubstrateDictionaryInput {
+ type: SubstrateDictionaryType
+ name: string
+ sortOrder?: number
+ isActive?: boolean
+ thicknessMm?: number | null
+ sheetLengthMm?: number | null
+ sheetWidthMm?: number | null
+}
+
+export interface UpdateSubstrateDictionaryInput {
+ name?: string
+ sortOrder?: number
+ isActive?: boolean
+ thicknessMm?: number | null
+ sheetLengthMm?: number | null
+ sheetWidthMm?: number | null
+}
+
export interface AssetReviewActionInput {
reason?: string | null
}
diff --git a/furniture-3d-system/src/server/services/material.service.ts b/furniture-3d-system/src/server/services/material.service.ts
index 4d1fb363..70fd7f7f 100644
--- a/furniture-3d-system/src/server/services/material.service.ts
+++ b/furniture-3d-system/src/server/services/material.service.ts
@@ -23,6 +23,7 @@ import { normalizeMaterialAssetPath } from '@/lib/material-asset-path'
export interface MaterialDTO {
id: string
name: string
+ code: string
categoryId: string
categoryName?: string
categoryIcon?: string | null
@@ -68,6 +69,7 @@ export interface MaterialCategoryWithMaterialsDTO {
export interface CreateMaterialInput {
name: string
+ code?: string
categoryId: string
thumbnailUrl?: string
textureUrl?: string | null
@@ -132,6 +134,17 @@ class MaterialService {
return Math.min(Math.max(value as number, 10), 5000)
}
+ /**
+ * 生成下一个花色编码 HS-XXXX
+ */
+ private async generateNextCode(): Promise {
+ const maxCode = await materialRepository.findMaxCode()
+ if (!maxCode) return 'HS-0001'
+ const numPart = parseInt(maxCode.split('-')[1], 10)
+ if (Number.isNaN(numPart)) return 'HS-0001'
+ return `HS-${String(numPart + 1).padStart(4, '0')}`
+ }
+
// ============ 材质方法 ============
/**
@@ -230,6 +243,7 @@ class MaterialService {
const createData = {
name: input.name,
+ code: input.code?.trim() || await this.generateNextCode(),
category: { connect: { id: input.categoryId } },
thumbnailUrl: input.thumbnailUrl || '/materials/thumbnails/default.jpg',
textureUrl: input.textureUrl,
@@ -544,6 +558,7 @@ class MaterialService {
private toMaterialDTO(material: {
id: string
name: string
+ code?: string
categoryId: string
thumbnailUrl: string
textureUrl: string | null
@@ -566,6 +581,7 @@ class MaterialService {
const dto: MaterialDTO = {
id: material.id,
name: material.name,
+ code: (material as { code?: string }).code ?? '',
categoryId: material.categoryId,
categoryName: material.category?.name,
categoryIcon: material.category?.icon,