Merge remote-tracking branch 'origin/main' into codex/xuxiake-packaging
This commit is contained in:
commit
ca5c44d2ec
214
docs/Precision-Atelier设计系统改造记录.md
Normal file
214
docs/Precision-Atelier设计系统改造记录.md
Normal file
@ -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={<PlatformHeaderTools user={user} />}` 传参:
|
||||
- `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 个业务文件 | 硬编码颜色替换 |
|
||||
@ -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 导入弹窗自动带出命名交互是否清晰
|
||||
- [ ] **包材资料库页面视觉联调**
|
||||
归属:`联调确认`
|
||||
说明:确认该页面视觉是否已统一到主线风格。
|
||||
|
||||
52
furniture-3d-system/scripts/package-wyj-test-deploy.ps1
Normal file
52
furniture-3d-system/scripts/package-wyj-test-deploy.ps1
Normal file
@ -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
|
||||
}
|
||||
@ -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] },
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
@ -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={[]}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,8 +2,8 @@ import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ReactNode } from 'react'
|
||||
import AssetCenterClient from './AssetCenterClient'
|
||||
import { createAssetFamily, createSubstrate } from '@/lib/open-product/platform-api'
|
||||
import type { AssetVariantDTO, MaterialDTO, SubstrateDTO } from '@/server/services'
|
||||
import { createAssetFamily, createMaterialCategory, createSubstrate, importAssetCad } from '@/lib/open-product/platform-api'
|
||||
import type { AssetFamilyDTO, AssetVariantDTO, MaterialDTO, SeriesTemplateDTO, SubstrateDTO } from '@/server/services'
|
||||
|
||||
jest.mock('next/link', () => ({
|
||||
__esModule: true,
|
||||
@ -108,7 +108,9 @@ 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(),
|
||||
updateAssetFamily: jest.fn(),
|
||||
@ -121,8 +123,36 @@ function renderClient() {
|
||||
<AssetCenterClient
|
||||
initialFamilies={[]}
|
||||
initialVariants={[] as AssetVariantDTO[]}
|
||||
initialSubstrates={[] as SubstrateDTO[]}
|
||||
finishes={[] as MaterialDTO[]}
|
||||
initialSubstrates={[{ id: 'substrate-1', code: 'PB18', name: '18mm 颗粒板' }] as SubstrateDTO[]}
|
||||
initialSeriesTemplates={[
|
||||
{
|
||||
id: 'template-1',
|
||||
code: 'single-board-desk',
|
||||
name: '单板桌',
|
||||
seriesName: '云序',
|
||||
},
|
||||
] as SeriesTemplateDTO[]}
|
||||
finishes={[
|
||||
{
|
||||
id: 'finish-1',
|
||||
name: '白橡木纹',
|
||||
categoryId: 'cat-1',
|
||||
categoryName: '木纹',
|
||||
thumbnailUrl: null,
|
||||
textureUrl: null,
|
||||
color: '#CCCCCC',
|
||||
metalness: 0,
|
||||
roughness: 0.5,
|
||||
opacity: 1,
|
||||
textureRepeat: 1,
|
||||
textureRotation: 0,
|
||||
textureSizeMm: 600,
|
||||
isPreset: false,
|
||||
isActive: true,
|
||||
createdAt: new Date('2026-04-07T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-07T00:00:00.000Z'),
|
||||
},
|
||||
] as MaterialDTO[]}
|
||||
materialCategories={[{ id: 'cat-1', name: '木纹', icon: 'Texture', sortOrder: 1 }]}
|
||||
metrics={[]}
|
||||
user={{
|
||||
@ -185,12 +215,12 @@ describe('AssetCenterClient workflow', () => {
|
||||
throw new Error('资产族操作区不存在')
|
||||
}
|
||||
|
||||
await actor.click(within(actionBar.parentElement ?? actionBar).getByRole('button', { name: '新建' }))
|
||||
expect(screen.getByText('新建资产族')).toBeInTheDocument()
|
||||
await actor.click(within(actionBar.parentElement ?? actionBar).getByRole('button', { name: '新建资产族' }))
|
||||
expect(screen.getByRole('heading', { name: '新建资产族' })).toBeInTheDocument()
|
||||
|
||||
await actor.type(screen.getByPlaceholderText('例如 BOARD-001'), 'BOARD-001')
|
||||
await actor.type(screen.getByPlaceholderText('例如 单板桌桌面板'), '桌面板')
|
||||
await actor.click(screen.getByRole('checkbox', { name: 'desktop_surface' }))
|
||||
await actor.click(screen.getByRole('checkbox', { name: '桌面板' }))
|
||||
await actor.click(screen.getByRole('button', { name: '创建' }))
|
||||
|
||||
await waitFor(() => {
|
||||
@ -198,12 +228,13 @@ describe('AssetCenterClient workflow', () => {
|
||||
type: 'BOARD',
|
||||
code: 'BOARD-001',
|
||||
name: '桌面板',
|
||||
applicableSeriesCodes: ['single-board-desk'],
|
||||
connectionRoles: ['desktop_surface'],
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('CAD 导入、新建基材、新建饰面花色都使用弹窗入口', async () => {
|
||||
it('CAD 导入从资产族内部发起,新建基材、新建饰面花色都使用弹窗入口', async () => {
|
||||
const actor = userEvent.setup()
|
||||
jest.mocked(createSubstrate).mockResolvedValue({
|
||||
substrate: {
|
||||
@ -213,10 +244,84 @@ describe('AssetCenterClient workflow', () => {
|
||||
},
|
||||
} as never)
|
||||
|
||||
renderClient()
|
||||
render(
|
||||
<AssetCenterClient
|
||||
initialFamilies={[
|
||||
{
|
||||
id: 'family-board-1',
|
||||
type: 'BOARD',
|
||||
code: 'BOARD-001',
|
||||
name: '桌面板资产族',
|
||||
description: null,
|
||||
productCategoryId: null,
|
||||
productCategoryName: null,
|
||||
applicableSeriesCodes: ['single-board-desk'],
|
||||
connectionRoles: ['desktop_surface'],
|
||||
metadata: {
|
||||
connectionRoleDefinitions: [
|
||||
{
|
||||
value: 'desktop_surface',
|
||||
label: '桌面板',
|
||||
assetType: 'BOARD',
|
||||
seriesCode: 'single-board-desk',
|
||||
},
|
||||
],
|
||||
},
|
||||
status: 'DRAFT',
|
||||
createdBy: 'user-1',
|
||||
creatorName: 'Test User',
|
||||
workflow: {} as never,
|
||||
createdAt: new Date('2026-04-07T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-07T00:00:00.000Z'),
|
||||
},
|
||||
] as AssetFamilyDTO[]}
|
||||
initialVariants={[] as AssetVariantDTO[]}
|
||||
initialSubstrates={[{ id: 'substrate-1', code: 'PB18', name: '18mm 颗粒板' }] as SubstrateDTO[]}
|
||||
initialSeriesTemplates={[
|
||||
{
|
||||
id: 'template-1',
|
||||
code: 'single-board-desk',
|
||||
name: '单板桌',
|
||||
seriesName: '云序',
|
||||
},
|
||||
] as SeriesTemplateDTO[]}
|
||||
finishes={[
|
||||
{
|
||||
id: 'finish-1',
|
||||
name: '白橡木纹',
|
||||
categoryId: 'cat-1',
|
||||
categoryName: '木纹',
|
||||
thumbnailUrl: null,
|
||||
textureUrl: null,
|
||||
color: '#CCCCCC',
|
||||
metalness: 0,
|
||||
roughness: 0.5,
|
||||
opacity: 1,
|
||||
textureRepeat: 1,
|
||||
textureRotation: 0,
|
||||
textureSizeMm: 600,
|
||||
isPreset: false,
|
||||
isActive: true,
|
||||
createdAt: new Date('2026-04-07T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-07T00:00:00.000Z'),
|
||||
},
|
||||
] as MaterialDTO[]}
|
||||
materialCategories={[{ id: 'cat-1', name: '木纹', icon: 'Texture', sortOrder: 1 }]}
|
||||
metrics={[]}
|
||||
user={{
|
||||
id: 'user-1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
role: 'SUPER_ADMIN',
|
||||
avatar: null,
|
||||
department: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: /CAD 导入/ }))
|
||||
expect(screen.getByText('CAD 导入板材')).toBeInTheDocument()
|
||||
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) {
|
||||
@ -235,4 +340,208 @@ describe('AssetCenterClient workflow', () => {
|
||||
await actor.click(within(finishSection.parentElement ?? finishSection).getByRole('button', { name: '新建' }))
|
||||
expect(screen.getByText('新建饰面 / 花色')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CAD 导入允许重量和成本价留空,提交时按 null 传给后端', async () => {
|
||||
const actor = userEvent.setup()
|
||||
jest.mocked(importAssetCad).mockResolvedValue({
|
||||
family: {
|
||||
id: 'family-1',
|
||||
type: 'BOARD',
|
||||
code: 'BOARD-140',
|
||||
name: '140台面',
|
||||
description: null,
|
||||
productCategoryId: null,
|
||||
productCategoryName: null,
|
||||
applicableSeriesCodes: [],
|
||||
connectionRoles: [],
|
||||
metadata: null,
|
||||
status: 'DRAFT',
|
||||
createdBy: 'user-1',
|
||||
creatorName: 'Test User',
|
||||
workflow: {} as never,
|
||||
createdAt: new Date('2026-04-07T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-07T00:00:00.000Z'),
|
||||
},
|
||||
variant: {
|
||||
id: 'variant-1',
|
||||
assetFamilyId: 'family-1',
|
||||
assetFamilyCode: 'BOARD-140',
|
||||
assetFamilyName: '140台面',
|
||||
code: 'TM-001',
|
||||
name: '140台面-A',
|
||||
assetType: 'BOARD',
|
||||
sizeLabel: '1400x600x25',
|
||||
dimensions: { length: 1400, width: 600, thickness: 25 },
|
||||
modelUrl: 'http://example.com/model.obj',
|
||||
thumbnailUrl: null,
|
||||
substrateId: 'substrate-1',
|
||||
substrateName: '18mm 颗粒板',
|
||||
finishId: 'finish-1',
|
||||
finishName: '白橡木纹',
|
||||
mountPoints: [],
|
||||
assemblyBasis: null,
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData: null,
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
status: 'DRAFT',
|
||||
inheritedFromVariantId: null,
|
||||
inheritedFromVariantCode: null,
|
||||
inheritedFromVariantName: null,
|
||||
createdBy: 'user-1',
|
||||
creatorName: 'Test User',
|
||||
workflow: {} as never,
|
||||
createdAt: new Date('2026-04-07T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-07T00:00:00.000Z'),
|
||||
},
|
||||
summary: {
|
||||
assetType: 'BOARD',
|
||||
panelCount: 1,
|
||||
holeCount: 25,
|
||||
hardwareTypeCount: 0,
|
||||
pluginVersion: '2.5.0',
|
||||
sourceFile: '修改模型.dwg',
|
||||
zipFileName: '140台面.zip',
|
||||
},
|
||||
warnings: [],
|
||||
} as never)
|
||||
|
||||
render(
|
||||
<AssetCenterClient
|
||||
initialFamilies={[
|
||||
{
|
||||
id: 'family-1',
|
||||
type: 'BOARD',
|
||||
code: 'BOARD-140',
|
||||
name: '140台面资产族',
|
||||
description: null,
|
||||
productCategoryId: null,
|
||||
productCategoryName: null,
|
||||
applicableSeriesCodes: ['single-board-desk'],
|
||||
connectionRoles: ['desktop_surface'],
|
||||
metadata: {
|
||||
connectionRoleDefinitions: [
|
||||
{
|
||||
value: 'desktop_surface',
|
||||
label: '桌面板',
|
||||
assetType: 'BOARD',
|
||||
seriesCode: 'single-board-desk',
|
||||
},
|
||||
],
|
||||
},
|
||||
status: 'DRAFT',
|
||||
createdBy: 'user-1',
|
||||
creatorName: 'Test User',
|
||||
workflow: {} as never,
|
||||
createdAt: new Date('2026-04-07T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-07T00:00:00.000Z'),
|
||||
},
|
||||
] as AssetFamilyDTO[]}
|
||||
initialVariants={[] as AssetVariantDTO[]}
|
||||
initialSubstrates={[{ id: 'substrate-1', code: 'PB18', name: '18mm 颗粒板' }] as SubstrateDTO[]}
|
||||
initialSeriesTemplates={[
|
||||
{
|
||||
id: 'template-1',
|
||||
code: 'single-board-desk',
|
||||
name: '单板桌',
|
||||
seriesName: '云序',
|
||||
},
|
||||
] as SeriesTemplateDTO[]}
|
||||
finishes={[
|
||||
{
|
||||
id: 'finish-1',
|
||||
name: '白橡木纹',
|
||||
categoryId: 'cat-1',
|
||||
categoryName: '木纹',
|
||||
thumbnailUrl: null,
|
||||
textureUrl: null,
|
||||
color: '#CCCCCC',
|
||||
metalness: 0,
|
||||
roughness: 0.5,
|
||||
opacity: 1,
|
||||
textureRepeat: 1,
|
||||
textureRotation: 0,
|
||||
textureSizeMm: 600,
|
||||
isPreset: false,
|
||||
isActive: true,
|
||||
createdAt: new Date('2026-04-07T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-07T00:00:00.000Z'),
|
||||
},
|
||||
] as MaterialDTO[]}
|
||||
materialCategories={[{ id: 'cat-1', name: '木纹', icon: 'Texture', sortOrder: 1 }]}
|
||||
metrics={[]}
|
||||
user={{
|
||||
id: 'user-1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
role: 'SUPER_ADMIN',
|
||||
avatar: null,
|
||||
department: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
await actor.click(screen.getByText('140台面资产族'))
|
||||
await actor.click(screen.getByRole('button', { name: 'CAD 导入到该资产族' }))
|
||||
await actor.type(screen.getByPlaceholderText('BOARD-VARIANT-001'), 'TM-001')
|
||||
await actor.type(screen.getByPlaceholderText('例如 桌面板 1200 / 方管桌腿 / 十字连接件'), '140台面-A')
|
||||
|
||||
await actor.click(screen.getByRole('combobox', { name: '基材' }))
|
||||
await actor.click(screen.getByRole('option', { name: '18mm 颗粒板 · PB18' }))
|
||||
await actor.click(screen.getByRole('combobox', { name: '饰面 / 花色' }))
|
||||
await actor.click(screen.getByRole('option', { name: '白橡木纹' }))
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"][accept=".zip"]') as HTMLInputElement
|
||||
const zipFile = new File(['zip-content'], '140台面.zip', { type: 'application/zip' })
|
||||
await actor.upload(fileInput, zipFile)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '开始导入' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(importAssetCad).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
file: expect.any(File),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Layers3, Package, Palette, Ruler } from 'lucide-react'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { assetCenterService, materialService } from '@/server/services'
|
||||
import { assetCenterService, materialService, seriesTemplateService } from '@/server/services'
|
||||
import AssetCenterClient from './AssetCenterClient'
|
||||
|
||||
export default async function AssetCenterPage() {
|
||||
@ -13,12 +13,14 @@ export default async function AssetCenterPage() {
|
||||
const substrates = await assetCenterService.listSubstrates()
|
||||
const finishes = await materialService.findAll({ limit: 200 })
|
||||
const materialCategories = await materialService.findAllCategories()
|
||||
const seriesTemplates = await seriesTemplateService.listTemplates()
|
||||
|
||||
return (
|
||||
<AssetCenterClient
|
||||
initialFamilies={families}
|
||||
initialVariants={variants}
|
||||
initialSubstrates={substrates}
|
||||
initialSeriesTemplates={seriesTemplates}
|
||||
finishes={finishes}
|
||||
materialCategories={materialCategories}
|
||||
metrics={[
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { startTransition, useState, type ReactNode } from 'react'
|
||||
import { startTransition, useEffect, useState, type ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
@ -38,6 +38,29 @@ function includesValue(values: string[], target: string) {
|
||||
return values.includes(target)
|
||||
}
|
||||
|
||||
function areStringArraysEqual(left: string[], right: string[]) {
|
||||
if (left.length !== right.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
function isFamilyCompatibleWithTemplate(
|
||||
family: AssetFamilyDTO,
|
||||
templateCode: string | null | undefined,
|
||||
) {
|
||||
if (!templateCode) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!family.applicableSeriesCodes || family.applicableSeriesCodes.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return family.applicableSeriesCodes.includes(templateCode)
|
||||
}
|
||||
|
||||
export default function AutoCombinationsClient({
|
||||
templates,
|
||||
families,
|
||||
@ -60,13 +83,40 @@ export default function AutoCombinationsClient({
|
||||
const [candidatesExpanded, setCandidatesExpanded] = useState(false)
|
||||
const [previewCandidateKey, setPreviewCandidateKey] = useState<string | null>(null)
|
||||
|
||||
const desktopFamilies = families.filter((family) => family.type === 'BOARD')
|
||||
const legFamilies = families.filter((family) => family.type === 'COMPONENT')
|
||||
const connectorFamilies = families.filter((family) => family.type === 'HARDWARE')
|
||||
const selectedTemplate = templates.find((t) => t.id === templateId)
|
||||
const selectedTemplateCode = selectedTemplate?.code ?? null
|
||||
const desktopFamilies = families.filter(
|
||||
(family) => family.type === 'BOARD' && isFamilyCompatibleWithTemplate(family, selectedTemplateCode),
|
||||
)
|
||||
const legFamilies = families.filter(
|
||||
(family) => family.type === 'COMPONENT' && isFamilyCompatibleWithTemplate(family, selectedTemplateCode),
|
||||
)
|
||||
const connectorFamilies = families.filter(
|
||||
(family) => family.type === 'HARDWARE' && isFamilyCompatibleWithTemplate(family, selectedTemplateCode),
|
||||
)
|
||||
const selectedCandidates = candidates.filter((candidate) =>
|
||||
selectedCandidateKeys.includes(candidate.key),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const allowedDesktopIds = new Set(desktopFamilies.map((family) => family.id))
|
||||
const allowedLegIds = new Set(legFamilies.map((family) => family.id))
|
||||
const allowedConnectorIds = new Set(connectorFamilies.map((family) => family.id))
|
||||
|
||||
setDesktopIds((current) => {
|
||||
const next = current.filter((id) => allowedDesktopIds.has(id))
|
||||
return areStringArraysEqual(current, next) ? current : next
|
||||
})
|
||||
setLegIds((current) => {
|
||||
const next = current.filter((id) => allowedLegIds.has(id))
|
||||
return areStringArraysEqual(current, next) ? current : next
|
||||
})
|
||||
setConnectorIds((current) => {
|
||||
const next = current.filter((id) => allowedConnectorIds.has(id))
|
||||
return areStringArraysEqual(current, next) ? current : next
|
||||
})
|
||||
}, [connectorFamilies, desktopFamilies, legFamilies])
|
||||
|
||||
function toggleSelection(list: string[], value: string) {
|
||||
if (list.includes(value)) {
|
||||
return list.filter((item) => item !== value)
|
||||
@ -135,7 +185,6 @@ export default function AutoCombinationsClient({
|
||||
}
|
||||
}
|
||||
|
||||
const selectedTemplate = templates.find((t) => t.id === templateId)
|
||||
const allSelectedFamilies = [
|
||||
...desktopFamilies.filter((f) => desktopIds.includes(f.id)),
|
||||
...legFamilies.filter((f) => legIds.includes(f.id)),
|
||||
|
||||
@ -64,6 +64,7 @@ interface ModelViewerProps {
|
||||
showPanelLeaders?: boolean;
|
||||
showHoleHighlights?: boolean;
|
||||
showFoldLines?: boolean;
|
||||
showSceneGrid?: boolean;
|
||||
}
|
||||
|
||||
function areStringArraysEqual(left: string[] = [], right: string[] = []): boolean {
|
||||
@ -190,12 +191,14 @@ function ModelViewerComponent({
|
||||
showPanelLeaders = true,
|
||||
showHoleHighlights = true,
|
||||
showFoldLines = true,
|
||||
showSceneGrid = false,
|
||||
}: ModelViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const engineRef = useRef<ThreeDEngine | null>(null);
|
||||
const pointerStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const explodedProgressRef = useRef(explodedProgress);
|
||||
const hiddenNodeNamesRef = useRef<string[]>(hiddenNodeNames);
|
||||
const showSceneGridRef = useRef(showSceneGrid);
|
||||
const nodePositionOffsetsRef = useRef<NodePositionOffset[]>(nodePositionOffsets);
|
||||
const semanticPreviewFallbackNodeNamesRef = useRef<string[]>(
|
||||
semanticPreviewFallbackNodeNames,
|
||||
@ -243,6 +246,12 @@ function ModelViewerComponent({
|
||||
engineRef.current.setHiddenNodes(hiddenNodeNames);
|
||||
}, [hiddenNodeNames]);
|
||||
|
||||
useEffect(() => {
|
||||
showSceneGridRef.current = showSceneGrid;
|
||||
if (!engineRef.current) return;
|
||||
engineRef.current.setSceneGridVisible(showSceneGrid);
|
||||
}, [showSceneGrid]);
|
||||
|
||||
useEffect(() => {
|
||||
nodePositionOffsetsRef.current = nodePositionOffsets;
|
||||
}, [nodePositionOffsets]);
|
||||
@ -328,6 +337,7 @@ function ModelViewerComponent({
|
||||
}
|
||||
|
||||
engine.setHiddenNodes(hiddenNodeNamesRef.current);
|
||||
engine.setSceneGridVisible(showSceneGridRef.current);
|
||||
setEngineInstance(engine);
|
||||
|
||||
onEngineReadyRef.current?.({
|
||||
@ -728,7 +738,7 @@ function ModelViewerComponent({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`relative bg-apple-bg-tertiary ${className}`}>
|
||||
<div className={`relative h-full w-full bg-apple-bg-tertiary ${className}`}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
@ -811,6 +821,7 @@ export const ModelViewer = memo(
|
||||
prevProps.showPanelLeaders === nextProps.showPanelLeaders &&
|
||||
prevProps.showHoleHighlights === nextProps.showHoleHighlights &&
|
||||
prevProps.showFoldLines === nextProps.showFoldLines &&
|
||||
prevProps.showSceneGrid === nextProps.showSceneGrid &&
|
||||
areStringArraysEqual(
|
||||
prevProps.highlightedParts,
|
||||
nextProps.highlightedParts,
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { Box, Axis3D, Move3D, ZoomIn, BoxSelect, Square, Grid3X3, Settings2 } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Box, BoxSelect, Square, Grid3X3, Settings2 } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/ui/EmptyState'
|
||||
import { CompositeAssetPreview } from '@/components/platform/CompositeAssetPreview'
|
||||
import type { ThreeDEngine } from '@/lib/3d/engine'
|
||||
import { CameraController } from '@/lib/3d/camera-controller'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ModelViewer 需要动态加载(依赖 Three.js,不支持 SSR)
|
||||
@ -34,8 +37,40 @@ interface ModelPreviewPanelProps {
|
||||
className?: string
|
||||
/** 面板顶部标题,显示在毛玻璃层中 */
|
||||
title?: string
|
||||
showFpsBadge?: boolean
|
||||
showCameraActionButtons?: boolean
|
||||
defaultShowSceneGrid?: boolean
|
||||
selectedNodeName?: string | null
|
||||
selectableNodeNames?: string[]
|
||||
materialAssignments?: Array<{
|
||||
nodeName: string
|
||||
material: import('@/lib/3d/material-manager').MaterialConfig
|
||||
}>
|
||||
onEngineReady?: (payload: {
|
||||
engine: ThreeDEngine
|
||||
modelRoot: ReturnType<ThreeDEngine['getModelRoot']>
|
||||
}) => void
|
||||
onModelNodesChange?: (nodeNames: string[]) => void
|
||||
onNodeSelect?: (payload: {
|
||||
nodeName: string | null
|
||||
rawNodeName: string | null
|
||||
candidateNodeName: string | null
|
||||
appendToSelection: boolean
|
||||
hasRaycastHit: boolean
|
||||
}) => void
|
||||
onNodeContextMenu?: (payload: {
|
||||
nodeName: string | null
|
||||
rawNodeName: string | null
|
||||
candidateNodeName: string | null
|
||||
clientX: number
|
||||
clientY: number
|
||||
appendToSelection: boolean
|
||||
hasRaycastHit: boolean
|
||||
}) => void
|
||||
}
|
||||
|
||||
type ViewPresetKey = 'front' | 'top' | 'isometric'
|
||||
|
||||
export function ModelPreviewPanel({
|
||||
modelUrl,
|
||||
previewNodes,
|
||||
@ -44,12 +79,83 @@ export function ModelPreviewPanel({
|
||||
emptyIcon,
|
||||
className,
|
||||
title = '资产中心',
|
||||
showFpsBadge = true,
|
||||
showCameraActionButtons = true,
|
||||
defaultShowSceneGrid = false,
|
||||
selectedNodeName = null,
|
||||
selectableNodeNames = [],
|
||||
materialAssignments = [],
|
||||
onEngineReady,
|
||||
onModelNodesChange,
|
||||
onNodeSelect,
|
||||
onNodeContextMenu,
|
||||
}: ModelPreviewPanelProps) {
|
||||
const engineRef = useRef<ThreeDEngine | null>(null)
|
||||
const cameraControllerRef = useRef<CameraController | null>(null)
|
||||
const modelRootRef = useRef<ReturnType<ThreeDEngine['getModelRoot']>>(null)
|
||||
const [engineReady, setEngineReady] = useState(false)
|
||||
const [showSceneGrid, setShowSceneGrid] = useState(defaultShowSceneGrid)
|
||||
const [activePreset, setActivePreset] = useState<ViewPresetKey | null>(null)
|
||||
// 组合预览模式(多个模型装配)
|
||||
const hasPreviewNodes = previewNodes && previewNodes.length > 0
|
||||
const hasModelUrl = !!modelUrl
|
||||
const hasContent = hasPreviewNodes || hasModelUrl
|
||||
|
||||
const handleEngineReady = useCallback(
|
||||
({ engine, modelRoot }: { engine: ThreeDEngine; modelRoot: ReturnType<ThreeDEngine['getModelRoot']> }) => {
|
||||
engineRef.current = engine
|
||||
modelRootRef.current = modelRoot
|
||||
setEngineReady(true)
|
||||
const controller = new CameraController(
|
||||
engine.getCamera(),
|
||||
engine.getControls(),
|
||||
engine.getScene(),
|
||||
)
|
||||
controller.setDefaultAnimConfig({ duration: 360, easing: 'easeOut' })
|
||||
controller.setNavigationModel(modelRoot)
|
||||
cameraControllerRef.current = controller
|
||||
onEngineReady?.({ engine, modelRoot })
|
||||
},
|
||||
[onEngineReady],
|
||||
)
|
||||
|
||||
const handlePresetChange = useCallback(async (preset: ViewPresetKey) => {
|
||||
setActivePreset(preset)
|
||||
const controller = cameraControllerRef.current
|
||||
if (!controller) return
|
||||
await controller.applyPreset(preset, { transition: 'smooth', duration: 360 })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasContent) return
|
||||
engineRef.current = null
|
||||
modelRootRef.current = null
|
||||
setEngineReady(false)
|
||||
cameraControllerRef.current?.dispose()
|
||||
cameraControllerRef.current = null
|
||||
setActivePreset(null)
|
||||
}, [hasContent])
|
||||
|
||||
useEffect(() => {
|
||||
setEngineReady(false)
|
||||
setActivePreset(null)
|
||||
}, [modelUrl])
|
||||
|
||||
useEffect(() => {
|
||||
setShowSceneGrid(defaultShowSceneGrid)
|
||||
}, [defaultShowSceneGrid, modelUrl])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cameraControllerRef.current?.dispose()
|
||||
cameraControllerRef.current = null
|
||||
engineRef.current = null
|
||||
modelRootRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const viewportControlsEnabled = hasModelUrl && engineReady
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@ -59,11 +165,14 @@ export function ModelPreviewPanel({
|
||||
>
|
||||
{/* 3D 舞台网格背景 */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-40 z-0"
|
||||
className={cn(
|
||||
'absolute inset-0 pointer-events-none z-0 transition-opacity',
|
||||
showSceneGrid ? 'opacity-70' : 'opacity-0',
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, rgba(172, 179, 184, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(172, 179, 184, 0.1) 1px, transparent 1px)`,
|
||||
backgroundSize: '40px 40px',
|
||||
backgroundImage: `linear-gradient(to right, rgba(112, 124, 135, 0.22) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(112, 124, 135, 0.22) 1px, transparent 1px)`,
|
||||
backgroundSize: '32px 32px',
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -74,39 +183,77 @@ export function ModelPreviewPanel({
|
||||
<span className="text-[10px] font-apple-bold text-apple-label-secondary">C-01</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<button className="p-2 text-apple-label-tertiary hover:text-apple-label-primary hover:scale-110 transition-transform group flex flex-col items-center">
|
||||
<Axis3D strokeWidth={1.5} className="h-5 w-5" />
|
||||
<span className="block text-[8px] mt-1 font-apple-bold group-hover:text-apple-label-primary">ROTATE</span>
|
||||
</button>
|
||||
<button className="p-2 text-apple-label-tertiary hover:text-apple-label-primary hover:scale-110 transition-transform group flex flex-col items-center">
|
||||
<Move3D strokeWidth={1.5} className="h-5 w-5" />
|
||||
<span className="block text-[8px] mt-1 font-apple-bold group-hover:text-apple-label-primary">MOVE</span>
|
||||
</button>
|
||||
<button className="p-2 text-apple-label-tertiary hover:text-apple-label-primary hover:scale-110 transition-transform group flex flex-col items-center">
|
||||
<ZoomIn strokeWidth={1.5} className="h-5 w-5" />
|
||||
<span className="block text-[8px] mt-1 font-apple-bold group-hover:text-apple-label-primary">ZOOM</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-8 h-px bg-apple-separator my-1"></div>
|
||||
{showCameraActionButtons && (
|
||||
<>
|
||||
<div className="w-8 h-px bg-apple-separator my-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<button className="p-2 bg-white/80 text-apple-label-primary shadow-apple-sm rounded-apple-lg transition-transform flex flex-col items-center border border-apple-separator/30">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handlePresetChange('front')}
|
||||
disabled={!viewportControlsEnabled}
|
||||
className={cn(
|
||||
'p-2 transition-transform flex flex-col items-center rounded-apple-lg border',
|
||||
activePreset === 'front'
|
||||
? 'bg-white/80 text-apple-label-primary shadow-apple-sm border-apple-separator/30'
|
||||
: 'text-apple-label-tertiary border-transparent hover:text-apple-label-primary hover:scale-110 disabled:opacity-40 disabled:hover:scale-100',
|
||||
)}
|
||||
>
|
||||
<Square strokeWidth={1.5} className="h-5 w-5" />
|
||||
<span className="block text-[8px] mt-1 font-apple-bold">FRONT</span>
|
||||
</button>
|
||||
<button className="p-2 text-apple-label-tertiary hover:text-apple-label-primary hover:scale-110 transition-transform group flex flex-col items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handlePresetChange('top')}
|
||||
disabled={!viewportControlsEnabled}
|
||||
className={cn(
|
||||
'p-2 transition-transform group flex flex-col items-center rounded-apple-lg border',
|
||||
activePreset === 'top'
|
||||
? 'bg-white/80 text-apple-label-primary shadow-apple-sm border-apple-separator/30'
|
||||
: 'text-apple-label-tertiary border-transparent hover:text-apple-label-primary hover:scale-110 disabled:opacity-40 disabled:hover:scale-100',
|
||||
)}
|
||||
>
|
||||
<Grid3X3 strokeWidth={1.5} className="h-5 w-5" />
|
||||
<span className="block text-[8px] mt-1 font-apple-bold group-hover:text-apple-label-primary">TOP</span>
|
||||
</button>
|
||||
<button className="p-2 text-apple-label-tertiary hover:text-apple-label-primary hover:scale-110 transition-transform group flex flex-col items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handlePresetChange('isometric')}
|
||||
disabled={!viewportControlsEnabled}
|
||||
className={cn(
|
||||
'p-2 transition-transform group flex flex-col items-center rounded-apple-lg border',
|
||||
activePreset === 'isometric'
|
||||
? 'bg-white/80 text-apple-label-primary shadow-apple-sm border-apple-separator/30'
|
||||
: 'text-apple-label-tertiary border-transparent hover:text-apple-label-primary hover:scale-110 disabled:opacity-40 disabled:hover:scale-100',
|
||||
)}
|
||||
>
|
||||
<BoxSelect strokeWidth={1.5} className="h-5 w-5" />
|
||||
<span className="block text-[8px] mt-1 font-apple-bold group-hover:text-apple-label-primary">ISO</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSceneGrid((current) => !current)}
|
||||
disabled={!viewportControlsEnabled}
|
||||
className={cn(
|
||||
'mb-4 p-2 transition-transform flex flex-col items-center rounded-apple-lg border',
|
||||
showSceneGrid
|
||||
? 'bg-white/80 text-apple-label-primary shadow-apple-sm border-apple-separator/30'
|
||||
: 'text-apple-label-tertiary border-transparent hover:text-apple-label-primary hover:scale-110 disabled:opacity-40 disabled:hover:scale-100',
|
||||
)}
|
||||
aria-pressed={showSceneGrid}
|
||||
aria-label="切换网格显示"
|
||||
title={showSceneGrid ? '网格已开启' : '网格已关闭'}
|
||||
>
|
||||
<Grid3X3 strokeWidth={1.5} className="h-5 w-5" />
|
||||
<span className="block text-[8px] mt-1 font-apple-bold">
|
||||
{showSceneGrid ? 'GRID ON' : 'GRID OFF'}
|
||||
</span>
|
||||
</button>
|
||||
<button className="p-2 text-apple-label-tertiary hover:text-apple-label-primary hover:scale-110 transition-transform">
|
||||
<Settings2 strokeWidth={1.5} className="h-5 w-5" />
|
||||
</button>
|
||||
@ -122,10 +269,12 @@ export function ModelPreviewPanel({
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="bg-white/70 px-4 py-2 rounded-apple-xl shadow-apple-sm border border-white/50 backdrop-blur-[20px] flex flex-col justify-center">
|
||||
<span className="block text-[10px] font-apple-bold text-apple-label-tertiary uppercase tracking-widest leading-none">FPS</span>
|
||||
<span className="font-display font-apple-bold text-apple-label-secondary mt-1 leading-none">60.0</span>
|
||||
</div>
|
||||
{showFpsBadge && (
|
||||
<div className="bg-white/70 px-4 py-2 rounded-apple-xl shadow-apple-sm border border-white/50 backdrop-blur-[20px] flex flex-col justify-center">
|
||||
<span className="block text-[10px] font-apple-bold text-apple-label-tertiary uppercase tracking-widest leading-none">FPS</span>
|
||||
<span className="font-display font-apple-bold text-apple-label-secondary mt-1 leading-none">60.0</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasContent ? (
|
||||
@ -135,7 +284,17 @@ export function ModelPreviewPanel({
|
||||
<CompositeAssetPreview nodes={previewNodes as any} />
|
||||
) : (
|
||||
<div className="h-full w-full">
|
||||
<ModelViewer modelUrl={modelUrl!} />
|
||||
<ModelViewer
|
||||
modelUrl={modelUrl!}
|
||||
showSceneGrid={showSceneGrid}
|
||||
onEngineReady={handleEngineReady}
|
||||
onModelNodesChange={onModelNodesChange}
|
||||
selectableNodeNames={selectableNodeNames}
|
||||
selectedNodeName={selectedNodeName}
|
||||
onNodeSelect={onNodeSelect}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
materialAssignments={materialAssignments}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -115,29 +115,29 @@ const ButtonComponent = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
const variantStyles = {
|
||||
filled: [
|
||||
'text-white',
|
||||
'shadow-apple',
|
||||
'border border-transparent',
|
||||
'hover:opacity-90 hover:shadow-apple-md',
|
||||
'focus:border-2 focus:border-apple-blue',
|
||||
'shadow-apple-md',
|
||||
'border border-white/10',
|
||||
'hover:opacity-90 hover:shadow-apple-lg hover:scale-[1.02]',
|
||||
'focus:border-2 focus:border-apple-separator',
|
||||
],
|
||||
tinted: [
|
||||
'text-apple-blue bg-apple-blue/10',
|
||||
'border border-transparent',
|
||||
'hover:bg-apple-blue/15',
|
||||
'focus:border-2 focus:border-apple-blue',
|
||||
'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',
|
||||
],
|
||||
gray: [
|
||||
'text-apple-label-primary',
|
||||
'bg-apple-bg-secondary',
|
||||
'border border-apple-separator',
|
||||
'hover:bg-apple-bg-tertiary hover:border-apple-label-tertiary',
|
||||
'focus:border-2 focus:border-apple-blue',
|
||||
'border border-apple-separator/30',
|
||||
'hover:bg-apple-bg-tertiary hover:border-apple-separator/60',
|
||||
'focus:border-2 focus:border-apple-separator',
|
||||
],
|
||||
plain: [
|
||||
'text-apple-blue bg-transparent',
|
||||
'text-apple-label-secondary bg-transparent',
|
||||
'border border-transparent',
|
||||
'hover:bg-apple-blue/8',
|
||||
'focus:border-2 focus:border-apple-blue',
|
||||
'hover:bg-apple-bg-secondary hover:text-apple-label-primary',
|
||||
'focus:border-2 focus:border-apple-separator',
|
||||
],
|
||||
}
|
||||
|
||||
@ -149,11 +149,11 @@ const ButtonComponent = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
baseStyles,
|
||||
sizeStyles[size],
|
||||
variantStyles[variant],
|
||||
primary && 'ring-2 ring-apple-blue/20',
|
||||
primary && 'ring-2 ring-apple-separator/20',
|
||||
className
|
||||
)}
|
||||
style={variant === 'filled' ? {
|
||||
background: 'linear-gradient(145deg, var(--color-blue), var(--color-teal))',
|
||||
background: 'linear-gradient(145deg, var(--color-primary), var(--color-secondary))',
|
||||
} : undefined}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@ -6,6 +6,10 @@ jest.mock("three/examples/jsm/loaders/GLTFLoader.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("three/examples/jsm/loaders/OBJLoader.js", () => ({
|
||||
OBJLoader: class {},
|
||||
}));
|
||||
|
||||
jest.mock("three/examples/jsm/loaders/DRACOLoader.js", () => ({
|
||||
DRACOLoader: class {
|
||||
setDecoderPath(): void {}
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
import * as THREE from "three";
|
||||
import { computeCenteredModelPlacement } from "../model-placement";
|
||||
|
||||
describe("computeCenteredModelPlacement", () => {
|
||||
it("默认仍使用几何中心归位", () => {
|
||||
const box = new THREE.Box3(
|
||||
new THREE.Vector3(-700, -300, 0),
|
||||
new THREE.Vector3(700, 300, 25),
|
||||
);
|
||||
|
||||
const placement = computeCenteredModelPlacement(box);
|
||||
|
||||
expect(placement.modelPosition.x).toBeCloseTo(0);
|
||||
expect(placement.modelPosition.y).toBeCloseTo(0);
|
||||
expect(placement.modelPosition.z).toBeCloseTo(-12.5);
|
||||
expect(placement.cameraTarget.toArray()).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it("OBJ/CAD 模型按底部贴地、XY 居中归位", () => {
|
||||
const box = new THREE.Box3(
|
||||
new THREE.Vector3(120, -80, 35),
|
||||
new THREE.Vector3(1520, 520, 60),
|
||||
);
|
||||
|
||||
const placement = computeCenteredModelPlacement(box, {
|
||||
alignBottomToGround: true,
|
||||
});
|
||||
|
||||
expect(placement.modelPosition.toArray()).toEqual([-820, 80, -47.5]);
|
||||
expect(placement.cameraTarget.toArray()).toEqual([0, 300, 0]);
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,6 @@
|
||||
import * as THREE from "three";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
|
||||
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { createDefaultSceneLights, setupRoomEnvironment } from "./engine-core";
|
||||
@ -46,11 +47,13 @@ export interface NodePositionOffset {
|
||||
* 3D娓叉煋寮曟搸绫? * 璐熻矗绠$悊Three.js鍦烘櫙銆佺浉鏈恒€佹覆鏌撳櫒鍜屾帶鍒跺櫒
|
||||
*/
|
||||
export class ThreeDEngine {
|
||||
private container: HTMLElement;
|
||||
private scene: THREE.Scene;
|
||||
private camera: THREE.PerspectiveCamera;
|
||||
private renderer: THREE.WebGLRenderer;
|
||||
private controls: OrbitControls;
|
||||
private loader: GLTFLoader;
|
||||
private gltfLoader: GLTFLoader;
|
||||
private objLoader: OBJLoader;
|
||||
private animationId: number | null = null;
|
||||
private highlightedParts: Set<string> = new Set();
|
||||
private modelRoot: THREE.Group | null = null;
|
||||
@ -59,6 +62,8 @@ export class ThreeDEngine {
|
||||
private modelOriginalCenter = new THREE.Vector3();
|
||||
private modelOriginalMaxDim = 0;
|
||||
private environmentTexture: THREE.Texture | null = null;
|
||||
private sceneGrid: THREE.GridHelper | null = null;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private lastExplodedProgress = -1;
|
||||
private runtimeSelectionAliasMap: Map<string, string> = new Map();
|
||||
private runtimePreferredObjectUuidByNormalizedName: Map<string, string> =
|
||||
@ -230,12 +235,13 @@ export class ThreeDEngine {
|
||||
|
||||
constructor(container: HTMLElement, options: EngineOptions = {}) {
|
||||
const { antialias = true, alpha = true } = options;
|
||||
this.container = container;
|
||||
|
||||
this.scene = new THREE.Scene();
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(
|
||||
45,
|
||||
container.clientWidth / Math.max(container.clientHeight, 1),
|
||||
Math.max(container.clientWidth, 1) / Math.max(container.clientHeight, 1),
|
||||
0.1,
|
||||
10000,
|
||||
);
|
||||
@ -245,11 +251,18 @@ export class ThreeDEngine {
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias, alpha });
|
||||
this.renderer.setPixelRatio(window.devicePixelRatio);
|
||||
this.renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
this.renderer.setSize(
|
||||
Math.max(container.clientWidth, 1),
|
||||
Math.max(container.clientHeight, 1),
|
||||
false,
|
||||
);
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
this.renderer.toneMappingExposure = 1.1;
|
||||
this.renderer.setClearColor(0xe4e6ea, 1);
|
||||
this.renderer.domElement.style.width = "100%";
|
||||
this.renderer.domElement.style.height = "100%";
|
||||
this.renderer.domElement.style.display = "block";
|
||||
container.appendChild(this.renderer.domElement);
|
||||
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||
@ -262,14 +275,20 @@ export class ThreeDEngine {
|
||||
|
||||
this.setupLights();
|
||||
this.setupEnvironment();
|
||||
this.setupSceneGrid();
|
||||
|
||||
this.loader = new GLTFLoader();
|
||||
this.gltfLoader = new GLTFLoader();
|
||||
this.objLoader = new OBJLoader();
|
||||
const dracoLoader = new DRACOLoader();
|
||||
dracoLoader.setDecoderPath("/draco/gltf/");
|
||||
this.loader.setDRACOLoader(dracoLoader);
|
||||
this.gltfLoader.setDRACOLoader(dracoLoader);
|
||||
|
||||
this.animate();
|
||||
bindEngineEvents(this.renderer.domElement, this.domEventHandlers);
|
||||
this.setupResizeObserver();
|
||||
window.requestAnimationFrame(() => {
|
||||
this.handleResize();
|
||||
});
|
||||
}
|
||||
|
||||
private setupLights(): void {
|
||||
@ -280,21 +299,105 @@ export class ThreeDEngine {
|
||||
this.environmentTexture = setupRoomEnvironment(this.renderer, this.scene);
|
||||
}
|
||||
|
||||
private handleResize = (): void => {
|
||||
const container = this.renderer.domElement.parentElement;
|
||||
if (!container) return;
|
||||
private setupSceneGrid(): void {
|
||||
const grid = new THREE.GridHelper(4000, 40, 0xa4acb8, 0xd3d9de);
|
||||
grid.name = "__scene_grid__";
|
||||
grid.visible = false;
|
||||
this.scene.add(grid);
|
||||
this.sceneGrid = grid;
|
||||
}
|
||||
|
||||
this.camera.aspect = container.clientWidth / container.clientHeight;
|
||||
private handleResize = (): void => {
|
||||
const width = Math.max(this.container.clientWidth, 1);
|
||||
const height = Math.max(this.container.clientHeight, 1);
|
||||
|
||||
this.camera.aspect = width / height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
this.renderer.setSize(width, height, false);
|
||||
};
|
||||
|
||||
private setupResizeObserver(): void {
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.handleResize();
|
||||
});
|
||||
this.resizeObserver.observe(this.container);
|
||||
}
|
||||
|
||||
private animate = (): void => {
|
||||
this.animationId = startRenderLoop(this.animate);
|
||||
this.controls.update();
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
};
|
||||
|
||||
private prepareObjModel(model: THREE.Group): void {
|
||||
model.traverse((child) => {
|
||||
if (!(child as THREE.Mesh).isMesh) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mesh = child as THREE.Mesh;
|
||||
const geometry = mesh.geometry;
|
||||
if (geometry) {
|
||||
if (!geometry.attributes.normal) {
|
||||
geometry.computeVertexNormals();
|
||||
}
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
}
|
||||
|
||||
const materials = Array.isArray(mesh.material)
|
||||
? mesh.material
|
||||
: [mesh.material];
|
||||
materials.forEach((material) => {
|
||||
if (!material) return;
|
||||
material.side = THREE.DoubleSide;
|
||||
const standardLikeMaterial = material as THREE.MeshStandardMaterial & {
|
||||
color?: THREE.Color;
|
||||
roughness?: number;
|
||||
metalness?: number;
|
||||
envMapIntensity?: number;
|
||||
};
|
||||
if (
|
||||
!("map" in standardLikeMaterial) ||
|
||||
!standardLikeMaterial.map
|
||||
) {
|
||||
standardLikeMaterial.color?.set(0x93a1ad);
|
||||
if (typeof standardLikeMaterial.roughness === "number") {
|
||||
standardLikeMaterial.roughness = 0.78;
|
||||
}
|
||||
if (typeof standardLikeMaterial.metalness === "number") {
|
||||
standardLikeMaterial.metalness = 0.08;
|
||||
}
|
||||
if (typeof standardLikeMaterial.envMapIntensity === "number") {
|
||||
standardLikeMaterial.envMapIntensity = 0.65;
|
||||
}
|
||||
}
|
||||
material.needsUpdate = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private getModelExtension(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url, "http://localhost");
|
||||
const pathname = parsed.pathname.toLowerCase();
|
||||
if (pathname.endsWith(".obj")) return ".obj";
|
||||
if (pathname.endsWith(".gltf")) return ".gltf";
|
||||
if (pathname.endsWith(".glb")) return ".glb";
|
||||
return "";
|
||||
} catch {
|
||||
const normalized = url.toLowerCase();
|
||||
if (normalized.includes(".obj")) return ".obj";
|
||||
if (normalized.includes(".gltf")) return ".gltf";
|
||||
if (normalized.includes(".glb")) return ".glb";
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 鍔犺浇glTF妯″瀷
|
||||
* @param url 妯″瀷鏂囦欢URL
|
||||
@ -305,13 +408,24 @@ export class ThreeDEngine {
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.loader.load(
|
||||
const extension = this.getModelExtension(url);
|
||||
const activeLoader =
|
||||
extension === ".obj" ? this.objLoader : this.gltfLoader;
|
||||
|
||||
activeLoader.load(
|
||||
url,
|
||||
(gltf) => {
|
||||
(loadedModel) => {
|
||||
if (SELECTION_DEBUG_ENABLED) {
|
||||
console.log("[3D Engine] Model loaded, processing...");
|
||||
}
|
||||
const model = gltf.scene;
|
||||
const model =
|
||||
extension === ".obj"
|
||||
? (loadedModel as THREE.Group)
|
||||
: ((loadedModel as { scene: THREE.Group }).scene ?? null);
|
||||
if (!model) {
|
||||
reject(new Error("模型文件格式无效,缺少可渲染场景"));
|
||||
return;
|
||||
}
|
||||
this.modelRoot = model;
|
||||
this.lastExplodedProgress = -1;
|
||||
this.meshOriginalPositions.clear();
|
||||
@ -325,6 +439,10 @@ export class ThreeDEngine {
|
||||
// 坐标系转换:AutoCAD (Z-up) → Three.js (Y-up)
|
||||
// 绕 X 轴旋转 -90°,将 Z 轴向上转换为 Y 轴向上
|
||||
model.rotation.x = -Math.PI / 2;
|
||||
if (extension === ".obj") {
|
||||
// CAD OBJ 常见面朝向不稳定,统一转双面并补齐几何法线,避免“加载成功但看不到模型”。
|
||||
this.prepareObjModel(model);
|
||||
}
|
||||
|
||||
this.scene.add(model);
|
||||
|
||||
@ -344,7 +462,9 @@ export class ThreeDEngine {
|
||||
|
||||
// 统一按模型几何中心归位,禁止再按“地面”抬升整体。
|
||||
// 否则爆炸图虽然按中心扩散,模型本体仍会带着地面约束整体上移。
|
||||
const placement = computeCenteredModelPlacement(box);
|
||||
const placement = computeCenteredModelPlacement(box, {
|
||||
alignBottomToGround: extension === ".obj",
|
||||
});
|
||||
model.position.copy(placement.modelPosition);
|
||||
|
||||
model.updateMatrixWorld(true);
|
||||
@ -756,6 +876,10 @@ export class ThreeDEngine {
|
||||
|
||||
// 绉婚櫎浜嬩欢鐩戝惉
|
||||
unbindEngineEvents(this.renderer.domElement, this.domEventHandlers);
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
|
||||
this.disposeSceneObjects(this.scene);
|
||||
|
||||
@ -894,6 +1018,16 @@ export class ThreeDEngine {
|
||||
return this.scene;
|
||||
}
|
||||
|
||||
setSceneGridVisible(visible: boolean): void {
|
||||
if (this.sceneGrid) {
|
||||
this.sceneGrid.visible = visible;
|
||||
}
|
||||
}
|
||||
|
||||
isSceneGridVisible(): boolean {
|
||||
return this.sceneGrid?.visible ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 鑾峰彇褰撳墠妯″瀷鏍硅妭鐐? * @returns THREE.Group | null 妯″瀷鏍硅妭鐐? */
|
||||
getModelRoot(): THREE.Group | null {
|
||||
|
||||
@ -5,10 +5,26 @@ export interface CenteredModelPlacement {
|
||||
cameraTarget: THREE.Vector3;
|
||||
}
|
||||
|
||||
export interface ModelPlacementOptions {
|
||||
alignBottomToGround?: boolean;
|
||||
}
|
||||
|
||||
export function computeCenteredModelPlacement(
|
||||
box: THREE.Box3,
|
||||
options: ModelPlacementOptions = {},
|
||||
): CenteredModelPlacement {
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const alignBottomToGround = options.alignBottomToGround ?? false;
|
||||
|
||||
if (alignBottomToGround) {
|
||||
return {
|
||||
// Three.js 场景采用 Y-up。CAD/OBJ 在坐标系转换后,真正的“贴地”应取 Y 最小值,
|
||||
// 平面内容则按 X/Z 居中,避免把模型沿错误轴推出镜头。
|
||||
modelPosition: new THREE.Vector3(-center.x, -box.min.y, -center.z),
|
||||
cameraTarget: new THREE.Vector3(0, size.y / 2, 0),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
modelPosition: center.clone().multiplyScalar(-1),
|
||||
|
||||
@ -0,0 +1,226 @@
|
||||
import type { AssetType } from '@prisma/client'
|
||||
|
||||
export interface ConnectionRoleDefinition {
|
||||
value: string
|
||||
label: string
|
||||
assetType: AssetType
|
||||
seriesCode?: string | null
|
||||
builtIn?: boolean
|
||||
}
|
||||
|
||||
interface ConnectionRoleMetadataRecord {
|
||||
value: string
|
||||
label: string
|
||||
assetType?: AssetType
|
||||
seriesCode?: string | null
|
||||
}
|
||||
|
||||
const LEGACY_ROLE_VALUE_MAP: Record<string, string> = {
|
||||
desktop: 'desktop_surface',
|
||||
}
|
||||
|
||||
const STANDARD_CONNECTION_ROLE_DEFINITIONS: ConnectionRoleDefinition[] = [
|
||||
{
|
||||
value: 'desktop_surface',
|
||||
label: '桌面板',
|
||||
assetType: 'BOARD',
|
||||
seriesCode: 'single-board-desk',
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
value: 'desk_leg',
|
||||
label: '桌腿',
|
||||
assetType: 'COMPONENT',
|
||||
seriesCode: 'single-board-desk',
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
value: 'desk_leg_connector',
|
||||
label: '桌腿连接件',
|
||||
assetType: 'HARDWARE',
|
||||
seriesCode: 'single-board-desk',
|
||||
builtIn: true,
|
||||
},
|
||||
]
|
||||
|
||||
function normalizeRoleValue(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
return LEGACY_ROLE_VALUE_MAP[trimmed] ?? trimmed
|
||||
}
|
||||
|
||||
export function normalizeConnectionRoles(values: string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const normalized: string[] = []
|
||||
|
||||
values.forEach((value) => {
|
||||
const nextValue = normalizeRoleValue(value)
|
||||
if (!nextValue || seen.has(nextValue)) {
|
||||
return
|
||||
}
|
||||
seen.add(nextValue)
|
||||
normalized.push(nextValue)
|
||||
})
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function getStandardConnectionRoleDefinitions(params: {
|
||||
assetType: AssetType
|
||||
seriesCode?: string | null
|
||||
}): ConnectionRoleDefinition[] {
|
||||
const { assetType, seriesCode } = params
|
||||
|
||||
return STANDARD_CONNECTION_ROLE_DEFINITIONS.filter((definition) => {
|
||||
if (definition.assetType !== assetType) {
|
||||
return false
|
||||
}
|
||||
if (!seriesCode) {
|
||||
return true
|
||||
}
|
||||
return definition.seriesCode === seriesCode
|
||||
})
|
||||
}
|
||||
|
||||
export function getConnectionRoleDefinitionsFromMetadata(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): ConnectionRoleDefinition[] {
|
||||
const records = Array.isArray(metadata?.connectionRoleDefinitions)
|
||||
? metadata.connectionRoleDefinitions
|
||||
: []
|
||||
const definitions: ConnectionRoleDefinition[] = []
|
||||
|
||||
records.forEach((record) => {
|
||||
if (!record || typeof record !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const entry = record as ConnectionRoleMetadataRecord
|
||||
if (typeof entry.value !== 'string' || typeof entry.label !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
definitions.push({
|
||||
value: normalizeRoleValue(entry.value),
|
||||
label: entry.label.trim(),
|
||||
assetType: entry.assetType ?? 'COMPONENT',
|
||||
seriesCode: entry.seriesCode ?? null,
|
||||
builtIn: false,
|
||||
})
|
||||
})
|
||||
|
||||
return definitions
|
||||
}
|
||||
|
||||
export function buildConnectionRoleMetadata(
|
||||
definitions: ConnectionRoleDefinition[],
|
||||
baseMetadata: Record<string, unknown> | null | undefined,
|
||||
options?: {
|
||||
hiddenValues?: string[]
|
||||
},
|
||||
): Record<string, unknown> | null {
|
||||
const nextDefinitions = definitions.map((definition) => ({
|
||||
value: normalizeRoleValue(definition.value),
|
||||
label: definition.label.trim(),
|
||||
assetType: definition.assetType,
|
||||
seriesCode: definition.seriesCode ?? null,
|
||||
}))
|
||||
|
||||
const metadata = {
|
||||
...(baseMetadata ?? {}),
|
||||
connectionRoleDefinitions: nextDefinitions,
|
||||
connectionRoleHiddenValues: normalizeConnectionRoles(options?.hiddenValues ?? []),
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
function getHiddenConnectionRoleValuesFromMetadata(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): string[] {
|
||||
if (!Array.isArray(metadata?.connectionRoleHiddenValues)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return normalizeConnectionRoles(
|
||||
metadata.connectionRoleHiddenValues.filter(
|
||||
(value): value is string => typeof value === 'string',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function mergeConnectionRoleDefinitions(params: {
|
||||
assetType: AssetType
|
||||
seriesCode?: string | null
|
||||
selectedRoles?: string[]
|
||||
metadata?: Record<string, unknown> | null
|
||||
}): ConnectionRoleDefinition[] {
|
||||
const { assetType, seriesCode, selectedRoles = [], metadata } = params
|
||||
const standardDefinitions = getStandardConnectionRoleDefinitions({ assetType, seriesCode })
|
||||
const hiddenValues = new Set(getHiddenConnectionRoleValuesFromMetadata(metadata))
|
||||
const metadataDefinitions = getConnectionRoleDefinitionsFromMetadata(metadata).filter(
|
||||
(definition) => definition.assetType === assetType,
|
||||
)
|
||||
const result = new Map<string, ConnectionRoleDefinition>()
|
||||
|
||||
;[
|
||||
...standardDefinitions.filter((definition) => !hiddenValues.has(normalizeRoleValue(definition.value))),
|
||||
...metadataDefinitions,
|
||||
].forEach((definition) => {
|
||||
result.set(normalizeRoleValue(definition.value), {
|
||||
...definition,
|
||||
value: normalizeRoleValue(definition.value),
|
||||
})
|
||||
})
|
||||
|
||||
normalizeConnectionRoles(selectedRoles).forEach((value) => {
|
||||
if (result.has(value)) {
|
||||
return
|
||||
}
|
||||
|
||||
result.set(value, {
|
||||
value,
|
||||
label: value,
|
||||
assetType,
|
||||
seriesCode: seriesCode ?? null,
|
||||
builtIn: false,
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(result.values()).sort((left, right) => {
|
||||
if ((left.builtIn ?? false) !== (right.builtIn ?? false)) {
|
||||
return left.builtIn ? -1 : 1
|
||||
}
|
||||
return left.label.localeCompare(right.label, 'zh-CN')
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveConnectionRoleLabel(
|
||||
value: string,
|
||||
definitions: ConnectionRoleDefinition[],
|
||||
): string {
|
||||
const normalizedValue = normalizeRoleValue(value)
|
||||
return definitions.find((definition) => definition.value === normalizedValue)?.label ?? normalizedValue
|
||||
}
|
||||
|
||||
export function createCustomConnectionRoleValue(params: {
|
||||
label: string
|
||||
existingValues: string[]
|
||||
}): string {
|
||||
const base = params.label
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
|| 'custom-role'
|
||||
|
||||
const normalizedExistingValues = new Set(params.existingValues.map((value) => normalizeRoleValue(value)))
|
||||
let candidate = `custom:${base}`
|
||||
let index = 2
|
||||
|
||||
while (normalizedExistingValues.has(candidate)) {
|
||||
candidate = `custom:${base}-${index}`
|
||||
index += 1
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
CreateAssetFamilyInput,
|
||||
CreateAssetVariantInput,
|
||||
CreateDraftGroupFromAutoCombinationInput,
|
||||
MaterialCategoryDTO,
|
||||
CreateSubstrateInput,
|
||||
DraftGroupDetailDTO,
|
||||
FormalizeDraftGroupInput,
|
||||
@ -17,6 +18,7 @@ import type {
|
||||
SavePreSkuPackagingPlanInput,
|
||||
WarehouseLocationDTO,
|
||||
} from '@/server/services'
|
||||
import type { MaterialDTO } from '@/server/services'
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
success: boolean
|
||||
@ -174,6 +176,24 @@ export async function createSubstrate(input: CreateSubstrateInput): Promise<{
|
||||
})
|
||||
}
|
||||
|
||||
export async function createMaterialCategory(input: {
|
||||
name: string
|
||||
icon?: string | null
|
||||
sortOrder?: number
|
||||
isActive?: boolean
|
||||
}): Promise<{ category: MaterialCategoryDTO }> {
|
||||
return requestPlatform('/api/material-categories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteMaterial(id: string): Promise<void> {
|
||||
await requestPlatform<{ material: MaterialDTO }>(`/api/materials/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function generateAutoCombinations(
|
||||
input: {
|
||||
seriesTemplateId: string
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user