From 7fdaaacb2d8a3530259be558beed75a20a5bd821 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Apr 2026 16:09:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=B5=84=E6=96=99=E5=BA=93):=20=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=E5=9F=BA=E6=9D=90=E4=B8=8E=E8=8A=B1=E8=89=B2=E6=96=B0?= =?UTF-8?q?=E5=BB=BA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 4 + furniture-3d-system/prisma/schema.prisma | 4 +- .../substrate-dictionaries/[id]/route.ts | 3 +- .../src/app/api/platform/schemas.ts | 3 - .../MaterialLibraryClient.test.tsx | 183 +- .../MaterialLibraryClient.tsx | 1636 ++++++++--------- .../src/lib/open-product/substrate-rules.ts | 22 +- .../repositories/asset-center.repository.ts | 2 +- .../asset-center.substrate.service.test.ts | 1 - .../server/services/asset-center.service.ts | 200 +- .../services/asset-center.service.types.ts | 6 - .../open-platform-demo-seed.service.ts | 1 - 12 files changed, 859 insertions(+), 1206 deletions(-) create mode 100644 furniture-3d-system/prisma/migrations/20260413103000_make_substrate_system_name_optional/migration.sql diff --git a/furniture-3d-system/prisma/migrations/20260413103000_make_substrate_system_name_optional/migration.sql b/furniture-3d-system/prisma/migrations/20260413103000_make_substrate_system_name_optional/migration.sql new file mode 100644 index 00000000..371075d7 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260413103000_make_substrate_system_name_optional/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "Substrate" +ALTER COLUMN "systemNameOptionId" DROP NOT NULL; + +DROP INDEX IF EXISTS "Substrate_categoryId_environmentGradeId_thicknessSpecId_sur_key"; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 955bf4be..2c8ad3cc 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -446,8 +446,8 @@ model Substrate { 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]) + systemNameOptionId String? + systemNameOption SubstrateSystemNameOption? @relation(fields: [systemNameOptionId], references: [id]) suggestedPrice Decimal? @db.Decimal(10, 2) weightKg Float? metadata Json? diff --git a/furniture-3d-system/src/app/api/asset-center/substrate-dictionaries/[id]/route.ts b/furniture-3d-system/src/app/api/asset-center/substrate-dictionaries/[id]/route.ts index 1f52afc8..6dc0219c 100644 --- a/furniture-3d-system/src/app/api/asset-center/substrate-dictionaries/[id]/route.ts +++ b/furniture-3d-system/src/app/api/asset-center/substrate-dictionaries/[id]/route.ts @@ -49,8 +49,7 @@ export async function DELETE(request: NextRequest, context: RouteParams) { type !== 'CATEGORY' && type !== 'ENVIRONMENT_GRADE' && type !== 'THICKNESS_SPEC' && - type !== 'SURFACE_OPTION' && - type !== 'SYSTEM_NAME_OPTION' + type !== 'SURFACE_OPTION' ) { return jsonError(ErrorCode.VALIDATION_FAILED, '缺少有效的字典类型', 400, ctx.requestId) } diff --git a/furniture-3d-system/src/app/api/platform/schemas.ts b/furniture-3d-system/src/app/api/platform/schemas.ts index f9165cac..65889adb 100644 --- a/furniture-3d-system/src/app/api/platform/schemas.ts +++ b/furniture-3d-system/src/app/api/platform/schemas.ts @@ -104,7 +104,6 @@ export const substrateCreateSchema = z.object({ 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().nonnegative().optional().nullable(), finishIds: z.array(z.string().trim().min(1)).optional(), @@ -118,7 +117,6 @@ export const substrateUpdateSchema = z.object({ 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().nonnegative().optional().nullable(), finishIds: z.array(z.string().trim().min(1)).optional(), @@ -131,7 +129,6 @@ export const substrateDictionaryTypeSchema = z.enum([ 'ENVIRONMENT_GRADE', 'THICKNESS_SPEC', 'SURFACE_OPTION', - 'SYSTEM_NAME_OPTION', ]) export const substrateDictionaryCreateSchema = z.object({ 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 5ed168b5..057a8924 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 @@ -14,23 +14,8 @@ jest.mock('next/link', () => ({ jest.mock('@/components/ui/Modal', () => ({ __esModule: true, - default: ({ - isOpen, - title, - children, - footer, - }: { - isOpen: boolean - title?: ReactNode - children: ReactNode - footer?: ReactNode - }) => - isOpen ? ( -
-
{children}
-
{footer}
-
- ) : null, + default: ({ isOpen, title, children, footer }: { isOpen: boolean; title?: ReactNode; children: ReactNode; footer?: ReactNode }) => + isOpen ?
{children}
{footer}
: null, })) jest.mock('@/components/ui/DeleteConfirmDialog', () => ({ @@ -40,144 +25,64 @@ 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(), createSubstrateDictionary: jest.fn(), deleteMaterial: jest.fn(), - deleteMaterialCategory: jest.fn(), deleteSubstrate: jest.fn(), deleteSubstrateDictionary: jest.fn(), updateMaterial: jest.fn(), - updateMaterialCategory: jest.fn(), updateSubstrate: jest.fn(), updateSubstrateDictionary: jest.fn(), - uploadMaterialThumbnail: jest.fn(), })) const mockedCreateSubstrate = jest.mocked(createSubstrate) 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(), - }, - ], + 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() }], } function renderClient() { return render( { jest.clearAllMocks() }) - it('默认先显示基材页签', () => { - renderClient() - - expect(screen.getByPlaceholderText('搜索基材名称、编码、材质...')).toBeInTheDocument() - }) - it('新建基材时按规则字段提交并显示自动名称', async () => { const actor = userEvent.setup() mockedCreateSubstrate.mockResolvedValue({ @@ -216,10 +115,8 @@ describe('MaterialLibraryClient', () => { sheetWidthMm: 1220, surfaceOptionId: 'surface-double', surfaceOptionName: '双贴', - systemNameOptionId: 'sys-18e1', - systemNameOptionName: '18E1级刨花板', suggestedPrice: 200, - weightKg: 36, + weightKg: null, finishes: [], metadata: null, isActive: true, @@ -230,24 +127,20 @@ describe('MaterialLibraryClient', () => { renderClient() + // Switch to substrate tab first + await actor.click(screen.getByRole('button', { name: /基材录入/ })) await actor.click(screen.getByRole('button', { name: /新建/ })) - await actor.type(screen.getByLabelText(/基材编码/), 'PB25') - await actor.type(screen.getByLabelText('建议售价'), '200') - await actor.type(screen.getByLabelText(/整板重量/), '36') - await actor.click(screen.getByRole('button', { name: '创建基材' })) + await actor.type(screen.getByLabelText('售价'), '200') + await actor.click(screen.getByRole('button', { name: '创建' })) await waitFor(() => { expect(mockedCreateSubstrate).toHaveBeenCalledWith( expect.objectContaining({ - code: 'PB25', categoryId: 'cat-board', environmentGradeId: 'grade-e1', thicknessSpecId: 'th-18', - surfaceOptionId: null, - systemNameOptionId: 'sys-18e1', + surfaceOptionId: 'surface-double', suggestedPrice: 200, - weightKg: 36, - finishIds: [], }), ) }) 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 ad29410e..b44d6c59 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, + ChevronDown, + ImagePlus, Package2, Pencil, Plus, @@ -15,24 +17,24 @@ import { } from 'lucide-react' 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 Input from '@/components/ui/Input' import Select from '@/components/ui/Select' import { ToastContainer, useToast } from '@/components/ui/Toast' import { PlatformSplitLayout } from '@/components/platform/PlatformSplitLayout' import type { PlatformMetricItem } from '@/components/platform/types' +import { + buildSubstrateDisplayName, +} from '@/lib/open-product/substrate-rules' import { cn } from '@/lib/utils' import { createMaterial, - createMaterialCategory, createSubstrate, createSubstrateDictionary, deleteMaterial, - deleteMaterialCategory, deleteSubstrate, deleteSubstrateDictionary, updateMaterial, - updateMaterialCategory, updateSubstrate, updateSubstrateDictionary, uploadMaterialThumbnail, @@ -46,7 +48,16 @@ import type { SubstrateDictionaryType, } 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[] @@ -57,16 +68,15 @@ interface MaterialLibraryClientProps { } type SubstrateFormState = { - code: string - description: string categoryId: string + categoryText: string environmentGradeId: string thicknessSpecId: string surfaceOptionId: string - systemNameOptionId: string suggestedPrice: string weightKg: string finishIds: string[] + newFinishName: string } type CategoryFormState = { @@ -78,8 +88,8 @@ type CategoryFormState = { type FinishFormState = { name: string categoryId: string - color: string thumbnailUrl: string + thumbnailPreview: string } type DictionaryFormState = { @@ -90,27 +100,26 @@ type DictionaryFormState = { type DeleteTarget = | { type: 'substrate'; id: string; name: string } - | { type: 'category'; id: string; name: string } | { type: 'finish'; id: string; name: string } | { type: 'dictionary'; id: string; name: string; dictionaryType: SubstrateDictionaryType } -type DetailPanelState = - | { mode: 'empty' } - | { mode: 'substrate'; id: string } - | { mode: 'finish'; id: 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 toNum(value: string): number | null { + if (!value.trim()) return null + const n = Number(value) + return Number.isFinite(n) ? n : null +} + function sortDictionaryOptions(items: SubstrateDictionaryOptionDTO[]): SubstrateDictionaryOptionDTO[] { return [...items].sort((left, right) => { const orderDiff = left.sortOrder - right.sortOrder @@ -124,22 +133,21 @@ function sortDictionaryOptions(items: SubstrateDictionaryOptionDTO[]): Substrate function toOptionalNumber(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(bundle: SubstrateDictionaryBundleDTO): SubstrateFormState { +function emptySubstrateForm(dict: SubstrateDictionaryBundleDTO): SubstrateFormState { return { - code: '', - description: '', - categoryId: bundle.categories[0]?.id ?? '', - environmentGradeId: bundle.environmentGrades[0]?.id ?? '', - thicknessSpecId: bundle.thicknessSpecs[0]?.id ?? '', - surfaceOptionId: '', - systemNameOptionId: bundle.systemNameOptions[0]?.id ?? '', + 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 ?? '', suggestedPrice: '', weightKg: '', finishIds: [], + newFinishName: '', } } @@ -147,10 +155,6 @@ function emptyCategoryForm(): CategoryFormState { return { name: '', icon: '', sortOrder: '' } } -function emptyFinishForm(categoryId = ''): FinishFormState { - return { name: '', categoryId, color: '#CCCCCC', thumbnailUrl: '' } -} - function emptyDictionaryForm(): DictionaryFormState { return { name: '', sortOrder: '0', thicknessMm: '' } } @@ -165,8 +169,6 @@ function dictionaryOptionsByType(bundle: SubstrateDictionaryBundleDTO, type: Sub return bundle.thicknessSpecs case 'SURFACE_OPTION': return bundle.surfaceOptions - case 'SYSTEM_NAME_OPTION': - return bundle.systemNameOptions } } @@ -180,8 +182,6 @@ function dictionaryLabel(type: SubstrateDictionaryType) { return '厚度规格' case 'SURFACE_OPTION': return '贴面类型' - case 'SYSTEM_NAME_OPTION': - return '系统名称' } } @@ -202,243 +202,322 @@ export default function MaterialLibraryClient({ const [selectedCategoryId, setSelectedCategoryId] = useState('all') const [substrates, setSubstrates] = useState(sortByName(initialSubstrates)) const [finishes, setFinishes] = useState(sortByName(initialFinishes)) - const [materialCategories, setMaterialCategories] = useState( - sortMaterialCategories(initialMaterialCategories), - ) - const [substrateDictionaries, setSubstrateDictionaries] = useState({ - categories: sortDictionaryOptions(initialSubstrateDictionaries.categories), - environmentGrades: sortDictionaryOptions(initialSubstrateDictionaries.environmentGrades), - thicknessSpecs: sortDictionaryOptions(initialSubstrateDictionaries.thicknessSpecs), - surfaceOptions: sortDictionaryOptions(initialSubstrateDictionaries.surfaceOptions), - systemNameOptions: sortDictionaryOptions(initialSubstrateDictionaries.systemNameOptions), - }) + const [categories] = useState(sortCategories(initialMaterialCategories)) + const [dictionaries, setDictionaries] = useState(initialSubstrateDictionaries) const [submitting, setSubmitting] = useState(false) const [deleteTarget, setDeleteTarget] = useState(null) - const [substrateModalOpen, setSubstrateModalOpen] = useState(false) - const [substrateModalMode, setSubstrateModalMode] = useState<'create' | 'edit'>('create') + const [previewUrl, setPreviewUrl] = useState(null) + const [uploading, setUploading] = useState(false) + const fileInputRef = useRef(null) + + // Right panel state + const [panel, setPanel] = useState({ mode: 'empty' }) + + // Form states + const [finishForm, setFinishForm] = useState({ + name: '', categoryId: '', thumbnailUrl: '', thumbnailPreview: '', + }) const [substrateForm, setSubstrateForm] = useState( emptySubstrateForm(initialSubstrateDictionaries), ) - const [editingSubstrateId, setEditingSubstrateId] = useState(null) - const [categoryModalOpen, setCategoryModalOpen] = useState(false) - const [categoryModalMode, setCategoryModalMode] = useState<'create' | 'edit'>('create') - const [categoryForm, setCategoryForm] = useState(emptyCategoryForm()) - const [editingCategoryId, setEditingCategoryId] = useState(null) - const [finishModalOpen, setFinishModalOpen] = useState(false) - const [finishModalMode, setFinishModalMode] = useState<'create' | 'edit'>('create') - const [finishForm, setFinishForm] = useState(emptyFinishForm()) - const [editingFinishId, setEditingFinishId] = useState(null) + const [finishSearch, setFinishSearch] = useState('') + const [finishDropdownOpen, setFinishDropdownOpen] = useState(false) + + // Dictionary management state const [dictionaryModalOpen, setDictionaryModalOpen] = useState(false) const [dictionaryModalMode, setDictionaryModalMode] = useState<'create' | 'edit'>('create') const [activeDictionaryType, setActiveDictionaryType] = useState('CATEGORY') const [editingDictionaryId, setEditingDictionaryId] = useState(null) const [dictionaryForm, setDictionaryForm] = useState(emptyDictionaryForm()) - const [detailPanel, setDetailPanel] = useState({ mode: 'empty' }) - const filteredSubstrates = useMemo(() => { - if (!searchText.trim()) return substrates - const keyword = searchText.trim().toLowerCase() - return substrates.filter((item) => - [ - item.name, - item.code, - item.categoryName, - item.environmentGradeName, - item.systemNameOptionName, - ].some((value) => value.toLowerCase().includes(keyword)), - ) - }, [searchText, substrates]) - - const substrateSections = useMemo(() => { - return substrateDictionaries.categories.map((category) => ({ - category, - items: filteredSubstrates.filter((substrate) => substrate.categoryId === category.id), - })) - }, [filteredSubstrates, substrateDictionaries.categories]) - - const generatedSubstrateName = useMemo(() => { - const thickness = substrateDictionaries.thicknessSpecs.find((item) => item.id === substrateForm.thicknessSpecId) - const environment = substrateDictionaries.environmentGrades.find((item) => item.id === substrateForm.environmentGradeId) - const category = substrateDictionaries.categories.find((item) => item.id === substrateForm.categoryId) - const surface = substrateDictionaries.surfaceOptions.find((item) => item.id === substrateForm.surfaceOptionId) - const systemName = substrateDictionaries.systemNameOptions.find((item) => item.id === substrateForm.systemNameOptionId) - if (!thickness || !environment || !systemName || !category) return '' - - const finishNames = substrateForm.finishIds - .map((finishId) => finishes.find((item) => item.id === finishId)?.name) - .filter(Boolean) - .join(' ') - - return [ - thickness.name.toUpperCase(), - environment.name, - category.name, - finishNames, - surface?.name ?? '', + // Dict lookups + const dictById = useMemo(() => { + const all = [ + ...dictionaries.categories, + ...dictionaries.environmentGrades, + ...dictionaries.thicknessSpecs, + ...dictionaries.surfaceOptions, ] - .filter(Boolean) - .join(' ') - }, [ - finishes, - substrateDictionaries, - substrateForm.categoryId, - substrateForm.environmentGradeId, - substrateForm.finishIds, - substrateForm.surfaceOptionId, - substrateForm.systemNameOptionId, - substrateForm.thicknessSpecId, - ]) + const map = new Map() + for (const item of all) map.set(item.id, item) + return map + }, [dictionaries]) + // Dictionary management helpers const usedDictionaryIds = useMemo(() => { return { CATEGORY: new Set(substrates.map((item) => item.categoryId).filter(Boolean)), ENVIRONMENT_GRADE: new Set(substrates.map((item) => item.environmentGradeId).filter(Boolean)), THICKNESS_SPEC: new Set(substrates.map((item) => item.thicknessSpecId).filter(Boolean)), SURFACE_OPTION: new Set(substrates.map((item) => item.surfaceOptionId).filter(Boolean)), - SYSTEM_NAME_OPTION: new Set(substrates.map((item) => item.systemNameOptionId).filter(Boolean)), } as Record> }, [substrates]) - const visibleDictionaryOptions = useMemo(() => { - const filterOptions = (type: SubstrateDictionaryType, items: SubstrateDictionaryOptionDTO[]) => - items.filter((item) => !isLegacySeedOptionId(item.id) || usedDictionaryIds[type].has(item.id)) - return { - categories: filterOptions('CATEGORY', substrateDictionaries.categories), - environmentGrades: filterOptions('ENVIRONMENT_GRADE', substrateDictionaries.environmentGrades), - thicknessSpecs: filterOptions('THICKNESS_SPEC', substrateDictionaries.thicknessSpecs), - surfaceOptions: filterOptions('SURFACE_OPTION', substrateDictionaries.surfaceOptions), - systemNameOptions: filterOptions('SYSTEM_NAME_OPTION', substrateDictionaries.systemNameOptions), - } - }, [substrateDictionaries, usedDictionaryIds]) - const activeDictionaryOptions = useMemo( - () => dictionaryOptionsByType(substrateDictionaries, activeDictionaryType), - [activeDictionaryType, substrateDictionaries], + () => dictionaryOptionsByType(dictionaries, activeDictionaryType), + [activeDictionaryType, 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 selectedSubstrate = useMemo( - () => (detailPanel.mode === 'substrate' ? substrates.find((item) => item.id === detailPanel.id) ?? null : null), - [detailPanel, substrates], - ) + 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]) - const selectedFinish = useMemo( - () => (detailPanel.mode === 'finish' ? finishes.find((item) => item.id === detailPanel.id) ?? null : null), - [detailPanel, finishes], - ) + const isSolidWood = (() => { + const catName = dictById.get(substrateForm.categoryId)?.name ?? substrateForm.categoryText + if (!catName) return false + const solidKeywords = ['实木', '橡胶木', '橡木', '黑胡桃', '白蜡', '榉木', '桦木', '松木', '柞木'] + return solidKeywords.some((kw) => catName.includes(kw)) + })() - function openCreateSubstrateModal() { - setSubstrateModalMode('create') - setEditingSubstrateId(null) - setSubstrateForm({ - code: '', - description: '', - categoryId: visibleDictionaryOptions.categories[0]?.id ?? '', - environmentGradeId: visibleDictionaryOptions.environmentGrades[0]?.id ?? '', - thicknessSpecId: visibleDictionaryOptions.thicknessSpecs[0]?.id ?? '', - surfaceOptionId: '', - systemNameOptionId: visibleDictionaryOptions.systemNameOptions[0]?.id ?? '', - suggestedPrice: '', - weightKg: '', - finishIds: [], - }) - setSubstrateModalOpen(true) + // Filter finishes by material type, exclude 五金油漆/漆面颜色/五金漆面色 (these are for hardware accessories only) + const filteredFinishesForSubstrate = useMemo(() => { + const excludeCategories = ['五金油漆', '漆面颜色', '五金漆面色'] + if (isSolidWood) { + return finishes.filter((f) => f.categoryName === '实木花色' && !excludeCategories.includes(f.categoryName ?? '')) + } + return finishes.filter((f) => f.categoryName !== '实木花色' && !excludeCategories.includes(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 finishAutoSuggestions = useMemo(() => { + const kw = finishSearch.trim().toLowerCase() + if (!kw) return [] + return filteredFinishesForSubstrate + .filter((f) => f.name.toLowerCase().includes(kw)) + .slice(0, 8) + }, [finishSearch, filteredFinishesForSubstrate]) + + const previewSubstrateName = useMemo(() => { + const cat = dictById.get(substrateForm.categoryId)?.name || '' + const env = dictById.get(substrateForm.environmentGradeId)?.name ?? '' + const thick = dictionaries.thicknessSpecs.find((t) => t.id === substrateForm.thicknessSpecId) + const surface = dictById.get(substrateForm.surfaceOptionId)?.name ?? '' + const finishName = substrateForm.finishIds.length > 0 + ? finishes.find((f) => f.id === substrateForm.finishIds[0])?.name + : substrateForm.newFinishName.trim() || '' + if (!cat || !env || !thick) return '' + const thickness = `${thick.thicknessMm ?? thick.name}` + const envPart = env.endsWith('级') ? env : `${env}级` + return [thickness, envPart, cat, finishName, 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, - description: substrate.description ?? '', - categoryId: substrate.categoryId, - environmentGradeId: substrate.environmentGradeId, - thicknessSpecId: substrate.thicknessSpecId, - surfaceOptionId: substrate.surfaceOptionId ?? '', - systemNameOptionId: substrate.systemNameOptionId, - suggestedPrice: substrate.suggestedPrice != null ? String(substrate.suggestedPrice) : '', - weightKg: substrate.weightKg != null ? String(substrate.weightKg) : '', - finishIds: substrate.finishes.map((item) => item.id), + function selectSubstrate(s: SubstrateDTO) { + setPanel({ mode: 'view-substrate', id: s.id }) + } + + function startCreateFinish() { + const defaultCat = selectedCategoryId !== 'all' ? selectedCategoryId : categories[0]?.id ?? '' + setFinishForm({ name: '', categoryId: defaultCat, thumbnailUrl: '', thumbnailPreview: '' }) + setPanel({ mode: 'create-finish' }) + } + + function startEditFinish(f: MaterialDTO) { + setFinishForm({ + name: f.name, + categoryId: f.categoryId, + thumbnailUrl: f.thumbnailUrl ?? '', + thumbnailPreview: f.thumbnailUrl ?? '', }) - setSubstrateModalOpen(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 ?? '', + 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.categoryId || - !substrateForm.environmentGradeId || - !substrateForm.thicknessSpecId || - !substrateForm.systemNameOptionId - ) { - toast.error('请先补齐基材规则字段') - return + const solidWood = substrateForm.categoryId === 'cat-solid' || substrateForm.categoryText.trim() === '实木' + if (!substrateForm.categoryText.trim() || !substrateForm.environmentGradeId || !substrateForm.thicknessSpecId) { + toast.error('请补齐必填选项'); return + } + if (!solidWood && !substrateForm.surfaceOptionId) { + toast.error('请选择贴面类型'); return } setSubmitting(true) try { - if (substrateModalMode === 'create') { - const result = await createSubstrate({ - code: substrateForm.code.trim() || undefined, - description: substrateForm.description.trim() || null, - categoryId: substrateForm.categoryId, - environmentGradeId: substrateForm.environmentGradeId, - thicknessSpecId: substrateForm.thicknessSpecId, - surfaceOptionId: substrateForm.surfaceOptionId.trim() || null, - systemNameOptionId: substrateForm.systemNameOptionId, - suggestedPrice: toOptionalNumber(substrateForm.suggestedPrice), - weightKg: toOptionalNumber(substrateForm.weightKg), - finishIds: substrateForm.finishIds, + // 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, + 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])) - setDetailPanel({ mode: 'substrate', id: result.substrate.id }) - setSubstrateModalOpen(false) - toast.success('基材规格已创建') + 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() || undefined, - description: substrateForm.description.trim() || null, - categoryId: substrateForm.categoryId, - environmentGradeId: substrateForm.environmentGradeId, - thicknessSpecId: substrateForm.thicknessSpecId, - surfaceOptionId: substrateForm.surfaceOptionId.trim() || null, - systemNameOptionId: substrateForm.systemNameOptionId, - suggestedPrice: toOptionalNumber(substrateForm.suggestedPrice), - weightKg: toOptionalNumber(substrateForm.weightKg), - finishIds: substrateForm.finishIds, - }) - + 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))), - ) - setDetailPanel({ mode: 'substrate', id: result.substrate.id }) - setSubstrateModalOpen(false) - toast.success('基材规格已更新') + 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 handleThumbnailUpload(file: File) { + if (!file) return + setUploading(true) + try { + 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 : '上传失败') + } finally { + setUploading(false) + } + } + function openDictionaryModal(type: SubstrateDictionaryType, option?: SubstrateDictionaryOptionDTO) { setActiveDictionaryType(type) setDictionaryModalMode(option ? 'edit' : 'create') @@ -458,11 +537,29 @@ export default function MaterialLibraryClient({ } async function handleSaveDictionary() { - if (!dictionaryForm.name.trim()) { + if (activeDictionaryType === 'THICKNESS_SPEC') { + if (!dictionaryForm.thicknessMm.trim() || !Number.isFinite(Number(dictionaryForm.thicknessMm))) { + toast.error('请填写有效的厚度数值') + return + } + } else if (!dictionaryForm.name.trim()) { toast.error(`请填写${dictionaryLabel(activeDictionaryType)}名称`) return } + // Uniqueness check for non-THICKNESS_SPEC types + if (activeDictionaryType !== 'THICKNESS_SPEC') { + const trimmedName = dictionaryForm.name.trim() + const existingOptions = dictionaryOptionsByType(dictionaries, activeDictionaryType) + const duplicate = existingOptions.find( + (opt) => opt.name === trimmedName && opt.id !== editingDictionaryId, + ) + if (duplicate) { + toast.error(`已存在同名「${trimmedName}」,不可重复创建`) + return + } + } + setSubmitting(true) try { if (dictionaryModalMode === 'create') { @@ -476,22 +573,11 @@ export default function MaterialLibraryClient({ : null, }) startTransition(() => { - setSubstrateDictionaries((current) => { - const next = { ...current } - const list = dictionaryOptionsByType(next, activeDictionaryType) + setDictionaries((current) => { + const list = dictionaryOptionsByType(current, activeDictionaryType) list.push(option) - return { - ...next, - categories: sortDictionaryOptions(next.categories), - environmentGrades: sortDictionaryOptions(next.environmentGrades), - thicknessSpecs: sortDictionaryOptions(next.thicknessSpecs), - surfaceOptions: sortDictionaryOptions(next.surfaceOptions), - systemNameOptions: sortDictionaryOptions(next.systemNameOptions), - } + return { ...current } }) - if (activeDictionaryType === 'CATEGORY' && substrateModalOpen) { - setSubstrateForm((current) => ({ ...current, categoryId: option.id })) - } setDictionaryModalOpen(false) toast.success(`${dictionaryLabel(activeDictionaryType)}已创建`) }) @@ -510,15 +596,15 @@ export default function MaterialLibraryClient({ }) startTransition(() => { - setSubstrateDictionaries((current) => { + setDictionaries((current) => { const patch = (items: SubstrateDictionaryOptionDTO[]) => - sortDictionaryOptions(items.map((item) => (item.id === editingDictionaryId ? option : item))) + items.map((item) => (item.id === editingDictionaryId ? option : item)) return { + ...current, categories: patch(current.categories), environmentGrades: patch(current.environmentGrades), thicknessSpecs: patch(current.thicknessSpecs), surfaceOptions: patch(current.surfaceOptions), - systemNameOptions: patch(current.systemNameOptions), } }) setDictionaryModalOpen(false) @@ -531,162 +617,6 @@ export default function MaterialLibraryClient({ } } - function openCreateCategoryModal() { - setCategoryModalMode('create') - setEditingCategoryId(null) - setCategoryForm(emptyCategoryForm()) - setCategoryModalOpen(true) - } - - 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) - setFinishForm({ - name: finish.name, - categoryId: finish.categoryId, - color: finish.color || '#CCCCCC', - thumbnailUrl: finish.thumbnailUrl ?? '', - }) - setFinishModalOpen(true) - } - - function selectSubstrate(substrateId: string) { - setDetailPanel({ mode: 'substrate', id: substrateId }) - } - - function selectFinish(finishId: string) { - setDetailPanel({ mode: 'finish', id: finishId }) - } - - async function handleSaveCategory() { - if (!categoryForm.name.trim()) { - toast.error('请填写分类名称') - return - } - - setSubmitting(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) - if (substrateModalOpen) { - setSubstrateForm((current) => ({ ...current, categoryId: 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))), - ) - setCategoryModalOpen(false) - toast.success('材质分类已更新') - }) - } catch (error) { - 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', - thumbnailUrl: finishForm.thumbnailUrl.trim() || null, - }) - startTransition(() => { - setFinishes((current) => sortByName([...current, material])) - setSelectedCategoryId(material.categoryId) - if (substrateModalOpen) { - setSubstrateForm((current) => ({ - ...current, - finishIds: current.finishIds.includes(material.id) - ? current.finishIds - : [...current.finishIds, material.id], - })) - } - if (!substrateModalOpen) { - setDetailPanel({ mode: 'finish', id: material.id }) - } - setFinishModalOpen(false) - toast.success('饰面/花色已创建') - }) - return - } - - if (!editingFinishId) return - const material = await updateMaterial(editingFinishId, { - name: finishForm.name.trim(), - categoryId: finishForm.categoryId, - color: finishForm.color || '#CCCCCC', - thumbnailUrl: finishForm.thumbnailUrl.trim() || null, - }) - - startTransition(() => { - setFinishes((current) => - sortByName(current.map((item) => (item.id === editingFinishId ? material : item))), - ) - setDetailPanel({ mode: 'finish', id: material.id }) - setFinishModalOpen(false) - toast.success('饰面/花色已更新') - }) - } catch (error) { - toast.error(error instanceof Error ? error.message : '饰面/花色保存失败') - } finally { - setSubmitting(false) - } - } - async function handleConfirmDelete() { if (!deleteTarget) return setSubmitting(true) @@ -694,25 +624,22 @@ export default function MaterialLibraryClient({ if (deleteTarget.type === 'substrate') { await deleteSubstrate(deleteTarget.id) startTransition(() => { - setSubstrates((current) => current.filter((item) => item.id !== deleteTarget.id)) - if (detailPanel.mode === 'substrate' && detailPanel.id === deleteTarget.id) { - setDetailPanel({ mode: 'empty' }) - } + 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 === 'dictionary') { await deleteSubstrateDictionary(deleteTarget.id, deleteTarget.dictionaryType) startTransition(() => { - setSubstrateDictionaries((current) => ({ + setDictionaries((current) => ({ + ...current, categories: current.categories.filter((item) => item.id !== deleteTarget.id), environmentGrades: current.environmentGrades.filter((item) => item.id !== deleteTarget.id), thicknessSpecs: current.thicknessSpecs.filter((item) => item.id !== deleteTarget.id), surfaceOptions: current.surfaceOptions.filter((item) => item.id !== deleteTarget.id), - systemNameOptions: current.systemNameOptions.filter((item) => item.id !== deleteTarget.id), })) setDeleteTarget(null) toast.success(`${dictionaryLabel(deleteTarget.dictionaryType)}已删除`) @@ -720,443 +647,463 @@ export default function MaterialLibraryClient({ return } - if (deleteTarget.type === 'category') { - await deleteMaterialCategory(deleteTarget.id) - startTransition(() => { - setMaterialCategories((current) => current.filter((item) => item.id !== deleteTarget.id)) - setDeleteTarget(null) - toast.success('材质分类已删除') - }) - return - } - await deleteMaterial(deleteTarget.id) startTransition(() => { - setFinishes((current) => current.filter((item) => item.id !== deleteTarget.id)) - if (detailPanel.mode === 'finish' && detailPanel.id === deleteTarget.id) { - setDetailPanel({ mode: 'empty' }) - } + 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) } } - const detailPanelContent = - detailPanel.mode === 'substrate' && selectedSubstrate ? ( -
-
-
-

基材详情

-

{selectedSubstrate.name}

-

{selectedSubstrate.code}

-
-
- - -
+ // ---- 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 = ( +
+
+

从左侧选择一项查看详情

+

或点击「新建」添加

-
-

材质主类

{selectedSubstrate.categoryName}

-

环保等级

{selectedSubstrate.environmentGradeName}

-

厚度规格

{selectedSubstrate.thicknessMm} mm

-

贴面类型

{selectedSubstrate.surfaceOptionName ?? '未填写'}

-

板材尺寸

{selectedSubstrate.sheetLengthMm} × {selectedSubstrate.sheetWidthMm} mm

-

整板重量

{selectedSubstrate.weightKg != null ? `${selectedSubstrate.weightKg} kg` : '未填写'}

-
-
-

关联花色

- {selectedSubstrate.finishes.length === 0 ? ( -

当前还没有关联花色。

- ) : ( -
- {selectedSubstrate.finishes.map((finish) => ( - - {finish.name} - {finish.code} - - ))} -
- )} -
-
- ) : detailPanel.mode === 'finish' && selectedFinish ? ( -
-
-
-

花色详情

-

{selectedFinish.name}

-

{selectedFinish.code} · {selectedFinish.categoryName ?? '未分组'}

-
-
- - -
-
-
- {selectedFinish.thumbnailUrl ? ( - {selectedFinish.name} - ) : ( -
-
-
- )} -
-
-

花色编码

{selectedFinish.code}

-

所属分类

{selectedFinish.categoryName ?? '未分组'}

-
-
- ) : ( -
-
-

详情面板

-

{activeTab === 'substrate' ? '基材资料' : '花色资料'}

-

左侧负责选择,右侧只看详情和动作,页面保持极简。

-
-
-

基材规格

{substrates.length} 条

-

花色

{finishes.length} 条

-

分类

{materialCategories.length} 条

-
- {activeTab === 'finish' ? ( -
-
-

分类管理

- -
-
- {materialCategories.map((category) => ( -
-
-

{category.name}

-

排序 {category.sortOrder ?? 0}

-
- -
- ))} -
-
- ) : ( -
-

当前规则:

-

1. `surfaceOptionId(贴面类型)` 可以不填。

-

2. 同一组 5 字段规格会按花色并入已有基材。

-

3. `weightKg(整板重量)` 只表示整板,不等于切割后的单块板材重量。

-
- )}
) + } else if (panel.mode === 'view-finish' && selectedFinish) { + const f = selectedFinish + rightContent = ( +
+
+

花色详情

+
+ + +
+
+ {/* Thumbnail preview */} + {f.thumbnailUrl ? ( + {f.name} setPreviewUrl(f.thumbnailUrl)} + /> + ) : ( +
+ 无贴图 +
+ )} + {/* Detail fields */} +
+
+
+

编码

+

{f.code}

+
+
+

名称

+

{f.name}

+
+
+

分类

+

{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="例如 橡木纹-自然色" /> + { const file = e.target.files?.[0]; if (file) handleThumbnailUpload(file); e.target.value = '' }} /> +
+ {finishForm.thumbnailPreview ? ( + 预览 + ) : ( +
+ +
+ )} +
+ + {finishForm.thumbnailPreview && ( + + )} +
+
+
+
+ +
+
+
+ ) + } else if (panel.mode === 'view-substrate' && selectedSubstrate) { + const s = selectedSubstrate + rightContent = ( +
+
+

基材详情

+
+ + +
+
+
+
+

系统名称

+

{s.name}

+
+
+

编码

{s.code}

+

材质

{s.categoryName}

+

环保等级

{s.environmentGradeName}

+

厚度

{s.thicknessMm}MM

+

贴面

{s.surfaceOptionName}

+

板尺寸

{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 */} + +
+ setSubstrateForm((c) => ({ ...c, environmentGradeId: e.target.value }))} options={dictionaries.environmentGrades.map((d) => ({ value: d.id, label: d.name }))} /> + setSubstrateForm((c) => ({ ...c, surfaceOptionId: e.target.value }))} options={dictionaries.surfaceOptions.map((d) => ({ value: d.id, label: d.name }))} /> + setSubstrateForm((c) => ({ ...c, suggestedPrice: e.target.value }))} placeholder="例如 180" /> + setSubstrateForm((c) => ({ ...c, weightKg: e.target.value }))} placeholder="例如 32" /> + {/* Finish single-select inside grid */} +
+ +
+
{ setFinishDropdownOpen(true); setFinishSearch('') }} + > + {(() => { + const selectedFinish = substrateForm.finishIds.length > 0 + ? finishes.find((x) => x.id === substrateForm.finishIds[0]) + : null + const pendingNew = substrateForm.newFinishName.trim() + if (pendingNew && !selectedFinish) { + return ( + + + {pendingNew}(新建) + + + ) + } + if (selectedFinish) { + return ( + + {selectedFinish.thumbnailUrl && } + {selectedFinish.name} + + + ) + } + return ( + { setFinishSearch(e.target.value); setFinishDropdownOpen(true) }} + onFocus={() => setFinishDropdownOpen(true)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + const name = finishSearch.trim() + if (!name) return + const existing = filteredFinishesForSubstrate.find((f) => f.name === name) + if (existing) { + setSubstrateForm((c) => ({ ...c, finishIds: [existing.id], newFinishName: '' })) + } else { + setSubstrateForm((c) => ({ ...c, finishIds: [], newFinishName: name })) + } + setFinishSearch('') + setFinishDropdownOpen(false) + } + }} + placeholder="输入花色名称搜索" + className="flex-1 bg-transparent px-3 py-1.5 text-sm text-apple-label-primary placeholder:text-apple-label-tertiary focus:outline-none" + /> + ) + })()} + +
+ {/* Dropdown */} + {finishDropdownOpen && ( +
+
+ {finishSearch.trim() ? ( + finishAutoSuggestions.length > 0 ? ( + finishAutoSuggestions.map((f) => ( + + )) + ) : ( + + ) + ) : ( + filteredFinishesForSubstrate.slice(0, 15).map((f) => { + const selected = substrateForm.finishIds.includes(f.id) + return ( + + ) + }) + )} +
+
+ )} +
+
+
+
+ +
+
+
+ ) + } else { + rightContent = ( +
+

该项目已不存在

+
+ ) + } return ( <> window.history.back()}> + 返回 + + } metrics={metrics} - rightPanelClassName="w-[420px] max-w-[36vw]" + rightPanelClassName="w-[680px] max-w-[58vw]" leftContent={ -
-
-
-

Material Library

-

基础资料库

-
- - - -
+ /* ---- 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" - /> - {searchText ? ( - - ) : 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 && ( + + )}
- {activeTab === 'substrate' ? ( - + {activeTab === 'finish' ? ( + ) : ( -
- - -
+ )}
- {activeTab === 'substrate' ? ( - <> -
- {(['CATEGORY', 'ENVIRONMENT_GRADE', 'THICKNESS_SPEC', 'SURFACE_OPTION', 'SYSTEM_NAME_OPTION'] as SubstrateDictionaryType[]).map((type) => ( - - ))} -
-
- {substrateSections.map((section, index) => ( -
0 && 'mt-4')}> -
-
-

{section.category.name}

-

{section.items.length} 个基材规格

-
- -
- {section.items.length === 0 ? ( -

这个材质主类下还没有基材规格。

- ) : ( -
- {section.items.map((substrate) => { - const selected = detailPanel.mode === 'substrate' && detailPanel.id === substrate.id - return ( - - ) - })} -
- )} -
- ))} -
- - ) : ( - <> -
- + {categories.map((cat) => ( + - {materialCategories.map((category) => ( - - ))} -
-
- {filteredFinishes.length === 0 ? ( -

暂无饰面花色

- ) : ( -
- {filteredFinishes.map((finish) => { - const selected = detailPanel.mode === 'finish' && detailPanel.id === finish.id - return ( - - ) - })} -
- )} -
- + ))} +
)} + + {/* Dictionary management buttons (substrate tab only) */} + {activeTab === 'substrate' && ( +
+ {(['CATEGORY', 'ENVIRONMENT_GRADE', 'THICKNESS_SPEC', 'SURFACE_OPTION'] as SubstrateDictionaryType[]).map((type) => ( + + ))} +
+ )} + + {/* Scrollable list */} +
+ {activeTab === 'finish' ? ( + filteredFinishes.length === 0 ? ( +

暂无花色

+ ) : ( + filteredFinishes.map((f) => ( + + )) + ) + ) : ( + filteredSubstrates.length === 0 ? ( +

暂无基材

+ ) : ( + filteredSubstrates.map((s) => ( + + )) + ) + )} +
} - rightContent={detailPanelContent} + rightContent={rightContent} /> - setSubstrateModalOpen(false)} - title={substrateModalMode === 'create' ? '新建基材' : '编辑基材'} - size="medium" - footer={
} - > -
-
- setSubstrateForm((current) => ({ ...current, code: event.target.value }))} placeholder="例如 BC-0001" /> - -
-
-
- setSubstrateForm((current) => ({ ...current, environmentGradeId: event.target.value }))} options={visibleDictionaryOptions.environmentGrades.map((item) => ({ value: item.id, label: item.name }))} /> - setSubstrateForm((current) => ({ ...current, surfaceOptionId: event.target.value }))} options={[{ value: '', label: '不填写' }, ...visibleDictionaryOptions.surfaceOptions.map((item) => ({ value: item.id, label: item.name }))]} /> - setSubstrateForm((current) => ({ ...current, suggestedPrice: event.target.value }))} placeholder="例如 180" /> - setSubstrateForm((current) => ({ ...current, weightKg: event.target.value }))} placeholder="例如 32" /> -
-
-
-

关联花色

- -
- {substrateForm.finishIds.length > 0 ? ( -
- {substrateForm.finishIds.map((finishId) => { - const finish = finishes.find((item) => item.id === finishId) - if (!finish) return null - return ( - - ) - })} -
- ) : ( -

当前还没有关联花色,可直接勾选或临时新建。

- )} -
- {finishes.length === 0 ? ( -

暂无花色,请先新建。

- ) : ( - finishes.map((finish) => { - const selected = substrateForm.finishIds.includes(finish.id) - return ( - - ) - }) - )} -
-
- setSubstrateForm((current) => ({ ...current, description: event.target.value }))} placeholder="可选" /> -
- + + {/* Dictionary management modal */} setDictionaryModalOpen(false)} title={`管理${dictionaryLabel(activeDictionaryType)}`} size="medium" footer={
}>
@@ -1173,12 +1120,13 @@ export default function MaterialLibraryClient({

- {item.name} + {item.type === 'THICKNESS_SPEC' && item.thicknessMm != null + ? `${item.thicknessMm}MM` + : item.name} {isLegacySeedOptionId(item.id) ? '(历史默认)' : ''}

排序 {item.sortOrder} - {item.type === 'THICKNESS_SPEC' && item.thicknessMm != null ? ` · ${item.thicknessMm}mm` : ''}

))}
- setDictionaryForm((current) => ({ ...current, name: event.target.value }))} /> - setDictionaryForm((current) => ({ ...current, sortOrder: event.target.value }))} /> - {activeDictionaryType === 'THICKNESS_SPEC' ? setDictionaryForm((current) => ({ ...current, thicknessMm: event.target.value }))} /> : null} -
-
- setCategoryModalOpen(false)} title={categoryModalMode === 'create' ? '新建材质分类' : '编辑材质分类'} size="medium" footer={
}> -
- setCategoryForm((current) => ({ ...current, name: event.target.value }))} /> -
- setCategoryForm((current) => ({ ...current, icon: event.target.value }))} /> - setCategoryForm((current) => ({ ...current, sortOrder: event.target.value }))} /> -
-
-
- setFinishModalOpen(false)} title={finishModalMode === 'create' ? '新建饰面 / 花色' : '编辑饰面 / 花色'} size="medium" footer={
}> -
- setFinishForm((current) => ({ ...current, name: event.target.value }))} /> - setFinishForm((current) => ({ ...current, color: event.target.value }))} /> - setFinishForm((current) => ({ ...current, thumbnailUrl: event.target.value }))} placeholder="可选,可上传后自动回填" /> -
- - { - const file = event.target.files?.[0] - if (!file) return - setSubmitting(true) - try { - const url = await uploadMaterialThumbnail(file, finishForm.categoryId || undefined) - setFinishForm((current) => ({ ...current, thumbnailUrl: url })) - toast.success('花色缩略图已上传') - } catch (error) { - toast.error(error instanceof Error ? error.message : '花色缩略图上传失败') - } finally { - setSubmitting(false) - event.target.value = '' - } - }} - /> - {finishForm.thumbnailUrl ? ( -
- 花色缩略图预览 -
- ) : null} -
+ {activeDictionaryType === 'THICKNESS_SPEC' ? ( + <> + setDictionaryForm((current) => ({ ...current, thicknessMm: event.target.value, name: event.target.value }))} placeholder="例如 12、15、18" /> + setDictionaryForm((current) => ({ ...current, sortOrder: event.target.value }))} /> + + ) : ( + <> + setDictionaryForm((current) => ({ ...current, name: event.target.value }))} /> + setDictionaryForm((current) => ({ ...current, sortOrder: event.target.value }))} /> + + )}
+ + {/* Delete confirm */} setDeleteTarget(null)} onConfirm={handleConfirmDelete} - title={deleteTarget?.type === 'substrate' ? '删除基材' : deleteTarget?.type === 'dictionary' ? `删除${dictionaryLabel(deleteTarget.dictionaryType)}` : deleteTarget?.type === 'category' ? '删除材质分类' : '删除饰面 / 花色'} + title={deleteTarget?.type === 'substrate' ? '删除基材' : deleteTarget?.type === 'dictionary' ? `删除${dictionaryLabel(deleteTarget.dictionaryType)}` : '删除饰面花色'} description={`确认删除「${deleteTarget?.name ?? ''}」吗?`} confirmText="确认删除" /> + + {/* Image preview overlay */} + {previewUrl && ( +
setPreviewUrl(null)}> +
+ + 预览 e.stopPropagation()} /> +
+
+ )} + ) diff --git a/furniture-3d-system/src/lib/open-product/substrate-rules.ts b/furniture-3d-system/src/lib/open-product/substrate-rules.ts index 6b4aa793..e9be2de8 100644 --- a/furniture-3d-system/src/lib/open-product/substrate-rules.ts +++ b/furniture-3d-system/src/lib/open-product/substrate-rules.ts @@ -15,17 +15,27 @@ function toFinitePositive(value: number | null | undefined): number | null { } export function buildSubstrateDisplayName(params: { - thicknessLabel: string + thicknessMm: number environmentGradeLabel: string - systemNameLabel: string + categoryLabel: string + finishLabels?: string[] + surfaceLabel?: string }): string { + const thicknessLabel = `${params.thicknessMm}MM` + const environmentLabel = params.environmentGradeLabel.trim() + const normalizedEnvironment = environmentLabel.endsWith('级') + ? environmentLabel + : `${environmentLabel}级` + return [ - params.thicknessLabel.trim(), - params.environmentGradeLabel.trim(), - params.systemNameLabel.trim(), + thicknessLabel, + normalizedEnvironment, + params.categoryLabel.trim(), + ...(params.finishLabels ?? []).map((item) => item.trim()).filter(Boolean), + params.surfaceLabel?.trim() ?? '', ] .filter(Boolean) - .join('') + .join(' ') } export function buildSubstrateSummaryLabel(params: { 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 72b14562..3efa3220 100644 --- a/furniture-3d-system/src/server/repositories/asset-center.repository.ts +++ b/furniture-3d-system/src/server/repositories/asset-center.repository.ts @@ -40,7 +40,7 @@ export interface SubstrateWithRelations extends Substrate { 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 } + systemNameOption: { id: string; name: string } | null finishes: { finish: { id: string diff --git a/furniture-3d-system/src/server/services/__tests__/asset-center.substrate.service.test.ts b/furniture-3d-system/src/server/services/__tests__/asset-center.substrate.service.test.ts index d5d0c4bc..38086e6f 100644 --- a/furniture-3d-system/src/server/services/__tests__/asset-center.substrate.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/asset-center.substrate.service.test.ts @@ -124,7 +124,6 @@ describe('assetCenterService.createSubstrate', () => { environmentGradeId: 'grade-e1', thicknessSpecId: 'th-18', surfaceOptionId: null, - systemNameOptionId: 'sys-solid', suggestedPrice: 320, weightKg: 32, finishIds: ['finish-oak', 'finish-walnut'], 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 d01eab53..1e9dfc94 100644 --- a/furniture-3d-system/src/server/services/asset-center.service.ts +++ b/furniture-3d-system/src/server/services/asset-center.service.ts @@ -27,7 +27,6 @@ import { buildSubstrateDisplayName, calculateEstimatedCost, } from '@/lib/open-product/substrate-rules' -import type { AssetThumbnailRenderAssetType } from '@/lib/asset-thumbnail-auth' import type { OpenProductMountPoint } from '@/lib/open-product/types' import { AppError, ErrorCode } from '@/server/core/errors' import type { AuthUser } from '@/server/core/types' @@ -63,17 +62,6 @@ import type { UpdateAssetVariantInput, UpdateSubstrateInput, } from './asset-center.service.types' -import { - assetVariantThumbnailService, -} from './asset-variant-thumbnail.service' - -interface VariantThumbnailRenderOptions { - renderBaseUrl?: string | null -} - -interface RegenerateVariantThumbnailInput extends VariantThumbnailRenderOptions { - updatedBy?: string | null -} type DictionaryRecord = | SubstrateCategory @@ -246,8 +234,6 @@ function toSubstrateDTO(record: SubstrateWithRelations): SubstrateDTO { 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((item) => ({ @@ -291,8 +277,6 @@ function getDictionaryLabel(type: SubstrateDictionaryType): string { return '厚度规格' case 'SURFACE_OPTION': return '贴面类型' - case 'SYSTEM_NAME_OPTION': - return '系统名称' } } @@ -361,9 +345,8 @@ async function loadSubstrateParts(input: { environmentGradeId: string thicknessSpecId: string surfaceOptionId: string | null - systemNameOptionId: string }) { - const [category, environmentGrade, thicknessSpec, surfaceOption, systemNameOption] = + const [category, environmentGrade, thicknessSpec, surfaceOption] = await Promise.all([ prisma.substrateCategory.findUnique({ where: { id: input.categoryId } }), prisma.substrateEnvironmentGrade.findUnique({ where: { id: input.environmentGradeId } }), @@ -371,7 +354,6 @@ async function loadSubstrateParts(input: { input.surfaceOptionId ? prisma.substrateSurfaceOption.findUnique({ where: { id: input.surfaceOptionId } }) : Promise.resolve(null), - prisma.substrateSystemNameOption.findUnique({ where: { id: input.systemNameOptionId } }), ]) if (!category) { @@ -386,16 +368,12 @@ async function loadSubstrateParts(input: { if (input.surfaceOptionId && !surfaceOption) { throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '贴面类型不存在') } - if (!systemNameOption) { - throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '系统名称不存在') - } return { category, environmentGrade, thicknessSpec, surfaceOption, - systemNameOption, } } @@ -404,13 +382,12 @@ async function buildSubstratePayload(input: { environmentGradeId: string thicknessSpecId: string surfaceOptionId: string | null - systemNameOptionId: string }) { const parts = await loadSubstrateParts(input) const generatedName = buildSubstrateDisplayName({ - thicknessLabel: parts.thicknessSpec.name, + thicknessMm: parts.thicknessSpec.thicknessMm, environmentGradeLabel: parts.environmentGrade.name, - systemNameLabel: parts.systemNameOption.name, + categoryLabel: parts.category.name, }) return { @@ -450,7 +427,6 @@ async function buildSubstratePayloadWithFinishes(input: { environmentGradeId: string thicknessSpecId: string surfaceOptionId: string | null - systemNameOptionId: string finishIds: string[] }) { const parts = await loadSubstrateParts(input) @@ -464,15 +440,13 @@ async function buildSubstratePayloadWithFinishes(input: { : [] const finishPart = finishRows.map((item) => item.name).join(' ') - const generatedName = [ - parts.thicknessSpec.name.toUpperCase(), - parts.environmentGrade.name, - parts.category.name, - finishPart, - parts.surfaceOption?.name ?? '', - ] - .filter(Boolean) - .join(' ') + const generatedName = buildSubstrateDisplayName({ + thicknessMm: parts.thicknessSpec.thicknessMm, + environmentGradeLabel: parts.environmentGrade.name, + categoryLabel: parts.category.name, + finishLabels: finishPart ? [finishPart] : [], + surfaceLabel: parts.surfaceOption?.name ?? '', + }) return { generatedName, @@ -506,62 +480,6 @@ async function computeEstimatedCostPrice(params: { } class AssetCenterService { - private async persistVariantThumbnail( - variant: AssetVariantDTO, - thumbnailUrl: string, - updatedBy?: string | null, - ): Promise { - const updated = await assetVariantRepository.updateWithRelations(variant.id, { - thumbnailUrl, - lastModifier: updatedBy ? { connect: { id: updatedBy } } : undefined, - }) - return toAssetVariantDTO(updated) - } - - private async maybeGenerateVariantThumbnail(params: { - variant: AssetVariantDTO - renderBaseUrl?: string | null - updatedBy?: string | null - force?: boolean - skip?: boolean - }): Promise { - const { variant, renderBaseUrl, updatedBy, force = false, skip = false } = params - if (skip || !renderBaseUrl) { - return variant - } - - const modelUrl = trimOrNull(variant.modelUrl) - if (!modelUrl) { - return variant - } - - if (!force && trimOrNull(variant.thumbnailUrl)) { - return variant - } - - try { - const thumbnailUrl = await assetVariantThumbnailService.generateForVariant({ - variantId: variant.id, - assetType: variant.assetType as AssetThumbnailRenderAssetType, - modelUrl, - renderBaseUrl, - }) - return this.persistVariantThumbnail(variant, thumbnailUrl, updatedBy) - } catch (error) { - console.warn('[AssetCenter] 自动生成规格款缩略图失败,已回退占位图', { - variantId: variant.id, - assetType: variant.assetType, - modelUrl, - error: error instanceof Error ? error.message : String(error), - }) - return this.persistVariantThumbnail( - variant, - assetVariantThumbnailService.getPlaceholderUrl(), - updatedBy, - ) - } - } - async listFamilies(filter?: Pick) { const result = await assetFamilyRepository.findManyWithRelations(filter ? { filter } : undefined) return result.items.map((item) => toAssetFamilyDTO(item)) @@ -597,13 +515,12 @@ class AssetCenterService { } async getSubstrateDictionaryBundle(): Promise { - const [categories, environmentGrades, thicknessSpecs, surfaceOptions, systemNameOptions] = + const [categories, environmentGrades, thicknessSpecs, surfaceOptions] = 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 { @@ -611,7 +528,6 @@ class AssetCenterService { environmentGrades: environmentGrades.map((item) => toDictionaryOptionDTO('ENVIRONMENT_GRADE', item)), thicknessSpecs: thicknessSpecs.map((item) => toDictionaryOptionDTO('THICKNESS_SPEC', item)), surfaceOptions: surfaceOptions.map((item) => toDictionaryOptionDTO('SURFACE_OPTION', item)), - systemNameOptions: systemNameOptions.map((item) => toDictionaryOptionDTO('SYSTEM_NAME_OPTION', item)), } } @@ -694,7 +610,6 @@ class AssetCenterService { environmentGradeId: input.environmentGradeId, thicknessSpecId: input.thicknessSpecId, surfaceOptionId: input.surfaceOptionId ?? null, - systemNameOptionId: input.systemNameOptionId, isActive: true, }, include: { @@ -718,7 +633,6 @@ class AssetCenterService { environmentGradeId: existing.environmentGradeId, thicknessSpecId: existing.thicknessSpecId, surfaceOptionId: existing.surfaceOptionId ?? null, - systemNameOptionId: existing.systemNameOptionId, finishIds: mergedFinishIds, }) @@ -751,7 +665,6 @@ class AssetCenterService { environmentGradeId: input.environmentGradeId, thicknessSpecId: input.thicknessSpecId, surfaceOptionId: input.surfaceOptionId ?? null, - systemNameOptionId: input.systemNameOptionId, finishIds, }) @@ -766,7 +679,6 @@ class AssetCenterService { surfaceOption: input.surfaceOptionId ? { connect: { id: input.surfaceOptionId } } : undefined, - systemNameOption: { connect: { id: input.systemNameOptionId } }, suggestedPrice: input.suggestedPrice ?? null, weightKg: input.weightKg ?? null, finishes: @@ -828,7 +740,6 @@ class AssetCenterService { 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 !== undefined ? normalizeFinishIds(input.finishIds) @@ -842,13 +753,12 @@ class AssetCenterService { environmentGradeId: nextEnvironmentGradeId, thicknessSpecId: nextThicknessSpecId, surfaceOptionId: nextSurfaceOptionId ?? null, - systemNameOptionId: nextSystemNameOptionId, isActive: true, }, select: { id: true }, }) if (duplicate) { - throw new AppError(ErrorCode.VALIDATION_FAILED, '已存在相同 5 字段规格的基材,请直接维护已有基材') + throw new AppError(ErrorCode.VALIDATION_FAILED, '已存在相同规格的基材,请直接维护已有基材') } const payload = await buildSubstratePayloadWithFinishes({ @@ -856,7 +766,6 @@ class AssetCenterService { environmentGradeId: nextEnvironmentGradeId, thicknessSpecId: nextThicknessSpecId, surfaceOptionId: nextSurfaceOptionId ?? null, - systemNameOptionId: nextSystemNameOptionId, finishIds: nextFinishIds, }) @@ -883,10 +792,6 @@ class AssetCenterService { : 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: @@ -981,11 +886,6 @@ class AssetCenterService { 'SURFACE_OPTION', await prisma.substrateSurfaceOption.create({ data: dataBase }), ) - case 'SYSTEM_NAME_OPTION': - return toDictionaryOptionDTO( - 'SYSTEM_NAME_OPTION', - await prisma.substrateSystemNameOption.create({ data: dataBase }), - ) } } catch (error) { if ( @@ -1056,18 +956,6 @@ class AssetCenterService { }, }), ) - case 'SYSTEM_NAME_OPTION': - return toDictionaryOptionDTO( - 'SYSTEM_NAME_OPTION', - await prisma.substrateSystemNameOption.update({ - where: { id }, - data: { - name: input.name?.trim(), - sortOrder: input.sortOrder, - isActive: input.isActive, - }, - }), - ) } } catch (error) { if ( @@ -1092,7 +980,6 @@ class AssetCenterService { ENVIRONMENT_GRADE: { environmentGradeId: id }, THICKNESS_SPEC: { thicknessSpecId: id }, SURFACE_OPTION: { surfaceOptionId: id }, - SYSTEM_NAME_OPTION: { systemNameOptionId: id }, } const referenceCount = await prisma.substrate.count({ where: whereByType[type] }) if (referenceCount > 0) { @@ -1113,9 +1000,6 @@ class AssetCenterService { case 'SURFACE_OPTION': await prisma.substrateSurfaceOption.delete({ where: { id } }) return - case 'SYSTEM_NAME_OPTION': - await prisma.substrateSystemNameOption.delete({ where: { id } }) - return } } catch (error) { if ( @@ -1128,30 +1012,14 @@ class AssetCenterService { } } - async createVariant( - input: CreateAssetVariantInput, - options?: VariantThumbnailRenderOptions, - ): Promise { - const variant = await this.createVariantInternal(input) - return this.maybeGenerateVariantThumbnail({ - variant, - renderBaseUrl: options?.renderBaseUrl, - updatedBy: input.createdBy ?? null, - skip: Boolean(trimOrNull(input.thumbnailUrl)), - }) + async createVariant(input: CreateAssetVariantInput): Promise { + return this.createVariantInternal(input) } async createVariantWithSimilarity( input: CreateAssetVariantInput, - options?: VariantThumbnailRenderOptions, ): Promise { - const createdVariant = await this.createVariantInternal(input) - const variant = await this.maybeGenerateVariantThumbnail({ - variant: createdVariant, - renderBaseUrl: options?.renderBaseUrl, - updatedBy: input.createdBy ?? null, - skip: Boolean(trimOrNull(input.thumbnailUrl)), - }) + const variant = await this.createVariantInternal(input) const similarity = await this.findSimilarCandidatesForVariant(variant) return { @@ -1293,11 +1161,7 @@ class AssetCenterService { } } - async updateVariant( - id: string, - input: UpdateAssetVariantInput, - options?: VariantThumbnailRenderOptions, - ): Promise { + async updateVariant(id: string, input: UpdateAssetVariantInput): Promise { const existing = await assetVariantRepository.findByIdWithRelations(id) if (!existing) { throw new AppError(ErrorCode.ASSET_VARIANT_NOT_FOUND, '规格款不存在') @@ -1353,8 +1217,6 @@ class AssetCenterService { const nextFinishId = input.finishId !== undefined ? input.finishId : existing.finishId const nextModelUrl = input.modelUrl?.trim() ?? existing.modelUrl const nextLossFactor = input.lossFactor !== undefined ? input.lossFactor : existing.lossFactor - const didModelUrlChange = - input.modelUrl !== undefined && trimOrNull(input.modelUrl) !== trimOrNull(existing.modelUrl) if ( existing.status === 'AVAILABLE' && @@ -1470,35 +1332,7 @@ class AssetCenterService { lastModifier: input.updatedBy ? { connect: { id: input.updatedBy } } : undefined, }) - const updatedVariant = toAssetVariantDTO(updated) - if (didModelUrlChange) { - return this.maybeGenerateVariantThumbnail({ - variant: updatedVariant, - renderBaseUrl: options?.renderBaseUrl, - updatedBy: input.updatedBy ?? null, - force: true, - skip: Boolean(trimOrNull(input.thumbnailUrl)), - }) - } - - return updatedVariant - } - - async regenerateVariantThumbnail( - id: string, - input: RegenerateVariantThumbnailInput, - ): Promise { - const existing = await assetVariantRepository.findByIdWithRelations(id) - if (!existing) { - throw new AppError(ErrorCode.ASSET_VARIANT_NOT_FOUND, '规格款不存在') - } - - return this.maybeGenerateVariantThumbnail({ - variant: toAssetVariantDTO(existing), - renderBaseUrl: input.renderBaseUrl, - updatedBy: input.updatedBy ?? null, - force: true, - }) + return toAssetVariantDTO(updated) } async deleteFamily(id: string): Promise { 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 2c88911d..e62684e3 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 @@ -100,8 +100,6 @@ export interface SubstrateDTO { sheetWidthMm: number surfaceOptionId: string | null surfaceOptionName: string | null - systemNameOptionId: string - systemNameOptionName: string suggestedPrice: number | null weightKg: number | null finishes: { @@ -121,7 +119,6 @@ export type SubstrateDictionaryType = | 'ENVIRONMENT_GRADE' | 'THICKNESS_SPEC' | 'SURFACE_OPTION' - | 'SYSTEM_NAME_OPTION' export interface SubstrateDictionaryOptionDTO { id: string @@ -141,7 +138,6 @@ export interface SubstrateDictionaryBundleDTO { environmentGrades: SubstrateDictionaryOptionDTO[] thicknessSpecs: SubstrateDictionaryOptionDTO[] surfaceOptions: SubstrateDictionaryOptionDTO[] - systemNameOptions: SubstrateDictionaryOptionDTO[] } export interface CreateAssetFamilyInput { @@ -223,7 +219,6 @@ export interface CreateSubstrateInput { environmentGradeId: string thicknessSpecId: string surfaceOptionId?: string | null - systemNameOptionId: string suggestedPrice?: number | null weightKg?: number | null finishIds?: string[] @@ -238,7 +233,6 @@ export interface UpdateSubstrateInput { environmentGradeId?: string thicknessSpecId?: string surfaceOptionId?: string | null - systemNameOptionId?: string suggestedPrice?: number | null weightKg?: number | null finishIds?: string[] diff --git a/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts b/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts index ee66c3e3..9ff5dfb2 100644 --- a/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts +++ b/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts @@ -376,7 +376,6 @@ async function ensureSubstrate(creatorId: string) { environmentGradeId: environmentGrade.id, thicknessSpecId: thicknessSpec.id, surfaceOptionId: surfaceOption.id, - systemNameOptionId: systemNameOption.id, suggestedPrice: 180, metadata: { seedKey: OPEN_PLATFORM_DEMO_SEED_MARKER,