diff --git a/docs/Precision-Atelier设计系统改造记录.md b/docs/Precision-Atelier设计系统改造记录.md
new file mode 100644
index 00000000..a0b76c4c
--- /dev/null
+++ b/docs/Precision-Atelier设计系统改造记录.md
@@ -0,0 +1,214 @@
+# Precision Atelier 设计系统改造记录
+
+## 改动日期
+- 2026-04-02:视觉层改造(配色、字体、组件样式)
+- 2026-04-03:全局布局改造(AppShell 三栏布局 + 去重)
+
+## 背景
+基于 `方案设定/stitch/` 中的 17 份前端设计稿(solidjoin_3d_1~15 + solidjoin_precision + solidjoin_3d_inventory_2),将前端视觉风格从 "Apple HIG" 切换为设计稿指定的 "Precision Atelier" 风格。
+
+## 核心变更
+
+### 1. 配色体系(colors.ts)
+
+| 维度 | 旧值 | 新值 | 用途 |
+|------|------|------|------|
+| 品牌色 | `#008D74`(Teal) | `#546067`(Graphite) | 导航高亮、标题、CTA |
+| 次要色 | `#47C4AF` | `#4b626e`(Fog Blue) | 链接、辅助操作 |
+| 成功色 | `#34C759` | `#286c34`(Sage Green) | 成功状态、验证 |
+| 错误色 | `#FF3B30` | `#9f403d` | 错误提示 |
+| 主背景 | `#FFFFFF` | `#f9f9fb`(surface) | 页面底色 |
+| 侧边栏 | `#F5F5F7` | `#f2f4f6`(surface-container-low) | 侧边栏 |
+| 卡片/浮动面板 | `#FFFFFF` | `#ffffff`(surface-container-lowest) | 卡片 |
+| 二级工作区 | — | `#ebeef2`(surface-container) | 数据区域 |
+| 深色背景 | `#000000` / `#1C1C1E` | `#0c0e10` / `#1a1c1e` | 深色主题 |
+
+新增令牌:
+- `glassmorphismTokens` — 玻璃面板颜色(70% 白 + blur 20px)
+- `ambientShadowTokens` — 环境阴影(带 on-surface 色调 `rgba(45, 51, 56, 0.06)`)
+- `toastColors` — Toast 专用颜色
+
+### 2. 字体系统(typography.ts)
+
+双字体策略:
+- **展示字体**:Manrope — 用于品牌标题、大标题
+- **功能字体**:Inter — 用于数据、标签、表单、技术规格
+
+命名体系对照:
+| 旧名 | 新名 | 用途 |
+|------|------|------|
+| largeTitle | displayLg (3.5rem) | 品牌大标题 |
+| title1 | displayMd (2.5rem) | 展示标题 |
+| title2 | headlineLg (1.75rem) | 页面标题 |
+| title3 | headlineMd (1.5rem) | 区块标题 |
+| headline | headlineSm (1.25rem) | 小标题 |
+| body | bodyMd (0.875rem) | 正文 |
+| callout | bodyLg (1rem) | 大正文 |
+| subhead | bodySm (0.75rem) | 小正文 |
+| footnote | bodySm (0.75rem) | 注释 |
+| caption1 | bodySm (0.75rem) | 小标签 |
+| caption2 | labelSm (0.6875rem) | 微标签 |
+
+兼容处理:`legacyTextStyleMap` 映射旧名到新名,`CompatibleTextStyle` 类型同时接受新旧名。
+
+### 3. 全局样式(globals.css)
+
+- **@theme 块**:颜色变量全部替换为 Precision Atelier 配色
+- **字间距**:展示标题统一 `letter-spacing: -0.02em`,技术标签 `0.05em`
+- **圆角**:改为紧凑风格(0.375rem ~ 1rem)
+- **过渡**:200ms ease-out("Snappy but Silky")
+- **阴影**:环境阴影 `0px 4px 32px rgba(45, 51, 56, 0.06)`
+- **Ghost Border**:`rgba(172, 179, 184, 0.3)` 用于数据网格分隔
+- **深色主题**:完整的深色变量覆盖
+- **Glassmorphism**:70% 白/深 + 20px blur
+- **滑块**:品牌色从 `#008D74` 改为 `#546067`,深色模式对应
+
+### 4. UI 组件改动
+
+**Button.tsx**
+- filled 变体:纯色背景改为 145° 渐变 `linear-gradient(145deg, var(--color-blue), var(--color-teal))`
+
+**Typography.tsx**
+- `variant` prop 类型改为 `CompatibleTextStyle`,兼容旧名(caption1/body/headline 等)
+- `fontType` 新增 `display` 选项,支持 Manrope 展示字体
+
+### 5. 硬编码颜色替换
+
+~20 个业务文件中的 82 处 `#008D74` 硬编码替换为 CSS 变量引用。
+
+### 6. 兼容性处理
+
+- `appleColorTokens`、`appleSemanticColors`、`appleToastColors`:旧名兼容导出
+- `appleTypographyTokens`:旧名兼容导出
+- `appleAnimationTokens`:保持原名不变
+- `precisionAtelierSpacingTokens`:新名,旧函数签名不变
+
+## 设计原则
+
+1. **No-Line Rule**:不用 1px 边框,通过 surface 层级的 hex 差异产生视觉分离
+2. **Glassmorphism**:浮动面板用 70% 白 + 20px blur
+3. **环境阴影**:仅用于浮动元素,颜色带 on-surface 色调
+4. **Snappy but Silky**:200ms ease-out 过渡
+5. **Ghost Border**:数据网格用 0.5px `outline-variant` @ 30% 透明度
+6. **CTA 渐变**:primary → primary-dim,145° 角,"machined metal" 质感
+
+## 改动文件清单
+
+| 文件 | 改动类型 |
+|------|----------|
+| `src/lib/design-tokens/colors.ts` | 全部重写 |
+| `src/lib/design-tokens/typography.ts` | 全部重写 |
+| `src/lib/design-tokens/spacing.ts` | 重命名+圆角调整 |
+| `src/lib/design-tokens/index.ts` | 注释更新 |
+| `src/app/globals.css` | 颜色/字体/阴影全部替换 |
+| `src/components/ui/Button.tsx` | CTA 渐变效果 |
+| `src/components/ui/Typography.tsx` | 兼容旧名 variant |
+| ~20 个业务文件 | 硬编码颜色替换 |
+
+---
+
+## 第二阶段:全局布局改造(2026-04-03)
+
+### 7. AppShell 三栏布局
+
+新增全局布局框架,所有常规页面统一使用三栏结构:
+
+```
+┌─────────────────────────────────────────────────┐
+│ AppTopNav(h-14,Glassmorphism 毛玻璃顶栏) │
+│ Logo + 搜索 + 通知 + 主题切换 + 用户菜单 │
+├──────────┬──────────────────────────────────────┤
+│ AppSideNav│ 主内容区(ml-56 mt-14) │
+│ w-56 │ │
+│ │ PlatformSplitLayout 等页面组件 │
+│ 导航链接 │ │
+│ 新建配置 │ │
+│ 技术支持 │ │
+│ 归档 │ │
+└──────────┴──────────────────────────────────────┘
+```
+
+**新增文件:**
+- `src/components/layout/AppTopNav.tsx` — 固定顶栏(Logo + 搜索 + 工具按钮)
+- `src/components/layout/AppSideNav.tsx` — 固定左侧导航(5 个导航项 + 底部操作区)
+- `src/components/layout/AppShell.tsx` — 全局布局容器,组合 TopNav + SideNav + main
+
+**排除页面:** `/login`、`/first-login`、`/debug-theme`、`/ui-preview` 不使用 AppShell。
+
+**AppSideNav 导航项:**
+| 路由 | 标签 | 图标 |
+|------|------|------|
+| `/` | 首页概览 | Home |
+| `/asset-center` | 资产中心 | Boxes |
+| `/auto-combinations` | 自动组合 | Layers |
+| `/draft-groups` | 草稿组 | PackageOpen |
+| `/warehouse` | 仓库管理 | Warehouse |
+
+### 8. 功能去重
+
+引入 AppShell 后,各页面中与全局导航重复的元素已清理:
+
+**去重 1:顶栏导航 tab 移除**
+- AppTopNav 原本有 5 个导航 tab(首页/资产中心/自动组合/草稿组/仓库),与 AppSideNav 完全重复
+- 已移除顶栏中的导航 tab,顶栏只保留:Logo、搜索框、通知铃铛、主题切换、用户菜单
+- 导航职责统一由左侧边栏承担
+
+**去重 2:页面内 headerTools(ThemeToggle + UserMenu)移除**
+以下 7 个文件移除了 `headerTools={}` 传参:
+- `src/app/asset-center/AssetCenterClient.tsx`
+- `src/app/auto-combinations/page.tsx`(服务端)+ `AutoCombinationsClient.tsx`(客户端)
+- `src/app/draft-groups/DraftGroupsClient.tsx`
+- `src/app/draft-groups/[id]/DraftGroupDetailClient.tsx`
+- `src/app/intake/IntakeClient.tsx`
+- `src/app/warehouse/WarehouseClient.tsx`
+- `src/app/HomeClient.workspace.tsx`
+
+**去重 3:eyebrow 副标题移除**
+各页面中 `eyebrow` prop(如"主链起点"、"系列模板驱动"、"中台承接页"等)已全部移除。
+侧边栏已有清晰的页面标识,无需重复。
+
+### 9. Platform 组件适配
+
+**PlatformPageFrame.tsx**
+- 移除:radial-gradient 背景、rounded-[28px] 卡片包装、liquid-glass-enhanced 头部
+- 现在:简洁的内联布局,Manrope 标题 + 内联 eyebrow badge + PlatformFlowNav
+
+**PlatformSplitLayout.tsx**
+- 移除:独立的 sticky 头部(含 Card、返回按钮、eyebrow)
+- 适配:`h-[calc(100vh-56px)]` 填满 AppShell 主内容区
+- 右侧面板:w-80(320px),surface-container-lowest 背景
+- `eyebrow` 和 `headerTools` 保留为 `@deprecated` props(解构后静默忽略),确保旧代码不报错
+
+## 最终布局职责划分
+
+| 区域 | 组件 | 职责 |
+|------|------|------|
+| 顶栏 | AppTopNav | Logo、搜索、通知、主题切换、用户菜单 |
+| 侧边栏 | AppSideNav | 5 个导航链接、新建配置按钮、技术支持、归档 |
+| 主内容区 | 各页面组件 | 只负责业务内容,不再自带导航/头部工具 |
+
+## 改动文件清单(完整)
+
+| 文件 | 改动类型 |
+|------|----------|
+| `src/lib/design-tokens/colors.ts` | 全部重写 |
+| `src/lib/design-tokens/typography.ts` | 全部重写 |
+| `src/lib/design-tokens/spacing.ts` | 重命名+圆角调整 |
+| `src/app/globals.css` | 颜色/字体/阴影全部替换 |
+| `src/components/ui/Button.tsx` | CTA 渐变效果 |
+| `src/components/ui/Typography.tsx` | 兼容旧名 variant |
+| `src/components/layout/AppTopNav.tsx` | **新增**:全局顶栏 |
+| `src/components/layout/AppSideNav.tsx` | **新增**:全局侧边导航 |
+| `src/components/layout/AppShell.tsx` | **新增**:全局布局容器 |
+| `src/app/layout.tsx` | 引入 AppShell 包裹 |
+| `src/components/platform/PlatformPageFrame.tsx` | 简化为内联布局 |
+| `src/components/platform/PlatformSplitLayout.tsx` | 适配 AppShell + deprecated props |
+| `src/app/asset-center/AssetCenterClient.tsx` | 移除 headerTools/eyebrow |
+| `src/app/auto-combinations/page.tsx` | 移除 headerTools 传参 |
+| `src/app/auto-combinations/AutoCombinationsClient.tsx` | headerTools 改为可选 |
+| `src/app/draft-groups/DraftGroupsClient.tsx` | 移除 headerTools/eyebrow |
+| `src/app/draft-groups/[id]/DraftGroupDetailClient.tsx` | 移除 headerTools/eyebrow(2 处) |
+| `src/app/intake/IntakeClient.tsx` | 移除 headerTools/eyebrow(2 处) |
+| `src/app/warehouse/WarehouseClient.tsx` | 移除 headerTools/eyebrow |
+| `src/app/HomeClient.workspace.tsx` | 移除 topTools 传参 |
+| ~20 个业务文件 | 硬编码颜色替换 |
diff --git a/docs/前端UI改动清单.md b/docs/前端UI改动清单.md
index 5b767c97..3bc95ff3 100644
--- a/docs/前端UI改动清单.md
+++ b/docs/前端UI改动清单.md
@@ -13,31 +13,16 @@
## 当前未完成项
-### 3D 视图与 UI 融合体验
+### 待联调确认 (全栈 / 后端 Agent 负责)
-- [ ] ModelPreviewPanel 控制栏按钮事件绑定 (Camera 控制逻辑)
+- [ ] **资产中心资产族新流程人工验收**
归属:`联调确认`
- 说明:前端 UI 已完成左侧相机控制按钮(Rotate, Move, Zoom, Front, Top, Iso)的样式布局。需要后端/联调 Agent 接入 Three.js / R3F 的上下文,实现点击这些按钮时相机的实际切换和参数调整(例如切换正交/透视、缓动切换视角、启用/禁用 OrbitControls 的特定轴向)。
+ 说明:确认资产中心现在已改成“先建资产族,再在资产族内新建规格款 / 发起 CAD 导入”的结构,用户理解是否自然,弹窗开关、列表刷新、导入完成后的落点是否符合预期。
-### 苹果风格视觉标准深度核查 (去 AI 味专项)
-
-- [ ] 检查全局是否有默认图标
- 归属:`前端负责`
- 说明:全局排查各个页面的按钮、菜单项是否还在使用原始默认占位符,需要全部更换为 lucide-react 细线条风格 (Stroke width 1.5px-2px)。
-- [ ] 按钮风格统一核查
- 归属:`前端负责`
- 说明:确保项目中所有 Button 均符合 Pill shape 或 Rounded(8px) 规范。移除复杂的彩虹渐变或高饱和度颜色按钮。
-- [ ] Slider / 范围选择器核查
- 归属:`前端负责`
- 说明:全局核查是否还有在使用原生 `input[type="range"]` 的情况,如果有,需全部替换为 `src/components/ui/Slider.tsx`。
-
-### 待联调确认
-
-- [ ] 包材资料库页面视觉是否已统一到主线风格
+- [ ] **资产族品类系列 + 中文连接角色维护人工验收**
归属:`联调确认`
+ 说明:已改成在资产族弹窗内选择品类系列,并维护中文连接角色(支持新增 / 编辑 / 删除)。需重点确认:系列切换后推荐角色是否合理、旧数据回显是否稳定、保存后列表和 CAD 导入提示是否一致。
-- [ ] 登录页深色模式下颜色是否正确
- 归属:`联调确认`
-
-- [ ] CAD 导入弹窗自动带出命名交互是否清晰
+- [ ] **包材资料库页面视觉联调**
归属:`联调确认`
+ 说明:确认该页面视觉是否已统一到主线风格。
diff --git a/furniture-3d-system/scripts/package-wyj-test-deploy.ps1 b/furniture-3d-system/scripts/package-wyj-test-deploy.ps1
new file mode 100644
index 00000000..40284027
--- /dev/null
+++ b/furniture-3d-system/scripts/package-wyj-test-deploy.ps1
@@ -0,0 +1,52 @@
+$ErrorActionPreference = 'Stop'
+
+$repoRoot = Split-Path -Parent $PSScriptRoot
+$defaultOutput = 'D:\tmp-codex\deploy-furniture-wyj-test-20260407-181637.tar.gz'
+$outputPath = if ($args.Count -gt 0 -and $args[0]) { $args[0] } else { $defaultOutput }
+
+$outputDir = Split-Path -Parent $outputPath
+if (-not (Test-Path $outputDir)) {
+ New-Item -ItemType Directory -Path $outputDir | Out-Null
+}
+
+$excludeArgs = @(
+ "--exclude=furniture-3d-system/node_modules",
+ "--exclude=furniture-3d-system/.next",
+ "--exclude=furniture-3d-system/.git",
+ "--exclude=furniture-3d-system/.planning",
+ "--exclude=furniture-3d-system/.codex",
+ "--exclude=furniture-3d-system/test-results",
+ "--exclude=furniture-3d-system/local-packages",
+ "--exclude=furniture-3d-system/backups",
+ "--exclude=furniture-3d-system/temp",
+ "--exclude=furniture-3d-system/temp-test",
+ "--exclude=furniture-3d-system/test-html",
+ "--exclude=furniture-3d-system/tests",
+ "--exclude=furniture-3d-system/docs",
+ "--exclude=furniture-3d-system/.cursor",
+ "--exclude=furniture-3d-system/.swc",
+ "--exclude=furniture-3d-system/.env",
+ "--exclude=furniture-3d-system/.env.test",
+ "--exclude=furniture-3d-system/.dev-*.log",
+ "--exclude=furniture-3d-system/.next-dev*.log",
+ "--exclude=furniture-3d-system/coverage",
+ "--exclude=furniture-3d-system/furniture-3d-system"
+)
+
+if (Test-Path $outputPath) {
+ Remove-Item -LiteralPath $outputPath -Force
+}
+
+& tar @excludeArgs -czf $outputPath -C (Split-Path -Parent $repoRoot) furniture-3d-system
+
+if ($LASTEXITCODE -ne 0) {
+ throw "部署包生成失败: $outputPath"
+}
+
+$archive = Get-Item -LiteralPath $outputPath
+[pscustomobject]@{
+ Archive = $archive.FullName
+ SizeBytes = $archive.Length
+ SizeMB = [math]::Round($archive.Length / 1MB, 2)
+ UpdatedAt = $archive.LastWriteTime
+}
diff --git a/furniture-3d-system/src/app/api/material-categories/route.ts b/furniture-3d-system/src/app/api/material-categories/route.ts
index 1f6cc1b7..1d6e874b 100644
--- a/furniture-3d-system/src/app/api/material-categories/route.ts
+++ b/furniture-3d-system/src/app/api/material-categories/route.ts
@@ -1,12 +1,24 @@
/**
* 材质分类 API
* GET /api/material-categories - 获取所有材质分类
+ * POST /api/material-categories - 创建材质分类
*/
import { NextRequest } from 'next/server'
-import { handleRequest, jsonSuccess } from '@/server/core/response'
+import { z } from 'zod'
+import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response'
+import { AppError, ErrorCode } from '@/server/core/errors'
import { materialService } from '@/server/services'
+const ADMIN_ROLES = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] as const
+
+const createMaterialCategorySchema = z.object({
+ name: z.string().trim().min(1, '请填写分类名称').max(50, '分类名称不能超过 50 个字符'),
+ icon: z.string().trim().max(50, '图标标识不能超过 50 个字符').optional().nullable(),
+ sortOrder: z.number().int().min(0).optional(),
+ isActive: z.boolean().optional(),
+})
+
export async function GET(request: NextRequest) {
return handleRequest(
request,
@@ -17,3 +29,39 @@ export async function GET(request: NextRequest) {
{ requireAuth: false }
)
}
+
+export async function POST(request: NextRequest) {
+ return handleRequest(
+ request,
+ async (ctx) => {
+ const body = await request.json().catch(() => null)
+ const parsed = createMaterialCategorySchema.safeParse(body)
+
+ if (!parsed.success) {
+ return jsonError(
+ ErrorCode.VALIDATION_FAILED,
+ parsed.error.issues[0]?.message || '材质分类参数不正确',
+ 400,
+ ctx.requestId,
+ )
+ }
+
+ try {
+ const category = await materialService.createCategory({
+ name: parsed.data.name,
+ icon: parsed.data.icon || undefined,
+ sortOrder: parsed.data.sortOrder,
+ isActive: parsed.data.isActive,
+ })
+
+ return jsonSuccess({ category }, 201, ctx.requestId)
+ } catch (error) {
+ if (error instanceof AppError) {
+ return jsonError(error.code, error.message, 400, ctx.requestId)
+ }
+ throw error
+ }
+ },
+ { allowedRoles: [...ADMIN_ROLES] },
+ )
+}
diff --git a/furniture-3d-system/src/app/api/materials/[id]/route.ts b/furniture-3d-system/src/app/api/materials/[id]/route.ts
index c5ad3d69..fdd39848 100644
--- a/furniture-3d-system/src/app/api/materials/[id]/route.ts
+++ b/furniture-3d-system/src/app/api/materials/[id]/route.ts
@@ -1,21 +1,32 @@
-/**
- * 单个材质 API
- * GET /api/materials/[id] - 获取材质详情
- */
-
import { NextRequest } from 'next/server'
-import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response'
-import { materialService } from '@/server/services'
import { AppError } from '@/server/core/errors'
+import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
+import { materialService } from '@/server/services'
-export async function GET(
- request: NextRequest,
- { params }: { params: Promise<{ id: string }> }
-) {
+interface RouteParams {
+ params: Promise<{
+ id: string
+ }>
+}
+
+export async function DELETE(request: NextRequest, context: RouteParams) {
return handleRequest(
request,
async (ctx) => {
- const { id } = await params
+ const { id } = await context.params
+ const material = await materialService.findById(id)
+ await materialService.delete(id)
+ return jsonSuccess({ material }, 200, ctx.requestId)
+ },
+ { requireAuth: true },
+ )
+}
+
+export async function GET(request: NextRequest, context: RouteParams) {
+ return handleRequest(
+ request,
+ async (ctx) => {
+ const { id } = await context.params
try {
const material = await materialService.findById(id)
@@ -27,6 +38,6 @@ export async function GET(
throw error
}
},
- { requireAuth: false }
+ { requireAuth: false },
)
}
diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx
index 371a3d7d..a8f6e311 100644
--- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx
+++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx
@@ -6,6 +6,7 @@ import type {
AssetFamilyDTO,
AssetVariantDTO,
MaterialDTO,
+ SeriesTemplateDTO,
SubstrateDTO,
} from '@/server/services'
@@ -148,6 +149,15 @@ const user = {
department: null,
}
+const seriesTemplates = [
+ {
+ id: 'template-1',
+ code: 'single-board-desk',
+ name: '单板桌',
+ seriesName: '云序',
+ },
+] as SeriesTemplateDTO[]
+
describe('AssetCenterClient', () => {
it('包装页保留包材资料库入口,但不重复展示包材资料库里的业务分类卡片', async () => {
const actor = userEvent.setup()
@@ -167,6 +177,7 @@ describe('AssetCenterClient', () => {
]}
initialVariants={[] as AssetVariantDTO[]}
initialSubstrates={[] as SubstrateDTO[]}
+ initialSeriesTemplates={seriesTemplates}
finishes={[] as MaterialDTO[]}
materialCategories={[]}
metrics={[]}
diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx
index 3f24462b..a67bd25f 100644
--- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx
+++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx
@@ -1,6 +1,6 @@
'use client'
-import { startTransition, useState, useMemo } from 'react'
+import { startTransition, useState, useMemo, useCallback, useEffect } from 'react'
import Link from 'next/link'
import {
ArrowRight,
@@ -24,19 +24,37 @@ import Checkbox from '@/components/ui/Checkbox'
import Input, { Textarea } from '@/components/ui/Input'
import Modal from '@/components/ui/Modal'
import Select from '@/components/ui/Select'
+import {
+ calculateMenuPosition,
+ ContextMenu,
+ ContextSubmenu,
+ type ContextMenuGroup,
+ type ContextMenuItem,
+} from '@/components/ui/ContextMenu'
import { DeleteConfirmDialog } from '@/components/ui/DeleteConfirmDialog'
import { useToast, ToastContainer } from '@/components/ui/Toast'
import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel'
import { PlatformSplitLayout } from '@/components/platform/PlatformSplitLayout'
import type { PlatformMetricItem } from '@/components/platform/types'
import { inferCadImportDefaults } from '@/lib/asset-cad-import-defaults'
+import {
+ buildConnectionRoleMetadata,
+ createCustomConnectionRoleValue,
+ getStandardConnectionRoleDefinitions,
+ mergeConnectionRoleDefinitions,
+ normalizeConnectionRoles,
+ resolveConnectionRoleLabel,
+ type ConnectionRoleDefinition,
+} from '@/lib/open-product/connection-role-catalog'
import { getLevel1FromFamily } from '@/lib/packaging-materials/catalog'
import { cn } from '@/lib/utils'
import {
createAssetFamily,
createAssetVariant,
+ createMaterialCategory,
createSubstrate,
deleteAssetFamily,
+ deleteMaterial,
deleteAssetVariant,
importAssetCad,
updateAssetFamily,
@@ -51,6 +69,7 @@ import type {
AssetCadImportResult,
AssetFamilyDTO,
AssetVariantDTO,
+ SeriesTemplateDTO,
SubstrateDTO,
} from '@/server/services'
@@ -60,6 +79,7 @@ interface AssetCenterClientProps {
initialFamilies: AssetFamilyDTO[]
initialVariants: AssetVariantDTO[]
initialSubstrates: SubstrateDTO[]
+ initialSeriesTemplates: SeriesTemplateDTO[]
finishes: MaterialDTO[]
materialCategories: MaterialCategoryDTO[]
metrics: PlatformMetricItem[]
@@ -79,6 +99,71 @@ function sortByName(items: T[]): T[] {
return [...items].sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
}
+function sortMaterialCategories(items: MaterialCategoryDTO[]): MaterialCategoryDTO[] {
+ return [...items].sort((left, right) => {
+ const orderDiff = (left.sortOrder ?? 0) - (right.sortOrder ?? 0)
+ if (orderDiff !== 0) {
+ return orderDiff
+ }
+ return left.name.localeCompare(right.name, 'zh-CN')
+ })
+}
+
+function isFamilyCompatibleWithSeries(
+ family: AssetFamilyDTO,
+ seriesCode: string | null | undefined,
+): boolean {
+ if (!seriesCode) {
+ return true
+ }
+
+ if (!family.applicableSeriesCodes || family.applicableSeriesCodes.length === 0) {
+ return true
+ }
+
+ return family.applicableSeriesCodes.includes(seriesCode)
+}
+
+function groupFinishesByCategory(
+ finishes: MaterialDTO[],
+ materialCategories: MaterialCategoryDTO[],
+) {
+ const categoryById = new Map(materialCategories.map((category) => [category.id, category]))
+ const grouped = new Map<
+ string,
+ {
+ id: string
+ name: string
+ sortOrder: number
+ finishes: MaterialDTO[]
+ }
+ >()
+
+ finishes.forEach((finish) => {
+ const category = categoryById.get(finish.categoryId)
+ const key = finish.categoryId || 'uncategorized'
+ const current = grouped.get(key) ?? {
+ id: key,
+ name: category?.name ?? finish.categoryName ?? '未分类',
+ sortOrder: category?.sortOrder ?? Number.MAX_SAFE_INTEGER,
+ finishes: [],
+ }
+ current.finishes.push(finish)
+ grouped.set(key, current)
+ })
+
+ return Array.from(grouped.values())
+ .map((group) => ({
+ ...group,
+ finishes: sortByName(group.finishes),
+ }))
+ .sort((left, right) => {
+ const orderDiff = left.sortOrder - right.sortOrder
+ if (orderDiff !== 0) return orderDiff
+ return left.name.localeCompare(right.name, 'zh-CN')
+ })
+}
+
function toNumberRecord(values: Record): Record {
const result: Record = {}
for (const [key, value] of Object.entries(values)) {
@@ -204,8 +289,6 @@ type CadImportFieldKey =
| 'variantName'
| 'substrateId'
| 'finishId'
- | 'weightKg'
- | 'costPrice'
| 'file'
type CadImportFieldErrors = Partial>
@@ -217,10 +300,6 @@ function splitConnectionRoles(value: string): string[] {
.filter(Boolean)
}
-function hasValidRequiredNumber(value: string): boolean {
- return value.trim().length > 0 && Number.isFinite(Number(value))
-}
-
function buildCadImportFieldErrors(params: {
form: CadImportForm
selectedFamily: AssetFamilyDTO | null
@@ -260,13 +339,6 @@ function buildCadImportFieldErrors(params: {
if (!form.variantName.trim()) {
errors.variantName = '请填写规格款名称'
}
- if (!hasValidRequiredNumber(form.weightKg)) {
- errors.weightKg = '请填写有效重量'
- }
- if (!hasValidRequiredNumber(form.costPrice)) {
- errors.costPrice = '请填写有效成本价'
- }
-
if (form.assetType === 'BOARD') {
if (!form.substrateId) {
errors.substrateId = '板材必须选择基材'
@@ -310,8 +382,9 @@ export default function AssetCenterClient({
initialFamilies,
initialVariants,
initialSubstrates,
- finishes,
- materialCategories,
+ initialSeriesTemplates,
+ finishes: initialFinishes,
+ materialCategories: initialMaterialCategories,
metrics,
user: _user,
}: AssetCenterClientProps) {
@@ -322,6 +395,10 @@ export default function AssetCenterClient({
const [families, setFamilies] = useState(sortByName(initialFamilies))
const [variants, setVariants] = useState(initialVariants)
const [substrates, setSubstrates] = useState(sortByName(initialSubstrates))
+ const [finishes, setFinishes] = useState(initialFinishes)
+ const [materialCategories, setMaterialCategories] = useState(
+ sortMaterialCategories(initialMaterialCategories),
+ )
const [selectedVariantId, setSelectedVariantId] = useState(null)
const [submitting, setSubmitting] = useState(false)
@@ -342,10 +419,16 @@ export default function AssetCenterClient({
code: '',
name: '',
description: '',
- roles: '',
+ applicableSeriesCode: initialSeriesTemplates[0]?.code ?? '',
+ selectedRoles: [] as string[],
available: false,
})
+ const [familyRoleDefinitions, setFamilyRoleDefinitions] = useState([])
const [editingFamilyId, setEditingFamilyId] = useState(null)
+ const [roleEditorOpen, setRoleEditorOpen] = useState(false)
+ const [roleEditorMode, setRoleEditorMode] = useState<'create' | 'edit'>('create')
+ const [editingRoleValue, setEditingRoleValue] = useState(null)
+ const [roleEditorLabel, setRoleEditorLabel] = useState('')
// 删除目标状态
const [deleteTarget, setDeleteTarget] = useState<{
@@ -362,6 +445,11 @@ export default function AssetCenterClient({
// 饰面/花色新建弹窗
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)
@@ -376,10 +464,57 @@ export default function AssetCenterClient({
const [cadImportResult, setCadImportResult] = useState(null)
const [cadImportParsing, setCadImportParsing] = useState(false)
const [autoInferredFields, setAutoInferredFields] = useState>(new Set())
+ const [previewNodeNames, setPreviewNodeNames] = useState([])
+ const [selectedPreviewNodeName, setSelectedPreviewNodeName] = useState(null)
+ const [previewContextMenuState, setPreviewContextMenuState] = useState<{
+ visible: boolean
+ x: number
+ y: number
+ nodeName: string | null
+ }>({
+ visible: false,
+ x: 0,
+ y: 0,
+ nodeName: null,
+ })
+ const [activePreviewSubmenu, setActivePreviewSubmenu] = useState(null)
+ const [activeFinishCategoryId, setActiveFinishCategoryId] = useState(null)
// ---- 派生数据 ----
const selectedVariant = variants.find((v) => v.id === selectedVariantId) ?? null
+ const editingFamily = useMemo(
+ () => families.find((family) => family.id === editingFamilyId) ?? null,
+ [editingFamilyId, families],
+ )
+ const seriesTemplates = initialSeriesTemplates
+ const seriesTemplateByCode = useMemo(
+ () => new Map(seriesTemplates.map((template) => [template.code, template])),
+ [seriesTemplates],
+ )
+ const seriesTemplateOptions = useMemo(
+ () =>
+ seriesTemplates.map((template) => ({
+ value: template.code,
+ label: `${template.name} · ${template.seriesName}`,
+ })),
+ [seriesTemplates],
+ )
+ const finishGroups = useMemo(
+ () => groupFinishesByCategory(finishes, materialCategories),
+ [finishes, materialCategories],
+ )
+ const selectedFinish = useMemo(
+ () => finishes.find((finish) => finish.id === selectedVariant?.finishId) ?? null,
+ [finishes, selectedVariant?.finishId],
+ )
+ const previewMaterialAssignments = useMemo(() => {
+ if (!selectedFinish || previewNodeNames.length === 0) return []
+ return previewNodeNames.map((nodeName) => ({
+ nodeName,
+ material: selectedFinish,
+ }))
+ }, [previewNodeNames, selectedFinish])
const currentFamilies = useMemo(() => {
const filtered = getVisibleFamiliesForTab(families, activeTab)
@@ -392,17 +527,6 @@ export default function AssetCenterClient({
)
}, [families, activeTab, searchText])
- const cadImportFamilyOptions = useMemo(
- () =>
- families
- .filter((family) => family.type === cadImportForm.assetType)
- .map((family) => ({
- value: family.id,
- label: `${family.name} · ${family.code}`,
- })),
- [families, cadImportForm.assetType],
- )
-
const selectedCadImportFamily = useMemo(
() => families.find((family) => family.id === cadImportForm.familyId) ?? null,
[families, cadImportForm.familyId],
@@ -444,14 +568,18 @@ export default function AssetCenterClient({
})
}
- function openCadImportModal() {
- if (!isImportableAssetType(activeTab)) {
+ function openCadImportModal(family: AssetFamilyDTO) {
+ if (!isImportableAssetType(family.type)) {
toast.warning('包装资产暂不支持 CAD 导入')
return
}
- const hasExistingFamilies = families.some((family) => family.type === activeTab)
- setCadImportForm(emptyCadImportForm(activeTab, hasExistingFamilies))
+ const nextForm = emptyCadImportForm(family.type, true)
+ setCadImportForm({
+ ...nextForm,
+ familyMode: 'existing',
+ familyId: family.id,
+ })
setCadImportSubmitAttempted(false)
setCadImportResult(null)
setAutoInferredFields(new Set())
@@ -465,8 +593,11 @@ export default function AssetCenterClient({
}
function prepareNextCadImport() {
- const hasExistingFamilies = families.some((family) => family.type === cadImportForm.assetType)
- setCadImportForm(emptyCadImportForm(cadImportForm.assetType, hasExistingFamilies))
+ setCadImportForm((current) => ({
+ ...emptyCadImportForm(current.assetType, true),
+ familyMode: 'existing',
+ familyId: current.familyId,
+ }))
setCadImportSubmitAttempted(false)
setCadImportResult(null)
setAutoInferredFields(new Set())
@@ -529,31 +660,137 @@ export default function AssetCenterClient({
// ---- 资产族操作 ----
function openCreateFamilyModal() {
+ const defaultSeriesCode = initialSeriesTemplates[0]?.code ?? ''
setFamilyModalMode('create')
- setFamilyForm({ type: activeTab, code: '', name: '', description: '', roles: '', available: false })
+ setFamilyForm({
+ type: activeTab,
+ code: '',
+ name: '',
+ description: '',
+ applicableSeriesCode: defaultSeriesCode,
+ selectedRoles: [],
+ available: false,
+ })
+ setFamilyRoleDefinitions(
+ mergeConnectionRoleDefinitions({
+ assetType: activeTab,
+ seriesCode: defaultSeriesCode,
+ }),
+ )
setFamilyModalOpen(true)
}
function openEditFamilyModal(family: AssetFamilyDTO) {
+ const applicableSeriesCode = family.applicableSeriesCodes[0] ?? ''
+ const normalizedRoles = normalizeConnectionRoles(family.connectionRoles)
setFamilyModalMode('edit')
setEditingFamilyId(family.id)
setFamilyForm({
+ type: family.type,
code: family.code,
name: family.name,
description: family.description ?? '',
- roles: family.connectionRoles.join(', '),
+ applicableSeriesCode,
+ selectedRoles: normalizedRoles,
available: family.status === 'AVAILABLE',
- type: family.type,
})
+ setFamilyRoleDefinitions(
+ mergeConnectionRoleDefinitions({
+ assetType: family.type,
+ seriesCode: applicableSeriesCode,
+ selectedRoles: normalizedRoles,
+ metadata: family.metadata,
+ }),
+ )
setFamilyModalOpen(true)
}
function closeFamilyModal() {
setFamilyModalOpen(false)
setEditingFamilyId(null)
+ setRoleEditorOpen(false)
+ setEditingRoleValue(null)
+ setRoleEditorLabel('')
+ }
+
+ function openCreateRoleEditor() {
+ setRoleEditorMode('create')
+ setEditingRoleValue(null)
+ setRoleEditorLabel('')
+ setRoleEditorOpen(true)
+ }
+
+ function openEditRoleEditor(definition: ConnectionRoleDefinition) {
+ setRoleEditorMode('edit')
+ setEditingRoleValue(definition.value)
+ setRoleEditorLabel(definition.label)
+ setRoleEditorOpen(true)
+ }
+
+ function handleSaveRoleDefinition() {
+ const label = roleEditorLabel.trim()
+ if (!label) {
+ toast.error('请填写连接角色名称')
+ return
+ }
+
+ if (roleEditorMode === 'create') {
+ const value = createCustomConnectionRoleValue({
+ label,
+ existingValues: familyRoleDefinitions.map((definition) => definition.value),
+ })
+ setFamilyRoleDefinitions((current) => [
+ ...current,
+ {
+ value,
+ label,
+ assetType: familyForm.type,
+ seriesCode: familyForm.applicableSeriesCode || null,
+ builtIn: false,
+ },
+ ])
+ setFamilyForm((current) => ({
+ ...current,
+ selectedRoles: normalizeConnectionRoles([...current.selectedRoles, value]),
+ }))
+ } else if (editingRoleValue) {
+ setFamilyRoleDefinitions((current) =>
+ current.map((definition) =>
+ definition.value === editingRoleValue
+ ? { ...definition, label, seriesCode: familyForm.applicableSeriesCode || null }
+ : definition,
+ ),
+ )
+ }
+
+ setRoleEditorOpen(false)
+ setEditingRoleValue(null)
+ setRoleEditorLabel('')
+ }
+
+ function handleDeleteRoleDefinition(value: string) {
+ setFamilyRoleDefinitions((current) => current.filter((definition) => definition.value !== value))
+ setFamilyForm((current) => ({
+ ...current,
+ selectedRoles: current.selectedRoles.filter((role) => role !== value),
+ }))
}
async function handleSaveFamily() {
+ const normalizedSelectedRoles = normalizeConnectionRoles(familyForm.selectedRoles)
+ const hiddenRecommendedRoleValues = getStandardConnectionRoleDefinitions({
+ assetType: familyForm.type,
+ seriesCode: familyForm.applicableSeriesCode || null,
+ })
+ .map((definition) => definition.value)
+ .filter((value) => !familyRoleDefinitions.some((definition) => definition.value === value))
+ const metadata = buildConnectionRoleMetadata(
+ familyRoleDefinitions,
+ familyModalMode === 'edit' ? editingFamily?.metadata : null,
+ {
+ hiddenValues: hiddenRecommendedRoleValues,
+ },
+ )
setSubmitting(true)
try {
if (familyModalMode === 'create') {
@@ -562,7 +799,11 @@ export default function AssetCenterClient({
code: familyForm.code,
name: familyForm.name,
description: familyForm.description || null,
- connectionRoles: splitConnectionRoles(familyForm.roles),
+ applicableSeriesCodes: familyForm.applicableSeriesCode
+ ? [familyForm.applicableSeriesCode]
+ : [],
+ connectionRoles: normalizedSelectedRoles,
+ metadata,
status: familyForm.available ? 'AVAILABLE' : 'DRAFT',
})
startTransition(() => {
@@ -576,7 +817,11 @@ export default function AssetCenterClient({
code: familyForm.code,
name: familyForm.name,
description: familyForm.description || null,
- connectionRoles: splitConnectionRoles(familyForm.roles),
+ applicableSeriesCodes: familyForm.applicableSeriesCode
+ ? [familyForm.applicableSeriesCode]
+ : [],
+ connectionRoles: normalizedSelectedRoles,
+ metadata,
status: familyForm.available ? 'AVAILABLE' : 'DRAFT',
})
startTransition(() => {
@@ -803,10 +1048,18 @@ export default function AssetCenterClient({
throw new Error(data.error || '创建失败')
}
startTransition(() => {
- // 不直接刷新 finishes 列表(它是 props 传入的),提示用户刷新或路由刷新
+ 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('饰面/花色已创建,页面刷新后可见')
+ toast.success('饰面/花色已创建')
})
} catch (error) {
toast.error(error instanceof Error ? error.message : '创建失败,请稍后再试')
@@ -815,6 +1068,164 @@ export default function AssetCenterClient({
}
}
+ 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)
+ setActiveFinishCategoryId(null)
+ }, [])
+
+ const handleQuickUpdateVariant = useCallback(
+ async (input: Record, successMessage: string) => {
+ if (!selectedVariantId) return
+ setSubmitting(true)
+ try {
+ const updated = await updateAssetVariant(selectedVariantId, input)
+ startTransition(() => {
+ setVariants((current) => current.map((variant) => (
+ variant.id === selectedVariantId ? updated : variant
+ )))
+ toast.success(successMessage)
+ })
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : '更新失败,请稍后再试')
+ } finally {
+ setSubmitting(false)
+ closePreviewContextMenu()
+ }
+ },
+ [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)
+ closePreviewContextMenu()
+ }, [closePreviewContextMenu, selectedVariantId])
+
+ useEffect(() => {
+ if (!familyModalOpen) {
+ return
+ }
+
+ setFamilyRoleDefinitions((current) => {
+ const merged = mergeConnectionRoleDefinitions({
+ assetType: familyForm.type,
+ seriesCode: familyForm.applicableSeriesCode || null,
+ selectedRoles: familyForm.selectedRoles,
+ metadata: familyModalMode === 'edit' ? editingFamily?.metadata : null,
+ })
+
+ const currentSignature = JSON.stringify(
+ current.map((definition) => ({
+ value: definition.value,
+ label: definition.label,
+ assetType: definition.assetType,
+ seriesCode: definition.seriesCode ?? null,
+ })),
+ )
+ const mergedSignature = JSON.stringify(
+ merged.map((definition) => ({
+ value: definition.value,
+ label: definition.label,
+ assetType: definition.assetType,
+ seriesCode: definition.seriesCode ?? null,
+ })),
+ )
+
+ return currentSignature === mergedSignature ? current : merged
+ })
+ }, [
+ editingFamily?.metadata,
+ familyForm.applicableSeriesCode,
+ familyForm.selectedRoles,
+ familyForm.type,
+ familyModalMode,
+ familyModalOpen,
+ ])
+
+ useEffect(() => {
+ if (!previewContextMenuState.visible) return
+
+ const handleClose = (event: MouseEvent) => {
+ const target = event.target as HTMLElement | null
+ if (target?.closest('[data-context-menu-root="true"]')) return
+ closePreviewContextMenu()
+ }
+
+ const handleEsc = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ closePreviewContextMenu()
+ }
+ }
+
+ document.addEventListener('mousedown', handleClose)
+ document.addEventListener('contextmenu', handleClose)
+ document.addEventListener('keydown', handleEsc)
+
+ return () => {
+ document.removeEventListener('mousedown', handleClose)
+ document.removeEventListener('contextmenu', handleClose)
+ document.removeEventListener('keydown', handleEsc)
+ }
+ }, [closePreviewContextMenu, previewContextMenuState.visible])
+
// ---- 规格款弹窗内的可选规格款(继承用) ----
const familyVariantOptions = variants.filter(
(v) => v.assetFamilyId === variantForm.assetFamilyId,
@@ -878,6 +1289,130 @@ export default function AssetCenterClient({
}
}
+ const previewContextMenuPosition = useMemo(
+ () => calculateMenuPosition(previewContextMenuState.x, previewContextMenuState.y, 200, 220),
+ [previewContextMenuState.x, previewContextMenuState.y],
+ )
+
+ const previewFinishCategorySubmenuPosition = useMemo(
+ () => ({
+ left: 212,
+ top: 46,
+ }),
+ [],
+ )
+
+ const previewFinishItemsSubmenuPosition = useMemo(
+ () => ({
+ left: 384,
+ top: 46,
+ }),
+ [],
+ )
+
+ const previewSubstrateItems = useMemo(
+ () =>
+ substrates.map((substrate) => ({
+ id: `substrate:${substrate.id}`,
+ label: `${substrate.name} · ${substrate.code}`,
+ })),
+ [substrates],
+ )
+
+ const previewFinishCategoryItems = useMemo(
+ () =>
+ finishGroups.map((group) => ({
+ id: `finish-category:${group.id}`,
+ label: `${group.name} (${group.finishes.length})`,
+ submenu: 'preview-finish-items',
+ })),
+ [finishGroups],
+ )
+
+ const activeFinishGroup = useMemo(
+ () => finishGroups.find((group) => group.id === activeFinishCategoryId) ?? null,
+ [activeFinishCategoryId, finishGroups],
+ )
+
+ const previewFinishItems = useMemo(
+ () =>
+ (activeFinishGroup?.finishes ?? []).map((finish) => ({
+ id: `finish:${finish.id}`,
+ label: finish.name,
+ thumbnailUrl: finish.thumbnailUrl,
+ })),
+ [activeFinishGroup],
+ )
+
+ const previewContextMenuGroups = useMemo(() => {
+ if (!selectedVariant) return []
+
+ return [
+ {
+ id: 'preview-edit',
+ items: [
+ {
+ id: 'preview-edit-variant',
+ label: '编辑当前规格款',
+ },
+ {
+ id: 'preview-change-substrate',
+ label: '快捷更改基材',
+ submenu: 'preview-substrate',
+ disabled: substrates.length === 0,
+ },
+ {
+ id: 'preview-change-finish',
+ label: '快捷更改饰面 / 花色',
+ submenu: 'preview-finish-category',
+ disabled: finishGroups.length === 0,
+ },
+ ],
+ },
+ ]
+ }, [finishGroups.length, selectedVariant, substrates.length])
+
+ const handlePreviewContextMenuItemClick = useCallback((item: ContextMenuItem) => {
+ if (!selectedVariant) return
+
+ if (item.id === 'preview-edit-variant') {
+ openEditVariantModal(selectedVariant)
+ closePreviewContextMenu()
+ return
+ }
+
+ if (item.id.startsWith('substrate:')) {
+ const substrateId = item.id.replace('substrate:', '')
+ void handleQuickUpdateVariant({ substrateId }, '基材已更新')
+ return
+ }
+
+ if (item.id.startsWith('finish:')) {
+ const finishId = item.id.replace('finish:', '')
+ void handleQuickUpdateVariant({ finishId }, '饰面 / 花色已更新')
+ return
+ }
+ }, [closePreviewContextMenu, handleQuickUpdateVariant, selectedVariant])
+
+ const handlePreviewContextMenuHover = useCallback((item: ContextMenuItem) => {
+ if (item.id === 'preview-change-substrate') {
+ setActivePreviewSubmenu('preview-substrate')
+ return
+ }
+ if (item.id === 'preview-change-finish') {
+ setActivePreviewSubmenu('preview-finish-category')
+ return
+ }
+ if (item.id.startsWith('finish-category:')) {
+ setActivePreviewSubmenu('preview-finish-items')
+ setActiveFinishCategoryId(item.id.replace('finish-category:', ''))
+ return
+ }
+ if (!item.submenu) {
+ setActivePreviewSubmenu(null)
+ }
+ }, [])
+
// ---- 渲染 ----
return (
@@ -892,6 +1427,32 @@ export default function AssetCenterClient({
emptyTitle="选择规格款预览 3D 模型"
emptyDescription="从右侧面板选择资产族并点击规格款,即可在此处预览其 3D 模型。"
className="h-full"
+ showFpsBadge={false}
+ showCameraActionButtons={false}
+ defaultShowSceneGrid
+ selectedNodeName={selectedPreviewNodeName}
+ selectableNodeNames={previewNodeNames}
+ materialAssignments={previewMaterialAssignments}
+ onModelNodesChange={(nodeNames) => {
+ setPreviewNodeNames(nodeNames)
+ setSelectedPreviewNodeName((current) =>
+ current && nodeNames.includes(current) ? current : nodeNames[0] ?? null,
+ )
+ }}
+ onNodeSelect={(payload) => {
+ setSelectedPreviewNodeName(payload.nodeName ?? payload.rawNodeName ?? null)
+ }}
+ onNodeContextMenu={(payload) => {
+ setSelectedPreviewNodeName(payload.nodeName ?? payload.rawNodeName ?? selectedPreviewNodeName)
+ setPreviewContextMenuState({
+ visible: true,
+ x: payload.clientX,
+ y: payload.clientY,
+ nodeName: payload.nodeName ?? payload.rawNodeName ?? selectedPreviewNodeName,
+ })
+ setActivePreviewSubmenu(null)
+ setActiveFinishCategoryId(null)
+ }}
/>
}
rightContent={
@@ -952,15 +1513,9 @@ export default function AssetCenterClient({
)}
- {isImportableAssetType(activeTab) ? (
-
- ) : null}
@@ -987,13 +1542,18 @@ export default function AssetCenterClient({
{searchText ? `未找到匹配「${searchText}」的资产族` : '暂无该类型资产族'}
{!searchText && (
-
+
+
+
+ 先建资产族,再在资产族里面新建或导入带模型的规格款。
+
+
)}
) : (
@@ -1021,19 +1581,27 @@ export default function AssetCenterClient({
) : (
)}
-
-
-
- {family.name}
-
+
+
+
+ {family.name}
+
{getStatusLabel(family.status)}
+
+
+ {family.code} · {familyVariants.length} 个规格款
+
+ {family.applicableSeriesCodes.length > 0 ? (
+
+ 适用品类系列:
+ {family.applicableSeriesCodes
+ .map((code) => seriesTemplateByCode.get(code)?.name ?? code)
+ .join('、')}
+
+ ) : null}
-
- {family.code} · {familyVariants.length} 个规格款
-
-
)}
{/* 新建规格款按钮 */}
-
+
+ {isImportableAssetType(family.type) ? (
+
+ ) : null}
)}
@@ -1163,25 +1741,56 @@ export default function AssetCenterClient({
-
- {finishes.slice(0, 12).map((finish) => (
-
-
-
-
{finish.name}
-
{finish.categoryName || '未分类'}
+
+
+ 先建材质分类,再建具体饰面 / 花色。
+
+
+
+
+ {finishGroups.map((group, groupIndex) => (
+
0 && 'border-t border-apple-separator pt-3')}>
+
+
+ {group.name}
+
+ {group.finishes.length} 种
+
+
+ {group.finishes.map((finish) => (
+
+
+
+
{finish.name}
+
{group.name}
+
+
+
+ ))}
))}
- {finishes.length > 12 && (
-
- 仅展示前 12 条,完整维护可进入材质管理模块。
-
- )}
@@ -1202,10 +1811,57 @@ export default function AssetCenterClient({
}
/>
+ {previewContextMenuState.visible && selectedVariant && (
+
+
+
+ 0}
+ position={previewFinishItemsSubmenuPosition}
+ title={activeFinishGroup?.name ?? '饰面 / 花色'}
+ items={previewFinishItems}
+ onItemClick={handlePreviewContextMenuItemClick}
+ width={220}
+ className="pointer-events-auto"
+ zIndex={222}
+ />
+
+ )}
+
tab.type === cadImportForm.assetType)?.label ?? '资产'}`}
+ title={`导入 CAD 规格款 · ${TYPE_TABS.find((tab) => tab.type === cadImportForm.assetType)?.label ?? '资产'}`}
size="large"
footer={
cadImportResult ? (
@@ -1302,89 +1958,52 @@ export default function AssetCenterClient({
- {cadImportFamilyOptions.length > 0 ? (
-