From baca2fde9d8d74641a447b65abb1efdd5593ac9b Mon Sep 17 00:00:00 2001
From: WuYongJie
Date: Tue, 28 Apr 2026 13:48:32 +0800
Subject: [PATCH 1/2] feat(assembly): add snap basis workflow
---
autocad-plugin/FurnitureExporter/Commands.cs | 24 +-
.../MountPatternAnalyzerTests.cs | 2 +
.../SnapSurfaceBuilderTests.cs | 103 +++++
.../FurnitureExporter.csproj | 2 +-
autocad-plugin/FurnitureExporter/Models.cs | 72 +++-
.../FurnitureExporter/MountPatternAnalyzer.cs | 6 +-
.../FurnitureExporter/SnapSurfaceBuilder.cs | 135 +++++++
autocad-plugin/build-installer.ps1 | 2 +-
.../PackageContents.xml | 10 +-
autocad-plugin/installer/使用说明.txt | 4 +-
.../src/app/assisted-assembly/page.tsx | 6 +-
furniture-3d-system/src/app/page.tsx | 2 +-
.../src/components/3d/HoleOverlay2D.tsx | 75 +++-
.../src/components/3d/hole-viewer-tokens.ts | 12 +
.../components/3d/hole-viewer.constants.ts | 6 +
.../platform/AssemblyWorkbenchV2.tsx | 127 ++++---
.../__tests__/AssemblyWorkbenchV2.test.tsx | 65 +++-
furniture-3d-system/src/lib/3d/engine.ts | 57 ++-
.../src/lib/__tests__/sku-cad-data.test.ts | 60 +++
.../src/lib/cad-export-processor.ts | 27 +-
.../__tests__/assembly-workspace.test.ts | 255 ++++++++++++-
.../assisted-assembly-variant.test.ts | 46 +++
.../lib/open-product/assembly-workspace.ts | 353 ++++++++++++++++--
.../open-product/assisted-assembly-variant.ts | 15 +-
furniture-3d-system/src/lib/sku-cad-data.ts | 100 ++++-
furniture-3d-system/src/lib/storage.ts | 3 +-
.../services/asset-cad-import.service.ts | 18 +-
27 files changed, 1423 insertions(+), 164 deletions(-)
create mode 100644 autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/SnapSurfaceBuilderTests.cs
create mode 100644 autocad-plugin/FurnitureExporter/SnapSurfaceBuilder.cs
create mode 100644 furniture-3d-system/src/lib/open-product/__tests__/assisted-assembly-variant.test.ts
diff --git a/autocad-plugin/FurnitureExporter/Commands.cs b/autocad-plugin/FurnitureExporter/Commands.cs
index a5b97b74..39ece423 100644
--- a/autocad-plugin/FurnitureExporter/Commands.cs
+++ b/autocad-plugin/FurnitureExporter/Commands.cs
@@ -72,6 +72,8 @@ public class Commands
public List Holes { get; } = new();
public List HoleGroups { get; } = new();
public List MountPatterns { get; } = new();
+ public List SnapPatterns { get; } = new();
+ public List SnapSurfaces { get; } = new();
public int LogicalHoleCount { get; set; }
public List EdgeFeatures { get; } = new();
public List ExcludedHoles { get; } = new();
@@ -339,6 +341,7 @@ public class Commands
HoleGrouping.AttachGroupsToPanels(panels, holeGroups);
var mountPatterns = MountPatternAnalyzer.BuildMountPatterns(panels, allHoles, holeGroups);
MountPatternAnalyzer.AttachPatternsToPanels(panels, mountPatterns);
+ var snapSurfaces = SnapSurfaceBuilder.BuildSnapSurfaces(panels);
var logicalHoleCount = HoleGrouping.ComputeLogicalHoleCount(allHoles);
foreach (var hole in allHoles)
@@ -357,6 +360,8 @@ public class Commands
result.Holes.AddRange(allHoles);
result.HoleGroups.AddRange(holeGroups);
result.MountPatterns.AddRange(mountPatterns);
+ result.SnapPatterns.AddRange(mountPatterns);
+ result.SnapSurfaces.AddRange(snapSurfaces);
result.LogicalHoleCount = logicalHoleCount;
result.EdgeFeatures.AddRange(allEdgeFeatures);
result.ExcludedHoles.AddRange(panelAnalyzer.ExcludedHoles);
@@ -689,6 +694,7 @@ public class Commands
HoleGrouping.AttachGroupsToPanels(panels, holeGroups);
var mountPatterns = MountPatternAnalyzer.BuildMountPatterns(panels, allHoles, holeGroups);
MountPatternAnalyzer.AttachPatternsToPanels(panels, mountPatterns);
+ var snapSurfaces = SnapSurfaceBuilder.BuildSnapSurfaces(panels);
var logicalHoleCount = HoleGrouping.ComputeLogicalHoleCount(allHoles);
@@ -736,6 +742,8 @@ public class Commands
exportData.Holes = allHoles;
exportData.HoleGroups = holeGroups;
exportData.MountPatterns = mountPatterns;
+ exportData.SnapPatterns = mountPatterns;
+ exportData.SnapSurfaces = snapSurfaces;
exportData.LogicalHoleCount = logicalHoleCount;
if (panelAnalyzer.ExcludedHoles.Count > 0)
@@ -993,6 +1001,8 @@ public class Commands
exportData.Holes = panelAnalysis.Holes;
exportData.HoleGroups = panelAnalysis.HoleGroups;
exportData.MountPatterns = panelAnalysis.MountPatterns;
+ exportData.SnapPatterns = panelAnalysis.SnapPatterns;
+ exportData.SnapSurfaces = panelAnalysis.SnapSurfaces;
exportData.LogicalHoleCount = panelAnalysis.LogicalHoleCount;
exportData.EdgeFeatures = panelAnalysis.EdgeFeatures;
exportData.PanelCount = panelAnalysis.Panels.Count;
@@ -1032,21 +1042,11 @@ public class Commands
if (mode.AnalyzePanels && panelAnalysis.Panels.Count > 0)
{
- ed.WriteMessage(" [4/5] 生成装配语义...");
- var assemblyAnalyzer = new AssemblySemanticAnalyzer();
- exportData.Assembly = assemblyAnalyzer.Analyze(
- panelAnalysis.Panels,
- panelAnalysis.Holes,
- panelAnalysis.HoleGroups,
- exportData.HardwareTypes);
- var highConfidenceCount = exportData.Assembly.PanelAssemblies.Count(item =>
- item.Confidence >= 0.75 &&
- !string.Equals(item.MountMode, "unknown", StringComparison.OrdinalIgnoreCase));
- ed.WriteMessage($" ✓ ({exportData.Assembly.PanelAssemblies.Count} 个板件语义, {highConfidenceCount} 个高置信)\n");
+ ed.WriteMessage(" [4/5] 跳过旧装配语义... ✓ (single_asset 使用 snapPatterns/snapSurfaces)\n");
}
else
{
- ed.WriteMessage(" [4/5] 生成装配语义... ✓ (当前模式无需生成)\n");
+ ed.WriteMessage(" [4/5] 跳过旧装配语义... ✓ (当前模式无需生成)\n");
}
ed.WriteMessage(" [5/5] 打包文件...");
diff --git a/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/MountPatternAnalyzerTests.cs b/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/MountPatternAnalyzerTests.cs
index b3ad97a4..0f33131f 100644
--- a/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/MountPatternAnalyzerTests.cs
+++ b/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/MountPatternAnalyzerTests.cs
@@ -24,6 +24,8 @@ namespace FurnitureExporter.Tests
Assert.Equal("line_2", pattern.PatternType);
Assert.Equal(2, pattern.MinRequiredHoles);
Assert.Equal(2, pattern.PreferredHoleCount);
+ Assert.Single(panel.SnapPatterns);
+ Assert.Equal(panel.MountPatterns, panel.SnapPatterns);
}
[Fact]
diff --git a/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/SnapSurfaceBuilderTests.cs b/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/SnapSurfaceBuilderTests.cs
new file mode 100644
index 00000000..92043dbf
--- /dev/null
+++ b/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/SnapSurfaceBuilderTests.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using Newtonsoft.Json.Serialization;
+
+namespace FurnitureExporter.Tests
+{
+ public class SnapSurfaceBuilderTests
+ {
+ [Fact]
+ public void BuildSnapSurfaces_FromBoundingBox_CreatesSixAxisFaces()
+ {
+ var panel = CreatePanel("panel_001");
+
+ var surfaces = SnapSurfaceBuilder.BuildSnapSurfaces(new[] { panel });
+
+ Assert.Equal(6, surfaces.Count);
+ Assert.Equal(6, panel.SnapSurfaces.Count);
+
+ var top = Assert.Single(surfaces.Where(surface => surface.Face == "top"));
+ Assert.Equal("panel_001_snap_top", top.Id);
+ Assert.Equal("panel_001", top.PanelId);
+ Assert.Equal(new[] { 0.0, 0.0, 1.0 }, top.Normal);
+ Assert.Equal(new[] { 0.0, 0.0, 18.0 }, top.Center);
+ Assert.Equal(18.0, top.Bounds.Min[2]);
+ Assert.Equal(18.0, top.Bounds.Max[2]);
+ Assert.Equal(1.0, top.Confidence);
+
+ var front = Assert.Single(surfaces.Where(surface => surface.Face == "front"));
+ Assert.Equal(new[] { 0.0, -1.0, 0.0 }, front.Normal);
+ Assert.Equal(-280.0, front.Center[1]);
+ Assert.Equal(-280.0, front.Bounds.Min[1]);
+ Assert.Equal(-280.0, front.Bounds.Max[1]);
+ }
+
+ [Fact]
+ public void ExportData_ForSingleAsset_EmitsSnapFieldsAndOmitsAssembly()
+ {
+ var panel = CreatePanel("panel_001");
+ var pattern = new MountPatternInfo
+ {
+ Id = "panel_001_mount_top_000",
+ PanelId = "panel_001",
+ MountFace = "top",
+ PatternType = "line_2",
+ HoleIds = new[] { "hole_a", "hole_b" },
+ RequiredHoleIds = new[] { "hole_a", "hole_b" },
+ MinRequiredHoles = 2,
+ PreferredHoleCount = 2,
+ Normal = new[] { 0.0, 0.0, 1.0 },
+ Position = new[] { 0.0, 0.0, 18.0 },
+ Confidence = 0.95,
+ };
+ panel.MountPatterns = new List { pattern };
+ panel.SnapPatterns = panel.MountPatterns;
+
+ var exportData = new ExportData
+ {
+ ExportMode = "single_asset",
+ Panels = new List { panel },
+ MountPatterns = new List { pattern },
+ SnapPatterns = new List { pattern },
+ SnapSurfaces = SnapSurfaceBuilder.BuildSnapSurfaces(new[] { panel }),
+ };
+
+ var json = JsonConvert.SerializeObject(
+ exportData,
+ new JsonSerializerSettings
+ {
+ ContractResolver = new CamelCasePropertyNamesContractResolver(),
+ NullValueHandling = NullValueHandling.Ignore,
+ });
+ var root = JObject.Parse(json);
+
+ Assert.Null(root["assembly"]);
+ Assert.DoesNotContain("\"mates\"", json, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("\"connections\"", json, StringComparison.OrdinalIgnoreCase);
+ Assert.Single(Assert.IsType(root["mountPatterns"]));
+ Assert.Single(Assert.IsType(root["snapPatterns"]));
+ Assert.Equal(6, Assert.IsType(root["snapSurfaces"]).Count);
+ var panels = Assert.IsType(root["panels"]);
+ Assert.Equal(6, Assert.IsType(panels[0]?["snapSurfaces"]).Count);
+ }
+
+ private static PanelInfo CreatePanel(string id)
+ {
+ return new PanelInfo
+ {
+ Id = id,
+ Position = new[] { 0.0, 0.0, 9.0 },
+ Normal = new[] { 0.0, 0.0, 1.0 },
+ BoundingBox = new BoundingBox3D
+ {
+ Min = new[] { -300.0, -280.0, 0.0 },
+ Max = new[] { 300.0, 280.0, 18.0 },
+ },
+ Size = new[] { 600.0, 560.0, 18.0 },
+ };
+ }
+ }
+}
diff --git a/autocad-plugin/FurnitureExporter/FurnitureExporter.csproj b/autocad-plugin/FurnitureExporter/FurnitureExporter.csproj
index 912893eb..d7c50877 100644
--- a/autocad-plugin/FurnitureExporter/FurnitureExporter.csproj
+++ b/autocad-plugin/FurnitureExporter/FurnitureExporter.csproj
@@ -11,7 +11,7 @@
FurnitureExporter
FurnitureExporter
- 2.5.0
+ 2.6.0
Furniture 3D System
家具 3D 模型导出插件 - 导出 GLB 模型和五金件位置数据
diff --git a/autocad-plugin/FurnitureExporter/Models.cs b/autocad-plugin/FurnitureExporter/Models.cs
index 15580f2c..27f9e7b1 100644
--- a/autocad-plugin/FurnitureExporter/Models.cs
+++ b/autocad-plugin/FurnitureExporter/Models.cs
@@ -111,14 +111,23 @@ public class ExportData
public List HoleGroups { get; set; } = new();
///
- /// 安装图样(v2.5 新增)
- /// 说明:
- /// - 吸附面先缩小范围;
- /// - HoleGroup 负责把几何孔组织成业务孔位;
- /// - MountPattern 负责定义“至少命中几个孔才允许吸附成立”。
+ /// 安装图样(v2.5 兼容字段)。
+ /// 新方案主字段是 SnapPatterns;这里继续保留给旧 Web 端兼容。
///
public List MountPatterns { get; set; } = new();
+ ///
+ /// 吸附图样(v2.6 新主字段)。
+ /// 内容与 MountPatterns 保持一致,Web 端后续优先读取 snapPatterns。
+ ///
+ public List SnapPatterns { get; set; } = new();
+
+ ///
+ /// 吸附面(v2.6 新增)。
+ /// 每块板件至少提供 6 个轴向外表面,用于辅助装配阶段做面级吸附候选。
+ ///
+ public List SnapSurfaces { get; set; } = new();
+
///
/// 逻辑孔位数量(v2.2.4 新增)
/// 计算方式:以 HoleGroupId 合并计数(HoleGroupId 为空则按 Hole.Id 计数)。
@@ -331,9 +340,19 @@ public class PanelInfo
public List HoleGroups { get; set; } = new();
///
- /// 该板件上的安装图样(v2.5 新增)
+ /// 该板件上的安装图样(v2.5 兼容字段)
///
public List MountPatterns { get; set; } = new();
+
+ ///
+ /// 该板件上的吸附图样(v2.6 新主字段)
+ ///
+ public List SnapPatterns { get; set; } = new();
+
+ ///
+ /// 该板件的吸附面(v2.6 新增)
+ ///
+ public List SnapSurfaces { get; set; } = new();
}
///
@@ -647,6 +666,47 @@ public class MountPatternInfo
public List Warnings { get; set; } = new();
}
+///
+/// 吸附面信息(v2.6 新增)
+///
+public class SnapSurfaceInfo
+{
+ ///
+ /// 吸附面 ID(如 panel_001_snap_top)
+ ///
+ public string Id { get; set; } = "";
+
+ ///
+ /// 面标签:left / right / front / back / bottom / top
+ ///
+ public string Face { get; set; } = "";
+
+ ///
+ /// 所属板件 ID
+ ///
+ public string PanelId { get; set; } = "";
+
+ ///
+ /// 外法向量 [X, Y, Z]
+ ///
+ public double[] Normal { get; set; } = new double[3];
+
+ ///
+ /// 面中心 [X, Y, Z] (mm)
+ ///
+ public double[] Center { get; set; } = new double[3];
+
+ ///
+ /// 面边界框。薄轴最小值和最大值相同,表示该外表面所在平面。
+ ///
+ public BoundingBox3D Bounds { get; set; } = new();
+
+ ///
+ /// 推导置信度(0-1)
+ ///
+ public double Confidence { get; set; }
+}
+
///
/// 3D 边界框
///
diff --git a/autocad-plugin/FurnitureExporter/MountPatternAnalyzer.cs b/autocad-plugin/FurnitureExporter/MountPatternAnalyzer.cs
index 3612634d..73be0879 100644
--- a/autocad-plugin/FurnitureExporter/MountPatternAnalyzer.cs
+++ b/autocad-plugin/FurnitureExporter/MountPatternAnalyzer.cs
@@ -50,6 +50,7 @@ public static class MountPatternAnalyzer
if (units.Count == 0)
{
panel.MountPatterns = new List();
+ panel.SnapPatterns = new List();
continue;
}
@@ -71,6 +72,7 @@ public static class MountPatternAnalyzer
}
panel.MountPatterns = panelPatterns;
+ panel.SnapPatterns = panelPatterns;
patterns.AddRange(panelPatterns);
}
@@ -85,9 +87,11 @@ public static class MountPatternAnalyzer
foreach (var panel in panels)
{
- panel.MountPatterns = map.TryGetValue(panel.Id, out var items)
+ var panelPatterns = map.TryGetValue(panel.Id, out var items)
? items
: new List();
+ panel.MountPatterns = panelPatterns;
+ panel.SnapPatterns = panelPatterns;
}
}
diff --git a/autocad-plugin/FurnitureExporter/SnapSurfaceBuilder.cs b/autocad-plugin/FurnitureExporter/SnapSurfaceBuilder.cs
new file mode 100644
index 00000000..65107276
--- /dev/null
+++ b/autocad-plugin/FurnitureExporter/SnapSurfaceBuilder.cs
@@ -0,0 +1,135 @@
+using System;
+using System.Collections.Generic;
+
+namespace FurnitureExporter;
+
+///
+/// 从板件包围盒推导辅助装配吸附面。
+///
+public static class SnapSurfaceBuilder
+{
+ private static readonly FaceDefinition[] Faces =
+ {
+ new("left", 0, false, new[] { -1.0, 0.0, 0.0 }),
+ new("right", 0, true, new[] { 1.0, 0.0, 0.0 }),
+ new("front", 1, false, new[] { 0.0, -1.0, 0.0 }),
+ new("back", 1, true, new[] { 0.0, 1.0, 0.0 }),
+ new("bottom", 2, false, new[] { 0.0, 0.0, -1.0 }),
+ new("top", 2, true, new[] { 0.0, 0.0, 1.0 }),
+ };
+
+ public static List BuildSnapSurfaces(IReadOnlyList panels)
+ {
+ var allSurfaces = new List();
+
+ foreach (var panel in panels)
+ {
+ var panelSurfaces = BuildPanelSurfaces(panel);
+ panel.SnapSurfaces = panelSurfaces;
+ allSurfaces.AddRange(panelSurfaces);
+ }
+
+ return allSurfaces;
+ }
+
+ private static List BuildPanelSurfaces(PanelInfo panel)
+ {
+ var (min, max, confidence) = ResolveBounds(panel);
+ var surfaces = new List(Faces.Length);
+
+ foreach (var face in Faces)
+ {
+ var center = new[]
+ {
+ Round((min[0] + max[0]) / 2.0),
+ Round((min[1] + max[1]) / 2.0),
+ Round((min[2] + max[2]) / 2.0),
+ };
+
+ var boundsMin = new[] { min[0], min[1], min[2] };
+ var boundsMax = new[] { max[0], max[1], max[2] };
+ var planeValue = face.UseMax ? max[face.Axis] : min[face.Axis];
+ center[face.Axis] = planeValue;
+ boundsMin[face.Axis] = planeValue;
+ boundsMax[face.Axis] = planeValue;
+
+ surfaces.Add(new SnapSurfaceInfo
+ {
+ Id = $"{panel.Id}_snap_{face.Name}",
+ Face = face.Name,
+ PanelId = panel.Id,
+ Normal = face.Normal,
+ Center = center,
+ Bounds = new BoundingBox3D
+ {
+ Min = RoundAll(boundsMin),
+ Max = RoundAll(boundsMax),
+ },
+ Confidence = confidence,
+ });
+ }
+
+ return surfaces;
+ }
+
+ private static (double[] Min, double[] Max, double Confidence) ResolveBounds(PanelInfo panel)
+ {
+ if (panel.BoundingBox?.Min?.Length == 3 && panel.BoundingBox.Max?.Length == 3)
+ {
+ return (RoundAll(panel.BoundingBox.Min), RoundAll(panel.BoundingBox.Max), 1.0);
+ }
+
+ var center = panel.Position?.Length == 3
+ ? panel.Position
+ : new[] { 0.0, 0.0, 0.0 };
+ var size = panel.Size?.Length == 3
+ ? panel.Size
+ : new[] { 0.0, 0.0, 0.0 };
+
+ var min = new[]
+ {
+ center[0] - Math.Abs(size[0]) / 2.0,
+ center[1] - Math.Abs(size[1]) / 2.0,
+ center[2] - Math.Abs(size[2]) / 2.0,
+ };
+ var max = new[]
+ {
+ center[0] + Math.Abs(size[0]) / 2.0,
+ center[1] + Math.Abs(size[1]) / 2.0,
+ center[2] + Math.Abs(size[2]) / 2.0,
+ };
+
+ return (RoundAll(min), RoundAll(max), 0.6);
+ }
+
+ private static double[] RoundAll(IReadOnlyList values)
+ {
+ return new[]
+ {
+ Round(values[0]),
+ Round(values[1]),
+ Round(values[2]),
+ };
+ }
+
+ private static double Round(double value)
+ {
+ return Math.Round(value, 2);
+ }
+
+ private sealed class FaceDefinition
+ {
+ public FaceDefinition(string name, int axis, bool useMax, double[] normal)
+ {
+ Name = name;
+ Axis = axis;
+ UseMax = useMax;
+ Normal = normal;
+ }
+
+ public string Name { get; }
+ public int Axis { get; }
+ public bool UseMax { get; }
+ public double[] Normal { get; }
+ }
+}
diff --git a/autocad-plugin/build-installer.ps1 b/autocad-plugin/build-installer.ps1
index 4f65e023..8bb9be84 100644
--- a/autocad-plugin/build-installer.ps1
+++ b/autocad-plugin/build-installer.ps1
@@ -49,7 +49,7 @@ Write-Host ""
# 路径设置
$PluginName = "FurnitureExporter"
-$Version = "2.5.0"
+$Version = "2.6.0"
$SourceDir = Join-Path $PSScriptRoot "FurnitureExporter"
$InstallerDir = Join-Path $PSScriptRoot "installer"
$OutputDir = Join-Path $PSScriptRoot "dist"
diff --git a/autocad-plugin/installer/FurnitureExporter.bundle/PackageContents.xml b/autocad-plugin/installer/FurnitureExporter.bundle/PackageContents.xml
index a4a35657..2bdec4dd 100644
--- a/autocad-plugin/installer/FurnitureExporter.bundle/PackageContents.xml
+++ b/autocad-plugin/installer/FurnitureExporter.bundle/PackageContents.xml
@@ -3,12 +3,12 @@
SchemaVersion="1.0"
AutodeskProduct="AutoCAD"
Name="FurnitureExporter"
- Description="家具导出插件 v2.5.0 - 支持整套兼容导出与资产中心单资产导出"
+ Description="家具导出插件 v2.6.0 - 支持整套兼容导出与资产中心单资产导出"
Author="Furniture 3D System"
- AppVersion="2.5.0"
- FriendlyVersion="2.5.0"
+ AppVersion="2.6.0"
+ FriendlyVersion="2.6.0"
ProductType="Application"
- Version="2.5.0"
+ Version="2.6.0"
ProductCode="{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}">
>
-}) {
+} = {}) {
const { authUser } = await requireSession('/login')
const headerStore = await headers()
const requestOrigin = resolveRequestOrigin(headerStore)
@@ -551,12 +551,12 @@ export default async function AssistedAssemblyPage({
variant.uvConfig,
)
const runtimeThumbnailUrl =
- materialConfig && variant.modelUrl
+ variant.modelUrl
? buildAssetThumbnailImageUrl(requestOrigin, {
modelUrl: variant.modelUrl,
assetType: variant.assetType as AssetThumbnailRenderAssetType,
expiresAt: buildAssetThumbnailExpiryTimestamp(),
- materialConfig,
+ materialConfig: materialConfig ?? null,
})
: variant.thumbnailUrl
diff --git a/furniture-3d-system/src/app/page.tsx b/furniture-3d-system/src/app/page.tsx
index 9a1396e4..74aa3640 100644
--- a/furniture-3d-system/src/app/page.tsx
+++ b/furniture-3d-system/src/app/page.tsx
@@ -13,7 +13,7 @@ export default async function HomePage({
searchParams,
}: {
searchParams?: Promise>
-}) {
+} = {}) {
const { user, authUser } = await requireSession('/login')
const resolvedSearchParams = searchParams ? await searchParams : undefined
const initialTab = parseHomeWorkspaceTab(resolvedSearchParams?.tab)
diff --git a/furniture-3d-system/src/components/3d/HoleOverlay2D.tsx b/furniture-3d-system/src/components/3d/HoleOverlay2D.tsx
index c394489b..833c12e4 100644
--- a/furniture-3d-system/src/components/3d/HoleOverlay2D.tsx
+++ b/furniture-3d-system/src/components/3d/HoleOverlay2D.tsx
@@ -31,9 +31,12 @@ interface ResolvedPanelMarker {
}
interface ResolvedHoleMarker {
+ holeId: string;
panelId: string;
targetObject: THREE.Object3D;
localPosition: THREE.Vector3;
+ holeType: string | null;
+ hardwareMatched?: boolean;
}
interface ResolveStrictHoleMarkersOptions {
@@ -52,6 +55,15 @@ interface ScreenPanelMarker {
labelY: number;
}
+interface ScreenHoleMarker {
+ panelId: string;
+ holeId: string;
+ holeType: string | null;
+ hardwareMatched?: boolean;
+ x: number;
+ y: number;
+}
+
interface PanelMarkerForDraw {
panelId: string;
label: string;
@@ -114,6 +126,47 @@ function normalizeNodeKey(value: string): string {
.replace(/[\s_-]/g, "");
}
+function getHoleMarkerPalette(marker: Pick) {
+ switch (marker.holeType) {
+ case "assembly-candidate":
+ return {
+ fill: "rgba(71, 100, 107, 0.30)",
+ stroke: "rgba(71, 100, 107, 0.95)",
+ inner: "rgba(245, 249, 249, 0.96)",
+ };
+ case "assembly-active":
+ return {
+ fill: "rgba(0, 141, 116, 0.34)",
+ stroke: "rgba(0, 141, 116, 0.98)",
+ inner: "rgba(245, 255, 252, 0.96)",
+ };
+ case "assembly-used":
+ return {
+ fill: "rgba(154, 129, 120, 0.28)",
+ stroke: "rgba(154, 129, 120, 0.86)",
+ inner: "rgba(255, 250, 247, 0.92)",
+ };
+ case "assembly-unused":
+ return {
+ fill: "rgba(143, 161, 166, 0.22)",
+ stroke: "rgba(143, 161, 166, 0.72)",
+ inner: "rgba(249, 251, 251, 0.90)",
+ };
+ default:
+ return marker.hardwareMatched
+ ? {
+ fill: "rgba(154, 129, 120, 0.28)",
+ stroke: "rgba(154, 129, 120, 0.86)",
+ inner: "rgba(255, 250, 247, 0.92)",
+ }
+ : {
+ fill: "rgba(0, 184, 150, 0.35)",
+ stroke: "rgba(0, 141, 116, 0.92)",
+ inner: "rgba(255, 255, 255, 0.94)",
+ };
+ }
+}
+
function registerPanelAlias(
aliasMap: Map,
alias: string,
@@ -696,9 +749,12 @@ export function resolveStrictHoleMarkers({
);
return {
+ holeId: hole.id,
panelId: resolvedHolePanelId,
targetObject: panelState.targetObject,
localPosition: panelState.targetObject.worldToLocal(finalWorld.clone()),
+ holeType: hole.holeType ?? null,
+ ...(hole.hardwareMatched !== undefined ? { hardwareMatched: hole.hardwareMatched } : {}),
};
})
.filter((value): value is ResolvedHoleMarker => value !== null);
@@ -1164,8 +1220,7 @@ export function HoleOverlay2D({
let holeCenterSumZ = 0;
let holeCenterCount = 0;
- const projectedHoleMarkers: Array<{ panelId: string; x: number; y: number }> =
- [];
+ const projectedHoleMarkers: ScreenHoleMarker[] = [];
resolvedHoleMarkers.forEach((marker, markerIndex) => {
const worldPosition = resolveWorldAnchor(
marker.targetObject,
@@ -1201,6 +1256,9 @@ export function HoleOverlay2D({
projectedHoleMarkers.push({
panelId: marker.panelId,
+ holeId: marker.holeId,
+ holeType: marker.holeType,
+ hardwareMatched: marker.hardwareMatched,
x: Number(projected.x.toFixed(2)),
y: Number(projected.y.toFixed(2)),
});
@@ -1446,15 +1504,22 @@ export function HoleOverlay2D({
rightMarkers.forEach(drawLeader);
projectedHoleMarkers.forEach((marker) => {
+ const palette = getHoleMarkerPalette(marker);
+
context.beginPath();
- context.fillStyle = "rgba(255, 255, 255, 0.94)";
+ context.fillStyle = palette.inner;
context.arc(marker.x, marker.y, 2, 0, Math.PI * 2);
context.fill();
context.beginPath();
- context.strokeStyle = "rgba(0, 141, 116, 0.92)";
+ context.fillStyle = palette.fill;
+ context.arc(marker.x, marker.y, 4.6, 0, Math.PI * 2);
+ context.fill();
+
+ context.beginPath();
+ context.strokeStyle = palette.stroke;
context.lineWidth = 1.2;
- context.arc(marker.x, marker.y, 4, 0, Math.PI * 2);
+ context.arc(marker.x, marker.y, 4.8, 0, Math.PI * 2);
context.stroke();
});
diff --git a/furniture-3d-system/src/components/3d/hole-viewer-tokens.ts b/furniture-3d-system/src/components/3d/hole-viewer-tokens.ts
index 06f35b02..76d422a0 100644
--- a/furniture-3d-system/src/components/3d/hole-viewer-tokens.ts
+++ b/furniture-3d-system/src/components/3d/hole-viewer-tokens.ts
@@ -8,6 +8,12 @@ export const HOLE_COLOR_TOKENS = {
screw: '#9C27B0',
dowel: '#795548',
cable: '#607D8B',
+ 'assembly-unused': '#8FA1A6',
+ 'assembly-candidate': '#47646B',
+ 'assembly-active': '#008D74',
+ 'assembly-used': '#9A8178',
+ 'assembly-snap-source': '#47646B',
+ 'assembly-snap-target': '#008D74',
unknown: '#E91E63',
},
selection: {
@@ -36,6 +42,12 @@ export const HOLE_TYPE_COLORS: Record = {
screw: toThreeHex(HOLE_COLOR_TOKENS.holeTypes.screw),
dowel: toThreeHex(HOLE_COLOR_TOKENS.holeTypes.dowel),
cable: toThreeHex(HOLE_COLOR_TOKENS.holeTypes.cable),
+ 'assembly-unused': toThreeHex(HOLE_COLOR_TOKENS.holeTypes['assembly-unused']),
+ 'assembly-candidate': toThreeHex(HOLE_COLOR_TOKENS.holeTypes['assembly-candidate']),
+ 'assembly-active': toThreeHex(HOLE_COLOR_TOKENS.holeTypes['assembly-active']),
+ 'assembly-used': toThreeHex(HOLE_COLOR_TOKENS.holeTypes['assembly-used']),
+ 'assembly-snap-source': toThreeHex(HOLE_COLOR_TOKENS.holeTypes['assembly-snap-source']),
+ 'assembly-snap-target': toThreeHex(HOLE_COLOR_TOKENS.holeTypes['assembly-snap-target']),
unknown: toThreeHex(HOLE_COLOR_TOKENS.holeTypes.unknown),
}
diff --git a/furniture-3d-system/src/components/3d/hole-viewer.constants.ts b/furniture-3d-system/src/components/3d/hole-viewer.constants.ts
index 71f86215..b9e9c3ee 100644
--- a/furniture-3d-system/src/components/3d/hole-viewer.constants.ts
+++ b/furniture-3d-system/src/components/3d/hole-viewer.constants.ts
@@ -5,6 +5,12 @@ export const HOLE_TYPE_COLORS: Record = {
screw: 0x9C27B0,
dowel: 0x795548,
cable: 0x607D8B,
+ 'assembly-unused': 0x8FA1A6,
+ 'assembly-candidate': 0x47646B,
+ 'assembly-active': 0x008D74,
+ 'assembly-used': 0x9A8178,
+ 'assembly-snap-source': 0x47646B,
+ 'assembly-snap-target': 0x008D74,
unknown: 0xE91E63,
}
diff --git a/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx b/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx
index 7b7ec223..4da09667 100644
--- a/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx
+++ b/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx
@@ -241,60 +241,102 @@ export function AssemblyWorkbenchV2({
return [activeSnapCandidate.targetNodeId]
}, [activeSnapCandidate])
const holeOverlayState = useMemo(() => {
- if (!selectedSingleNode || !activeSnapCandidate) {
- return {
- holes: [] as HoleOverlayData[],
- activeHolePanelIds: [] as string[],
- panelNodeNameByPanelId: {} as Record,
- panelLabelByPanelId: {} as Record,
+ const holesById = new Map()
+ const activeHolePanelIdSet = new Set()
+ const panelNodeNameByPanelId: Record = {}
+ const panelLabelByPanelId: Record = {}
+
+ const registerHole = (
+ hole: HoleOverlayData,
+ nodeId: string,
+ label: string,
+ ) => {
+ holesById.set(hole.id, hole)
+ if (hole.panelId) {
+ panelNodeNameByPanelId[hole.panelId] = nodeId
+ panelLabelByPanelId[hole.panelId] = label
}
}
- 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,
- )
+ const appendCandidateHoles = (
+ candidate: NonNullable,
+ sourceNode: AssemblyWorkspaceNode,
+ targetNode: AssemblyWorkspaceNode | null,
+ holeType: string,
+ confidence = 1,
+ ) => {
+ if (candidate.snapMode === 'surface') return
- return {
- holes: [
+ const sourcePanelId = buildSnapHolePanelId(candidate.sourceNodeId, candidate.sourceHoleId)
+ const targetPanelId = buildSnapHolePanelId(candidate.targetNodeId, candidate.targetHoleId)
+ if (holeType === 'assembly-active' || holeType === 'assembly-candidate') {
+ activeHolePanelIdSet.add(sourcePanelId)
+ activeHolePanelIdSet.add(targetPanelId)
+ }
+
+ registerHole(
{
id: sourcePanelId,
panelId: sourcePanelId,
- position: activeSnapCandidate.sourceHoleWorldPosition,
- diameter: activeSnapCandidate.sourceHoleDiameter ?? 8,
- depth: activeSnapCandidate.sourceHoleDepth ?? 12,
- normal: activeSnapCandidate.sourceHoleNormal,
- holeType: 'assembly-snap-source',
- confidence: 1,
+ position: candidate.sourceHoleWorldPosition,
+ diameter: candidate.sourceHoleDiameter ?? 8,
+ depth: candidate.sourceHoleDepth ?? 12,
+ normal: candidate.sourceHoleNormal,
+ holeType,
+ confidence,
+ hardwareMatched: holeType === 'assembly-used',
},
+ candidate.sourceNodeId,
+ sourceNode.name,
+ )
+ registerHole(
{
id: targetPanelId,
panelId: targetPanelId,
- position: activeSnapCandidate.targetHoleWorldPosition,
- diameter: activeSnapCandidate.targetHoleDiameter ?? 8,
- depth: activeSnapCandidate.targetHoleDepth ?? 12,
- normal: activeSnapCandidate.targetHoleNormal,
- holeType: 'assembly-snap-target',
- confidence: 1,
+ position: candidate.targetHoleWorldPosition,
+ diameter: candidate.targetHoleDiameter ?? 8,
+ depth: candidate.targetHoleDepth ?? 12,
+ normal: candidate.targetHoleNormal,
+ holeType,
+ confidence,
+ hardwareMatched: holeType === 'assembly-used',
},
- ],
- activeHolePanelIds: [sourcePanelId, targetPanelId],
- panelNodeNameByPanelId: {
- [sourcePanelId]: selectedSingleNode.nodeId,
- [targetPanelId]: activeSnapCandidate.targetNodeId,
- },
- panelLabelByPanelId: {
- [sourcePanelId]: selectedSingleNode.name,
- [targetPanelId]: targetNode?.name ?? activeSnapCandidate.targetNodeId,
- },
+ candidate.targetNodeId,
+ targetNode?.name ?? candidate.targetNodeId,
+ )
}
- }, [activeSnapCandidate, presentWorkspace.nodes, selectedSingleNode])
+
+ presentWorkspace.nodes.forEach((node) => {
+ if (!node.snapBinding) return
+ const displayCandidate = deriveAssemblyActiveSnapCandidate(presentWorkspace, node.nodeId)
+ const targetNode =
+ displayCandidate
+ ? presentWorkspace.nodes.find((item) => item.nodeId === displayCandidate.targetNodeId) ?? null
+ : null
+ if (displayCandidate) {
+ appendCandidateHoles(displayCandidate, node, targetNode, 'assembly-used', 0.72)
+ }
+ })
+
+ if (selectedSingleNode && activeSnapCandidate) {
+ const targetNode =
+ presentWorkspace.nodes.find((node) => node.nodeId === activeSnapCandidate.targetNodeId) ?? null
+ appendCandidateHoles(
+ activeSnapCandidate,
+ selectedSingleNode,
+ targetNode,
+ selectedSingleNode.isSnapped ? 'assembly-active' : 'assembly-candidate',
+ 1,
+ )
+ }
+
+ return {
+ holes: Array.from(holesById.values()),
+ activeHolePanelIds: Array.from(activeHolePanelIdSet),
+ panelNodeNameByPanelId,
+ panelLabelByPanelId,
+ }
+ }, [activeSnapCandidate, presentWorkspace, selectedSingleNode])
const activeAssetBrowserItems = useMemo(() => {
if (!activeAssetBrowserSource) return []
return presentWorkspace.inventory.filter((item) => item.source === activeAssetBrowserSource)
@@ -323,6 +365,7 @@ export function AssemblyWorkbenchV2({
return presentWorkspace.nodes.map((node) => ({
nodeName: node.nodeId,
modelUrl: node.modelUrl,
+ expectedDimensions: node.dimensions ?? null,
position: node.position,
rotation: node.rotation,
scale: node.scale,
@@ -900,7 +943,7 @@ export function AssemblyWorkbenchV2({
key={workspaceKey ?? 'assisted-assembly-workspace'}
modelUrl={null}
worldNodes={stageWorldNodes.length > 0 ? stageWorldNodes : undefined}
- worldFrameMode="initial"
+ worldFrameMode="always"
title={title ?? candidateName}
customViewportContent={stageWorldNodes.length === 0 ? : undefined}
customHasContent={stageWorldNodes.length === 0}
diff --git a/furniture-3d-system/src/components/platform/__tests__/AssemblyWorkbenchV2.test.tsx b/furniture-3d-system/src/components/platform/__tests__/AssemblyWorkbenchV2.test.tsx
index b6a23461..2c06bf5a 100644
--- a/furniture-3d-system/src/components/platform/__tests__/AssemblyWorkbenchV2.test.tsx
+++ b/furniture-3d-system/src/components/platform/__tests__/AssemblyWorkbenchV2.test.tsx
@@ -73,7 +73,10 @@ jest.mock('../ModelPreviewPanel', () => {
onNodeSelect,
onNodeContextMenu,
}: {
- worldNodes?: Array<{ nodeName: string }>
+ worldNodes?: Array<{
+ nodeName: string
+ expectedDimensions?: Record | null
+ }>
worldFrameMode?: 'always' | 'initial' | 'never'
selectedNodeName?: string | null
materialAssignments?: Array<{ nodeName: string }>
@@ -105,6 +108,9 @@ jest.mock('../ModelPreviewPanel', () => {
return (
世界节点:{worldNodes.length}
+
+ {JSON.stringify(worldNodes.map((node) => node.expectedDimensions ?? null))}
+
{worldFrameMode ?? 'none'}
{selectedNodeName ?? 'none'}
材质指派:{materialAssignments.length}
@@ -222,7 +228,7 @@ function buildVariants(): AssistedAssemblyVariant[] {
modelUrl: '/board.glb',
thumbnailUrl: null,
runtimeThumbnailUrl: '/board-material.png',
- dimensions: {},
+ dimensions: { length: 1200, width: 600, thickness: 25 },
mountPoints: [],
assemblyBasis: null,
materialConfig: {
@@ -246,7 +252,7 @@ function buildVariants(): AssistedAssemblyVariant[] {
status: 'AVAILABLE',
modelUrl: '/leg.glb',
thumbnailUrl: null,
- dimensions: {},
+ dimensions: { length: 50, width: 50, height: 720 },
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
assemblyBasis: {
attachTo: 'desktop',
@@ -491,6 +497,16 @@ async function openBoardBrowserAndLoadFirstVariant(actor: ReturnType
) {
+ await actor.click(screen.getByRole('button', { name: '打开组件物料' }))
+ await waitFor(() => {
+ expect(screen.getByTestId('asset-browser-panel')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '选择桌腿A资产族' })).toBeInTheDocument()
+ })
+ await actor.click(screen.getByRole('button', { name: '选择桌腿A资产族' }))
+ await actor.click(screen.getByRole('button', { name: '加载桌腿A-001到 3D 世界' }))
+}
+
describe('AssemblyWorkbenchV2', () => {
it('默认显示统一 3D 世界,并在工具条网格下方提供资产分类入口', async () => {
const actor = userEvent.setup()
@@ -498,7 +514,7 @@ describe('AssemblyWorkbenchV2', () => {
render(右侧业务区 } />)
expect(screen.getByTestId('world-node-count')).toHaveTextContent('世界节点:0')
- expect(screen.getByTestId('world-frame-mode')).toHaveTextContent('initial')
+ expect(screen.getByTestId('world-frame-mode')).toHaveTextContent('always')
expect(screen.getByRole('button', { name: '打开板材物料' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '打开组件物料' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '打开五金物料' })).toBeDisabled()
@@ -560,6 +576,41 @@ describe('AssemblyWorkbenchV2', () => {
expect(latestSnapshot.nodes[0]?.materialConfig?.id).toBe('finish-oak')
})
+ it('连续点击多个规格款会追加到同一个 3D 世界,并保持追加后重新构图', async () => {
+ const actor = userEvent.setup()
+ const onChange = jest.fn()
+
+ render(
+ 右侧业务区}
+ />,
+ )
+
+ await openBoardBrowserAndLoadFirstVariant(actor)
+ await openLegBrowserAndLoadFirstVariant(actor)
+
+ await waitFor(() => {
+ expect(screen.getByTestId('world-node-count')).toHaveTextContent('世界节点:2')
+ expect(screen.getByTestId('world-node-dimensions')).toHaveTextContent(
+ JSON.stringify([
+ { length: 1200, width: 600, thickness: 25 },
+ { length: 50, width: 50, height: 720 },
+ ]),
+ )
+ expect(screen.getByTestId('world-frame-mode')).toHaveTextContent('always')
+ expect(screen.getByTestId('selected-node')).not.toHaveTextContent('none')
+ })
+
+ const latestSnapshot = onChange.mock.calls.at(-1)?.[0] as AssemblyWorkspaceSnapshot
+ expect(latestSnapshot.nodes).toHaveLength(2)
+ expect(latestSnapshot.nodes.map((node) => node.variantId)).toEqual([
+ 'variant-board-1',
+ 'variant-leg-1',
+ ])
+ expect(latestSnapshot.nodes[1]?.zone).toBe('staging')
+ })
+
it('右键菜单可以移除 3D 世界里的模型', async () => {
const actor = userEvent.setup()
const onChange = jest.fn()
@@ -628,7 +679,7 @@ describe('AssemblyWorkbenchV2', () => {
})
})
- it('选中缺少安装图样语义的节点时,会明确提示无法自动吸附', async () => {
+ it('选中缺少吸附图样语义的节点时,会明确提示无法自动吸附', async () => {
const actor = userEvent.setup()
render(
@@ -641,8 +692,8 @@ describe('AssemblyWorkbenchV2', () => {
await actor.click(screen.getByRole('button', { name: '模拟选中第二节点' }))
await waitFor(() => {
- expect(screen.getByText('缺少安装图样语义')).toBeInTheDocument()
- expect(screen.getByText('当前资产缺少安装图样语义,无法自动吸附。')).toBeInTheDocument()
+ expect(screen.getByText('缺少吸附图样语义')).toBeInTheDocument()
+ expect(screen.getByText('当前资产缺少吸附图样语义,无法自动吸附。')).toBeInTheDocument()
})
})
diff --git a/furniture-3d-system/src/lib/3d/engine.ts b/furniture-3d-system/src/lib/3d/engine.ts
index 28567e0e..254cb7c7 100644
--- a/furniture-3d-system/src/lib/3d/engine.ts
+++ b/furniture-3d-system/src/lib/3d/engine.ts
@@ -73,6 +73,7 @@ export type EngineWorldNodeSource =
export interface EngineWorldNode {
nodeName: string;
modelUrl?: string | null;
+ expectedDimensions?: ModelExpectedDimensions | null;
position?: [number, number, number];
rotation?: [number, number, number];
scale?: [number, number, number];
@@ -597,8 +598,12 @@ export class ThreeDEngine {
const fov = this.camera.fov * (Math.PI / 180);
let distance = Math.abs(maxDim / 2 / Math.tan(fov / 2));
distance *= 1.8;
- const cameraDistance =
- distance * Math.sqrt(0.9 * 0.9 + 0.6 * 0.6 + 1.1 * 1.1);
+ const cameraOffset = new THREE.Vector3(
+ distance * 1.02,
+ distance * 0.74,
+ distance * 1.18,
+ );
+ const cameraDistance = cameraOffset.length();
this.camera.near = Math.max(0.01, cameraDistance * 0.0002);
this.camera.far = cameraDistance * 20;
this.camera.updateProjectionMatrix();
@@ -611,7 +616,7 @@ export class ThreeDEngine {
});
}
- this.camera.position.set(distance * 0.9, distance * 0.6, distance * 1.1);
+ this.camera.position.copy(cameraTarget).add(cameraOffset);
this.camera.lookAt(cameraTarget);
this.controls.target.copy(cameraTarget);
const minDistance = Math.max(
@@ -820,6 +825,33 @@ export class ThreeDEngine {
return groundedRoot;
}
+ private applyExpectedDimensionsScale(
+ model: THREE.Object3D,
+ expectedDimensions?: ModelExpectedDimensions | null,
+ ): void {
+ const box = new THREE.Box3().setFromObject(model);
+ if (box.isEmpty()) {
+ return;
+ }
+
+ const sourceSize = box.getSize(new THREE.Vector3());
+ const unitScale = resolveModelUnitScaleFromBounds(
+ {
+ x: sourceSize.x,
+ y: sourceSize.y,
+ z: sourceSize.z,
+ },
+ expectedDimensions,
+ );
+
+ if (unitScale === 1) {
+ return;
+ }
+
+ model.scale.multiplyScalar(unitScale);
+ model.updateMatrixWorld(true);
+ }
+
/**
* 鍔犺浇glTF妯″瀷
* @param url 妯″瀷鏂囦欢URL
@@ -829,24 +861,8 @@ export class ThreeDEngine {
options: EngineModelLoadOptions = {},
): Promise {
const { model, extension } = await this.loadModelAsset(url);
+ this.applyExpectedDimensionsScale(model, options.expectedDimensions);
let box = new THREE.Box3().setFromObject(model);
- if (!box.isEmpty()) {
- const sourceSize = box.getSize(new THREE.Vector3());
- const unitScale = resolveModelUnitScaleFromBounds(
- {
- x: sourceSize.x,
- y: sourceSize.y,
- z: sourceSize.z,
- },
- options.expectedDimensions,
- );
-
- if (unitScale !== 1) {
- model.scale.multiplyScalar(unitScale);
- model.updateMatrixWorld(true);
- box = new THREE.Box3().setFromObject(model);
- }
- }
if (SELECTION_DEBUG_ENABLED) {
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
@@ -909,6 +925,7 @@ export class ThreeDEngine {
let content: THREE.Object3D;
if (node.modelUrl?.trim()) {
const { model } = await this.loadModelAsset(node.modelUrl.trim());
+ this.applyExpectedDimensionsScale(model, node.expectedDimensions);
content = this.buildWorkbenchNodeContent(model);
} else {
content = this.createWorkbenchProxy(source);
diff --git a/furniture-3d-system/src/lib/__tests__/sku-cad-data.test.ts b/furniture-3d-system/src/lib/__tests__/sku-cad-data.test.ts
index 2fc27204..70c0bbe0 100644
--- a/furniture-3d-system/src/lib/__tests__/sku-cad-data.test.ts
+++ b/furniture-3d-system/src/lib/__tests__/sku-cad-data.test.ts
@@ -84,6 +84,66 @@ describe('sku-cad-data', () => {
requiredHoleIds: ['hole-top-1'],
}),
)
+ expect(result?.snapPatterns[0]).toEqual(
+ expect.objectContaining({
+ id: 'pattern-top-1',
+ snapFace: 'bottom',
+ holeIds: ['hole-top-1'],
+ }),
+ )
+ })
+
+ it('优先保留 snapPatterns 和 snapSurfaces,并把旧 mountPatterns 映射为兼容字段', () => {
+ const result = buildPersistedSkuCadData({
+ pluginVersion: '2.7.0',
+ units: 'mm',
+ snapPatterns: [
+ {
+ id: 'snap-pattern-1',
+ panelId: 'panel-top',
+ snapFace: 'bottom',
+ patternType: 'four-corner',
+ holeIds: ['hole-top-1'],
+ requiredHoleIds: ['hole-top-1'],
+ minRequiredHoles: 1,
+ preferredHoleCount: 1,
+ position: [0, 0, 0],
+ normal: [0, -1, 0],
+ },
+ ],
+ snapSurfaces: [
+ {
+ id: 'panel-top-bottom',
+ panelId: 'panel-top',
+ face: 'bottom',
+ center: [0, -9, 0],
+ normal: [0, -1, 0],
+ bounds: {
+ min: [-600, -9, -300],
+ max: [600, -9, 300],
+ },
+ confidence: 0.92,
+ },
+ ],
+ })
+
+ expect(result).not.toBeNull()
+ expect(result?.snapPatterns[0]).toEqual(
+ expect.objectContaining({
+ id: 'snap-pattern-1',
+ mountFace: 'bottom',
+ snapFace: 'bottom',
+ }),
+ )
+ expect(result?.mountPatterns[0]?.id).toBe('snap-pattern-1')
+ expect(result?.snapSurfaces[0]).toEqual(
+ expect.objectContaining({
+ id: 'panel-top-bottom',
+ panelId: 'panel-top',
+ face: 'bottom',
+ confidence: 0.92,
+ }),
+ )
})
it('从 SKU 属性读取时,会归一化 CAD 底稿和装配语义快照', () => {
diff --git a/furniture-3d-system/src/lib/cad-export-processor.ts b/furniture-3d-system/src/lib/cad-export-processor.ts
index e73e5a94..dd26bef3 100644
--- a/furniture-3d-system/src/lib/cad-export-processor.ts
+++ b/furniture-3d-system/src/lib/cad-export-processor.ts
@@ -30,6 +30,8 @@ export interface CADExportData {
panels?: CADPanel[]
holes?: CADHole[]
holeGroups?: CADHoleGroup[]
+ snapPatterns?: CADMountPattern[]
+ snapSurfaces?: CADSnapSurface[]
mountPatterns?: CADMountPattern[]
/**
* 被过滤/排除的孔位(兜底用)。
@@ -112,6 +114,7 @@ export interface CADMountPattern {
id: string
panelId: string
mountFace: string
+ snapFace?: string
patternType: string
holeIds: string[]
requiredHoleIds?: string[]
@@ -125,6 +128,20 @@ export interface CADMountPattern {
warnings?: string[]
}
+export interface CADSnapSurface {
+ id: string
+ panelId: string
+ face: string
+ normal: number[]
+ center: number[]
+ bounds?: {
+ min: number[]
+ max: number[]
+ }
+ confidence?: number
+ source?: string
+}
+
export interface CADSuggestedFastener {
category: string
nominalDiameter?: number
@@ -255,6 +272,8 @@ export interface ProcessingResult {
coordinateSystem?: string
panels: ProcessedPanel[]
holeGroups: CADHoleGroup[]
+ snapPatterns: CADMountPattern[]
+ snapSurfaces: CADSnapSurface[]
mountPatterns: CADMountPattern[]
hardware: ProcessedHardware[]
statistics: {
@@ -460,6 +479,8 @@ export function processCADExport(data: CADExportData): ProcessingResult {
coordinateSystem: data.coordinateSystem,
panels: [],
holeGroups: [],
+ snapPatterns: [],
+ snapSurfaces: [],
mountPatterns: [],
hardware: [],
statistics: {
@@ -489,6 +510,8 @@ export function processCADExport(data: CADExportData): ProcessingResult {
const totalHoles = panels.reduce((sum, p) => sum + p.holes.length, 0)
const totalHardwareCount = hardware.reduce((sum, h) => sum + h.count, 0)
+ const snapPatterns = data.snapPatterns ?? data.mountPatterns ?? []
+ const mountPatterns = data.mountPatterns ?? data.snapPatterns ?? []
return {
success: true,
@@ -497,7 +520,9 @@ export function processCADExport(data: CADExportData): ProcessingResult {
coordinateSystem: data.coordinateSystem,
panels,
holeGroups: data.holeGroups ?? [],
- mountPatterns: data.mountPatterns ?? [],
+ snapPatterns,
+ snapSurfaces: data.snapSurfaces ?? [],
+ mountPatterns,
hardware,
statistics: {
totalPanels: panels.length,
diff --git a/furniture-3d-system/src/lib/open-product/__tests__/assembly-workspace.test.ts b/furniture-3d-system/src/lib/open-product/__tests__/assembly-workspace.test.ts
index 055943a8..c6b9a46d 100644
--- a/furniture-3d-system/src/lib/open-product/__tests__/assembly-workspace.test.ts
+++ b/furniture-3d-system/src/lib/open-product/__tests__/assembly-workspace.test.ts
@@ -204,6 +204,21 @@ describe('assembly-workspace', () => {
},
}
+ const convertMountPatternsToSnapPatterns = (
+ variant: OpenProductAssetVariant,
+ ): OpenProductAssetVariant => {
+ const assemblyBasis = variant.assemblyBasis ?? {}
+ const { mountPatterns, ...basisWithoutLegacyPatterns } = assemblyBasis
+
+ return {
+ ...variant,
+ assemblyBasis: {
+ ...basisWithoutLegacyPatterns,
+ snapPatterns: mountPatterns,
+ },
+ }
+ }
+
it('会先以资产族库存打开工作台,而不是直接生成候选节点', () => {
expect(baseWorkspace.inventory).toHaveLength(2)
expect(baseWorkspace.nodes).toHaveLength(0)
@@ -233,6 +248,51 @@ describe('assembly-workspace', () => {
expect(snapshot.nodes[0]?.source).toBe('desktop')
})
+ it('追加支撑件时默认落在桌面板装配目标附近,而不是远处待装区', () => {
+ const withDesktop = instantiateAssemblyNode(baseWorkspace, {
+ inventoryId: 'desktop:family-desktop:1',
+ variant: desktopVariant,
+ })
+ const withLeg = instantiateAssemblyNode(withDesktop, {
+ inventoryId: 'leg:family-leg:2',
+ variant: legVariant,
+ })
+
+ expect(withLeg.nodes).toHaveLength(2)
+ expect(withLeg.nodes[1]?.zone).toBe('staging')
+ expect(withLeg.nodes[1]?.position).toEqual([-450, 360, 250])
+ expect(withLeg.nodes[1]?.targetPosition).toEqual([-450, 360, 250])
+ })
+
+ it('同一个工作区可以同时保留多个规格款实例', () => {
+ const withDesktop = instantiateAssemblyNode(baseWorkspace, {
+ inventoryId: 'desktop:family-desktop:1',
+ variant: desktopVariant,
+ })
+ const withFirstLeg = instantiateAssemblyNode(withDesktop, {
+ inventoryId: 'leg:family-leg:2',
+ variant: legVariant,
+ })
+ const withSecondLeg = instantiateAssemblyNode(withFirstLeg, {
+ inventoryId: 'leg:family-leg:2',
+ variant: {
+ ...legVariant,
+ id: 'variant-leg-wide',
+ code: 'LEG-A-WIDE-001',
+ name: '宽桌腿A',
+ },
+ })
+
+ expect(withSecondLeg.nodes).toHaveLength(3)
+ expect(new Set(withSecondLeg.nodes.map((node) => node.nodeId)).size).toBe(3)
+ expect(withSecondLeg.nodes.map((node) => node.variantId)).toEqual([
+ 'variant-desktop',
+ 'variant-leg',
+ 'variant-leg-wide',
+ ])
+ expect(withSecondLeg.inventory.find((item) => item.source === 'leg')?.instanceCount).toBe(2)
+ })
+
it('主装配区只有 1 个桌面板且支撑件吸附后,才允许保存', () => {
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
inventoryId: 'desktop:family-desktop:1',
@@ -274,6 +334,34 @@ describe('assembly-workspace', () => {
expect(deriveAssemblySaveValidation(snapped).level).toBe('PASSED')
})
+ it('同时存在目标位和孔位候选时,松手吸附优先使用孔位绑定', () => {
+ const withDesktop = instantiateAssemblyNode(baseWorkspace, {
+ inventoryId: 'desktop:family-desktop:1',
+ variant: desktopVariant,
+ })
+ const withLeg = instantiateAssemblyNode(withDesktop, {
+ inventoryId: 'leg:family-leg:2',
+ variant: legVariant,
+ })
+
+ expect(withLeg.nodes[1]?.targetPosition).toEqual([-450, 360, 250])
+
+ const moved = updateAssemblyNodeTransform(withLeg, withLeg.nodes[1]!.nodeId, {
+ position: [0, 720, 0],
+ })
+ const snapped = attemptSnapNode(moved, withLeg.nodes[1]!.nodeId)
+
+ expect(snapped.nodes[1]?.position).toEqual([0, 720, 0])
+ expect(snapped.nodes[1]?.position).not.toEqual(snapped.nodes[1]?.targetPosition)
+ expect(snapped.nodes[1]?.snapStatus).toBe('hole-snap')
+ expect(snapped.nodes[1]?.snapBinding).toMatchObject({
+ sourceHoleId: 'leg-hole-1',
+ targetHoleId: 'board-hole-1',
+ sourceMountPatternId: 'leg-pattern-1',
+ targetMountPatternId: 'board-pattern-1',
+ })
+ })
+
it('拖到兼容孔位附近并松手后,会完成自动吸附', () => {
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
inventoryId: 'desktop:family-desktop:1',
@@ -363,7 +451,7 @@ describe('assembly-workspace', () => {
expect(released.nodes[1]?.snapStatus ?? null).toBeNull()
})
- it('缺少孔位和安装图样语义的组件不会再回退到旧的中心点吸附', () => {
+ it('缺少孔位和吸附图样语义的组件不会再回退到旧的中心点吸附', () => {
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
inventoryId: 'desktop:family-desktop:1',
variant: desktopVariant,
@@ -385,7 +473,65 @@ describe('assembly-workspace', () => {
const feedback = deriveAssemblyAssistFeedback(moved, moved.nodes[1]!.nodeId)
expect(feedback).toMatchObject({
- title: '缺少孔位与安装图样语义',
+ title: '缺少孔位与吸附图样语义',
+ canAutoSnap: false,
+ withinSnapRange: false,
+ })
+ })
+
+ it('缺少孔位或缺少吸附图样时会给出明确状态且不会崩溃', () => {
+ const withDesktop = instantiateAssemblyNode(baseWorkspace, {
+ inventoryId: 'desktop:family-desktop:1',
+ variant: desktopVariant,
+ })
+ const withMissingPatternLeg = instantiateAssemblyNode(withDesktop, {
+ inventoryId: 'leg:family-leg:2',
+ variant: {
+ ...legVariant,
+ id: 'variant-leg-missing-pattern',
+ assemblyBasis: {
+ ...legVariant.assemblyBasis,
+ mountPatterns: [],
+ },
+ },
+ })
+ const missingPatternFeedback = deriveAssemblyAssistFeedback(
+ withMissingPatternLeg,
+ withMissingPatternLeg.nodes[1]!.nodeId,
+ )
+
+ expect(deriveAssemblyAutoSnapCandidate(
+ withMissingPatternLeg,
+ withMissingPatternLeg.nodes[1]!.nodeId,
+ )).toBeNull()
+ expect(missingPatternFeedback).toMatchObject({
+ title: '缺少吸附图样语义',
+ canAutoSnap: false,
+ withinSnapRange: false,
+ })
+
+ const withMissingHoleLeg = instantiateAssemblyNode(withDesktop, {
+ inventoryId: 'leg:family-leg:2',
+ variant: {
+ ...legVariant,
+ id: 'variant-leg-missing-hole',
+ assemblyBasis: {
+ ...legVariant.assemblyBasis,
+ holes: [],
+ },
+ },
+ })
+ const missingHoleFeedback = deriveAssemblyAssistFeedback(
+ withMissingHoleLeg,
+ withMissingHoleLeg.nodes[1]!.nodeId,
+ )
+
+ expect(deriveAssemblyAutoSnapCandidate(
+ withMissingHoleLeg,
+ withMissingHoleLeg.nodes[1]!.nodeId,
+ )).toBeNull()
+ expect(missingHoleFeedback).toMatchObject({
+ title: '缺少孔位语义',
canAutoSnap: false,
withinSnapRange: false,
})
@@ -549,6 +695,28 @@ describe('assembly-workspace', () => {
expect(activeCandidate?.targetNodeId).toBe(withDesktop.nodes[0]?.nodeId)
})
+ it('assemblyBasis.snapPatterns 新字段可以替代旧 mountPatterns 参与孔位吸附', () => {
+ const withDesktop = instantiateAssemblyNode(baseWorkspace, {
+ inventoryId: 'desktop:family-desktop:1',
+ variant: convertMountPatternsToSnapPatterns(desktopVariant),
+ })
+ const withLeg = instantiateAssemblyNode(withDesktop, {
+ inventoryId: 'leg:family-leg:2',
+ variant: convertMountPatternsToSnapPatterns(legVariant),
+ })
+
+ const moved = updateAssemblyNodeTransform(withLeg, withLeg.nodes[1]!.nodeId, {
+ position: [0, 720, 0],
+ })
+ const candidate = deriveAssemblyAutoSnapCandidate(moved, moved.nodes[1]!.nodeId)
+
+ expect(candidate).not.toBeNull()
+ expect(candidate?.sourceMountPatternId).toBe('leg-pattern-1')
+ expect(candidate?.targetMountPatternId).toBe('board-pattern-1')
+ expect(candidate?.sourceHoleId).toBe('leg-hole-1')
+ expect(candidate?.targetHoleId).toBe('board-hole-1')
+ })
+
it('CAD 坐标系孔位会先转成 Three.js 坐标,再参与孔位吸附候选', () => {
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
inventoryId: 'desktop:family-desktop:1',
@@ -571,7 +739,7 @@ describe('assembly-workspace', () => {
expect(candidate?.targetMountPatternId).toBe('board-pattern-1')
})
- it('两个支撑件抢同一安装孔位 / 图样时,会判定空间冲突并阻止保存', () => {
+ it('手动形成两个支撑件占用同一吸附孔位 / 图样时,会判定空间冲突并阻止保存', () => {
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
inventoryId: 'desktop:family-desktop:1',
variant: desktopVariant,
@@ -600,16 +768,35 @@ describe('assembly-workspace', () => {
inventoryId: 'leg:family-leg:2',
variant: secondLegVariant,
})
- const secondSnapped = attemptSnapNode(
- applyAutomaticAssemblySnapPreview(
- updateAssemblyNodeTransform(withSecondLeg, withSecondLeg.nodes[2]!.nodeId, {
- position: [0, 720, 0],
- }),
- withSecondLeg.nodes[2]!.nodeId,
- { fallbackZone: 'staging' },
+ const secondSnapped = {
+ ...withSecondLeg,
+ nodes: withSecondLeg.nodes.map((node) =>
+ node.nodeId === withSecondLeg.nodes[2]!.nodeId
+ ? {
+ ...node,
+ zone: 'main' as const,
+ isSnapped: true,
+ snapStatus: 'hole-snap' as const,
+ snapBinding: {
+ targetNodeId: withSecondLeg.nodes[0]!.nodeId,
+ sourceHoleId: 'leg-hole-1',
+ targetHoleId: 'board-hole-1',
+ sourceHolePanelId: 'leg-panel',
+ targetHolePanelId: 'board-panel',
+ sourceFace: 'top',
+ targetFace: 'bottom',
+ sourceMountPatternId: 'leg-pattern-1',
+ targetMountPatternId: 'board-pattern-1',
+ snapMode: 'hole' as const,
+ sourceSnapPatternId: 'leg-pattern-1',
+ targetSnapPatternId: 'board-pattern-1',
+ sourceSurfaceId: null,
+ targetSurfaceId: null,
+ },
+ }
+ : node,
),
- withSecondLeg.nodes[2]!.nodeId,
- )
+ }
const saveValidation = deriveAssemblySaveValidation(secondSnapped)
expect(secondSnapped.nodes[1]?.snapBinding?.targetHoleId).toBe('board-hole-1')
@@ -618,4 +805,48 @@ describe('assembly-workspace', () => {
expect.arrayContaining([expect.stringContaining('空间冲突')]),
)
})
+
+ it('已占用的目标孔位和吸附图样不会再次生成自动吸附候选', () => {
+ const withDesktop = instantiateAssemblyNode(baseWorkspace, {
+ inventoryId: 'desktop:family-desktop:1',
+ variant: desktopVariant,
+ })
+ const firstLeg = instantiateAssemblyNode(withDesktop, {
+ inventoryId: 'leg:family-leg:2',
+ variant: legVariant,
+ })
+ const firstSnapped = attemptSnapNode(
+ updateAssemblyNodeTransform(firstLeg, firstLeg.nodes[1]!.nodeId, {
+ position: [0, 720, 0],
+ }),
+ firstLeg.nodes[1]!.nodeId,
+ )
+ const withSecondLeg = instantiateAssemblyNode(firstSnapped, {
+ inventoryId: 'leg:family-leg:2',
+ variant: {
+ ...legVariant,
+ id: 'variant-leg-conflict',
+ code: 'LEG-A-CONFLICT-001',
+ name: '冲突桌腿',
+ },
+ })
+ const secondMoved = updateAssemblyNodeTransform(withSecondLeg, withSecondLeg.nodes[2]!.nodeId, {
+ position: [0, 720, 0],
+ })
+ const secondCandidate = deriveAssemblyAutoSnapCandidate(secondMoved, secondMoved.nodes[2]!.nodeId)
+ const secondPreview = applyAutomaticAssemblySnapPreview(
+ secondMoved,
+ secondMoved.nodes[2]!.nodeId,
+ { fallbackZone: 'staging' },
+ )
+
+ expect(firstSnapped.nodes[1]?.snapBinding).toMatchObject({
+ targetHoleId: 'board-hole-1',
+ targetMountPatternId: 'board-pattern-1',
+ })
+ expect(secondCandidate).toBeNull()
+ expect(secondPreview.nodes[2]?.zone).toBe('staging')
+ expect(secondPreview.nodes[2]?.snapBinding ?? null).toBeNull()
+ expect(secondPreview.nodes[2]?.snapStatus ?? null).toBeNull()
+ })
})
diff --git a/furniture-3d-system/src/lib/open-product/__tests__/assisted-assembly-variant.test.ts b/furniture-3d-system/src/lib/open-product/__tests__/assisted-assembly-variant.test.ts
new file mode 100644
index 00000000..bc7bc3b6
--- /dev/null
+++ b/furniture-3d-system/src/lib/open-product/__tests__/assisted-assembly-variant.test.ts
@@ -0,0 +1,46 @@
+import {
+ getAssistedAssemblyVariantThumbnailSources,
+ getAssistedAssemblyVariantThumbnailUrl,
+ type AssistedAssemblyVariant,
+} from '../assisted-assembly-variant'
+
+describe('assisted assembly variant thumbnails', () => {
+ it('falls back from generated thumbnails to material preview assets', () => {
+ const variant = {
+ runtimeThumbnailUrl: '/internal/runtime.png',
+ thumbnailUrl: null,
+ materialConfig: {
+ id: 'finish-oak',
+ name: '暖白橡木',
+ categoryId: 'wood',
+ thumbnailUrl: '/materials/thumbnails/oak.jpg',
+ textureUrl: '/materials/pbr/oak/oak_col.jpg',
+ },
+ } as AssistedAssemblyVariant
+
+ expect(getAssistedAssemblyVariantThumbnailUrl(variant)).toBe('/internal/runtime.png')
+ expect(getAssistedAssemblyVariantThumbnailSources(variant)).toEqual([
+ '/internal/runtime.png',
+ '/materials/thumbnails/oak.jpg',
+ '/materials/pbr/oak/oak_col.jpg',
+ ])
+ })
+
+ it('uses material thumbnails when the variant has no generated thumbnail', () => {
+ const variant = {
+ thumbnailUrl: null,
+ runtimeThumbnailUrl: null,
+ materialConfig: {
+ id: 'finish-walnut',
+ name: '胡桃木',
+ categoryId: 'wood',
+ thumbnailUrl: '/materials/thumbnails/walnut.jpg',
+ },
+ } as AssistedAssemblyVariant
+
+ expect(getAssistedAssemblyVariantThumbnailUrl(variant)).toBe('/materials/thumbnails/walnut.jpg')
+ expect(getAssistedAssemblyVariantThumbnailSources(variant)).toEqual([
+ '/materials/thumbnails/walnut.jpg',
+ ])
+ })
+})
diff --git a/furniture-3d-system/src/lib/open-product/assembly-workspace.ts b/furniture-3d-system/src/lib/open-product/assembly-workspace.ts
index ced8ce08..c688a5ad 100644
--- a/furniture-3d-system/src/lib/open-product/assembly-workspace.ts
+++ b/furniture-3d-system/src/lib/open-product/assembly-workspace.ts
@@ -20,6 +20,7 @@ export type AssemblyRelationType = 'hole-group' | 'surface' | 'connector'
export type AssemblyRelationStatus = 'CONNECTED' | 'SUGGESTED' | 'PENDING'
export type AssemblySnapStatus = 'hole-preview' | 'hole-snap' | 'manual-unverified'
+export type AssemblySnapMode = 'hole' | 'surface'
export interface WeakSlotAssignment {
role: string
@@ -78,6 +79,11 @@ export interface AssemblySnapBinding {
targetFace: string | null
sourceMountPatternId: string | null
targetMountPatternId: string | null
+ snapMode?: AssemblySnapMode
+ sourceSnapPatternId?: string | null
+ targetSnapPatternId?: string | null
+ sourceSurfaceId?: string | null
+ targetSurfaceId?: string | null
}
export interface AssemblySubassembly {
@@ -125,8 +131,13 @@ export interface AssemblyAssistFeedback {
export interface AssemblyAutoSnapCandidate {
sourceNodeId: string
targetNodeId: string
+ snapMode?: AssemblySnapMode
sourceMountPatternId: string
targetMountPatternId: string
+ sourceSnapPatternId?: string | null
+ targetSnapPatternId?: string | null
+ sourceSurfaceId?: string | null
+ targetSurfaceId?: string | null
sourceHoleId: string
targetHoleId: string
sourceHolePanelId: string | null
@@ -217,6 +228,8 @@ 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 SURFACE_SNAP_DISTANCE_THRESHOLD_MM = 96
+const SURFACE_NORMAL_ALIGNMENT_DOT_MAX = -0.55
const SPACE_CONFLICT_DISTANCE_THRESHOLD_MM = 60
const DEFAULT_DESKTOP_POSITION: [number, number, number] = [0, 720, 0]
@@ -262,6 +275,19 @@ interface AssemblyBasisMountPatternRecord {
minRequiredHoles: number
}
+interface AssemblyBasisSnapSurfaceRecord {
+ id: string
+ panelId: string | null
+ face: string | null
+ normal: [number, number, number]
+ center: [number, number, number]
+ bounds: {
+ min: [number, number, number]
+ max: [number, number, number]
+ } | null
+ confidence: number | null
+}
+
interface AssemblyMatchedHolePair {
sourceHole: AssemblyBasisHoleRecord
targetHole: AssemblyBasisHoleRecord
@@ -330,6 +356,15 @@ function readRecordArray(record: Record | null | undefined, key
: []
}
+function readSnapFaceValue(record: Record | null | undefined) {
+ return (
+ readStringValue(record, 'snapFace') ??
+ readStringValue(record, 'mountFace') ??
+ readStringValue(record, 'face') ??
+ readStringValue(record, 'surfaceFace')
+ )
+}
+
function readVector3Tuple(value: unknown): [number, number, number] | null {
if (!Array.isArray(value) || value.length < 3) {
return null
@@ -346,6 +381,17 @@ function readVector3Tuple(value: unknown): [number, number, number] | null {
return [x, y, z]
}
+function readBoundsTuple(value: unknown): { min: [number, number, number]; max: [number, number, number] } | null {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return null
+ }
+
+ const record = value as Record
+ const min = readVector3Tuple(record.min)
+ const max = readVector3Tuple(record.max)
+ return min && max ? { min, max } : null
+}
+
function normalizeVector3Tuple(
value: [number, number, number],
fallback: [number, number, number],
@@ -471,7 +517,7 @@ function buildAssemblyHoleGroups(node: AssemblyWorkspaceNode) {
return {
id,
panelId: readStringValue(record, 'panelId'),
- mountFace: readStringValue(record, 'mountFace'),
+ mountFace: readSnapFaceValue(record),
surfaceFaces: normalizeSurfaceFaces(record.surfaceFaces),
}
})
@@ -481,7 +527,13 @@ function buildAssemblyHoleGroups(node: AssemblyWorkspaceNode) {
}
function buildAssemblyMountPatterns(node: AssemblyWorkspaceNode): AssemblyBasisMountPatternRecord[] {
- return readRecordArray(node.assemblyBasis, 'mountPatterns')
+ const snapPatternRecords = readRecordArray(node.assemblyBasis, 'snapPatterns')
+ const patternRecords =
+ snapPatternRecords.length > 0
+ ? snapPatternRecords
+ : readRecordArray(node.assemblyBasis, 'mountPatterns')
+
+ return patternRecords
.map((record) => {
const id = readStringValue(record, 'id')
const holeIds = readStringList(record.holeIds)
@@ -497,7 +549,7 @@ function buildAssemblyMountPatterns(node: AssemblyWorkspaceNode): AssemblyBasisM
return {
id,
panelId: readStringValue(record, 'panelId'),
- mountFace: readStringValue(record, 'mountFace'),
+ mountFace: readSnapFaceValue(record),
holeIds,
minRequiredHoles: Math.max(1, Math.min(holeIds.length, Math.round(requestedMinRequired))),
}
@@ -509,6 +561,34 @@ function getNodeAssemblyMountPatterns(node: AssemblyWorkspaceNode) {
return buildAssemblyMountPatterns(node)
}
+function getNodeAssemblySnapSurfaces(node: AssemblyWorkspaceNode): AssemblyBasisSnapSurfaceRecord[] {
+ const applyCadTransform = shouldApplyCadHoleTransform(node)
+
+ return readRecordArray(node.assemblyBasis, 'snapSurfaces')
+ .map((record) => {
+ const id = readStringValue(record, 'id')
+ const rawCenter = readVector3Tuple(record.center ?? record.position)
+ const rawNormal = readVector3Tuple(record.normal)
+ if (!id || !rawCenter || !rawNormal) {
+ return null
+ }
+
+ const center = applyCadTransform ? transformCadVector3Tuple(rawCenter) : rawCenter
+ const normal = applyCadTransform ? transformCadVector3Tuple(rawNormal) : rawNormal
+
+ return {
+ id,
+ panelId: readStringValue(record, 'panelId'),
+ face: readSnapFaceValue(record),
+ center,
+ normal: normalizeVector3Tuple(normal, [0, 1, 0]),
+ bounds: readBoundsTuple(record.bounds),
+ confidence: readNumberValue(record, 'confidence'),
+ }
+ })
+ .filter((item): item is AssemblyBasisSnapSurfaceRecord => item !== null)
+}
+
function getNodeAssemblyHoles(node: AssemblyWorkspaceNode): AssemblyBasisHoleRecord[] {
const groupById = buildAssemblyHoleGroups(node)
const applyCadTransform = shouldApplyCadHoleTransform(node)
@@ -619,9 +699,68 @@ function buildSnapBindingFromCandidate(candidate: AssemblyAutoSnapCandidate): As
targetFace: candidate.targetFace,
sourceMountPatternId: candidate.sourceMountPatternId,
targetMountPatternId: candidate.targetMountPatternId,
+ snapMode: candidate.snapMode ?? 'hole',
+ sourceSnapPatternId: candidate.sourceSnapPatternId ?? candidate.sourceMountPatternId,
+ targetSnapPatternId: candidate.targetSnapPatternId ?? candidate.targetMountPatternId,
+ sourceSurfaceId: candidate.sourceSurfaceId ?? null,
+ targetSurfaceId: candidate.targetSurfaceId ?? null,
}
}
+interface OccupiedSnapTargets {
+ holeIds: Set
+ patternIds: Set
+ surfaceIds: Set
+}
+
+function createOccupiedSnapTargets(): OccupiedSnapTargets {
+ return {
+ holeIds: new Set(),
+ patternIds: new Set(),
+ surfaceIds: new Set(),
+ }
+}
+
+function getOccupiedSnapTargets(
+ occupiedTargetsByNodeId: Map,
+ targetNodeId: string,
+) {
+ return occupiedTargetsByNodeId.get(targetNodeId) ?? createOccupiedSnapTargets()
+}
+
+function buildOccupiedSnapTargetsByNodeId(
+ snapshot: AssemblyWorkspaceSnapshot,
+ movingNodeId: string,
+): Map {
+ const result = new Map()
+
+ snapshot.nodes.forEach((node) => {
+ if (node.nodeId === movingNodeId || node.zone !== 'main' || !node.snapBinding?.targetNodeId) {
+ return
+ }
+
+ const targetNodeId = node.snapBinding.targetNodeId
+ const targets = result.get(targetNodeId) ?? createOccupiedSnapTargets()
+ targets.holeIds.add(node.snapBinding.targetHoleId)
+
+ const targetPatternId =
+ node.snapBinding.targetSnapPatternId ??
+ node.snapBinding.targetMountPatternId ??
+ null
+ if (targetPatternId) {
+ targets.patternIds.add(targetPatternId)
+ }
+
+ if (node.snapBinding.targetSurfaceId) {
+ targets.surfaceIds.add(node.snapBinding.targetSurfaceId)
+ }
+
+ result.set(targetNodeId, targets)
+ })
+
+ return result
+}
+
function resolveSnapBindingPattern(
node: AssemblyWorkspaceNode,
holeId: string,
@@ -674,8 +813,13 @@ function buildSnapBindingDisplayCandidate(
return {
sourceNodeId: sourceNode.nodeId,
targetNodeId: targetNode.nodeId,
+ snapMode: binding.snapMode ?? 'hole',
sourceMountPatternId: sourcePattern?.id ?? binding.sourceMountPatternId ?? sourceHole.id,
targetMountPatternId: targetPattern?.id ?? binding.targetMountPatternId ?? targetHole.id,
+ sourceSnapPatternId: binding.sourceSnapPatternId ?? binding.sourceMountPatternId ?? sourcePattern?.id ?? null,
+ targetSnapPatternId: binding.targetSnapPatternId ?? binding.targetMountPatternId ?? targetPattern?.id ?? null,
+ sourceSurfaceId: binding.sourceSurfaceId ?? null,
+ targetSurfaceId: binding.targetSurfaceId ?? null,
sourceHoleId: sourceHole.id,
targetHoleId: targetHole.id,
sourceHolePanelId: binding.sourceHolePanelId ?? sourceHole.panelId,
@@ -781,8 +925,8 @@ export function deriveAssemblyAssistFeedback(
if (snapSupport.missingMountPatterns && snapSupport.missingHoles) {
return {
nodeId,
- title: '缺少孔位与安装图样语义',
- description: '当前资产缺少孔位与安装图样语义,无法自动吸附。',
+ title: '缺少孔位与吸附图样语义',
+ description: '当前资产缺少孔位与吸附图样语义,无法自动吸附。',
tone: 'warning',
targetNodeId: primaryAnchorNode.nodeId,
targetLabel: primaryAnchorNode.name,
@@ -795,8 +939,8 @@ export function deriveAssemblyAssistFeedback(
if (snapSupport.missingMountPatterns) {
return {
nodeId,
- title: '缺少安装图样语义',
- description: '当前资产缺少安装图样语义,无法自动吸附。',
+ title: '缺少吸附图样语义',
+ description: '当前资产缺少吸附图样语义,无法自动吸附。',
tone: 'warning',
targetNodeId: primaryAnchorNode.nodeId,
targetLabel: primaryAnchorNode.name,
@@ -873,50 +1017,169 @@ export function deriveAssemblyAssistFeedback(
}
}
-export function deriveAssemblyAutoSnapCandidate(
+function deriveAssemblySurfaceSnapCandidate(
snapshot: AssemblyWorkspaceSnapshot,
- nodeId: string,
+ sourceNode: AssemblyWorkspaceNode,
+ occupiedTargetsByNodeId: Map,
): AssemblyAutoSnapCandidate | null {
- const sourceNode = snapshot.nodes.find((node) => node.nodeId === nodeId) ?? null
- if (!sourceNode || sourceNode.source === 'desktop' || !canUseHoleAutoSnap(sourceNode)) {
+ const sourceSurfaces = getNodeAssemblySnapSurfaces(sourceNode)
+ if (sourceSurfaces.length === 0) {
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]))
+ const targetSurfaces = getNodeAssemblySnapSurfaces(targetNode)
+ if (targetSurfaces.length === 0) {
+ return
+ }
- 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
- }
+ const occupiedTargets = getOccupiedSnapTargets(occupiedTargetsByNodeId, targetNode.nodeId)
- targetPatterns.forEach((targetPattern) => {
- const targetPatternHoles = targetPattern.holeIds
- .map((holeId) => targetHoleMap.get(holeId) ?? null)
- .filter((hole): hole is AssemblyBasisHoleRecord => hole !== null)
- if (targetPatternHoles.length === 0) {
+ sourceSurfaces.forEach((sourceSurface) => {
+ const sourceCenterWorld = getNodeWorldPoint(sourceNode, sourceSurface.center)
+ const sourceNormalWorld = getNodeWorldDirection(sourceNode, sourceSurface.normal)
+
+ targetSurfaces.forEach((targetSurface) => {
+ if (occupiedTargets.surfaceIds.has(targetSurface.id)) {
return
}
- sourcePatternHoles.forEach((anchorSourceHole) => {
- targetPatternHoles.forEach((anchorTargetHole) => {
+ const facePair = resolveCompatibleFacePair(
+ sourceSurface.face ? [sourceSurface.face] : [],
+ targetSurface.face ? [targetSurface.face] : [],
+ )
+ if (!facePair) {
+ return
+ }
+
+ const targetCenterWorld = getNodeWorldPoint(targetNode, targetSurface.center)
+ const targetNormalWorld = getNodeWorldDirection(targetNode, targetSurface.normal)
+ if (sourceNormalWorld.dot(targetNormalWorld) > SURFACE_NORMAL_ALIGNMENT_DOT_MAX) {
+ return
+ }
+
+ const distanceMm = sourceCenterWorld.distanceTo(targetCenterWorld)
+ if (distanceMm > SURFACE_SNAP_DISTANCE_THRESHOLD_MM) {
+ return
+ }
+
+ const translation = targetCenterWorld.clone().sub(sourceCenterWorld)
+ const previewPosition = tupleFromVector(
+ new THREE.Vector3(sourceNode.position[0], sourceNode.position[1], sourceNode.position[2]).add(
+ translation,
+ ),
+ )
+ const confidence = Math.min(
+ sourceSurface.confidence ?? 1,
+ targetSurface.confidence ?? 1,
+ )
+ const candidate: AssemblyAutoSnapCandidate = {
+ sourceNodeId: sourceNode.nodeId,
+ targetNodeId: targetNode.nodeId,
+ snapMode: 'surface',
+ sourceMountPatternId: sourceSurface.id,
+ targetMountPatternId: targetSurface.id,
+ sourceSnapPatternId: sourceSurface.id,
+ targetSnapPatternId: targetSurface.id,
+ sourceSurfaceId: sourceSurface.id,
+ targetSurfaceId: targetSurface.id,
+ sourceHoleId: `surface:${sourceSurface.id}`,
+ targetHoleId: `surface:${targetSurface.id}`,
+ sourceHolePanelId: sourceSurface.panelId,
+ targetHolePanelId: targetSurface.panelId,
+ sourceHoleDiameter: null,
+ targetHoleDiameter: null,
+ sourceHoleDepth: null,
+ targetHoleDepth: null,
+ sourceFace: facePair.sourceFace,
+ targetFace: facePair.targetFace,
+ matchedHoleCount: 0,
+ requiredHoleCount: 0,
+ previewPosition,
+ previewRotation: cloneVector3(sourceNode.rotation),
+ sourceHoleWorldPosition: tupleFromVector(sourceCenterWorld.clone().add(translation)),
+ targetHoleWorldPosition: tupleFromVector(targetCenterWorld),
+ sourceHoleNormal: tupleFromVector(sourceNormalWorld),
+ targetHoleNormal: tupleFromVector(targetNormalWorld),
+ distanceMm,
+ isValid: confidence >= 0.5,
+ rejectReason: confidence >= 0.5 ? null : 'surface_confidence_low',
+ }
+
+ if (!bestCandidate || candidate.distanceMm < bestCandidate.distanceMm) {
+ bestCandidate = candidate
+ }
+ })
+ })
+ })
+
+ return bestCandidate
+}
+
+export function deriveAssemblyAutoSnapCandidate(
+ snapshot: AssemblyWorkspaceSnapshot,
+ nodeId: string,
+): AssemblyAutoSnapCandidate | null {
+ const sourceNode = snapshot.nodes.find((node) => node.nodeId === nodeId) ?? null
+ if (!sourceNode || sourceNode.source === 'desktop') {
+ return null
+ }
+
+ const occupiedTargetsByNodeId = buildOccupiedSnapTargetsByNodeId(snapshot, sourceNode.nodeId)
+ const sourcePatterns = getNodeAssemblyMountPatterns(sourceNode)
+ const sourceHoleMap = new Map(getNodeAssemblyHoles(sourceNode).map((hole) => [hole.id, hole]))
+ let bestCandidate: AssemblyAutoSnapCandidate | null = null
+
+ if (canUseHoleAutoSnap(sourceNode)) {
+ 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]))
+ const occupiedTargets = getOccupiedSnapTargets(occupiedTargetsByNodeId, targetNode.nodeId)
+
+ 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) => {
+ if (occupiedTargets.patternIds.has(targetPattern.id)) {
+ return
+ }
+
+ const targetPatternHoles = targetPattern.holeIds
+ .map((holeId) => targetHoleMap.get(holeId) ?? null)
+ .filter(
+ (hole): hole is AssemblyBasisHoleRecord =>
+ hole !== null && !occupiedTargets.holeIds.has(hole.id),
+ )
+ if (targetPatternHoles.length === 0) {
+ return
+ }
+
+ sourcePatternHoles.forEach((anchorSourceHole) => {
+ targetPatternHoles.forEach((anchorTargetHole) => {
if (!areHoleDiametersCompatible(anchorSourceHole, anchorTargetHole)) {
return
}
@@ -1023,8 +1286,11 @@ export function deriveAssemblyAutoSnapCandidate(
const candidate: AssemblyAutoSnapCandidate = {
sourceNodeId: sourceNode.nodeId,
targetNodeId: targetNode.nodeId,
+ snapMode: 'hole',
sourceMountPatternId: sourcePattern.id,
targetMountPatternId: targetPattern.id,
+ sourceSnapPatternId: sourcePattern.id,
+ targetSnapPatternId: targetPattern.id,
sourceHoleId: firstPair.sourceHole.id,
targetHoleId: firstPair.targetHole.id,
sourceHolePanelId: firstPair.sourceHole.panelId,
@@ -1061,8 +1327,9 @@ export function deriveAssemblyAutoSnapCandidate(
})
})
})
+ }
- return bestCandidate
+ return bestCandidate ?? deriveAssemblySurfaceSnapCandidate(snapshot, sourceNode, occupiedTargetsByNodeId)
}
function buildInventoryId(source: Exclude, familyId: string, index: number) {
@@ -1204,14 +1471,18 @@ function deriveAssemblySpaceConflictIssues(nodes: AssemblyWorkspaceNode[]) {
(
currentNode.snapBinding?.targetHoleId === otherNode.snapBinding?.targetHoleId ||
(
- currentNode.snapBinding?.targetMountPatternId &&
- currentNode.snapBinding.targetMountPatternId ===
- otherNode.snapBinding?.targetMountPatternId
+ (currentNode.snapBinding?.targetSnapPatternId ?? currentNode.snapBinding?.targetMountPatternId) &&
+ (currentNode.snapBinding?.targetSnapPatternId ?? currentNode.snapBinding?.targetMountPatternId) ===
+ (otherNode.snapBinding?.targetSnapPatternId ?? otherNode.snapBinding?.targetMountPatternId)
+ ) ||
+ (
+ currentNode.snapBinding?.targetSurfaceId &&
+ currentNode.snapBinding.targetSurfaceId === otherNode.snapBinding?.targetSurfaceId
)
)
if (sharesSnapTarget) {
- issues.add(`${currentNode.name} 与 ${otherNode.name} 占用了同一安装孔位 / 图样,存在空间冲突`)
+ issues.add(`${currentNode.name} 与 ${otherNode.name} 占用了同一吸附孔位 / 图样,存在空间冲突`)
continue
}
@@ -1637,7 +1908,7 @@ export function deriveAssemblyValidation(input: {
warnings.push(`${node.name} 缺少装配规则,当前只能预览,不能稳定保存`)
}
if (node.snapStatus === 'manual-unverified') {
- warnings.push(`${node.name} 仍处于手动未核验状态,需要完成孔位 / 安装图样核验`)
+ warnings.push(`${node.name} 仍处于手动未核验状态,需要完成孔位 / 吸附图样核验`)
} else if (node.snapStatus === 'hole-preview') {
warnings.push(`${node.name} 当前只是孔位预吸附预览,松手确认后才算连接`)
} else if (!node.isSnapped) {
@@ -1749,10 +2020,16 @@ export function instantiateAssemblyNode(
(node) => node.zone === 'main' && node.source === 'desktop',
)
const nodeZone: AssemblyZone = isDesktop && !hasMainDesktop ? 'main' : 'staging'
+ const anchorOffset = isDesktop ? null : buildAnchorOffset(inventoryItem.source, sourceIndex)
+ const assemblyTargetPosition = anchorOffset
+ ? addVector3(getDesktopReferencePosition(snapshot.nodes), anchorOffset)
+ : null
const initialPosition =
nodeZone === 'main'
? cloneVector3(DEFAULT_DESKTOP_POSITION)
- : buildStagingPosition(snapshot.nodes.length, inventoryItem.source)
+ : !isDesktop && assemblyTargetPosition
+ ? cloneVector3(assemblyTargetPosition)
+ : buildStagingPosition(snapshot.nodes.length, inventoryItem.source)
const baseRole = isDesktop ? 'desktop' : inventoryItem.source
const nextNodes: AssemblyWorkspaceNode[] = [
@@ -1781,7 +2058,7 @@ export function instantiateAssemblyNode(
parentRolePath: isDesktop ? null : 'desktop',
instanceIndex: sourceIndex,
},
- targetPosition: isDesktop ? cloneVector3(DEFAULT_DESKTOP_POSITION) : null,
+ targetPosition: isDesktop ? cloneVector3(DEFAULT_DESKTOP_POSITION) : assemblyTargetPosition,
targetRotation: [0, 0, 0] as [number, number, number],
isSnapped: isDesktop && nodeZone === 'main',
snapStatus: (isDesktop && nodeZone === 'main' ? 'hole-snap' : null) as AssemblySnapStatus | null,
diff --git a/furniture-3d-system/src/lib/open-product/assisted-assembly-variant.ts b/furniture-3d-system/src/lib/open-product/assisted-assembly-variant.ts
index b7cd4de5..699cbd54 100644
--- a/furniture-3d-system/src/lib/open-product/assisted-assembly-variant.ts
+++ b/furniture-3d-system/src/lib/open-product/assisted-assembly-variant.ts
@@ -26,13 +26,24 @@ export function resolveAssistedAssemblyNodeMaterialConfig(options: {
export function getAssistedAssemblyVariantThumbnailUrl(
variant: AssistedAssemblyVariant | null | undefined,
): string | null {
- return variant?.runtimeThumbnailUrl ?? variant?.thumbnailUrl ?? null
+ return (
+ variant?.runtimeThumbnailUrl ??
+ variant?.thumbnailUrl ??
+ variant?.materialConfig?.thumbnailUrl ??
+ variant?.materialConfig?.textureUrl ??
+ null
+ )
}
export function getAssistedAssemblyVariantThumbnailSources(
variant: AssistedAssemblyVariant | null | undefined,
): string[] {
- const sources = [variant?.runtimeThumbnailUrl, variant?.thumbnailUrl]
+ const sources = [
+ variant?.runtimeThumbnailUrl,
+ variant?.thumbnailUrl,
+ variant?.materialConfig?.thumbnailUrl,
+ variant?.materialConfig?.textureUrl,
+ ]
.map((value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : null))
.filter((value): value is string => Boolean(value))
diff --git a/furniture-3d-system/src/lib/sku-cad-data.ts b/furniture-3d-system/src/lib/sku-cad-data.ts
index 07146677..0f348de0 100644
--- a/furniture-3d-system/src/lib/sku-cad-data.ts
+++ b/furniture-3d-system/src/lib/sku-cad-data.ts
@@ -61,6 +61,7 @@ export interface PersistedSkuCadMountPattern {
id: string
panelId: string
mountFace: string
+ snapFace?: string
patternType: string
holeIds: string[]
requiredHoleIds?: string[]
@@ -74,6 +75,17 @@ export interface PersistedSkuCadMountPattern {
warnings?: string[]
}
+export interface PersistedSkuCadSnapSurface {
+ id: string
+ panelId: string
+ face: string
+ normal: Vector3Tuple
+ center: Vector3Tuple
+ bounds?: PersistedSkuCadBoundingBox
+ confidence?: number
+ source?: string
+}
+
export interface PersistedSkuCadHardwareInstance {
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number }
@@ -110,6 +122,8 @@ export interface PersistedSkuCadData {
panels: PersistedSkuCadPanel[]
holes: PersistedSkuCadHole[]
holeGroups: PersistedSkuCadHoleGroup[]
+ snapPatterns: PersistedSkuCadMountPattern[]
+ snapSurfaces: PersistedSkuCadSnapSurface[]
mountPatterns: PersistedSkuCadMountPattern[]
hardwareTypes: PersistedSkuCadHardwareType[]
hardwareSummary: CADHardwareSummary[]
@@ -466,7 +480,12 @@ function normalizeCadMountPatterns(rawPatterns: unknown): PersistedSkuCadMountPa
seen.add(id)
const panelId = typeof record.panelId === 'string' ? record.panelId.trim() : ''
- const mountFace = typeof record.mountFace === 'string' ? record.mountFace.trim() : ''
+ const mountFace =
+ typeof record.mountFace === 'string' && record.mountFace.trim().length > 0
+ ? record.mountFace.trim()
+ : typeof record.snapFace === 'string' && record.snapFace.trim().length > 0
+ ? record.snapFace.trim()
+ : ''
if (!panelId || !mountFace) {
return
}
@@ -482,6 +501,10 @@ function normalizeCadMountPatterns(rawPatterns: unknown): PersistedSkuCadMountPa
id,
panelId,
mountFace,
+ snapFace:
+ typeof record.snapFace === 'string' && record.snapFace.trim().length > 0
+ ? record.snapFace.trim()
+ : mountFace,
patternType:
typeof record.patternType === 'string' && record.patternType.trim().length > 0
? record.patternType.trim()
@@ -505,6 +528,66 @@ function normalizeCadMountPatterns(rawPatterns: unknown): PersistedSkuCadMountPa
return patterns
}
+function normalizeCadSnapSurfaces(rawSurfaces: unknown): PersistedSkuCadSnapSurface[] {
+ if (!Array.isArray(rawSurfaces)) {
+ return []
+ }
+
+ const surfaces: PersistedSkuCadSnapSurface[] = []
+ const seen = new Set()
+
+ rawSurfaces.forEach((entry, index) => {
+ if (!entry || typeof entry !== 'object') {
+ return
+ }
+
+ const record = entry as Record
+ const id =
+ typeof record.id === 'string' && record.id.trim().length > 0
+ ? record.id.trim()
+ : `cad-snap-surface-${index + 1}`
+ if (seen.has(id)) {
+ return
+ }
+ seen.add(id)
+
+ const panelId = typeof record.panelId === 'string' ? record.panelId.trim() : ''
+ const face =
+ typeof record.face === 'string' && record.face.trim().length > 0
+ ? record.face.trim()
+ : typeof record.snapFace === 'string' && record.snapFace.trim().length > 0
+ ? record.snapFace.trim()
+ : typeof record.mountFace === 'string' && record.mountFace.trim().length > 0
+ ? record.mountFace.trim()
+ : ''
+ const center = toVector3Tuple(record.center ?? record.position)
+ const normal = toVector3Tuple(record.normal, [0, 0, 1])
+ if (!panelId || !face) {
+ return
+ }
+
+ const bounds = toBoundingBox(record.bounds)
+ surfaces.push({
+ id,
+ panelId,
+ face,
+ center,
+ normal,
+ ...(bounds ? { bounds } : {}),
+ confidence:
+ typeof record.confidence === 'number' && Number.isFinite(record.confidence)
+ ? record.confidence
+ : undefined,
+ source:
+ typeof record.source === 'string' && record.source.trim().length > 0
+ ? record.source.trim()
+ : undefined,
+ })
+ })
+
+ return surfaces
+}
+
function normalizeCadHardwareTypes(rawHardwareTypes: unknown): PersistedSkuCadHardwareType[] {
if (!Array.isArray(rawHardwareTypes)) {
return []
@@ -680,6 +763,8 @@ export function buildPersistedSkuCadData(params: {
panels?: unknown
holes?: unknown
holeGroups?: unknown
+ snapPatterns?: unknown
+ snapSurfaces?: unknown
mountPatterns?: unknown
hardwareTypes?: unknown
hardwareSummary?: unknown
@@ -689,6 +774,10 @@ export function buildPersistedSkuCadData(params: {
const holes = mergeCadHoles(normalizeCadHoles(params.holes), params.panels)
const holeGroups = normalizeCadHoleGroups(params.holeGroups)
const mountPatterns = normalizeCadMountPatterns(params.mountPatterns)
+ const explicitSnapPatterns = normalizeCadMountPatterns(params.snapPatterns)
+ const snapPatterns = explicitSnapPatterns.length > 0 ? explicitSnapPatterns : mountPatterns
+ const compatibilityMountPatterns = mountPatterns.length > 0 ? mountPatterns : snapPatterns
+ const snapSurfaces = normalizeCadSnapSurfaces(params.snapSurfaces)
const hardwareTypes = normalizeCadHardwareTypes(params.hardwareTypes)
const hardwareSummary = normalizeHardwareSummary(params.hardwareSummary)
const assembly = normalizePersistedSkuCadAssemblyData(params.assembly)
@@ -697,7 +786,8 @@ export function buildPersistedSkuCadData(params: {
panels.length === 0 &&
holes.length === 0 &&
holeGroups.length === 0 &&
- mountPatterns.length === 0 &&
+ compatibilityMountPatterns.length === 0 &&
+ snapSurfaces.length === 0 &&
hardwareTypes.length === 0 &&
hardwareSummary.length === 0 &&
!assembly
@@ -717,7 +807,9 @@ export function buildPersistedSkuCadData(params: {
panels,
holes,
holeGroups,
- mountPatterns,
+ snapPatterns,
+ snapSurfaces,
+ mountPatterns: compatibilityMountPatterns,
hardwareTypes,
hardwareSummary,
...(assembly ? { assembly } : {}),
@@ -742,6 +834,8 @@ export function readSkuCadDataFromAttributes(attributes: unknown): PersistedSkuC
panels: record.panels,
holes: record.holes,
holeGroups: record.holeGroups,
+ snapPatterns: record.snapPatterns,
+ snapSurfaces: record.snapSurfaces,
mountPatterns: record.mountPatterns,
hardwareTypes: record.hardwareTypes,
hardwareSummary: record.hardwareSummary,
diff --git a/furniture-3d-system/src/lib/storage.ts b/furniture-3d-system/src/lib/storage.ts
index 33fdcd66..6454b3af 100644
--- a/furniture-3d-system/src/lib/storage.ts
+++ b/furniture-3d-system/src/lib/storage.ts
@@ -179,7 +179,8 @@ export async function doesS3ObjectExist(key: string): Promise {
? (error as { $metadata?: { httpStatusCode?: number } }).$metadata!.httpStatusCode
: null
- if (statusCode === 404) {
+ // Some S3-compatible stores return 403 for missing objects when ListBucket is not allowed.
+ if (statusCode === 404 || statusCode === 403) {
return false
}
diff --git a/furniture-3d-system/src/server/services/asset-cad-import.service.ts b/furniture-3d-system/src/server/services/asset-cad-import.service.ts
index 5f6d132b..ce7cf2d3 100644
--- a/furniture-3d-system/src/server/services/asset-cad-import.service.ts
+++ b/furniture-3d-system/src/server/services/asset-cad-import.service.ts
@@ -235,7 +235,17 @@ function buildAssemblyBasis(params: {
}): Record {
const holes = params.assetType === 'HARDWARE' ? [] : (params.cadData?.holes ?? [])
const holeGroups = params.assetType === 'HARDWARE' ? [] : (params.cadData?.holeGroups ?? [])
- const mountPatterns = params.assetType === 'HARDWARE' ? [] : (params.cadData?.mountPatterns ?? [])
+ const snapPatterns =
+ params.assetType === 'HARDWARE'
+ ? []
+ : (params.cadData?.snapPatterns?.length
+ ? params.cadData.snapPatterns
+ : (params.cadData?.mountPatterns ?? []))
+ const mountPatterns =
+ params.assetType === 'HARDWARE'
+ ? []
+ : (params.cadData?.mountPatterns?.length ? params.cadData.mountPatterns : snapPatterns)
+ const snapSurfaces = params.assetType === 'HARDWARE' ? [] : (params.cadData?.snapSurfaces ?? [])
const edgeFeatures = params.assetType === 'BOARD' ? (params.cadExportData.edgeFeatures ?? []) : []
const suggestedFastenerCount = holes.reduce(
(sum, hole) => sum + (hole.suggestedFasteners?.length ?? 0),
@@ -252,10 +262,14 @@ function buildAssemblyBasis(params: {
holeCount: holes.length,
hardwareTypeCount: params.cadData?.hardwareTypes.length ?? 0,
coordinateSystem: params.cadData?.coordinateSystem ?? params.cadExportData.coordinateSystem ?? null,
+ snapPatternCount: snapPatterns.length,
+ snapSurfaceCount: snapSurfaces.length,
mountPatternCount: mountPatterns.length,
suggestedFastenerCount,
holes,
holeGroups,
+ snapPatterns,
+ snapSurfaces,
mountPatterns,
edgeFeatureCount: edgeFeatures.length,
edgeFeatures,
@@ -376,6 +390,8 @@ class AssetCadImportService {
panels: parseResult.cadExportData.panels,
holes: parseResult.cadExportData.holes,
holeGroups: (parseResult.cadExportData as unknown as { holeGroups?: unknown }).holeGroups,
+ snapPatterns: parseResult.cadExportData.snapPatterns,
+ snapSurfaces: parseResult.cadExportData.snapSurfaces,
mountPatterns: (parseResult.cadExportData as unknown as { mountPatterns?: unknown }).mountPatterns,
hardwareTypes: parseResult.cadExportData.hardwareTypes,
hardwareSummary: parseResult.cadExportData.hardwareSummary,
From 1740036d240b9d90ec8c161a6dae92a903089133 Mon Sep 17 00:00:00 2001
From: WuYongJie
Date: Tue, 28 Apr 2026 14:48:55 +0800
Subject: [PATCH 2/2] feat: add non-integrated project review flow
---
.../NonIntegratedDetailClient.test.tsx | 222 ++-
.../NonIntegratedDetailClient.tsx | 1432 ++++++++++++++++-
.../nonIntegratedStorage.test.ts | 39 +
.../non-integrated/nonIntegratedStorage.ts | 175 +-
4 files changed, 1747 insertions(+), 121 deletions(-)
diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx
index 181d33c0..9bf048cf 100644
--- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx
+++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx
@@ -11,6 +11,7 @@ import {
} from "./nonIntegratedApi";
import {
createDefaultDocumentWorkspaceConfirmations,
+ createEmptyNonIntegratedOpeningReference,
getNonIntegratedProductById,
NON_INTEGRATED_STORAGE_KEY,
saveNonIntegratedProduct,
@@ -156,6 +157,15 @@ function createProductRecord(
return {
id: "test-product",
spu: "DNZ0001",
+ projectStatus: "approved",
+ projectReview: {
+ reviewDate: "",
+ reviewers: "",
+ conclusion: "",
+ revisionNotes: "",
+ decidedAt: "",
+ },
+ openingReference: createEmptyNonIntegratedOpeningReference(),
year: "",
demandFlowNo: "",
processTemplate: "",
@@ -348,13 +358,14 @@ describe("NonIntegratedDetailClient", () => {
const actor = userEvent.setup();
const { container } = render();
- await actor.type(screen.getByLabelText("SPU"), "DNZ0001");
+ await actor.type(screen.getByLabelText("开品名称"), "测试开品");
+ await actor.type(screen.getByLabelText("临时编号"), "DNZ0001");
await actor.type(screen.getByLabelText("设计师"), "测试设计师");
const imageFile = new File(["image"], "preview.png", { type: "image/png" });
const fileInputs = container.querySelectorAll('input[type="file"]');
await actor.upload(fileInputs[0] as HTMLInputElement, imageFile);
- await actor.click(screen.getByRole("button", { name: "提交完成" }));
+ await actor.click(screen.getByRole("button", { name: "创建开品单" }));
await waitFor(() => expect(push).toHaveBeenCalledTimes(1));
@@ -364,8 +375,11 @@ describe("NonIntegratedDetailClient", () => {
expect(savedProducts).toHaveLength(1);
expect(savedProducts[0]?.spu).toBe("DNZ0001");
+ expect(savedProducts[0]?.projectStatus).toBe("draft");
expect(savedProducts[0]?.brand).toBe("基本盘");
expect(savedProducts[0]?.designer).toBe("测试设计师");
+ expect(savedProducts[0]?.openingReference.productName).toBe("测试开品");
+ expect(savedProducts[0]?.openingReference.temporaryCode).toBe("DNZ0001");
expect(savedProducts[0]?.previewImage).toContain(
"data:image/png;base64,dGVzdA==",
);
@@ -379,7 +393,7 @@ describe("NonIntegratedDetailClient", () => {
expect(screen.getByRole("button", { name: "返回" })).toBeInTheDocument();
expect(
- screen.getByRole("button", { name: "提交完成" }),
+ screen.getByRole("button", { name: "创建开品单" }),
).toBeInTheDocument();
createRender.unmount();
@@ -399,7 +413,7 @@ describe("NonIntegratedDetailClient", () => {
expect(screen.queryByRole("button", { name: "返回" })).not.toBeInTheDocument();
expect(
- screen.queryByRole("button", { name: "提交完成" }),
+ screen.queryByRole("button", { name: "创建开品单" }),
).not.toBeInTheDocument();
});
it("keeps brand as a single value when adaptable brands change", async () => {
@@ -426,7 +440,7 @@ describe("NonIntegratedDetailClient", () => {
await actor.click(adaptableBrandButton);
await actor.click(screen.getByLabelText("基本盘"));
expect(brandSelect).toHaveValue("顾家");
- expect(screen.getByLabelText("SPU")).toHaveValue("");
+ expect(screen.getByLabelText("临时编号")).toHaveValue("");
});
it("shows the document sidebar in the new order and marks empty categories softly", () => {
@@ -471,7 +485,7 @@ describe("NonIntegratedDetailClient", () => {
'button[data-testid^="document-workspace"]',
),
)[0]?.getAttribute("data-testid"),
- ).toBe("document-workspace-basic-info-trigger");
+ ).toBe("document-workspace-trigger-开品参考");
const triggerIds = Array.from(
sidebar.querySelectorAll(
@@ -480,12 +494,14 @@ describe("NonIntegratedDetailClient", () => {
).map((element) => element.dataset.testid);
expect(triggerIds).toEqual([
- "document-workspace-trigger-产品实拍图",
- "document-workspace-trigger-评审资料",
+ "document-workspace-trigger-开品参考",
+ "document-workspace-trigger-产品模型",
"document-workspace-trigger-设计卖点",
+ "document-workspace-trigger-立项评审",
+ "document-workspace-trigger-评审资料",
+ "document-workspace-trigger-产品实拍图",
"document-workspace-trigger-安装方式",
"document-workspace-trigger-包装细节",
- "document-workspace-trigger-产品模型",
"document-workspace-trigger-产品定厂",
"document-workspace-trigger-样包测量",
"document-workspace-trigger-产品核价",
@@ -502,6 +518,170 @@ describe("NonIntegratedDetailClient", () => {
expect(reportTrigger).toHaveAttribute("data-has-files", "false");
expect(reportTrigger).toHaveClass("bg-[#e6ebf0]");
expect(reviewTrigger).not.toHaveClass("bg-[#e6ebf0]");
+ expect(
+ Array.from(
+ sidebar.querySelectorAll(
+ 'button[data-testid^="document-workspace"]',
+ ),
+ )[4]?.getAttribute("data-testid"),
+ ).toBe("document-workspace-basic-info-trigger");
+ });
+
+ it("limits draft products to setup nodes until project review is approved", async () => {
+ const actor = userEvent.setup();
+
+ window.localStorage.setItem(
+ NON_INTEGRATED_STORAGE_KEY,
+ JSON.stringify([
+ createProductRecord({
+ id: "DNZ0001-draft-project",
+ projectStatus: "draft",
+ openingReference: {
+ ...createEmptyNonIntegratedOpeningReference(),
+ productName: "测试开品",
+ temporaryCode: "KP-001",
+ brand: "顾家",
+ adaptableBrands: ["顾家", "基本盘"],
+ productCategory: "书桌",
+ salesCategory: "书桌",
+ designer: "测试设计师",
+ planningOwner: "测试企划",
+ currentStage: "待立项评审",
+ year: "2026",
+ targetPriceBand: "399-499",
+ size: "140/60/78",
+ material: "18ENF级橡胶木",
+ urgent: "否",
+ reason: "补齐书房价格带",
+ },
+ }),
+ ]),
+ );
+
+ render(
+ ,
+ );
+
+ const sidebar = screen.getByTestId("document-workspace-sidebar");
+ const draftTriggerIds = Array.from(
+ sidebar.querySelectorAll(
+ 'button[data-testid^="document-workspace-trigger-"]',
+ ),
+ ).map((element) => element.dataset.testid);
+
+ expect(draftTriggerIds).toEqual([
+ "document-workspace-trigger-开品参考",
+ "document-workspace-trigger-产品模型",
+ "document-workspace-trigger-设计卖点",
+ "document-workspace-trigger-立项评审",
+ ]);
+ expect(
+ sidebar.querySelector(
+ 'button[data-testid="document-workspace-basic-info-trigger"]',
+ ),
+ ).toBeNull();
+ expect(screen.getByText("开品参考填写")).toBeInTheDocument();
+
+ await actor.click(screen.getByTestId("document-workspace-trigger-立项评审"));
+ await actor.click(screen.getByRole("button", { name: "立项通过" }));
+
+ expect(screen.getByRole("dialog", { name: "确认立项" })).toBeInTheDocument();
+ await actor.type(screen.getByLabelText("正式 SPU"), "SZ0230");
+ await actor.click(screen.getByRole("button", { name: "确认立项" }));
+
+ expect(
+ screen.getByTestId("document-workspace-basic-info-trigger"),
+ ).toHaveAttribute("data-active", "true");
+ expect(
+ screen.getByTestId("document-workspace-trigger-产品实拍图"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("document-workspace-trigger-立项评审"),
+ ).toHaveTextContent("1");
+
+ await waitFor(() => {
+ const savedProduct = getNonIntegratedProductById("DNZ0001-draft-project");
+
+ expect(savedProduct?.projectStatus).toBe("approved");
+ expect(savedProduct?.spu).toBe("SZ0230");
+ expect(savedProduct?.brand).toBe("顾家");
+ expect(savedProduct?.productCategory).toBe("书桌");
+ expect(savedProduct?.salesCategory).toBe("书桌");
+ expect(savedProduct?.designer).toBe("测试设计师");
+ expect(savedProduct?.productPlanning).toBe("测试企划");
+ expect(savedProduct?.year).toBe("2026");
+ expect(savedProduct?.priceBand).toBe("399-499");
+ expect(savedProduct?.size).toBe("140/60/78");
+ expect(savedProduct?.materialType).toBe("18ENF级橡胶木");
+ expect(savedProduct?.openingReference.currentStage).toBe("已立项");
+ });
+ });
+
+ it("renders opening reference fields and persists edits", async () => {
+ const actor = userEvent.setup();
+
+ saveNonIntegratedProduct(
+ createProductRecord({
+ id: "SZ0230-opening-reference",
+ spu: "SZ0230",
+ openingReference: {
+ ...createEmptyNonIntegratedOpeningReference(),
+ productName: "胡桃木色窄边书桌",
+ temporaryCode: "KP-SZ0230",
+ brand: "基本盘",
+ adaptableBrands: ["基本盘", "顾家"],
+ productCategory: "书桌",
+ salesCategory: "书桌",
+ designer: "徐霞客",
+ planningOwner: "企划A",
+ currentStage: "立项前",
+ year: "2026",
+ competitorLink: "https://example.com/item",
+ targetPriceBand: "399-499",
+ size: "140/60/78",
+ material: "18ENF级橡胶木",
+ urgent: "否",
+ reason: "补齐书房价格带",
+ },
+ uploadFiles: [
+ {
+ id: "opening-doc-1",
+ name: "竞品资料.pdf",
+ category: "开品参考",
+ dataUrl: "data:application/pdf;base64,AAAA",
+ mimeType: "application/pdf",
+ },
+ ],
+ }),
+ );
+
+ render(
+ ,
+ );
+
+ await actor.click(screen.getByTestId("document-workspace-trigger-开品参考"));
+
+ expect(screen.getByTestId("opening-reference-workspace")).toBeInTheDocument();
+ expect(screen.getByLabelText("开品名称")).toHaveValue("胡桃木色窄边书桌");
+ expect(screen.getByLabelText("临时编号")).toHaveValue("KP-SZ0230");
+ expect(screen.getByLabelText("企划负责人")).toHaveValue("企划A");
+ expect(screen.getByLabelText("目标价格带")).toHaveValue("399-499");
+ expect(screen.getByText("竞品资料.pdf")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "下载" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "删除" })).toBeInTheDocument();
+
+ await actor.clear(screen.getByLabelText("开品理由"));
+ await actor.type(screen.getByLabelText("开品理由"), "新的开品理由");
+
+ await waitFor(() => {
+ const savedProduct = getNonIntegratedProductById(
+ "SZ0230-opening-reference",
+ );
+ expect(savedProduct?.openingReference.reason).toBe("新的开品理由");
+ });
});
it("links the SKU material lookup to the raw material process config", () => {
@@ -1529,13 +1709,7 @@ describe("NonIntegratedDetailClient", () => {
/>,
);
- const productPhotoTrigger = screen
- .getByTestId("document-workspace-sidebar")
- .querySelector(
- 'button[data-testid^="document-workspace-trigger-"]',
- );
- expect(productPhotoTrigger).not.toBeNull();
- await actor.click(productPhotoTrigger!);
+ await actor.click(screen.getByTestId("document-workspace-trigger-产品实拍图"));
await actor.click(screen.getByTestId("product-photo-slot-back"));
await actor.upload(
screen.getByTestId("product-photo-current-upload-input"),
@@ -1566,13 +1740,7 @@ describe("NonIntegratedDetailClient", () => {
/>,
);
- const productPhotoTrigger = screen
- .getByTestId("document-workspace-sidebar")
- .querySelector(
- 'button[data-testid^="document-workspace-trigger-"]',
- );
- expect(productPhotoTrigger).not.toBeNull();
- await actor.click(productPhotoTrigger!);
+ await actor.click(screen.getByTestId("document-workspace-trigger-产品实拍图"));
await actor.click(screen.getByTestId("product-photo-panel-tab-detail"));
await actor.click(screen.getByTestId("product-photo-empty-panel-upload-detail"));
await actor.upload(
@@ -1616,13 +1784,7 @@ describe("NonIntegratedDetailClient", () => {
/>,
);
- const productPhotoTrigger = screen
- .getByTestId("document-workspace-sidebar")
- .querySelector(
- 'button[data-testid^="document-workspace-trigger-"]',
- );
- expect(productPhotoTrigger).not.toBeNull();
- await actor.click(productPhotoTrigger!);
+ await actor.click(screen.getByTestId("document-workspace-trigger-产品实拍图"));
expect(screen.getByTestId("product-photo-style-K01")).toBeInTheDocument();
expect(screen.queryByTestId("product-photo-style-K02")).not.toBeInTheDocument();
diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx
index 4ef63f2b..bf208871 100644
--- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx
+++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx
@@ -96,6 +96,8 @@ import {
buildNonIntegratedSkuId,
buildNonIntegratedUploadFileId,
createDefaultDocumentWorkspaceConfirmations,
+ createEmptyNonIntegratedOpeningReference,
+ createEmptyNonIntegratedProjectReview,
listNonIntegratedProducts,
NON_INTEGRATED_BRAND_PREFIX_MAP,
NON_INTEGRATED_DUPLICATE_SPU_MESSAGE,
@@ -103,6 +105,9 @@ import {
getNonIntegratedDocumentWorkspaceCategoryLabel,
getNonIntegratedProductById,
hasPendingDocumentWorkspaceConfirmation,
+ normalizeNonIntegratedOpeningReference,
+ normalizeNonIntegratedProjectReview,
+ normalizeNonIntegratedProjectStatus,
replaceNonIntegratedProducts,
saveNonIntegratedProduct,
stripNonIntegratedBrandPrefix,
@@ -112,9 +117,12 @@ import {
type NonIntegratedDocumentWorkspaceCategory,
type NonIntegratedDocumentWorkspaceConfirmationMap,
type NonIntegratedFactoryRow,
+ type NonIntegratedOpeningReference,
type NonIntegratedPricingRow,
type NonIntegratedPackagingInnerItem,
type NonIntegratedProductRecord,
+ type NonIntegratedProjectReview,
+ type NonIntegratedProjectStatus,
type NonIntegratedQualityInspectionSheet,
type NonIntegratedReviewIssue,
type NonIntegratedReviewRound,
@@ -142,6 +150,8 @@ type SkuTabKey = "skuInfo" | "fileUpload";
type ProductFactoryTabKey = "factoryInfo" | "skuInfo";
type UploadCategoryKey =
| "未分类"
+ | "开品参考"
+ | "立项评审"
| "评审资料"
| "产品模型"
| "产品实拍图"
@@ -368,6 +378,14 @@ type ReviewSheetAutoFieldKey =
| "size"
| "material"
| "color";
+type ProjectReviewFieldKey = Exclude<
+ keyof NonIntegratedProjectReview,
+ "decidedAt"
+>;
+type OpeningReferenceFieldKey = Exclude<
+ keyof NonIntegratedOpeningReference,
+ "productImage" | "productImageName" | "adaptableBrands"
+>;
const REVIEW_SHEET_AUTO_FIELD_KEYS: ReviewSheetAutoFieldKey[] = [
"factory",
"productName",
@@ -752,6 +770,65 @@ const DOCUMENT_WORKSPACE_CATEGORY_ORDER: DocumentWorkspaceCategory[] = [
...NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES,
];
+const OPENING_REFERENCE_WORKSPACE_CATEGORY: DocumentWorkspaceCategory =
+ "开品参考";
+const PROJECT_REVIEW_WORKSPACE_CATEGORY: DocumentWorkspaceCategory =
+ "立项评审";
+const PROJECT_SETUP_WORKSPACE_CATEGORIES: DocumentWorkspaceCategory[] = [
+ OPENING_REFERENCE_WORKSPACE_CATEGORY,
+ PRODUCT_DATA_WORKSPACE_CATEGORY,
+ "设计卖点",
+ PROJECT_REVIEW_WORKSPACE_CATEGORY,
+];
+const PROJECT_SETUP_NODE_COUNT = PROJECT_SETUP_WORKSPACE_CATEGORIES.length;
+const PROJECT_STATUS_COPY: Record<
+ NonIntegratedProjectStatus,
+ {
+ label: string;
+ description: string;
+ badgeClassName: string;
+ panelClassName: string;
+ saveMessage: string;
+ }
+> = {
+ draft: {
+ label: "待立项",
+ description: "先整理开品参考、产品资料和设计卖点,再由负责人判断是否进入开发。",
+ badgeClassName: "bg-[#fff2d9] text-[#8a5a00]",
+ panelClassName: "border-[#efd49d] bg-[#fff9ec]",
+ saveMessage: "已标记为待立项",
+ },
+ revision: {
+ label: "退回修改",
+ description: "资料需要回到产品资料或设计卖点继续补充,补完后再重新评审。",
+ badgeClassName: "bg-[#e8f1fb] text-[#23659b]",
+ panelClassName: "border-[#c7ddec] bg-[#f4f9fd]",
+ saveMessage: "已退回修改,后续节点保持收起",
+ },
+ rejected: {
+ label: "废弃不开",
+ description: "这个品暂不进入开发,只保留前期资料和立项结论。",
+ badgeClassName: "bg-[#ffe6e9] text-[#a32135]",
+ panelClassName: "border-[#f4c7ce] bg-[#fff7f8]",
+ saveMessage: "已标记为废弃不开,后续节点保持收起",
+ },
+ approved: {
+ label: "已立项",
+ description: "立项通过后展开基础信息和后续节点,采购可基于资料推进打样。",
+ badgeClassName: "bg-[#dff7eb] text-[#156b3f]",
+ panelClassName: "border-[#bde8ce] bg-[#f4fbf7]",
+ saveMessage: "立项已通过,已展开基础信息和后续节点",
+ },
+};
+const OPENING_REFERENCE_STAGE_OPTIONS = [
+ "立项前",
+ "资料补充中",
+ "待立项评审",
+ "已立项",
+ "退回修改",
+ "废弃不开",
+];
+
const UPLOAD_CATEGORY_OPTIONS: UploadCategoryKey[] = [
"未分类",
...DOCUMENT_WORKSPACE_CATEGORY_ORDER,
@@ -771,7 +848,7 @@ const SUPPLEMENT_WORKSPACE_SHORTCUT_LAYOUT: DocumentWorkspaceCategory[] = [
];
const DEFAULT_DOCUMENT_WORKSPACE_CATEGORY: DocumentWorkspaceCategory =
- "产品实拍图";
+ OPENING_REFERENCE_WORKSPACE_CATEGORY;
const PRODUCT_PHOTO_CATEGORY: DocumentWorkspaceCategory = "产品实拍图";
const AUTO_SAVE_DELAY_MS = 500;
@@ -975,8 +1052,10 @@ const EMPTY_DOCUMENT_WORKSPACE_NOTES: Record<
DocumentWorkspaceCategory,
string
> = {
+ 开品参考: "",
产品实拍图: "",
评审资料: "",
+ 立项评审: "",
设计卖点: "",
安装方式: "",
包装细节: "",
@@ -998,6 +1077,13 @@ const DOCUMENT_WORKSPACE_COPY: Record<
emptyDescription: string;
}
> = {
+ 开品参考: {
+ description: "维护开品前的参考资料、竞品链接和方向说明。",
+ noteLabel: "参考备注",
+ notePlaceholder: "可填写竞品链接、参考方向、价格段或开品理由。",
+ emptyTitle: "当前还没有开品参考",
+ emptyDescription: "可先上传竞品截图、参考文件,或在备注里记录竞品链接。",
+ },
产品模型: {
description: "按四个大类集中维护打样资料、线稿爆炸图、孔位图和模型资料。",
noteLabel: "资料备注",
@@ -1015,12 +1101,19 @@ const DOCUMENT_WORKSPACE_COPY: Record<
"可先上传九视图、细节图、质检照片或竞品照片,再在这里集中查看。",
},
评审资料: {
- description: "按评审记录单模板在本地填写评审信息,并支持一键导出 Excel。",
- noteLabel: "评审备注",
- notePlaceholder: "可填写评审用途、版本说明或交接备注。",
- emptyTitle: "当前还没有评审资料",
+ description: "按样品评审记录单模板在本地填写评审信息,并支持一键导出 Excel。",
+ noteLabel: "样品评审备注",
+ notePlaceholder: "可填写样品评审用途、版本说明或交接备注。",
+ emptyTitle: "当前还没有样品评审资料",
emptyDescription: "可直接在当前工作区填写评审记录,并导出成 Excel。",
},
+ 立项评审: {
+ description: "记录立项会议资料和评审结论;确认通过后会展开基础信息及后续节点。",
+ noteLabel: "立项备注",
+ notePlaceholder: "可填写会议结论、负责人意见、待补资料或废弃原因。",
+ emptyTitle: "当前还没有立项评审资料",
+ emptyDescription: "可上传会议记录、立项判断材料,确认通过后继续维护基础信息。",
+ },
设计卖点: {
description: "集中查看设计卖点图、卖点文案和展示素材。",
noteLabel: "卖点备注",
@@ -3163,6 +3256,145 @@ function createFormValuesFromProduct(
};
}
+function getOpeningReferenceStageFromStatus(
+ status: NonIntegratedProjectStatus,
+) {
+ if (status === "approved") return "已立项";
+ if (status === "revision") return "退回修改";
+ if (status === "rejected") return "废弃不开";
+ return "立项前";
+}
+
+function createInitialOpeningReference(
+ product: NonIntegratedProductRecord | null,
+): NonIntegratedOpeningReference {
+ if (!product) {
+ return {
+ ...createEmptyNonIntegratedOpeningReference(),
+ brand: "基本盘",
+ adaptableBrands: ["基本盘"],
+ currentStage: "立项前",
+ urgent: "否",
+ };
+ }
+
+ const normalized = normalizeNonIntegratedOpeningReference(
+ product.openingReference,
+ );
+ const normalizedBrand = normalized.brand || product.brand || "基本盘";
+ const fallbackAdaptableBrands = Array.from(
+ new Set([
+ normalizedBrand,
+ ...(product.adaptableBrands?.filter((brand) =>
+ BRAND_OPTIONS.includes(brand),
+ ) ?? []),
+ ]),
+ );
+ const normalizedAdaptableBrands = normalized.adaptableBrands.length
+ ? Array.from(
+ new Set([
+ normalizedBrand,
+ ...normalized.adaptableBrands.filter((brand) =>
+ BRAND_OPTIONS.includes(brand),
+ ),
+ ]),
+ )
+ : fallbackAdaptableBrands;
+ const projectStatus = normalizeNonIntegratedProjectStatus(
+ product?.projectStatus,
+ );
+
+ return {
+ ...normalized,
+ productImage: normalized.productImage || product.previewImage || "",
+ productImageName:
+ normalized.productImageName || product.previewImageName || "",
+ productName: normalized.productName || product.spu || "",
+ temporaryCode: normalized.temporaryCode || product.spu || product.id || "",
+ brand: normalizedBrand,
+ adaptableBrands: normalizedAdaptableBrands,
+ productCategory:
+ normalized.productCategory || product.productCategory || "",
+ salesCategory: normalized.salesCategory || product.salesCategory || "",
+ designer: normalized.designer || product.designer || "",
+ planningOwner: normalized.planningOwner || product.productPlanning || "",
+ currentStage:
+ normalized.currentStage || getOpeningReferenceStageFromStatus(projectStatus),
+ year: normalized.year || product.year || "",
+ targetPriceBand: normalized.targetPriceBand || product.priceBand || "",
+ size: normalized.size || product.size || "",
+ material:
+ normalized.material || product.materialType || product.tagMaterial || "",
+ urgent: normalized.urgent || "否",
+ };
+}
+
+function hasOpeningReferenceContent(reference: NonIntegratedOpeningReference) {
+ return (
+ [
+ reference.productImage,
+ reference.productName,
+ reference.temporaryCode,
+ reference.productCategory,
+ reference.salesCategory,
+ reference.designer,
+ reference.planningOwner,
+ reference.year,
+ reference.competitorLink,
+ reference.targetPriceBand,
+ reference.size,
+ reference.material,
+ reference.reason,
+ ].some((value) => value.trim()) ||
+ reference.adaptableBrands.length > 1 ||
+ (reference.brand.trim() && reference.brand.trim() !== "基本盘") ||
+ (reference.urgent.trim() && reference.urgent.trim() !== "否")
+ );
+}
+
+function buildOpeningReferenceFormValues(
+ baseValues: FormValues,
+ reference: NonIntegratedOpeningReference,
+ spu: string,
+): FormValues {
+ const normalizedSpu = spu.trim();
+ const normalizedBrand = reference.brand.trim() || baseValues.brand || "基本盘";
+ const normalizedAdaptableBrands = Array.from(
+ new Set([
+ normalizedBrand,
+ ...reference.adaptableBrands.filter((brand) =>
+ BRAND_OPTIONS.includes(brand),
+ ),
+ ...baseValues.adaptableBrands.filter((brand) =>
+ BRAND_OPTIONS.includes(brand),
+ ),
+ ]),
+ );
+
+ return {
+ ...baseValues,
+ spu: normalizedSpu,
+ productCode: baseValues.productCode || normalizedSpu,
+ previewImage: reference.productImage.trim() || baseValues.previewImage,
+ previewImageName:
+ reference.productImageName.trim() || baseValues.previewImageName,
+ year: reference.year.trim() || baseValues.year,
+ brand: normalizedBrand,
+ adaptableBrands: normalizedAdaptableBrands,
+ productCategory:
+ reference.productCategory.trim() || baseValues.productCategory,
+ salesCategory: reference.salesCategory.trim() || baseValues.salesCategory,
+ productPlanning:
+ reference.planningOwner.trim() || baseValues.productPlanning,
+ spuOwner: baseValues.spuOwner || reference.designer.trim(),
+ designer: reference.designer.trim() || baseValues.designer,
+ priceBand: reference.targetPriceBand.trim() || baseValues.priceBand,
+ size: reference.size.trim() || baseValues.size,
+ materialType: reference.material.trim() || baseValues.materialType,
+ tagMaterial: reference.material.trim() || baseValues.tagMaterial,
+ };
+}
+
function createInitialDocumentWorkspaceNotes(
product: NonIntegratedProductRecord | null,
) {
@@ -3687,32 +3919,45 @@ function SectionCard({
bodyClassName = "",
headerActions,
}: {
- title: string;
+ title?: string;
description?: string;
children: React.ReactNode;
className?: string;
bodyClassName?: string;
headerActions?: React.ReactNode;
}) {
+ const hasHeaderText = Boolean(title || description);
+ const hasHeader = hasHeaderText || Boolean(headerActions);
+
return (
-
-
-
- {title}
-
- {description ? (
-
- {description}
-
+ {hasHeader ? (
+
+ {hasHeaderText ? (
+
+ {title ? (
+
+ {title}
+
+ ) : null}
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ ) : null}
+ {headerActions ? (
+
+ {headerActions}
+
) : null}
- {headerActions ?
{headerActions}
: null}
-
+ ) : null}
{children}
);
@@ -4115,6 +4360,7 @@ function productHasLocalAssets(product: NonIntegratedProductRecord) {
if (
isDataUrlSource(product.previewImage) ||
+ isDataUrlSource(product.openingReference.productImage) ||
isDataUrlSource(product.model3dDataUrl ?? "")
) {
return true;
@@ -5170,6 +5416,9 @@ function buildProductPayload(
uploadFiles: NonIntegratedUploadFile[],
documentWorkspaceNotes: Record
,
documentWorkspaceConfirmations: NonIntegratedDocumentWorkspaceConfirmationMap,
+ openingReference: NonIntegratedOpeningReference,
+ projectReview: NonIntegratedProjectReview,
+ projectStatus: NonIntegratedProjectStatus,
createdAt?: string,
copiedFromProductId?: string,
copiedFromSpu?: string,
@@ -5360,6 +5609,9 @@ function buildProductPayload(
return {
id,
+ projectStatus,
+ projectReview: normalizeNonIntegratedProjectReview(projectReview),
+ openingReference: normalizeNonIntegratedOpeningReference(openingReference),
spu: formValues.spu.trim(),
year: formValues.year.trim(),
demandFlowNo: formValues.demandFlowNo.trim(),
@@ -5478,6 +5730,7 @@ export default function NonIntegratedDetailClient({
}) {
const router = useRouter();
const previewFileInputRef = useRef(null);
+ const openingReferenceImageInputRef = useRef(null);
const batchSkuImageInputRef = useRef(null);
const fileUploadInputRef = useRef(null);
const documentWorkspaceUploadInputRef = useRef(null);
@@ -5507,6 +5760,10 @@ export default function NonIntegratedDetailClient({
? initialProduct
: getNonIntegratedProductById(productId)
: null;
+ const initialProjectStatus: NonIntegratedProjectStatus =
+ mode === "create"
+ ? "draft"
+ : normalizeNonIntegratedProjectStatus(existingProduct?.projectStatus);
const initialBomDetailsResultRef = useRef(null);
if (!initialBomDetailsResultRef.current) {
initialBomDetailsResultRef.current = createInitialBomDetails(existingProduct);
@@ -5543,8 +5800,25 @@ export default function NonIntegratedDetailClient({
const [uploadFiles, setUploadFiles] = useState(
existingProduct?.uploadFiles ?? [],
);
+ const [projectStatus, setProjectStatus] =
+ useState(initialProjectStatus);
+ const [projectReview, setProjectReview] = useState(
+ () =>
+ existingProduct?.projectReview
+ ? normalizeNonIntegratedProjectReview(existingProduct.projectReview)
+ : createEmptyNonIntegratedProjectReview(),
+ );
+ const [openingReference, setOpeningReference] =
+ useState(() =>
+ createInitialOpeningReference(existingProduct),
+ );
+ const [projectApprovalDialogOpen, setProjectApprovalDialogOpen] =
+ useState(false);
+ const [projectApprovalSpu, setProjectApprovalSpu] = useState("");
const [documentWorkspaceView, setDocumentWorkspaceView] =
- useState("detail");
+ useState(
+ initialProjectStatus === "approved" ? "detail" : "document",
+ );
const [activeDocumentWorkspaceCategory, setActiveDocumentWorkspaceCategory] =
useState(DEFAULT_DOCUMENT_WORKSPACE_CATEGORY);
const [documentWorkspaceNotes, setDocumentWorkspaceNotes] = useState<
@@ -5860,8 +6134,8 @@ export default function NonIntegratedDetailClient({
}
: undefined;
const isCopiedBrandProduct = Boolean(existingProduct?.copiedFromProductId);
- const isDocumentWorkspaceMode =
- mode !== "create" && documentWorkspaceView === "document";
+ const isProjectApproved = projectStatus === "approved";
+ const isDocumentWorkspaceMode = documentWorkspaceView === "document";
const derivedSkuItems = useMemo(
() =>
skuItems.map((item, index) =>
@@ -6192,6 +6466,23 @@ export default function NonIntegratedDetailClient({
})),
[uploadFiles],
);
+ const visibleDocumentWorkspaceCategories = useMemo(
+ () =>
+ isProjectApproved
+ ? UPLOAD_CATEGORY_PANEL_ORDER
+ : PROJECT_SETUP_WORKSPACE_CATEGORIES,
+ [isProjectApproved],
+ );
+ const visibleGroupedUploadFiles = useMemo(
+ () =>
+ visibleDocumentWorkspaceCategories.map((category) => ({
+ category,
+ files:
+ groupedUploadFiles.find((group) => group.category === category)
+ ?.files ?? [],
+ })),
+ [groupedUploadFiles, visibleDocumentWorkspaceCategories],
+ );
const uploadFileCountByCategory = useMemo(
() =>
Object.fromEntries(
@@ -6236,6 +6527,12 @@ export default function NonIntegratedDetailClient({
return {
...uploadFileCountByCategory,
+ [OPENING_REFERENCE_WORKSPACE_CATEGORY]:
+ uploadFileCountByCategory[OPENING_REFERENCE_WORKSPACE_CATEGORY] +
+ (hasOpeningReferenceContent(openingReference) ? 1 : 0),
+ [PROJECT_REVIEW_WORKSPACE_CATEGORY]:
+ uploadFileCountByCategory[PROJECT_REVIEW_WORKSPACE_CATEGORY] +
+ (isProjectApproved ? 1 : 0),
[REVIEW_WORKSPACE_CATEGORY]:
uploadFileCountByCategory[REVIEW_WORKSPACE_CATEGORY] +
populatedReviewRoundCount,
@@ -6252,6 +6549,8 @@ export default function NonIntegratedDetailClient({
};
}, [
confirmedProductFactoryRowCount,
+ isProjectApproved,
+ openingReference,
populatedReviewRoundCount,
populatedProductPricingRowCount,
populatedSampleMeasurementRowCount,
@@ -6346,6 +6645,10 @@ export default function NonIntegratedDetailClient({
activeDocumentWorkspaceCategory === FACTORY_QUALITY_WORKSPACE_CATEGORY;
const isReviewWorkspace =
activeDocumentWorkspaceCategory === REVIEW_WORKSPACE_CATEGORY;
+ const isOpeningReferenceWorkspace =
+ activeDocumentWorkspaceCategory === OPENING_REFERENCE_WORKSPACE_CATEGORY;
+ const isProjectReviewWorkspace =
+ activeDocumentWorkspaceCategory === PROJECT_REVIEW_WORKSPACE_CATEGORY;
const isDetectionWorkspace =
activeDocumentWorkspaceCategory === DETECTION_WORKSPACE_CATEGORY;
const isInstallationWorkspace =
@@ -6943,6 +7246,8 @@ export default function NonIntegratedDetailClient({
? `按款式分栏维护产品实拍。当前款式:${currentProductPhotoStyleLabel};九视图支持按文件名自动匹配,其他照片分组支持批量上传和替换。`
: isProductDataWorkspace
? "左侧按四个大类切换,中间查看当前分类文件明细,右侧只预览 PDF 和图片;其他资料会展开文件明细。"
+ : isProjectReviewWorkspace
+ ? "承接开品参考、产品资料和设计卖点,记录立项会议结论;只有立项通过后才展开基础信息和后续节点。"
: isReviewWorkspace
? activeDocumentWorkspaceCopy.description
: isDetectionWorkspace
@@ -7055,8 +7360,10 @@ export default function NonIntegratedDetailClient({
? `当前还没有${selectedFactoryQualityStyleEntry?.label ?? "该款式"}的${activeFactoryQualityAssetLabel},可点击上方“上传到当前栏”补充。`
: activeDocumentWorkspaceCopy.emptyDescription;
const canSubmitCreate =
- formValues.spu.trim().length > 0 &&
- formValues.previewImage.length > 0;
+ mode === "create"
+ ? openingReference.productName.trim().length > 0 ||
+ openingReference.temporaryCode.trim().length > 0
+ : formValues.spu.trim().length > 0 && formValues.previewImage.length > 0;
const existingProductCreatedAt = existingProduct?.createdAt ?? "";
const uploadAssetSourceToServer = useCallback(
@@ -7118,6 +7425,7 @@ export default function NonIntegratedDetailClient({
];
const [
previewImage,
+ openingReferenceProductImage,
model3dDataUrl,
styleDetailsWithAssets,
skuItemsWithAssets,
@@ -7131,6 +7439,13 @@ export default function NonIntegratedDetailClient({
`${product.spu || product.id}-preview.png`,
"image/png",
),
+ uploadAssetSourceToServer(
+ product.id,
+ product.openingReference.productImage,
+ product.openingReference.productImageName ||
+ `${product.spu || product.id}-opening-reference.png`,
+ "image/png",
+ ),
uploadAssetSourceToServer(
product.id,
product.model3dDataUrl ?? "",
@@ -7223,6 +7538,10 @@ export default function NonIntegratedDetailClient({
return {
...product,
previewImage,
+ openingReference: {
+ ...product.openingReference,
+ productImage: openingReferenceProductImage,
+ },
model3dDataUrl,
styleDetails: styleDetailsWithAssets,
skuItems: skuItemsWithAssets,
@@ -7294,6 +7613,9 @@ export default function NonIntegratedDetailClient({
uploadFiles,
documentWorkspaceNotes,
documentWorkspaceConfirmations,
+ openingReference,
+ projectReview,
+ projectStatus,
existingProductCreatedAt,
existingProduct?.copiedFromProductId,
existingProduct?.copiedFromSpu,
@@ -7333,6 +7655,9 @@ export default function NonIntegratedDetailClient({
productFactoryRows,
productPricingRows,
productSampleMeasurementRows,
+ openingReference,
+ projectReview,
+ projectStatus,
skuItems,
styleDetails,
uploadFiles,
@@ -7346,6 +7671,23 @@ export default function NonIntegratedDetailClient({
void compactStoredProducts().catch(() => undefined);
}, [compactStoredProducts]);
+ useEffect(() => {
+ if (isProjectApproved) return;
+ if (
+ documentWorkspaceView === "document" &&
+ PROJECT_SETUP_WORKSPACE_CATEGORIES.includes(activeDocumentWorkspaceCategory)
+ ) {
+ return;
+ }
+
+ setDocumentWorkspaceView("document");
+ setActiveDocumentWorkspaceCategory(OPENING_REFERENCE_WORKSPACE_CATEGORY);
+ }, [
+ activeDocumentWorkspaceCategory,
+ documentWorkspaceView,
+ isProjectApproved,
+ ]);
+
useEffect(() => {
if (!isInstallationWorkspace) return;
@@ -7605,6 +7947,8 @@ export default function NonIntegratedDetailClient({
mode,
persistEditedProduct,
productId,
+ projectReview,
+ projectStatus,
reviewRounds,
skuItems,
styleDetails,
@@ -9078,6 +9422,81 @@ export default function NonIntegratedDetailClient({
setSaveMessageType("success");
}
+ function handleOpeningReferenceFieldChange(
+ field: OpeningReferenceFieldKey,
+ value: string,
+ ) {
+ if (isReadOnly) return;
+
+ setOpeningReference((current) => {
+ if (field !== "brand") {
+ return {
+ ...current,
+ [field]: value,
+ };
+ }
+
+ const nextBrand = value.trim() || "基本盘";
+ const nextAdaptableBrands = Array.from(
+ new Set([
+ nextBrand,
+ ...current.adaptableBrands.filter((brand) =>
+ BRAND_OPTIONS.includes(brand),
+ ),
+ ]),
+ );
+
+ return {
+ ...current,
+ brand: nextBrand,
+ adaptableBrands: nextAdaptableBrands,
+ };
+ });
+ setSaveMessage("");
+ setSaveMessageType("success");
+ }
+
+ function handleOpeningReferenceAdaptableBrandsChange(value: string[]) {
+ if (isReadOnly) return;
+
+ setOpeningReference((current) => {
+ const nextBrand = current.brand.trim() || "基本盘";
+
+ return {
+ ...current,
+ adaptableBrands: Array.from(
+ new Set([
+ nextBrand,
+ ...BRAND_OPTIONS.filter((brand) => value.includes(brand)),
+ ]),
+ ),
+ };
+ });
+ setSaveMessage("");
+ setSaveMessageType("success");
+ }
+
+ async function handleOpeningReferenceImageChange(
+ event: ChangeEvent,
+ ) {
+ if (isReadOnly) return;
+ const file = event.target.files?.[0];
+ if (!file) return;
+
+ try {
+ const uploadedAsset = await uploadFileToServer(file, detachedUploadOptions);
+ setOpeningReference((current) => ({
+ ...current,
+ productImage: uploadedAsset.url,
+ productImageName: uploadedAsset.fileName,
+ }));
+ setSaveMessage("");
+ setSaveMessageType("success");
+ } finally {
+ event.target.value = "";
+ }
+ }
+
function updateSelectedPackagingStyleDetail(
updater: (detail: StyleDetailForm) => StyleDetailForm,
) {
@@ -9146,6 +9565,73 @@ export default function NonIntegratedDetailClient({
setSaveMessageType("success");
}
+ function handleProjectReviewFieldChange(
+ field: ProjectReviewFieldKey,
+ value: string,
+ ) {
+ if (isReadOnly) return;
+
+ setProjectReview((current) => ({
+ ...current,
+ [field]: value,
+ }));
+ }
+
+ function handleSetProjectStatus(nextStatus: NonIntegratedProjectStatus) {
+ if (isReadOnly || projectStatus === nextStatus) return;
+
+ if (nextStatus === "approved") {
+ setProjectApprovalSpu("");
+ setProjectApprovalDialogOpen(true);
+ return;
+ }
+
+ setProjectStatus(nextStatus);
+ setProjectReview((current) => ({
+ ...current,
+ decidedAt: new Date().toISOString(),
+ }));
+ setOpeningReference((current) => ({
+ ...current,
+ currentStage: getOpeningReferenceStageFromStatus(nextStatus),
+ }));
+
+ setDocumentWorkspaceView("document");
+ setActiveDocumentWorkspaceCategory(PROJECT_REVIEW_WORKSPACE_CATEGORY);
+
+ setSaveMessage(PROJECT_STATUS_COPY[nextStatus].saveMessage);
+ setSaveMessageType("success");
+ }
+
+ function handleConfirmProjectApproval() {
+ if (isReadOnly) return;
+
+ const normalizedSpu = projectApprovalSpu.trim().toUpperCase();
+ if (!normalizedSpu) {
+ setSaveMessage("请先填写正式 SPU");
+ setSaveMessageType("error");
+ return;
+ }
+
+ setFormValues((current) =>
+ buildOpeningReferenceFormValues(current, openingReference, normalizedSpu),
+ );
+ setProjectStatus("approved");
+ setProjectReview((current) => ({
+ ...current,
+ decidedAt: new Date().toISOString(),
+ }));
+ setOpeningReference((current) => ({
+ ...current,
+ temporaryCode: current.temporaryCode.trim() || normalizedSpu,
+ currentStage: getOpeningReferenceStageFromStatus("approved"),
+ }));
+ setDocumentWorkspaceView("detail");
+ setProjectApprovalDialogOpen(false);
+ setSaveMessage(PROJECT_STATUS_COPY.approved.saveMessage);
+ setSaveMessageType("success");
+ }
+
function renderDocumentWorkspaceConfirmationAction() {
if (isReadOnly || !activeDocumentWorkspaceNeedsConfirmation) {
return null;
@@ -10349,12 +10835,27 @@ export default function NonIntegratedDetailClient({
setIsSubmitting(true);
try {
- const nextProductId = buildNonIntegratedProductId(formValues.spu);
+ const draftSpu =
+ openingReference.temporaryCode.trim() ||
+ openingReference.productName.trim() ||
+ `KP-${Date.now()}`;
+ const nextOpeningReference = normalizeNonIntegratedOpeningReference({
+ ...openingReference,
+ productName: openingReference.productName.trim() || draftSpu,
+ temporaryCode: draftSpu,
+ currentStage: getOpeningReferenceStageFromStatus(projectStatus),
+ });
+ const nextFormValues = buildOpeningReferenceFormValues(
+ formValues,
+ nextOpeningReference,
+ draftSpu,
+ );
+ const nextProductId = buildNonIntegratedProductId(nextFormValues.spu);
await compactStoredProducts();
const normalizedProduct = await normalizeProductAssetsForStorage(
buildProductPayload(
nextProductId,
- formValues,
+ nextFormValues,
skuItems,
styleDetails,
bomDetails,
@@ -10366,6 +10867,9 @@ export default function NonIntegratedDetailClient({
uploadFiles,
documentWorkspaceNotes,
documentWorkspaceConfirmations,
+ nextOpeningReference,
+ projectReview,
+ projectStatus,
),
);
const savedProduct = shouldUseRemoteAssetStorage()
@@ -10385,6 +10889,32 @@ export default function NonIntegratedDetailClient({
}
}
+ function renderCreateFooter() {
+ if (isReadOnly || mode !== "create") return null;
+
+ return (
+
+
+
+
+
+
+ );
+ }
+
if (mode !== "create" && productId && !existingProduct) {
return (
@@ -11978,6 +12508,611 @@ export default function NonIntegratedDetailClient({
);
}
+ function renderOpeningReferenceWorkspace() {
+ const topFields: {
+ key: OpeningReferenceFieldKey;
+ label: string;
+ type?: BasicFieldType;
+ options?: string[];
+ placeholder?: string;
+ className?: string;
+ }[] = [
+ {
+ key: "productName",
+ label: "开品名称",
+ placeholder: "如:胡桃木色窄边书桌",
+ },
+ {
+ key: "temporaryCode",
+ label: "临时编号",
+ placeholder: "可先保留,立项后再生成正式 SPU",
+ },
+ {
+ key: "brand",
+ label: "品牌",
+ type: "select",
+ options: availableBrandOptions.length ? availableBrandOptions : BRAND_OPTIONS,
+ },
+ {
+ key: "productCategory",
+ label: "商品类目",
+ placeholder: "如:书桌",
+ },
+ {
+ key: "salesCategory",
+ label: "销售类目",
+ placeholder: "如:书桌",
+ },
+ {
+ key: "designer",
+ label: "设计师",
+ placeholder: "请输入设计师",
+ },
+ {
+ key: "planningOwner",
+ label: "企划负责人",
+ placeholder: "请输入企划负责人",
+ },
+ {
+ key: "currentStage",
+ label: "当前阶段",
+ type: "select",
+ options: OPENING_REFERENCE_STAGE_OPTIONS,
+ },
+ {
+ key: "year",
+ label: "年份",
+ placeholder: "如:2026",
+ },
+ ];
+ const demandFields: {
+ key: OpeningReferenceFieldKey;
+ label: string;
+ type?: BasicFieldType;
+ options?: string[];
+ placeholder?: string;
+ className?: string;
+ }[] = [
+ {
+ key: "competitorLink",
+ label: "竞品链接",
+ placeholder: "粘贴竞品链接,可多个链接用换行分隔",
+ type: "textarea",
+ className: "xl:col-span-2",
+ },
+ {
+ key: "targetPriceBand",
+ label: "目标价格带",
+ placeholder: "如:399-499",
+ },
+ {
+ key: "size",
+ label: "尺寸",
+ placeholder: "如:140/60/78",
+ },
+ {
+ key: "material",
+ label: "材质",
+ placeholder: "如:18ENF级橡胶木",
+ },
+ {
+ key: "urgent",
+ label: "是否急开",
+ type: "select",
+ options: ["否", "是"],
+ },
+ {
+ key: "reason",
+ label: "开品理由",
+ placeholder: "说明为什么要开这个品,目标机会点是什么。",
+ type: "textarea",
+ className: "xl:col-span-6",
+ },
+ ];
+ const openingReferenceFiles = activeDocumentWorkspaceFiles;
+ const renderCompactOpeningReferenceField = (field: {
+ key: OpeningReferenceFieldKey;
+ label: string;
+ type?: BasicFieldType;
+ options?: string[];
+ placeholder?: string;
+ className?: string;
+ }) => (
+
+
+ {field.type === "textarea" ? (
+
+ );
+
+ return (
+
+
+
+ 开品参考填写
+
+
+ 开品前只先记录参考图、品牌方向、类目、竞品和开品理由。立项通过后再补完整基础信息。
+
+
+
+
+
+
+ void handleOpeningReferenceImageChange(event)
+ }
+ onPreview={() =>
+ openingReference.productImage
+ ? setWorkspaceImagePreview({
+ kind: "standalone",
+ src: openingReference.productImage,
+ alt:
+ openingReference.productName.trim() ||
+ "开品参考图片",
+ })
+ : undefined
+ }
+ compact
+ readOnly={isReadOnly}
+ />
+
+
+ {topFields.map((field) =>
+ renderCompactOpeningReferenceField(field),
+ )}
+
+
+
+
+
+ {demandFields.map((field) =>
+ renderCompactOpeningReferenceField(field),
+ )}
+
+
+
+
+
+
+ 参考资料
+
+
+ 上传竞品截图、参考文档或前期资料,支持下载和删除。
+
+
+ {isReadOnly ? null : (
+
+
+
+ void handleUploadFilesChange(
+ event,
+ OPENING_REFERENCE_WORKSPACE_CATEGORY,
+ )
+ }
+ />
+
+ )}
+
+
+ {openingReferenceFiles.length === 0 ? (
+
+
+ 当前还没有参考资料
+
+
+ 可以先把竞品截图、价格表、需求文档放在这里,后续立项评审时直接引用。
+
+
+ ) : (
+
+
+
+
+ | 序号 |
+ 文档名称 |
+ 类型 |
+
+ 操作
+ |
+
+
+
+ {openingReferenceFiles.map((file, index) => (
+
+ |
+ {index + 1}
+ |
+
+
+ {file.name}
+
+ |
+
+ {getUploadFileTypeLabel(file)}
+ |
+
+
+
+ {isReadOnly ? null : (
+
+ )}
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
+ );
+ }
+
+ function renderProjectReviewWorkspace() {
+ const statusCopy = PROJECT_STATUS_COPY[projectStatus];
+ const sourceCards: {
+ category: DocumentWorkspaceCategory;
+ title: string;
+ description: string;
+ }[] = [
+ {
+ category: OPENING_REFERENCE_WORKSPACE_CATEGORY,
+ title: "开品参考",
+ description: "竞品链接、参考图、价格段、开品理由等前置判断资料。",
+ },
+ {
+ category: PRODUCT_DATA_WORKSPACE_CATEGORY,
+ title: "产品资料",
+ description: "打样资料、产品模型、线稿爆炸图、孔位图等开发依据。",
+ },
+ {
+ category: "设计卖点",
+ title: "设计卖点",
+ description: "卖点图、文案方向、差异化表达和展示重点。",
+ },
+ ];
+ const decisionButtons: {
+ status: NonIntegratedProjectStatus;
+ label: string;
+ className: string;
+ }[] = [
+ {
+ status: "revision",
+ label: "退回修改",
+ className:
+ "border-[#c7ddec] bg-[#e8f1fb] text-[#23659b] hover:bg-[#dcecf8]",
+ },
+ {
+ status: "rejected",
+ label: "废弃不开",
+ className:
+ "border-[#f4c7ce] bg-[#ffe6e9] text-[#a32135] hover:bg-[#ffdbe0]",
+ },
+ {
+ status: "approved",
+ label: "立项通过",
+ className:
+ "border-[#203542] bg-[#203542] text-white hover:bg-[#162833]",
+ },
+ ];
+ const decidedAtDate = projectReview.decidedAt
+ ? new Date(projectReview.decidedAt)
+ : null;
+ const decidedAtText =
+ decidedAtDate && !Number.isNaN(decidedAtDate.getTime())
+ ? decidedAtDate.toLocaleString()
+ : projectReview.decidedAt;
+
+ return (
+
+
+
+
+
+
+ 立项评审
+
+
+ 先汇总前置资料,再记录会议判断。内容字段后面可以继续按你们真实会议优化。
+
+
+
+ {statusCopy.label}
+
+
+
+
+
+ {sourceCards.map((source) => {
+ const fileCount =
+ documentWorkspaceCountByCategory[source.category] ?? 0;
+ const note = documentWorkspaceNotes[source.category]?.trim();
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+
+ handleProjectReviewFieldChange(
+ "reviewDate",
+ event.target.value,
+ )
+ }
+ readOnly={isReadOnly}
+ className={`h-10 w-full rounded-apple-lg border border-apple-separator px-3 text-sm outline-none ${
+ isReadOnly
+ ? "bg-apple-bg-secondary text-apple-label-secondary"
+ : "bg-apple-bg-primary text-apple-label-primary"
+ }`}
+ />
+
+
+
+
+ handleProjectReviewFieldChange(
+ "reviewers",
+ event.target.value,
+ )
+ }
+ readOnly={isReadOnly}
+ placeholder="如:产品负责人、采购、设计、运营"
+ className={`h-10 w-full rounded-apple-lg border border-apple-separator px-3 text-sm outline-none ${
+ isReadOnly
+ ? "bg-apple-bg-secondary text-apple-label-secondary"
+ : "bg-apple-bg-primary text-apple-label-primary"
+ }`}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
function renderProductDataWorkspace() {
return (
- {mode === "create" || documentWorkspaceView === "detail" ? (
+ {documentWorkspaceView === "detail" ? (
{mode === "create" ? (
>
) : null}
- {isReadOnly || mode !== "create" ? null : (
-
-
-
-
-
-
- )}
+ {renderCreateFooter()}
) : (
-
+
+ ) : isProjectReviewWorkspace ? (
+
+ {isReadOnly ? null : (
+ <>
+
+
+ void handleUploadFilesChange(
+ event,
+ activeDocumentWorkspaceCategory,
+ )
+ }
+ />
+ >
+ )}
+
+
+ {isReadOnly ? null : (
+
+ )}
+ {renderDocumentWorkspaceConfirmationAction()}
+
) : isProductPhotoWorkspace ? (
{isReadOnly ? null : (
@@ -15409,7 +16601,11 @@ export default function NonIntegratedDetailClient({
data-testid="document-workspace-panel"
className="flex min-h-0 flex-1 flex-col gap-2"
>
- {isSampleMeasurementWorkspace ? (
+ {isOpeningReferenceWorkspace ? (
+ renderOpeningReferenceWorkspace()
+ ) : isProjectReviewWorkspace ? (
+ renderProjectReviewWorkspace()
+ ) : isSampleMeasurementWorkspace ? (
renderSampleMeasurementWorkspace()
) : isProductFactoryWorkspace ? (
renderProductFactoryWorkspace()
@@ -15736,6 +16932,8 @@ export default function NonIntegratedDetailClient({
)}
+ {renderCreateFooter()}
+
)}
@@ -15768,35 +16966,7 @@ export default function NonIntegratedDetailClient({
-
- {groupedUploadFiles.map(({ category, files }) => {
+ {visibleGroupedUploadFiles.map(({ category, files }, index) => {
const isActive =
documentWorkspaceView === "document" &&
activeDocumentWorkspaceCategory === category;
@@ -15806,10 +16976,44 @@ export default function NonIntegratedDetailClient({
const hasFiles = fileCount > 0;
const needsConfirmation =
!documentWorkspaceConfirmations[category];
+ const shouldInsertBasicInfoTrigger =
+ isProjectApproved && index === PROJECT_SETUP_NODE_COUNT;
return (
+
+ {shouldInsertBasicInfoTrigger ? (
+
+ ) : null}
+
);
})}
@@ -15914,6 +17119,57 @@ export default function NonIntegratedDetailClient({
navigationLabel={workspacePreviewDialogLabel}
/>
) : null}
+ {projectApprovalDialogOpen ? (
+
+ ) : null}
setBarcodeScannerSkuId(null)}
diff --git a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.test.ts b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.test.ts
index 25056203..2ca0f0cd 100644
--- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.test.ts
+++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.test.ts
@@ -1,6 +1,7 @@
import {
createCopiedNonIntegratedProduct,
createDefaultDocumentWorkspaceConfirmations,
+ createEmptyNonIntegratedOpeningReference,
getNonIntegratedProductById,
deleteNonIntegratedProduct,
NON_INTEGRATED_STORAGE_KEY,
@@ -13,6 +14,15 @@ function createProduct(overrides?: Partial): NonInte
return {
id: 'product-1',
spu: 'YMJ0110',
+ projectStatus: 'approved',
+ projectReview: {
+ reviewDate: '',
+ reviewers: '',
+ conclusion: '',
+ revisionNotes: '',
+ decidedAt: '',
+ },
+ openingReference: createEmptyNonIntegratedOpeningReference(),
designer: '设计师A',
operator: '运营A',
purchaser: '采购A',
@@ -95,6 +105,35 @@ describe('nonIntegratedStorage', () => {
expect(savedProducts[0].spu).toBe('YMJ0110')
})
+ it('keeps opening reference fields when saving locally', () => {
+ saveNonIntegratedProduct(
+ createProduct({
+ openingReference: {
+ ...createEmptyNonIntegratedOpeningReference(),
+ productName: '窄边书桌开品',
+ brand: '基本盘',
+ adaptableBrands: ['基本盘', '顾家'],
+ competitorLink: 'https://example.com/item',
+ targetPriceBand: '399-499',
+ material: '18ENF级橡胶木',
+ urgent: '是',
+ reason: '补齐书房价格带',
+ },
+ }),
+ )
+
+ const savedProduct = getNonIntegratedProductById('product-1')
+ expect(savedProduct?.openingReference).toMatchObject({
+ productName: '窄边书桌开品',
+ adaptableBrands: ['基本盘', '顾家'],
+ competitorLink: 'https://example.com/item',
+ targetPriceBand: '399-499',
+ material: '18ENF级橡胶木',
+ urgent: '是',
+ reason: '补齐书房价格带',
+ })
+ })
+
it('keeps bom details when saving the local record', () => {
saveNonIntegratedProduct(
createProduct({
diff --git a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts
index 35435b5d..8ebc8510 100644
--- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts
+++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts
@@ -31,12 +31,14 @@ export const NON_INTEGRATED_BRAND_PREFIX_MAP: Record = {
};
export const NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES = [
- "产品实拍图",
- "评审资料",
+ "开品参考",
+ "产品模型",
"设计卖点",
+ "立项评审",
+ "评审资料",
+ "产品实拍图",
"安装方式",
"包装细节",
- "产品模型",
"产品定厂",
"样包测量",
"产品核价",
@@ -52,10 +54,94 @@ export function getNonIntegratedDocumentWorkspaceCategoryLabel(
) {
if (category === "产品实拍图") return "产品实拍";
if (category === "产品模型") return "产品资料";
+ if (category === "评审资料") return "样品评审";
if (category === "检测报告") return "仓库抽检";
return category;
}
+export type NonIntegratedProjectStatus =
+ | "draft"
+ | "revision"
+ | "rejected"
+ | "approved";
+
+export type NonIntegratedProjectReview = {
+ reviewDate: string;
+ reviewers: string;
+ conclusion: string;
+ revisionNotes: string;
+ decidedAt: string;
+};
+
+export type NonIntegratedOpeningReference = {
+ productImage: string;
+ productImageName: string;
+ productName: string;
+ temporaryCode: string;
+ brand: string;
+ adaptableBrands: string[];
+ productCategory: string;
+ salesCategory: string;
+ designer: string;
+ planningOwner: string;
+ currentStage: string;
+ year: string;
+ competitorLink: string;
+ targetPriceBand: string;
+ size: string;
+ material: string;
+ urgent: string;
+ reason: string;
+};
+
+export function createEmptyNonIntegratedProjectReview(): NonIntegratedProjectReview {
+ return {
+ reviewDate: "",
+ reviewers: "",
+ conclusion: "",
+ revisionNotes: "",
+ decidedAt: "",
+ };
+}
+
+export function createEmptyNonIntegratedOpeningReference(): NonIntegratedOpeningReference {
+ return {
+ productImage: "",
+ productImageName: "",
+ productName: "",
+ temporaryCode: "",
+ brand: "",
+ adaptableBrands: [],
+ productCategory: "",
+ salesCategory: "",
+ designer: "",
+ planningOwner: "",
+ currentStage: "",
+ year: "",
+ competitorLink: "",
+ targetPriceBand: "",
+ size: "",
+ material: "",
+ urgent: "",
+ reason: "",
+ };
+}
+
+export function normalizeNonIntegratedProjectStatus(
+ value: unknown,
+): NonIntegratedProjectStatus {
+ if (
+ value === "draft" ||
+ value === "revision" ||
+ value === "rejected" ||
+ value === "approved"
+ ) {
+ return value;
+ }
+
+ return "approved";
+}
+
export type NonIntegratedDocumentWorkspaceConfirmationMap = Record<
NonIntegratedDocumentWorkspaceCategory,
boolean
@@ -74,7 +160,10 @@ export function createDefaultDocumentWorkspaceConfirmations(
const NON_INTEGRATED_UPLOAD_CATEGORY_MAP: Record = {
未分类: "未分类",
+ 开品参考: "开品参考",
+ 立项评审: "立项评审",
评审资料: "评审资料",
+ 样品评审: "评审资料",
资料信息: "评审资料",
产品模型: "产品模型",
产品实拍图: "产品实拍图",
@@ -286,6 +375,9 @@ export type NonIntegratedQualityInspectionSheet = {
export type NonIntegratedProductRecord = {
id: string;
spu: string;
+ projectStatus: NonIntegratedProjectStatus;
+ projectReview: NonIntegratedProjectReview;
+ openingReference: NonIntegratedOpeningReference;
brand: string;
adaptableBrands: string[];
designer: string;
@@ -403,6 +495,61 @@ function normalizeString(value: unknown) {
return typeof value === "string" ? value : "";
}
+export function normalizeNonIntegratedProjectReview(
+ value: unknown,
+): NonIntegratedProjectReview {
+ if (!value || typeof value !== "object") {
+ return createEmptyNonIntegratedProjectReview();
+ }
+
+ const item = value as Partial;
+
+ return {
+ reviewDate: normalizeString(item.reviewDate),
+ reviewers: normalizeString(item.reviewers),
+ conclusion: normalizeString(item.conclusion),
+ revisionNotes: normalizeString(item.revisionNotes),
+ decidedAt: normalizeString(item.decidedAt),
+ };
+}
+
+export function normalizeNonIntegratedOpeningReference(
+ value: unknown,
+): NonIntegratedOpeningReference {
+ if (!value || typeof value !== "object") {
+ return createEmptyNonIntegratedOpeningReference();
+ }
+
+ const item = value as Partial;
+
+ return {
+ productImage: normalizeNonIntegratedAssetUrl(
+ normalizeString(item.productImage),
+ ),
+ productImageName: normalizeString(item.productImageName),
+ productName: normalizeString(item.productName),
+ temporaryCode: normalizeString(item.temporaryCode),
+ brand: normalizeString(item.brand),
+ adaptableBrands: Array.isArray(item.adaptableBrands)
+ ? item.adaptableBrands.filter(
+ (brand): brand is string => typeof brand === "string",
+ )
+ : [],
+ productCategory: normalizeString(item.productCategory),
+ salesCategory: normalizeString(item.salesCategory),
+ designer: normalizeString(item.designer),
+ planningOwner: normalizeString(item.planningOwner),
+ currentStage: normalizeString(item.currentStage),
+ year: normalizeString(item.year),
+ competitorLink: normalizeString(item.competitorLink),
+ targetPriceBand: normalizeString(item.targetPriceBand),
+ size: normalizeString(item.size),
+ material: normalizeString(item.material),
+ urgent: normalizeString(item.urgent),
+ reason: normalizeString(item.reason),
+ };
+}
+
function normalizeStringRecord(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
@@ -1019,6 +1166,11 @@ function toProductRecord(
return {
id: item.id,
spu: item.spu,
+ projectStatus: normalizeNonIntegratedProjectStatus(item.projectStatus),
+ projectReview: normalizeNonIntegratedProjectReview(item.projectReview),
+ openingReference: normalizeNonIntegratedOpeningReference(
+ item.openingReference,
+ ),
brand: normalizeString(item.brand) || "基本盘",
adaptableBrands: Array.isArray(item.adaptableBrands)
? item.adaptableBrands.filter(
@@ -1257,6 +1409,11 @@ export function saveNonIntegratedProduct(
const nextProduct: NonIntegratedProductRecord = {
...product,
+ projectStatus: normalizeNonIntegratedProjectStatus(product.projectStatus),
+ projectReview: normalizeNonIntegratedProjectReview(product.projectReview),
+ openingReference: normalizeNonIntegratedOpeningReference(
+ product.openingReference,
+ ),
documentWorkspaceConfirmations: normalizeDocumentWorkspaceConfirmations(
product.documentWorkspaceConfirmations,
),
@@ -1299,6 +1456,18 @@ export function createCopiedNonIntegratedProduct(
...sourceProduct,
id: buildNonIntegratedProductId(nextSpu),
spu: nextSpu,
+ projectStatus: normalizeNonIntegratedProjectStatus(
+ sourceProduct.projectStatus,
+ ),
+ projectReview: normalizeNonIntegratedProjectReview(
+ sourceProduct.projectReview,
+ ),
+ openingReference: {
+ ...normalizeNonIntegratedOpeningReference(sourceProduct.openingReference),
+ temporaryCode: nextSpu,
+ brand: targetBrand,
+ adaptableBrands: [targetBrand],
+ },
brand: targetBrand,
adaptableBrands: [targetBrand],
styleDetails: sourceProduct.styleDetails.map((detail) => ({