feat: split asset material library from asset center
This commit is contained in:
parent
e1d78a6ecb
commit
29036152d1
@ -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 右键快捷菜单都能读到最新数据。
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
||||
169
furniture-3d-system/scripts/sync-standard-finishes.ts
Normal file
169
furniture-3d-system/scripts/sync-standard-finishes.ts
Normal 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()
|
||||
})
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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} />
|
||||
</>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
78
furniture-3d-system/src/lib/standard-finish-catalog.ts
Normal file
78
furniture-3d-system/src/lib/standard-finish-catalog.ts
Normal 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('标准饰面目录缺少默认演示花色配置')
|
||||
})()
|
||||
|
||||
66
furniture-3d-system/src/lib/standard-finish-sync.ts
Normal file
66
furniture-3d-system/src/lib/standard-finish-sync.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -163,6 +163,7 @@ export type {
|
||||
SubstrateDTO,
|
||||
UpdateAssetFamilyInput,
|
||||
UpdateAssetVariantInput,
|
||||
UpdateSubstrateInput,
|
||||
} from './asset-center.service'
|
||||
export { assetCadImportService, AssetCadImportService } from './asset-cad-import.service'
|
||||
export type {
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user