diff --git a/docs/前端UI改动清单.md b/docs/前端UI改动清单.md index 3bc95ff3..14700ac5 100644 --- a/docs/前端UI改动清单.md +++ b/docs/前端UI改动清单.md @@ -17,7 +17,7 @@ - [ ] **资产中心资产族新流程人工验收** 归属:`联调确认` - 说明:确认资产中心现在已改成“先建资产族,再在资产族内新建规格款 / 发起 CAD 导入”的结构,用户理解是否自然,弹窗开关、列表刷新、导入完成后的落点是否符合预期。 + 说明:确认资产中心现在已改成"先建资产族,再在资产族内新建规格款 / 发起 CAD 导入;缺资料时跳转基础资料库补录"的结构,用户理解是否自然,弹窗开关、列表刷新、导入完成后的落点是否符合预期。 - [ ] **资产族品类系列 + 中文连接角色维护人工验收** 归属:`联调确认` @@ -26,3 +26,7 @@ - [ ] **包材资料库页面视觉联调** 归属:`联调确认` 说明:确认该页面视觉是否已统一到主线风格。 + +- [ ] **基础资料库 CRUD 联调** + 归属:`联调确认` + 说明:确认 `基础资料库` 页的基材新增/编辑/删除、材质分类新增/编辑/删除、饰面新增/编辑/删除全部可用;删除后回到资产中心,CAD 导入弹窗、规格款弹窗和 3D 右键快捷菜单都能读到最新数据。 diff --git a/furniture-3d-system/package.json b/furniture-3d-system/package.json index 22495725..bad46a54 100644 --- a/furniture-3d-system/package.json +++ b/furniture-3d-system/package.json @@ -23,7 +23,8 @@ "demo:setup": "npm run prisma:push && npm run demo:create", "demo:update": "node scripts/update-demo-models.js", "demo:refresh": "npm run prisma:push && npm run demo:update", - "materials:sync-textures": "tsx scripts/sync-material-textures.ts" + "materials:sync-textures": "tsx scripts/sync-material-textures.ts", + "materials:sync-standard": "tsx scripts/sync-standard-finishes.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.948.0", diff --git a/furniture-3d-system/prisma/seed.ts b/furniture-3d-system/prisma/seed.ts index 3baf95f7..050514be 100644 --- a/furniture-3d-system/prisma/seed.ts +++ b/furniture-3d-system/prisma/seed.ts @@ -3,11 +3,12 @@ * 用于填充测试数据和演示数据 */ -import { PrismaClient, FeedbackStatus, FeedbackPriority, ActivityType, Prisma } from '@prisma/client'; +import { PrismaClient, FeedbackStatus, FeedbackPriority, ActivityType } from '@prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; import { Pool } from 'pg'; import { hash } from 'bcryptjs'; -import { mergeMaterialWithPreset } from '../src/lib/3d/material-pbr-presets'; +import { STANDARD_FINISH_CATALOG } from '../src/lib/standard-finish-catalog'; +import { buildStandardFinishMaterialData } from '../src/lib/standard-finish-sync'; import 'dotenv/config'; // Prisma 7 需要使用适配器 @@ -17,55 +18,6 @@ const pool = new Pool({ const adapter = new PrismaPg(pool); const prisma = new PrismaClient({ adapter }); -interface SeedBaseMaterial { - name: string - color: string - roughness: number - metalness: number - opacity?: number -} - -function buildPresetMaterialData(params: { - material: SeedBaseMaterial - categoryId: string - categoryName: string | null - thumbnailFolder: string -}): Prisma.MaterialUncheckedCreateInput { - const { material, categoryId, categoryName, thumbnailFolder } = params - const defaultThumbnailUrl = `/materials/thumbnails/${thumbnailFolder}/${material.name.replace(/[/-]/g, '_')}.jpg` - - const merged = mergeMaterialWithPreset({ - id: `seed-${material.name}`, - name: material.name, - categoryId, - categoryName, - thumbnailUrl: defaultThumbnailUrl, - color: material.color, - roughness: material.roughness, - metalness: material.metalness, - opacity: material.opacity ?? 1, - textureRepeat: 1, - textureRotation: 0, - isPreset: true, - }, { forcePreset: true }) - - return { - name: material.name, - categoryId, - thumbnailUrl: merged.thumbnailUrl || defaultThumbnailUrl, - color: merged.color ?? material.color, - roughness: merged.roughness ?? material.roughness, - metalness: merged.metalness ?? material.metalness, - opacity: merged.opacity ?? material.opacity ?? 1, - textureUrl: merged.textureUrl ?? null, - normalMapUrl: merged.normalMapUrl ?? null, - textureRepeat: merged.textureRepeat ?? 1, - textureRotation: merged.textureRotation ?? 0, - isPreset: true, - isActive: true, - } -} - async function main() { console.log('🌱 开始填充数据库...'); @@ -90,192 +42,41 @@ async function main() { await prisma.department.deleteMany(); // ============================================ - // 创建材质分类和预置材质 + // 创建饰面/花色标准库 // ============================================ - console.log('🎨 创建材质分类...'); - const materialCategories = await Promise.all([ - prisma.materialCategory.create({ - data: { name: '实木', icon: '🪵', sortOrder: 1 }, - }), - prisma.materialCategory.create({ - data: { name: '金属', icon: '🔩', sortOrder: 2 }, - }), - prisma.materialCategory.create({ - data: { name: '玻璃', icon: '🪟', sortOrder: 3 }, - }), - prisma.materialCategory.create({ - data: { name: '亚克力', icon: '💎', sortOrder: 4 }, - }), - prisma.materialCategory.create({ - data: { name: '皮革', icon: '🛋️', sortOrder: 5 }, - }), - prisma.materialCategory.create({ - data: { name: '布艺', icon: '🧵', sortOrder: 6 }, - }), - prisma.materialCategory.create({ - data: { name: '石材', icon: '🪨', sortOrder: 7 }, - }), - ]); - - // 材质分类映射 - const matCatMap: Record = {}; - materialCategories.forEach(cat => { - matCatMap[cat.name] = cat.id; - }); - const matCatNameById = new Map(materialCategories.map((category) => [category.id, category.name])); - - const woodCategoryId = matCatMap['实木']; - const metalCategoryId = matCatMap['金属']; - const glassCategoryId = matCatMap['玻璃']; - const acrylicCategoryId = matCatMap['亚克力']; - const leatherCategoryId = matCatMap['皮革']; - const fabricCategoryId = matCatMap['布艺']; - const stoneCategoryId = matCatMap['石材']; - - console.log('🎨 创建预置材质...'); - - // 实木类材质 - const woodMaterials = [ - { name: '橡木-自然色', color: '#C4A35A', roughness: 0.7, metalness: 0 }, - { name: '橡木-深色', color: '#8B6914', roughness: 0.7, metalness: 0 }, - { name: '橡木-漂白', color: '#E8DCC4', roughness: 0.6, metalness: 0 }, - { name: '胡桃木-北美', color: '#5D432C', roughness: 0.65, metalness: 0 }, - { name: '胡桃木-黑胡桃', color: '#3D2817', roughness: 0.7, metalness: 0 }, - { name: '樱桃木', color: '#9A4A3A', roughness: 0.6, metalness: 0 }, - { name: '白蜡木', color: '#D4C4A8', roughness: 0.55, metalness: 0 }, - { name: '松木', color: '#E6D5B8', roughness: 0.65, metalness: 0 }, - { name: '榉木', color: '#C9A86C', roughness: 0.6, metalness: 0 }, - ]; - - for (const mat of woodMaterials) { - await prisma.material.create({ - data: buildPresetMaterialData({ - material: mat, - categoryId: woodCategoryId, - categoryName: matCatNameById.get(woodCategoryId) ?? null, - thumbnailFolder: 'wood', + console.log('🎨 创建饰面/花色标准分类...'); + const materialCategories = await Promise.all( + STANDARD_FINISH_CATALOG.map((category) => + prisma.materialCategory.create({ + data: { + name: category.name, + icon: category.icon, + sortOrder: category.sortOrder, + isActive: true, + }, }), - }); - } + ), + ); - // 金属类材质 - const metalMaterials = [ - { name: '不锈钢-镜面', color: '#D4D4D4', roughness: 0.1, metalness: 1 }, - { name: '不锈钢-拉丝', color: '#C0C0C0', roughness: 0.3, metalness: 1 }, - { name: '不锈钢-哑光', color: '#A8A8A8', roughness: 0.5, metalness: 0.9 }, - { name: '黄铜-抛光', color: '#D4A84B', roughness: 0.15, metalness: 1 }, - { name: '黄铜-做旧', color: '#B08D4B', roughness: 0.5, metalness: 0.8 }, - { name: '黑铁', color: '#2A2A2A', roughness: 0.6, metalness: 0.85 }, - { name: '铝合金', color: '#E0E0E0', roughness: 0.35, metalness: 0.95 }, - ]; + const categoryIdByKey = new Map( + STANDARD_FINISH_CATALOG.map((category, index) => [category.key, materialCategories[index].id]), + ); + console.log('🎨 创建饰面/花色标准预设...'); + for (const category of STANDARD_FINISH_CATALOG) { + const categoryId = categoryIdByKey.get(category.key); + if (!categoryId) { + throw new Error(`缺少饰面分类 ID: ${category.key}`); + } - for (const mat of metalMaterials) { - await prisma.material.create({ - data: buildPresetMaterialData({ - material: mat, - categoryId: metalCategoryId, - categoryName: matCatNameById.get(metalCategoryId) ?? null, - thumbnailFolder: 'metal', - }), - }); - } - - // 玻璃类材质 - const glassMaterials = [ - { name: '透明玻璃', color: '#FFFFFF', roughness: 0.05, metalness: 0, opacity: 0.3 }, - { name: '磨砂玻璃', color: '#F5F5F5', roughness: 0.4, metalness: 0, opacity: 0.5 }, - { name: '茶色玻璃', color: '#8B7355', roughness: 0.05, metalness: 0, opacity: 0.4 }, - { name: '灰色玻璃', color: '#808080', roughness: 0.05, metalness: 0, opacity: 0.35 }, - ]; - - for (const mat of glassMaterials) { - await prisma.material.create({ - data: buildPresetMaterialData({ - material: mat, - categoryId: glassCategoryId, - categoryName: matCatNameById.get(glassCategoryId) ?? null, - thumbnailFolder: 'glass', - }), - }); - } - - // 亚克力类材质 - const acrylicMaterials = [ - { name: '透明亚克力', color: '#FFFFFF', roughness: 0.1, metalness: 0, opacity: 0.25 }, - { name: '乳白亚克力', color: '#FFFAF0', roughness: 0.15, metalness: 0, opacity: 0.6 }, - { name: '黑色亚克力', color: '#1A1A1A', roughness: 0.1, metalness: 0, opacity: 0.95 }, - ]; - - for (const mat of acrylicMaterials) { - await prisma.material.create({ - data: buildPresetMaterialData({ - material: mat, - categoryId: acrylicCategoryId, - categoryName: matCatNameById.get(acrylicCategoryId) ?? null, - thumbnailFolder: 'acrylic', - }), - }); - } - - // 皮革类材质 - const leatherMaterials = [ - { name: '真皮-黑色', color: '#1A1A1A', roughness: 0.5, metalness: 0 }, - { name: '真皮-棕色', color: '#5D3A1A', roughness: 0.5, metalness: 0 }, - { name: '真皮-米白', color: '#E8DCC4', roughness: 0.45, metalness: 0 }, - { name: '仿皮-黑色', color: '#2A2A2A', roughness: 0.4, metalness: 0 }, - { name: '仿皮-灰色', color: '#6B6B6B', roughness: 0.4, metalness: 0 }, - ]; - - for (const mat of leatherMaterials) { - await prisma.material.create({ - data: buildPresetMaterialData({ - material: mat, - categoryId: leatherCategoryId, - categoryName: matCatNameById.get(leatherCategoryId) ?? null, - thumbnailFolder: 'leather', - }), - }); - } - - // 布艺类材质 - const fabricMaterials = [ - { name: '棉麻-米白', color: '#F5F0E6', roughness: 0.8, metalness: 0 }, - { name: '棉麻-灰色', color: '#9B9B9B', roughness: 0.8, metalness: 0 }, - { name: '棉麻-蓝色', color: '#4A6B8A', roughness: 0.8, metalness: 0 }, - { name: '绒布-深蓝', color: '#1E3A5F', roughness: 0.75, metalness: 0 }, - { name: '绒布-墨绿', color: '#2F4538', roughness: 0.75, metalness: 0 }, - { name: '绒布-酒红', color: '#6B2D3A', roughness: 0.75, metalness: 0 }, - { name: '网布-黑色', color: '#2A2A2A', roughness: 0.7, metalness: 0 }, - ]; - - for (const mat of fabricMaterials) { - await prisma.material.create({ - data: buildPresetMaterialData({ - material: mat, - categoryId: fabricCategoryId, - categoryName: matCatNameById.get(fabricCategoryId) ?? null, - thumbnailFolder: 'fabric', - }), - }); - } - - // 石材类材质 - const stoneMaterials = [ - { name: '大理石-白色', color: '#F5F5F5', roughness: 0.2, metalness: 0 }, - { name: '大理石-灰色', color: '#A0A0A0', roughness: 0.25, metalness: 0 }, - { name: '大理石-黑色', color: '#2A2A2A', roughness: 0.2, metalness: 0 }, - { name: '岩板-深灰', color: '#4A4A4A', roughness: 0.35, metalness: 0 }, - ]; - - for (const mat of stoneMaterials) { - await prisma.material.create({ - data: buildPresetMaterialData({ - material: mat, - categoryId: stoneCategoryId, - categoryName: matCatNameById.get(stoneCategoryId) ?? null, - thumbnailFolder: 'stone', - }), - }); + for (const material of category.materials) { + await prisma.material.create({ + data: buildStandardFinishMaterialData({ + material, + category, + categoryId, + }), + }); + } } // ============================================ diff --git a/furniture-3d-system/scripts/sync-standard-finishes.ts b/furniture-3d-system/scripts/sync-standard-finishes.ts new file mode 100644 index 00000000..aea2a13d --- /dev/null +++ b/furniture-3d-system/scripts/sync-standard-finishes.ts @@ -0,0 +1,169 @@ +import 'dotenv/config' + +import { prisma } from '../src/lib/prisma' +import { STANDARD_FINISH_CATALOG } from '../src/lib/standard-finish-catalog' +import { buildStandardFinishMaterialData } from '../src/lib/standard-finish-sync' + +interface SyncSummary { + categoriesCreated: number + categoriesUpdated: number + categoriesDeactivated: number + materialsCreated: number + materialsUpdated: number + materialsDeactivated: number +} + +async function main() { + const summary: SyncSummary = { + categoriesCreated: 0, + categoriesUpdated: 0, + categoriesDeactivated: 0, + materialsCreated: 0, + materialsUpdated: 0, + materialsDeactivated: 0, + } + + const standardCategoryNames = new Set(STANDARD_FINISH_CATALOG.map((category) => category.name)) + const standardMaterialKeys = new Set( + STANDARD_FINISH_CATALOG.flatMap((category) => + category.materials.map((material) => `${category.name}::${material.name}`), + ), + ) + + for (const categoryDefinition of STANDARD_FINISH_CATALOG) { + const category = await prisma.materialCategory.upsert({ + where: { name: categoryDefinition.name }, + update: { + icon: categoryDefinition.icon, + sortOrder: categoryDefinition.sortOrder, + isActive: true, + }, + create: { + name: categoryDefinition.name, + icon: categoryDefinition.icon, + sortOrder: categoryDefinition.sortOrder, + isActive: true, + }, + }) + + const existingCategory = await prisma.materialCategory.findUnique({ + where: { id: category.id }, + select: { createdAt: true, updatedAt: true }, + }) + + if (existingCategory && existingCategory.createdAt.getTime() === existingCategory.updatedAt.getTime()) { + summary.categoriesCreated += 1 + } else { + summary.categoriesUpdated += 1 + } + + for (const materialDefinition of categoryDefinition.materials) { + const data = buildStandardFinishMaterialData({ + material: materialDefinition, + category: categoryDefinition, + categoryId: category.id, + }) + + const existingMaterial = await prisma.material.findFirst({ + where: { + categoryId: category.id, + name: materialDefinition.name, + }, + select: { + id: true, + }, + }) + + if (existingMaterial) { + await prisma.material.update({ + where: { id: existingMaterial.id }, + data: { + thumbnailUrl: data.thumbnailUrl, + color: data.color, + metalness: data.metalness, + roughness: data.roughness, + opacity: data.opacity, + textureUrl: data.textureUrl, + normalMapUrl: data.normalMapUrl, + textureRepeat: data.textureRepeat, + textureRotation: data.textureRotation, + textureSizeMm: data.textureSizeMm, + isPreset: true, + isActive: true, + }, + }) + summary.materialsUpdated += 1 + } else { + await prisma.material.create({ data }) + summary.materialsCreated += 1 + } + } + } + + const presetCategoriesToDeactivate = await prisma.materialCategory.findMany({ + where: { + isActive: true, + materials: { + some: { + isPreset: true, + }, + }, + name: { + notIn: Array.from(standardCategoryNames), + }, + }, + select: { id: true }, + }) + + for (const category of presetCategoriesToDeactivate) { + await prisma.materialCategory.update({ + where: { id: category.id }, + data: { isActive: false }, + }) + summary.categoriesDeactivated += 1 + } + + const presetMaterials = await prisma.material.findMany({ + where: { + isPreset: true, + category: { + isActive: true, + }, + }, + select: { + id: true, + name: true, + category: { + select: { + name: true, + }, + }, + }, + }) + + for (const material of presetMaterials) { + const materialKey = `${material.category.name}::${material.name}` + if (standardMaterialKeys.has(materialKey)) { + continue + } + + await prisma.material.update({ + where: { id: material.id }, + data: { isActive: false }, + }) + summary.materialsDeactivated += 1 + } + + console.log('标准饰面同步完成。') + console.log(JSON.stringify(summary, null, 2)) +} + +main() + .catch((error) => { + console.error('标准饰面同步失败:') + console.error(error) + process.exitCode = 1 + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/furniture-3d-system/src/app/api/asset-center/substrates/[id]/route.ts b/furniture-3d-system/src/app/api/asset-center/substrates/[id]/route.ts new file mode 100644 index 00000000..3682d9ef --- /dev/null +++ b/furniture-3d-system/src/app/api/asset-center/substrates/[id]/route.ts @@ -0,0 +1,60 @@ +import { NextRequest } from 'next/server' +import { AppError, ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { assetCenterService } from '@/server/services' +import { substrateUpdateSchema } from '../../../platform/schemas' + +interface RouteParams { + params: Promise<{ + id: string + }> +} + +export async function PUT(request: NextRequest, context: RouteParams) { + return handleRequest( + request, + async (ctx) => { + const rawBody = await request.json().catch(() => null) + const parsed = substrateUpdateSchema.safeParse(rawBody) + if (!parsed.success) { + return jsonError( + ErrorCode.VALIDATION_FAILED, + `基材参数不正确: ${parsed.error.message}`, + 400, + ctx.requestId, + ) + } + + try { + const { id } = await context.params + const substrate = await assetCenterService.updateSubstrate(id, parsed.data) + return jsonSuccess({ substrate }, 200, ctx.requestId) + } catch (error) { + if (error instanceof AppError) { + return jsonError(error.code, error.message, error.statusCode, ctx.requestId) + } + throw error + } + }, + { requireAuth: true }, + ) +} + +export async function DELETE(request: NextRequest, context: RouteParams) { + return handleRequest( + request, + async (ctx) => { + try { + const { id } = await context.params + const substrate = await assetCenterService.deleteSubstrate(id) + return jsonSuccess({ substrate }, 200, ctx.requestId) + } catch (error) { + if (error instanceof AppError) { + return jsonError(error.code, error.message, error.statusCode, ctx.requestId) + } + throw error + } + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/material-categories/[id]/route.ts b/furniture-3d-system/src/app/api/material-categories/[id]/route.ts new file mode 100644 index 00000000..e12d4eea --- /dev/null +++ b/furniture-3d-system/src/app/api/material-categories/[id]/route.ts @@ -0,0 +1,74 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { AppError, ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { materialService } from '@/server/services' + +const updateMaterialCategorySchema = z.object({ + name: z.string().trim().min(1, '请填写分类名称').max(50, '分类名称不能超过 50 个字符').optional(), + icon: z.string().trim().max(50, '图标标识不能超过 50 个字符').optional().nullable(), + sortOrder: z.number().int().min(0).optional(), + isActive: z.boolean().optional(), +}) + +interface RouteParams { + params: Promise<{ + id: string + }> +} + +export async function PUT(request: NextRequest, context: RouteParams) { + return handleRequest( + request, + async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = updateMaterialCategorySchema.safeParse(body) + + if (!parsed.success) { + return jsonError( + ErrorCode.VALIDATION_FAILED, + parsed.error.issues[0]?.message || '材质分类参数不正确', + 400, + ctx.requestId, + ) + } + + try { + const { id } = await context.params + const category = await materialService.updateCategory(id, { + name: parsed.data.name, + icon: parsed.data.icon || undefined, + sortOrder: parsed.data.sortOrder, + isActive: parsed.data.isActive, + }) + return jsonSuccess({ category }, 200, ctx.requestId) + } catch (error) { + if (error instanceof AppError) { + return jsonError(error.code, error.message, 400, ctx.requestId) + } + throw error + } + }, + { requireAuth: true }, + ) +} + +export async function DELETE(request: NextRequest, context: RouteParams) { + return handleRequest( + request, + async (ctx) => { + try { + const { id } = await context.params + const category = await materialService.findCategoryById(id) + await materialService.deleteCategory(id) + return jsonSuccess({ category }, 200, ctx.requestId) + } catch (error) { + if (error instanceof AppError) { + return jsonError(error.code, error.message, 400, ctx.requestId) + } + throw error + } + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/materials/[id]/route.ts b/furniture-3d-system/src/app/api/materials/[id]/route.ts index fdd39848..e2e3ffaa 100644 --- a/furniture-3d-system/src/app/api/materials/[id]/route.ts +++ b/furniture-3d-system/src/app/api/materials/[id]/route.ts @@ -22,6 +22,41 @@ export async function DELETE(request: NextRequest, context: RouteParams) { ) } +export async function PUT(request: NextRequest, context: RouteParams) { + return handleRequest( + request, + async (ctx) => { + const { id } = await context.params + const body = await request.json().catch(() => null) + const payload = (body ?? {}) as { + name?: string + categoryId?: string + color?: string + thumbnailUrl?: string | null + isActive?: boolean + } + + try { + const material = await materialService.update(id, { + name: payload.name?.trim() || undefined, + categoryId: payload.categoryId?.trim() || undefined, + color: payload.color, + thumbnailUrl: + payload.thumbnailUrl === undefined ? undefined : payload.thumbnailUrl?.trim() || '/materials/thumbnails/default.svg', + isActive: payload.isActive, + }) + return jsonSuccess(material, 200, ctx.requestId) + } catch (error) { + if (error instanceof AppError) { + return jsonError(error.code, error.message, 400, ctx.requestId) + } + throw error + } + }, + { requireAuth: true }, + ) +} + export async function GET(request: NextRequest, context: RouteParams) { return handleRequest( request, diff --git a/furniture-3d-system/src/app/api/materials/route.ts b/furniture-3d-system/src/app/api/materials/route.ts index 60982094..c31d37f5 100644 --- a/furniture-3d-system/src/app/api/materials/route.ts +++ b/furniture-3d-system/src/app/api/materials/route.ts @@ -5,7 +5,8 @@ */ import { NextRequest } from 'next/server' -import { handleRequest, jsonSuccess } from '@/server/core/response' +import { AppError, ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' import { materialService } from '@/server/services' export async function GET(request: NextRequest) { @@ -23,30 +24,39 @@ export async function POST(request: NextRequest) { return handleRequest( request, async (ctx) => { - const body = await request.json() - const { name, categoryId, color } = body as { + const body = await request.json().catch(() => null) + const { name, categoryId, color, thumbnailUrl } = (body ?? {}) as { name?: string categoryId?: string color?: string + thumbnailUrl?: string } if (!name?.trim()) { - throw new Error('材质名称不能为空') + return jsonError(ErrorCode.VALIDATION_FAILED, '材质名称不能为空', 400, ctx.requestId) } if (!categoryId) { - throw new Error('请选择材质分类') + return jsonError(ErrorCode.VALIDATION_FAILED, '请选择材质分类', 400, ctx.requestId) } - const created = await materialService.create({ - name: name.trim(), - categoryId, - color: color || '#CCCCCC', - metalness: 0, - roughness: 0.5, - opacity: 1, - }) + try { + const created = await materialService.create({ + name: name.trim(), + categoryId, + color: color || '#CCCCCC', + thumbnailUrl: thumbnailUrl?.trim() || undefined, + metalness: 0, + roughness: 0.5, + opacity: 1, + }) - return jsonSuccess(created, 201, ctx.requestId) + return jsonSuccess(created, 201, ctx.requestId) + } catch (error) { + if (error instanceof AppError) { + return jsonError(error.code, error.message, 400, ctx.requestId) + } + throw error + } }, { requireAuth: true } ) diff --git a/furniture-3d-system/src/app/api/platform/__tests__/schemas.test.ts b/furniture-3d-system/src/app/api/platform/__tests__/schemas.test.ts index bd1bdc65..b4aff21f 100644 --- a/furniture-3d-system/src/app/api/platform/__tests__/schemas.test.ts +++ b/furniture-3d-system/src/app/api/platform/__tests__/schemas.test.ts @@ -7,8 +7,6 @@ describe('assetCadImportPayloadSchema', () => { familyMode: 'existing', variantCode: 'HINGE-40', variantName: '40 合页', - weightKg: 0.4, - costPrice: 6, }) expect(result.success).toBe(false) @@ -34,8 +32,6 @@ describe('assetCadImportPayloadSchema', () => { connectionRoles: [], variantCode: 'COMP-LEG-60', variantName: '桌腿组件 60', - weightKg: 3.2, - costPrice: 45, }) expect(result.success).toBe(false) @@ -52,7 +48,7 @@ describe('assetCadImportPayloadSchema', () => { ) }) - it('板材导入会要求基材、饰面、重量和成本', () => { + it('板材导入会要求基材和饰面,但不再强制要求重量和成本', () => { const result = assetCadImportPayloadSchema.safeParse({ assetType: 'BOARD', familyMode: 'create', @@ -71,6 +67,10 @@ describe('assetCadImportPayloadSchema', () => { expect.arrayContaining([ expect.objectContaining({ path: ['substrateId'] }), expect.objectContaining({ path: ['finishId'] }), + ]), + ) + expect(result.error.issues).not.toEqual( + expect.arrayContaining([ expect.objectContaining({ path: ['weightKg'] }), expect.objectContaining({ path: ['costPrice'] }), ]), @@ -94,4 +94,20 @@ describe('assetCadImportPayloadSchema', () => { expect(result.success).toBe(true) }) + + it('板材导入在不填写重量和成本时也可以通过校验', () => { + const result = assetCadImportPayloadSchema.safeParse({ + assetType: 'BOARD', + familyMode: 'create', + familyCode: 'BOARD-TOP', + familyName: '桌面板', + connectionRoles: [], + variantCode: 'BOARD-1200', + variantName: '桌面板 1200', + substrateId: 'substrate-board', + finishId: 'finish-oak', + }) + + expect(result.success).toBe(true) + }) }) diff --git a/furniture-3d-system/src/app/api/platform/schemas.ts b/furniture-3d-system/src/app/api/platform/schemas.ts index 75c76907..015f3a7c 100644 --- a/furniture-3d-system/src/app/api/platform/schemas.ts +++ b/furniture-3d-system/src/app/api/platform/schemas.ts @@ -101,6 +101,15 @@ export const substrateCreateSchema = z.object({ 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(), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), + isActive: z.boolean().optional(), +}) + export const assetCadImportPayloadSchema = z.object({ assetType: z.enum(['BOARD', 'COMPONENT', 'HARDWARE']), familyMode: assetCadImportFamilyModeSchema, @@ -145,22 +154,6 @@ export const assetCadImportPayloadSchema = z.object({ }) } - if (value.weightKg === null || value.weightKg === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['weightKg'], - message: '规格款必须填写重量', - }) - } - - if (value.costPrice === null || value.costPrice === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['costPrice'], - message: '规格款必须填写成本价', - }) - } - if (value.familyMode === 'existing' && (!value.variantCode || !value.variantName)) { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx index ac95d5be..43253298 100644 --- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx +++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx @@ -46,29 +46,12 @@ import { resolveConnectionRoleLabel, type ConnectionRoleDefinition, } from '@/lib/open-product/connection-role-catalog' -import { - calculateBoardMetrics, - buildBoardRuleCode, - buildBoardRuleName, - getBoardFinishOptionsForRule, - getBoardFinishScopeLabel, - getBoardVeneerModeLabel, - matchBoardRuleForFinish, - normalizeBoardRuleMetadata, - readBoardRuleFromSubstrate, - type BoardFinishScope, - type BoardSubstrateRecord, - type BoardVeneerMode, -} from '@/lib/board-substrate-rules' import { getLevel1FromFamily } from '@/lib/packaging-materials/catalog' import { cn } from '@/lib/utils' import { createAssetFamily, createAssetVariant, - createMaterialCategory, - createSubstrate, deleteAssetFamily, - deleteMaterial, deleteAssetVariant, importAssetCad, updateAssetFamily, @@ -197,151 +180,6 @@ function toOptionalNumber(value: string): number | null { return Number.isFinite(parsed) ? parsed : null } -const BOARD_MATERIAL_OPTIONS = ['密度板', '刨花板', '生态板', '三合板', '橡胶木'] as const - -const BOARD_ENVIRONMENTAL_OPTIONS: Record<(typeof BOARD_MATERIAL_OPTIONS)[number], string[]> = { - 密度板: ['E2'], - 刨花板: ['E1'], - 生态板: ['E1'], - 三合板: ['E1'], - 橡胶木: ['ENF'], -} - -function splitFinishNames(value: string): string[] { - return value - .split(/[,,、]/) - .map((item) => item.trim()) - .filter(Boolean) -} - -type SubstrateForm = { - code: string - name: string - materialName: (typeof BOARD_MATERIAL_OPTIONS)[number] - environmentalGrade: string - thicknessMm: string - veneerMode: BoardVeneerMode - finishScope: BoardFinishScope - finishNames: string - sheetLengthMm: string - sheetWidthMm: string - sheetPrice: string - sheetWeightKg: string -} - -function emptySubstrateForm(): SubstrateForm { - const materialName = '密度板' - const environmentalGrade = BOARD_ENVIRONMENTAL_OPTIONS[materialName][0] - const thicknessMm = '12' - const veneerMode: BoardVeneerMode = 'single' - const finishScope: BoardFinishScope = 'REGULAR' - const finishNames = '' - - return { - code: buildBoardRuleCode({ - materialName, - environmentalGrade, - thicknessMm: Number(thicknessMm), - veneerMode, - finishScope, - finishNames: [], - sheetPrice: 0, - sheetWeightKg: null, - }), - name: buildBoardRuleName({ - materialName, - environmentalGrade, - thicknessMm: Number(thicknessMm), - veneerMode, - finishScope, - finishNames: [], - }), - materialName, - environmentalGrade, - thicknessMm, - veneerMode, - finishScope, - finishNames, - sheetLengthMm: '2440', - sheetWidthMm: '1220', - sheetPrice: '', - sheetWeightKg: '', - } -} - -function buildSubstrateDraft(form: SubstrateForm) { - const finishNames = form.finishScope === 'SPECIAL' - ? splitFinishNames(form.finishNames) - : [] - - const metadata = normalizeBoardRuleMetadata({ - materialName: form.materialName, - environmentalGrade: form.environmentalGrade, - thicknessMm: Number(form.thicknessMm) || 0, - veneerMode: form.veneerMode, - finishScope: form.finishScope, - finishNames, - sheetLengthMm: Number(form.sheetLengthMm) || undefined, - sheetWidthMm: Number(form.sheetWidthMm) || undefined, - sheetPrice: Number(form.sheetPrice) || 0, - sheetWeightKg: toOptionalNumber(form.sheetWeightKg), - }) - - return { - metadata, - suggestedCode: buildBoardRuleCode({ - materialName: form.materialName, - environmentalGrade: form.environmentalGrade, - thicknessMm: Number(form.thicknessMm) || 0, - veneerMode: form.veneerMode, - finishScope: form.finishScope, - finishNames, - sheetPrice: Number(form.sheetPrice) || 0, - sheetWeightKg: toOptionalNumber(form.sheetWeightKg), - }), - suggestedName: buildBoardRuleName({ - materialName: form.materialName, - environmentalGrade: form.environmentalGrade, - thicknessMm: Number(form.thicknessMm) || 0, - veneerMode: form.veneerMode, - finishScope: form.finishScope, - finishNames, - }), - } -} - -type BoardVariantForm = { - materialName: string - environmentalGrade: string - thicknessMm: string - veneerMode: BoardVeneerMode -} - -function sortUniqueValues(values: string[]) { - return Array.from(new Set(values)).sort((left, right) => - left.localeCompare(right, 'zh-CN', { numeric: true }), - ) -} - -function buildBoardVariantForm( - rules: BoardSubstrateRecord[], - substrateId?: string | null, -): BoardVariantForm | null { - const matchedRule = - (substrateId ? rules.find((rule) => rule.substrateId === substrateId) : null) ?? rules[0] ?? null - - if (!matchedRule) { - return null - } - - return { - materialName: matchedRule.materialName, - environmentalGrade: matchedRule.environmentalGrade, - thicknessMm: String(matchedRule.thicknessMm), - veneerMode: matchedRule.veneerMode, - } -} - function getStatusTone(status: string) { if (status === 'AVAILABLE') return 'success' if (status === 'DISABLED') return 'warning' @@ -597,26 +435,10 @@ export default function AssetCenterClient({ cascadeCount?: number } | null>(null) - // 基材新建 - const [substrateModalOpen, setSubstrateModalOpen] = useState(false) - const [substrateForm, setSubstrateForm] = useState(emptySubstrateForm()) - const [substrateCodeEdited, setSubstrateCodeEdited] = useState(false) - const [substrateNameEdited, setSubstrateNameEdited] = useState(false) - - // 饰面/花色新建弹窗 - const [finishModalOpen, setFinishModalOpen] = useState(false) - const [finishForm, setFinishForm] = useState({ name: '', categoryId: '', color: '#CCCCCC' }) - const [materialCategoryModalOpen, setMaterialCategoryModalOpen] = useState(false) - const [materialCategoryForm, setMaterialCategoryForm] = useState({ - name: '', - icon: '', - }) - // 规格款弹窗 const [variantModalOpen, setVariantModalOpen] = useState(false) const [variantModalMode, setVariantModalMode] = useState<'create' | 'edit'>('create') const [variantForm, setVariantForm] = useState(emptyVariantForm('')) - const [boardVariantForm, setBoardVariantForm] = useState(null) const [editingVariantId, setEditingVariantId] = useState(null) const [cadImportModalOpen, setCadImportModalOpen] = useState(false) const [cadImportForm, setCadImportForm] = useState( @@ -645,10 +467,6 @@ export default function AssetCenterClient({ // ---- 派生数据 ---- const selectedVariant = variants.find((v) => v.id === selectedVariantId) ?? null - const selectedVariantFamily = useMemo( - () => families.find((family) => family.id === variantForm.assetFamilyId) ?? null, - [families, variantForm.assetFamilyId], - ) const editingFamily = useMemo( () => families.find((family) => family.id === editingFamilyId) ?? null, [editingFamilyId, families], @@ -670,137 +488,6 @@ export default function AssetCenterClient({ () => groupFinishesByCategory(finishes, materialCategories), [finishes, materialCategories], ) - const boardSubstrateRules = useMemo( - () => - substrates - .map((substrate) => readBoardRuleFromSubstrate(substrate)) - .filter((rule): rule is BoardSubstrateRecord => rule !== null), - [substrates], - ) - const substrateBoardRuleMap = useMemo( - () => new Map(boardSubstrateRules.map((rule) => [rule.substrateId, rule])), - [boardSubstrateRules], - ) - const substrateDraft = useMemo(() => buildSubstrateDraft(substrateForm), [substrateForm]) - const boardVariantMaterialOptions = useMemo( - () => - BOARD_MATERIAL_OPTIONS.filter((materialName) => - boardSubstrateRules.some((rule) => rule.materialName === materialName), - ), - [boardSubstrateRules], - ) - const boardVariantEnvironmentalOptions = useMemo(() => { - if (!boardVariantForm) { - return [] - } - - return sortUniqueValues( - boardSubstrateRules - .filter((rule) => rule.materialName === boardVariantForm.materialName) - .map((rule) => rule.environmentalGrade), - ) - }, [boardSubstrateRules, boardVariantForm]) - const boardVariantThicknessOptions = useMemo(() => { - if (!boardVariantForm) { - return [] - } - - return Array.from( - new Set( - boardSubstrateRules - .filter( - (rule) => - rule.materialName === boardVariantForm.materialName && - rule.environmentalGrade === boardVariantForm.environmentalGrade, - ) - .map((rule) => rule.thicknessMm), - ), - ).sort((left, right) => left - right) - }, [boardSubstrateRules, boardVariantForm]) - const boardVariantVeneerOptions = useMemo(() => { - if (!boardVariantForm) { - return [] - } - - return Array.from( - new Set( - boardSubstrateRules - .filter( - (rule) => - rule.materialName === boardVariantForm.materialName && - rule.environmentalGrade === boardVariantForm.environmentalGrade && - String(rule.thicknessMm) === boardVariantForm.thicknessMm, - ) - .map((rule) => rule.veneerMode), - ), - ) - }, [boardSubstrateRules, boardVariantForm]) - const boardVariantRuleCandidates = useMemo(() => { - if (!boardVariantForm) { - return [] - } - - return boardSubstrateRules.filter( - (rule) => - rule.materialName === boardVariantForm.materialName && - rule.environmentalGrade === boardVariantForm.environmentalGrade && - String(rule.thicknessMm) === boardVariantForm.thicknessMm && - rule.veneerMode === boardVariantForm.veneerMode, - ) - }, [boardSubstrateRules, boardVariantForm]) - const boardVariantFinishOptions = useMemo(() => { - if (boardVariantRuleCandidates.length === 0) { - return [] - } - - const finishNames = sortUniqueValues( - boardVariantRuleCandidates.flatMap((rule) => getBoardFinishOptionsForRule(rule)), - ) - - return finishNames - .map((finishName) => finishes.find((finish) => finish.name === finishName)) - .filter((finish): finish is MaterialDTO => finish !== undefined) - }, [boardVariantRuleCandidates, finishes]) - const selectedBoardFinish = useMemo( - () => finishes.find((finish) => finish.id === variantForm.finishId) ?? null, - [finishes, variantForm.finishId], - ) - const matchedBoardRule = useMemo(() => { - if (!boardVariantForm || !selectedBoardFinish) { - return null - } - - return ( - matchBoardRuleForFinish( - boardVariantRuleCandidates, - { - materialName: boardVariantForm.materialName, - environmentalGrade: boardVariantForm.environmentalGrade, - thicknessMm: Number(boardVariantForm.thicknessMm) || 0, - veneerMode: boardVariantForm.veneerMode, - }, - selectedBoardFinish.name, - ) ?? null - ) - }, [boardVariantForm, boardVariantRuleCandidates, selectedBoardFinish]) - const boardVariantMetrics = useMemo(() => { - if (!matchedBoardRule) { - return null - } - - const lengthMm = Number(variantForm.length) - const widthMm = Number(variantForm.width) - if (!(lengthMm > 0) || !(widthMm > 0)) { - return null - } - - return calculateBoardMetrics({ - pricePerSquareMeter: matchedBoardRule.pricePerSquareMeter, - weightPerSquareMeterKg: matchedBoardRule.weightPerSquareMeterKg, - lengthMm, - widthMm, - }) - }, [matchedBoardRule, variantForm.length, variantForm.width]) const selectedFinish = useMemo( () => finishes.find((finish) => finish.id === selectedVariant?.finishId) ?? null, [finishes, selectedVariant?.finishId], @@ -1137,13 +824,9 @@ export default function AssetCenterClient({ // ---- 规格款弹窗操作 ---- function openCreateVariantModal(familyId: string) { - const family = families.find((item) => item.id === familyId) ?? null setVariantModalMode('create') setEditingVariantId(null) setVariantForm(emptyVariantForm(familyId)) - setBoardVariantForm( - family?.type === 'BOARD' ? buildBoardVariantForm(boardSubstrateRules) : null, - ) setVariantModalOpen(true) } @@ -1174,18 +857,12 @@ export default function AssetCenterClient({ inheritedFromVariantId: '', includeLegConnectorRole: false, }) - setBoardVariantForm( - variant.assetType === 'BOARD' - ? buildBoardVariantForm(boardSubstrateRules, variant.substrateId) - : null, - ) setVariantModalOpen(true) } function closeVariantModal() { setVariantModalOpen(false) setEditingVariantId(null) - setBoardVariantForm(null) } function applyInheritedVariant(variantId: string) { @@ -1205,11 +882,6 @@ export default function AssetCenterClient({ } async function handleSaveVariant() { - if (showBoardRulePicker && (!variantForm.finishId || !matchedBoardRule)) { - toast.error('板材规格款请先选完整材质规则和花色') - return - } - setSubmitting(true) try { const dimensions = toNumberRecord({ @@ -1296,337 +968,6 @@ export default function AssetCenterClient({ // ---- 基材操作 ---- - function openCreateSubstrateModal() { - setSubstrateForm(emptySubstrateForm()) - setSubstrateCodeEdited(false) - setSubstrateNameEdited(false) - setSubstrateModalOpen(true) - } - - function closeSubstrateModal() { - setSubstrateModalOpen(false) - } - - async function handleCreateSubstrate() { - const thicknessMm = Number(substrateForm.thicknessMm) - const sheetLengthMm = Number(substrateForm.sheetLengthMm) - const sheetWidthMm = Number(substrateForm.sheetWidthMm) - const sheetPrice = Number(substrateForm.sheetPrice) - const finishNames = splitFinishNames(substrateForm.finishNames) - - if (!(thicknessMm > 0)) { - toast.error('请填写正确的厚度') - return - } - if (!(sheetLengthMm > 0) || !(sheetWidthMm > 0)) { - toast.error('请填写正确的大板长宽') - return - } - if (!(sheetPrice > 0)) { - toast.error('请填写正确的整张采购价') - return - } - if (substrateForm.finishScope === 'SPECIAL' && finishNames.length === 0) { - toast.error('指定花色时,请至少填写一个花色名') - return - } - - setSubmitting(true) - try { - const nextCode = substrateForm.code.trim() || substrateDraft.suggestedCode - const nextName = substrateForm.name.trim() || substrateDraft.suggestedName - const description = `${substrateForm.materialName} ${substrateForm.environmentalGrade} ${thicknessMm}mm` - const substrateMetadata = substrateDraft.metadata as unknown as Record - const result = await createSubstrate({ - code: nextCode, - name: nextName, - description, - densityKgM3: null, - metadata: substrateMetadata, - }) - startTransition(() => { - setSubstrates((current) => - sortByName([ - ...current, - { - id: result.substrate.id, - code: nextCode, - name: nextName, - description, - densityKgM3: null, - metadata: substrateMetadata, - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - }, - ]), - ) - setSubstrateForm(emptySubstrateForm()) - setSubstrateCodeEdited(false) - setSubstrateNameEdited(false) - closeSubstrateModal() - toast.success('基材规则已创建') - }) - } catch (error) { - toast.error(error instanceof Error ? error.message : '提交失败,请稍后再试') - } finally { - setSubmitting(false) - } - } - - // ---- 饰面/花色新建 ---- - - async function handleCreateFinish() { - if (!finishForm.name.trim()) { - toast.error('请填写材质名称') - return - } - if (!finishForm.categoryId) { - toast.error('请选择材质分类') - return - } - setSubmitting(true) - try { - const response = await fetch('/api/materials', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: finishForm.name.trim(), - categoryId: finishForm.categoryId, - color: finishForm.color || '#CCCCCC', - }), - }) - const data = await response.json() - if (!response.ok || !data.success) { - throw new Error(data.error || '创建失败') - } - startTransition(() => { - const createdMaterial = data.data?.material as MaterialDTO | undefined - if (createdMaterial) { - setFinishes((current) => - sortByName([ - ...current.filter((item) => item.id !== createdMaterial.id), - createdMaterial, - ]), - ) - } - setFinishForm({ name: '', categoryId: '', color: '#CCCCCC' }) - setFinishModalOpen(false) - toast.success('饰面/花色已创建') - }) - } catch (error) { - toast.error(error instanceof Error ? error.message : '创建失败,请稍后再试') - } finally { - setSubmitting(false) - } - } - - async function handleCreateMaterialCategory() { - if (!materialCategoryForm.name.trim()) { - toast.error('请填写分类名称') - return - } - - setSubmitting(true) - try { - const result = await createMaterialCategory({ - name: materialCategoryForm.name.trim(), - icon: materialCategoryForm.icon.trim() || null, - }) - - startTransition(() => { - setMaterialCategories((current) => - sortMaterialCategories([ - ...current.filter((item) => item.id !== result.category.id), - result.category, - ]), - ) - setFinishForm((current) => ({ - ...current, - categoryId: result.category.id, - })) - setMaterialCategoryForm({ name: '', icon: '' }) - setMaterialCategoryModalOpen(false) - toast.success('材质分类已创建') - }) - } catch (error) { - toast.error(error instanceof Error ? error.message : '材质分类创建失败,请稍后再试') - } finally { - setSubmitting(false) - } - } - - useEffect(() => { - setSubstrateForm((current) => { - let changed = false - const next = { ...current } - - if (!substrateCodeEdited && current.code !== substrateDraft.suggestedCode) { - next.code = substrateDraft.suggestedCode - changed = true - } - - if (!substrateNameEdited && current.name !== substrateDraft.suggestedName) { - next.name = substrateDraft.suggestedName - changed = true - } - - return changed ? next : current - }) - }, [ - substrateCodeEdited, - substrateDraft.suggestedCode, - substrateDraft.suggestedName, - substrateNameEdited, - ]) - - useEffect(() => { - if (!variantModalOpen) { - return - } - - if (selectedVariantFamily?.type !== 'BOARD') { - if (boardVariantForm !== null) { - setBoardVariantForm(null) - } - return - } - - const fallback = buildBoardVariantForm(boardSubstrateRules) - if (!fallback) { - return - } - - const current = boardVariantForm ?? fallback - const materialOptions = BOARD_MATERIAL_OPTIONS.filter((materialName) => - boardSubstrateRules.some((rule) => rule.materialName === materialName), - ) - const nextMaterial = - materialOptions.find((materialName) => materialName === current.materialName) ?? - materialOptions[0] ?? - fallback.materialName - const environmentalOptions = sortUniqueValues( - boardSubstrateRules - .filter((rule) => rule.materialName === nextMaterial) - .map((rule) => rule.environmentalGrade), - ) - const nextEnvironmental = - environmentalOptions.find((value) => value === current.environmentalGrade) ?? - environmentalOptions[0] ?? - fallback.environmentalGrade - const thicknessOptions = Array.from( - new Set( - boardSubstrateRules - .filter( - (rule) => - rule.materialName === nextMaterial && rule.environmentalGrade === nextEnvironmental, - ) - .map((rule) => rule.thicknessMm), - ), - ).sort((left, right) => left - right) - const currentThickness = Number(current.thicknessMm) - const nextThickness = String( - thicknessOptions.find((value) => value === currentThickness) ?? - thicknessOptions[0] ?? - Number(fallback.thicknessMm), - ) - const veneerOptions = Array.from( - new Set( - boardSubstrateRules - .filter( - (rule) => - rule.materialName === nextMaterial && - rule.environmentalGrade === nextEnvironmental && - String(rule.thicknessMm) === nextThickness, - ) - .map((rule) => rule.veneerMode), - ), - ) - const nextVeneer = - veneerOptions.find((value) => value === current.veneerMode) ?? - veneerOptions[0] ?? - fallback.veneerMode - - if ( - !boardVariantForm || - nextMaterial !== current.materialName || - nextEnvironmental !== current.environmentalGrade || - nextThickness !== current.thicknessMm || - nextVeneer !== current.veneerMode - ) { - setBoardVariantForm({ - materialName: nextMaterial, - environmentalGrade: nextEnvironmental, - thicknessMm: nextThickness, - veneerMode: nextVeneer, - }) - } - }, [boardSubstrateRules, boardVariantForm, selectedVariantFamily?.type, variantModalOpen]) - - useEffect(() => { - if (selectedVariantFamily?.type !== 'BOARD') { - return - } - - const allowedFinishIds = new Set(boardVariantFinishOptions.map((finish) => finish.id)) - setVariantForm((current) => { - if (current.finishId && !allowedFinishIds.has(current.finishId)) { - return { - ...current, - finishId: '', - } - } - - return current - }) - }, [boardVariantFinishOptions, selectedVariantFamily?.type]) - - useEffect(() => { - if (selectedVariantFamily?.type !== 'BOARD' || !boardVariantForm) { - return - } - - setVariantForm((current) => { - let changed = false - const next = { ...current } - - if (current.thickness !== boardVariantForm.thicknessMm) { - next.thickness = boardVariantForm.thicknessMm - changed = true - } - - if (matchedBoardRule) { - if (current.substrateId !== matchedBoardRule.substrateId) { - next.substrateId = matchedBoardRule.substrateId - changed = true - } - } else if (current.substrateId) { - next.substrateId = '' - changed = true - } - - const nextCostPrice = boardVariantMetrics ? String(boardVariantMetrics.costPrice) : '' - if (current.costPrice !== nextCostPrice) { - next.costPrice = nextCostPrice - changed = true - } - - const nextWeightKg = - boardVariantMetrics?.weightKg == null ? '' : String(boardVariantMetrics.weightKg) - if (current.weightKg !== nextWeightKg) { - next.weightKg = nextWeightKg - changed = true - } - - return changed ? next : current - }) - }, [ - boardVariantForm, - boardVariantMetrics, - matchedBoardRule, - selectedVariantFamily?.type, - ]) - const closePreviewContextMenu = useCallback(() => { setPreviewContextMenuState((current) => ({ ...current, visible: false })) setActivePreviewSubmenu(null) @@ -1655,28 +996,6 @@ export default function AssetCenterClient({ [closePreviewContextMenu, selectedVariantId, toast], ) - const handleDeleteFinish = useCallback(async (finish: MaterialDTO) => { - setSubmitting(true) - try { - await deleteMaterial(finish.id) - startTransition(() => { - setFinishes((current) => current.filter((item) => item.id !== finish.id)) - setVariants((current) => - current.map((variant) => - variant.finishId === finish.id - ? { ...variant, finishId: null } - : variant, - ), - ) - toast.success(`已删除饰面/花色「${finish.name}」`) - }) - } catch (error) { - toast.error(error instanceof Error ? error.message : '删除失败,请稍后再试') - } finally { - setSubmitting(false) - } - }, [toast]) - useEffect(() => { setPreviewNodeNames([]) setSelectedPreviewNodeName(null) @@ -1754,10 +1073,6 @@ export default function AssetCenterClient({ const familyVariantOptions = variants.filter( (v) => v.assetFamilyId === variantForm.assetFamilyId, ) - const showBoardRulePicker = - selectedVariantFamily?.type === 'BOARD' && - boardVariantForm !== null && - boardVariantMaterialOptions.length > 0 async function handleImportCadAsset() { setCadImportSubmitAttempted(true) @@ -1948,7 +1263,7 @@ export default function AssetCenterClient({ setSearchText('')} - className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-apple-label-tertiary hover:bg-apple-bg-secondary hover:text-apple-label-primary" + 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" > @@ -2133,7 +1448,7 @@ export default function AssetCenterClient({ + {isImportableAssetType(family.type) ? ( - + CAD 导入 + ) : null} @@ -2227,143 +1534,26 @@ export default function AssetCenterClient({ )} - {/* 基材库 */} -
-
-
- - 基材库 - {substrates.length} 种 + +
+
+
- -
-
- {substrates.map((substrate) => { - const boardRule = substrateBoardRuleMap.get(substrate.id) - const finishSummary = - boardRule && boardRule.finishScope === 'SPECIAL' && boardRule.finishNames.length > 0 - ? ` · ${boardRule.finishNames.join('、')}` - : '' - - return ( -
-
-
-

{substrate.name}

-

{substrate.code}

-
- {substrate.isActive ? ( - - 启用中 - - ) : null} -
- {boardRule ? ( - <> -

- {boardRule.materialName} · {boardRule.environmentalGrade} ·{' '} - {boardRule.thicknessMm}mm · {getBoardVeneerModeLabel(boardRule.veneerMode)} ·{' '} - {getBoardFinishScopeLabel(boardRule.finishScope)} - {finishSummary} -

-
- 整张采购价:¥{boardRule.sheetPrice.toFixed(2)} - - 整张板重量: - {boardRule.sheetWeightKg == null ? '待补' : `${boardRule.sheetWeightKg.toFixed(2)} kg`} - - 每平方米单价:¥{boardRule.pricePerSquareMeter.toFixed(2)} - - 每平方米重量: - {boardRule.weightPerSquareMeterKg == null - ? '待补' - : `${boardRule.weightPerSquareMeterKg.toFixed(2)} kg`} - -
- - ) : ( -

- 旧基材记录,暂未带板材规则明细。 -

- )} -
- ) - })} -
-
- - {/* 饰面/花色库 */} -
-
-
- - 饰面 / 花色库 - 共 {finishes.length} 种 -
- -
-
-
+
+

基础资料库

- 先建材质分类,再建具体饰面 / 花色。 + {substrates.length} 个基材 · {finishes.length} 个饰面花色

- -
-
- {finishGroups.map((group, groupIndex) => ( -
0 && 'border-t border-apple-separator pt-3')}> -
- - {group.name} - - {group.finishes.length} 种 -
-
- {group.finishes.map((finish) => ( -
-
-
-

{finish.name}

-

{group.name}

-
- -
- ))} -
-
- ))}
-
+ + 去基础资料库维护 + + + {/* 下一步引导 */} {cadImportForm.assetType === 'BOARD' ? ( + <>
setVariantForm((c) => ({ ...c, sizeLabel: e.target.value }))} placeholder="例如 1200x600" /> + setVariantForm((c) => ({ ...c, finishId: e.target.value }))} + options={[ + { value: '', label: '未设置' }, + ...finishes.map((f) => ({ value: f.id, label: f.name })), + ]} + /> {variantModalMode === 'create' && ( - setBoardVariantForm((current) => - current - ? { - ...current, - materialName: e.target.value, - } - : current, - ) - } - options={boardVariantMaterialOptions.map((materialName) => ({ - value: materialName, - label: materialName, - }))} - /> - - setBoardVariantForm((current) => - current - ? { - ...current, - thicknessMm: e.target.value, - } - : current, - ) - } - options={boardVariantThicknessOptions.map((thickness) => ({ - value: String(thickness), - label: String(thickness), - }))} - /> - - setVariantForm((current) => ({ ...current, finishId: e.target.value })) - } - placeholder="请选择花色" - options={boardVariantFinishOptions.map((finish) => ({ - value: finish.id, - label: finish.name, - }))} - /> -
-

系统匹配基材

- {matchedBoardRule ? ( -
-

- {matchedBoardRule.name} · {matchedBoardRule.code} -

-

- {matchedBoardRule.materialName} · {matchedBoardRule.environmentalGrade} ·{' '} - {matchedBoardRule.thicknessMm}mm ·{' '} - {getBoardVeneerModeLabel(matchedBoardRule.veneerMode)} -

-
- ) : ( -

- 先选完整材质规则和花色后,系统会自动匹配基材。 -

- )} -
-
-
-

自动测算

-
- - 每平方米单价: - {matchedBoardRule ? `¥${matchedBoardRule.pricePerSquareMeter.toFixed(2)}` : '待匹配'} - - - 每平方米重量: - {matchedBoardRule?.weightPerSquareMeterKg == null - ? '待补' - : `${matchedBoardRule.weightPerSquareMeterKg.toFixed(2)} kg`} - - - 板件面积: - {boardVariantMetrics ? `${boardVariantMetrics.areaSquareMeter.toFixed(4)} ㎡` : '待输入长宽'} - - - 自动成本: - {boardVariantMetrics ? `¥${boardVariantMetrics.costPrice.toFixed(2)}` : '待计算'} - - - 自动重量: - {boardVariantMetrics?.weightKg == null - ? '待计算' - : `${boardVariantMetrics.weightKg.toFixed(2)} kg`} - -
-
- - ) : ( -
- setVariantForm((c) => ({ ...c, finishId: e.target.value }))} - options={[ - { value: '', label: '未设置' }, - ...finishes.map((f) => ({ value: f.id, label: f.name })), - ]} - /> -
- )}

3D 模型

{variantForm.modelUrl ? ( @@ -2968,14 +2022,7 @@ export default function AssetCenterClient({
setVariantForm((c) => ({ ...c, length: e.target.value }))} placeholder="1200" /> setVariantForm((c) => ({ ...c, width: e.target.value }))} placeholder="600" /> - setVariantForm((c) => ({ ...c, thickness: e.target.value }))} - placeholder="25" - disabled={showBoardRulePicker} - helperText={showBoardRulePicker ? '板材规格会自动带出厚度' : undefined} - /> + setVariantForm((c) => ({ ...c, thickness: e.target.value }))} placeholder="25" /> setVariantForm((c) => ({ ...c, height: e.target.value }))} placeholder="可选" />
@@ -3099,7 +2146,7 @@ export default function AssetCenterClient({ - -
- } - > -
-
- - setSubstrateForm((current) => ({ ...current, environmentalGrade: e.target.value })) - } - options={(BOARD_ENVIRONMENTAL_OPTIONS[substrateForm.materialName] ?? []).map((grade) => ({ - value: grade, - label: grade, - }))} - /> -
-
- - setSubstrateForm((current) => ({ ...current, thicknessMm: e.target.value })) - } - placeholder="例如 12" - /> - - setSubstrateForm((current) => ({ - ...current, - finishScope: e.target.value as BoardFinishScope, - })) - } - options={[ - { value: 'REGULAR', label: '常规颜色' }, - { value: 'ALL', label: '所有颜色' }, - { value: 'SPECIAL', label: '指定花色' }, - ]} - /> - {substrateForm.finishScope === 'SPECIAL' ? ( - - setSubstrateForm((current) => ({ ...current, finishNames: e.target.value })) - } - placeholder="多个花色可用逗号分隔" - /> - ) : ( -
- {substrateForm.finishScope === 'REGULAR' - ? '按常规颜色组统一计价。' - : '这条规则对当前材质下的所有颜色生效。'} -
- )} -
-
- - setSubstrateForm((current) => ({ ...current, sheetLengthMm: e.target.value })) - } - placeholder="2440" - /> - - setSubstrateForm((current) => ({ ...current, sheetWidthMm: e.target.value })) - } - placeholder="1220" - /> -
-
- - setSubstrateForm((current) => ({ ...current, sheetPrice: e.target.value })) - } - placeholder="例如 48" - /> - - setSubstrateForm((current) => ({ ...current, sheetWeightKg: e.target.value })) - } - placeholder="可选,例如 22.1" - /> -
-
- { - setSubstrateCodeEdited(true) - setSubstrateForm((current) => ({ ...current, code: e.target.value })) - }} - helperText={`系统建议:${substrateDraft.suggestedCode}`} - /> - { - setSubstrateNameEdited(true) - setSubstrateForm((current) => ({ ...current, name: e.target.value })) - }} - helperText={`系统建议:${substrateDraft.suggestedName}`} - /> -
-
-

自动换算摘要

-
-
- 大板面积 - {substrateDraft.metadata.sheetAreaSquareMeter.toFixed(4)} ㎡ -
-
- 每平方米单价 - ¥{substrateDraft.metadata.pricePerSquareMeter.toFixed(2)} -
-
- 每平方米重量 - - {substrateDraft.metadata.weightPerSquareMeterKg == null - ? '待补' - : `${substrateDraft.metadata.weightPerSquareMeterKg.toFixed(2)} kg`} - -
-
- 花色规则 - - {getBoardFinishScopeLabel(substrateDraft.metadata.finishScope)} - {substrateDraft.metadata.finishScope === 'SPECIAL' && - substrateDraft.metadata.finishNames.length > 0 - ? ` · ${substrateDraft.metadata.finishNames.join('、')}` - : ''} - -
-
-
-
- - - {/* 饰面/花色新建弹窗 */} - setFinishModalOpen(false)} - title="新建饰面 / 花色" - size="medium" - footer={ -
- - -
- } - > -
- setFinishForm((c) => ({ ...c, name: e.target.value }))} - placeholder="例如 白橡木纹" - /> - setFinishForm((c) => ({ ...c, color: e.target.value }))} - placeholder="#CCCCCC" - className="flex-1" - helperText="花色主色,用于列表预览" - /> -
-
-

- 这是主线最小创建入口。纹理贴图、粗糙度、金属度等参数可在后台材质管理模块继续补齐。 -

-
-
- - setMaterialCategoryModalOpen(false)} - title="新建材质分类" - size="medium" - footer={ -
- - -
- } - > -
- setMaterialCategoryForm((current) => ({ ...current, name: e.target.value }))} - placeholder="例如 木纹、纯色、石纹" - /> - setMaterialCategoryForm((current) => ({ ...current, icon: e.target.value }))} - placeholder="可选,例如 palette" - helperText="这里只做最小录入,后续如需更换展示图标,可再统一调整。" - /> -
-
- {/* Toast 容器 */} diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.workflow.test.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.workflow.test.tsx index aee447c4..ab2625d1 100644 --- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.workflow.test.tsx +++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.workflow.test.tsx @@ -2,8 +2,8 @@ import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import type { ReactNode } from 'react' import AssetCenterClient from './AssetCenterClient' -import { normalizeBoardRuleMetadata } from '@/lib/board-substrate-rules' -import { createAssetFamily, createMaterialCategory, createSubstrate, importAssetCad } from '@/lib/open-product/platform-api' +import { createAssetFamily, importAssetCad } from '@/lib/open-product/platform-api' +import { parseZipFile } from '@/lib/zip-parser' import type { AssetFamilyDTO, AssetVariantDTO, MaterialDTO, SeriesTemplateDTO, SubstrateDTO } from '@/server/services' jest.mock('next/link', () => ({ @@ -105,20 +105,10 @@ jest.mock('@/components/upload/ModelUploader', () => ({ ModelUploader: () =>
ModelUploader
, })) -jest.mock('@/lib/zip-parser', () => ({ - __esModule: true, - parseZipFile: jest.fn().mockResolvedValue({ - success: false, - error: 'mocked zip parsing in tests', - }), -})) - jest.mock('@/lib/open-product/platform-api', () => ({ __esModule: true, createAssetFamily: jest.fn(), createAssetVariant: jest.fn(), - createMaterialCategory: jest.fn(), - createSubstrate: jest.fn(), importAssetCad: jest.fn(), deleteAssetFamily: jest.fn(), deleteAssetVariant: jest.fn(), @@ -127,12 +117,17 @@ jest.mock('@/lib/open-product/platform-api', () => ({ uploadModelFile: jest.fn(), })) +jest.mock('@/lib/zip-parser', () => ({ + __esModule: true, + parseZipFile: jest.fn(), +})) + function renderClient() { return render( , - ) -} - describe('AssetCenterClient workflow', () => { beforeEach(() => { jest.clearAllMocks() + jest.mocked(parseZipFile).mockResolvedValue({ + success: false, + error: 'ZIP 文件解析失败', + } as never) global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ @@ -332,15 +242,8 @@ describe('AssetCenterClient workflow', () => { }) }) - it('CAD 导入从资产族内部发起,新建基材、新建饰面花色都使用弹窗入口', async () => { + it('CAD 导入继续从资产族内部发起,基础资料维护改走独立资料页入口', async () => { const actor = userEvent.setup() - jest.mocked(createSubstrate).mockResolvedValue({ - substrate: { - id: 'substrate-1', - code: 'PB18', - name: '18mm 颗粒板', - }, - } as never) render( { }, ] as AssetFamilyDTO[]} initialVariants={[] as AssetVariantDTO[]} - initialSubstrates={[{ id: 'substrate-1', code: 'PB18', name: '18mm 颗粒板' }] as unknown as SubstrateDTO[]} + initialSubstrates={[{ id: 'substrate-1', code: 'PB18', name: '18mm 颗粒板' }] as SubstrateDTO[]} initialSeriesTemplates={[ { id: 'template-1', @@ -403,7 +306,7 @@ describe('AssetCenterClient workflow', () => { createdAt: new Date('2026-04-07T00:00:00.000Z'), updatedAt: new Date('2026-04-07T00:00:00.000Z'), }, - ] as unknown as MaterialDTO[]} + ] as unknown as MaterialDTO[]} materialCategories={[{ id: 'cat-1', name: '木纹', icon: 'Texture', sortOrder: 1 }]} metrics={[]} user={{ @@ -418,25 +321,13 @@ describe('AssetCenterClient workflow', () => { ) await actor.click(screen.getByText('桌面板资产族')) - await actor.click(screen.getByRole('button', { name: 'CAD 导入到该资产族' })) + await actor.click(screen.getByRole('button', { name: 'CAD 导入' })) expect(screen.getByText('导入 CAD 规格款 · 板材')).toBeInTheDocument() - - const substrateSection = screen.getByText('基材库').closest('div') - if (!substrateSection) { - throw new Error('基材库区块不存在') - } - await actor.click(within(substrateSection.parentElement ?? substrateSection).getByRole('button', { name: '新建' })) - expect(screen.getByText('新建基材')).toBeInTheDocument() - - const cancelButtons = screen.getAllByRole('button', { name: '取消' }) - await actor.click(cancelButtons[cancelButtons.length - 1]) - - const finishSection = screen.getByText('饰面 / 花色库').closest('div') - if (!finishSection) { - throw new Error('饰面 / 花色库区块不存在') - } - await actor.click(within(finishSection.parentElement ?? finishSection).getByRole('button', { name: '新建' })) - expect(screen.getByText('新建饰面 / 花色')).toBeInTheDocument() + expect(screen.getByText('基础资料库')).toBeInTheDocument() + expect(screen.getByRole('link', { name: '去基础资料库补录' })).toHaveAttribute( + 'href', + '/asset-center/material-library', + ) }) it('CAD 导入允许重量和成本价留空,提交时按 null 传给后端', async () => { @@ -537,7 +428,7 @@ describe('AssetCenterClient workflow', () => { }, ] as AssetFamilyDTO[]} initialVariants={[] as AssetVariantDTO[]} - initialSubstrates={[{ id: 'substrate-1', code: 'PB18', name: '18mm 颗粒板' }] as unknown as SubstrateDTO[]} + initialSubstrates={[{ id: 'substrate-1', code: 'PB18', name: '18mm 颗粒板' }] as SubstrateDTO[]} initialSeriesTemplates={[ { id: 'template-1', @@ -581,7 +472,7 @@ describe('AssetCenterClient workflow', () => { ) await actor.click(screen.getByText('140台面资产族')) - await actor.click(screen.getByRole('button', { name: 'CAD 导入到该资产族' })) + await actor.click(screen.getByRole('button', { name: 'CAD 导入' })) await actor.type(screen.getByPlaceholderText('BOARD-VARIANT-001'), 'TM-001') await actor.type(screen.getByPlaceholderText('例如 桌面板 1200 / 方管桌腿 / 十字连接件'), '140台面-A') @@ -592,6 +483,9 @@ describe('AssetCenterClient workflow', () => { const fileInput = document.querySelector('input[type="file"][accept=".zip"]') as HTMLInputElement const zipFile = new File(['zip-content'], '140台面.zip', { type: 'application/zip' }) + Object.defineProperty(zipFile, 'arrayBuffer', { + value: async () => new TextEncoder().encode('zip-content').buffer, + }) await actor.upload(fileInput, zipFile) await actor.click(screen.getByRole('button', { name: '开始导入' })) @@ -607,87 +501,4 @@ describe('AssetCenterClient workflow', () => { }) }) - it('可在饰面库内直接新建材质分类,并立即用于饰面录入', async () => { - const actor = userEvent.setup() - jest.mocked(createMaterialCategory).mockResolvedValue({ - category: { - id: 'cat-2', - name: '石纹', - icon: 'palette', - sortOrder: 2, - materialCount: 0, - }, - } as never) - - renderClient() - - await actor.click(screen.getByRole('button', { name: '新建材质分类' })) - expect(screen.getByRole('heading', { name: '新建材质分类' })).toBeInTheDocument() - - await actor.type(screen.getByPlaceholderText('例如 木纹、纯色、石纹'), '石纹') - await actor.type(screen.getByPlaceholderText('可选,例如 palette'), 'palette') - await actor.click(screen.getByRole('button', { name: '创建分类' })) - - await waitFor(() => { - expect(createMaterialCategory).toHaveBeenCalledWith({ - name: '石纹', - icon: 'palette', - }) - }) - - const finishSection = screen.getByText('饰面 / 花色库').closest('div') - if (!finishSection) { - throw new Error('饰面 / 花色库区块不存在') - } - await actor.click(within(finishSection.parentElement ?? finishSection).getByRole('button', { name: '新建' })) - expect(screen.getByText('新建饰面 / 花色')).toBeInTheDocument() - }) - it('新建基材时显示板材规则字段和自动换算摘要', async () => { - const actor = userEvent.setup() - - renderClient() - - const substrateSection = screen.getByText(/基材库|鍩烘潗搴?/).closest('div') - if (!substrateSection) { - throw new Error('鍩烘潗搴撳尯鍧椾笉瀛樺湪') - } - - await actor.click( - within(substrateSection.parentElement ?? substrateSection).getByRole('button', { name: /新建|鏂板缓/ }), - ) - - expect(screen.getByRole('heading', { name: /新建基材|鏂板缓鍩烘潗/ })).toBeInTheDocument() - expect(screen.getByText('材质')).toBeInTheDocument() - expect(screen.getByText('环保等级')).toBeInTheDocument() - expect(screen.getByText('厚度 (mm)')).toBeInTheDocument() - expect(screen.getByText('贴面')).toBeInTheDocument() - expect(screen.getByText('整张采购价')).toBeInTheDocument() - expect(screen.getByText('每平方米单价')).toBeInTheDocument() - expect(screen.getByText('每平方米重量')).toBeInTheDocument() - }) - - it('板材规格款选择规则和花色后自动带出成本与重量', async () => { - const actor = userEvent.setup() - - renderClientWithBoardRule() - - await actor.click(screen.getByText('桌面板资产族')) - await actor.click(screen.getByRole('button', { name: '新建规格款' })) - - expect(screen.getByRole('combobox', { name: '材质' })).toBeInTheDocument() - expect(screen.getByRole('combobox', { name: '环保等级' })).toBeInTheDocument() - expect(screen.getByRole('combobox', { name: '厚度 (mm)' })).toBeInTheDocument() - expect(screen.getByRole('combobox', { name: '贴面' })).toBeInTheDocument() - - await actor.click(screen.getByRole('combobox', { name: '饰面 / 花色' })) - await actor.click(screen.getByRole('option', { name: '冰川白石纹色' })) - - await actor.type(screen.getByPlaceholderText('1200'), '1400') - await actor.type(screen.getByPlaceholderText('600'), '600') - - await waitFor(() => { - expect(screen.getByDisplayValue('13.54')).toBeInTheDocument() - expect(screen.getByDisplayValue('6.24')).toBeInTheDocument() - }) - }) }) 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 new file mode 100644 index 00000000..30d281af --- /dev/null +++ b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.test.tsx @@ -0,0 +1,227 @@ +import { render, screen, waitFor, within } 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' + +jest.mock('next/link', () => ({ + __esModule: true, + 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, +})) + +jest.mock('@/components/ui/DeleteConfirmDialog', () => ({ + __esModule: true, + DeleteConfirmDialog: () => null, +})) + +jest.mock('@/components/ui/Toast', () => ({ + __esModule: true, + 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}
+
+ ), +})) + +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) + +function renderClient() { + return render( + , + ) +} + +describe('MaterialLibraryClient', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('新建基材后使用后端返回的完整字段刷新列表', async () => { + const actor = userEvent.setup() + mockedCreateSubstrate.mockResolvedValue({ + substrate: { + id: 'substrate-2', + code: 'PB25', + name: '25mm 颗粒板', + description: '后端标准说明', + densityKgM3: 720, + metadata: { source: 'server' }, + isActive: true, + createdAt: new Date('2026-04-08T10:00:00.000Z'), + updatedAt: new Date('2026-04-08T10:00:00.000Z'), + }, + }) + + renderClient() + + 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 waitFor(() => { + expect(mockedCreateSubstrate).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'PB25', + name: '25mm 颗粒板', + }), + ) + }) + + 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 new file mode 100644 index 00000000..951769fe --- /dev/null +++ b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx @@ -0,0 +1,869 @@ +'use client' + +import Link from 'next/link' +import { startTransition, useMemo, useState } from 'react' +import { + ArrowLeft, + Package2, + Pencil, + Plus, + Search, + SwatchBook, + Trash2, + X, +} 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 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 { cn } from '@/lib/utils' +import { + createMaterial, + createMaterialCategory, + createSubstrate, + deleteMaterial, + deleteMaterialCategory, + deleteSubstrate, + updateMaterial, + updateMaterialCategory, + updateSubstrate, +} from '@/lib/open-product/platform-api' +import type { MaterialCategoryDTO, MaterialDTO, SubstrateDTO } from '@/server/services' + +type LibraryTab = 'substrate' | 'finish' + +interface MaterialLibraryClientProps { + initialSubstrates: SubstrateDTO[] + initialFinishes: MaterialDTO[] + initialMaterialCategories: MaterialCategoryDTO[] + metrics: PlatformMetricItem[] +} + +type SubstrateFormState = { + code: string + name: string + description: string + densityKgM3: string +} + +type CategoryFormState = { + name: string + icon: string + sortOrder: string +} + +type FinishFormState = { + name: string + categoryId: string + color: 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')) +} + +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 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 { + if (!value.trim()) return null + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : 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' } +} + +export default function MaterialLibraryClient({ + initialSubstrates, + initialFinishes, + initialMaterialCategories, + metrics, +}: MaterialLibraryClientProps) { + const toast = useToast() + const [activeTab, setActiveTab] = useState('substrate') + 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 [submitting, setSubmitting] = useState(false) + const [deleteTarget, setDeleteTarget] = useState(null) + + const [substrateModalOpen, setSubstrateModalOpen] = useState(false) + const [substrateModalMode, setSubstrateModalMode] = useState<'create' | 'edit'>('create') + const [substrateForm, setSubstrateForm] = useState(emptySubstrateForm()) + 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 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]) + + 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), + ) + }) + }, [finishes, searchText, selectedCategoryId]) + + const groupedFinishes = useMemo( + () => groupFinishesByCategory(filteredFinishes, materialCategories, selectedCategoryId), + [filteredFinishes, materialCategories, selectedCategoryId], + ) + + function openCreateSubstrateModal() { + setSubstrateModalMode('create') + setEditingSubstrateId(null) + setSubstrateForm(emptySubstrateForm()) + setSubstrateModalOpen(true) + } + + 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 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', + }) + setFinishModalOpen(true) + } + + async function handleSaveSubstrate() { + if (!substrateForm.code.trim() || !substrateForm.name.trim()) { + 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), + }) + + startTransition(() => { + setSubstrates((current) => sortByName([...current, result.substrate])) + setSubstrateModalOpen(false) + 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), + }) + + startTransition(() => { + setSubstrates((current) => + sortByName(current.map((item) => (item.id === editingSubstrateId ? result.substrate : item))), + ) + setSubstrateModalOpen(false) + toast.success('基材已更新') + }) + } catch (error) { + toast.error(error instanceof Error ? error.message : '基材保存失败,请稍后再试') + } finally { + setSubmitting(false) + } + } + + 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) + 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('材质分类已更新') + }) + } 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', + }) + + 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) + } + } + + async function handleConfirmDelete() { + if (!deleteTarget) return + setSubmitting(true) + try { + if (deleteTarget.type === 'substrate') { + await deleteSubstrate(deleteTarget.id) + startTransition(() => { + setSubstrates((current) => current.filter((item) => item.id !== deleteTarget.id)) + 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)) + setDeleteTarget(null) + toast.success('饰面/花色已删除') + }) + } catch (error) { + toast.error(error instanceof Error ? error.message : '删除失败,请稍后再试') + } finally { + setSubmitting(false) + } + } + + return ( + <> + +
+
+

+ 基础资料库 +

+

+ 维护基材、分类和饰面花色,补录后返回资产中心继续操作。 +

+
+ + + +
+ +

+ {substrates.length} 基材 + · + {materialCategories.length} 分类 + · + {finishes.length} 饰面 +

+ +

+ 缺基材或饰面时先来这里补录,保存后回 CAD 导入或规格款弹窗继续操作。 +

+
+ } + rightContent={ + <> +
+ + +
+ +
+
+ + 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 ? ( + + ) : null} +
+ {activeTab === 'substrate' ? ( + + ) : ( +
+ + +
+ )} +
+ + {activeTab === 'substrate' ? ( + filteredSubstrates.length === 0 ? ( +
+ +

暂无基材

+

点击上方「新建基材」添加第一条记录

+
+ ) : ( +
+ {filteredSubstrates.map((substrate) => ( +
+

+ {substrate.name} +

+
+ {substrate.code} + {substrate.densityKgM3 ? {substrate.densityKgM3} kg/m³ : null} +
+ + +
+ ))} +
+ ) + ) : ( + <> +
+ + {materialCategories.map((category) => ( + + ))} +
+ + {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' ? ( +
+ + +
+ ) : null} +
+
+ {group.finishes.map((finish) => ( +
+
+

+ {finish.name} +

+ + +
+ ))} +
+
+ ))} +
+ )} + + )} + + } + /> + + setSubstrateModalOpen(false)} + title={substrateModalMode === 'create' ? '新建基材' : '编辑基材'} + size="medium" + footer={ +
+ + +
+ } + > +
+
+ setSubstrateForm((current) => ({ ...current, code: event.target.value }))} + placeholder="例如 PB18" + /> + setSubstrateForm((current) => ({ ...current, name: event.target.value }))} + placeholder="例如 18mm 颗粒板" + /> +
+ setSubstrateForm((current) => ({ ...current, description: event.target.value }))} + placeholder="可选" + /> + setSubstrateForm((current) => ({ ...current, densityKgM3: event.target.value }))} + placeholder="例如 680" + /> +
+
+ + setCategoryModalOpen(false)} + title={categoryModalMode === 'create' ? '新建材质分类' : '编辑材质分类'} + size="medium" + footer={ +
+ + +
+ } + > +
+ setCategoryForm((current) => ({ ...current, name: event.target.value }))} + placeholder="例如 木纹、石纹、纯色" + /> +
+ setCategoryForm((current) => ({ ...current, icon: event.target.value }))} + placeholder="可选" + /> + setCategoryForm((current) => ({ ...current, sortOrder: event.target.value }))} + placeholder="数字越小越靠前" + /> +
+
+
+ + setFinishModalOpen(false)} + title={finishModalMode === 'create' ? '新建饰面 / 花色' : '编辑饰面 / 花色'} + size="medium" + footer={ +
+ + +
+ } + > +
+ setFinishForm((current) => ({ ...current, name: event.target.value }))} + placeholder="例如 白橡木纹" + /> + setFinishForm((current) => ({ ...current, color: event.target.value }))} + placeholder="#CCCCCC" + className="flex-1" + /> +
+
+
+
+ + setDeleteTarget(null)} + onConfirm={handleConfirmDelete} + title={ + deleteTarget?.type === 'substrate' + ? '删除基材' + : deleteTarget?.type === 'category' + ? '删除材质分类' + : '删除饰面 / 花色' + } + description={`确认删除「${deleteTarget?.name ?? ''}」吗?删除后已引用规格款会自动回落为空值。`} + confirmText="确认删除" + /> + + + + ) +} 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 new file mode 100644 index 00000000..09183f54 --- /dev/null +++ b/furniture-3d-system/src/app/asset-center/material-library/page.tsx @@ -0,0 +1,39 @@ +import { Layers3, Package, Palette } from 'lucide-react' +import { requireSession } from '@/lib/server-session' +import { assetCenterService, materialService } from '@/server/services' +import MaterialLibraryClient from './MaterialLibraryClient' + +export default async function MaterialLibraryPage() { + await requireSession('/login') + + const [substrates, finishes, materialCategories] = await Promise.all([ + assetCenterService.listSubstrates(), + materialService.findAll({ limit: 500 }), + materialService.findAllCategories(), + ]) + + return ( + , + }, + { + label: '材质分类', + value: materialCategories.length, + icon: , + }, + { + label: '饰面/花色', + value: finishes.length, + icon: , + }, + ]} + /> + ) +} diff --git a/furniture-3d-system/src/app/asset-center/page.tsx b/furniture-3d-system/src/app/asset-center/page.tsx index bbd41be3..7d41a206 100644 --- a/furniture-3d-system/src/app/asset-center/page.tsx +++ b/furniture-3d-system/src/app/asset-center/page.tsx @@ -6,8 +6,6 @@ import AssetCenterClient from './AssetCenterClient' export default async function AssetCenterPage() { const { user } = await requireSession('/login') - await assetCenterService.ensureBoardLibraryDefaults() - const families = await assetCenterService.listFamilies() const variants = await assetCenterService.listVariantsByFamilyIds( families.map((family) => family.id), diff --git a/furniture-3d-system/src/components/ui/Button.tsx b/furniture-3d-system/src/components/ui/Button.tsx index baf1b917..39b86904 100644 --- a/furniture-3d-system/src/components/ui/Button.tsx +++ b/furniture-3d-system/src/components/ui/Button.tsx @@ -97,7 +97,7 @@ const ButtonComponent = React.forwardRef( 'font-apple-system font-apple-medium tracking-apple-tighter', // 统一字体系统 'transition-all duration-apple-fast ease-apple-ease-out', 'focus:outline-hidden', // 移除默认 outline,不使用 ring - 'disabled:opacity-50 disabled:cursor-not-allowed', + 'disabled:cursor-not-allowed', 'active:scale-95 active:transition-transform active:duration-apple-fast active:ease-apple-ease-in', 'will-change-transform', 'select-none', // 防止文本选择 @@ -119,12 +119,14 @@ const ButtonComponent = React.forwardRef( 'border border-white/10', 'hover:opacity-90 hover:shadow-apple-lg hover:scale-[1.02]', 'focus:border-2 focus:border-apple-separator', + 'disabled:bg-apple-bg-tertiary disabled:text-apple-label-tertiary disabled:shadow-none disabled:border-apple-separator/30', ], tinted: [ 'text-apple-label-primary bg-apple-bg-secondary/50', 'border border-apple-separator/50 backdrop-blur-md', 'hover:bg-apple-bg-tertiary hover:border-apple-separator', 'focus:border-2 focus:border-apple-separator', + 'disabled:opacity-50', ], gray: [ 'text-apple-label-primary', @@ -132,12 +134,14 @@ const ButtonComponent = React.forwardRef( 'border border-apple-separator/30', 'hover:bg-apple-bg-tertiary hover:border-apple-separator/60', 'focus:border-2 focus:border-apple-separator', + 'disabled:opacity-50', ], plain: [ - 'text-apple-label-secondary bg-transparent', + 'text-apple-label-primary bg-transparent', 'border border-transparent', - 'hover:bg-apple-bg-secondary hover:text-apple-label-primary', + 'hover:bg-apple-bg-secondary', 'focus:border-2 focus:border-apple-separator', + 'disabled:opacity-40', ], } @@ -152,7 +156,7 @@ const ButtonComponent = React.forwardRef( primary && 'ring-2 ring-apple-separator/20', className )} - style={variant === 'filled' ? { + style={variant === 'filled' && !(disabled || loading) ? { background: 'linear-gradient(145deg, var(--color-primary), var(--color-secondary))', } : undefined} onClick={handleClick} diff --git a/furniture-3d-system/src/lib/3d/material-pbr-presets.ts b/furniture-3d-system/src/lib/3d/material-pbr-presets.ts index 3e216027..01cf4f50 100644 --- a/furniture-3d-system/src/lib/3d/material-pbr-presets.ts +++ b/furniture-3d-system/src/lib/3d/material-pbr-presets.ts @@ -42,7 +42,7 @@ export interface MaterialPresetCapableConfig extends MaterialPBRPreset { thumbnailUrl: string } -type MaterialFamily = 'wood' | 'metal' | 'glass' | 'acrylic' | 'leather' | 'fabric' | 'stone' +type MaterialFamily = 'wood' | 'solid' | 'metal' | 'glass' | 'acrylic' | 'leather' | 'fabric' | 'stone' interface NamedMaterialPreset extends MaterialPBRPreset { family: MaterialFamily @@ -163,6 +163,7 @@ const TEXTURE_LIBRARY = { const CATEGORY_DEFAULT_PRESETS: Record = { wood: { metalness: 0, roughness: 0.62, opacity: 1, textureRepeat: 1.8, envMapIntensity: 0.55 }, + solid: { metalness: 0, roughness: 0.42, opacity: 1, textureRepeat: 1, envMapIntensity: 0.4 }, metal: { metalness: 1, roughness: 0.28, opacity: 1, textureRepeat: 1.3, envMapIntensity: 1.25 }, glass: { metalness: 0, @@ -194,6 +195,23 @@ const CATEGORY_DEFAULT_PRESETS: Record = { } const NAME_PRESETS: Record = { + '暖白橡木': { family: 'wood', textureKey: 'fineWood', color: '#E8E1D3', roughness: 0.62 }, + '自然榆木': { family: 'wood', textureKey: 'kitchenWood', color: '#CBB392', roughness: 0.58 }, + '烟熏胡桃': { family: 'wood', textureKey: 'darkWood', color: '#6A4B38', roughness: 0.56 }, + '浅砂木纹': { family: 'wood', textureKey: 'laminateWood', color: '#DDD0B7', roughness: 0.54 }, + + '云雾白': { family: 'solid', color: '#F3F1EB', roughness: 0.42 }, + '岩板灰': { family: 'solid', color: '#A6A29D', roughness: 0.48 }, + '曜石黑': { family: 'solid', color: '#2A2A2A', roughness: 0.44 }, + + '哑光黑砂': { family: 'metal', textureKey: 'corrugatedMetal', color: '#2C2C2C', metalness: 0.92, roughness: 0.58 }, + '雾银拉丝': { family: 'metal', textureKey: 'blueMetal', color: '#C2C6C9', metalness: 1, roughness: 0.26 }, + '香槟金属': { family: 'metal', textureKey: 'metalPlate', color: '#C8A46B', metalness: 1, roughness: 0.22 }, + + '雪山白石': { family: 'stone', textureKey: 'marbleStone', color: '#F2F2EE', roughness: 0.22 }, + '云灰石纹': { family: 'stone', textureKey: 'graniteStone', color: '#B5B1AA', roughness: 0.28 }, + '深岩灰板': { family: 'stone', textureKey: 'sandstoneStone', color: '#555555', roughness: 0.34 }, + '橡木-自然色': { family: 'wood', textureKey: 'oakVeneer', color: '#C4A35A', roughness: 0.58 }, '橡木-深色': { family: 'wood', textureKey: 'darkWood', color: '#8B6914', roughness: 0.63 }, '橡木-漂白': { family: 'wood', textureKey: 'laminateWood', color: '#E8DCC4', roughness: 0.52 }, @@ -322,8 +340,9 @@ function detectFamilyByName(name: string, categoryName?: string | null): Materia const text = `${name} ${categoryName || ''}` if (/玻璃/.test(text)) return 'glass' if (/亚克力|压克力|有机玻璃/.test(text)) return 'acrylic' + if (/纯色|云雾白|岩板灰|曜石黑/.test(text)) return 'solid' if (/金属|不锈钢|铝|铜|铁/.test(text)) return 'metal' - if (/木|橡木|胡桃木|樱桃木|松木|榉木|白蜡木/.test(text)) return 'wood' + if (/木|橡木|胡桃木|榆木|樱桃木|松木|榉木|白蜡木/.test(text)) return 'wood' if (/皮|皮革|仿皮/.test(text)) return 'leather' if (/布|绒|棉|麻|织物|网布/.test(text)) return 'fabric' if (/石|岩|大理石|花岗岩|砂岩/.test(text)) return 'stone' 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 258c62a2..9b1c8801 100644 --- a/furniture-3d-system/src/lib/open-product/platform-api.ts +++ b/furniture-3d-system/src/lib/open-product/platform-api.ts @@ -8,8 +8,10 @@ import type { CreateAssetFamilyInput, CreateAssetVariantInput, CreateDraftGroupFromAutoCombinationInput, + UpdateSubstrateInput, MaterialCategoryDTO, CreateSubstrateInput, + SubstrateDTO, DraftGroupDetailDTO, FormalizeDraftGroupInput, FormalizeDraftGroupResult, @@ -168,7 +170,7 @@ export async function importAssetCad(input: ImportAssetCadPayload): Promise { return requestPlatform('/api/asset-center/substrates', { method: 'POST', @@ -176,6 +178,22 @@ export async function createSubstrate(input: CreateSubstrateInput): Promise<{ }) } +export async function updateSubstrate( + id: string, + input: UpdateSubstrateInput, +): Promise<{ substrate: SubstrateDTO }> { + return requestPlatform(`/api/asset-center/substrates/${id}`, { + method: 'PUT', + body: JSON.stringify(input), + }) +} + +export async function deleteSubstrate(id: string): Promise { + await requestPlatform<{ substrate: SubstrateDTO }>(`/api/asset-center/substrates/${id}`, { + method: 'DELETE', + }) +} + export async function createMaterialCategory(input: { name: string icon?: string | null @@ -188,6 +206,55 @@ export async function createMaterialCategory(input: { }) } +export async function updateMaterialCategory( + id: string, + input: { + name?: string + icon?: string | null + sortOrder?: number + isActive?: boolean + }, +): Promise<{ category: MaterialCategoryDTO }> { + return requestPlatform(`/api/material-categories/${id}`, { + method: 'PUT', + body: JSON.stringify(input), + }) +} + +export async function deleteMaterialCategory(id: string): Promise { + await requestPlatform<{ category: MaterialCategoryDTO }>(`/api/material-categories/${id}`, { + method: 'DELETE', + }) +} + +export async function createMaterial(input: { + name: string + categoryId: string + color?: string + thumbnailUrl?: string | null +}): Promise { + return requestPlatform('/api/materials', { + method: 'POST', + body: JSON.stringify(input), + }) +} + +export async function updateMaterial( + id: string, + input: { + name?: string + categoryId?: string + color?: string + thumbnailUrl?: string | null + isActive?: boolean + }, +): Promise { + return requestPlatform(`/api/materials/${id}`, { + method: 'PUT', + body: JSON.stringify(input), + }) +} + export async function deleteMaterial(id: string): Promise { await requestPlatform<{ material: MaterialDTO }>(`/api/materials/${id}`, { method: 'DELETE', diff --git a/furniture-3d-system/src/lib/standard-finish-catalog.ts b/furniture-3d-system/src/lib/standard-finish-catalog.ts new file mode 100644 index 00000000..f60a078d --- /dev/null +++ b/furniture-3d-system/src/lib/standard-finish-catalog.ts @@ -0,0 +1,78 @@ +export interface StandardFinishMaterialDefinition { + name: string + color: string + roughness: number + metalness: number + opacity?: number + isDefaultForDemo?: boolean +} + +export interface StandardFinishCategoryDefinition { + key: string + name: string + icon: string + sortOrder: number + thumbnailFolder: string + materials: StandardFinishMaterialDefinition[] +} + +export const STANDARD_FINISH_CATALOG: StandardFinishCategoryDefinition[] = [ + { + key: 'woodgrain', + name: '木纹', + icon: '🪵', + sortOrder: 1, + thumbnailFolder: 'wood', + materials: [ + { name: '暖白橡木', color: '#E8E1D3', roughness: 0.62, metalness: 0, isDefaultForDemo: true }, + { name: '自然榆木', color: '#CBB392', roughness: 0.58, metalness: 0 }, + { name: '烟熏胡桃', color: '#6A4B38', roughness: 0.56, metalness: 0 }, + { name: '浅砂木纹', color: '#DDD0B7', roughness: 0.54, metalness: 0 }, + ], + }, + { + key: 'solid', + name: '纯色', + icon: '🎨', + sortOrder: 2, + thumbnailFolder: 'solid', + materials: [ + { name: '云雾白', color: '#F3F1EB', roughness: 0.42, metalness: 0 }, + { name: '岩板灰', color: '#A6A29D', roughness: 0.48, metalness: 0 }, + { name: '曜石黑', color: '#2A2A2A', roughness: 0.44, metalness: 0 }, + ], + }, + { + key: 'metal', + name: '金属', + icon: '🔩', + sortOrder: 3, + thumbnailFolder: 'metal', + materials: [ + { name: '哑光黑砂', color: '#2C2C2C', roughness: 0.58, metalness: 0.92 }, + { name: '雾银拉丝', color: '#C2C6C9', roughness: 0.26, metalness: 1 }, + { name: '香槟金属', color: '#C8A46B', roughness: 0.22, metalness: 1 }, + ], + }, + { + key: 'stone', + name: '石纹', + icon: '🪨', + sortOrder: 4, + thumbnailFolder: 'stone', + materials: [ + { name: '雪山白石', color: '#F2F2EE', roughness: 0.22, metalness: 0 }, + { name: '云灰石纹', color: '#B5B1AA', roughness: 0.28, metalness: 0 }, + { name: '深岩灰板', color: '#555555', roughness: 0.34, metalness: 0 }, + ], + }, +] + +export const DEFAULT_STANDARD_FINISH = + STANDARD_FINISH_CATALOG.flatMap((category) => + category.materials.map((material) => ({ category, material })), + ).find((entry) => entry.material.isDefaultForDemo) ?? + (() => { + throw new Error('标准饰面目录缺少默认演示花色配置') + })() + diff --git a/furniture-3d-system/src/lib/standard-finish-sync.ts b/furniture-3d-system/src/lib/standard-finish-sync.ts new file mode 100644 index 00000000..f3ab5eab --- /dev/null +++ b/furniture-3d-system/src/lib/standard-finish-sync.ts @@ -0,0 +1,66 @@ +import type { Prisma } from '@prisma/client' + +import { mergeMaterialWithPreset } from '@/lib/3d/material-pbr-presets' +import type { + StandardFinishCategoryDefinition, + StandardFinishMaterialDefinition, +} from '@/lib/standard-finish-catalog' +import type { MaterialPresetCapableConfig } from '@/lib/3d/material-pbr-presets' + +function sanitizeMaterialNameForPath(name: string): string { + return name.replace(/[\\/]/g, '_').replace(/\s+/g, '-') +} + +export function buildStandardFinishThumbnailUrl( + category: Pick, + material: Pick, +): string { + return `/materials/thumbnails/${category.thumbnailFolder}/${sanitizeMaterialNameForPath(material.name)}.jpg` +} + +export function buildStandardFinishMaterialData(params: { + material: StandardFinishMaterialDefinition + category: Pick + categoryId: string +}): Prisma.MaterialUncheckedCreateInput { + const { material, category, categoryId } = params + const defaultThumbnailUrl = buildStandardFinishThumbnailUrl(category, material) + + const baseMaterial: MaterialPresetCapableConfig = { + id: `standard-finish-${categoryId}-${material.name}`, + name: material.name, + categoryId, + categoryName: category.name, + thumbnailUrl: defaultThumbnailUrl, + color: material.color, + roughness: material.roughness, + metalness: material.metalness, + opacity: material.opacity ?? 1, + textureRepeat: 1, + textureRotation: 0, + textureSizeMm: 600, + isPreset: true, + } + + const merged = mergeMaterialWithPreset( + baseMaterial, + { forcePreset: true }, + ) + + return { + name: material.name, + categoryId, + thumbnailUrl: merged.thumbnailUrl || defaultThumbnailUrl, + color: merged.color ?? material.color, + roughness: merged.roughness ?? material.roughness, + metalness: merged.metalness ?? material.metalness, + opacity: merged.opacity ?? material.opacity ?? 1, + textureUrl: merged.textureUrl ?? null, + normalMapUrl: merged.normalMapUrl ?? null, + textureRepeat: merged.textureRepeat ?? 1, + textureRotation: merged.textureRotation ?? 0, + textureSizeMm: merged.textureSizeMm ?? 600, + isPreset: true, + isActive: true, + } +} diff --git a/furniture-3d-system/src/server/services/__tests__/open-platform-demo-seed.service.test.ts b/furniture-3d-system/src/server/services/__tests__/open-platform-demo-seed.service.test.ts index 482cc438..97c69756 100644 --- a/furniture-3d-system/src/server/services/__tests__/open-platform-demo-seed.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/open-platform-demo-seed.service.test.ts @@ -4,6 +4,7 @@ import { openPlatformDemoSeedService, pickSeedCandidates, } from '../open-platform-demo-seed.service' +import { DEFAULT_STANDARD_FINISH } from '@/lib/standard-finish-catalog' import { prisma } from '@/lib/prisma' import { assetCenterService } from '../asset-center.service' import { combinationService } from '../combination.service' @@ -105,6 +106,8 @@ jest.mock('../warehouse.service', () => ({ })) describe('openPlatformDemoSeedService', () => { + const defaultFinishName = DEFAULT_STANDARD_FINISH.material.name + const defaultFinishCategoryName = DEFAULT_STANDARD_FINISH.category.name const mockedPrisma = jest.mocked(prisma) const mockedAssetCenterService = jest.mocked(assetCenterService) const mockedSeriesTemplateService = jest.mocked(seriesTemplateService) @@ -267,12 +270,12 @@ describe('openPlatformDemoSeedService', () => { mockedPrisma.materialCategory.findUnique.mockResolvedValue(null as never) mockedPrisma.materialCategory.create.mockResolvedValue({ id: 'material-category-finish', - name: '饰面/花色', + name: defaultFinishCategoryName, } as never) mockedPrisma.material.findFirst.mockResolvedValue(null as never) mockedPrisma.material.create.mockResolvedValue({ id: 'finish-white-oak', - name: '云序白橡', + name: defaultFinishName, } as never) mockedPrisma.substrate.findUnique.mockResolvedValue(null as never) mockedPrisma.assetFamily.findUnique.mockResolvedValue(null as never) @@ -433,7 +436,7 @@ describe('openPlatformDemoSeedService', () => { substrateCode: input.substrateId ?? null, substrateName: input.substrateId ? '18mm 颗粒板' : null, finishId: input.finishId ?? null, - finishName: input.finishId ? '云序白橡' : null, + finishName: input.finishId ? defaultFinishName : null, dimensions: input.dimensions, modelUrl: input.modelUrl, thumbnailUrl: input.thumbnailUrl ?? null, @@ -479,7 +482,7 @@ describe('openPlatformDemoSeedService', () => { substrateCode: input.substrateId ?? null, substrateName: input.substrateId ? '18mm 颗粒板' : null, finishId: input.finishId ?? null, - finishName: input.finishId ? '云序白橡' : null, + finishName: input.finishId ? defaultFinishName : null, dimensions: input.dimensions ?? {}, modelUrl: input.modelUrl ?? '/uploads/models/skus/BJ012401_1773486549974.glb', thumbnailUrl: input.thumbnailUrl ?? 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 291d5114..d425d6e1 100644 --- a/furniture-3d-system/src/server/services/asset-center.service.ts +++ b/furniture-3d-system/src/server/services/asset-center.service.ts @@ -39,6 +39,7 @@ import type { CreateSubstrateInput, EnsureBoardLibraryDefaultsResult, SubstrateDTO, + UpdateSubstrateInput, UpdateAssetFamilyInput, UpdateAssetVariantInput, } from './asset-center.service.types' @@ -79,6 +80,10 @@ 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 @@ -227,6 +232,75 @@ function buildBoardFinishCreateData(name: string, categoryId: string) { } 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 + } +} + +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 + } +} + +async function ensureBoardSubstrate(rule: (typeof BOARD_LIBRARY_DEFAULTS)[number]) { + const existingSubstrate = await substrateRepository.findByCode(rule.code) + if (existingSubstrate) { + return existingSubstrate + } + + 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 + } +} + async function ensureCategoryExists(categoryId: string | null | undefined): Promise { if (!categoryId) return const category = await categoryRepository.findById(categoryId) @@ -267,51 +341,15 @@ async function ensureVariantCodeAvailable(code: string, currentId?: string): Pro class AssetCenterService { async ensureBoardLibraryDefaults(): Promise { - const existingCategory = await materialCategoryRepository.findByName(BOARD_FINISH_CATEGORY_NAME) - const finishCategory = - existingCategory ?? - (await materialCategoryRepository.create({ - name: BOARD_FINISH_CATEGORY_NAME, - icon: 'Palette', - sortOrder: 10, - isActive: true, - })) + const finishCategory = await ensureBoardFinishCategory() const finishNames = Array.from( new Set([...BOARD_REGULAR_FINISHES, ...BOARD_RUBBER_WOOD_FINISHES, '冰川白石纹色']), ) - await Promise.all( - finishNames.map(async (finishName) => { - const existingFinish = await materialRepository.findByNameAndCategory( - finishName, - finishCategory.id, - ) - if (existingFinish) { - return existingFinish - } + await Promise.all(finishNames.map((finishName) => ensureBoardFinish(finishName, finishCategory.id))) - return materialRepository.create(buildBoardFinishCreateData(finishName, finishCategory.id)) - }), - ) - - const substrates = await Promise.all( - BOARD_LIBRARY_DEFAULTS.map(async (rule) => { - const existingSubstrate = await substrateRepository.findByCode(rule.code) - if (existingSubstrate) { - return existingSubstrate - } - - return substrateRepository.create({ - code: rule.code, - name: rule.name, - description: `${rule.materialName} ${rule.environmentalGrade} ${rule.thicknessMm}mm`, - densityKgM3: null, - metadata: toJsonValue(rule), - isActive: true, - }) - }), - ) + const substrates = await Promise.all(BOARD_LIBRARY_DEFAULTS.map((rule) => ensureBoardSubstrate(rule))) return { finishCategoryId: finishCategory.id, @@ -433,6 +471,59 @@ class AssetCenterService { return toSubstrateDTO(created) } + async updateSubstrate(id: string, input: UpdateSubstrateInput): Promise { + const existing = await substrateRepository.findById(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, '基材编码和名称不能为空') + } + + const duplicateByCode = await substrateRepository.findByCode(nextCode) + if (duplicateByCode && duplicateByCode.id !== id) { + 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 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, + }) + + return toSubstrateDTO(updated) + } + + async deleteSubstrate(id: string): Promise { + const existing = await substrateRepository.findById(id) + if (!existing) { + throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '基材不存在') + } + + await substrateRepository.delete(id) + return toSubstrateDTO(existing) + } + async createVariant(input: CreateAssetVariantInput): Promise { const family = await assetFamilyRepository.findById(input.assetFamilyId) if (!family) { @@ -891,4 +982,5 @@ export type { SubstrateDTO, UpdateAssetFamilyInput, UpdateAssetVariantInput, + 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 3f15f4d4..95092d91 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 @@ -164,6 +164,15 @@ export interface EnsureBoardLibraryDefaultsResult { substrates: SubstrateDTO[] } +export interface UpdateSubstrateInput { + code?: string + name?: string + description?: string | null + densityKgM3?: number | null + metadata?: Record | null + isActive?: boolean +} + export interface AssetReviewActionInput { reason?: string | null } diff --git a/furniture-3d-system/src/server/services/index.ts b/furniture-3d-system/src/server/services/index.ts index 9fa248f3..dbcaea60 100644 --- a/furniture-3d-system/src/server/services/index.ts +++ b/furniture-3d-system/src/server/services/index.ts @@ -164,6 +164,7 @@ export type { SubstrateDTO, UpdateAssetFamilyInput, UpdateAssetVariantInput, + UpdateSubstrateInput, } from './asset-center.service' export { assetCadImportService, AssetCadImportService } from './asset-cad-import.service' export type { 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 34bbd805..965f8603 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 @@ -1,6 +1,11 @@ import { hash } from 'bcryptjs' import type { AssetType } from '@prisma/client' import { prisma } from '@/lib/prisma' +import { + DEFAULT_STANDARD_FINISH, + STANDARD_FINISH_CATALOG, +} from '@/lib/standard-finish-catalog' +import { buildStandardFinishMaterialData } from '@/lib/standard-finish-sync' import type { OpenProductMountPoint } from '@/lib/open-product/types' import { AppError, ErrorCode } from '@/server/core/errors' import type { AuthUser } from '@/server/core/types' @@ -239,53 +244,66 @@ async function ensureSingleBoardDeskCategory() { }) } -async function ensureFinishMaterial() { - const existingCategory = await prisma.materialCategory.findUnique({ - where: { name: '饰面/花色' }, - }) - const category = - existingCategory ?? - (await prisma.materialCategory.create({ - data: { - name: '饰面/花色', - icon: '🎨', - sortOrder: 10, - isActive: true, - }, - })) +async function ensureStandardFinishMaterial() { + const categoryByKey = new Map() + const materialByKey = new Map() - const existing = await prisma.material.findFirst({ - where: { - name: '云序白橡', - categoryId: category.id, - }, - }) + for (const categoryDefinition of STANDARD_FINISH_CATALOG) { + const existingCategory = await prisma.materialCategory.findUnique({ + where: { name: categoryDefinition.name }, + }) - const data = { - name: '云序白橡', - categoryId: category.id, - thumbnailUrl: - '/materials/pbr/polyhaven/fine_grained_wood/fine_grained_wood_col_1k.jpg', - textureUrl: - '/materials/pbr/polyhaven/fine_grained_wood/fine_grained_wood_col_1k.jpg', - normalMapUrl: - '/materials/pbr/polyhaven/fine_grained_wood/fine_grained_wood_nor_gl_1k.jpg', - color: '#E8E1D3', - metalness: 0, - roughness: 0.62, - opacity: 1, - textureRepeat: 1, - textureRotation: 0, - textureSizeMm: 600, - isPreset: false, - isActive: true, + const category = + existingCategory ?? + (await prisma.materialCategory.create({ + data: { + name: categoryDefinition.name, + icon: categoryDefinition.icon, + sortOrder: categoryDefinition.sortOrder, + isActive: true, + }, + })) + + categoryByKey.set(categoryDefinition.key, category) + + for (const materialDefinition of categoryDefinition.materials) { + const existingMaterial = await prisma.material.findFirst({ + where: { + name: materialDefinition.name, + categoryId: category.id, + }, + }) + + const material = + existingMaterial ?? + (await prisma.material.create({ + data: buildStandardFinishMaterialData({ + material: materialDefinition, + category: categoryDefinition, + categoryId: category.id, + }), + })) + + materialByKey.set(`${categoryDefinition.key}:${materialDefinition.name}`, material) + } } - if (existing) { - return existing + const defaultCategory = categoryByKey.get(DEFAULT_STANDARD_FINISH.category.key) + if (!defaultCategory) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '标准饰面分类初始化失败') } - return prisma.material.create({ data }) + const defaultMaterial = materialByKey.get( + `${DEFAULT_STANDARD_FINISH.category.key}:${DEFAULT_STANDARD_FINISH.material.name}`, + ) + if (!defaultMaterial) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '标准默认饰面初始化失败') + } + + return { + category: defaultCategory, + material: defaultMaterial, + } } async function ensureSubstrate(creatorId: string) { @@ -559,16 +577,19 @@ function buildDemoFamilyDefinitions(categoryId: string): Record { return { desktopA: { familyCode: 'OPD-DESKTOP-A', code: 'OPD-DESKTOP-A-1200-WO', - name: '演示桌面板 A 1200 白橡', + name: `演示桌面板 A 1200 ${input.finish.name}`, sizeLabel: '1200x600', substrateId: input.substrateId, - finishId: input.finishId, + finishId: input.finish.id, dimensions: { length: 1200, width: 600, @@ -583,14 +604,14 @@ function buildDemoVariantDefinitions(input: { }, namingFields: { size: '1200x600', - finish: '云序白橡', + finish: input.finish.name, }, businessData: { designerName: '开放平台录入员', developedAt: '2026-04-01', - colorOptions: ['白橡'], + colorOptions: [input.finish.name], sizeOptions: ['1200x600'], - materialLabel: '18mm 颗粒板 + 白橡饰面', + materialLabel: `18mm 颗粒板 + ${input.finish.name}饰面`, processName: '封边+贴面', weightLabelKg: 14.2, supplierName: '演示供应商 A', @@ -602,10 +623,10 @@ function buildDemoVariantDefinitions(input: { desktopB: { familyCode: 'OPD-DESKTOP-B', code: 'OPD-DESKTOP-B-1400-WO', - name: '演示桌面板 B 1400 白橡', + name: `演示桌面板 B 1400 ${input.finish.name}`, sizeLabel: '1400x700', substrateId: input.substrateId, - finishId: input.finishId, + finishId: input.finish.id, dimensions: { length: 1400, width: 700, @@ -620,14 +641,14 @@ function buildDemoVariantDefinitions(input: { }, namingFields: { size: '1400x700', - finish: '云序白橡', + finish: input.finish.name, }, businessData: { designerName: '开放平台录入员', developedAt: '2026-04-01', - colorOptions: ['白橡'], + colorOptions: [input.finish.name], sizeOptions: ['1400x700'], - materialLabel: '18mm 颗粒板 + 白橡饰面', + materialLabel: `18mm 颗粒板 + ${input.finish.name}饰面`, processName: '封边+贴面', weightLabelKg: 16.8, supplierName: '演示供应商 A', @@ -810,7 +831,10 @@ function buildDemoVariantDefinitions(input: { async function ensureDemoAssets(input: { categoryId: string substrateId: string - finishId: string + finish: { + id: string + name: string + } creator: AuthUser reviewer: AuthUser }): Promise { @@ -854,7 +878,7 @@ async function ensureDemoAssets(input: { const variants = buildDemoVariantDefinitions({ substrateId: input.substrateId, - finishId: input.finishId, + finish: input.finish, }) const desktopAVariant = await ensureVariantAvailable( @@ -927,9 +951,9 @@ class OpenPlatformDemoSeedService { const planner = toAuthUser(plannerRecord) const reviewer = toAuthUser(reviewerRecord) - const [category, finish, substrateRecord, location] = await Promise.all([ + const [category, finishBundle, substrateRecord, location] = await Promise.all([ ensureSingleBoardDeskCategory(), - ensureFinishMaterial(), + ensureStandardFinishMaterial(), ensureSubstrate(planner.id), ensureWarehouseLocation(), ]) @@ -939,7 +963,10 @@ class OpenPlatformDemoSeedService { const assets = await ensureDemoAssets({ categoryId: category.id, substrateId: substrateRecord.id, - finishId: finish.id, + finish: { + id: finishBundle.material.id, + name: finishBundle.material.name, + }, creator: planner, reviewer, })