Merge remote-tracking branch 'origin/main' into codex/xuxiake-packaging

This commit is contained in:
Codex 2026-04-08 10:15:44 +08:00
commit ca5c44d2ec
19 changed files with 2306 additions and 314 deletions

View 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 个业务文件 | 硬编码颜色替换 |

View File

@ -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 导入弹窗自动带出命名交互是否清晰
- [ ] **包材资料库页面视觉联调**
归属:`联调确认`
说明:确认该页面视觉是否已统一到主线风格。

View 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
}

View File

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

View File

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

View File

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

View File

@ -2,8 +2,8 @@ import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react'
import AssetCenterClient from './AssetCenterClient'
import { 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()
})
})

View File

@ -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={[

View File

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

View File

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

View File

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

View File

@ -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}

View File

@ -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 {}

View File

@ -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]);
});
});

View File

@ -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 {

View File

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

View File

@ -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
}

View File

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