feat: split asset material library from asset center

This commit is contained in:
WuYongJie 2026-04-08 12:06:11 +08:00
parent e1d78a6ecb
commit 29036152d1
24 changed files with 1888 additions and 761 deletions

View File

@ -13,11 +13,51 @@
## 当前未完成项
### 待前端修正 (前端 UI Agent 负责)
- [ ] **资产中心弹窗主按钮禁用态过浅**
归属:`前端负责`
说明:资产中心内一批浅色弹窗底部的主按钮,在不可提交或 loading 前后状态下,存在“底色发灰、文字发白、和浅色 footer 混在一起”的问题,主次层级不清晰。请统一修正 `filled` 主按钮在浅色主题下的默认态 / disabled 态 / loading 态对比度,不要只改单个弹窗。
涉及位置:
`资产族弹窗`:`创建` / `保存修改`
`规格款弹窗`:`创建规格款` / `保存修改`
`CAD 导入弹窗`:`开始导入` / `继续导入`
`新增/编辑连接角色弹窗`:`新增` / `保存`
`基础资料库` 页面弹窗:`创建基材` / `保存修改` / `创建分类` / `创建饰面`
- [ ] **资产中心弹窗次级文字按钮在浅底上过淡**
归属:`前端负责`
说明:浅色弹窗 footer 内的 `plain` 次级按钮,现在默认文字和背景层级太接近,小字号时尤其不清楚。请统一检查并修正 `plain` 按钮在浅色弹窗中的默认态、hover 态、禁用态,让“取消 / 完成 / 新增连接角色”这类按钮肉眼一看就能分清。
涉及位置:
`资产族弹窗`:`取消`
`规格款弹窗`:`取消`
`CAD 导入弹窗`:`取消` / `完成`
`连接角色区域`:`新增连接角色`
`新增/编辑连接角色弹窗`:`取消`
`基础资料库` 页:`新建分类`
`基础资料库` 页面弹窗:`取消`
- [ ] **资产中心列表内图标按钮默认态辨识度不足**
归属:`前端负责`
说明:列表和卡片内的编辑 / 删除图标按钮,默认态颜色过浅,未 hover 时像“没激活”或“不可点”,尤其在浅色卡片和浅色分组背景里不够清楚。请统一调整这类圆形图标按钮的默认态和 hover 态,不要只修某一个图标。
涉及位置:
`资产族 / 规格款列表`:铅笔编辑按钮
`连接角色列表`:编辑 / 删除图标按钮
`基础资料库` 页:基材 / 分类 / 饰面列表的编辑 / 删除图标按钮
- [ ] **同一弹窗内主次按钮层级需要统一规范**
归属:`前端负责`
说明:当前资产中心多个弹窗里,主按钮、次按钮、危险按钮的视觉层级不完全一致;有的主按钮太淡,有的删除按钮太抢,有的次按钮像禁用态。请按统一视觉标准整理这三类按钮在浅色主题弹窗 footer 内的层级关系,保证“主操作最清楚、次操作清楚但不抢、危险操作醒目但不刺眼”。这一项是规则统一,不是逐页补丁。
- [ ] **基础资料库页面需要做正式视觉收口**
归属:`前端负责`
说明:`基础资料库` 已经作为独立子页面接入主流程,但目前还是功能先行版。请按全局统一视觉,把这个页面的左侧说明区、右侧列表区、分类筛选 chips、列表空态和“返回资产中心 / 新建分类 / 新建饰面 / 新建基材”几组操作做成和主线一致的正式界面,不要回退为老式后台页。
### 待联调确认 (全栈 / 后端 Agent 负责)
- [ ] **资产中心资产族新流程人工验收**
归属:`联调确认`
说明:确认资产中心现在已改成“先建资产族,再在资产族内新建规格款 / 发起 CAD 导入”的结构,用户理解是否自然,弹窗开关、列表刷新、导入完成后的落点是否符合预期。
说明:确认资产中心现在已改成“先建资产族,再在资产族内新建规格款 / 发起 CAD 导入;缺资料时跳转基础资料库补录”的结构,用户理解是否自然,弹窗开关、列表刷新、导入完成后的落点是否符合预期。
- [ ] **资产族品类系列 + 中文连接角色维护人工验收**
归属:`联调确认`
@ -26,3 +66,7 @@
- [ ] **包材资料库页面视觉联调**
归属:`联调确认`
说明:确认该页面视觉是否已统一到主线风格。
- [ ] **基础资料库 CRUD 联调**
归属:`联调确认`
说明:确认 `基础资料库` 页的基材新增/编辑/删除、材质分类新增/编辑/删除、饰面新增/编辑/删除全部可用;删除后回到资产中心,CAD 导入弹窗、规格款弹窗和 3D 右键快捷菜单都能读到最新数据。

View File

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

View File

@ -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<string, string> = {};
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,
}),
});
}
}
// ============================================

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -51,10 +51,7 @@ import { cn } from '@/lib/utils'
import {
createAssetFamily,
createAssetVariant,
createMaterialCategory,
createSubstrate,
deleteAssetFamily,
deleteMaterial,
deleteAssetVariant,
importAssetCad,
updateAssetFamily,
@ -438,19 +435,6 @@ export default function AssetCenterClient({
cascadeCount?: number
} | null>(null)
// 基材新建
const [substrateModalOpen, setSubstrateModalOpen] = useState(false)
const [substrateForm, setSubstrateForm] = useState({ code: '', name: '', description: '', densityKgM3: '' })
// 饰面/花色新建弹窗
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')
@ -984,125 +968,6 @@ export default function AssetCenterClient({
// ---- 基材操作 ----
async function handleCreateSubstrate() {
setSubmitting(true)
try {
const result = await createSubstrate({
code: substrateForm.code,
name: substrateForm.name,
description: substrateForm.description || null,
densityKgM3: toOptionalNumber(substrateForm.densityKgM3),
})
startTransition(() => {
setSubstrates((current) =>
sortByName([
...current,
{
id: result.substrate.id,
code: result.substrate.code,
name: result.substrate.name,
description: substrateForm.description || null,
densityKgM3: toOptionalNumber(substrateForm.densityKgM3),
metadata: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
]),
)
setSubstrateForm({ code: '', name: '', description: '', densityKgM3: '' })
setSubstrateModalOpen(false)
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)
}
}
const closePreviewContextMenu = useCallback(() => {
setPreviewContextMenuState((current) => ({ ...current, visible: false }))
setActivePreviewSubmenu(null)
@ -1131,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)
@ -1420,7 +1263,7 @@ export default function AssetCenterClient({
<PlatformSplitLayout
title="资产中心"
metrics={metrics}
rightPanelClassName="w-[440px] max-w-[46vw]"
rightPanelClassName="w-[560px] max-w-[52vw]"
leftContent={
<ModelPreviewPanel
modelUrl={selectedVariant?.modelUrl || null}
@ -1699,100 +1542,31 @@ export default function AssetCenterClient({
)}
</div>
{/* 基材库 */}
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<Package2 className="h-4 w-4 text-apple-label-tertiary" />
<span className="text-sm font-apple-medium text-apple-label-primary">基材库</span>
<span className="text-xs text-apple-label-secondary">{substrates.length} 种</span>
<Link
href="/asset-center/material-library"
className="flex items-center justify-between rounded-apple-lg border border-apple-separator bg-apple-bg-primary px-4 py-4 transition hover:bg-apple-bg-secondary/50"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-apple-lg bg-apple-blue/10 text-apple-blue">
<SwatchBook className="h-4 w-4" />
</div>
<Button variant="tinted" size="small" onClick={() => { setSubstrateModalOpen(true); setSubstrateForm({ code: '', name: '', description: '', densityKgM3: '' }); }}>
<Plus className="h-3.5 w-3.5" />
新建
</Button>
</div>
<div className="border-t border-apple-separator">
{substrates.map((substrate) => (
<div key={substrate.id} className="flex items-center justify-between px-4 py-2 hover:bg-apple-bg-secondary/50 transition">
<div>
<p className="text-sm text-apple-label-primary">{substrate.name}</p>
<p className="text-xs text-apple-label-secondary">{substrate.code}</p>
</div>
{substrate.densityKgM3 ? (
<span className="text-xs text-apple-label-tertiary">{substrate.densityKgM3} kg/m³</span>
) : null}
</div>
))}
</div>
</div>
{/* 饰面/花色库 */}
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<SwatchBook className="h-4 w-4 text-apple-label-tertiary" />
<span className="text-sm font-apple-medium text-apple-label-primary">饰面 / 花色库</span>
<span className="text-xs text-apple-label-secondary">共 {finishes.length} 种</span>
</div>
<Button variant="tinted" size="small" onClick={() => { setFinishForm({ name: '', categoryId: '', color: '#CCCCCC' }); setFinishModalOpen(true) }}>
<Plus className="h-3.5 w-3.5" />
新建
</Button>
</div>
<div className="border-t border-apple-separator px-4 py-3">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 rounded-apple-md bg-apple-bg-secondary/60 px-3 py-2">
<div>
<p className="text-sm font-apple-medium text-apple-label-primary">基础资料库</p>
<p className="text-xs text-apple-label-secondary">
先建材质分类,再建具体饰面 / 花色。
统一维护基材、材质分类、饰面花色;补录完成后再回当前资产族继续操作。
</p>
<Button
variant="plain"
size="small"
onClick={() => {
setMaterialCategoryForm({ name: '', icon: '' })
setMaterialCategoryModalOpen(true)
}}
>
<Plus className="h-3.5 w-3.5" />
新建材质分类
</Button>
</div>
<div className="space-y-3">
{finishGroups.map((group, groupIndex) => (
<div key={group.id} className={cn(groupIndex > 0 && 'border-t border-apple-separator pt-3')}>
<div className="mb-2 flex items-center gap-2">
<span className="text-[11px] font-apple-medium uppercase tracking-wide text-apple-label-tertiary">
{group.name}
</span>
<span className="text-[10px] text-apple-label-secondary">{group.finishes.length} 种</span>
</div>
<div className="grid gap-2 md:grid-cols-2">
{group.finishes.map((finish) => (
<div key={finish.id} className="flex items-center gap-2.5 rounded-apple-md px-3 py-2 hover:bg-apple-bg-secondary/50 transition">
<div
className="h-7 w-7 rounded-apple-md border border-apple-separator shrink-0"
style={{ backgroundColor: finish.color }}
/>
<div className="min-w-0 flex-1">
<p className="text-xs font-apple-medium text-apple-label-primary truncate">{finish.name}</p>
<p className="text-[10px] text-apple-label-secondary">{group.name}</p>
</div>
<button
type="button"
onClick={() => void handleDeleteFinish(finish)}
className="shrink-0 rounded-full p-1.5 text-apple-label-tertiary hover:bg-apple-bg-primary hover:text-apple-red transition"
title={`删除 ${finish.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
<div className="text-right">
<p className="text-xs text-apple-label-tertiary">
{substrates.length} 个基材 · {finishes.length} 个饰面
</p>
<div className="mt-1 flex items-center justify-end gap-1 text-sm text-apple-blue">
打开资料页
<ArrowRight className="h-4 w-4" />
</div>
</div>
</Link>
{/* 下一步引导 */}
<Link
@ -2478,117 +2252,6 @@ export default function AssetCenterClient({
}
/>
{/* 基材新建弹窗 */}
<Modal
isOpen={substrateModalOpen}
onClose={() => setSubstrateModalOpen(false)}
title="新建基材"
size="medium"
footer={
<div className="flex justify-end gap-2">
<Button variant="plain" size="small" onClick={() => setSubstrateModalOpen(false)}>取消</Button>
<Button size="small" onClick={handleCreateSubstrate} loading={submitting}>
<Package2 className="h-3.5 w-3.5" />
创建基材
</Button>
</div>
}
>
<div className="space-y-4">
<div className="grid gap-3 grid-cols-2">
<Input label="编码" value={substrateForm.code} onChange={(e) => setSubstrateForm((c) => ({ ...c, code: e.target.value }))} placeholder="例如 PB18" />
<Input label="名称" value={substrateForm.name} onChange={(e) => setSubstrateForm((c) => ({ ...c, name: e.target.value }))} placeholder="例如 18mm 颗粒板" />
</div>
<Input label="密度 kg/m³" value={substrateForm.densityKgM3} onChange={(e) => setSubstrateForm((c) => ({ ...c, densityKgM3: e.target.value }))} placeholder="例如 680" />
</div>
</Modal>
{/* 饰面/花色新建弹窗 */}
<Modal
isOpen={finishModalOpen}
onClose={() => setFinishModalOpen(false)}
title="新建饰面 / 花色"
size="medium"
footer={
<div className="flex justify-end gap-2">
<Button variant="plain" size="small" onClick={() => setFinishModalOpen(false)}>取消</Button>
<Button size="small" onClick={handleCreateFinish} loading={submitting}>
<SwatchBook className="h-3.5 w-3.5" />
创建
</Button>
</div>
}
>
<div className="space-y-4">
<Input
label="名称"
value={finishForm.name}
onChange={(e) => setFinishForm((c) => ({ ...c, name: e.target.value }))}
placeholder="例如 白橡木纹"
/>
<Select
label="材质分类"
required
value={finishForm.categoryId}
onChange={(e) => setFinishForm((c) => ({ ...c, categoryId: e.target.value }))}
options={materialCategories.map((cat) => ({ value: cat.id, label: cat.name }))}
placeholder="请选择分类"
helperText="如果还没有分类,可先关闭当前弹窗,在饰面 / 花色库卡片里点击“新建材质分类”"
/>
<div className="flex items-end gap-3">
<Input
label="颜色值"
value={finishForm.color}
onChange={(e) => setFinishForm((c) => ({ ...c, color: e.target.value }))}
placeholder="#CCCCCC"
className="flex-1"
helperText="花色主色,用于列表预览"
/>
<div
className="h-10 w-10 rounded-apple-md border border-apple-separator shrink-0"
style={{ backgroundColor: finishForm.color }}
/>
</div>
<p className="text-xs text-apple-label-secondary">
这是主线最小创建入口。纹理贴图、粗糙度、金属度等参数可在后台材质管理模块继续补齐。
</p>
</div>
</Modal>
<Modal
isOpen={materialCategoryModalOpen}
onClose={() => setMaterialCategoryModalOpen(false)}
title="新建材质分类"
size="medium"
footer={
<div className="flex justify-end gap-2">
<Button variant="plain" size="small" onClick={() => setMaterialCategoryModalOpen(false)}>
取消
</Button>
<Button size="small" onClick={handleCreateMaterialCategory} loading={submitting}>
<Plus className="h-3.5 w-3.5" />
创建分类
</Button>
</div>
}
>
<div className="space-y-4">
<Input
label="分类名称"
value={materialCategoryForm.name}
onChange={(e) => setMaterialCategoryForm((current) => ({ ...current, name: e.target.value }))}
placeholder="例如 木纹、纯色、石纹"
/>
<Input
label="图标标识"
value={materialCategoryForm.icon}
onChange={(e) => setMaterialCategoryForm((current) => ({ ...current, icon: e.target.value }))}
placeholder="可选,例如 palette"
helperText="这里只做最小录入,后续如需更换展示图标,可再统一调整。"
/>
</div>
</Modal>
{/* Toast 容器 */}
<ToastContainer toasts={toast.toasts} onClose={toast.removeToast} />
</>

View File

@ -2,7 +2,7 @@ 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 { createAssetFamily, createMaterialCategory, createSubstrate, importAssetCad } from '@/lib/open-product/platform-api'
import { createAssetFamily, importAssetCad } from '@/lib/open-product/platform-api'
import type { AssetFamilyDTO, AssetVariantDTO, MaterialDTO, SeriesTemplateDTO, SubstrateDTO } from '@/server/services'
jest.mock('next/link', () => ({
@ -108,8 +108,6 @@ 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(),
@ -152,7 +150,7 @@ function renderClient() {
createdAt: new Date('2026-04-07T00:00:00.000Z'),
updatedAt: new Date('2026-04-07T00:00:00.000Z'),
},
] as MaterialDTO[]}
] as unknown as MaterialDTO[]}
materialCategories={[{ id: 'cat-1', name: '木纹', icon: 'Texture', sortOrder: 1 }]}
metrics={[]}
user={{
@ -234,15 +232,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(
<AssetCenterClient
@ -305,7 +296,7 @@ describe('AssetCenterClient workflow', () => {
createdAt: new Date('2026-04-07T00:00:00.000Z'),
updatedAt: new Date('2026-04-07T00:00:00.000Z'),
},
] as MaterialDTO[]}
] as unknown as MaterialDTO[]}
materialCategories={[{ id: 'cat-1', name: '木纹', icon: 'Texture', sortOrder: 1 }]}
metrics={[]}
user={{
@ -322,23 +313,11 @@ describe('AssetCenterClient workflow', () => {
await actor.click(screen.getByText('桌面板资产族'))
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 () => {
@ -468,7 +447,7 @@ describe('AssetCenterClient workflow', () => {
createdAt: new Date('2026-04-07T00:00:00.000Z'),
updatedAt: new Date('2026-04-07T00:00:00.000Z'),
},
] as MaterialDTO[]}
] as unknown as MaterialDTO[]}
materialCategories={[{ id: 'cat-1', name: '木纹', icon: 'Texture', sortOrder: 1 }]}
metrics={[]}
user={{
@ -509,39 +488,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()
})
})

View File

@ -0,0 +1,949 @@
'use client'
import Link from 'next/link'
import { startTransition, useMemo, useState } from 'react'
import {
ArrowLeft,
Layers3,
Package2,
Palette,
Pencil,
Plus,
Search,
SwatchBook,
Trash2,
X,
} from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
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<T extends { name: string }>(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<LibraryTab>('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<DeleteTarget | null>(null)
const [substrateModalOpen, setSubstrateModalOpen] = useState(false)
const [substrateModalMode, setSubstrateModalMode] = useState<'create' | 'edit'>('create')
const [substrateForm, setSubstrateForm] = useState<SubstrateFormState>(emptySubstrateForm())
const [editingSubstrateId, setEditingSubstrateId] = useState<string | null>(null)
const [categoryModalOpen, setCategoryModalOpen] = useState(false)
const [categoryModalMode, setCategoryModalMode] = useState<'create' | 'edit'>('create')
const [categoryForm, setCategoryForm] = useState<CategoryFormState>(emptyCategoryForm())
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null)
const [finishModalOpen, setFinishModalOpen] = useState(false)
const [finishModalMode, setFinishModalMode] = useState<'create' | 'edit'>('create')
const [finishForm, setFinishForm] = useState<FinishFormState>(emptyFinishForm())
const [editingFinishId, setEditingFinishId] = useState<string | null>(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,
{
id: result.substrate.id,
code: result.substrate.code,
name: result.substrate.name,
description: substrateForm.description.trim() || null,
densityKgM3: toOptionalNumber(substrateForm.densityKgM3),
metadata: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
]),
)
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)),
),
)
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))
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 (
<>
<PlatformSplitLayout
title="基础资料库"
description="这里专门维护基材、材质分类和饰面花色;CAD 导入与规格款弹窗只负责选值,不再承担资料维护。"
metrics={metrics}
rightPanelClassName="w-[580px] max-w-[52vw]"
leftContent={
<div className="flex h-full flex-col gap-4 rounded-apple-lg bg-[var(--color-surface-container-low)] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-apple-label-tertiary">
Library Scope
</p>
<h2 className="mt-2 text-2xl font-apple-semibold text-apple-label-primary">
基础资料统一维护
</h2>
<p className="mt-2 max-w-2xl text-sm text-apple-label-secondary">
资产中心主页面只保留“资产族 + 规格款”主流程。缺基材或饰面时,先来这里补录,保存后再回 CAD 导入或规格款弹窗继续操作。
</p>
</div>
<Link href="/asset-center">
<Button variant="gray" size="small">
<ArrowLeft className="h-3.5 w-3.5" />
返回资产中心
</Button>
</Link>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4">
<div className="flex items-center gap-2 text-apple-label-primary">
<Package2 className="h-4 w-4 text-apple-label-tertiary" />
<span className="text-sm font-apple-medium">基材</span>
</div>
<p className="mt-3 text-3xl font-apple-semibold text-apple-label-primary">
{substrates.length}
</p>
<p className="mt-1 text-xs text-apple-label-secondary">
服务板材规格款、CAD 导入和 3D 右键快捷更改。
</p>
</div>
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4">
<div className="flex items-center gap-2 text-apple-label-primary">
<Layers3 className="h-4 w-4 text-apple-label-tertiary" />
<span className="text-sm font-apple-medium">材质分类</span>
</div>
<p className="mt-3 text-3xl font-apple-semibold text-apple-label-primary">
{materialCategories.length}
</p>
<p className="mt-1 text-xs text-apple-label-secondary">
继续保持“先分类,再具体饰面”的结构,不再写第二套口径。
</p>
</div>
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4">
<div className="flex items-center gap-2 text-apple-label-primary">
<Palette className="h-4 w-4 text-apple-label-tertiary" />
<span className="text-sm font-apple-medium">饰面 / 花色</span>
</div>
<p className="mt-3 text-3xl font-apple-semibold text-apple-label-primary">
{finishes.length}
</p>
<p className="mt-1 text-xs text-apple-label-secondary">
删除后会自动让已引用规格款回落为空,不额外制造异常状态。
</p>
</div>
</div>
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-5">
<h3 className="text-base font-apple-semibold text-apple-label-primary">使用口径</h3>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<div className="rounded-apple-md bg-apple-bg-secondary/60 p-4">
<p className="text-sm font-apple-medium text-apple-label-primary">什么时候来这里</p>
<p className="mt-2 text-sm text-apple-label-secondary">
需要新增或调整基材、材质分类、饰面花色时,统一来这个页面处理。
</p>
</div>
<div className="rounded-apple-md bg-apple-bg-secondary/60 p-4">
<p className="text-sm font-apple-medium text-apple-label-primary">什么时候回资产中心</p>
<p className="mt-2 text-sm text-apple-label-secondary">
资料补录完成后,回资产中心继续 CAD 导入、规格款新建编辑和 3D 右键快捷更改。
</p>
</div>
</div>
</div>
</div>
}
rightContent={
<>
<div className="flex items-center gap-1 rounded-apple-lg bg-apple-bg-tertiary p-1">
<button
type="button"
onClick={() => setActiveTab('substrate')}
className={cn(
'flex flex-1 items-center justify-center gap-1.5 rounded-apple-md px-3 py-2 text-xs font-apple-medium transition',
activeTab === 'substrate'
? 'bg-apple-bg-primary text-apple-label-primary shadow-apple-xs'
: 'text-apple-label-secondary hover:text-apple-label-primary',
)}
>
<Package2 className="h-3.5 w-3.5" />
基材
</button>
<button
type="button"
onClick={() => setActiveTab('finish')}
className={cn(
'flex flex-1 items-center justify-center gap-1.5 rounded-apple-md px-3 py-2 text-xs font-apple-medium transition',
activeTab === 'finish'
? 'bg-apple-bg-primary text-apple-label-primary shadow-apple-xs'
: 'text-apple-label-secondary hover:text-apple-label-primary',
)}
>
<SwatchBook className="h-3.5 w-3.5" />
饰面花色
</button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-apple-label-tertiary" />
<input
type="text"
value={searchText}
onChange={(event) => 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 ? (
<button
type="button"
onClick={() => 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"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
{activeTab === 'substrate' ? (
<Button variant="tinted" size="small" onClick={openCreateSubstrateModal}>
<Plus className="h-3.5 w-3.5" />
新建基材
</Button>
) : (
<div className="flex items-center gap-2">
<Button variant="plain" size="small" onClick={openCreateCategoryModal}>
<Plus className="h-3.5 w-3.5" />
新建分类
</Button>
<Button variant="tinted" size="small" onClick={openCreateFinishModal}>
<Plus className="h-3.5 w-3.5" />
新建饰面
</Button>
</div>
)}
</div>
{activeTab === 'substrate' ? (
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary">
<div className="border-b border-apple-separator px-4 py-3">
<p className="text-sm font-apple-medium text-apple-label-primary">基材列表</p>
<p className="text-xs text-apple-label-secondary">
当前共 {filteredSubstrates.length} 条,删除后规格款会自动回落为未设置。
</p>
</div>
<div className="divide-y divide-apple-separator">
{filteredSubstrates.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-apple-label-tertiary">
暂无匹配的基材
</div>
) : (
filteredSubstrates.map((substrate) => (
<div key={substrate.id} className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-apple-medium text-apple-label-primary">
{substrate.name}
</p>
<Badge type="info" variant="subtle" size="small">
{substrate.code}
</Badge>
</div>
<p className="mt-1 text-xs text-apple-label-secondary">
{substrate.description?.trim() || '未填写说明'}
</p>
</div>
<div className="text-right text-xs text-apple-label-tertiary">
{substrate.densityKgM3 ? `${substrate.densityKgM3} kg/m³` : '未填密度'}
</div>
<button
type="button"
onClick={() => openEditSubstrateModal(substrate)}
className="rounded-full p-1.5 text-apple-label-tertiary hover:bg-apple-bg-secondary hover:text-apple-label-primary transition"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => setDeleteTarget({ type: 'substrate', id: substrate.id, name: substrate.name })}
className="rounded-full p-1.5 text-apple-label-tertiary hover:bg-apple-bg-secondary hover:text-apple-red transition"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))
)}
</div>
</div>
) : (
<>
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-apple-medium text-apple-label-primary">材质分类筛选</p>
<p className="text-xs text-apple-label-secondary">
先选分类再维护具体饰面;也可以查看全部。
</p>
</div>
<Select
size="small"
value={selectedCategoryId}
onChange={(event) => setSelectedCategoryId(event.target.value)}
options={[
{ value: 'all', label: '全部分类' },
...materialCategories.map((category) => ({
value: category.id,
label: `${category.name} (${category.materialCount ?? 0})`,
})),
]}
/>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{materialCategories.map((category) => (
<button
key={category.id}
type="button"
onClick={() =>
setSelectedCategoryId((current) => (current === category.id ? 'all' : category.id))
}
className={cn(
'rounded-full border px-3 py-1.5 text-xs transition',
selectedCategoryId === category.id
? 'border-apple-blue/30 bg-apple-blue/8 text-apple-blue'
: 'border-apple-separator bg-apple-bg-secondary text-apple-label-secondary hover:text-apple-label-primary',
)}
>
{category.name}
</button>
))}
</div>
</div>
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary">
<div className="border-b border-apple-separator px-4 py-3">
<p className="text-sm font-apple-medium text-apple-label-primary">饰面 / 花色资料</p>
<p className="text-xs text-apple-label-secondary">
分类与饰面在同一页面维护,当前共 {filteredFinishes.length} 条结果。
</p>
</div>
<div className="space-y-4 px-4 py-4">
{groupedFinishes.length === 0 ? (
<div className="rounded-apple-md bg-apple-bg-secondary/60 px-4 py-8 text-center text-sm text-apple-label-tertiary">
当前筛选下暂无饰面 / 花色
</div>
) : (
groupedFinishes.map((group, groupIndex) => (
<div
key={group.category.id}
className={cn(groupIndex > 0 && 'border-t border-apple-separator pt-4')}
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-apple-medium text-apple-label-primary">
{group.category.name}
</p>
<span className="text-[11px] text-apple-label-tertiary">
{group.finishes.length} 种
</span>
</div>
</div>
{group.category.id !== 'uncategorized' ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => openEditCategoryModal(group.category)}
className="rounded-full p-1.5 text-apple-label-tertiary hover:bg-apple-bg-secondary hover:text-apple-label-primary transition"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => setDeleteTarget({ type: 'category', id: group.category.id, name: group.category.name })}
className="rounded-full p-1.5 text-apple-label-tertiary hover:bg-apple-bg-secondary hover:text-apple-red transition"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
) : null}
</div>
<div className="grid gap-2">
{group.finishes.map((finish) => (
<div
key={finish.id}
className="flex items-center gap-3 rounded-apple-md bg-apple-bg-secondary/40 px-3 py-2"
>
<div
className="h-8 w-8 shrink-0 rounded-apple-md border border-apple-separator"
style={{ backgroundColor: finish.color }}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-apple-medium text-apple-label-primary">
{finish.name}
</p>
<p className="text-[11px] text-apple-label-secondary">
{finish.categoryName ?? group.category.name}
</p>
</div>
<button
type="button"
onClick={() => openEditFinishModal(finish)}
className="rounded-full p-1.5 text-apple-label-tertiary hover:bg-apple-bg-primary hover:text-apple-label-primary transition"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => setDeleteTarget({ type: 'finish', id: finish.id, name: finish.name })}
className="rounded-full p-1.5 text-apple-label-tertiary hover:bg-apple-bg-primary hover:text-apple-red transition"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</div>
))
)}
</div>
</div>
</>
)}
</>
}
/>
<Modal
isOpen={substrateModalOpen}
onClose={() => setSubstrateModalOpen(false)}
title={substrateModalMode === 'create' ? '新建基材' : '编辑基材'}
size="medium"
footer={
<div className="flex justify-end gap-2">
<Button variant="plain" size="small" onClick={() => setSubstrateModalOpen(false)}>
取消
</Button>
<Button size="small" onClick={handleSaveSubstrate} loading={submitting}>
{substrateModalMode === 'create' ? '创建基材' : '保存修改'}
</Button>
</div>
}
>
<div className="space-y-4">
<div className="grid gap-3 grid-cols-2">
<Input
label="编码"
value={substrateForm.code}
onChange={(event) => setSubstrateForm((current) => ({ ...current, code: event.target.value }))}
placeholder="例如 PB18"
/>
<Input
label="名称"
value={substrateForm.name}
onChange={(event) => setSubstrateForm((current) => ({ ...current, name: event.target.value }))}
placeholder="例如 18mm 颗粒板"
/>
</div>
<Input
label="说明"
value={substrateForm.description}
onChange={(event) => setSubstrateForm((current) => ({ ...current, description: event.target.value }))}
placeholder="可选"
/>
<Input
label="密度 kg/m³"
value={substrateForm.densityKgM3}
onChange={(event) => setSubstrateForm((current) => ({ ...current, densityKgM3: event.target.value }))}
placeholder="例如 680"
/>
</div>
</Modal>
<Modal
isOpen={categoryModalOpen}
onClose={() => setCategoryModalOpen(false)}
title={categoryModalMode === 'create' ? '新建材质分类' : '编辑材质分类'}
size="medium"
footer={
<div className="flex justify-end gap-2">
<Button variant="plain" size="small" onClick={() => setCategoryModalOpen(false)}>
取消
</Button>
<Button size="small" onClick={handleSaveCategory} loading={submitting}>
{categoryModalMode === 'create' ? '创建分类' : '保存修改'}
</Button>
</div>
}
>
<div className="space-y-4">
<Input
label="分类名称"
value={categoryForm.name}
onChange={(event) => setCategoryForm((current) => ({ ...current, name: event.target.value }))}
placeholder="例如 木纹、石纹、纯色"
/>
<div className="grid gap-3 grid-cols-2">
<Input
label="图标标识"
value={categoryForm.icon}
onChange={(event) => setCategoryForm((current) => ({ ...current, icon: event.target.value }))}
placeholder="可选"
/>
<Input
label="排序值"
value={categoryForm.sortOrder}
onChange={(event) => setCategoryForm((current) => ({ ...current, sortOrder: event.target.value }))}
placeholder="数字越小越靠前"
/>
</div>
</div>
</Modal>
<Modal
isOpen={finishModalOpen}
onClose={() => setFinishModalOpen(false)}
title={finishModalMode === 'create' ? '新建饰面 / 花色' : '编辑饰面 / 花色'}
size="medium"
footer={
<div className="flex justify-end gap-2">
<Button variant="plain" size="small" onClick={() => setFinishModalOpen(false)}>
取消
</Button>
<Button size="small" onClick={handleSaveFinish} loading={submitting}>
{finishModalMode === 'create' ? '创建饰面' : '保存修改'}
</Button>
</div>
}
>
<div className="space-y-4">
<Input
label="名称"
value={finishForm.name}
onChange={(event) => setFinishForm((current) => ({ ...current, name: event.target.value }))}
placeholder="例如 白橡木纹"
/>
<Select
label="材质分类"
value={finishForm.categoryId}
onChange={(event) => setFinishForm((current) => ({ ...current, categoryId: event.target.value }))}
options={materialCategories.map((category) => ({ value: category.id, label: category.name }))}
placeholder="请选择分类"
/>
<div className="flex items-end gap-3">
<Input
label="颜色值"
value={finishForm.color}
onChange={(event) => setFinishForm((current) => ({ ...current, color: event.target.value }))}
placeholder="#CCCCCC"
className="flex-1"
/>
<div className="h-10 w-10 shrink-0 rounded-apple-md border border-apple-separator" style={{ backgroundColor: finishForm.color }} />
</div>
</div>
</Modal>
<DeleteConfirmDialog
isOpen={deleteTarget != null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleConfirmDelete}
title={
deleteTarget?.type === 'substrate'
? '删除基材'
: deleteTarget?.type === 'category'
? '删除材质分类'
: '删除饰面 / 花色'
}
description={`确认删除「${deleteTarget?.name ?? ''}」吗?删除后已引用规格款会自动回落为空值。`}
confirmText="确认删除"
/>
<ToastContainer toasts={toast.toasts} onClose={toast.removeToast} />
</>
)
}

View File

@ -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 (
<MaterialLibraryClient
initialSubstrates={substrates}
initialFinishes={finishes}
initialMaterialCategories={materialCategories}
metrics={[
{
label: '基材',
value: substrates.length,
icon: <Package className="h-4 w-4" />,
},
{
label: '材质分类',
value: materialCategories.length,
icon: <Layers3 className="h-4 w-4" />,
},
{
label: '饰面/花色',
value: finishes.length,
icon: <Palette className="h-4 w-4" />,
},
]}
/>
)
}

View File

@ -97,7 +97,7 @@ const ButtonComponent = React.forwardRef<HTMLButtonElement, ButtonProps>(
'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<HTMLButtonElement, ButtonProps>(
'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<HTMLButtonElement, ButtonProps>(
'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<HTMLButtonElement, ButtonProps>(
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}

View File

@ -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<MaterialFamily, MaterialPBRPreset> = {
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<MaterialFamily, MaterialPBRPreset> = {
}
const NAME_PRESETS: Record<string, NamedMaterialPreset> = {
'暖白橡木': { 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'

View File

@ -8,8 +8,10 @@ import type {
CreateAssetFamilyInput,
CreateAssetVariantInput,
CreateDraftGroupFromAutoCombinationInput,
UpdateSubstrateInput,
MaterialCategoryDTO,
CreateSubstrateInput,
SubstrateDTO,
DraftGroupDetailDTO,
FormalizeDraftGroupInput,
FormalizeDraftGroupResult,
@ -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<void> {
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<void> {
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<MaterialDTO> {
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<MaterialDTO> {
return requestPlatform(`/api/materials/${id}`, {
method: 'PUT',
body: JSON.stringify(input),
})
}
export async function deleteMaterial(id: string): Promise<void> {
await requestPlatform<{ material: MaterialDTO }>(`/api/materials/${id}`, {
method: 'DELETE',

View File

@ -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('标准饰面目录缺少默认演示花色配置')
})()

View File

@ -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<StandardFinishCategoryDefinition, 'thumbnailFolder'>,
material: Pick<StandardFinishMaterialDefinition, 'name'>,
): string {
return `/materials/thumbnails/${category.thumbnailFolder}/${sanitizeMaterialNameForPath(material.name)}.jpg`
}
export function buildStandardFinishMaterialData(params: {
material: StandardFinishMaterialDefinition
category: Pick<StandardFinishCategoryDefinition, 'name' | 'thumbnailFolder'>
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,
}
}

View File

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

View File

@ -32,6 +32,7 @@ import type {
CreateAssetVariantInput,
CreateSubstrateInput,
SubstrateDTO,
UpdateSubstrateInput,
UpdateAssetFamilyInput,
UpdateAssetVariantInput,
} from './asset-center.service.types'
@ -330,6 +331,59 @@ class AssetCenterService {
return toSubstrateDTO(created)
}
async updateSubstrate(id: string, input: UpdateSubstrateInput): Promise<SubstrateDTO> {
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<SubstrateDTO> {
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<AssetVariantDTO> {
const family = await assetFamilyRepository.findById(input.assetFamilyId)
if (!family) {
@ -787,4 +841,5 @@ export type {
SubstrateDTO,
UpdateAssetFamilyInput,
UpdateAssetVariantInput,
UpdateSubstrateInput,
} from './asset-center.service.types'

View File

@ -158,6 +158,15 @@ export interface CreateSubstrateInput {
createdBy?: string | null
}
export interface UpdateSubstrateInput {
code?: string
name?: string
description?: string | null
densityKgM3?: number | null
metadata?: Record<string, unknown> | null
isActive?: boolean
}
export interface AssetReviewActionInput {
reason?: string | null
}

View File

@ -163,6 +163,7 @@ export type {
SubstrateDTO,
UpdateAssetFamilyInput,
UpdateAssetVariantInput,
UpdateSubstrateInput,
} from './asset-center.service'
export { assetCadImportService, AssetCadImportService } from './asset-cad-import.service'
export type {

View File

@ -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<string, { id: string; name: string }>()
const materialByKey = new Map<string, { id: string; name: string }>()
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<string, DemoFami
function buildDemoVariantDefinitions(input: {
substrateId: string
finishId: string
finish: {
id: string
name: string
}
}): Record<string, DemoVariantDefinition> {
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<DemoAssetBundle> {
@ -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,
})