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 ? ( - setCadImportForm((current) => ({ ...current, familyId: e.target.value }))} - options={cadImportFamilyOptions} - placeholder="请选择已存在资产族" - helperText={ - cadImportForm.assetType === 'COMPONENT' || cadImportForm.assetType === 'HARDWARE' - ? selectedCadImportFamily - ? selectedCadImportFamily.connectionRoles.length > 0 - ? `当前连接角色:${selectedCadImportFamily.connectionRoles.join('、')}` - : '当前资产族还没有连接角色' - : '请选择同类型的资产族' - : undefined - } - errorText={cadImportSubmitAttempted ? cadImportFieldErrors.familyId : undefined} - /> - ) : ( - <> -
- setCadImportForm((current) => ({ ...current, familyCode: e.target.value }))} - placeholder={`${cadImportForm.assetType}-FAMILY-001`} - helperText={autoInferredFields.has('familyCode') ? '已从 ZIP 自动带出' : undefined} - errorText={cadImportSubmitAttempted ? cadImportFieldErrors.familyCode : undefined} - /> - setCadImportForm((current) => ({ ...current, familyName: e.target.value }))} - placeholder="例如 桌面板 / 桌腿 / 连接件" - helperText={autoInferredFields.has('familyName') ? '已从 ZIP 自动带出' : undefined} - errorText={cadImportSubmitAttempted ? cadImportFieldErrors.familyName : undefined} - /> + {selectedCadImportFamily ? ( +
+
+
+

导入目标资产族

+

+ {selectedCadImportFamily.name} +

+

+ {selectedCadImportFamily.code} + {selectedCadImportFamily.applicableSeriesCodes.length > 0 + ? ` · ${selectedCadImportFamily.applicableSeriesCodes + .map((code) => seriesTemplateByCode.get(code)?.name ?? code) + .join('、')}` + : ''} +

+
+ + 规格款导入 +
-