fix: close assisted assembly cad snap and prod deploy
This commit is contained in:
parent
3158e87908
commit
69b1daf157
@ -113,7 +113,10 @@ jest.mock('../assisted-assembly/AssistedAssemblyClient', () => ({
|
||||
families: Array<{ id: string }>
|
||||
variants: Array<{ id: string }>
|
||||
initialLaunchParams?: { entryMode?: string | null; sourceSkuId?: string | null } | null
|
||||
initialWorkspace?: { sourceSkuId?: string | null; nodes?: Array<{ nodeId: string }> } | null
|
||||
initialWorkspace?: {
|
||||
sourceSkuId?: string | null
|
||||
nodes?: Array<{ nodeId: string; assemblyBasis?: Record<string, unknown> | null }>
|
||||
} | null
|
||||
initialFeedback?: { tone?: string; text?: string } | null
|
||||
}) => (
|
||||
<div data-testid="assisted-assembly-client">
|
||||
@ -129,6 +132,9 @@ jest.mock('../assisted-assembly/AssistedAssemblyClient', () => ({
|
||||
<div data-testid="launch-source-sku-id">{initialLaunchParams?.sourceSkuId ?? 'none'}</div>
|
||||
<div data-testid="workspace-source-sku-id">{initialWorkspace?.sourceSkuId ?? 'none'}</div>
|
||||
<div data-testid="workspace-first-node-id">{initialWorkspace?.nodes?.[0]?.nodeId ?? 'none'}</div>
|
||||
<div data-testid="workspace-first-node-coordinate-system">
|
||||
{String(initialWorkspace?.nodes?.[0]?.assemblyBasis?.coordinateSystem ?? 'none')}
|
||||
</div>
|
||||
<div data-testid="feedback-text">{initialFeedback?.text ?? 'none'}</div>
|
||||
</div>
|
||||
),
|
||||
@ -341,4 +347,70 @@ describe('AssistedAssemblyPage', () => {
|
||||
expect(screen.getByTestId('workspace-first-node-id')).toHaveTextContent('workspace-node-1')
|
||||
expect(screen.getByTestId('feedback-text')).toHaveTextContent('当前已按源 SKU MODEL-001 的装配结果恢复工作台。')
|
||||
})
|
||||
|
||||
it('源 SKU fallback 恢复 previewNodes 时,会保留节点上的 assemblyBasis 坐标系语义', async () => {
|
||||
jest.mocked(prisma.productSKU.findUnique).mockResolvedValue({
|
||||
id: 'sku-source-2',
|
||||
productId: 'product-source-2',
|
||||
product: {
|
||||
id: 'product-source-2',
|
||||
name: '源产品 2',
|
||||
modelNumber: 'MODEL-002',
|
||||
createdBy: 'user-1',
|
||||
departmentId: 'dept-1',
|
||||
visibility: 'PRIVATE',
|
||||
collaboratorUsers: [],
|
||||
creator: {
|
||||
departmentId: 'dept-1',
|
||||
teamId: 'team-1',
|
||||
},
|
||||
},
|
||||
sourcePreSku: {
|
||||
previewNodes: [
|
||||
{
|
||||
variantId: 'variant-board',
|
||||
assetFamilyId: 'family-board',
|
||||
role: 'desktop',
|
||||
position: [0, 720, 0],
|
||||
rotation: [0, 0, 0],
|
||||
scale: [1, 1, 1],
|
||||
assemblyBasis: {
|
||||
coordinateSystem: 'cad-z-up',
|
||||
holes: [],
|
||||
holeGroups: [],
|
||||
mountPatterns: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
variantSelection: {
|
||||
slots: [
|
||||
{
|
||||
slotKey: 'desktop-1',
|
||||
role: 'desktop',
|
||||
assetFamilyId: 'family-board',
|
||||
assetType: 'BOARD',
|
||||
variantId: 'variant-board',
|
||||
mountPoints: [],
|
||||
assemblyBasis: { anchor: 'desktop-center' },
|
||||
},
|
||||
],
|
||||
hardwareSelections: [],
|
||||
},
|
||||
displaySummary: null,
|
||||
},
|
||||
} as never)
|
||||
|
||||
render(
|
||||
await AssistedAssemblyPage({
|
||||
searchParams: Promise.resolve({
|
||||
entryMode: 'source-sku',
|
||||
sourceSkuId: 'sku-source-2',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('launch-entry-mode')).toHaveTextContent('source-sku')
|
||||
expect(screen.getByTestId('workspace-source-sku-id')).toHaveTextContent('sku-source-2')
|
||||
expect(screen.getByTestId('workspace-first-node-coordinate-system')).toHaveTextContent('cad-z-up')
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,8 +3,12 @@ import { headers } from 'next/headers'
|
||||
import { canViewProduct } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { buildAssetThumbnailRenderUrl, type AssetThumbnailRenderAssetType } from '@/lib/asset-thumbnail-auth'
|
||||
import {
|
||||
buildAssetThumbnailImageUrl,
|
||||
type AssetThumbnailRenderAssetType,
|
||||
} from '@/lib/asset-thumbnail-auth'
|
||||
import { toMaterialConfig } from '@/lib/material-config'
|
||||
import { applyAssetVariantUvConfigToMaterial } from '@/lib/asset-variant-render-config'
|
||||
import {
|
||||
buildAssemblyWorkspaceFamilies,
|
||||
buildManualAssemblyWorkspace,
|
||||
@ -102,6 +106,31 @@ function normalizeMountPoints(value: unknown): OpenProductMountPoint[] {
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeAssemblyBasisRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function mergeAssemblyBasisRecords(
|
||||
previewNodeAssemblyBasis: unknown,
|
||||
selectionAssemblyBasis: unknown,
|
||||
): Record<string, unknown> | null {
|
||||
const previewBasis = normalizeAssemblyBasisRecord(previewNodeAssemblyBasis)
|
||||
const selectionBasis = normalizeAssemblyBasisRecord(selectionAssemblyBasis)
|
||||
|
||||
if (previewBasis && selectionBasis) {
|
||||
return {
|
||||
...previewBasis,
|
||||
...selectionBasis,
|
||||
}
|
||||
}
|
||||
|
||||
return selectionBasis ?? previewBasis ?? null
|
||||
}
|
||||
|
||||
function buildSelectedFamilyIdsFromVariantSelection(
|
||||
variantSelection: unknown,
|
||||
): ManualAssemblySelectedFamilyIdsByType {
|
||||
@ -275,7 +304,10 @@ function buildSourceSkuWorkspaceFallback(input: {
|
||||
mountPoints: normalizeMountPoints(
|
||||
slotSelection?.mountPoints ?? hardwareSelection?.mountPoints ?? [],
|
||||
),
|
||||
assemblyBasis: slotSelection?.assemblyBasis ?? hardwareSelection?.assemblyBasis ?? null,
|
||||
assemblyBasis: mergeAssemblyBasisRecords(
|
||||
node.assemblyBasis,
|
||||
slotSelection?.assemblyBasis ?? hardwareSelection?.assemblyBasis ?? null,
|
||||
),
|
||||
compatibilityRules: null,
|
||||
materialConfig:
|
||||
node.materialConfig && typeof node.materialConfig === 'object'
|
||||
@ -501,10 +533,16 @@ export default async function AssistedAssemblyPage({
|
||||
finishes.map((finish) => [finish.id, toMaterialConfig(finish)]),
|
||||
)
|
||||
const runtimeSelectableVariants: AssistedAssemblyVariant[] = selectableVariants.map((variant) => {
|
||||
const materialConfig = variant.finishId ? finishMaterialById.get(variant.finishId) ?? null : null
|
||||
const finishMaterialConfig = variant.finishId
|
||||
? finishMaterialById.get(variant.finishId) ?? null
|
||||
: null
|
||||
const materialConfig = applyAssetVariantUvConfigToMaterial(
|
||||
finishMaterialConfig,
|
||||
variant.uvConfig,
|
||||
)
|
||||
const runtimeThumbnailUrl =
|
||||
materialConfig && variant.modelUrl
|
||||
? buildAssetThumbnailRenderUrl(requestOrigin, {
|
||||
? buildAssetThumbnailImageUrl(requestOrigin, {
|
||||
modelUrl: variant.modelUrl,
|
||||
assetType: variant.assetType as AssetThumbnailRenderAssetType,
|
||||
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
|
||||
|
||||
@ -4,7 +4,7 @@ import { prisma } from '@/lib/prisma'
|
||||
import { canViewProduct } from '@/lib/permissions'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import AssemblyEditorWorkspace from '../../../assembly/AssemblyEditorWorkspace'
|
||||
import { type StepData } from '@/components/assembly'
|
||||
import { type PanelInfo, type StepData } from '@/components/assembly'
|
||||
import { extractResponseData, isApiFailure, isRecord, readJsonSafely } from '@/lib/api-response'
|
||||
import { buildHoleOverlayDataFromPersistedHoles } from '@/lib/hole-overlay-data'
|
||||
import {
|
||||
@ -263,7 +263,7 @@ export default async function SKUAssemblyEditorPage({ params }: PageProps) {
|
||||
cookieHeader,
|
||||
})
|
||||
const skuHoleOverlayData = buildHoleOverlayDataFromPersistedHoles(scopedHoles)
|
||||
const displayPanels = enrichPanelsWithSkuCadSpatial(
|
||||
const displayPanels = enrichPanelsWithSkuCadSpatial<PanelInfo>(
|
||||
scopedPanels.map((p) => ({
|
||||
id: p.id,
|
||||
code: p.code,
|
||||
|
||||
@ -4,7 +4,7 @@ import { prisma } from '@/lib/prisma'
|
||||
import { canViewProduct } from '@/lib/permissions'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { SKUDetailClient } from './SKUDetailClient'
|
||||
import type { AssemblyStep, CameraPosition, Transform } from '@/types/product'
|
||||
import type { AssemblyStep, CameraPosition, Panel, Transform } from '@/types/product'
|
||||
import { extractResponseData, isApiFailure, isRecord, readJsonSafely } from '@/lib/api-response'
|
||||
import { buildHoleOverlayDataFromPersistedHoles } from '@/lib/hole-overlay-data'
|
||||
import {
|
||||
@ -349,7 +349,7 @@ export default async function SKUDetailPage({ params }: SKUDetailPageProps) {
|
||||
orderBy: [{ panelId: 'asc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
const skuHoleOverlayData = buildHoleOverlayDataFromPersistedHoles(scopedHoles)
|
||||
const displayPanels = enrichPanelsWithSkuCadSpatial(
|
||||
const displayPanels = enrichPanelsWithSkuCadSpatial<Panel>(
|
||||
scopedPanels.map((p) => ({
|
||||
id: p.id,
|
||||
productId: p.productId,
|
||||
|
||||
@ -14,11 +14,14 @@ import {
|
||||
} from './AssemblyMaterialDock'
|
||||
import { AssemblyMaterialCard } from './AssemblyMaterialCard'
|
||||
import { ModelPreviewPanel } from './ModelPreviewPanel'
|
||||
import type { HoleData as HoleOverlayData } from '@/components/3d/HoleViewer'
|
||||
import {
|
||||
getAssistedAssemblyVariantMaterialConfig,
|
||||
resolveAssistedAssemblyNodeMaterialConfig,
|
||||
type AssistedAssemblyVariant,
|
||||
} from '@/lib/open-product/assisted-assembly-variant'
|
||||
import {
|
||||
deriveAssemblyActiveSnapCandidate,
|
||||
moveAssemblyNodes,
|
||||
removeAssemblyNodes,
|
||||
instantiateAssemblyNode,
|
||||
@ -59,6 +62,10 @@ function getSourceLabel(source: AssemblyWorkspaceNode['source']) {
|
||||
return '零件'
|
||||
}
|
||||
|
||||
function buildSnapHolePanelId(nodeId: string, holeId: string) {
|
||||
return `${nodeId}::hole::${holeId}`
|
||||
}
|
||||
|
||||
function getNodeStatus(node: { isSnapped?: boolean; zone: 'main' | 'staging' | 'subassembly' }) {
|
||||
if (node.isSnapped) return '已连接'
|
||||
if (node.zone === 'main') return '待连接'
|
||||
@ -80,6 +87,7 @@ function toOpenProductVariant(variant: AssistedAssemblyVariant) {
|
||||
mountPoints: variant.mountPoints,
|
||||
substrateId: variant.substrateId,
|
||||
finishId: variant.finishId,
|
||||
uvConfig: variant.uvConfig,
|
||||
weightKg: variant.weightKg,
|
||||
costPrice: variant.costPrice,
|
||||
assemblyBasis: variant.assemblyBasis,
|
||||
@ -263,6 +271,75 @@ export function AssemblyWorkbenchV2({
|
||||
|
||||
const selectedNodes = presentWorkspace.nodes.filter((node) => selectedNodeIds.includes(node.nodeId))
|
||||
const selectedSingleNode = selectedNodes.length === 1 ? selectedNodes[0] : null
|
||||
const activeSnapCandidate = useMemo(
|
||||
() =>
|
||||
selectedSingleNode
|
||||
? deriveAssemblyActiveSnapCandidate(presentWorkspace, selectedSingleNode.nodeId)
|
||||
: null,
|
||||
[presentWorkspace, selectedSingleNode],
|
||||
)
|
||||
const highlightedNodeNames = useMemo(() => {
|
||||
if (!activeSnapCandidate?.targetNodeId) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [activeSnapCandidate.targetNodeId]
|
||||
}, [activeSnapCandidate])
|
||||
const holeOverlayState = useMemo(() => {
|
||||
if (!selectedSingleNode || !activeSnapCandidate) {
|
||||
return {
|
||||
holes: [] as HoleOverlayData[],
|
||||
activeHolePanelIds: [] as string[],
|
||||
panelNodeNameByPanelId: {} as Record<string, string>,
|
||||
panelLabelByPanelId: {} as Record<string, string>,
|
||||
}
|
||||
}
|
||||
|
||||
const targetNode =
|
||||
presentWorkspace.nodes.find((node) => node.nodeId === activeSnapCandidate.targetNodeId) ?? null
|
||||
const sourcePanelId = buildSnapHolePanelId(
|
||||
activeSnapCandidate.sourceNodeId,
|
||||
activeSnapCandidate.sourceHoleId,
|
||||
)
|
||||
const targetPanelId = buildSnapHolePanelId(
|
||||
activeSnapCandidate.targetNodeId,
|
||||
activeSnapCandidate.targetHoleId,
|
||||
)
|
||||
|
||||
return {
|
||||
holes: [
|
||||
{
|
||||
id: sourcePanelId,
|
||||
panelId: sourcePanelId,
|
||||
position: activeSnapCandidate.sourceHoleWorldPosition,
|
||||
diameter: activeSnapCandidate.sourceHoleDiameter ?? 8,
|
||||
depth: activeSnapCandidate.sourceHoleDepth ?? 12,
|
||||
normal: activeSnapCandidate.sourceHoleNormal,
|
||||
holeType: 'assembly-snap-source',
|
||||
confidence: 1,
|
||||
},
|
||||
{
|
||||
id: targetPanelId,
|
||||
panelId: targetPanelId,
|
||||
position: activeSnapCandidate.targetHoleWorldPosition,
|
||||
diameter: activeSnapCandidate.targetHoleDiameter ?? 8,
|
||||
depth: activeSnapCandidate.targetHoleDepth ?? 12,
|
||||
normal: activeSnapCandidate.targetHoleNormal,
|
||||
holeType: 'assembly-snap-target',
|
||||
confidence: 1,
|
||||
},
|
||||
],
|
||||
activeHolePanelIds: [sourcePanelId, targetPanelId],
|
||||
panelNodeNameByPanelId: {
|
||||
[sourcePanelId]: selectedSingleNode.nodeId,
|
||||
[targetPanelId]: activeSnapCandidate.targetNodeId,
|
||||
},
|
||||
panelLabelByPanelId: {
|
||||
[sourcePanelId]: selectedSingleNode.name,
|
||||
[targetPanelId]: targetNode?.name ?? activeSnapCandidate.targetNodeId,
|
||||
},
|
||||
}
|
||||
}, [activeSnapCandidate, presentWorkspace.nodes, selectedSingleNode])
|
||||
const preferredInventoryId = useMemo(
|
||||
() => getPreferredInventoryId(presentWorkspace.inventory, availableVariantMap),
|
||||
[availableVariantMap, presentWorkspace.inventory],
|
||||
@ -298,9 +375,10 @@ export function AssemblyWorkbenchV2({
|
||||
|
||||
const stageMaterialAssignments = useMemo(() => {
|
||||
return presentWorkspace.nodes.flatMap((node) => {
|
||||
const materialConfig =
|
||||
node.materialConfig ??
|
||||
getAssistedAssemblyVariantMaterialConfig(availableVariantById.get(node.variantId))
|
||||
const materialConfig = resolveAssistedAssemblyNodeMaterialConfig({
|
||||
variant: availableVariantById.get(node.variantId),
|
||||
nodeMaterialConfig: node.materialConfig,
|
||||
})
|
||||
|
||||
if (!materialConfig) {
|
||||
return []
|
||||
@ -772,6 +850,11 @@ export function AssemblyWorkbenchV2({
|
||||
onGizmoInteractionStart={handleTransformStart}
|
||||
onGizmoInteractionEnd={handleTransformEnd}
|
||||
onWorldNodeTransformChange={handleWorldNodeTransformChange}
|
||||
holes={holeOverlayState.holes}
|
||||
activeHolePanelIds={holeOverlayState.activeHolePanelIds}
|
||||
panelNodeNameByPanelId={holeOverlayState.panelNodeNameByPanelId}
|
||||
panelLabelByPanelId={holeOverlayState.panelLabelByPanelId}
|
||||
highlightedNodeNames={highlightedNodeNames}
|
||||
overlayLeft={materialSelectionZone}
|
||||
overlayLeftClassName="left-0 top-[88px] bottom-6"
|
||||
overlayRight={rightDrawer}
|
||||
|
||||
@ -5,6 +5,7 @@ import { Box } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/ui/EmptyState'
|
||||
import { CompositeAssetPreview } from '@/components/platform/CompositeAssetPreview'
|
||||
import { ViewportStageShell, type ViewportPresetKey } from '@/components/platform/ViewportStageShell'
|
||||
import type { HoleData as HoleOverlayData } from '@/components/3d/HoleViewer'
|
||||
import type {
|
||||
NodePositionOffset,
|
||||
NodeRotationState,
|
||||
@ -58,10 +59,18 @@ interface ModelPreviewPanelProps {
|
||||
selectableNodeNames?: string[]
|
||||
nodePositionOffsets?: NodePositionOffset[]
|
||||
nodeRotationStates?: NodeRotationState[]
|
||||
highlightedNodeNames?: string[]
|
||||
materialAssignments?: Array<{
|
||||
nodeName: string
|
||||
material: import('@/lib/3d/material-manager').MaterialConfig
|
||||
}>
|
||||
holes?: HoleOverlayData[]
|
||||
activeHolePanelIds?: string[]
|
||||
leaderPanelIds?: string[]
|
||||
panelNodeNameByPanelId?: Record<string, string>
|
||||
panelLabelByPanelId?: Record<string, string>
|
||||
showPanelLeaders?: boolean
|
||||
showHoleHighlights?: boolean
|
||||
onGizmoInteractionStart?: (nodeName: string) => void
|
||||
onGizmoInteractionEnd?: (nodeName: string) => void
|
||||
onNodePositionOffsetChange?: (payload: {
|
||||
@ -154,7 +163,15 @@ export function ModelPreviewPanel({
|
||||
selectableNodeNames = [],
|
||||
nodePositionOffsets = [],
|
||||
nodeRotationStates = [],
|
||||
highlightedNodeNames = [],
|
||||
materialAssignments = [],
|
||||
holes = [],
|
||||
activeHolePanelIds = [],
|
||||
leaderPanelIds = [],
|
||||
panelNodeNameByPanelId = {},
|
||||
panelLabelByPanelId = {},
|
||||
showPanelLeaders = false,
|
||||
showHoleHighlights = true,
|
||||
onGizmoInteractionStart,
|
||||
onGizmoInteractionEnd,
|
||||
onNodePositionOffsetChange,
|
||||
@ -344,6 +361,7 @@ export function ModelPreviewPanel({
|
||||
gizmoMode={gizmoMode}
|
||||
onEngineReady={handleEngineReady}
|
||||
onModelNodesChange={onModelNodesChange}
|
||||
highlightedParts={highlightedNodeNames}
|
||||
selectableNodeNames={selectableNodeNames}
|
||||
selectedNodeName={selectedNodeName}
|
||||
onNodeSelect={onNodeSelect}
|
||||
@ -352,6 +370,13 @@ export function ModelPreviewPanel({
|
||||
onGizmoInteractionEnd={onGizmoInteractionEnd}
|
||||
onWorldNodeTransformChange={onWorldNodeTransformChange}
|
||||
materialAssignments={materialAssignments}
|
||||
holes={holes}
|
||||
activeHolePanelIds={activeHolePanelIds}
|
||||
leaderPanelIds={leaderPanelIds}
|
||||
panelNodeNameByPanelId={panelNodeNameByPanelId}
|
||||
panelLabelByPanelId={panelLabelByPanelId}
|
||||
showPanelLeaders={showPanelLeaders}
|
||||
showHoleHighlights={showHoleHighlights}
|
||||
/>
|
||||
</div>
|
||||
) : hasPreviewNodes ? (
|
||||
@ -362,22 +387,23 @@ export function ModelPreviewPanel({
|
||||
<ModelViewer
|
||||
modelUrl={modelUrl!}
|
||||
showSceneGrid={effectiveShowSceneGrid}
|
||||
enableTranslateGizmo={enableTranslateGizmo}
|
||||
gizmoMode={gizmoMode}
|
||||
nodePositionOffsets={nodePositionOffsets}
|
||||
nodeRotationStates={nodeRotationStates}
|
||||
onEngineReady={handleEngineReady}
|
||||
onModelNodesChange={onModelNodesChange}
|
||||
selectableNodeNames={selectableNodeNames}
|
||||
selectedNodeName={selectedNodeName}
|
||||
onNodeSelect={onNodeSelect}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
onGizmoInteractionStart={onGizmoInteractionStart}
|
||||
onGizmoInteractionEnd={onGizmoInteractionEnd}
|
||||
onNodePositionOffsetChange={onNodePositionOffsetChange}
|
||||
onNodeRotationChange={onNodeRotationChange}
|
||||
materialAssignments={materialAssignments}
|
||||
/>
|
||||
enableTranslateGizmo={enableTranslateGizmo}
|
||||
gizmoMode={gizmoMode}
|
||||
nodePositionOffsets={nodePositionOffsets}
|
||||
nodeRotationStates={nodeRotationStates}
|
||||
onEngineReady={handleEngineReady}
|
||||
onModelNodesChange={onModelNodesChange}
|
||||
highlightedParts={highlightedNodeNames}
|
||||
selectableNodeNames={selectableNodeNames}
|
||||
selectedNodeName={selectedNodeName}
|
||||
onNodeSelect={onNodeSelect}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
onGizmoInteractionStart={onGizmoInteractionStart}
|
||||
onGizmoInteractionEnd={onGizmoInteractionEnd}
|
||||
onNodePositionOffsetChange={onNodePositionOffsetChange}
|
||||
onNodeRotationChange={onNodeRotationChange}
|
||||
materialAssignments={materialAssignments}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -4,7 +4,10 @@ import { useState, type ReactElement, type ReactNode } from 'react'
|
||||
import { AssemblyWorkbenchV2 } from '../AssemblyWorkbenchV2'
|
||||
import { calculateAssemblyMaterialPanelTop } from '../AssemblyMaterialCard'
|
||||
import {
|
||||
applyAutomaticAssemblySnapPreview,
|
||||
buildManualAssemblyWorkspace,
|
||||
instantiateAssemblyNode,
|
||||
updateAssemblyNodeTransform,
|
||||
type AssemblyWorkspaceSnapshot,
|
||||
} from '@/lib/open-product/assembly-workspace'
|
||||
|
||||
@ -61,6 +64,9 @@ jest.mock('../ModelPreviewPanel', () => {
|
||||
worldFrameMode,
|
||||
selectedNodeName,
|
||||
materialAssignments = [],
|
||||
holes = [],
|
||||
activeHolePanelIds = [],
|
||||
highlightedNodeNames = [],
|
||||
overlayLeft,
|
||||
overlayRight,
|
||||
overlayBottomCenter,
|
||||
@ -71,6 +77,9 @@ jest.mock('../ModelPreviewPanel', () => {
|
||||
worldFrameMode?: 'always' | 'initial' | 'never'
|
||||
selectedNodeName?: string | null
|
||||
materialAssignments?: Array<{ nodeName: string }>
|
||||
holes?: Array<{ id: string; diameter?: number; depth?: number }>
|
||||
activeHolePanelIds?: string[]
|
||||
highlightedNodeNames?: string[]
|
||||
overlayLeft?: ReactNode
|
||||
overlayRight?: ReactNode
|
||||
overlayBottomCenter?: ReactNode
|
||||
@ -99,6 +108,12 @@ jest.mock('../ModelPreviewPanel', () => {
|
||||
<div data-testid="world-frame-mode">{worldFrameMode ?? 'none'}</div>
|
||||
<div data-testid="selected-node">{selectedNodeName ?? 'none'}</div>
|
||||
<div data-testid="material-assignment-count">材质指派:{materialAssignments.length}</div>
|
||||
<div data-testid="hole-overlay-count">孔位覆盖:{holes.length}</div>
|
||||
<div data-testid="hole-overlay-first-metrics">
|
||||
{holes[0] ? `${holes[0].diameter ?? 'none'}/${holes[0].depth ?? 'none'}` : 'none'}
|
||||
</div>
|
||||
<div data-testid="active-hole-panel-count">激活孔位面板:{activeHolePanelIds.length}</div>
|
||||
<div data-testid="highlighted-node-count">高亮节点:{highlightedNodeNames.length}</div>
|
||||
{withTestId(overlayLeft, 'material-zone')}
|
||||
{withTestId(overlayRight, 'right-drawer-zone')}
|
||||
<div>{overlayBottomCenter}</div>
|
||||
@ -116,6 +131,21 @@ jest.mock('../ModelPreviewPanel', () => {
|
||||
>
|
||||
模拟选中首节点
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const secondNode = worldNodes[1] ?? null
|
||||
onNodeSelect?.({
|
||||
nodeName: secondNode?.nodeName ?? null,
|
||||
rawNodeName: secondNode?.nodeName ?? null,
|
||||
candidateNodeName: secondNode?.nodeName ?? null,
|
||||
appendToSelection: false,
|
||||
hasRaycastHit: Boolean(secondNode),
|
||||
})
|
||||
}}
|
||||
>
|
||||
模拟选中第二节点
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@ -217,8 +247,51 @@ function buildVariants() {
|
||||
modelUrl: '/leg.glb',
|
||||
thumbnailUrl: null,
|
||||
dimensions: {},
|
||||
mountPoints: [],
|
||||
assemblyBasis: null,
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
assemblyBasis: {
|
||||
attachTo: 'desktop',
|
||||
holes: [
|
||||
{
|
||||
id: 'leg-hole-1',
|
||||
panelId: 'leg-panel',
|
||||
position: [0, 0, 0],
|
||||
normal: [0, 1, 0],
|
||||
diameter: 8,
|
||||
depth: 12,
|
||||
holeGroupId: 'leg-hole-group',
|
||||
holeGroupType: 'pair',
|
||||
holeGroupRole: 'mount',
|
||||
surfaceFaces: ['bottom'],
|
||||
},
|
||||
],
|
||||
holeGroups: [
|
||||
{
|
||||
id: 'leg-hole-group',
|
||||
panelId: 'leg-panel',
|
||||
groupType: 'pair',
|
||||
groupRole: 'mount',
|
||||
mountFace: 'bottom',
|
||||
holeIds: ['leg-hole-1'],
|
||||
surfaceFaces: ['bottom'],
|
||||
},
|
||||
],
|
||||
mountPatterns: [
|
||||
{
|
||||
id: 'leg-pattern-1',
|
||||
panelId: 'leg-panel',
|
||||
mountFace: 'bottom',
|
||||
patternType: 'single',
|
||||
holeIds: ['leg-hole-1'],
|
||||
requiredHoleIds: ['leg-hole-1'],
|
||||
optionalHoleIds: [],
|
||||
minRequiredHoles: 1,
|
||||
preferredHoleCount: 1,
|
||||
symmetry: false,
|
||||
confidence: 0.98,
|
||||
},
|
||||
],
|
||||
},
|
||||
compatibilityRules: { allowedRoles: ['desktop'] },
|
||||
} as never,
|
||||
]
|
||||
}
|
||||
@ -228,11 +301,13 @@ const AVAILABLE_VARIANTS = buildVariants()
|
||||
function ControlledWorkbench({
|
||||
onSnapshotChange,
|
||||
rightDrawerContent,
|
||||
initialWorkspace,
|
||||
}: {
|
||||
onSnapshotChange?: (next: AssemblyWorkspaceSnapshot) => void
|
||||
rightDrawerContent?: ReactNode
|
||||
initialWorkspace?: AssemblyWorkspaceSnapshot
|
||||
}) {
|
||||
const [workspace, setWorkspace] = useState(() => buildWorkspace())
|
||||
const [workspace, setWorkspace] = useState(() => initialWorkspace ?? buildWorkspace())
|
||||
|
||||
return (
|
||||
<AssemblyWorkbenchV2
|
||||
@ -251,6 +326,80 @@ function ControlledWorkbench({
|
||||
)
|
||||
}
|
||||
|
||||
function buildHoleSnapWorkspace() {
|
||||
const baseWorkspace = buildWorkspace()
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-board-1:1',
|
||||
variant: {
|
||||
id: 'variant-board-1',
|
||||
assetFamilyId: 'family-board-1',
|
||||
assetType: 'BOARD',
|
||||
code: 'BOARD-A-001',
|
||||
name: '桌面A-001',
|
||||
status: 'AVAILABLE',
|
||||
modelUrl: '/board.glb',
|
||||
thumbnailUrl: null,
|
||||
dimensions: { length: 1200, width: 600, thickness: 25 },
|
||||
mountPoints: [],
|
||||
assemblyBasis: {
|
||||
anchor: 'desktop-center',
|
||||
holes: [
|
||||
{
|
||||
id: 'board-hole-1',
|
||||
panelId: 'board-panel',
|
||||
position: [0, 0, 0],
|
||||
normal: [0, -1, 0],
|
||||
diameter: 8,
|
||||
depth: 12,
|
||||
holeGroupId: 'board-hole-group',
|
||||
holeGroupType: 'pair',
|
||||
holeGroupRole: 'mount',
|
||||
surfaceFaces: ['top'],
|
||||
},
|
||||
],
|
||||
holeGroups: [
|
||||
{
|
||||
id: 'board-hole-group',
|
||||
panelId: 'board-panel',
|
||||
groupType: 'pair',
|
||||
groupRole: 'mount',
|
||||
mountFace: 'top',
|
||||
holeIds: ['board-hole-1'],
|
||||
surfaceFaces: ['top'],
|
||||
},
|
||||
],
|
||||
mountPatterns: [
|
||||
{
|
||||
id: 'board-pattern-1',
|
||||
panelId: 'board-panel',
|
||||
mountFace: 'top',
|
||||
patternType: 'single',
|
||||
holeIds: ['board-hole-1'],
|
||||
requiredHoleIds: ['board-hole-1'],
|
||||
optionalHoleIds: [],
|
||||
minRequiredHoles: 1,
|
||||
preferredHoleCount: 1,
|
||||
symmetry: false,
|
||||
confidence: 0.98,
|
||||
},
|
||||
],
|
||||
},
|
||||
materialConfig: AVAILABLE_VARIANTS[0]?.materialConfig ?? null,
|
||||
} as never,
|
||||
})
|
||||
const withLeg = instantiateAssemblyNode(withDesktop, {
|
||||
inventoryId: 'leg:family-leg-1:2',
|
||||
variant: AVAILABLE_VARIANTS[1] as never,
|
||||
})
|
||||
const moved = updateAssemblyNodeTransform(withLeg, withLeg.nodes[1]!.nodeId, {
|
||||
position: [0, 720, 0],
|
||||
})
|
||||
|
||||
return applyAutomaticAssemblySnapPreview(moved, moved.nodes[1]!.nodeId, {
|
||||
fallbackZone: 'staging',
|
||||
})
|
||||
}
|
||||
|
||||
describe('AssemblyWorkbenchV2', () => {
|
||||
it('默认显示统一 3D 世界,并通过悬停展开左右浮层', async () => {
|
||||
const actor = userEvent.setup()
|
||||
@ -359,6 +508,29 @@ describe('AssemblyWorkbenchV2', () => {
|
||||
expect(latestSnapshot.nodes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('选中存在孔位候选的节点后,会把孔位覆盖层和目标节点高亮传给预览面板', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(
|
||||
<ControlledWorkbench
|
||||
initialWorkspace={buildHoleSnapWorkspace()}
|
||||
rightDrawerContent={<div>右侧业务区</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('world-node-count')).toHaveTextContent('世界节点:2')
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '模拟选中第二节点' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('selected-node')).not.toHaveTextContent('none')
|
||||
expect(screen.getByTestId('hole-overlay-count')).toHaveTextContent('孔位覆盖:2')
|
||||
expect(screen.getByTestId('hole-overlay-first-metrics')).toHaveTextContent('8/12')
|
||||
expect(screen.getByTestId('active-hole-panel-count')).toHaveTextContent('激活孔位面板:2')
|
||||
expect(screen.getByTestId('highlighted-node-count')).toHaveTextContent('高亮节点:1')
|
||||
})
|
||||
})
|
||||
|
||||
it('选中节点后不再显示底部快捷浮条,操作统一走右键菜单', async () => {
|
||||
render(<ControlledWorkbench />)
|
||||
|
||||
|
||||
@ -22,12 +22,15 @@ export interface CADExportData {
|
||||
modelFile: string
|
||||
panelCount: number
|
||||
units: string
|
||||
coordinateSystem?: string
|
||||
originOffset?: number[]
|
||||
modelBounds?: number[]
|
||||
|
||||
// v2.0+ 字段
|
||||
panels?: CADPanel[]
|
||||
holes?: CADHole[]
|
||||
holeGroups?: CADHoleGroup[]
|
||||
mountPatterns?: CADMountPattern[]
|
||||
/**
|
||||
* 被过滤/排除的孔位(兜底用)。
|
||||
*
|
||||
@ -87,9 +90,41 @@ export interface CADHole {
|
||||
holeGroupId?: string
|
||||
holeGroupType?: string
|
||||
holeGroupRole?: string
|
||||
surfaceFaces?: string[]
|
||||
suggestedFasteners?: CADSuggestedFastener[]
|
||||
}
|
||||
|
||||
export interface CADHoleGroup {
|
||||
id: string
|
||||
panelId: string
|
||||
groupType: string
|
||||
holeIds: string[]
|
||||
position: number[]
|
||||
normal: number[]
|
||||
mountFace?: string
|
||||
confidence?: number
|
||||
reason?: string
|
||||
surfaceFaces?: string[]
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface CADMountPattern {
|
||||
id: string
|
||||
panelId: string
|
||||
mountFace: string
|
||||
patternType: string
|
||||
holeIds: string[]
|
||||
requiredHoleIds?: string[]
|
||||
optionalHoleIds?: string[]
|
||||
minRequiredHoles: number
|
||||
preferredHoleCount: number
|
||||
symmetry?: boolean
|
||||
position: number[]
|
||||
normal: number[]
|
||||
confidence?: number
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface CADSuggestedFastener {
|
||||
category: string
|
||||
nominalDiameter?: number
|
||||
@ -188,6 +223,7 @@ export interface ProcessedHole {
|
||||
holeGroupId?: string
|
||||
holeGroupType?: string
|
||||
holeGroupRole?: string
|
||||
surfaceFaces?: string[]
|
||||
}
|
||||
|
||||
export interface ProcessedHardware {
|
||||
@ -216,7 +252,10 @@ export interface ProcessingResult {
|
||||
success: boolean
|
||||
version: string
|
||||
processingPath: 'direct' | 'fallback' | 'mixed'
|
||||
coordinateSystem?: string
|
||||
panels: ProcessedPanel[]
|
||||
holeGroups: CADHoleGroup[]
|
||||
mountPatterns: CADMountPattern[]
|
||||
hardware: ProcessedHardware[]
|
||||
statistics: {
|
||||
totalPanels: number
|
||||
@ -295,6 +334,7 @@ function mapPanelsDirectly(data: CADExportData): ProcessedPanel[] {
|
||||
holeGroupId: hole.holeGroupId,
|
||||
holeGroupType: hole.holeGroupType,
|
||||
holeGroupRole: hole.holeGroupRole,
|
||||
surfaceFaces: Array.isArray(hole.surfaceFaces) ? [...hole.surfaceFaces] : undefined,
|
||||
})),
|
||||
processingPath: 'direct' as const,
|
||||
}))
|
||||
@ -417,7 +457,10 @@ export function processCADExport(data: CADExportData): ProcessingResult {
|
||||
success: false,
|
||||
version,
|
||||
processingPath: 'direct',
|
||||
coordinateSystem: data.coordinateSystem,
|
||||
panels: [],
|
||||
holeGroups: [],
|
||||
mountPatterns: [],
|
||||
hardware: [],
|
||||
statistics: {
|
||||
totalPanels: 0,
|
||||
@ -451,7 +494,10 @@ export function processCADExport(data: CADExportData): ProcessingResult {
|
||||
success: true,
|
||||
version,
|
||||
processingPath,
|
||||
coordinateSystem: data.coordinateSystem,
|
||||
panels,
|
||||
holeGroups: data.holeGroups ?? [],
|
||||
mountPatterns: data.mountPatterns ?? [],
|
||||
hardware,
|
||||
statistics: {
|
||||
totalPanels: panels.length,
|
||||
|
||||
@ -2,6 +2,8 @@ import {
|
||||
applyAutomaticAssemblySnapPreview,
|
||||
attemptSnapNode,
|
||||
buildManualAssemblyWorkspace,
|
||||
deriveAssemblyActiveSnapCandidate,
|
||||
deriveAssemblyAutoSnapCandidate,
|
||||
deriveAssemblySaveValidation,
|
||||
instantiateAssemblyNode,
|
||||
moveAssemblyNodes,
|
||||
@ -9,6 +11,7 @@ import {
|
||||
syncManualAssemblyWorkspace,
|
||||
updateAssemblyNodeTransform,
|
||||
} from '../assembly-workspace'
|
||||
import type { OpenProductAssetVariant } from '../types'
|
||||
|
||||
describe('assembly-workspace', () => {
|
||||
const baseWorkspace = buildManualAssemblyWorkspace({
|
||||
@ -36,6 +39,169 @@ describe('assembly-workspace', () => {
|
||||
],
|
||||
})
|
||||
|
||||
const desktopVariant: OpenProductAssetVariant = {
|
||||
id: 'variant-desktop',
|
||||
assetFamilyId: 'family-desktop',
|
||||
code: 'BOARD-A-001',
|
||||
name: '桌面A',
|
||||
assetType: 'BOARD',
|
||||
status: 'AVAILABLE',
|
||||
modelUrl: '/board.glb',
|
||||
thumbnailUrl: null,
|
||||
dimensions: { length: 1200, width: 600, thickness: 25 },
|
||||
mountPoints: [],
|
||||
assemblyBasis: {
|
||||
anchor: 'desktop-center',
|
||||
holes: [
|
||||
{
|
||||
id: 'board-hole-1',
|
||||
panelId: 'board-panel',
|
||||
position: [0, 0, 0],
|
||||
normal: [0, -1, 0],
|
||||
diameter: 8,
|
||||
depth: 12,
|
||||
holeGroupId: 'board-hole-group',
|
||||
holeGroupType: 'pair',
|
||||
holeGroupRole: 'mount',
|
||||
surfaceFaces: ['top'],
|
||||
},
|
||||
],
|
||||
holeGroups: [
|
||||
{
|
||||
id: 'board-hole-group',
|
||||
panelId: 'board-panel',
|
||||
groupType: 'pair',
|
||||
groupRole: 'mount',
|
||||
mountFace: 'top',
|
||||
holeIds: ['board-hole-1'],
|
||||
surfaceFaces: ['top'],
|
||||
},
|
||||
],
|
||||
mountPatterns: [
|
||||
{
|
||||
id: 'board-pattern-1',
|
||||
panelId: 'board-panel',
|
||||
mountFace: 'top',
|
||||
patternType: 'single',
|
||||
holeIds: ['board-hole-1'],
|
||||
requiredHoleIds: ['board-hole-1'],
|
||||
optionalHoleIds: [],
|
||||
minRequiredHoles: 1,
|
||||
preferredHoleCount: 1,
|
||||
symmetry: false,
|
||||
confidence: 0.98,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const legVariant: OpenProductAssetVariant = {
|
||||
id: 'variant-leg',
|
||||
assetFamilyId: 'family-leg',
|
||||
code: 'LEG-A-001',
|
||||
name: '桌腿A',
|
||||
assetType: 'COMPONENT',
|
||||
status: 'AVAILABLE',
|
||||
modelUrl: '/leg.glb',
|
||||
thumbnailUrl: null,
|
||||
dimensions: { length: 80, width: 80, height: 700 },
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
assemblyBasis: {
|
||||
attachTo: 'desktop',
|
||||
holes: [
|
||||
{
|
||||
id: 'leg-hole-1',
|
||||
panelId: 'leg-panel',
|
||||
position: [0, 0, 0],
|
||||
normal: [0, 1, 0],
|
||||
diameter: 8,
|
||||
depth: 12,
|
||||
holeGroupId: 'leg-hole-group',
|
||||
holeGroupType: 'pair',
|
||||
holeGroupRole: 'mount',
|
||||
surfaceFaces: ['bottom'],
|
||||
},
|
||||
],
|
||||
holeGroups: [
|
||||
{
|
||||
id: 'leg-hole-group',
|
||||
panelId: 'leg-panel',
|
||||
groupType: 'pair',
|
||||
groupRole: 'mount',
|
||||
mountFace: 'bottom',
|
||||
holeIds: ['leg-hole-1'],
|
||||
surfaceFaces: ['bottom'],
|
||||
},
|
||||
],
|
||||
mountPatterns: [
|
||||
{
|
||||
id: 'leg-pattern-1',
|
||||
panelId: 'leg-panel',
|
||||
mountFace: 'bottom',
|
||||
patternType: 'single',
|
||||
holeIds: ['leg-hole-1'],
|
||||
requiredHoleIds: ['leg-hole-1'],
|
||||
optionalHoleIds: [],
|
||||
minRequiredHoles: 1,
|
||||
preferredHoleCount: 1,
|
||||
symmetry: false,
|
||||
confidence: 0.98,
|
||||
},
|
||||
],
|
||||
},
|
||||
compatibilityRules: { allowedRoles: ['desktop'] },
|
||||
}
|
||||
|
||||
const cadLegVariant: OpenProductAssetVariant = {
|
||||
...legVariant,
|
||||
id: 'variant-leg-cad',
|
||||
code: 'LEG-A-CAD-001',
|
||||
assemblyBasis: {
|
||||
source: 'CAD',
|
||||
attachTo: 'desktop',
|
||||
holes: [
|
||||
{
|
||||
id: 'leg-hole-cad-1',
|
||||
panelId: 'leg-panel',
|
||||
position: [0, 15, 0],
|
||||
normal: [0, 0, -1],
|
||||
diameter: 8,
|
||||
depth: 12,
|
||||
holeGroupId: 'leg-hole-group',
|
||||
holeGroupType: 'pair',
|
||||
holeGroupRole: 'mount',
|
||||
surfaceFaces: ['bottom'],
|
||||
},
|
||||
],
|
||||
holeGroups: [
|
||||
{
|
||||
id: 'leg-hole-group',
|
||||
panelId: 'leg-panel',
|
||||
groupType: 'pair',
|
||||
groupRole: 'mount',
|
||||
mountFace: 'bottom',
|
||||
holeIds: ['leg-hole-cad-1'],
|
||||
surfaceFaces: ['bottom'],
|
||||
},
|
||||
],
|
||||
mountPatterns: [
|
||||
{
|
||||
id: 'leg-pattern-cad-1',
|
||||
panelId: 'leg-panel',
|
||||
mountFace: 'bottom',
|
||||
patternType: 'single',
|
||||
holeIds: ['leg-hole-cad-1'],
|
||||
requiredHoleIds: ['leg-hole-cad-1'],
|
||||
optionalHoleIds: [],
|
||||
minRequiredHoles: 1,
|
||||
preferredHoleCount: 1,
|
||||
symmetry: false,
|
||||
confidence: 0.98,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
it('会先以资产族库存打开工作台,而不是直接生成候选节点', () => {
|
||||
expect(baseWorkspace.inventory).toHaveLength(2)
|
||||
expect(baseWorkspace.nodes).toHaveLength(0)
|
||||
@ -330,4 +496,78 @@ describe('assembly-workspace', () => {
|
||||
expect(synced.nodes).toHaveLength(1)
|
||||
expect(synced.nodes[0]?.assetFamilyId).toBe('family-desktop')
|
||||
})
|
||||
|
||||
it('mountPattern / holeGroup 会先生成预吸附绑定,再在松手后固化 snapStatus 与 snapBinding', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
variant: desktopVariant,
|
||||
})
|
||||
const withLeg = instantiateAssemblyNode(withDesktop, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: legVariant,
|
||||
})
|
||||
|
||||
const moved = updateAssemblyNodeTransform(withLeg, withLeg.nodes[1]!.nodeId, {
|
||||
position: [0, 720, 0],
|
||||
})
|
||||
const liveCandidate = deriveAssemblyAutoSnapCandidate(moved, moved.nodes[1]!.nodeId)
|
||||
|
||||
expect(liveCandidate).not.toBeNull()
|
||||
expect(liveCandidate?.sourceMountPatternId).toBe('leg-pattern-1')
|
||||
expect(liveCandidate?.targetMountPatternId).toBe('board-pattern-1')
|
||||
expect(liveCandidate?.sourceHoleId).toBe('leg-hole-1')
|
||||
expect(liveCandidate?.targetHoleId).toBe('board-hole-1')
|
||||
expect(liveCandidate?.sourceHoleDiameter).toBe(8)
|
||||
expect(liveCandidate?.targetHoleDepth).toBe(12)
|
||||
|
||||
const preview = applyAutomaticAssemblySnapPreview(moved, moved.nodes[1]!.nodeId, {
|
||||
fallbackZone: 'staging',
|
||||
})
|
||||
|
||||
expect(preview.nodes[1]?.zone).toBe('main')
|
||||
expect(preview.nodes[1]?.isSnapped).toBe(false)
|
||||
expect(preview.nodes[1]?.snapStatus).toBe('hole-preview')
|
||||
expect(preview.nodes[1]?.snapBinding).toMatchObject({
|
||||
targetNodeId: withDesktop.nodes[0]?.nodeId,
|
||||
sourceHoleId: 'leg-hole-1',
|
||||
targetHoleId: 'board-hole-1',
|
||||
sourceMountPatternId: 'leg-pattern-1',
|
||||
targetMountPatternId: 'board-pattern-1',
|
||||
})
|
||||
|
||||
const snapped = attemptSnapNode(preview, preview.nodes[1]!.nodeId)
|
||||
const activeCandidate = deriveAssemblyActiveSnapCandidate(snapped, snapped.nodes[1]!.nodeId)
|
||||
|
||||
expect(snapped.nodes[1]?.isSnapped).toBe(true)
|
||||
expect(snapped.nodes[1]?.snapStatus).toBe('hole-snap')
|
||||
expect(snapped.nodes[1]?.snapBinding).toMatchObject({
|
||||
targetNodeId: withDesktop.nodes[0]?.nodeId,
|
||||
sourceHoleId: 'leg-hole-1',
|
||||
targetHoleId: 'board-hole-1',
|
||||
})
|
||||
expect(activeCandidate).not.toBeNull()
|
||||
expect(activeCandidate?.targetNodeId).toBe(withDesktop.nodes[0]?.nodeId)
|
||||
})
|
||||
|
||||
it('CAD 坐标系孔位会先转成 Three.js 坐标,再参与孔位吸附候选', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
variant: desktopVariant,
|
||||
})
|
||||
const withCadLeg = instantiateAssemblyNode(withDesktop, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: cadLegVariant,
|
||||
})
|
||||
|
||||
const moved = updateAssemblyNodeTransform(withCadLeg, withCadLeg.nodes[1]!.nodeId, {
|
||||
position: [0, 720, -15],
|
||||
})
|
||||
const candidate = deriveAssemblyAutoSnapCandidate(moved, moved.nodes[1]!.nodeId)
|
||||
|
||||
expect(candidate).not.toBeNull()
|
||||
expect(candidate?.sourceHoleId).toBe('leg-hole-cad-1')
|
||||
expect(candidate?.targetHoleId).toBe('board-hole-1')
|
||||
expect(candidate?.sourceMountPatternId).toBe('leg-pattern-cad-1')
|
||||
expect(candidate?.targetMountPatternId).toBe('board-pattern-1')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import * as THREE from 'three'
|
||||
import type {
|
||||
OpenProductAssetFamily,
|
||||
OpenProductAssetType,
|
||||
@ -18,6 +19,7 @@ export type AssemblyNodeSource = 'desktop' | 'leg' | 'connector' | 'unknown'
|
||||
export type AssemblyRelationType = 'hole-group' | 'surface' | 'connector'
|
||||
|
||||
export type AssemblyRelationStatus = 'CONNECTED' | 'SUGGESTED' | 'PENDING'
|
||||
export type AssemblySnapStatus = 'hole-preview' | 'hole-snap' | 'manual-unverified'
|
||||
|
||||
export interface WeakSlotAssignment {
|
||||
role: string
|
||||
@ -57,12 +59,27 @@ export interface AssemblyWorkspaceNode extends PreSkuPreviewNode {
|
||||
targetPosition: [number, number, number] | null
|
||||
targetRotation: [number, number, number] | null
|
||||
isSnapped: boolean
|
||||
snapStatus?: AssemblySnapStatus | null
|
||||
subassemblyId: string | null
|
||||
dimensions?: Record<string, number> | null
|
||||
snapBinding?: AssemblySnapBinding | null
|
||||
mountPoints: OpenProductMountPoint[]
|
||||
assemblyBasis: Record<string, unknown> | null
|
||||
compatibilityRules?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface AssemblySnapBinding {
|
||||
targetNodeId: string
|
||||
sourceHoleId: string
|
||||
targetHoleId: string
|
||||
sourceHolePanelId: string | null
|
||||
targetHolePanelId: string | null
|
||||
sourceFace: string | null
|
||||
targetFace: string | null
|
||||
sourceMountPatternId: string | null
|
||||
targetMountPatternId: string | null
|
||||
}
|
||||
|
||||
export interface AssemblySubassembly {
|
||||
id: string
|
||||
name: string
|
||||
@ -91,6 +108,34 @@ export interface AssemblyValidationResult {
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface AssemblyAutoSnapCandidate {
|
||||
sourceNodeId: string
|
||||
targetNodeId: string
|
||||
sourceMountPatternId: string
|
||||
targetMountPatternId: string
|
||||
sourceHoleId: string
|
||||
targetHoleId: string
|
||||
sourceHolePanelId: string | null
|
||||
targetHolePanelId: string | null
|
||||
sourceHoleDiameter: number | null
|
||||
targetHoleDiameter: number | null
|
||||
sourceHoleDepth: number | null
|
||||
targetHoleDepth: number | null
|
||||
sourceFace: string | null
|
||||
targetFace: string | null
|
||||
matchedHoleCount: number
|
||||
requiredHoleCount: number
|
||||
previewPosition: [number, number, number]
|
||||
previewRotation: [number, number, number]
|
||||
sourceHoleWorldPosition: [number, number, number]
|
||||
targetHoleWorldPosition: [number, number, number]
|
||||
sourceHoleNormal: [number, number, number]
|
||||
targetHoleNormal: [number, number, number]
|
||||
distanceMm: number
|
||||
isValid: boolean
|
||||
rejectReason: string | null
|
||||
}
|
||||
|
||||
export interface AssemblyWorkspaceSnapshot {
|
||||
version: '2026-04-13' | '2026-04-09' | '2026-04-08'
|
||||
productCategoryId?: string | null
|
||||
@ -155,6 +200,9 @@ interface BuildSingleBoardDeskAssemblyWorkspaceInput {
|
||||
}
|
||||
|
||||
const SNAP_DISTANCE_THRESHOLD = 180
|
||||
const HOLE_SNAP_DISTANCE_THRESHOLD_MM = 120
|
||||
const HOLE_DIAMETER_TOLERANCE_MM = 2
|
||||
const HOLE_NORMAL_ALIGNMENT_DOT_MAX = -0.45
|
||||
|
||||
const DEFAULT_DESKTOP_POSITION: [number, number, number] = [0, 720, 0]
|
||||
|
||||
@ -172,6 +220,45 @@ const DEFAULT_CONNECTOR_TARGET_OFFSETS: Array<[number, number, number]> = [
|
||||
[220, -110, -220],
|
||||
]
|
||||
|
||||
interface AssemblyBasisHoleGroupRecord {
|
||||
id: string
|
||||
panelId: string | null
|
||||
mountFace: string | null
|
||||
surfaceFaces: string[]
|
||||
}
|
||||
|
||||
interface AssemblyBasisHoleRecord {
|
||||
id: string
|
||||
panelId: string | null
|
||||
position: [number, number, number]
|
||||
normal: [number, number, number]
|
||||
diameter: number | null
|
||||
depth: number | null
|
||||
holeType: string | null
|
||||
groupId: string | null
|
||||
surfaceFaces: string[]
|
||||
}
|
||||
|
||||
interface AssemblyBasisMountPatternRecord {
|
||||
id: string
|
||||
panelId: string | null
|
||||
mountFace: string | null
|
||||
holeIds: string[]
|
||||
minRequiredHoles: number
|
||||
}
|
||||
|
||||
interface AssemblyMatchedHolePair {
|
||||
sourceHole: AssemblyBasisHoleRecord
|
||||
targetHole: AssemblyBasisHoleRecord
|
||||
sourceHoleWorldPosition: [number, number, number]
|
||||
targetHoleWorldPosition: [number, number, number]
|
||||
sourceHoleNormal: [number, number, number]
|
||||
targetHoleNormal: [number, number, number]
|
||||
distanceMm: number
|
||||
sourceFace: string | null
|
||||
targetFace: string | null
|
||||
}
|
||||
|
||||
function cloneVector3(
|
||||
input: [number, number, number] | undefined | null,
|
||||
): [number, number, number] {
|
||||
@ -213,6 +300,570 @@ function readStringValue(record: Record<string, unknown> | null | undefined, key
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : null
|
||||
}
|
||||
|
||||
function readNumberValue(record: Record<string, unknown> | null | undefined, key: string) {
|
||||
const value = record?.[key]
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
function readRecordArray(record: Record<string, unknown> | null | undefined, key: string) {
|
||||
const value = record?.[key]
|
||||
return Array.isArray(value)
|
||||
? value.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
Boolean(item) && typeof item === 'object' && !Array.isArray(item),
|
||||
)
|
||||
: []
|
||||
}
|
||||
|
||||
function readVector3Tuple(value: unknown): [number, number, number] | null {
|
||||
if (!Array.isArray(value) || value.length < 3) {
|
||||
return null
|
||||
}
|
||||
|
||||
const x = typeof value[0] === 'number' && Number.isFinite(value[0]) ? value[0] : null
|
||||
const y = typeof value[1] === 'number' && Number.isFinite(value[1]) ? value[1] : null
|
||||
const z = typeof value[2] === 'number' && Number.isFinite(value[2]) ? value[2] : null
|
||||
|
||||
if (x === null || y === null || z === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return [x, y, z]
|
||||
}
|
||||
|
||||
function normalizeVector3Tuple(
|
||||
value: [number, number, number],
|
||||
fallback: [number, number, number],
|
||||
): [number, number, number] {
|
||||
const vector = new THREE.Vector3(value[0], value[1], value[2])
|
||||
if (vector.lengthSq() <= 1e-6) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
vector.normalize()
|
||||
return [vector.x, vector.y, vector.z]
|
||||
}
|
||||
|
||||
function transformCadVector3Tuple(value: [number, number, number]): [number, number, number] {
|
||||
return [value[0], -value[2], value[1]]
|
||||
}
|
||||
|
||||
function shouldApplyCadHoleTransform(node: AssemblyWorkspaceNode) {
|
||||
const sourceHints = [
|
||||
readStringValue(node.assemblyBasis, 'source'),
|
||||
readStringValue(node.assemblyBasis, 'sourceType'),
|
||||
readStringValue(node.assemblyBasis, 'coordinateSystem'),
|
||||
readStringValue(node.assemblyBasis, 'generatedFrom'),
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => value.toLowerCase())
|
||||
|
||||
return sourceHints.some(
|
||||
(value) =>
|
||||
value.includes('cad') ||
|
||||
value.includes('z-up') ||
|
||||
value.includes('zup') ||
|
||||
value.includes('solidworks') ||
|
||||
value.includes('inventor'),
|
||||
)
|
||||
}
|
||||
|
||||
function getOppositeSurfaceFace(face: string) {
|
||||
switch (face) {
|
||||
case 'left':
|
||||
return 'right'
|
||||
case 'right':
|
||||
return 'left'
|
||||
case 'front':
|
||||
return 'back'
|
||||
case 'back':
|
||||
return 'front'
|
||||
case 'top':
|
||||
return 'bottom'
|
||||
case 'bottom':
|
||||
return 'top'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSurfaceFaces(value: unknown): string[] {
|
||||
return readStringList(value).map((item) => item.trim().toLowerCase())
|
||||
}
|
||||
|
||||
function resolveCompatibleFacePair(sourceFaces: string[], targetFaces: string[]) {
|
||||
if (sourceFaces.length === 0 || targetFaces.length === 0) {
|
||||
return {
|
||||
sourceFace: sourceFaces[0] ?? null,
|
||||
targetFace: targetFaces[0] ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
const targetSet = new Set(targetFaces)
|
||||
for (const sourceFace of sourceFaces) {
|
||||
const oppositeFace = getOppositeSurfaceFace(sourceFace)
|
||||
if (oppositeFace && targetSet.has(oppositeFace)) {
|
||||
return {
|
||||
sourceFace,
|
||||
targetFace: oppositeFace,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getNodeQuaternion(rotation: [number, number, number]) {
|
||||
return new THREE.Quaternion().setFromEuler(new THREE.Euler(rotation[0], rotation[1], rotation[2], 'XYZ'))
|
||||
}
|
||||
|
||||
function getScaledVector(vector: [number, number, number], scale: [number, number, number]) {
|
||||
return new THREE.Vector3(vector[0] * scale[0], vector[1] * scale[1], vector[2] * scale[2])
|
||||
}
|
||||
|
||||
function tupleFromVector(vector: THREE.Vector3): [number, number, number] {
|
||||
return [vector.x, vector.y, vector.z]
|
||||
}
|
||||
|
||||
function getNodeWorldPoint(
|
||||
node: Pick<AssemblyWorkspaceNode, 'position' | 'rotation' | 'scale'>,
|
||||
localPosition: [number, number, number],
|
||||
): THREE.Vector3 {
|
||||
const quaternion = getNodeQuaternion(node.rotation)
|
||||
return getScaledVector(localPosition, node.scale)
|
||||
.applyQuaternion(quaternion)
|
||||
.add(new THREE.Vector3(node.position[0], node.position[1], node.position[2]))
|
||||
}
|
||||
|
||||
function getNodeWorldDirection(
|
||||
node: Pick<AssemblyWorkspaceNode, 'rotation'>,
|
||||
localDirection: [number, number, number],
|
||||
): THREE.Vector3 {
|
||||
const quaternion = getNodeQuaternion(node.rotation)
|
||||
return new THREE.Vector3(localDirection[0], localDirection[1], localDirection[2])
|
||||
.normalize()
|
||||
.applyQuaternion(quaternion)
|
||||
.normalize()
|
||||
}
|
||||
|
||||
function buildAssemblyHoleGroups(node: AssemblyWorkspaceNode) {
|
||||
return new Map(
|
||||
readRecordArray(node.assemblyBasis, 'holeGroups')
|
||||
.map<AssemblyBasisHoleGroupRecord | null>((record) => {
|
||||
const id = readStringValue(record, 'id')
|
||||
if (!id) return null
|
||||
|
||||
return {
|
||||
id,
|
||||
panelId: readStringValue(record, 'panelId'),
|
||||
mountFace: readStringValue(record, 'mountFace'),
|
||||
surfaceFaces: normalizeSurfaceFaces(record.surfaceFaces),
|
||||
}
|
||||
})
|
||||
.filter((item): item is AssemblyBasisHoleGroupRecord => item !== null)
|
||||
.map((item) => [item.id, item]),
|
||||
)
|
||||
}
|
||||
|
||||
function buildAssemblyMountPatterns(node: AssemblyWorkspaceNode): AssemblyBasisMountPatternRecord[] {
|
||||
return readRecordArray(node.assemblyBasis, 'mountPatterns')
|
||||
.map<AssemblyBasisMountPatternRecord | null>((record) => {
|
||||
const id = readStringValue(record, 'id')
|
||||
const holeIds = readStringList(record.holeIds)
|
||||
if (!id || holeIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const requestedMinRequired =
|
||||
readNumberValue(record, 'minRequiredHoles') ??
|
||||
readStringList(record.requiredHoleIds).length ??
|
||||
holeIds.length
|
||||
|
||||
return {
|
||||
id,
|
||||
panelId: readStringValue(record, 'panelId'),
|
||||
mountFace: readStringValue(record, 'mountFace'),
|
||||
holeIds,
|
||||
minRequiredHoles: Math.max(1, Math.min(holeIds.length, Math.round(requestedMinRequired))),
|
||||
}
|
||||
})
|
||||
.filter((item): item is AssemblyBasisMountPatternRecord => item !== null)
|
||||
}
|
||||
|
||||
function getNodeAssemblyMountPatterns(node: AssemblyWorkspaceNode) {
|
||||
return buildAssemblyMountPatterns(node)
|
||||
}
|
||||
|
||||
function getNodeAssemblyHoles(node: AssemblyWorkspaceNode): AssemblyBasisHoleRecord[] {
|
||||
const groupById = buildAssemblyHoleGroups(node)
|
||||
const applyCadTransform = shouldApplyCadHoleTransform(node)
|
||||
|
||||
return readRecordArray(node.assemblyBasis, 'holes')
|
||||
.map<AssemblyBasisHoleRecord | null>((record) => {
|
||||
const id = readStringValue(record, 'id')
|
||||
const rawPosition = readVector3Tuple(record.position)
|
||||
const rawNormal = readVector3Tuple(record.normal)
|
||||
if (!id || !rawPosition || !rawNormal) {
|
||||
return null
|
||||
}
|
||||
|
||||
const groupId =
|
||||
readStringValue(record, 'groupId') ??
|
||||
readStringValue(record, 'holeGroupId')
|
||||
const group = groupId ? groupById.get(groupId) ?? null : null
|
||||
const position = applyCadTransform ? transformCadVector3Tuple(rawPosition) : rawPosition
|
||||
const normal = applyCadTransform ? transformCadVector3Tuple(rawNormal) : rawNormal
|
||||
|
||||
return {
|
||||
id,
|
||||
panelId: readStringValue(record, 'panelId') ?? group?.panelId ?? null,
|
||||
position,
|
||||
normal: normalizeVector3Tuple(normal, [0, 1, 0]),
|
||||
diameter: readNumberValue(record, 'diameter'),
|
||||
depth: readNumberValue(record, 'depth'),
|
||||
holeType: readStringValue(record, 'holeType') ?? readStringValue(record, 'type'),
|
||||
groupId,
|
||||
surfaceFaces: normalizeSurfaceFaces(record.surfaceFaces ?? group?.surfaceFaces),
|
||||
}
|
||||
})
|
||||
.filter((item): item is AssemblyBasisHoleRecord => item !== null)
|
||||
}
|
||||
|
||||
function canUseHoleAutoSnap(node: AssemblyWorkspaceNode) {
|
||||
return getNodeAssemblyMountPatterns(node).length > 0 && getNodeAssemblyHoles(node).length > 0
|
||||
}
|
||||
|
||||
function areHoleDiametersCompatible(
|
||||
sourceHole: AssemblyBasisHoleRecord,
|
||||
targetHole: AssemblyBasisHoleRecord,
|
||||
) {
|
||||
if (sourceHole.diameter === null || targetHole.diameter === null) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Math.abs(sourceHole.diameter - targetHole.diameter) <= HOLE_DIAMETER_TOLERANCE_MM
|
||||
}
|
||||
|
||||
function resolveHoleFacePair(
|
||||
sourceHole: AssemblyBasisHoleRecord,
|
||||
targetHole: AssemblyBasisHoleRecord,
|
||||
sourcePattern: AssemblyBasisMountPatternRecord,
|
||||
targetPattern: AssemblyBasisMountPatternRecord,
|
||||
) {
|
||||
const sourceFaces =
|
||||
sourceHole.surfaceFaces.length > 0
|
||||
? sourceHole.surfaceFaces
|
||||
: sourcePattern.mountFace
|
||||
? [sourcePattern.mountFace]
|
||||
: []
|
||||
const targetFaces =
|
||||
targetHole.surfaceFaces.length > 0
|
||||
? targetHole.surfaceFaces
|
||||
: targetPattern.mountFace
|
||||
? [targetPattern.mountFace]
|
||||
: []
|
||||
|
||||
return resolveCompatibleFacePair(sourceFaces, targetFaces)
|
||||
}
|
||||
|
||||
function buildSnapBindingFromCandidate(candidate: AssemblyAutoSnapCandidate): AssemblySnapBinding {
|
||||
return {
|
||||
targetNodeId: candidate.targetNodeId,
|
||||
sourceHoleId: candidate.sourceHoleId,
|
||||
targetHoleId: candidate.targetHoleId,
|
||||
sourceHolePanelId: candidate.sourceHolePanelId,
|
||||
targetHolePanelId: candidate.targetHolePanelId,
|
||||
sourceFace: candidate.sourceFace,
|
||||
targetFace: candidate.targetFace,
|
||||
sourceMountPatternId: candidate.sourceMountPatternId,
|
||||
targetMountPatternId: candidate.targetMountPatternId,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSnapBindingPattern(
|
||||
node: AssemblyWorkspaceNode,
|
||||
holeId: string,
|
||||
preferredPatternId: string | null,
|
||||
) {
|
||||
const patterns = getNodeAssemblyMountPatterns(node)
|
||||
if (preferredPatternId) {
|
||||
const preferredPattern = patterns.find((pattern) => pattern.id === preferredPatternId) ?? null
|
||||
if (preferredPattern) {
|
||||
return preferredPattern
|
||||
}
|
||||
}
|
||||
|
||||
return patterns.find((pattern) => pattern.holeIds.includes(holeId)) ?? null
|
||||
}
|
||||
|
||||
function buildSnapBindingDisplayCandidate(
|
||||
snapshot: AssemblyWorkspaceSnapshot,
|
||||
sourceNode: AssemblyWorkspaceNode,
|
||||
): AssemblyAutoSnapCandidate | null {
|
||||
const binding = sourceNode.snapBinding
|
||||
if (!binding?.targetNodeId || !binding.sourceHoleId || !binding.targetHoleId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const targetNode = snapshot.nodes.find((node) => node.nodeId === binding.targetNodeId) ?? null
|
||||
if (!targetNode) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sourceHole = getNodeAssemblyHoles(sourceNode).find((hole) => hole.id === binding.sourceHoleId) ?? null
|
||||
const targetHole = getNodeAssemblyHoles(targetNode).find((hole) => hole.id === binding.targetHoleId) ?? null
|
||||
if (!sourceHole || !targetHole) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sourcePattern = resolveSnapBindingPattern(
|
||||
sourceNode,
|
||||
sourceHole.id,
|
||||
binding.sourceMountPatternId ?? null,
|
||||
)
|
||||
const targetPattern = resolveSnapBindingPattern(
|
||||
targetNode,
|
||||
targetHole.id,
|
||||
binding.targetMountPatternId ?? null,
|
||||
)
|
||||
const sourceHoleWorldPosition = getNodeWorldPoint(sourceNode, sourceHole.position)
|
||||
const targetHoleWorldPosition = getNodeWorldPoint(targetNode, targetHole.position)
|
||||
|
||||
return {
|
||||
sourceNodeId: sourceNode.nodeId,
|
||||
targetNodeId: targetNode.nodeId,
|
||||
sourceMountPatternId: sourcePattern?.id ?? binding.sourceMountPatternId ?? sourceHole.id,
|
||||
targetMountPatternId: targetPattern?.id ?? binding.targetMountPatternId ?? targetHole.id,
|
||||
sourceHoleId: sourceHole.id,
|
||||
targetHoleId: targetHole.id,
|
||||
sourceHolePanelId: binding.sourceHolePanelId ?? sourceHole.panelId,
|
||||
targetHolePanelId: binding.targetHolePanelId ?? targetHole.panelId,
|
||||
sourceHoleDiameter: sourceHole.diameter,
|
||||
targetHoleDiameter: targetHole.diameter,
|
||||
sourceHoleDepth: sourceHole.depth,
|
||||
targetHoleDepth: targetHole.depth,
|
||||
sourceFace: binding.sourceFace ?? null,
|
||||
targetFace: binding.targetFace ?? null,
|
||||
matchedHoleCount: 1,
|
||||
requiredHoleCount: Math.max(
|
||||
sourcePattern?.minRequiredHoles ?? 1,
|
||||
targetPattern?.minRequiredHoles ?? 1,
|
||||
),
|
||||
previewPosition: cloneVector3(sourceNode.position),
|
||||
previewRotation: cloneVector3(sourceNode.rotation),
|
||||
sourceHoleWorldPosition: tupleFromVector(sourceHoleWorldPosition),
|
||||
targetHoleWorldPosition: tupleFromVector(targetHoleWorldPosition),
|
||||
sourceHoleNormal: tupleFromVector(getNodeWorldDirection(sourceNode, sourceHole.normal)),
|
||||
targetHoleNormal: tupleFromVector(getNodeWorldDirection(targetNode, targetHole.normal)),
|
||||
distanceMm: sourceHoleWorldPosition.distanceTo(targetHoleWorldPosition),
|
||||
isValid: true,
|
||||
rejectReason: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveAssemblyActiveSnapCandidate(
|
||||
snapshot: AssemblyWorkspaceSnapshot,
|
||||
nodeId: string,
|
||||
): AssemblyAutoSnapCandidate | null {
|
||||
const liveCandidate = deriveAssemblyAutoSnapCandidate(snapshot, nodeId)
|
||||
if (liveCandidate) {
|
||||
return liveCandidate
|
||||
}
|
||||
|
||||
const sourceNode = snapshot.nodes.find((node) => node.nodeId === nodeId) ?? null
|
||||
if (!sourceNode?.snapBinding) {
|
||||
return null
|
||||
}
|
||||
|
||||
return buildSnapBindingDisplayCandidate(snapshot, sourceNode)
|
||||
}
|
||||
|
||||
export function deriveAssemblyAutoSnapCandidate(
|
||||
snapshot: AssemblyWorkspaceSnapshot,
|
||||
nodeId: string,
|
||||
): AssemblyAutoSnapCandidate | null {
|
||||
const sourceNode = snapshot.nodes.find((node) => node.nodeId === nodeId) ?? null
|
||||
if (!sourceNode || sourceNode.source === 'desktop' || !canUseHoleAutoSnap(sourceNode)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sourcePatterns = getNodeAssemblyMountPatterns(sourceNode)
|
||||
const sourceHoleMap = new Map(getNodeAssemblyHoles(sourceNode).map((hole) => [hole.id, hole]))
|
||||
let bestCandidate: AssemblyAutoSnapCandidate | null = null
|
||||
|
||||
snapshot.nodes.forEach((targetNode) => {
|
||||
if (
|
||||
targetNode.nodeId === sourceNode.nodeId ||
|
||||
targetNode.zone !== 'main' ||
|
||||
!canUseHoleAutoSnap(targetNode) ||
|
||||
!matchesPrimaryAnchorRole(sourceNode, targetNode)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetPatterns = getNodeAssemblyMountPatterns(targetNode)
|
||||
const targetHoleMap = new Map(getNodeAssemblyHoles(targetNode).map((hole) => [hole.id, hole]))
|
||||
|
||||
sourcePatterns.forEach((sourcePattern) => {
|
||||
const sourcePatternHoles = sourcePattern.holeIds
|
||||
.map((holeId) => sourceHoleMap.get(holeId) ?? null)
|
||||
.filter((hole): hole is AssemblyBasisHoleRecord => hole !== null)
|
||||
if (sourcePatternHoles.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
targetPatterns.forEach((targetPattern) => {
|
||||
const targetPatternHoles = targetPattern.holeIds
|
||||
.map((holeId) => targetHoleMap.get(holeId) ?? null)
|
||||
.filter((hole): hole is AssemblyBasisHoleRecord => hole !== null)
|
||||
if (targetPatternHoles.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
sourcePatternHoles.forEach((anchorSourceHole) => {
|
||||
targetPatternHoles.forEach((anchorTargetHole) => {
|
||||
if (!areHoleDiametersCompatible(anchorSourceHole, anchorTargetHole)) {
|
||||
return
|
||||
}
|
||||
|
||||
const anchorFacePair = resolveHoleFacePair(
|
||||
anchorSourceHole,
|
||||
anchorTargetHole,
|
||||
sourcePattern,
|
||||
targetPattern,
|
||||
)
|
||||
if (!anchorFacePair) {
|
||||
return
|
||||
}
|
||||
|
||||
const anchorSourceWorld = getNodeWorldPoint(sourceNode, anchorSourceHole.position)
|
||||
const anchorTargetWorld = getNodeWorldPoint(targetNode, anchorTargetHole.position)
|
||||
const anchorSourceNormal = getNodeWorldDirection(sourceNode, anchorSourceHole.normal)
|
||||
const anchorTargetNormal = getNodeWorldDirection(targetNode, anchorTargetHole.normal)
|
||||
const anchorNormalDot = anchorSourceNormal.dot(anchorTargetNormal)
|
||||
if (anchorNormalDot > HOLE_NORMAL_ALIGNMENT_DOT_MAX) {
|
||||
return
|
||||
}
|
||||
|
||||
const translation = anchorTargetWorld.clone().sub(anchorSourceWorld)
|
||||
const matchedPairs: AssemblyMatchedHolePair[] = []
|
||||
const usedSourceHoleIds = new Set<string>()
|
||||
const usedTargetHoleIds = new Set<string>()
|
||||
|
||||
sourcePatternHoles.forEach((sourceHole) => {
|
||||
let bestPair: AssemblyMatchedHolePair | null = null
|
||||
|
||||
targetPatternHoles.forEach((targetHole) => {
|
||||
if (usedTargetHoleIds.has(targetHole.id) || !areHoleDiametersCompatible(sourceHole, targetHole)) {
|
||||
return
|
||||
}
|
||||
|
||||
const facePair = resolveHoleFacePair(sourceHole, targetHole, sourcePattern, targetPattern)
|
||||
if (!facePair) {
|
||||
return
|
||||
}
|
||||
|
||||
const translatedSourceWorld = getNodeWorldPoint(sourceNode, sourceHole.position).add(translation)
|
||||
const targetHoleWorld = getNodeWorldPoint(targetNode, targetHole.position)
|
||||
const sourceHoleNormal = getNodeWorldDirection(sourceNode, sourceHole.normal)
|
||||
const targetHoleNormal = getNodeWorldDirection(targetNode, targetHole.normal)
|
||||
const normalDot = sourceHoleNormal.dot(targetHoleNormal)
|
||||
if (normalDot > HOLE_NORMAL_ALIGNMENT_DOT_MAX) {
|
||||
return
|
||||
}
|
||||
|
||||
const distanceMm = translatedSourceWorld.distanceTo(targetHoleWorld)
|
||||
if (distanceMm > HOLE_SNAP_DISTANCE_THRESHOLD_MM) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidatePair: AssemblyMatchedHolePair = {
|
||||
sourceHole,
|
||||
targetHole,
|
||||
sourceHoleWorldPosition: tupleFromVector(translatedSourceWorld),
|
||||
targetHoleWorldPosition: tupleFromVector(targetHoleWorld),
|
||||
sourceHoleNormal: tupleFromVector(sourceHoleNormal),
|
||||
targetHoleNormal: tupleFromVector(targetHoleNormal),
|
||||
distanceMm,
|
||||
sourceFace: facePair.sourceFace,
|
||||
targetFace: facePair.targetFace,
|
||||
}
|
||||
|
||||
if (!bestPair || candidatePair.distanceMm < bestPair.distanceMm) {
|
||||
bestPair = candidatePair
|
||||
}
|
||||
})
|
||||
|
||||
const resolvedBestPair = bestPair as AssemblyMatchedHolePair | null
|
||||
if (resolvedBestPair) {
|
||||
usedSourceHoleIds.add(resolvedBestPair.sourceHole.id)
|
||||
usedTargetHoleIds.add(resolvedBestPair.targetHole.id)
|
||||
matchedPairs.push(resolvedBestPair)
|
||||
}
|
||||
})
|
||||
|
||||
const requiredHoleCount = Math.max(
|
||||
1,
|
||||
sourcePattern.minRequiredHoles,
|
||||
targetPattern.minRequiredHoles,
|
||||
)
|
||||
if (matchedPairs.length < requiredHoleCount) {
|
||||
return
|
||||
}
|
||||
|
||||
const averageDistance =
|
||||
matchedPairs.reduce((sum, pair) => sum + pair.distanceMm, 0) / matchedPairs.length
|
||||
const previewPosition = tupleFromVector(
|
||||
new THREE.Vector3(sourceNode.position[0], sourceNode.position[1], sourceNode.position[2]).add(
|
||||
translation,
|
||||
),
|
||||
)
|
||||
const firstPair = matchedPairs[0]!
|
||||
const candidate: AssemblyAutoSnapCandidate = {
|
||||
sourceNodeId: sourceNode.nodeId,
|
||||
targetNodeId: targetNode.nodeId,
|
||||
sourceMountPatternId: sourcePattern.id,
|
||||
targetMountPatternId: targetPattern.id,
|
||||
sourceHoleId: firstPair.sourceHole.id,
|
||||
targetHoleId: firstPair.targetHole.id,
|
||||
sourceHolePanelId: firstPair.sourceHole.panelId,
|
||||
targetHolePanelId: firstPair.targetHole.panelId,
|
||||
sourceHoleDiameter: firstPair.sourceHole.diameter,
|
||||
targetHoleDiameter: firstPair.targetHole.diameter,
|
||||
sourceHoleDepth: firstPair.sourceHole.depth,
|
||||
targetHoleDepth: firstPair.targetHole.depth,
|
||||
sourceFace: firstPair.sourceFace,
|
||||
targetFace: firstPair.targetFace,
|
||||
matchedHoleCount: matchedPairs.length,
|
||||
requiredHoleCount,
|
||||
previewPosition,
|
||||
previewRotation: cloneVector3(sourceNode.rotation),
|
||||
sourceHoleWorldPosition: firstPair.sourceHoleWorldPosition,
|
||||
targetHoleWorldPosition: firstPair.targetHoleWorldPosition,
|
||||
sourceHoleNormal: firstPair.sourceHoleNormal,
|
||||
targetHoleNormal: firstPair.targetHoleNormal,
|
||||
distanceMm: averageDistance,
|
||||
isValid: true,
|
||||
rejectReason: null,
|
||||
}
|
||||
|
||||
if (
|
||||
!bestCandidate ||
|
||||
candidate.matchedHoleCount > bestCandidate.matchedHoleCount ||
|
||||
(candidate.matchedHoleCount === bestCandidate.matchedHoleCount &&
|
||||
candidate.distanceMm < bestCandidate.distanceMm)
|
||||
) {
|
||||
bestCandidate = candidate
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return bestCandidate
|
||||
}
|
||||
|
||||
function buildInventoryId(source: Exclude<AssemblyNodeSource, 'unknown'>, familyId: string, index: number) {
|
||||
return `${source}:${familyId}:${index + 1}`
|
||||
}
|
||||
@ -693,7 +1344,10 @@ export function buildSingleBoardDeskAssemblyWorkspace(
|
||||
targetPosition: source === 'desktop' ? cloneVector3(node.position) : null,
|
||||
targetRotation: cloneVector3(node.rotation),
|
||||
isSnapped: source === 'desktop',
|
||||
snapStatus: source === 'desktop' ? 'hole-snap' : null,
|
||||
subassemblyId: null,
|
||||
dimensions: null,
|
||||
snapBinding: null,
|
||||
mountPoints: slotSelection?.variant.mountPoints ?? [],
|
||||
assemblyBasis: slotSelection?.variant.assemblyBasis ?? null,
|
||||
compatibilityRules: null,
|
||||
@ -843,7 +1497,7 @@ export function instantiateAssemblyNode(
|
||||
: buildStagingPosition(snapshot.nodes.length, inventoryItem.source)
|
||||
const baseRole = isDesktop ? 'desktop' : inventoryItem.source
|
||||
|
||||
const nextNodes = [
|
||||
const nextNodes: AssemblyWorkspaceNode[] = [
|
||||
...snapshot.nodes,
|
||||
{
|
||||
nodeId: buildNodeId(input.inventoryId, input.variant.id, instanceIndex, snapshot.nodes.length),
|
||||
@ -872,7 +1526,10 @@ export function instantiateAssemblyNode(
|
||||
targetPosition: isDesktop ? cloneVector3(DEFAULT_DESKTOP_POSITION) : null,
|
||||
targetRotation: [0, 0, 0] as [number, number, number],
|
||||
isSnapped: isDesktop && nodeZone === 'main',
|
||||
snapStatus: (isDesktop && nodeZone === 'main' ? 'hole-snap' : null) as AssemblySnapStatus | null,
|
||||
subassemblyId: null,
|
||||
dimensions: input.variant.dimensions ?? null,
|
||||
snapBinding: null,
|
||||
mountPoints: input.variant.mountPoints ?? [],
|
||||
assemblyBasis: input.variant.assemblyBasis ?? null,
|
||||
compatibilityRules: input.variant.compatibilityRules ?? null,
|
||||
@ -909,6 +1566,8 @@ export function moveAssemblyNodes(
|
||||
zone,
|
||||
subassemblyId: zone === 'subassembly' ? node.subassemblyId : null,
|
||||
isSnapped: zone === 'main' ? (isDesktop ? true : false) : false,
|
||||
snapStatus: zone === 'main' ? (isDesktop ? 'hole-snap' : 'manual-unverified') : null,
|
||||
snapBinding: zone === 'main' && isDesktop ? node.snapBinding ?? null : null,
|
||||
roleConfirmed: zone === 'main' && isDesktop ? 'desktop' : node.roleConfirmed,
|
||||
}
|
||||
})
|
||||
@ -931,6 +1590,13 @@ export function updateAssemblyNodeTransform(
|
||||
position: transform.position ? cloneVector3(transform.position) : cloneVector3(node.position),
|
||||
rotation: transform.rotation ? cloneVector3(transform.rotation) : cloneVector3(node.rotation),
|
||||
isSnapped: node.zone === 'main' && node.source !== 'desktop' ? false : node.isSnapped,
|
||||
snapStatus:
|
||||
node.source === 'desktop'
|
||||
? 'hole-snap'
|
||||
: node.zone === 'main'
|
||||
? 'manual-unverified'
|
||||
: null,
|
||||
snapBinding: node.source === 'desktop' ? node.snapBinding ?? null : null,
|
||||
}
|
||||
: node,
|
||||
)
|
||||
@ -974,6 +1640,7 @@ export function snapNodesIntoMainAssembly(
|
||||
position: cloneVector3(node.targetPosition),
|
||||
rotation: node.targetRotation ? cloneVector3(node.targetRotation) : cloneVector3(node.rotation),
|
||||
isSnapped: true,
|
||||
snapStatus: 'hole-snap',
|
||||
roleConfirmed: node.roleConfirmed ?? node.roleRecommendation,
|
||||
}
|
||||
: node,
|
||||
@ -986,13 +1653,48 @@ export function attemptSnapNode(
|
||||
snapshot: AssemblyWorkspaceSnapshot,
|
||||
nodeId: string,
|
||||
): AssemblyWorkspaceSnapshot {
|
||||
const targetNode = snapshot.nodes.find((node) => node.nodeId === nodeId)
|
||||
if (!targetNode || !isNodeCloseEnoughToSnap(targetNode)) {
|
||||
const currentNode = snapshot.nodes.find((node) => node.nodeId === nodeId) ?? null
|
||||
if (currentNode?.zone === 'main' && currentNode.snapBinding && !currentNode.isSnapped) {
|
||||
const nextNodes = snapshot.nodes.map<AssemblyWorkspaceNode>((node) =>
|
||||
node.nodeId === nodeId
|
||||
? {
|
||||
...node,
|
||||
isSnapped: true,
|
||||
snapStatus: 'hole-snap',
|
||||
roleConfirmed: node.roleConfirmed ?? node.roleRecommendation,
|
||||
}
|
||||
: node,
|
||||
)
|
||||
|
||||
return rebuildWorkspaceSnapshot(snapshot, nextNodes)
|
||||
}
|
||||
|
||||
const candidate = deriveAssemblyAutoSnapCandidate(snapshot, nodeId)
|
||||
if (candidate?.isValid) {
|
||||
const nextNodes = snapshot.nodes.map<AssemblyWorkspaceNode>((node) =>
|
||||
node.nodeId === nodeId
|
||||
? {
|
||||
...node,
|
||||
zone: 'main',
|
||||
position: cloneVector3(candidate.previewPosition),
|
||||
rotation: cloneVector3(candidate.previewRotation),
|
||||
isSnapped: true,
|
||||
snapStatus: 'hole-snap',
|
||||
snapBinding: buildSnapBindingFromCandidate(candidate),
|
||||
roleConfirmed: node.roleConfirmed ?? node.roleRecommendation,
|
||||
}
|
||||
: node,
|
||||
)
|
||||
|
||||
return rebuildWorkspaceSnapshot(snapshot, nextNodes)
|
||||
}
|
||||
|
||||
if (!currentNode || !isNodeCloseEnoughToSnap(currentNode)) {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const readyForSnap =
|
||||
targetNode.zone === 'main'
|
||||
currentNode.zone === 'main'
|
||||
? snapshot
|
||||
: moveAssemblyNodes(snapshot, [nodeId], 'main')
|
||||
|
||||
@ -1012,6 +1714,26 @@ export function applyAutomaticAssemblySnapPreview(
|
||||
}
|
||||
|
||||
const fallbackZone = options.fallbackZone ?? targetNode.zone
|
||||
const candidate = deriveAssemblyAutoSnapCandidate(snapshot, nodeId)
|
||||
|
||||
if (candidate?.isValid) {
|
||||
const nextNodes = snapshot.nodes.map<AssemblyWorkspaceNode>((node) =>
|
||||
node.nodeId === nodeId
|
||||
? {
|
||||
...node,
|
||||
zone: 'main',
|
||||
position: cloneVector3(candidate.previewPosition),
|
||||
rotation: cloneVector3(candidate.previewRotation),
|
||||
isSnapped: false,
|
||||
snapStatus: 'hole-preview',
|
||||
snapBinding: buildSnapBindingFromCandidate(candidate),
|
||||
}
|
||||
: node,
|
||||
)
|
||||
|
||||
return rebuildWorkspaceSnapshot(snapshot, nextNodes)
|
||||
}
|
||||
|
||||
const primaryAnchorNode = resolvePrimaryAnchorNode(snapshot.nodes)
|
||||
const shouldSnap =
|
||||
canAutoSnapNodeToPrimaryAnchor(targetNode, primaryAnchorNode) &&
|
||||
@ -1035,6 +1757,8 @@ export function applyAutomaticAssemblySnapPreview(
|
||||
...node,
|
||||
zone: fallbackZone,
|
||||
isSnapped: false,
|
||||
snapStatus: fallbackZone === 'main' ? 'manual-unverified' : null,
|
||||
snapBinding: null,
|
||||
}
|
||||
: node,
|
||||
)
|
||||
|
||||
@ -12,6 +12,17 @@ export function getAssistedAssemblyVariantMaterialConfig(
|
||||
return variant?.materialConfig ?? null
|
||||
}
|
||||
|
||||
export function resolveAssistedAssemblyNodeMaterialConfig(options: {
|
||||
variant?: AssistedAssemblyVariant | null
|
||||
nodeMaterialConfig?: MaterialConfig | null
|
||||
}): MaterialConfig | null {
|
||||
return (
|
||||
getAssistedAssemblyVariantMaterialConfig(options.variant) ??
|
||||
options.nodeMaterialConfig ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
export function getAssistedAssemblyVariantThumbnailUrl(
|
||||
variant: AssistedAssemblyVariant | null | undefined,
|
||||
): string | null {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { MaterialConfig } from '@/lib/3d/material-manager'
|
||||
import type { AssetVariantUvConfig } from '@/lib/asset-variant-render-config'
|
||||
|
||||
export type OpenProductAssetType = 'BOARD' | 'COMPONENT' | 'HARDWARE' | 'PACKAGING'
|
||||
|
||||
@ -38,6 +39,7 @@ export interface OpenProductAssetVariant {
|
||||
mountPoints: OpenProductMountPoint[]
|
||||
substrateId?: string | null
|
||||
finishId?: string | null
|
||||
uvConfig?: AssetVariantUvConfig | null
|
||||
weightKg?: number | null
|
||||
costPrice?: number | null
|
||||
assemblyBasis?: Record<string, unknown> | null
|
||||
|
||||
@ -39,6 +39,7 @@ export interface PersistedSkuCadHole {
|
||||
holeGroupId?: string
|
||||
holeGroupType?: string
|
||||
holeGroupRole?: string
|
||||
surfaceFaces?: string[]
|
||||
suggestedFasteners?: CADSuggestedFastener[]
|
||||
}
|
||||
|
||||
@ -49,8 +50,28 @@ export interface PersistedSkuCadHoleGroup {
|
||||
holeIds: string[]
|
||||
position: Vector3Tuple
|
||||
normal: Vector3Tuple
|
||||
mountFace?: string
|
||||
confidence?: number
|
||||
reason?: string
|
||||
surfaceFaces?: string[]
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface PersistedSkuCadMountPattern {
|
||||
id: string
|
||||
panelId: string
|
||||
mountFace: string
|
||||
patternType: string
|
||||
holeIds: string[]
|
||||
requiredHoleIds?: string[]
|
||||
optionalHoleIds?: string[]
|
||||
minRequiredHoles: number
|
||||
preferredHoleCount: number
|
||||
symmetry?: boolean
|
||||
position: Vector3Tuple
|
||||
normal: Vector3Tuple
|
||||
confidence?: number
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface PersistedSkuCadHardwareInstance {
|
||||
@ -84,10 +105,12 @@ export interface PersistedSkuCadData {
|
||||
source: 'hardware.json'
|
||||
pluginVersion: string
|
||||
units: string
|
||||
coordinateSystem?: string
|
||||
panelCount: number
|
||||
panels: PersistedSkuCadPanel[]
|
||||
holes: PersistedSkuCadHole[]
|
||||
holeGroups: PersistedSkuCadHoleGroup[]
|
||||
mountPatterns: PersistedSkuCadMountPattern[]
|
||||
hardwareTypes: PersistedSkuCadHardwareType[]
|
||||
hardwareSummary: CADHardwareSummary[]
|
||||
assembly?: PersistedSkuCadAssemblyData
|
||||
@ -145,6 +168,18 @@ function toBoundingBox(value: unknown): PersistedSkuCadBoundingBox | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalized = value.filter(
|
||||
(item): item is string => typeof item === 'string' && item.trim().length > 0,
|
||||
)
|
||||
|
||||
return normalized.length > 0 ? normalized.map((item) => item.trim()) : undefined
|
||||
}
|
||||
|
||||
function normalizePanelTypeLabel(panelType?: string): string {
|
||||
const typeNameMap: Record<string, string> = {
|
||||
side_panel: '侧板',
|
||||
@ -278,6 +313,7 @@ function normalizeCadHoles(rawHoles: unknown): PersistedSkuCadHole[] {
|
||||
typeof record.holeGroupRole === 'string' && record.holeGroupRole.trim().length > 0
|
||||
? record.holeGroupRole.trim()
|
||||
: undefined,
|
||||
surfaceFaces: normalizeStringArray(record.surfaceFaces),
|
||||
suggestedFasteners: normalizeSuggestedFasteners(record.suggestedFasteners),
|
||||
})
|
||||
})
|
||||
@ -386,6 +422,10 @@ function normalizeCadHoleGroups(rawGroups: unknown): PersistedSkuCadHoleGroup[]
|
||||
: [],
|
||||
position: toVector3Tuple(record.position),
|
||||
normal: toVector3Tuple(record.normal, [0, 0, 1]),
|
||||
mountFace:
|
||||
typeof record.mountFace === 'string' && record.mountFace.trim().length > 0
|
||||
? record.mountFace.trim()
|
||||
: undefined,
|
||||
confidence:
|
||||
typeof record.confidence === 'number' && Number.isFinite(record.confidence)
|
||||
? record.confidence
|
||||
@ -394,12 +434,77 @@ function normalizeCadHoleGroups(rawGroups: unknown): PersistedSkuCadHoleGroup[]
|
||||
typeof record.reason === 'string' && record.reason.trim().length > 0
|
||||
? record.reason.trim()
|
||||
: undefined,
|
||||
surfaceFaces: normalizeStringArray(record.surfaceFaces),
|
||||
warnings: normalizeStringArray(record.warnings),
|
||||
})
|
||||
})
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function normalizeCadMountPatterns(rawPatterns: unknown): PersistedSkuCadMountPattern[] {
|
||||
if (!Array.isArray(rawPatterns)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const patterns: PersistedSkuCadMountPattern[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
rawPatterns.forEach((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const record = entry as Record<string, unknown>
|
||||
const id =
|
||||
typeof record.id === 'string' && record.id.trim().length > 0
|
||||
? record.id.trim()
|
||||
: `cad-mount-pattern-${index + 1}`
|
||||
if (seen.has(id)) {
|
||||
return
|
||||
}
|
||||
seen.add(id)
|
||||
|
||||
const panelId = typeof record.panelId === 'string' ? record.panelId.trim() : ''
|
||||
const mountFace = typeof record.mountFace === 'string' ? record.mountFace.trim() : ''
|
||||
if (!panelId || !mountFace) {
|
||||
return
|
||||
}
|
||||
|
||||
const holeIds = Array.isArray(record.holeIds)
|
||||
? record.holeIds.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
: []
|
||||
if (holeIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
patterns.push({
|
||||
id,
|
||||
panelId,
|
||||
mountFace,
|
||||
patternType:
|
||||
typeof record.patternType === 'string' && record.patternType.trim().length > 0
|
||||
? record.patternType.trim()
|
||||
: 'generic',
|
||||
holeIds,
|
||||
requiredHoleIds: normalizeStringArray(record.requiredHoleIds),
|
||||
optionalHoleIds: normalizeStringArray(record.optionalHoleIds),
|
||||
minRequiredHoles: Math.max(1, toPositiveNumber(record.minRequiredHoles, holeIds.length)),
|
||||
preferredHoleCount: Math.max(1, toPositiveNumber(record.preferredHoleCount, holeIds.length)),
|
||||
symmetry: record.symmetry === true,
|
||||
position: toVector3Tuple(record.position),
|
||||
normal: toVector3Tuple(record.normal, [0, 0, 1]),
|
||||
confidence:
|
||||
typeof record.confidence === 'number' && Number.isFinite(record.confidence)
|
||||
? record.confidence
|
||||
: undefined,
|
||||
warnings: normalizeStringArray(record.warnings),
|
||||
})
|
||||
})
|
||||
|
||||
return patterns
|
||||
}
|
||||
|
||||
function normalizeCadHardwareTypes(rawHardwareTypes: unknown): PersistedSkuCadHardwareType[] {
|
||||
if (!Array.isArray(rawHardwareTypes)) {
|
||||
return []
|
||||
@ -571,9 +676,11 @@ function normalizeHardwareSummary(rawSummary: unknown): CADHardwareSummary[] {
|
||||
export function buildPersistedSkuCadData(params: {
|
||||
pluginVersion?: string
|
||||
units?: string
|
||||
coordinateSystem?: unknown
|
||||
panels?: unknown
|
||||
holes?: unknown
|
||||
holeGroups?: unknown
|
||||
mountPatterns?: unknown
|
||||
hardwareTypes?: unknown
|
||||
hardwareSummary?: unknown
|
||||
assembly?: unknown
|
||||
@ -581,6 +688,7 @@ export function buildPersistedSkuCadData(params: {
|
||||
const panels = normalizeCadPanels(params.panels)
|
||||
const holes = mergeCadHoles(normalizeCadHoles(params.holes), params.panels)
|
||||
const holeGroups = normalizeCadHoleGroups(params.holeGroups)
|
||||
const mountPatterns = normalizeCadMountPatterns(params.mountPatterns)
|
||||
const hardwareTypes = normalizeCadHardwareTypes(params.hardwareTypes)
|
||||
const hardwareSummary = normalizeHardwareSummary(params.hardwareSummary)
|
||||
const assembly = normalizePersistedSkuCadAssemblyData(params.assembly)
|
||||
@ -589,6 +697,7 @@ export function buildPersistedSkuCadData(params: {
|
||||
panels.length === 0 &&
|
||||
holes.length === 0 &&
|
||||
holeGroups.length === 0 &&
|
||||
mountPatterns.length === 0 &&
|
||||
hardwareTypes.length === 0 &&
|
||||
hardwareSummary.length === 0 &&
|
||||
!assembly
|
||||
@ -600,10 +709,15 @@ export function buildPersistedSkuCadData(params: {
|
||||
source: 'hardware.json',
|
||||
pluginVersion: typeof params.pluginVersion === 'string' ? params.pluginVersion : 'unknown',
|
||||
units: typeof params.units === 'string' && params.units.trim().length > 0 ? params.units.trim() : 'mm',
|
||||
coordinateSystem:
|
||||
typeof params.coordinateSystem === 'string' && params.coordinateSystem.trim().length > 0
|
||||
? params.coordinateSystem.trim()
|
||||
: undefined,
|
||||
panelCount: panels.length,
|
||||
panels,
|
||||
holes,
|
||||
holeGroups,
|
||||
mountPatterns,
|
||||
hardwareTypes,
|
||||
hardwareSummary,
|
||||
...(assembly ? { assembly } : {}),
|
||||
@ -624,9 +738,11 @@ export function readSkuCadDataFromAttributes(attributes: unknown): PersistedSkuC
|
||||
return buildPersistedSkuCadData({
|
||||
pluginVersion: typeof record.pluginVersion === 'string' ? record.pluginVersion : undefined,
|
||||
units: typeof record.units === 'string' ? record.units : undefined,
|
||||
coordinateSystem: record.coordinateSystem,
|
||||
panels: record.panels,
|
||||
holes: record.holes,
|
||||
holeGroups: record.holeGroups,
|
||||
mountPatterns: record.mountPatterns,
|
||||
hardwareTypes: record.hardwareTypes,
|
||||
hardwareSummary: record.hardwareSummary,
|
||||
assembly: record.assembly,
|
||||
@ -744,5 +860,5 @@ export function enrichPanelsWithSkuCadSpatial<T extends { gltfNodeName: string }
|
||||
cadBoundingBox: cadPanel?.boundingBox ?? null,
|
||||
sourceLayer: cadPanel?.sourceLayer ?? null,
|
||||
}
|
||||
})
|
||||
}) as Array<T & SkuCadSpatialFields>
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { AssetStatus, AssetType } from '@prisma/client'
|
||||
import type { AssetVariantUvConfig } from '@/lib/asset-variant-render-config'
|
||||
import type { OpenProductMountPoint } from '@/lib/open-product/types'
|
||||
|
||||
export interface AssetWorkflowAuditDTO {
|
||||
@ -59,6 +60,7 @@ export interface AssetVariantDTO {
|
||||
compatibilityRules: Record<string, unknown> | null
|
||||
namingFields: Record<string, unknown> | null
|
||||
businessData: Record<string, unknown> | null
|
||||
uvConfig?: AssetVariantUvConfig | null
|
||||
weightKg: number | null
|
||||
costPrice: number | null
|
||||
lossFactor: number | null
|
||||
@ -181,6 +183,7 @@ export interface CreateAssetVariantInput {
|
||||
compatibilityRules?: Record<string, unknown> | null
|
||||
namingFields?: Record<string, unknown> | null
|
||||
businessData?: Record<string, unknown> | null
|
||||
uvConfig?: AssetVariantUvConfig | null
|
||||
weightKg?: number | null
|
||||
costPrice?: number | null
|
||||
lossFactor?: number | null
|
||||
@ -205,6 +208,7 @@ export interface UpdateAssetVariantInput {
|
||||
compatibilityRules?: Record<string, unknown> | null
|
||||
namingFields?: Record<string, unknown> | null
|
||||
businessData?: Record<string, unknown> | null
|
||||
uvConfig?: AssetVariantUvConfig | null
|
||||
weightKg?: number | null
|
||||
costPrice?: number | null
|
||||
lossFactor?: number | null
|
||||
|
||||
@ -1,352 +1,317 @@
|
||||
param(
|
||||
[string]$ProjectRoot = "D:\AI Code\3danzhuang\furniture-3d-system",
|
||||
[string]$OutputRoot = "D:\AI Code\3danzhuang",
|
||||
param(
|
||||
[string]$RepoRoot = $(Split-Path -Parent $PSScriptRoot),
|
||||
[string]$ProjectSubdir = "furniture-3d-system",
|
||||
[string]$OutputRoot = "",
|
||||
[string]$Ref = "origin/main",
|
||||
[string]$RemoteBase = "/opt/furniture-3d-system-prod",
|
||||
[string]$ServerHost = "ltai5tdb2ogyj59fquyedh.yydd.ai",
|
||||
[int]$AppPort = 3001,
|
||||
[string]$PublicAppUrl = "",
|
||||
[string]$Pm2Name = "furniture-3d-system-prod",
|
||||
[string]$DbName = "furniture3d_prod",
|
||||
[string]$DbUser = "furniture3d_prod",
|
||||
[string]$DbPass = "",
|
||||
[string]$HrSyncSecret = "",
|
||||
[string]$S3Endpoint = "http://192.168.58.20:9000",
|
||||
[string]$S3Bucket = "gongxiangpan",
|
||||
[string]$S3Region = "us-east-1",
|
||||
[string]$S3PublicDomain = "https://s3.jiansun.vip/gongxiangpan",
|
||||
[string]$S3FilePath = "3danzhuang/furniture-3d-system/"
|
||||
[int]$KeepReleases = 5
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ProjectRoot)) {
|
||||
throw "项目目录不存在: $ProjectRoot"
|
||||
if ([string]::IsNullOrWhiteSpace($OutputRoot)) {
|
||||
$OutputRoot = $RepoRoot
|
||||
}
|
||||
|
||||
function Get-EnvValue {
|
||||
if (-not (Test-Path -LiteralPath $RepoRoot)) {
|
||||
throw "仓库目录不存在: $RepoRoot"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $OutputRoot)) {
|
||||
New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null
|
||||
}
|
||||
|
||||
$projectRoot = Join-Path $RepoRoot $ProjectSubdir
|
||||
if (-not (Test-Path -LiteralPath $projectRoot)) {
|
||||
throw "项目目录不存在: $projectRoot"
|
||||
}
|
||||
|
||||
function Invoke-NativeCommand {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$Key
|
||||
[string]$FilePath,
|
||||
[string[]]$Arguments,
|
||||
[string]$WorkingDirectory = $RepoRoot
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
return $null
|
||||
}
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
|
||||
$line = Get-Content -LiteralPath $Path | Where-Object { $_ -match "^$([regex]::Escape($Key))=" } | Select-Object -First 1
|
||||
if (-not $line) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$value = $line.Substring($line.IndexOf('=') + 1).Trim()
|
||||
if ($value.Length -ge 2) {
|
||||
if (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'"))) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
try {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Push-Location -LiteralPath $WorkingDirectory
|
||||
try {
|
||||
$output = & $FilePath @Arguments 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
$lines = @(
|
||||
$output |
|
||||
ForEach-Object {
|
||||
if ($_ -is [System.Management.Automation.ErrorRecord]) {
|
||||
$_.ToString()
|
||||
}
|
||||
else {
|
||||
[string]$_
|
||||
}
|
||||
} |
|
||||
Where-Object { $_.Length -gt 0 }
|
||||
)
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
throw (($lines -join [Environment]::NewLine).Trim())
|
||||
}
|
||||
|
||||
return $lines
|
||||
}
|
||||
finally {
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
}
|
||||
|
||||
return $value
|
||||
}
|
||||
|
||||
function New-RandomSecret([int]$Length = 48) {
|
||||
$bytes = New-Object byte[] $Length
|
||||
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
|
||||
return [Convert]::ToBase64String($bytes).Replace('+', 'A').Replace('/', 'B').TrimEnd('=').Substring(0, $Length)
|
||||
function Invoke-Git {
|
||||
param(
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$Arguments
|
||||
)
|
||||
|
||||
return Invoke-NativeCommand -FilePath 'git' -Arguments (@('-C', $RepoRoot) + $Arguments) -WorkingDirectory $RepoRoot
|
||||
}
|
||||
|
||||
$localEnvPath = Join-Path $ProjectRoot '.env'
|
||||
$jwtSecret = Get-EnvValue -Path $localEnvPath -Key 'JWT_SECRET'
|
||||
if ([string]::IsNullOrWhiteSpace($jwtSecret) -or $jwtSecret.Length -lt 32) {
|
||||
$jwtSecret = New-RandomSecret 48
|
||||
if ($Ref -match '^(origin/|refs/remotes/origin/)') {
|
||||
Invoke-Git fetch origin | Out-Null
|
||||
}
|
||||
|
||||
$nextAuthSecret = Get-EnvValue -Path $localEnvPath -Key 'NEXTAUTH_SECRET'
|
||||
if ([string]::IsNullOrWhiteSpace($nextAuthSecret) -or $nextAuthSecret.Length -lt 32) {
|
||||
$nextAuthSecret = New-RandomSecret 48
|
||||
$commit = (Invoke-Git rev-parse $Ref | Select-Object -First 1).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($commit)) {
|
||||
throw "无法解析部署引用: $Ref"
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($DbPass)) {
|
||||
$DbPass = New-RandomSecret 32
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($HrSyncSecret)) {
|
||||
$HrSyncSecret = Get-EnvValue -Path $localEnvPath -Key 'HR_SYNC_SECRET'
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($HrSyncSecret)) {
|
||||
$HrSyncSecret = Get-EnvValue -Path $localEnvPath -Key 'INTERNAL_SYNC_SECRET'
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($HrSyncSecret) -or $HrSyncSecret.Length -lt 32) {
|
||||
$HrSyncSecret = New-RandomSecret 48
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($PublicAppUrl)) {
|
||||
$publicAppUrl = "http://$ServerHost`:$AppPort"
|
||||
} else {
|
||||
$publicAppUrl = $PublicAppUrl.TrimEnd('/')
|
||||
}
|
||||
|
||||
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$archiveName = "deploy-furniture-prod-$timestamp.tar.gz"
|
||||
$scriptName = "deploy-furniture-prod-$timestamp.sh"
|
||||
$shortCommit = $commit.Substring(0, 7)
|
||||
$releaseId = "{0}-{1}" -f (Get-Date -Format 'yyyyMMdd-HHmmss'), $shortCommit
|
||||
$archiveName = "deploy-furniture-prod-$releaseId.tar.gz"
|
||||
$scriptName = "deploy-furniture-prod-$releaseId.sh"
|
||||
$archivePath = Join-Path $OutputRoot $archiveName
|
||||
$scriptPath = Join-Path $OutputRoot $scriptName
|
||||
$remoteIncoming = "$RemoteBase/incoming"
|
||||
$remoteArchivePath = "$remoteIncoming/$archiveName"
|
||||
$remoteScriptPath = "$remoteIncoming/$scriptName"
|
||||
|
||||
$includePaths = @(
|
||||
'furniture-3d-system/package.json'
|
||||
'furniture-3d-system/package-lock.json'
|
||||
'furniture-3d-system/.npmrc'
|
||||
'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
|
||||
if (Test-Path -LiteralPath $archivePath) {
|
||||
Remove-Item -LiteralPath $archivePath -Force
|
||||
}
|
||||
if (Test-Path -LiteralPath $scriptPath) {
|
||||
Remove-Item -LiteralPath $scriptPath -Force
|
||||
}
|
||||
|
||||
Invoke-Git archive --format=tar.gz "--output=$archivePath" "--prefix=$ProjectSubdir/" "$commit`:$ProjectSubdir" | Out-Null
|
||||
|
||||
$shellScript = @'
|
||||
set -e
|
||||
BASE=__REMOTE_BASE__
|
||||
APP=$BASE/furniture-3d-system
|
||||
ARCHIVE=$BASE/__ARCHIVE_NAME__
|
||||
DB_NAME=__DB_NAME__
|
||||
DB_USER=__DB_USER__
|
||||
DB_PASS='__DB_PASS__'
|
||||
APP_PORT=__APP_PORT__
|
||||
PM2_NAME=__PM2_NAME__
|
||||
PUBLIC_APP_URL='__PUBLIC_APP_URL__'
|
||||
S3_ENDPOINT='__S3_ENDPOINT__'
|
||||
S3_BUCKET='__S3_BUCKET__'
|
||||
S3_REGION='__S3_REGION__'
|
||||
S3_PUBLIC_DOMAIN='__S3_PUBLIC_DOMAIN__'
|
||||
S3_FILE_PATH='__S3_FILE_PATH__'
|
||||
NEXTAUTH_SECRET='__NEXTAUTH_SECRET__'
|
||||
JWT_SECRET='__JWT_SECRET__'
|
||||
HR_SYNC_SECRET='__HR_SYNC_SECRET__'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
EXISTING_ENV_PATH="$BASE/.env"
|
||||
read_env_value() {
|
||||
local key="$1"
|
||||
if [ ! -f "$EXISTING_ENV_PATH" ]; then
|
||||
return 0
|
||||
fi
|
||||
local line
|
||||
line=$(grep -E "^${key}=" "$EXISTING_ENV_PATH" | head -n 1 || true)
|
||||
if [ -z "$line" ]; then
|
||||
return 0
|
||||
fi
|
||||
local value="${line#*=}"
|
||||
value="${value%\"}"
|
||||
value="${value#\"}"
|
||||
value="${value%\'}"
|
||||
value="${value#\'}"
|
||||
printf '%s' "$value"
|
||||
BASE="__REMOTE_BASE__"
|
||||
INCOMING="$BASE/incoming"
|
||||
RELEASES="$BASE/releases"
|
||||
SHARED="$BASE/shared"
|
||||
CURRENT="$BASE/current"
|
||||
LEGACY_APP="$BASE/__PROJECT_SUBDIR__"
|
||||
|
||||
RELEASE_ID="__RELEASE_ID__"
|
||||
ARCHIVE="$INCOMING/__ARCHIVE_NAME__"
|
||||
APP_PORT="__APP_PORT__"
|
||||
PM2_NAME="__PM2_NAME__"
|
||||
KEEP_RELEASES="__KEEP_RELEASES__"
|
||||
COMMIT_SHA="__COMMIT_SHA__"
|
||||
SOURCE_REF="__SOURCE_REF__"
|
||||
|
||||
assert_path_in_base() {
|
||||
local target="$1"
|
||||
case "$target" in
|
||||
"$BASE"/*) ;;
|
||||
*)
|
||||
echo "Refuse to touch path outside deployment base: $target" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
S3_ACCESS_KEY_VALUE="${S3_ACCESS_KEY:-${S3_ACCESS_KEY_ID:-$(read_env_value S3_ACCESS_KEY)}}"
|
||||
S3_SECRET_KEY_VALUE="${S3_SECRET_KEY:-${S3_SECRET_ACCESS_KEY:-$(read_env_value S3_SECRET_KEY)}}"
|
||||
NEXTAUTH_SECRET_VALUE="${NEXTAUTH_SECRET:-$(read_env_value NEXTAUTH_SECRET)}"
|
||||
JWT_SECRET_VALUE="${JWT_SECRET:-$(read_env_value JWT_SECRET)}"
|
||||
DINGTALK_CALLBACK_ORIGIN_VALUE="${DINGTALK_CALLBACK_ORIGIN:-$(read_env_value DINGTALK_CALLBACK_ORIGIN)}"
|
||||
DINGTALK_CLIENT_ID_VALUE="${DINGTALK_CLIENT_ID:-$(read_env_value DINGTALK_CLIENT_ID)}"
|
||||
DINGTALK_CLIENT_SECRET_VALUE="${DINGTALK_CLIENT_SECRET:-$(read_env_value DINGTALK_CLIENT_SECRET)}"
|
||||
HR_SYNC_SECRET_VALUE="${HR_SYNC_SECRET:-${INTERNAL_SYNC_SECRET:-$(read_env_value HR_SYNC_SECRET)}}"
|
||||
copy_legacy_env_if_needed() {
|
||||
if [ -f "$SHARED/.env" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -z "$NEXTAUTH_SECRET_VALUE" ]; then
|
||||
NEXTAUTH_SECRET_VALUE="$NEXTAUTH_SECRET"
|
||||
fi
|
||||
if [ -z "$JWT_SECRET_VALUE" ]; then
|
||||
JWT_SECRET_VALUE="$JWT_SECRET"
|
||||
fi
|
||||
if [ -z "$DINGTALK_CALLBACK_ORIGIN_VALUE" ]; then
|
||||
DINGTALK_CALLBACK_ORIGIN_VALUE="$PUBLIC_APP_URL"
|
||||
fi
|
||||
if [ -z "$HR_SYNC_SECRET_VALUE" ]; then
|
||||
HR_SYNC_SECRET_VALUE="$HR_SYNC_SECRET"
|
||||
fi
|
||||
mkdir -p "$SHARED"
|
||||
|
||||
if [ -z "$S3_ACCESS_KEY_VALUE" ] || [ -z "$S3_SECRET_KEY_VALUE" ]; then
|
||||
echo 'Missing shared S3 credentials. Export S3_ACCESS_KEY / S3_SECRET_KEY (or AWS-style names) before running this script.'
|
||||
if [ -f "$BASE/.env" ]; then
|
||||
cp "$BASE/.env" "$SHARED/.env"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -f "$LEGACY_APP/.env" ]; then
|
||||
cp "$LEGACY_APP/.env" "$SHARED/.env"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Missing shared env file. Expected one of:" >&2
|
||||
echo " $SHARED/.env" >&2
|
||||
echo " $BASE/.env" >&2
|
||||
echo " $LEGACY_APP/.env" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
start_pm2_release() {
|
||||
local release_root="$1"
|
||||
local app_root="$release_root/__PROJECT_SUBDIR__"
|
||||
local start_command
|
||||
start_command="cd '$app_root' && set -a && source '$SHARED/.env' && set +a && exec ./node_modules/.bin/next start -H 0.0.0.0 -p $APP_PORT"
|
||||
pm2 delete "$PM2_NAME" >/dev/null 2>&1 || true
|
||||
pm2 start /bin/bash --name "$PM2_NAME" -- -lc "$start_command"
|
||||
pm2 save >/dev/null
|
||||
}
|
||||
|
||||
cleanup_old_releases() {
|
||||
if [ "$KEEP_RELEASES" -le 0 ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
mapfile -t release_dirs < <(find "$RELEASES" -mindepth 1 -maxdepth 1 -type d | sort)
|
||||
local total="${#release_dirs[@]}"
|
||||
if [ "$total" -le "$KEEP_RELEASES" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local current_real=""
|
||||
current_real="$(readlink -f "$CURRENT" 2>/dev/null || true)"
|
||||
local remove_count=$((total - KEEP_RELEASES))
|
||||
|
||||
for ((i=0; i<remove_count; i++)); do
|
||||
local candidate="${release_dirs[$i]}"
|
||||
if [ -n "$current_real" ] && [ "$candidate" = "$current_real" ]; then
|
||||
continue
|
||||
fi
|
||||
assert_path_in_base "$candidate"
|
||||
rm -rf -- "$candidate"
|
||||
done
|
||||
}
|
||||
|
||||
mkdir -p "$INCOMING" "$RELEASES" "$SHARED"
|
||||
copy_legacy_env_if_needed
|
||||
|
||||
RELEASE_DIR="$RELEASES/$RELEASE_ID"
|
||||
APP_DIR="$RELEASE_DIR/__PROJECT_SUBDIR__"
|
||||
PREVIOUS_RELEASE=""
|
||||
PREVIOUS_RELEASE="$(readlink -f "$CURRENT" 2>/dev/null || true)"
|
||||
HEALTHCHECK_FILE="/tmp/furniture-3d-system-prod-healthcheck-$RELEASE_ID.txt"
|
||||
DEPLOY_SUCCEEDED=0
|
||||
|
||||
cleanup_failed_release() {
|
||||
local status="$1"
|
||||
|
||||
rm -f -- "${HEALTHCHECK_FILE:-}" >/dev/null 2>&1 || true
|
||||
|
||||
if [ "$DEPLOY_SUCCEEDED" -eq 1 ]; then
|
||||
exit "$status"
|
||||
fi
|
||||
|
||||
if [ -n "${RELEASE_DIR:-}" ] && [ -d "$RELEASE_DIR" ]; then
|
||||
local current_real=""
|
||||
current_real="$(readlink -f "$CURRENT" 2>/dev/null || true)"
|
||||
|
||||
if [ -n "$current_real" ] && [ "$current_real" = "$RELEASE_DIR" ]; then
|
||||
if [ -n "$PREVIOUS_RELEASE" ] && [ -d "$PREVIOUS_RELEASE" ]; then
|
||||
ln -sfn "$PREVIOUS_RELEASE" "$CURRENT"
|
||||
if ! start_pm2_release "$CURRENT"; then
|
||||
echo "Rollback start failed for previous release: $PREVIOUS_RELEASE" >&2
|
||||
status=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
assert_path_in_base "$RELEASE_DIR"
|
||||
rm -rf -- "$RELEASE_DIR"
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
trap 'cleanup_failed_release "$?"' EXIT
|
||||
|
||||
assert_path_in_base "$RELEASE_DIR"
|
||||
rm -rf -- "$RELEASE_DIR"
|
||||
mkdir -p "$RELEASE_DIR"
|
||||
|
||||
tar -xzf "$ARCHIVE" -C "$RELEASE_DIR"
|
||||
|
||||
if [ ! -d "$APP_DIR" ]; then
|
||||
echo "Archive extracted but app directory missing: $APP_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p $BASE
|
||||
mkdir -p $APP
|
||||
OLD_LOCK_HASH=""
|
||||
if [ -f "$APP/package-lock.json" ]; then
|
||||
OLD_LOCK_HASH=$(sha256sum "$APP/package-lock.json" | awk '{print $1}')
|
||||
fi
|
||||
printf '%s\n' "$COMMIT_SHA" > "$RELEASE_DIR/REVISION"
|
||||
printf '%s\n' "$SOURCE_REF" > "$RELEASE_DIR/SOURCE_REF"
|
||||
|
||||
rm -rf \
|
||||
$APP/src \
|
||||
$APP/public \
|
||||
$APP/prisma \
|
||||
$APP/scripts \
|
||||
$APP/lib \
|
||||
$APP/.next
|
||||
ln -sfn "$SHARED/.env" "$APP_DIR/.env"
|
||||
|
||||
rm -f \
|
||||
$APP/package.json \
|
||||
$APP/package-lock.json \
|
||||
$APP/.npmrc \
|
||||
$APP/next.config.js \
|
||||
$APP/postcss.config.mjs \
|
||||
$APP/tsconfig.json \
|
||||
$APP/next-env.d.ts \
|
||||
$APP/prisma.config.ts
|
||||
|
||||
cd $BASE
|
||||
tar -xzf $ARCHIVE
|
||||
|
||||
NEW_LOCK_HASH=""
|
||||
if [ -f "$APP/package-lock.json" ]; then
|
||||
NEW_LOCK_HASH=$(sha256sum "$APP/package-lock.json" | awk '{print $1}')
|
||||
fi
|
||||
|
||||
sudo -u postgres psql -d postgres <<SQL
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$DB_USER') THEN
|
||||
CREATE ROLE $DB_USER LOGIN PASSWORD '$DB_PASS';
|
||||
END IF;
|
||||
END
|
||||
\$\$;
|
||||
ALTER ROLE $DB_USER WITH PASSWORD '$DB_PASS';
|
||||
SQL
|
||||
|
||||
if ! sudo -u postgres psql -d postgres -Atqc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1; then
|
||||
sudo -u postgres createdb -O $DB_USER $DB_NAME
|
||||
fi
|
||||
|
||||
sudo -u postgres psql -d postgres <<SQL
|
||||
GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;
|
||||
ALTER DATABASE $DB_NAME OWNER TO $DB_USER;
|
||||
SQL
|
||||
|
||||
sudo -u postgres psql -d $DB_NAME <<SQL
|
||||
GRANT ALL PRIVILEGES ON SCHEMA public TO $DB_USER;
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO $DB_USER;
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO $DB_USER;
|
||||
GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO $DB_USER;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO $DB_USER;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO $DB_USER;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO $DB_USER;
|
||||
SQL
|
||||
|
||||
cat > $BASE/.env <<EOF
|
||||
NODE_ENV=production
|
||||
PORT=$APP_PORT
|
||||
HOSTNAME=0.0.0.0
|
||||
DATABASE_URL=postgresql://$DB_USER:$DB_PASS@127.0.0.1:5432/$DB_NAME
|
||||
DIRECT_URL=postgresql://$DB_USER:$DB_PASS@127.0.0.1:5432/$DB_NAME
|
||||
NEXTAUTH_URL=$PUBLIC_APP_URL
|
||||
NEXT_PUBLIC_APP_URL=$PUBLIC_APP_URL
|
||||
NEXTAUTH_SECRET=$NEXTAUTH_SECRET_VALUE
|
||||
JWT_SECRET=$JWT_SECRET_VALUE
|
||||
S3_ENDPOINT=$S3_ENDPOINT
|
||||
S3_BUCKET=$S3_BUCKET
|
||||
S3_REGION=$S3_REGION
|
||||
S3_ENABLE_PATH_STYLE=1
|
||||
S3_PUBLIC_DOMAIN=$S3_PUBLIC_DOMAIN
|
||||
S3_FILE_PATH=$S3_FILE_PATH
|
||||
S3_ACCESS_KEY=$S3_ACCESS_KEY_VALUE
|
||||
S3_SECRET_KEY=$S3_SECRET_KEY_VALUE
|
||||
PUPPETEER_SKIP_DOWNLOAD=true
|
||||
DINGTALK_CALLBACK_ORIGIN=$DINGTALK_CALLBACK_ORIGIN_VALUE
|
||||
HR_SYNC_SECRET=$HR_SYNC_SECRET_VALUE
|
||||
EOF
|
||||
|
||||
if [ -n "$DINGTALK_CLIENT_ID_VALUE" ]; then
|
||||
echo "DINGTALK_CLIENT_ID=$DINGTALK_CLIENT_ID_VALUE" >> $BASE/.env
|
||||
fi
|
||||
if [ -n "$DINGTALK_CLIENT_SECRET_VALUE" ]; then
|
||||
echo "DINGTALK_CLIENT_SECRET=$DINGTALK_CLIENT_SECRET_VALUE" >> $BASE/.env
|
||||
fi
|
||||
ln -sfn $BASE/.env $APP/.env
|
||||
|
||||
cd $APP
|
||||
cd "$APP_DIR"
|
||||
export PUPPETEER_SKIP_DOWNLOAD=true
|
||||
if [ ! -d "$APP/node_modules" ] || [ "$OLD_LOCK_HASH" != "$NEW_LOCK_HASH" ]; then
|
||||
echo '=== Install or update dependencies ==='
|
||||
npm install --no-audit --no-fund
|
||||
else
|
||||
echo '=== Reuse existing dependencies (package-lock unchanged) ==='
|
||||
fi
|
||||
|
||||
if find /root/.cache/puppeteer -type f -name chrome-headless-shell 2>/dev/null | grep -q .; then
|
||||
echo '=== Reuse existing chrome-headless-shell ==='
|
||||
else
|
||||
echo '=== Install chrome-headless-shell ==='
|
||||
PUPPETEER_CACHE_DIR=/root/.cache/puppeteer npx puppeteer browsers install chrome-headless-shell@stable
|
||||
fi
|
||||
|
||||
npm ci --no-audit --no-fund
|
||||
npx prisma generate
|
||||
PRISMA_SCHEMA_SYNC_SQL=/tmp/furniture-3d-system-prod-prisma-schema-sync.sql
|
||||
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script -o "$PRISMA_SCHEMA_SYNC_SQL"
|
||||
if [ -s "$PRISMA_SCHEMA_SYNC_SQL" ]; then
|
||||
echo '=== Sync Prisma schema diff ==='
|
||||
sudo -u postgres psql -v ON_ERROR_STOP=1 -d $DB_NAME -f "$PRISMA_SCHEMA_SYNC_SQL"
|
||||
else
|
||||
echo '=== Prisma schema already up to date ==='
|
||||
fi
|
||||
|
||||
npx prisma migrate deploy
|
||||
npm run build
|
||||
|
||||
mkdir -p "$APP/.next/standalone/.next"
|
||||
rm -rf "$APP/.next/standalone/.next/static" "$APP/.next/standalone/public"
|
||||
cp -R "$APP/.next/static" "$APP/.next/standalone/.next/static"
|
||||
cp -R "$APP/public" "$APP/.next/standalone/public"
|
||||
ln -sfn "$RELEASE_DIR" "$CURRENT"
|
||||
start_pm2_release "$CURRENT"
|
||||
|
||||
pm2 delete $PM2_NAME >/dev/null 2>&1 || true
|
||||
pm2 start /bin/bash --name $PM2_NAME -- -lc "cd '$APP' && set -a && source '$APP/.env' && set +a && mkdir -p '$APP/.next/standalone/.next' && rm -rf '$APP/.next/standalone/.next/static' '$APP/.next/standalone/public' && cp -R '$APP/.next/static' '$APP/.next/standalone/.next/static' && cp -R '$APP/public' '$APP/.next/standalone/public' && exec env PORT=$APP_PORT HOSTNAME=0.0.0.0 node '$APP/.next/standalone/server.js'"
|
||||
pm2 save >/dev/null
|
||||
sleep 5
|
||||
curl -I -s http://127.0.0.1:$APP_PORT | sed -n '1,12p'
|
||||
echo '=== DONE ==='
|
||||
pm2 describe $PM2_NAME | sed -n '1,35p'
|
||||
if ! curl -fsS -I "http://127.0.0.1:$APP_PORT" >"$HEALTHCHECK_FILE"; then
|
||||
echo "New release health check failed. Rolling back..." >&2
|
||||
if [ -n "$PREVIOUS_RELEASE" ] && [ -d "$PREVIOUS_RELEASE" ]; then
|
||||
ln -sfn "$PREVIOUS_RELEASE" "$CURRENT"
|
||||
start_pm2_release "$CURRENT"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sed -n '1,12p' "$HEALTHCHECK_FILE"
|
||||
cleanup_old_releases
|
||||
rm -f -- "$ARCHIVE"
|
||||
rm -f -- "$HEALTHCHECK_FILE"
|
||||
DEPLOY_SUCCEEDED=1
|
||||
trap - EXIT
|
||||
echo "=== SAFE RELEASE DEPLOY DONE ==="
|
||||
pm2 describe "$PM2_NAME" | sed -n '1,35p'
|
||||
'@
|
||||
|
||||
$shellScript = $shellScript.Replace('__REMOTE_BASE__', $RemoteBase)
|
||||
$shellScript = $shellScript.Replace('__PROJECT_SUBDIR__', $ProjectSubdir)
|
||||
$shellScript = $shellScript.Replace('__RELEASE_ID__', $releaseId)
|
||||
$shellScript = $shellScript.Replace('__ARCHIVE_NAME__', $archiveName)
|
||||
$shellScript = $shellScript.Replace('__DB_NAME__', $DbName)
|
||||
$shellScript = $shellScript.Replace('__DB_USER__', $DbUser)
|
||||
$shellScript = $shellScript.Replace('__DB_PASS__', $DbPass)
|
||||
$shellScript = $shellScript.Replace('__APP_PORT__', [string]$AppPort)
|
||||
$shellScript = $shellScript.Replace('__PM2_NAME__', $Pm2Name)
|
||||
$shellScript = $shellScript.Replace('__PUBLIC_APP_URL__', $publicAppUrl)
|
||||
$shellScript = $shellScript.Replace('__S3_ENDPOINT__', $S3Endpoint)
|
||||
$shellScript = $shellScript.Replace('__S3_BUCKET__', $S3Bucket)
|
||||
$shellScript = $shellScript.Replace('__S3_REGION__', $S3Region)
|
||||
$shellScript = $shellScript.Replace('__S3_PUBLIC_DOMAIN__', $S3PublicDomain)
|
||||
$shellScript = $shellScript.Replace('__S3_FILE_PATH__', $S3FilePath)
|
||||
$shellScript = $shellScript.Replace('__NEXTAUTH_SECRET__', $nextAuthSecret)
|
||||
$shellScript = $shellScript.Replace('__JWT_SECRET__', $jwtSecret)
|
||||
$shellScript = $shellScript.Replace('__HR_SYNC_SECRET__', $HrSyncSecret)
|
||||
$shellScript = $shellScript.Replace('__KEEP_RELEASES__', [string]$KeepReleases)
|
||||
$shellScript = $shellScript.Replace('__COMMIT_SHA__', $commit)
|
||||
$shellScript = $shellScript.Replace('__SOURCE_REF__', $Ref)
|
||||
|
||||
[System.IO.File]::WriteAllText($scriptPath, $shellScript, [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
$archive = Get-Item -LiteralPath $archivePath
|
||||
[PSCustomObject]@{
|
||||
Mode = 'safe-git-release'
|
||||
Ref = $Ref
|
||||
Commit = $commit
|
||||
Archive = $archive.FullName
|
||||
ArchiveMB = [math]::Round($archive.Length / 1MB, 2)
|
||||
Script = $scriptPath
|
||||
RemoteArchivePath = $remoteArchivePath
|
||||
RemoteScriptPath = $remoteScriptPath
|
||||
RemoteBase = $RemoteBase
|
||||
ServerHost = $ServerHost
|
||||
AppPort = $AppPort
|
||||
PublicAppUrl = $publicAppUrl
|
||||
S3Bucket = $S3Bucket
|
||||
S3FilePath = $S3FilePath
|
||||
Pm2Name = $Pm2Name
|
||||
KeepReleases = $KeepReleases
|
||||
}
|
||||
|
||||
188
scripts/deploy-yydd-prod-from-git.ps1
Normal file
188
scripts/deploy-yydd-prod-from-git.ps1
Normal file
@ -0,0 +1,188 @@
|
||||
param(
|
||||
[string]$RepoRoot = $(Split-Path -Parent $PSScriptRoot),
|
||||
[string]$Ref = "origin/main",
|
||||
[string]$RemoteBase = "/opt/furniture-3d-system-prod",
|
||||
[string]$ServerHost = "ltai5tdb2ogyj59fquyedh.yydd.ai",
|
||||
[int]$SshPort = 60022,
|
||||
[string]$ServerUser = "root",
|
||||
[string]$ServerPassword = "",
|
||||
[string]$HostKeyFingerprint = "SHA256:JPHcomdjuUZOTM7sNdBYg0/xRkQSXzbXe7FEfNtIFAU",
|
||||
[int]$AppPort = 3001,
|
||||
[string]$Pm2Name = "furniture-3d-system-prod",
|
||||
[int]$KeepReleases = 5,
|
||||
[switch]$KeepArtifacts
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$createScript = Join-Path $RepoRoot 'scripts\create-yydd-prod-deploy.ps1'
|
||||
$plinkPath = 'C:\Program Files\PuTTY\plink.exe'
|
||||
$pscpPath = 'C:\Program Files\PuTTY\pscp.exe'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $createScript)) {
|
||||
throw "缺少打包脚本: $createScript"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $plinkPath)) {
|
||||
throw "缺少 plink.exe: $plinkPath"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $pscpPath)) {
|
||||
throw "缺少 pscp.exe: $pscpPath"
|
||||
}
|
||||
|
||||
function Invoke-NativeCommand {
|
||||
param(
|
||||
[string]$FilePath,
|
||||
[string[]]$Arguments,
|
||||
[string]$WorkingDirectory = $RepoRoot
|
||||
)
|
||||
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
|
||||
try {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Push-Location -LiteralPath $WorkingDirectory
|
||||
try {
|
||||
$output = & $FilePath @Arguments 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
$lines = @(
|
||||
$output |
|
||||
ForEach-Object {
|
||||
if ($_ -is [System.Management.Automation.ErrorRecord]) {
|
||||
$_.ToString()
|
||||
}
|
||||
else {
|
||||
[string]$_
|
||||
}
|
||||
} |
|
||||
Where-Object { $_.Length -gt 0 }
|
||||
)
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
throw (($lines -join [Environment]::NewLine).Trim())
|
||||
}
|
||||
|
||||
return $lines
|
||||
}
|
||||
finally {
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Plink {
|
||||
param(
|
||||
[string]$Command
|
||||
)
|
||||
|
||||
$arguments = @(
|
||||
'-ssh'
|
||||
'-batch'
|
||||
'-hostkey'
|
||||
$HostKeyFingerprint
|
||||
'-P'
|
||||
[string]$SshPort
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($ServerPassword)) {
|
||||
$arguments += @('-pw', $ServerPassword)
|
||||
}
|
||||
|
||||
$arguments += @(
|
||||
('{0}@{1}' -f $ServerUser, $ServerHost)
|
||||
$Command
|
||||
)
|
||||
|
||||
return Invoke-NativeCommand -FilePath $plinkPath -Arguments $arguments -WorkingDirectory $RepoRoot
|
||||
}
|
||||
|
||||
function Copy-WithPscp {
|
||||
param(
|
||||
[string]$LocalPath,
|
||||
[string]$RemotePath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $LocalPath)) {
|
||||
throw "上传文件不存在: $LocalPath"
|
||||
}
|
||||
|
||||
$arguments = @(
|
||||
'-batch'
|
||||
'-hostkey'
|
||||
$HostKeyFingerprint
|
||||
'-P'
|
||||
[string]$SshPort
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($ServerPassword)) {
|
||||
$arguments += @('-pw', $ServerPassword)
|
||||
}
|
||||
|
||||
$arguments += @(
|
||||
$LocalPath
|
||||
('{0}@{1}:{2}' -f $ServerUser, $ServerHost, $RemotePath)
|
||||
)
|
||||
|
||||
return Invoke-NativeCommand -FilePath $pscpPath -Arguments $arguments -WorkingDirectory $RepoRoot
|
||||
}
|
||||
|
||||
$package = & $createScript `
|
||||
-RepoRoot $RepoRoot `
|
||||
-Ref $Ref `
|
||||
-RemoteBase $RemoteBase `
|
||||
-ServerHost $ServerHost `
|
||||
-AppPort $AppPort `
|
||||
-Pm2Name $Pm2Name `
|
||||
-KeepReleases $KeepReleases
|
||||
|
||||
if (-not $package) {
|
||||
throw '打包脚本未返回结果'
|
||||
}
|
||||
|
||||
$archivePath = [string]$package.Archive
|
||||
$scriptPath = [string]$package.Script
|
||||
$remoteArchivePath = [string]$package.RemoteArchivePath
|
||||
$remoteScriptPath = [string]$package.RemoteScriptPath
|
||||
$remoteIncoming = ('{0}/incoming' -f $RemoteBase)
|
||||
$mkdirIncomingCommand = "mkdir -p '{0}'" -f $remoteIncoming
|
||||
$runDeployCommand = "chmod +x '{0}' && bash '{0}'" -f $remoteScriptPath
|
||||
|
||||
try {
|
||||
Invoke-Plink $mkdirIncomingCommand | Out-Null
|
||||
Copy-WithPscp -LocalPath $archivePath -RemotePath $remoteArchivePath | Out-Null
|
||||
Copy-WithPscp -LocalPath $scriptPath -RemotePath $remoteScriptPath | Out-Null
|
||||
$deployOutput = Invoke-Plink $runDeployCommand
|
||||
}
|
||||
finally {
|
||||
if (-not $KeepArtifacts) {
|
||||
try {
|
||||
Invoke-Plink ("rm -f '{0}' '{1}'" -f $remoteArchivePath, $remoteScriptPath) | Out-Null
|
||||
}
|
||||
catch {
|
||||
Write-Warning ("远端部署临时文件清理失败: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $archivePath) {
|
||||
Remove-Item -LiteralPath $archivePath -Force
|
||||
}
|
||||
if (Test-Path -LiteralPath $scriptPath) {
|
||||
Remove-Item -LiteralPath $scriptPath -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
Mode = 'safe-git-release-deploy'
|
||||
Ref = [string]$package.Ref
|
||||
Commit = [string]$package.Commit
|
||||
RemoteBase = $RemoteBase
|
||||
RemoteArchivePath = $remoteArchivePath
|
||||
RemoteScriptPath = $remoteScriptPath
|
||||
ServerHost = $ServerHost
|
||||
SshPort = $SshPort
|
||||
Pm2Name = $Pm2Name
|
||||
Output = ($deployOutput -join [Environment]::NewLine)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user