diff --git a/docs/前端UI改动清单.md b/docs/前端UI改动清单.md index dedfaf9d..2910046f 100644 --- a/docs/前端UI改动清单.md +++ b/docs/前端UI改动清单.md @@ -12,9 +12,3 @@ --- ## 当前未完成项 - -### 待前端修正 (前端 UI Agent 负责) - -- [ ] **包材资料库页面视觉联调** - 归属:`前端负责` - 说明:确认该页面视觉是否已统一到主线风格。 diff --git a/furniture-3d-system/src/app/api/draft-groups/route.ts b/furniture-3d-system/src/app/api/draft-groups/route.ts index 1f339027..33249ba0 100644 --- a/furniture-3d-system/src/app/api/draft-groups/route.ts +++ b/furniture-3d-system/src/app/api/draft-groups/route.ts @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server' import { AppError, ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' import { draftGroupService } from '@/server/services' +import type { CreateDraftGroupFromAutoCombinationInput } from '@/server/services' import { createDraftGroupSchema } from '../platform/schemas' export async function GET(request: NextRequest) { @@ -38,7 +39,10 @@ export async function POST(request: NextRequest) { } try { - const result = await draftGroupService.createFromAutoCombination(parsed.data, ctx.user) + const result = await draftGroupService.createFromAutoCombination( + parsed.data as unknown as CreateDraftGroupFromAutoCombinationInput, + ctx.user, + ) return jsonSuccess({ result }, 201, ctx.requestId) } catch (error) { if (error instanceof AppError) { diff --git a/furniture-3d-system/src/app/api/platform/schemas.ts b/furniture-3d-system/src/app/api/platform/schemas.ts index ef9f521f..f593114f 100644 --- a/furniture-3d-system/src/app/api/platform/schemas.ts +++ b/furniture-3d-system/src/app/api/platform/schemas.ts @@ -237,6 +237,8 @@ export const createDraftGroupSchema = z.object({ z.object({ key: z.string().trim().min(1), defaultName: z.string().trim().min(1), + candidateSnapshot: z.record(z.string(), z.unknown()).optional().nullable(), + assemblyWorkspace: z.record(z.string(), z.unknown()).optional().nullable(), familySelection: z.object({ desktopFamilyId: z.string().trim().min(1), legGroupFamilyId: z.string().trim().min(1).optional().nullable(), diff --git a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx index e18fb7e0..07053c47 100644 --- a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx +++ b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx @@ -177,6 +177,10 @@ function dictionaryLabel(type: SubstrateDictionaryType) { } } +function isLegacySeedOptionId(id: string) { + return id.startsWith('seed_substrate_') +} + export default function MaterialLibraryClient({ initialSubstrates, initialFinishes, @@ -255,6 +259,33 @@ export default function MaterialLibraryClient({ }) }, [substrateDictionaries, substrateForm.environmentGradeId, substrateForm.systemNameOptionId, substrateForm.thicknessSpecId]) + const usedDictionaryIds = useMemo(() => { + return { + CATEGORY: new Set(substrates.map((item) => item.categoryId).filter(Boolean)), + ENVIRONMENT_GRADE: new Set(substrates.map((item) => item.environmentGradeId).filter(Boolean)), + THICKNESS_SPEC: new Set(substrates.map((item) => item.thicknessSpecId).filter(Boolean)), + SURFACE_OPTION: new Set(substrates.map((item) => item.surfaceOptionId).filter(Boolean)), + SYSTEM_NAME_OPTION: new Set(substrates.map((item) => item.systemNameOptionId).filter(Boolean)), + } as Record> + }, [substrates]) + + const visibleDictionaryOptions = useMemo(() => { + const filterOptions = (type: SubstrateDictionaryType, items: SubstrateDictionaryOptionDTO[]) => + items.filter((item) => !isLegacySeedOptionId(item.id) || usedDictionaryIds[type].has(item.id)) + return { + categories: filterOptions('CATEGORY', substrateDictionaries.categories), + environmentGrades: filterOptions('ENVIRONMENT_GRADE', substrateDictionaries.environmentGrades), + thicknessSpecs: filterOptions('THICKNESS_SPEC', substrateDictionaries.thicknessSpecs), + surfaceOptions: filterOptions('SURFACE_OPTION', substrateDictionaries.surfaceOptions), + systemNameOptions: filterOptions('SYSTEM_NAME_OPTION', substrateDictionaries.systemNameOptions), + } + }, [substrateDictionaries, usedDictionaryIds]) + + const activeDictionaryOptions = useMemo( + () => dictionaryOptionsByType(substrateDictionaries, activeDictionaryType), + [activeDictionaryType, substrateDictionaries], + ) + const filteredFinishes = useMemo(() => { const keyword = searchText.trim().toLowerCase() return finishes.filter((item) => { @@ -270,7 +301,16 @@ export default function MaterialLibraryClient({ function openCreateSubstrateModal() { setSubstrateModalMode('create') setEditingSubstrateId(null) - setSubstrateForm(emptySubstrateForm(substrateDictionaries)) + setSubstrateForm({ + code: '', + description: '', + categoryId: visibleDictionaryOptions.categories[0]?.id ?? '', + environmentGradeId: visibleDictionaryOptions.environmentGrades[0]?.id ?? '', + thicknessSpecId: visibleDictionaryOptions.thicknessSpecs[0]?.id ?? '', + surfaceOptionId: visibleDictionaryOptions.surfaceOptions[0]?.id ?? '', + systemNameOptionId: visibleDictionaryOptions.systemNameOptions[0]?.id ?? '', + suggestedPrice: '', + }) setSubstrateModalOpen(true) } @@ -366,6 +406,12 @@ export default function MaterialLibraryClient({ setDictionaryModalOpen(true) } + function beginCreateDictionaryOption() { + setDictionaryModalMode('create') + setEditingDictionaryId(null) + setDictionaryForm(emptyDictionaryForm()) + } + async function handleSaveDictionary() { if (!dictionaryForm.name.trim()) { toast.error(`请填写${dictionaryLabel(activeDictionaryType)}名称`) @@ -704,9 +750,9 @@ export default function MaterialLibraryClient({ {activeTab === 'substrate' ? (
-
+
{(['CATEGORY', 'ENVIRONMENT_GRADE', 'THICKNESS_SPEC', 'SURFACE_OPTION', 'SYSTEM_NAME_OPTION'] as SubstrateDictionaryType[]).map((type) => ( - @@ -803,18 +849,49 @@ export default function MaterialLibraryClient({
- setSubstrateForm((current) => ({ ...current, environmentGradeId: event.target.value }))} options={substrateDictionaries.environmentGrades.map((item) => ({ value: item.id, label: item.name }))} /> - setSubstrateForm((current) => ({ ...current, surfaceOptionId: event.target.value }))} options={substrateDictionaries.surfaceOptions.map((item) => ({ value: item.id, label: item.name }))} /> - setSubstrateForm((current) => ({ ...current, categoryId: event.target.value }))} options={visibleDictionaryOptions.categories.map((item) => ({ value: item.id, label: item.name }))} /> + setSubstrateForm((current) => ({ ...current, thicknessSpecId: event.target.value }))} options={visibleDictionaryOptions.thicknessSpecs.map((item) => ({ value: item.id, label: item.name }))} /> + setSubstrateForm((current) => ({ ...current, systemNameOptionId: event.target.value }))} options={visibleDictionaryOptions.systemNameOptions.map((item) => ({ value: item.id, label: item.name }))} /> setSubstrateForm((current) => ({ ...current, suggestedPrice: event.target.value }))} placeholder="例如 180" />
setSubstrateForm((current) => ({ ...current, description: event.target.value }))} placeholder="可选" />
- setDictionaryModalOpen(false)} title={`${dictionaryModalMode === 'create' ? '新增' : '编辑'}${dictionaryLabel(activeDictionaryType)}`} size="small" footer={
}> + setDictionaryModalOpen(false)} title={`管理${dictionaryLabel(activeDictionaryType)}`} size="medium" footer={
}>
+
+

在这里可以查看已有选项,并直接新增/编辑/删除。

+ +
+
+ {activeDictionaryOptions.length === 0 ? ( +

暂无选项

+ ) : activeDictionaryOptions.map((item) => ( +
+
+

+ {item.name} + {isLegacySeedOptionId(item.id) ? '(历史默认)' : ''} +

+

+ 排序 {item.sortOrder} + {item.type === 'THICKNESS_SPEC' && item.thicknessMm != null ? ` · ${item.thicknessMm}mm` : ''} +

+
+ + +
+ ))} +
setDictionaryForm((current) => ({ ...current, name: event.target.value }))} /> setDictionaryForm((current) => ({ ...current, sortOrder: event.target.value }))} /> {activeDictionaryType === 'THICKNESS_SPEC' ? setDictionaryForm((current) => ({ ...current, thicknessMm: event.target.value }))} /> : null} diff --git a/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.tsx b/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.tsx index 96f4e73f..f98428df 100644 --- a/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.tsx +++ b/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.tsx @@ -1748,24 +1748,21 @@ export default function PackagingMaterialsClient({

从右侧面板开始配置

- 选择系列模板并勾选资产族后,生成候选并点击即可预览 3D 组合。 + 选择系列模板并勾选资产族后,生成候选并点击即可进入辅助式手动装配。

) : ( @@ -273,7 +273,7 @@ export default function AutoCombinationsClient({ 配置就绪,点击右侧「生成候选列表」

- 已选 {allSelectedFamilies.length} 个资产族,生成后可逐条预览 3D 组合效果。 + 已选 {allSelectedFamilies.length} 个资产族,生成后可逐条进入装配工作台确认最终结构。

)} @@ -479,7 +479,7 @@ export default function AutoCombinationsClient({ {/* 候选结果 */} } aside={ candidates.length > 0 ? ( @@ -494,6 +494,7 @@ export default function AutoCombinationsClient({ {candidates.length === 0 ? (
还没有候选结果。先选好模板和资产族,生成后点击候选卡片即可预览 3D 拼装效果。 +
) : (
@@ -550,6 +551,29 @@ export default function AutoCombinationsClient({ ))}
)} +
+ + 工作台 + {(candidateWorkspaces[candidate.key] ?? candidate.assemblyWorkspace).validation.level === + 'PASSED' + ? '已成立' + : (candidateWorkspaces[candidate.key] ?? candidate.assemblyWorkspace).validation.level === + 'WARNING' + ? '有警告' + : '未成立'} + +
))} diff --git a/furniture-3d-system/src/app/home/components/HomeModelsSection.tsx b/furniture-3d-system/src/app/home/components/HomeModelsSection.tsx index 83d2cd5d..c1dfc78d 100644 --- a/furniture-3d-system/src/app/home/components/HomeModelsSection.tsx +++ b/furniture-3d-system/src/app/home/components/HomeModelsSection.tsx @@ -91,7 +91,7 @@ export function HomeModelsSection({ ) : ( -
+
{filteredProducts.map((product) => ( setCopiedToken(null), 2000) }, []) + const unwrapShareLinkPayload = useCallback((payload: unknown): ShareLinkApiPayload => { + const resolved = + payload && + typeof payload === 'object' && + 'data' in payload && + payload.data && + typeof payload.data === 'object' + ? payload.data + : payload + + if ( + !resolved || + typeof resolved !== 'object' || + !('shareLink' in resolved) || + !resolved.shareLink || + typeof resolved.shareLink !== 'object' + ) { + throw new Error('分享链接返回数据不完整') + } + + const shareLink = resolved.shareLink as Record + if ( + typeof shareLink.id !== 'string' || + typeof shareLink.token !== 'string' || + typeof shareLink.viewCount !== 'number' + ) { + throw new Error('分享链接返回字段缺失') + } + + return { + shareLink: { + id: shareLink.id, + token: shareLink.token, + expiresAt: typeof shareLink.expiresAt === 'string' ? shareLink.expiresAt : null, + viewCount: shareLink.viewCount, + }, + createdBy: + 'createdBy' in resolved && typeof resolved.createdBy === 'string' + ? resolved.createdBy + : undefined, + } + }, []) + // 创建分享链接 const handleCreateShareLink = useCallback(async () => { setIsCreatingLink(true) @@ -195,12 +243,14 @@ export default function ProductDetailClient({ throw new Error(getApiErrorMessage(data.error, '创建分享链接失败')) } + const payload = unwrapShareLinkPayload(data) + // 添加新链接到列表 const newLink: ShareLinkData = { - id: data.shareLink.id, - token: data.shareLink.token, - expiresAt: data.shareLink.expiresAt, - viewCount: data.shareLink.viewCount, + id: payload.shareLink.id, + token: payload.shareLink.token, + expiresAt: payload.shareLink.expiresAt, + viewCount: payload.shareLink.viewCount, } setLinks(prev => [newLink, ...prev]) setShowCreateModal(false) @@ -213,7 +263,7 @@ export default function ProductDetailClient({ } finally { setIsCreatingLink(false) } - }, [product.id, expiresInDays, router]) + }, [product.id, expiresInDays, router, unwrapShareLinkPayload]) // 删除分享链接 const handleDeleteShareLink = useCallback(async (linkId: string) => { diff --git a/furniture-3d-system/src/app/products/[id]/components/ProductDetailTopBar.tsx b/furniture-3d-system/src/app/products/[id]/components/ProductDetailTopBar.tsx index 7be65998..f895ca10 100644 --- a/furniture-3d-system/src/app/products/[id]/components/ProductDetailTopBar.tsx +++ b/furniture-3d-system/src/app/products/[id]/components/ProductDetailTopBar.tsx @@ -21,6 +21,8 @@ export function ProductDetailTopBar({ hasAssemblyPreview, previewToken, }: ProductDetailTopBarProps) { + const previewHref = previewToken ? `/share/${previewToken}` : null + return (
@@ -45,9 +47,9 @@ export function ProductDetailTopBar({ > 编辑信息 - {hasAssemblyPreview && ( + {hasAssemblyPreview && previewHref && ( diff --git a/furniture-3d-system/src/components/platform/AssemblyWorkbench.tsx b/furniture-3d-system/src/components/platform/AssemblyWorkbench.tsx new file mode 100644 index 00000000..5759c4c9 --- /dev/null +++ b/furniture-3d-system/src/components/platform/AssemblyWorkbench.tsx @@ -0,0 +1,362 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import dynamic from 'next/dynamic' +import { CheckCircle2, MoveRight, PackagePlus, RotateCcw, Workflow } from 'lucide-react' +import type { + AssemblyWorkspaceNode, + AssemblyWorkspaceSnapshot, +} from '@/lib/open-product/assembly-workspace' +import { + applyNodeRoleConfirmation, + attemptSnapNode, + createSubassembly, + moveAssemblyNodes, + snapNodesIntoMainAssembly, + updateAssemblyNodePosition, +} from '@/lib/open-product/assembly-workspace' +import { Badge } from '@/components/ui/Badge' +import Button from '@/components/ui/Button' +import Checkbox from '@/components/ui/Checkbox' +import { cn } from '@/lib/utils' + +const AssemblyWorkbenchCanvas = dynamic( + () => + import('./AssemblyWorkbenchCanvas').then((module) => ({ + default: module.AssemblyWorkbenchCanvas, + })), + { + ssr: false, + loading: () => ( +
+ 装配工作区加载中... +
+ ), + }, +) + +interface AssemblyWorkbenchProps { + value: AssemblyWorkspaceSnapshot + candidateName: string + statusTag: string + warnings: string[] + onChange: (next: AssemblyWorkspaceSnapshot) => void +} + +const ROLE_OPTIONS = [ + { value: 'desktop', label: '桌面板' }, + { value: 'frontLeft', label: '前左腿' }, + { value: 'frontRight', label: '前右腿' }, + { value: 'rearLeft', label: '后左腿' }, + { value: 'rearRight', label: '后右腿' }, +] + +function getValidationTone(level: AssemblyWorkbenchProps['value']['validation']['level']) { + if (level === 'PASSED') return 'success' + if (level === 'WARNING') return 'warning' + return 'error' +} + +function getValidationLabel(level: AssemblyWorkbenchProps['value']['validation']['level']) { + if (level === 'PASSED') return '成立' + if (level === 'WARNING') return '有警告' + return '不成立' +} + +function ZoneColumn({ + title, + description, + nodes, + selectedNodeIds, + onToggle, +}: { + title: string + description: string + nodes: AssemblyWorkspaceNode[] + selectedNodeIds: string[] + onToggle: (nodeId: string) => void +}) { + return ( +
+
+

{title}

+

{description}

+
+ + {nodes.length === 0 ? ( +
+ 当前没有部件 +
+ ) : ( +
+ {nodes.map((node) => ( +
+ onToggle(node.nodeId)} + label={node.name} + description={ + [ + node.roleConfirmed ? `已确认:${node.roleConfirmed}` : null, + node.roleRecommendation ? `推荐:${node.roleRecommendation}` : null, + ] + .filter(Boolean) + .join(' / ') + } + /> +
+ ))} +
+ )} +
+ ) +} + +export function AssemblyWorkbench({ + value, + candidateName, + statusTag, + warnings, + onChange, +}: AssemblyWorkbenchProps) { + const [selectedNodeIds, setSelectedNodeIds] = useState([]) + const workspaceRef = useRef(value) + + useEffect(() => { + workspaceRef.current = value + }, [value]) + + useEffect(() => { + setSelectedNodeIds([]) + }, [value.version, candidateName]) + + const selectedNodes = value.nodes.filter((node) => selectedNodeIds.includes(node.nodeId)) + const selectedSingleNode = selectedNodes.length === 1 ? selectedNodes[0] : null + const stagingNodes = value.nodes.filter((node) => node.zone === 'staging') + const subassemblyNodes = value.nodes.filter((node) => node.zone === 'subassembly') + const mainNodes = value.nodes.filter((node) => node.zone === 'main') + const connectedRelations = value.relations.filter((relation) => relation.status === 'CONNECTED') + const suggestedFastenerCount = connectedRelations.reduce( + (sum, relation) => + sum + relation.suggestedFasteners.reduce((inner, item) => inner + item.quantity, 0), + 0, + ) + + const validationMessages = useMemo( + () => [...value.validation.blockingIssues, ...value.validation.warnings], + [value.validation.blockingIssues, value.validation.warnings], + ) + + function toggleNode(nodeId: string) { + setSelectedNodeIds((current) => + current.includes(nodeId) + ? current.filter((id) => id !== nodeId) + : [...current, nodeId], + ) + } + + function replaceWorkspace(next: AssemblyWorkspaceSnapshot) { + onChange(next) + } + + function handleMove(zone: 'staging' | 'subassembly' | 'main') { + if (selectedNodeIds.length === 0) return + replaceWorkspace(moveAssemblyNodes(value, selectedNodeIds, zone)) + } + + function handleSnap() { + if (selectedNodeIds.length === 0) return + replaceWorkspace(snapNodesIntoMainAssembly(value, selectedNodeIds)) + } + + function handleCreateSubassembly() { + if (selectedNodeIds.length < 2) return + replaceWorkspace(createSubassembly(value, selectedNodeIds)) + } + + function handleRoleConfirm(role: string) { + if (!selectedSingleNode) return + replaceWorkspace(applyNodeRoleConfirmation(value, selectedSingleNode.nodeId, role)) + } + + return ( +
+
+
+
+

+ 自动组合工作台 +

+

+ {candidateName} +

+

+ 现在不是自动拼完,而是系统先给候选和吸附建议。部件可以直接在画布里拖动,拖到目标位附近后会按挂载点/目标位尝试吸附。 +

+
+
+ + {getValidationLabel(value.validation.level)} + + + {statusTag} + +
+
+ +
+
+

待装区

+

{stagingNodes.length}

+
+
+

分子装配区

+

{subassemblyNodes.length}

+
+
+

主装配区

+

{mainNodes.length}

+
+
+
+ +
+
+ + + + + +
+ + {selectedSingleNode && ( +
+

角色确认

+

+ 系统当前推荐:{selectedSingleNode.roleRecommendation ?? '未推荐'},你可以在这里轻确认。 +

+
+ {ROLE_OPTIONS.filter((option) => + selectedSingleNode.source === 'desktop' + ? option.value === 'desktop' + : option.value !== 'desktop', + ).map((option) => ( + + ))} +
+
+ )} + +
+ setSelectedNodeIds([nodeId])} + onMoveNode={(nodeId, position) => + replaceWorkspace( + updateAssemblyNodePosition(workspaceRef.current, nodeId, position), + ) + } + onDragEndNode={(nodeId) => + replaceWorkspace(attemptSnapNode(workspaceRef.current, nodeId)) + } + /> +
+ +
+ + + +
+
+ +
+
+
+

装配校验

+

+ 只拦截致命错误,其他问题先给警告,不会一刀切把流程卡死。 +

+
+ + {getValidationLabel(value.validation.level)} + +
+
+ {validationMessages.length === 0 && warnings.length === 0 ? ( +
+ 当前没有额外警告,主线可以继续往草稿组走。 +
+ ) : ( + [...validationMessages, ...warnings].map((message, index) => ( +
+ {message} +
+ )) + )} +
+ +
+

连接件建议

+

+ 当前已成立关系 {connectedRelations.length} 条,系统建议补齐连接件 {suggestedFastenerCount} 个。 +

+
+
+
+ ) +} diff --git a/furniture-3d-system/src/components/platform/AssemblyWorkbenchCanvas.tsx b/furniture-3d-system/src/components/platform/AssemblyWorkbenchCanvas.tsx new file mode 100644 index 00000000..a9d48936 --- /dev/null +++ b/furniture-3d-system/src/components/platform/AssemblyWorkbenchCanvas.tsx @@ -0,0 +1,135 @@ +'use client' + +import { Suspense, useMemo, useRef, useState } from 'react' +import { Bounds, Clone, OrbitControls, useGLTF } from '@react-three/drei' +import { Canvas } from '@react-three/fiber' +import * as THREE from 'three' +import type { AssemblyWorkspaceNode } from '@/lib/open-product/assembly-workspace' + +interface AssemblyWorkbenchCanvasProps { + nodes: AssemblyWorkspaceNode[] + selectedNodeIds: string[] + onSelectNode?: (nodeId: string) => void + onMoveNode?: (nodeId: string, position: [number, number, number]) => void + onDragEndNode?: (nodeId: string) => void +} + +function AssetNodeMesh({ + node, + selected, + onSelectNode, + onMoveNode, + onDragEndNode, +}: { + node: AssemblyWorkspaceNode + selected: boolean + onSelectNode?: (nodeId: string) => void + onMoveNode?: (nodeId: string, position: [number, number, number]) => void + onDragEndNode?: (nodeId: string) => void +}) { + const { scene } = useGLTF(node.modelUrl) + const [dragging, setDragging] = useState(false) + const planeRef = useRef(new THREE.Plane(new THREE.Vector3(0, 1, 0), -node.position[1])) + + return ( + { + event.stopPropagation() + onSelectNode?.(node.nodeId) + }} + onPointerDown={(event) => { + event.stopPropagation() + if (node.source === 'desktop') { + return + } + planeRef.current = new THREE.Plane(new THREE.Vector3(0, 1, 0), -node.position[1]) + setDragging(true) + // @ts-expect-error R3F pointer capture typing is narrower than runtime + event.target.setPointerCapture?.(event.pointerId) + onSelectNode?.(node.nodeId) + }} + onPointerMove={(event) => { + if (!dragging) return + event.stopPropagation() + const point = new THREE.Vector3() + if (!event.ray.intersectPlane(planeRef.current, point)) { + return + } + onMoveNode?.(node.nodeId, [point.x, node.position[1], point.z]) + }} + onPointerUp={(event) => { + if (!dragging) return + event.stopPropagation() + setDragging(false) + // @ts-expect-error R3F pointer capture typing is narrower than runtime + event.target.releasePointerCapture?.(event.pointerId) + onDragEndNode?.(node.nodeId) + }} + > + + {selected ? ( + + + + + ) : null} + + ) +} + +function SceneContent({ + nodes, + selectedNodeIds, + onSelectNode, + onMoveNode, + onDragEndNode, +}: AssemblyWorkbenchCanvasProps) { + const selectedSet = useMemo(() => new Set(selectedNodeIds), [selectedNodeIds]) + + return ( + <> + + + + + + + + + + + + + + + {nodes.map((node) => ( + + ))} + + + + + + ) +} + +export function AssemblyWorkbenchCanvas(props: AssemblyWorkbenchCanvasProps) { + return ( + + + + + + ) +} diff --git a/furniture-3d-system/src/components/ui/ProductListItem.tsx b/furniture-3d-system/src/components/ui/ProductListItem.tsx index 2a58cccf..e57309fd 100644 --- a/furniture-3d-system/src/components/ui/ProductListItem.tsx +++ b/furniture-3d-system/src/components/ui/ProductListItem.tsx @@ -1,22 +1,18 @@ /** - * 产品列表项组件 - 可折叠的产品+SKU列表 + * 产品列表项组件 - 可折叠的产品+SKU卡片 (Precision Atelier 风格) * - * 基于 Apple 视觉规范重构 + * 基于 Apple 视觉规范与 Precision Atelier 设计系统重构 * - * @updated 2026-01-29 - 修复 Tailwind 类名问题 + * @updated 2026-04-08 - 重构为网格卡片布局,优化了高宽比与视觉密度 */ 'use client' import React, { memo, useCallback } from 'react' import Link from 'next/link' -import { ChevronDown, ChevronRight, Package, User, Plus, Trash2 } from 'lucide-react' +import { ChevronDown, ChevronRight, User, Plus, Trash2, Pencil } from 'lucide-react' import Badge from '@/components/ui/Badge' -import Button from '@/components/ui/Button' -import { Caption1 } from '@/components/ui/Typography' import { cn } from '@/lib/utils' -// 动画库已在 globals.css 中定义,此处仅保留类型导入备用 -// import { getAccordionAnimation, getListItemAnimation } from '@/lib/design-tokens/animations' // 产品列表项组件的入参类型 interface ProductListItemProps { @@ -29,7 +25,6 @@ interface ProductListItemProps { updatedAt?: Date | string description?: string | null visibility?: 'TEAM' | 'DEPARTMENT' | 'PUBLIC' - // 添加创建者信息 creator?: { id: string name: string @@ -55,17 +50,11 @@ interface ProductListItemProps { }> } onEdit?: (product: ProductListItemProps['product']) => void - /** 上传 SKU 回调 */ onAddSKU?: (productId: string) => void - /** 删除产品回调 */ onDelete?: (product: ProductListItemProps['product']) => void - /** 删除 SKU 回调 */ onDeleteSKU?: (sku: ProductListItemProps['product']['skus'][number], product: ProductListItemProps['product']) => void - /** 是否展开(由父组件控制手风琴效果) */ isExpanded?: boolean - /** 展开/收起回调 */ onToggleExpand?: (productId: string) => void - /** 离开首页前标记需要恢复展开状态 */ onNavigate?: () => void } @@ -79,13 +68,10 @@ const ProductListItem = memo(function ProductListItem({ onToggleExpand, onNavigate, }: ProductListItemProps) { - // 支持受控和非受控两种模式 const [internalExpanded, setInternalExpanded] = React.useState(false) const isControlled = controlledExpanded !== undefined const isExpanded = isControlled ? controlledExpanded : internalExpanded - const [isHovered, setIsHovered] = React.useState(false) - const handleToggleExpand = useCallback(() => { if (isControlled && onToggleExpand) { onToggleExpand(product.id) @@ -93,112 +79,87 @@ const ProductListItem = memo(function ProductListItem({ setInternalExpanded(prev => !prev) } }, [isControlled, onToggleExpand, product.id]) - - const handleMouseEnter = useCallback(() => { - setIsHovered(true) - }, []) - - const handleMouseLeave = useCallback(() => { - setIsHovered(false) - }, []) return ( -
- {/* 产品主行 */} +
+ {/* 卡片封面区域 (带有展开 SKU 控制) */}
-
- {/* 左侧:产品信息 */} -
- {/* 展开/折叠图标 */} -
- {isExpanded ? : } -
- - {/* 产品图标 */} -
- -
- - {/* 产品文本信息 */} -
-
- {/* 产品型号 */} -
- {product.modelNumber} -
- {product.category && ( - - {product.category.name} - - )} -
- - {/* 增加间距到 gap-x-6 (24px) */} -
- - {product.name} - - • - - {product._count.skus} 个 SKU - - {product.creator && ( - <> - • -
- - 创建者: {product.creator.name} -
- - )} -
-
+
+ {/* 占位图使用网格纹理 */} +
+
+ NO IMAGE
+
- {/* 右侧:操作按钮 */} -
e.stopPropagation()}> - - {onDelete && ( - - )} + {/* 右上角角标 */} +
+ + {product._count.skus} SKUs + +
+ + {/* 底部悬浮展开提示 */} +
+
+ {isExpanded ? : }
- {/* SKU 列表(展开时显示) - 使用 CSS Grid 实现平滑高度过渡 */} + {/* 卡片文本信息 */} +
+
+

+ {product.category?.name || 'Uncategorized'} +

+
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+
+ +

+ {product.name} +

+ +
+ + # {product.modelNumber} + + {product.creator && ( + + {product.creator.name} + + )} +
+
+ + {/* SKU 列表展开区域 */}
-
-
- {/* SKU 编码和属性 */} -
-
+
+
+
{sku.skuCode}
+
+ ¥{sku.price.toFixed(2)} +
+
+ +
+ + {sku.isActive ? '已上架' : '未上架'} + - {/* 规格属性展示 - 紧凑排列 */} - {sku.attributes && Object.keys(sku.attributes).length > 0 && ( -
- {Object.entries(sku.attributes) - .filter(([, value]) => typeof value === 'string' || typeof value === 'number') - .map(([key, value]) => ( - - {String(value)} - - ))} -
- )} -
- - {/* 价格 */} -
- ¥{sku.price.toFixed(2)} -
- - {/* 库存 */} -
- {sku.stock} 件 -
- - {/* 状态 */} - - {sku.isActive ? '已上架' : '未上架'} - - - {/* SKU 操作 - 统一使用 gray 变体 */} -
e.stopPropagation()}> - {/* 预览按钮:跳转到 SKU 详情页(内部,需登录) */} - - - - - - - {onDeleteSKU && ( - - )} +
+ + + + + + + {onDeleteSKU && ( + + )} +
))} + {product.skus.length === 0 && ( -
+
暂无 SKU 数据
)} {/* 添加 SKU 按钮 */} {onAddSKU && ( -
{ e.stopPropagation(); onAddSKU(product.id) }} + className="w-full mt-1 py-1.5 rounded-apple-md bg-transparent border border-dashed border-apple-separator hover:border-apple-label-tertiary hover:bg-white transition-all duration-200 flex items-center justify-center gap-1 text-[10px] font-apple-system font-apple-medium text-apple-label-secondary" > - -
+ + 添加 SKU + )}
diff --git a/furniture-3d-system/src/lib/open-product/__tests__/assembly-workspace.test.ts b/furniture-3d-system/src/lib/open-product/__tests__/assembly-workspace.test.ts new file mode 100644 index 00000000..24368198 --- /dev/null +++ b/furniture-3d-system/src/lib/open-product/__tests__/assembly-workspace.test.ts @@ -0,0 +1,137 @@ +import { + applyNodeRoleConfirmation, + buildSingleBoardDeskAssemblyWorkspace, + createSubassembly, + moveAssemblyNodes, + snapNodesIntoMainAssembly, +} from '../assembly-workspace' + +describe('assembly-workspace', () => { + const previewNodes = [ + { + role: 'desktop', + label: '桌面板', + assetFamilyId: 'family-desktop', + variantId: 'variant-desktop', + code: 'TOP-A', + name: '桌面A', + modelUrl: '/models/top-a.glb', + position: [0, 720, 0] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + scale: [1, 1, 1] as [number, number, number], + }, + { + role: 'frontLeft', + label: '前左腿', + assetFamilyId: 'family-leg', + variantId: 'variant-leg-1', + code: 'LEG-A', + name: '桌腿A', + modelUrl: '/models/leg-a.glb', + position: [-450, 360, 250] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + scale: [1, 1, 1] as [number, number, number], + }, + { + role: 'frontRight', + label: '前右腿', + assetFamilyId: 'family-leg', + variantId: 'variant-leg-2', + code: 'LEG-A', + name: '桌腿A', + modelUrl: '/models/leg-a.glb', + position: [450, 360, 250] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + scale: [1, 1, 1] as [number, number, number], + }, + ] + + it('会把桌面板默认放进主装配区,其它部件先放待装区', () => { + const snapshot = buildSingleBoardDeskAssemblyWorkspace({ + templateCode: 'single-board-desk', + candidateKey: 'candidate-1', + defaultName: '云序-桌面A-桌腿A', + previewNodes, + connectorRecommendations: [], + }) + + expect(snapshot.nodes.find((node) => node.role === 'desktop')?.zone).toBe('main') + expect(snapshot.nodes.filter((node) => node.source === 'leg').every((node) => node.zone === 'staging')).toBe(true) + expect(snapshot.validation.level).toBe('PASSED') + }) + + it('腿进入主装配区但没有吸附时,会给出阻断提示', () => { + const snapshot = buildSingleBoardDeskAssemblyWorkspace({ + templateCode: 'single-board-desk', + candidateKey: 'candidate-1', + defaultName: '云序-桌面A-桌腿A', + previewNodes, + connectorRecommendations: [], + }) + + const moved = moveAssemblyNodes(snapshot, ['candidate-1:frontLeft:1'], 'main') + + expect(moved.validation.level).toBe('BLOCKED') + expect(moved.validation.blockingIssues).toContain('桌腿A 和主件之间还没有形成有效连接') + }) + + it('智能吸附后会补齐连接关系和确认角色', () => { + const snapshot = buildSingleBoardDeskAssemblyWorkspace({ + templateCode: 'single-board-desk', + candidateKey: 'candidate-1', + defaultName: '云序-桌面A-桌腿A', + previewNodes, + connectorRecommendations: [], + }) + + const moved = moveAssemblyNodes(snapshot, ['candidate-1:frontLeft:1'], 'main') + const snapped = snapNodesIntoMainAssembly(moved, ['candidate-1:frontLeft:1']) + + expect(snapped.validation.level).toBe('PASSED') + expect( + snapped.relations.find((relation) => relation.sourceNodeId === 'candidate-1:frontLeft:1')?.status, + ).toBe('CONNECTED') + expect( + snapped.nodes.find((node) => node.nodeId === 'candidate-1:frontLeft:1')?.roleConfirmed, + ).toBe('frontLeft') + }) + + it('支持在分子装配区把多个部件封装成模块', () => { + const snapshot = buildSingleBoardDeskAssemblyWorkspace({ + templateCode: 'single-board-desk', + candidateKey: 'candidate-1', + defaultName: '云序-桌面A-桌腿A', + previewNodes, + connectorRecommendations: [], + }) + + const moved = moveAssemblyNodes( + snapshot, + ['candidate-1:frontLeft:1', 'candidate-1:frontRight:2'], + 'subassembly', + ) + const sealed = createSubassembly(moved, ['candidate-1:frontLeft:1', 'candidate-1:frontRight:2']) + + expect(sealed.subassemblies).toHaveLength(1) + expect(sealed.subassemblies[0]?.nodeIds).toEqual([ + 'candidate-1:frontLeft:1', + 'candidate-1:frontRight:2', + ]) + }) + + it('支持人工改最终角色', () => { + const snapshot = buildSingleBoardDeskAssemblyWorkspace({ + templateCode: 'single-board-desk', + candidateKey: 'candidate-1', + defaultName: '云序-桌面A-桌腿A', + previewNodes, + connectorRecommendations: [], + }) + + const updated = applyNodeRoleConfirmation(snapshot, 'candidate-1:frontLeft:1', 'rearLeft') + + expect( + updated.nodes.find((node) => node.nodeId === 'candidate-1:frontLeft:1')?.roleConfirmed, + ).toBe('rearLeft') + }) +}) diff --git a/furniture-3d-system/src/lib/open-product/assembly-workspace.ts b/furniture-3d-system/src/lib/open-product/assembly-workspace.ts new file mode 100644 index 00000000..fac21fb6 --- /dev/null +++ b/furniture-3d-system/src/lib/open-product/assembly-workspace.ts @@ -0,0 +1,564 @@ +import type { MaterializedSlotVariantSelection } from './single-board-desk' +import type { OpenProductMountPoint, PreSkuPreviewNode } from './types' + +export type AssemblyZone = 'staging' | 'subassembly' | 'main' + +export type AssemblyValidationLevel = 'PASSED' | 'WARNING' | 'BLOCKED' + +export type AssemblyNodeSource = 'desktop' | 'leg' | 'connector' | 'unknown' + +export type AssemblyRelationType = 'hole-group' | 'surface' | 'connector' + +export type AssemblyRelationStatus = 'CONNECTED' | 'SUGGESTED' | 'PENDING' + +export interface WeakSlotAssignment { + role: string + parentRolePath: string | null + instanceIndex: number +} + +export interface AssemblyFastenerRecommendation { + selectionKey: string + assetFamilyId: string + variantId?: string | null + quantity: number + label: string +} + +export interface AssemblyWorkspaceNode extends PreSkuPreviewNode { + nodeId: string + zone: AssemblyZone + source: AssemblyNodeSource + slotKey: string | null + roleRecommendation: string | null + roleConfirmed: string | null + weakSlot: WeakSlotAssignment | null + targetPosition: [number, number, number] | null + targetRotation: [number, number, number] | null + isSnapped: boolean + subassemblyId: string | null + mountPoints: OpenProductMountPoint[] + assemblyBasis: Record | null +} + +export interface AssemblySubassembly { + id: string + name: string + nodeIds: string[] + status: 'draft' | 'sealed' +} + +export interface AssemblyRelation { + relationId: string + sourceNodeId: string + targetNodeId: string + relationType: AssemblyRelationType + status: AssemblyRelationStatus + description: string + minMatchCount: number | null + matchedCount: number | null + roleRecommendation: string | null + roleConfirmed: string | null + requiresFastener: boolean + suggestedFasteners: AssemblyFastenerRecommendation[] +} + +export interface AssemblyValidationResult { + level: AssemblyValidationLevel + blockingIssues: string[] + warnings: string[] +} + +export interface AssemblyWorkspaceSnapshot { + version: '2026-04-08' + templateCode: string + workflowMode: 'assisted-manual' + nodes: AssemblyWorkspaceNode[] + subassemblies: AssemblySubassembly[] + relations: AssemblyRelation[] + validation: AssemblyValidationResult +} + +export interface CombinationCandidateSnapshot { + key: string + defaultName: string + templateCode: string + combinationItems: Array<{ + label: string + value: string + quantity: number + role?: string + }> +} + +interface BuildSingleBoardDeskAssemblyWorkspaceInput { + templateCode: string + candidateKey: string + defaultName: string + previewNodes: PreSkuPreviewNode[] + slotSelections?: MaterializedSlotVariantSelection[] + connectorRecommendations?: AssemblyFastenerRecommendation[] +} + +const SNAP_DISTANCE_THRESHOLD = 180 + +const MAIN_NODE_SOURCE_LABELS = new Set(['desktop']) + +const LEG_ROLE_ORDER = ['frontLeft', 'frontRight', 'rearLeft', 'rearRight'] + +function cloneVector3( + input: [number, number, number] | undefined, +): [number, number, number] { + return input ? [...input] as [number, number, number] : [0, 0, 0] +} + +function createNodeId(candidateKey: string, role: string, index: number) { + return `${candidateKey}:${role}:${index}` +} + +function inferNodeSource(role: string): AssemblyNodeSource { + if (role === 'desktop') return 'desktop' + if (LEG_ROLE_ORDER.includes(role)) return 'leg' + if (role.includes('connector')) return 'connector' + return 'unknown' +} + +function buildWeakSlot(role: string, index: number): WeakSlotAssignment | null { + if (!role) return null + + return { + role, + parentRolePath: role === 'desktop' ? null : 'desktop', + instanceIndex: index + 1, + } +} + +function buildStagingPosition(index: number, role: string): [number, number, number] { + if (role === 'desktop') { + return [0, 720, 0] + } + + const row = Math.floor(index / 2) + const col = index % 2 + return [-1500 + col * 520, 320, 900 - row * 520] +} + +function buildInitialNode( + candidateKey: string, + node: PreSkuPreviewNode, + index: number, + slotSelection?: MaterializedSlotVariantSelection, +): AssemblyWorkspaceNode { + const source = inferNodeSource(node.role) + const isDesktop = MAIN_NODE_SOURCE_LABELS.has(node.role) + + return { + ...node, + nodeId: createNodeId(candidateKey, node.role, index), + zone: isDesktop ? 'main' : 'staging', + source, + slotKey: source === 'desktop' + ? 'desktop' + : source === 'leg' + ? `${node.role}Leg` + : source === 'connector' + ? 'connectorGroup' + : null, + roleRecommendation: node.role ?? null, + roleConfirmed: isDesktop ? node.role : null, + weakSlot: buildWeakSlot(node.role, index), + position: isDesktop ? cloneVector3(node.position) : buildStagingPosition(index, node.role), + rotation: cloneVector3(node.rotation), + scale: cloneVector3(node.scale), + targetPosition: cloneVector3(node.position), + targetRotation: cloneVector3(node.rotation), + isSnapped: isDesktop, + subassemblyId: null, + mountPoints: slotSelection?.variant.mountPoints ?? [], + assemblyBasis: slotSelection?.variant.assemblyBasis ?? null, + } +} + +function findSlotSelectionForNode( + node: PreSkuPreviewNode, + slotSelections: MaterializedSlotVariantSelection[], +) { + return ( + slotSelections.find( + (selection) => + selection.variant.id === node.variantId || + selection.role === node.role || + selection.slotKey === node.role, + ) ?? null + ) +} + +function distanceBetween( + left: [number, number, number], + right: [number, number, number], +) { + const dx = left[0] - right[0] + const dy = left[1] - right[1] + const dz = left[2] - right[2] + return Math.sqrt(dx * dx + dy * dy + dz * dz) +} + +function isNodeCloseEnoughToSnap(node: AssemblyWorkspaceNode) { + if (!node.targetPosition) { + return false + } + + if (node.mountPoints.length === 0 && node.source !== 'desktop') { + return false + } + + return distanceBetween(node.position, node.targetPosition) <= SNAP_DISTANCE_THRESHOLD +} + +export function buildSingleBoardDeskAssemblyWorkspace( + input: BuildSingleBoardDeskAssemblyWorkspaceInput, +): AssemblyWorkspaceSnapshot { + const nodes = input.previewNodes.map((node, index) => + buildInitialNode( + input.candidateKey, + node, + index, + input.slotSelections + ? findSlotSelectionForNode(node, input.slotSelections) ?? undefined + : undefined, + ), + ) + + const desktopNode = nodes.find((node) => node.source === 'desktop') ?? null + + const relations = nodes + .filter((node) => node.source === 'leg') + .map((node) => ({ + relationId: `${node.nodeId}->${desktopNode?.nodeId ?? 'desktop'}`, + sourceNodeId: node.nodeId, + targetNodeId: desktopNode?.nodeId ?? '', + relationType: 'hole-group', + status: 'SUGGESTED', + description: `${node.name} 可通过孔位组吸附到 ${desktopNode?.name ?? '桌面板'}`, + minMatchCount: 3, + matchedCount: 0, + roleRecommendation: node.roleRecommendation, + roleConfirmed: null, + requiresFastener: input.connectorRecommendations + ? input.connectorRecommendations.length > 0 + : false, + suggestedFasteners: input.connectorRecommendations ?? [], + })) + + return { + version: '2026-04-08', + templateCode: input.templateCode, + workflowMode: 'assisted-manual', + nodes, + subassemblies: [], + relations, + validation: deriveAssemblyValidation({ + nodes, + relations, + }), + } +} + +export function deriveAssemblyValidation(input: { + nodes: AssemblyWorkspaceNode[] + relations: AssemblyRelation[] +}): AssemblyValidationResult { + const blockingIssues: string[] = [] + const warnings: string[] = [] + + const mainNodes = input.nodes.filter((node) => node.zone === 'main') + const desktopNode = mainNodes.find((node) => node.source === 'desktop') ?? null + + if (!desktopNode) { + blockingIssues.push('主装配区缺少桌面板,当前装配不成立') + } + + const mainLegNodes = mainNodes.filter((node) => node.source === 'leg') + const connectedRelations = new Map( + input.relations + .filter((relation) => relation.status === 'CONNECTED') + .map((relation) => [relation.sourceNodeId, relation]), + ) + + mainLegNodes.forEach((node) => { + if (!node.isSnapped) { + warnings.push(`${node.name} 已进入主装配区,但还没有完成吸附`) + } + if (!node.roleConfirmed) { + warnings.push(`${node.name} 还没有确认最终角色`) + } + if (!connectedRelations.has(node.nodeId)) { + blockingIssues.push(`${node.name} 和主件之间还没有形成有效连接`) + } + }) + + const confirmedRoles = mainLegNodes + .map((node) => node.roleConfirmed) + .filter((value): value is string => Boolean(value)) + const duplicateRoles = confirmedRoles.filter( + (role, index) => confirmedRoles.indexOf(role) !== index, + ) + Array.from(new Set(duplicateRoles)).forEach((role) => { + warnings.push(`主装配区里存在重复角色:${role}`) + }) + + const level: AssemblyValidationLevel = + blockingIssues.length > 0 ? 'BLOCKED' : warnings.length > 0 ? 'WARNING' : 'PASSED' + + return { + level, + blockingIssues, + warnings, + } +} + +export function moveAssemblyNodes( + snapshot: AssemblyWorkspaceSnapshot, + nodeIds: string[], + zone: AssemblyZone, +): AssemblyWorkspaceSnapshot { + const nodeIdSet = new Set(nodeIds) + + const nodes = snapshot.nodes.map((node) => { + if (!nodeIdSet.has(node.nodeId)) { + return node + } + + return { + ...node, + zone, + subassemblyId: zone === 'subassembly' ? node.subassemblyId : null, + isSnapped: zone === 'main' ? node.isSnapped : false, + roleConfirmed: zone === 'main' && node.source === 'desktop' ? 'desktop' : node.roleConfirmed, + } + }) + + const relations = snapshot.relations.map((relation) => ({ + ...relation, + status: + zone === 'main' && nodeIdSet.has(relation.sourceNodeId) + ? relation.status + : nodeIdSet.has(relation.sourceNodeId) + ? 'SUGGESTED' + : relation.status, + matchedCount: + zone === 'main' && nodeIdSet.has(relation.sourceNodeId) + ? relation.matchedCount + : nodeIdSet.has(relation.sourceNodeId) + ? 0 + : relation.matchedCount, + roleConfirmed: + zone === 'main' && nodeIdSet.has(relation.sourceNodeId) + ? relation.roleConfirmed + : nodeIdSet.has(relation.sourceNodeId) + ? null + : relation.roleConfirmed, + })) + + return { + ...snapshot, + nodes, + relations, + validation: deriveAssemblyValidation({ nodes, relations }), + } +} + +export function applyNodeRoleConfirmation( + snapshot: AssemblyWorkspaceSnapshot, + nodeId: string, + role: string, +): AssemblyWorkspaceSnapshot { + const nodes = snapshot.nodes.map((node) => + node.nodeId === nodeId + ? { + ...node, + roleConfirmed: role, + weakSlot: { + role, + parentRolePath: role === 'desktop' ? null : 'desktop', + instanceIndex: node.weakSlot?.instanceIndex ?? 1, + }, + } + : node, + ) + + const relations = snapshot.relations.map((relation) => + relation.sourceNodeId === nodeId + ? { + ...relation, + roleConfirmed: role, + } + : relation, + ) + + return { + ...snapshot, + nodes, + relations, + validation: deriveAssemblyValidation({ nodes, relations }), + } +} + +export function snapNodesIntoMainAssembly( + snapshot: AssemblyWorkspaceSnapshot, + nodeIds: string[], +): AssemblyWorkspaceSnapshot { + const nodeIdSet = new Set(nodeIds) + + const nodes = snapshot.nodes.map((node) => { + if (!nodeIdSet.has(node.nodeId) || node.zone !== 'main') { + return node + } + + return { + ...node, + position: node.targetPosition ? cloneVector3(node.targetPosition) : cloneVector3(node.position), + rotation: node.targetRotation ? cloneVector3(node.targetRotation) : cloneVector3(node.rotation), + isSnapped: true, + roleConfirmed: node.roleConfirmed ?? node.roleRecommendation, + } + }) + + const relations = snapshot.relations.map((relation) => + nodeIdSet.has(relation.sourceNodeId) + ? { + ...relation, + status: 'CONNECTED', + matchedCount: relation.minMatchCount, + roleConfirmed: relation.roleConfirmed ?? relation.roleRecommendation, + } + : relation, + ) + + return { + ...snapshot, + nodes, + relations, + validation: deriveAssemblyValidation({ nodes, relations }), + } +} + +export function updateAssemblyNodePosition( + snapshot: AssemblyWorkspaceSnapshot, + nodeId: string, + position: [number, number, number], +): AssemblyWorkspaceSnapshot { + const nodes = snapshot.nodes.map((node) => + node.nodeId === nodeId + ? { + ...node, + position, + isSnapped: node.zone === 'main' ? false : node.isSnapped, + } + : node, + ) + + const relations = snapshot.relations.map((relation) => + relation.sourceNodeId === nodeId + ? { + ...relation, + status: 'PENDING', + matchedCount: 0, + roleConfirmed: + snapshot.nodes.find((node) => node.nodeId === nodeId)?.zone === 'main' + ? relation.roleConfirmed + : null, + } + : relation, + ) + + return { + ...snapshot, + nodes, + relations, + validation: deriveAssemblyValidation({ nodes, relations }), + } +} + +export function attemptSnapNode( + snapshot: AssemblyWorkspaceSnapshot, + nodeId: string, +): AssemblyWorkspaceSnapshot { + const targetNode = snapshot.nodes.find((node) => node.nodeId === nodeId) + if (!targetNode || targetNode.zone !== 'main' || !isNodeCloseEnoughToSnap(targetNode)) { + return snapshot + } + + const nodes = snapshot.nodes.map((node) => + node.nodeId === nodeId + ? { + ...node, + position: targetNode.targetPosition + ? cloneVector3(targetNode.targetPosition) + : cloneVector3(node.position), + rotation: targetNode.targetRotation + ? cloneVector3(targetNode.targetRotation) + : cloneVector3(node.rotation), + isSnapped: true, + roleConfirmed: node.roleConfirmed ?? node.roleRecommendation, + } + : node, + ) + + const relations = snapshot.relations.map((relation) => + relation.sourceNodeId === nodeId + ? { + ...relation, + status: 'CONNECTED', + matchedCount: relation.minMatchCount, + roleConfirmed: relation.roleConfirmed ?? relation.roleRecommendation, + } + : relation, + ) + + return { + ...snapshot, + nodes, + relations, + validation: deriveAssemblyValidation({ nodes, relations }), + } +} + +export function createSubassembly( + snapshot: AssemblyWorkspaceSnapshot, + nodeIds: string[], +): AssemblyWorkspaceSnapshot { + const eligibleNodes = snapshot.nodes.filter( + (node) => nodeIds.includes(node.nodeId) && node.zone === 'subassembly', + ) + if (eligibleNodes.length < 2) { + return snapshot + } + + const subassemblyId = `subassembly-${snapshot.subassemblies.length + 1}` + const subassembly: AssemblySubassembly = { + id: subassemblyId, + name: `模块 ${snapshot.subassemblies.length + 1}`, + nodeIds: eligibleNodes.map((node) => node.nodeId), + status: 'sealed', + } + + const nodes = snapshot.nodes.map((node) => + nodeIds.includes(node.nodeId) + ? { + ...node, + subassemblyId, + } + : node, + ) + + return { + ...snapshot, + nodes, + subassemblies: [...snapshot.subassemblies, subassembly], + validation: deriveAssemblyValidation({ + nodes, + relations: snapshot.relations, + }), + } +} diff --git a/furniture-3d-system/src/server/services/__tests__/combination.service.test.ts b/furniture-3d-system/src/server/services/__tests__/combination.service.test.ts index 8825a239..9b61e567 100644 --- a/furniture-3d-system/src/server/services/__tests__/combination.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/combination.service.test.ts @@ -141,6 +141,18 @@ describe('combinationService', () => { expect.objectContaining({ role: 'desktop', modelUrl: '/models/top-a.glb' }), expect.objectContaining({ role: 'frontLeft', modelUrl: '/models/leg-a.glb' }), ]), + candidateSnapshot: expect.objectContaining({ + key: 'family-desktop-a::family-leg-a', + templateCode: 'single-board-desk', + }), + assemblyWorkspace: expect.objectContaining({ + templateCode: 'single-board-desk', + workflowMode: 'assisted-manual', + nodes: expect.arrayContaining([ + expect.objectContaining({ role: 'desktop', zone: 'main' }), + expect.objectContaining({ role: 'frontLeft', zone: 'staging' }), + ]), + }), }), ) }) diff --git a/furniture-3d-system/src/server/services/__tests__/share.service.test.ts b/furniture-3d-system/src/server/services/__tests__/share.service.test.ts index 9a649ed4..9a2982ee 100644 --- a/furniture-3d-system/src/server/services/__tests__/share.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/share.service.test.ts @@ -180,4 +180,52 @@ describe('ShareService.getShareDetail', () => { expect(result.product.assemblySteps).toEqual([productLevelStep]) expect(result.product.panels).toEqual([productLevelPanel]) }) + + it('传入不存在的 sku 时回落到产品级分享数据,而不是使用无效 sku', async () => { + mockedAssemblyStepRepository.findByProductId.mockResolvedValue([productLevelStep]) + mockedPanelRepository.findByProductId.mockResolvedValue([productLevelPanel]) + + const result = await service.getShareDetail('token-1', 'sku-missing') + + expect(mockedAssemblyStepRepository.findByProductId).toHaveBeenCalledTimes(1) + expect(mockedAssemblyStepRepository.findByProductId).toHaveBeenCalledWith('product-1', null) + expect(mockedPanelRepository.findByProductId).toHaveBeenCalledTimes(1) + expect(mockedPanelRepository.findByProductId).toHaveBeenCalledWith('product-1', { + skuId: null, + }) + expect(result.currentSkuId).toBeNull() + expect(result.product.modelUrl).toBe('https://example.com/product.glb') + expect(result.product.assemblySteps).toEqual([productLevelStep]) + expect(result.product.panels).toEqual([productLevelPanel]) + }) + + it('指定 sku 且该 sku 没有模型时,只回退到产品级模型,不回退到其他 sku 的模型', async () => { + mockedShareRepository.findPublicDetailByToken.mockResolvedValue({ + ...baseLink, + product: { + ...baseLink.product, + modelUrl: 'https://example.com/product.glb', + skus: [ + { + ...baseLink.product.skus[0], + id: 'sku-1', + modelUrl: null, + }, + { + ...baseLink.product.skus[0], + id: 'sku-2', + skuCode: 'SKU-002', + modelUrl: 'https://example.com/sku-2.glb', + }, + ], + }, + } as never) + mockedAssemblyStepRepository.findByProductId.mockResolvedValue([skuLevelStep]) + mockedPanelRepository.findByProductId.mockResolvedValue([skuLevelPanel]) + + const result = await service.getShareDetail('token-1', 'sku-1') + + expect(result.currentSkuId).toBe('sku-1') + expect(result.product.modelUrl).toBe('https://example.com/product.glb') + }) }) diff --git a/furniture-3d-system/src/server/services/combination.service.ts b/furniture-3d-system/src/server/services/combination.service.ts index ed81bf29..b21be58f 100644 --- a/furniture-3d-system/src/server/services/combination.service.ts +++ b/furniture-3d-system/src/server/services/combination.service.ts @@ -1,4 +1,8 @@ import { generateSingleBoardDeskCandidates } from '@/lib/open-product/single-board-desk' +import { + buildSingleBoardDeskAssemblyWorkspace, + type AssemblyFastenerRecommendation, +} from '@/lib/open-product/assembly-workspace' import { AppError, ErrorCode } from '@/server/core/errors' import { assetFamilyRepository, @@ -62,6 +66,27 @@ function buildCombinationItems(candidate: ReturnType['candidates'][number], + componentCodes: string[], +): AssemblyFastenerRecommendation[] { + const connectorFamilyIds = candidate.familySelection.connectorFamilyIds + if (connectorFamilyIds.length === 0) { + return [] + } + + const quantity = Object.values(candidate.familySelection.legRoles).filter(Boolean).length + + return connectorFamilyIds.map((assetFamilyId, index) => ({ + selectionKey: `connector:${assetFamilyId}:${index + 1}`, + assetFamilyId, + quantity, + label: + componentCodes.find((code) => code.includes('CT-')) ?? + `推荐连接件 ${index + 1}`, + })) +} + class CombinationService { async generateCandidates(input: GenerateCombinationInput): Promise { const template = await seriesTemplateRepository.findByIdWithRelations(input.seriesTemplateId) @@ -140,6 +165,11 @@ class CombinationService { : assemblyWarnings.length > 0 ? '可预览但需确认' : '可直接预览' + const combinationItems = buildCombinationItems(candidate) + const fastenerRecommendations = buildFastenerRecommendations( + candidate, + materialized.componentCodes, + ) return { ...candidate, @@ -152,7 +182,21 @@ class CombinationService { componentCodes: materialized.componentCodes, bomPreview: materialized.bom, installStepPreview: materialized.installStepPreview, - combinationItems: buildCombinationItems(candidate), + combinationItems, + candidateSnapshot: { + key: candidate.key, + defaultName: candidate.defaultName, + templateCode: templateConfig.code, + combinationItems, + }, + assemblyWorkspace: buildSingleBoardDeskAssemblyWorkspace({ + templateCode: templateConfig.code, + candidateKey: candidate.key, + defaultName: candidate.defaultName, + previewNodes: materialized.previewNodes, + slotSelections: materialized.selectedSlots, + connectorRecommendations: fastenerRecommendations, + }), } }) diff --git a/furniture-3d-system/src/server/services/combination.service.types.ts b/furniture-3d-system/src/server/services/combination.service.types.ts index 735a3d63..60551259 100644 --- a/furniture-3d-system/src/server/services/combination.service.types.ts +++ b/furniture-3d-system/src/server/services/combination.service.types.ts @@ -1,5 +1,9 @@ import type { SingleBoardDeskCandidate } from '@/lib/open-product/single-board-desk' import type { AssemblyResultStatus } from '@/lib/open-product/assembly-contract' +import type { + AssemblyWorkspaceSnapshot, + CombinationCandidateSnapshot, +} from '@/lib/open-product/assembly-workspace' import type { InstallPreviewStep, PreSkuBomItem, @@ -29,6 +33,8 @@ export interface CombinationCandidateDTO extends SingleBoardDeskCandidate { bomPreview: PreSkuBomItem[] installStepPreview: InstallPreviewStep[] combinationItems: CombinationCandidateItemDTO[] + candidateSnapshot: CombinationCandidateSnapshot + assemblyWorkspace: AssemblyWorkspaceSnapshot } export interface CombinationGenerationResult { diff --git a/furniture-3d-system/src/server/services/draft-group.mapper.ts b/furniture-3d-system/src/server/services/draft-group.mapper.ts index 50899d21..51395627 100644 --- a/furniture-3d-system/src/server/services/draft-group.mapper.ts +++ b/furniture-3d-system/src/server/services/draft-group.mapper.ts @@ -1,4 +1,10 @@ import type { DraftGroupWithRelations, PreSkuWithRelations } from '../repositories' +import type { + AssemblyWorkspaceSnapshot, + CombinationCandidateSnapshot, + AssemblyRelation, + AssemblyWorkspaceNode, +} from '@/lib/open-product/assembly-workspace' import type { DraftGroupDTO, DraftGroupPreSkuDTO, @@ -28,6 +34,150 @@ function toNullableNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null } +function normalizeCandidateSnapshot(value: unknown): CombinationCandidateSnapshot | null { + if (!isRecord(value)) { + return null + } + + const combinationItems = Array.isArray(value.combinationItems) + ? value.combinationItems + .filter((item): item is Record => isRecord(item)) + .map((item) => ({ + label: typeof item.label === 'string' ? item.label : '', + value: typeof item.value === 'string' ? item.value : '', + quantity: typeof item.quantity === 'number' ? item.quantity : 0, + role: typeof item.role === 'string' ? item.role : undefined, + })) + : [] + + return { + key: typeof value.key === 'string' ? value.key : '', + defaultName: typeof value.defaultName === 'string' ? value.defaultName : '', + templateCode: typeof value.templateCode === 'string' ? value.templateCode : '', + combinationItems, + } +} + +function normalizeAssemblyWorkspace(value: unknown): AssemblyWorkspaceSnapshot | null { + if (!isRecord(value)) { + return null + } + + const nodes = asArray>(value.nodes).map((node) => ({ + nodeId: typeof node.nodeId === 'string' ? node.nodeId : '', + role: typeof node.role === 'string' ? node.role : '', + label: typeof node.label === 'string' ? node.label : '', + assetFamilyId: typeof node.assetFamilyId === 'string' ? node.assetFamilyId : '', + variantId: typeof node.variantId === 'string' ? node.variantId : '', + code: typeof node.code === 'string' ? node.code : '', + name: typeof node.name === 'string' ? node.name : '', + modelUrl: typeof node.modelUrl === 'string' ? node.modelUrl : '', + position: Array.isArray(node.position) ? node.position as [number, number, number] : [0, 0, 0], + rotation: Array.isArray(node.rotation) ? node.rotation as [number, number, number] : [0, 0, 0], + scale: Array.isArray(node.scale) ? node.scale as [number, number, number] : [1, 1, 1], + zone: + node.zone === 'main' || node.zone === 'subassembly' || node.zone === 'staging' + ? node.zone + : 'staging', + source: + node.source === 'desktop' || + node.source === 'leg' || + node.source === 'connector' || + node.source === 'unknown' + ? node.source + : 'unknown', + slotKey: typeof node.slotKey === 'string' ? node.slotKey : null, + roleRecommendation: typeof node.roleRecommendation === 'string' ? node.roleRecommendation : null, + roleConfirmed: typeof node.roleConfirmed === 'string' ? node.roleConfirmed : null, + weakSlot: isRecord(node.weakSlot) + ? { + role: typeof node.weakSlot.role === 'string' ? node.weakSlot.role : '', + parentRolePath: + typeof node.weakSlot.parentRolePath === 'string' ? node.weakSlot.parentRolePath : null, + instanceIndex: + typeof node.weakSlot.instanceIndex === 'number' ? node.weakSlot.instanceIndex : 1, + } + : null, + targetPosition: Array.isArray(node.targetPosition) + ? node.targetPosition as [number, number, number] + : null, + targetRotation: Array.isArray(node.targetRotation) + ? node.targetRotation as [number, number, number] + : null, + isSnapped: Boolean(node.isSnapped), + subassemblyId: typeof node.subassemblyId === 'string' ? node.subassemblyId : null, + mountPoints: asArray(node.mountPoints), + assemblyBasis: isRecord(node.assemblyBasis) + ? (node.assemblyBasis as Record) + : null, + materialConfig: isRecord(node.materialConfig) + ? (node.materialConfig as unknown as AssemblyWorkspaceNode['materialConfig']) + : null, + })) + + const relations = asArray>(value.relations).map((relation) => ({ + relationId: typeof relation.relationId === 'string' ? relation.relationId : '', + sourceNodeId: typeof relation.sourceNodeId === 'string' ? relation.sourceNodeId : '', + targetNodeId: typeof relation.targetNodeId === 'string' ? relation.targetNodeId : '', + relationType: + relation.relationType === 'hole-group' || + relation.relationType === 'surface' || + relation.relationType === 'connector' + ? relation.relationType + : 'surface', + status: + relation.status === 'CONNECTED' || + relation.status === 'SUGGESTED' || + relation.status === 'PENDING' + ? relation.status + : 'PENDING', + description: typeof relation.description === 'string' ? relation.description : '', + minMatchCount: toNullableNumber(relation.minMatchCount), + matchedCount: toNullableNumber(relation.matchedCount), + roleRecommendation: + typeof relation.roleRecommendation === 'string' ? relation.roleRecommendation : null, + roleConfirmed: typeof relation.roleConfirmed === 'string' ? relation.roleConfirmed : null, + requiresFastener: Boolean(relation.requiresFastener), + suggestedFasteners: asArray>(relation.suggestedFasteners).map((item) => ({ + selectionKey: typeof item.selectionKey === 'string' ? item.selectionKey : '', + assetFamilyId: typeof item.assetFamilyId === 'string' ? item.assetFamilyId : '', + variantId: typeof item.variantId === 'string' ? item.variantId : null, + quantity: typeof item.quantity === 'number' ? item.quantity : 0, + label: typeof item.label === 'string' ? item.label : '', + })), + })) + + return { + version: value.version === '2026-04-08' ? '2026-04-08' : '2026-04-08', + templateCode: typeof value.templateCode === 'string' ? value.templateCode : '', + workflowMode: 'assisted-manual', + nodes, + subassemblies: asArray>(value.subassemblies).map((item) => ({ + id: typeof item.id === 'string' ? item.id : '', + name: typeof item.name === 'string' ? item.name : '', + nodeIds: asArray(item.nodeIds).filter((entry): entry is string => typeof entry === 'string'), + status: item.status === 'sealed' ? 'sealed' : 'draft', + })), + relations, + validation: isRecord(value.validation) + ? { + level: + value.validation.level === 'PASSED' || + value.validation.level === 'WARNING' || + value.validation.level === 'BLOCKED' + ? value.validation.level + : 'WARNING', + blockingIssues: asArray(value.validation.blockingIssues).filter( + (entry): entry is string => typeof entry === 'string', + ), + warnings: asArray(value.validation.warnings).filter( + (entry): entry is string => typeof entry === 'string', + ), + } + : { level: 'WARNING', blockingIssues: [], warnings: [] }, + } +} + export function toDraftGroupDTO(record: DraftGroupWithRelations): DraftGroupDTO { return { id: record.id, @@ -302,6 +452,9 @@ export function toDraftGroupPreSkuDTO(record: PreSkuWithRelations): DraftGroupPr : [] const assemblyWarnings = Array.from(new Set([...summaryWarnings, ...noteWarnings])) + const candidateSnapshot = normalizeCandidateSnapshot(displaySummary?.candidateSnapshot) + const assemblyWorkspace = normalizeAssemblyWorkspace(displaySummary?.assemblyWorkspace) + return { id: record.id, draftGroupId: record.draftGroupId, @@ -317,6 +470,8 @@ export function toDraftGroupPreSkuDTO(record: PreSkuWithRelations): DraftGroupPr : null, previewNodes: Array.isArray(record.previewNodes) ? (record.previewNodes as unknown[]) : [], displaySummary, + candidateSnapshot, + assemblyWorkspace, assemblyResultStatus: normalizeAssemblyResultStatus( displaySummary?.resultStatus, ), diff --git a/furniture-3d-system/src/server/services/draft-group.service.types.ts b/furniture-3d-system/src/server/services/draft-group.service.types.ts index f3274eeb..a5ef7e55 100644 --- a/furniture-3d-system/src/server/services/draft-group.service.types.ts +++ b/furniture-3d-system/src/server/services/draft-group.service.types.ts @@ -1,6 +1,10 @@ import type { DraftGroupStatus, PreSkuStatus } from '@prisma/client' import type { AssetFamilyDTO, AssetVariantDTO } from './asset-center.service.types' import type { AssemblyResultStatus } from '@/lib/open-product/assembly-contract' +import type { + AssemblyWorkspaceSnapshot, + CombinationCandidateSnapshot, +} from '@/lib/open-product/assembly-workspace' type PackagingSourceType = 'PRODUCT' | 'PRE_SKU' | 'DRAFT_GROUP' type PackagingItemType = 'panel' | 'component' | 'hardware' | 'accessory' @@ -8,6 +12,8 @@ type PackagingItemType = 'panel' | 'component' | 'hardware' | 'accessory' export interface DraftGroupCandidateInput { key: string defaultName: string + candidateSnapshot?: CombinationCandidateSnapshot | null + assemblyWorkspace?: AssemblyWorkspaceSnapshot | null familySelection: { desktopFamilyId: string legGroupFamilyId?: string | null @@ -59,6 +65,8 @@ export interface DraftGroupPreSkuDTO { recommendedHardware: unknown[] | null previewNodes: unknown[] displaySummary: Record | null + candidateSnapshot: CombinationCandidateSnapshot | null + assemblyWorkspace: AssemblyWorkspaceSnapshot | null assemblyResultStatus: AssemblyResultStatus | null assemblyWarnings: string[] bomPreview: unknown[] diff --git a/furniture-3d-system/src/server/services/pre-sku-materialization.service.ts b/furniture-3d-system/src/server/services/pre-sku-materialization.service.ts index 26fb970c..e1e05ea5 100644 --- a/furniture-3d-system/src/server/services/pre-sku-materialization.service.ts +++ b/furniture-3d-system/src/server/services/pre-sku-materialization.service.ts @@ -1,5 +1,11 @@ import { Prisma } from '@prisma/client' import { materializeSingleBoardDeskPreSku } from '@/lib/open-product/single-board-desk' +import { + buildSingleBoardDeskAssemblyWorkspace, + deriveAssemblyValidation, + type AssemblyWorkspaceSnapshot, + type CombinationCandidateSnapshot, +} from '@/lib/open-product/assembly-workspace' import { AppError, ErrorCode } from '@/server/core/errors' import { assetFamilyRepository, @@ -36,6 +42,50 @@ function normalizeCandidate( } } +function normalizeCandidateSnapshot( + candidate: CreateDraftGroupFromAutoCombinationInput['candidates'][number], + templateCode: string, +): CombinationCandidateSnapshot { + if (candidate.candidateSnapshot) { + return candidate.candidateSnapshot + } + + return { + key: candidate.key, + defaultName: candidate.defaultName, + templateCode, + combinationItems: [], + } +} + +function normalizeAssemblyWorkspace( + candidate: CreateDraftGroupFromAutoCombinationInput['candidates'][number], + templateCode: string, + previewNodes: ReturnType['previewNodes'], + selectedSlots: ReturnType['selectedSlots'], +): AssemblyWorkspaceSnapshot { + if (candidate.assemblyWorkspace) { + const validation = deriveAssemblyValidation({ + nodes: candidate.assemblyWorkspace.nodes, + relations: candidate.assemblyWorkspace.relations, + }) + + return { + ...candidate.assemblyWorkspace, + templateCode, + validation, + } + } + + return buildSingleBoardDeskAssemblyWorkspace({ + templateCode, + candidateKey: candidate.key, + defaultName: candidate.defaultName, + previewNodes, + slotSelections: selectedSlots, + }) +} + function collectCandidateFamilyIds( candidate: CreateDraftGroupFromAutoCombinationInput['candidates'][number], ): string[] { @@ -111,6 +161,13 @@ class PreSkuMaterializationService { familyNameById: familyNames, }) const variantSelection = buildVariantSelection(materialized) + const candidateSnapshot = normalizeCandidateSnapshot(candidate, templateRecord.code) + const assemblyWorkspace = normalizeAssemblyWorkspace( + candidate, + templateRecord.code, + materialized.previewNodes, + materialized.selectedSlots, + ) return { draftGroupId, @@ -123,13 +180,27 @@ class PreSkuMaterializationService { variantSelection: variantSelection as unknown as Prisma.InputJsonValue, recommendedHardware: materialized.recommendedHardware as unknown as Prisma.InputJsonValue, - previewNodes: materialized.previewNodes as unknown as Prisma.InputJsonValue, + previewNodes: assemblyWorkspace.nodes.map((node) => ({ + role: node.roleConfirmed ?? node.roleRecommendation ?? node.role, + label: node.label, + assetFamilyId: node.assetFamilyId, + variantId: node.variantId, + code: node.code, + name: node.name, + modelUrl: node.modelUrl, + position: node.position, + rotation: node.rotation, + scale: node.scale, + materialConfig: node.materialConfig ?? null, + })) as unknown as Prisma.InputJsonValue, displaySummary: { defaultName: candidate.defaultName, componentCodes: materialized.componentCodes, resultStatus: assemblyResultStatus, warnings: assemblyWarnings, - } as Prisma.InputJsonValue, + candidateSnapshot, + assemblyWorkspace, + } as unknown as Prisma.InputJsonValue, bomPreview: materialized.bom as unknown as Prisma.InputJsonValue, weightPreview: materialized.weightKg, componentCodes: materialized.componentCodes, diff --git a/furniture-3d-system/src/server/services/share.service.ts b/furniture-3d-system/src/server/services/share.service.ts index c6e9603d..ee5354d7 100644 --- a/furniture-3d-system/src/server/services/share.service.ts +++ b/furniture-3d-system/src/server/services/share.service.ts @@ -147,18 +147,16 @@ class ShareService { const normalizedRequestedSkuId = this.normalizeSkuId(requestedSkuId) const currentSku = this.resolveCurrentSku(link.product.skus, normalizedRequestedSkuId) - const scopedSkuId = normalizedRequestedSkuId ?? currentSku?.id ?? null + const scopedSkuId = currentSku?.id ?? null const [assemblySteps, panels] = await Promise.all([ this.loadScopedAssemblySteps(link.product.id, scopedSkuId), this.loadScopedPanels(link.product.id, scopedSkuId), ]) - const resolvedModelUrl = - currentSku?.modelUrl || - link.product.modelUrl || - link.product.skus.find((sku) => Boolean(sku.modelUrl))?.modelUrl || - null + const resolvedModelUrl = currentSku + ? currentSku.modelUrl || link.product.modelUrl || null + : link.product.modelUrl || null // 返回白名单字段(公开分享数据) return { diff --git a/scripts/create-wyj-test-deploy.ps1 b/scripts/create-wyj-test-deploy.ps1 new file mode 100644 index 00000000..bf04563f --- /dev/null +++ b/scripts/create-wyj-test-deploy.ps1 @@ -0,0 +1,160 @@ +param( + [string]$ProjectRoot = "D:\AI Code\3danzhuang\furniture-3d-system", + [string]$OutputRoot = "D:\AI Code\3danzhuang" +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $ProjectRoot)) { + throw "项目目录不存在: $ProjectRoot" +} + +$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$archiveName = "deploy-furniture-wyj-test-$timestamp.tar.gz" +$scriptName = "deploy-furniture-wyj-test-$timestamp.sh" +$archivePath = Join-Path $OutputRoot $archiveName +$scriptPath = Join-Path $OutputRoot $scriptName + +$includePaths = @( + 'furniture-3d-system/package.json' + 'furniture-3d-system/package-lock.json' + 'furniture-3d-system/next.config.js' + 'furniture-3d-system/postcss.config.mjs' + 'furniture-3d-system/tsconfig.json' + 'furniture-3d-system/next-env.d.ts' + 'furniture-3d-system/prisma.config.ts' + 'furniture-3d-system/src' + 'furniture-3d-system/public' + 'furniture-3d-system/prisma' + 'furniture-3d-system/scripts' + 'furniture-3d-system/lib' +) + +Push-Location (Split-Path $ProjectRoot -Parent) +try { + if (Test-Path -LiteralPath $archivePath) { + Remove-Item -LiteralPath $archivePath -Force + } + + tar -czf $archivePath @includePaths +} finally { + Pop-Location +} + +$shellScript = @' +set -e +BASE=/root/codex-workspaces/furniture-3d-system-wyj-test +APP=$BASE/furniture-3d-system +ARCHIVE=/root/codex-workspaces/__ARCHIVE_NAME__ +DB_NAME=furniture3d_wyj_test +DB_USER=furniture3d_wyj_test +DB_PASS='F3dWyjTest_3200_20260407' +APP_PORT=3200 +BUCKET=furniture-3d-models-wyj-test +MINIO_USER=furniture3dtest +MINIO_PASS='F3dMinio_39100_20260331' + +mkdir -p $BASE +rm -rf $APP +cd $BASE +tar -xzf $ARCHIVE + +sudo -u postgres psql -d postgres </dev/null && mc mb -p local/$BUCKET >/dev/null 2>&1 || true && mc anonymous set download local/$BUCKET >/dev/null" + +cd $APP +npm install --no-audit --no-fund +sudo -u postgres psql -d $DB_NAME </dev/null 2>&1 || true +pm2 start /bin/bash --name furniture-3d-system-wyj-test --cwd $APP -- -lc "set -a; source ./.env; set +a; mkdir -p .next/standalone/.next; rm -rf .next/standalone/.next/static .next/standalone/public; cp -R .next/static .next/standalone/.next/static; cp -R public .next/standalone/public; exec env PORT=$APP_PORT HOSTNAME=0.0.0.0 node .next/standalone/server.js" +pm2 save >/dev/null +sleep 5 +curl -I -s http://127.0.0.1:$APP_PORT | sed -n '1,12p' +ls -1dt /root/codex-workspaces/deploy-furniture-wyj-test-*.tar.gz 2>/dev/null | tail -n +4 | xargs -r rm -f +echo '=== DONE ===' +pm2 describe furniture-3d-system-wyj-test | sed -n '1,35p' +'@ + +$shellScript = $shellScript.Replace('__ARCHIVE_NAME__', $archiveName) + +[System.IO.File]::WriteAllText($scriptPath, $shellScript, [System.Text.UTF8Encoding]::new($false)) + +$archive = Get-Item -LiteralPath $archivePath +[PSCustomObject]@{ + Archive = $archive.FullName + ArchiveMB = [math]::Round($archive.Length / 1MB, 2) + Script = $scriptPath + IncludedPaths = $includePaths -join ', ' +}