merge: sync latest main into codex/xuxiake-packaging

This commit is contained in:
Codex 2026-04-08 14:33:22 +08:00
commit 3436335b7d
26 changed files with 2120 additions and 1879 deletions

View File

@ -17,7 +17,7 @@
- [ ] **资产中心资产族新流程人工验收**
归属:`联调确认`
说明:确认资产中心现在已改成“先建资产族,再在资产族内新建规格款 / 发起 CAD 导入”的结构,用户理解是否自然,弹窗开关、列表刷新、导入完成后的落点是否符合预期。
说明:确认资产中心现在已改成"先建资产族,再在资产族内新建规格款 / 发起 CAD 导入;缺资料时跳转基础资料库补录"的结构,用户理解是否自然,弹窗开关、列表刷新、导入完成后的落点是否符合预期。
- [ ] **资产族品类系列 + 中文连接角色维护人工验收**
归属:`联调确认`
@ -26,3 +26,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,

File diff suppressed because it is too large Load Diff

View File

@ -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: () => <div>ModelUploader</div>,
}))
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(
<AssetCenterClient
initialFamilies={[]}
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',
@ -176,98 +171,13 @@ function renderClient() {
)
}
function renderClientWithBoardRule() {
return render(
<AssetCenterClient
initialFamilies={[
{
id: 'family-board-1',
type: 'BOARD',
code: 'BOARD-001',
name: '桌面板资产族',
description: null,
productCategoryId: null,
productCategoryName: null,
applicableSeriesCodes: ['single-board-desk'],
connectionRoles: ['desktop_surface'],
metadata: null,
status: 'DRAFT',
createdBy: 'user-1',
creatorName: 'Test User',
workflow: {} as never,
createdAt: new Date('2026-04-07T00:00:00.000Z'),
updatedAt: new Date('2026-04-07T00:00:00.000Z'),
},
] as AssetFamilyDTO[]}
initialVariants={[] as AssetVariantDTO[]}
initialSubstrates={[
{
id: 'substrate-board-1',
code: 'BOARD-DENSITY-E2-12-SPECIAL-ICE',
name: '12密度板',
description: '密度板 E2 12mm',
densityKgM3: null,
metadata: normalizeBoardRuleMetadata({
materialName: '密度板',
environmentalGrade: 'E2',
thicknessMm: 12,
veneerMode: 'single',
finishScope: 'SPECIAL',
finishNames: ['冰川白石纹色'],
sheetPrice: 48,
sheetWeightKg: 22.1,
}),
isActive: true,
createdAt: new Date('2026-04-07T00:00:00.000Z'),
updatedAt: new Date('2026-04-07T00:00:00.000Z'),
},
] as unknown as SubstrateDTO[]}
initialSeriesTemplates={[
{
id: 'template-1',
code: 'single-board-desk',
name: '单板桌',
seriesName: '云序',
},
] as SeriesTemplateDTO[]}
finishes={[
{
id: 'finish-ice-1',
name: '冰川白石纹色',
categoryId: 'cat-1',
categoryName: '板材花色',
thumbnailUrl: null,
textureUrl: null,
color: '#EFEFEC',
metalness: 0,
roughness: 0.5,
opacity: 1,
textureRepeat: 1,
textureRotation: 0,
textureSizeMm: 600,
isPreset: false,
isActive: true,
createdAt: new Date('2026-04-07T00:00:00.000Z'),
updatedAt: new Date('2026-04-07T00:00:00.000Z'),
},
] as unknown as MaterialDTO[]}
materialCategories={[{ id: 'cat-1', name: '板材花色', icon: 'Texture', sortOrder: 1 }]}
metrics={[]}
user={{
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
role: 'SUPER_ADMIN',
avatar: null,
department: null,
}}
/>,
)
}
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(
<AssetCenterClient
@ -374,7 +277,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',
@ -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()
})
})
})

View File

@ -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
}) => (
<a href={href} {...props}>
{children}
</a>
),
}))
jest.mock('@/components/ui/Modal', () => ({
__esModule: true,
default: ({
isOpen,
title,
children,
footer,
}: {
isOpen: boolean
title?: ReactNode
children: ReactNode
footer?: ReactNode
}) =>
isOpen ? (
<div role="dialog" aria-label={typeof title === 'string' ? title : undefined}>
<div>{title}</div>
<div>{children}</div>
<div>{footer}</div>
</div>
) : 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
}) => (
<div>
<div>{leftContent}</div>
<div>{rightContent}</div>
</div>
),
}))
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(
<MaterialLibraryClient
initialSubstrates={[{ id: 'substrate-1', code: 'PB18', name: '18mm 颗粒板' }] as SubstrateDTO[]}
initialFinishes={[
{
id: 'finish-1',
name: '白橡木纹',
categoryId: 'cat-1',
categoryName: '木纹',
thumbnailUrl: null,
textureUrl: null,
color: '#CCCCCC',
metalness: 0,
roughness: 0.5,
opacity: 1,
textureRepeat: 1,
textureRotation: 0,
textureSizeMm: 600,
isPreset: false,
isActive: true,
createdAt: new Date('2026-04-08T00:00:00.000Z'),
updatedAt: new Date('2026-04-08T00:00:00.000Z'),
},
] as unknown as MaterialDTO[]}
initialMaterialCategories={[
{ id: 'cat-1', name: '木纹', icon: 'Texture', sortOrder: 1, materialCount: 1 },
] as MaterialCategoryDTO[]}
metrics={[]}
/>,
)
}
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()
})
})

View File

@ -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<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, 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 (
<>
<PlatformSplitLayout
title="基础资料库"
description="这里专门维护基材、材质分类和饰面花色;CAD 导入与规格款弹窗只负责选值,不再承担资料维护。"
metrics={metrics}
rightPanelClassName="w-[580px] max-w-[52vw]"
leftContent={
<div className="flex h-full flex-col gap-5 rounded-apple-lg bg-apple-bg-secondary p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-xl font-apple-semibold text-apple-label-primary">
基础资料库
</h2>
<p className="mt-1.5 text-sm text-apple-label-secondary">
维护基材、分类和饰面花色,补录后返回资产中心继续操作。
</p>
</div>
<Link href="/asset-center">
<Button variant="gray" size="small">
<ArrowLeft className="h-3.5 w-3.5" />
返回资产中心
</Button>
</Link>
</div>
<p className="text-sm text-apple-label-secondary">
<span className="font-apple-medium text-apple-label-primary">{substrates.length}</span> 基材
<span className="mx-1.5 text-apple-separator">&middot;</span>
<span className="font-apple-medium text-apple-label-primary">{materialCategories.length}</span> 分类
<span className="mx-1.5 text-apple-separator">&middot;</span>
<span className="font-apple-medium text-apple-label-primary">{finishes.length}</span> 饰面
</p>
<p className="text-xs text-apple-label-tertiary leading-relaxed">
缺基材或饰面时先来这里补录,保存后回 CAD 导入或规格款弹窗继续操作。
</p>
</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-secondary 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' ? (
filteredSubstrates.length === 0 ? (
<div className="py-12 text-center">
<Package2 className="mx-auto h-8 w-8 text-apple-label-tertiary" />
<p className="mt-3 text-sm text-apple-label-secondary">暂无基材</p>
<p className="mt-1 text-xs text-apple-label-tertiary">点击上方「新建基材」添加第一条记录</p>
</div>
) : (
<div className="divide-y divide-apple-separator rounded-apple-lg border border-apple-separator bg-apple-bg-primary">
{filteredSubstrates.map((substrate) => (
<div key={substrate.id} className="flex items-center gap-3 px-4 py-3">
<p className="min-w-0 truncate text-sm font-apple-medium text-apple-label-primary">
{substrate.name}
</p>
<div className="ml-auto flex items-center gap-3 text-xs text-apple-label-tertiary">
<span>{substrate.code}</span>
{substrate.densityKgM3 ? <span>{substrate.densityKgM3} kg/m³</span> : null}
</div>
<button
type="button"
onClick={() => openEditSubstrateModal(substrate)}
className="rounded-full p-1.5 text-apple-label-secondary 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-secondary hover:bg-apple-bg-secondary hover:text-apple-red transition"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setSelectedCategoryId('all')}
className={cn(
'rounded-full border px-3 py-1.5 text-xs transition',
selectedCategoryId === 'all'
? 'border-apple-blue/30 bg-apple-blue/10 text-apple-blue'
: 'border-apple-separator bg-transparent text-apple-label-secondary hover:text-apple-label-primary',
)}
>
全部
</button>
{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/10 text-apple-blue'
: 'border-apple-separator bg-transparent text-apple-label-secondary hover:text-apple-label-primary',
)}
>
{category.name}
</button>
))}
</div>
{groupedFinishes.length === 0 ? (
<div className="py-12 text-center">
<SwatchBook className="mx-auto h-8 w-8 text-apple-label-tertiary" />
<p className="mt-3 text-sm text-apple-label-secondary">暂无饰面花色</p>
<p className="mt-1 text-xs text-apple-label-tertiary">点击上方「新建饰面」添加第一条记录</p>
</div>
) : (
<div className="space-y-4">
{groupedFinishes.map((group, groupIndex) => (
<div
key={group.category.id}
className={cn(groupIndex > 0 && 'border-t border-apple-separator pt-4')}
>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<p className="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>
{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-secondary 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-secondary 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-1.5">
{group.finishes.map((finish) => (
<div
key={finish.id}
className="flex items-center gap-3 rounded-apple-md px-3 py-2"
>
<div
className="h-6 w-6 shrink-0 rounded border border-apple-separator"
style={{ backgroundColor: finish.color }}
/>
<p className="min-w-0 flex-1 truncate text-sm text-apple-label-primary">
{finish.name}
</p>
<button
type="button"
onClick={() => openEditFinishModal(finish)}
className="rounded-full p-1.5 text-apple-label-secondary 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: 'finish', id: finish.id, name: finish.name })}
className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-red transition"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</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

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

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,
@ -168,7 +170,7 @@ export async function importAssetCad(input: ImportAssetCadPayload): Promise<Asse
}
export async function createSubstrate(input: CreateSubstrateInput): Promise<{
substrate: { id: string; code: string; name: string }
substrate: SubstrateDTO
}> {
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<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

@ -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<void> {
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<EnsureBoardLibraryDefaultsResult> {
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<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) {
@ -891,4 +982,5 @@ export type {
SubstrateDTO,
UpdateAssetFamilyInput,
UpdateAssetVariantInput,
UpdateSubstrateInput,
} from './asset-center.service.types'

View File

@ -164,6 +164,15 @@ export interface EnsureBoardLibraryDefaultsResult {
substrates: SubstrateDTO[]
}
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

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