From 3f6a67afdeab832518a3c0a48b97e922ad136d54 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 10 Apr 2026 20:16:55 +0800 Subject: [PATCH] feat: streamline assisted assembly direct workspace flow --- .../AssistedAssemblyClient.test.tsx | 21 +- .../AssistedAssemblyClient.tsx | 577 +++++++++--------- .../src/app/assisted-assembly/page.tsx | 11 + .../components/platform/AssemblyWorkbench.tsx | 128 +++- .../platform/AssemblyWorkbenchCanvas.tsx | 96 ++- .../__tests__/AssemblyWorkbench.test.tsx | 52 ++ .../__tests__/assembly-workspace.test.ts | 40 ++ .../lib/open-product/assembly-workspace.ts | 47 ++ 8 files changed, 677 insertions(+), 295 deletions(-) diff --git a/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.test.tsx b/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.test.tsx index 2aa1f969..d5550c49 100644 --- a/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.test.tsx +++ b/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.test.tsx @@ -126,7 +126,7 @@ describe('AssistedAssemblyClient', () => { mockSearchParams = new URLSearchParams() }) - it('选资产族后直接进入手动拼装工作台', async () => { + it('选资产族后会直接同步到手动拼装工作台', async () => { const actor = userEvent.setup() render( @@ -187,12 +187,15 @@ describe('AssistedAssemblyClient', () => { />, ) + expect(screen.getByText('手动拼装工作台')).toBeInTheDocument() + expect(screen.getByText('库存:0')).toBeInTheDocument() + await actor.click(screen.getByRole('checkbox', { name: /桌面A/ })) await actor.click(screen.getByRole('checkbox', { name: /桌腿A/ })) - await actor.click(screen.getByRole('button', { name: /进入手动拼装工作台/ })) - expect(screen.getByText('手动拼装工作台')).toBeInTheDocument() - expect(screen.getByText('库存:2')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText('库存:2')).toBeInTheDocument() + }) }) it('保存草稿组时会发送手动拼装 assemblies 输入', async () => { @@ -240,7 +243,6 @@ describe('AssistedAssemblyClient', () => { ) await actor.click(screen.getByRole('checkbox', { name: /桌面A/ })) - await actor.click(screen.getByRole('button', { name: /进入手动拼装工作台/ })) await actor.click(screen.getByRole('button', { name: /准备可保存工作台/ })) await actor.clear(screen.getByLabelText('草稿组名称')) await actor.type(screen.getByLabelText('草稿组名称'), '测试草稿组') @@ -260,7 +262,6 @@ describe('AssistedAssemblyClient', () => { }) it('资产中心带草稿资产族进入时,会先预选资产族,并在进入工作台后保留草稿规格款作为默认选择', async () => { - const actor = userEvent.setup() mockSearchParams = new URLSearchParams( 'templateCode=single-board-desk&source=asset-center&desktopFamilyIds=family-board-draft', ) @@ -306,16 +307,10 @@ describe('AssistedAssemblyClient', () => { await waitFor(() => { expect(screen.getByRole('checkbox', { name: /草稿桌面/ })).toBeChecked() - expect(screen.queryByText('手动拼装工作台')).not.toBeInTheDocument() - expect(screen.queryByText('来自资产中心')).not.toBeInTheDocument() - }) - - await actor.click(screen.getByRole('button', { name: /进入手动拼装工作台/ })) - - await waitFor(() => { expect(screen.getByText('手动拼装工作台')).toBeInTheDocument() expect(screen.getByText('库存:1')).toBeInTheDocument() expect(screen.getByText('首个规格款:variant-board-draft')).toBeInTheDocument() + expect(screen.queryByText('来自资产中心')).not.toBeInTheDocument() }) }) }) diff --git a/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.tsx b/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.tsx index 12ced624..aea1949c 100644 --- a/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.tsx +++ b/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.tsx @@ -5,30 +5,27 @@ import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { ArrowRight, - CheckSquare2, - Layers3, - Plug, Sparkles, - SquareAsterisk, - Table2, } from 'lucide-react' import { Badge } from '@/components/ui/Badge' import Button from '@/components/ui/Button' import Checkbox from '@/components/ui/Checkbox' import Input, { Textarea } from '@/components/ui/Input' -import { AccordionSection } from '@/components/platform/AccordionSection' import { AssemblyWorkbench } from '@/components/platform/AssemblyWorkbench' import { PlatformSplitLayout } from '@/components/platform/PlatformSplitLayout' import type { PlatformMetricItem } from '@/components/platform/types' import { createDraftGroup } from '@/lib/open-product/platform-api' import { buildAssemblyWorkspaceFamilies, - buildManualAssemblyWorkspace, deriveAssemblySaveValidation, + syncManualAssemblyWorkspace, type AssemblyWorkspaceSnapshot, type AssemblyWorkspaceSource, } from '@/lib/open-product/assembly-workspace' -import { parseManualAssemblyLaunchParams } from '@/lib/open-product/manual-assembly-launch' +import { + parseManualAssemblyLaunchParams, + type ManualAssemblyLaunchParams, +} from '@/lib/open-product/manual-assembly-launch' import type { OpenProductAssetFamily } from '@/lib/open-product/types' import type { AssetFamilyDTO, AssetVariantDTO, SeriesTemplateDTO } from '@/server/services' @@ -38,6 +35,7 @@ interface AssistedAssemblyClientProps { variants: AssetVariantDTO[] metrics: PlatformMetricItem[] headerTools?: ReactNode + initialLaunchParams?: ManualAssemblyLaunchParams | null } function includesValue(values: string[], target: string) { @@ -51,6 +49,14 @@ function toggleSelection(list: string[], value: string) { return [...list, value] } +function filterSelectionsByVisibleFamilies(current: string[], visibleIds: Set) { + const filtered = current.filter((id) => visibleIds.has(id)) + if (filtered.length === current.length && filtered.every((id, index) => id === current[index])) { + return current + } + return filtered +} + function getVariantStatusPriority(status: AssetVariantDTO['status']) { if (status === 'AVAILABLE') return 0 if (status === 'DRAFT') return 1 @@ -113,13 +119,15 @@ export default function AssistedAssemblyClient({ families, variants, metrics, + initialLaunchParams = null, }: AssistedAssemblyClientProps) { const router = useRouter() const searchParams = useSearchParams() - const launchParams = useMemo( + const searchLaunchParams = useMemo( () => parseManualAssemblyLaunchParams(searchParams), [searchParams], ) + const launchParams = initialLaunchParams ?? searchLaunchParams const [templateId, setTemplateId] = useState(templates[0]?.id ?? '') const [desktopIds, setDesktopIds] = useState([]) @@ -127,7 +135,7 @@ export default function AssistedAssemblyClient({ const [connectorIds, setConnectorIds] = useState([]) const [workspace, setWorkspace] = useState(null) const [workspaceSource, setWorkspaceSource] = - useState('assisted-assembly') + useState(initialLaunchParams?.source ?? 'assisted-assembly') const [draftGroupName, setDraftGroupName] = useState('') const [notes, setNotes] = useState('') const [loading, setLoading] = useState(false) @@ -204,14 +212,58 @@ export default function AssistedAssemblyClient({ () => buildAssemblyDefaultName(selectedTemplate, selectedFamilyMap), [selectedFamilyMap, selectedTemplate], ) + const visibleDesktopFamilyIds = useMemo(() => new Set(desktopFamilies.map((family) => family.id)), [desktopFamilies]) + const visibleLegFamilyIds = useMemo(() => new Set(legFamilies.map((family) => family.id)), [legFamilies]) + const visibleConnectorFamilyIds = useMemo( + () => new Set(connectorFamilies.map((family) => family.id)), + [connectorFamilies], + ) + const selectedFamilyCount = + selectedFamilyMap.desktops.length + + selectedFamilyMap.legs.length + + selectedFamilyMap.connectors.length const saveValidation = workspace ? deriveAssemblySaveValidation(workspace) : null - const canStartWorkspace = - Boolean(selectedTemplate) && - (desktopIds.length > 0 || legIds.length > 0 || connectorIds.length > 0) const canCreateDraftGroup = Boolean(workspace) && Boolean(draftGroupName.trim()) && saveValidation?.level !== 'BLOCKED' + const workspaceMainNodes = workspace?.nodes.filter((node) => node.zone === 'main') ?? [] + const workspaceMainDesktopCount = workspaceMainNodes.filter((node) => node.source === 'desktop').length + const workspaceConnectedNodeIdSet = useMemo( + () => + new Set( + (workspace?.relations ?? []) + .filter((relation) => relation.status === 'CONNECTED') + .map((relation) => relation.sourceNodeId), + ), + [workspace], + ) + const workspacePendingMainNodeCount = workspaceMainNodes.filter( + (node) => node.source !== 'desktop' && !workspaceConnectedNodeIdSet.has(node.nodeId), + ).length + const nextActionText = !selectedTemplate + ? '先选模板。' + : selectedFamilyCount === 0 + ? '先在右侧勾选要参与这次拼装的资产族。' + : !workspace || workspace.inventory.length === 0 + ? '已选资产会直接进入左侧物料区。' + : workspaceMainDesktopCount === 0 + ? '先从左侧物料区选规格款,再把 1 个桌面板放进主装配区。' + : workspacePendingMainNodeCount > 0 + ? `还有 ${workspacePendingMainNodeCount} 个主装配零件未连接,先完成吸附连接。` + : '当前拼装结果已经可以保存到草稿组。' + const saveChecklist = [ + { + key: 'desktop', + label: '主装配区保留 1 个桌面板', + passed: workspace ? workspaceMainDesktopCount === 1 : false, + }, + { + key: 'connection', + label: '主装配区里的支撑件和连接件都已连接', + passed: workspace ? workspacePendingMainNodeCount === 0 : false, + }, + ] useEffect(() => { if (launchAppliedRef.current) { @@ -251,13 +303,26 @@ export default function AssistedAssemblyClient({ if (nextDesktopIds.length > 0 || nextLegIds.length > 0 || nextConnectorIds.length > 0) { setFeedback({ tone: 'success', - text: '已带入当前选中的资产族,请确认后进入手动拼装工作台。', + text: '已带入当前选中的资产族,左侧物料区已同步更新。', }) } }, [defaultVariantIdsByFamilyId, families, launchParams, templates]) - function handleOpenWorkspace() { + useEffect(() => { + setDesktopIds((current) => filterSelectionsByVisibleFamilies(current, visibleDesktopFamilyIds)) + }, [visibleDesktopFamilyIds]) + + useEffect(() => { + setLegIds((current) => filterSelectionsByVisibleFamilies(current, visibleLegFamilyIds)) + }, [visibleLegFamilyIds]) + + useEffect(() => { + setConnectorIds((current) => filterSelectionsByVisibleFamilies(current, visibleConnectorFamilyIds)) + }, [visibleConnectorFamilyIds]) + + useEffect(() => { if (!selectedTemplate) { + setWorkspace(null) return } @@ -266,26 +331,28 @@ export default function AssistedAssemblyClient({ ...selectedFamilyMap.legs, ...selectedFamilyMap.connectors, ] - - setWorkspaceSource((current) => (launchParams?.source ?? current)) - setWorkspace( - buildManualAssemblyWorkspace({ - templateCode: selectedTemplate.code, - source: launchParams?.source ?? workspaceSource, - families: buildAssemblyWorkspaceFamilies( - selectedFamilies.map(toOpenProductFamily), - defaultVariantIdsByFamilyId, - ), - }), + const nextFamilies = buildAssemblyWorkspaceFamilies( + selectedFamilies.map(toOpenProductFamily), + defaultVariantIdsByFamilyId, ) + + setWorkspace((current) => { + const nextWorkspace = syncManualAssemblyWorkspace(current, { + templateCode: selectedTemplate.code, + source: workspaceSource, + families: nextFamilies, + }) + + if (current && JSON.stringify(current) === JSON.stringify(nextWorkspace)) { + return current + } + + return nextWorkspace + }) setDraftGroupName((current) => current.trim().length > 0 ? current : buildDraftGroupDefaultName(selectedTemplate), ) - setFeedback({ - tone: 'success', - text: '已按当前选料进入手动拼装工作台。', - }) - } + }, [defaultVariantIdsByFamilyId, selectedFamilyMap, selectedTemplate, workspaceSource]) async function handleCreateDraftGroup() { if (!workspace || !selectedTemplate) { @@ -335,15 +402,56 @@ export default function AssistedAssemblyClient({ } } - const currentStep = workspace - ? canCreateDraftGroup - ? 4 - : 3 - : canStartWorkspace - ? 2 - : 1 - const hasAnySelection = - templateId || desktopIds.length > 0 || legIds.length > 0 || connectorIds.length > 0 + function renderFamilySelectionBlock({ + title, + emptyText, + families: blockFamilies, + selectedIds, + onToggle, + }: { + title: string + emptyText: string + families: AssetFamilyDTO[] + selectedIds: string[] + onToggle: (familyId: string) => void + }) { + return ( +
+
+
+

{title}

+

+ {blockFamilies.length} 个资产族 +

+
+ {selectedIds.length > 0 ? ( + + 已选 {selectedIds.length} + + ) : null} +
+ + {blockFamilies.length === 0 ? ( +

{emptyText}

+ ) : ( +
+ {blockFamilies.map((family) => ( + onToggle(family.id)} + label={family.name} + description={`${family.code} · ${availableVariantMap.get(family.id)?.length ?? 0} 个规格款`} + containerClassName="w-full" + labelClassName="text-sm font-apple-medium text-apple-label-primary" + descriptionClassName="text-[11px] text-apple-label-tertiary" + /> + ))} +
+ )} +
+ ) + } const leftContent = (
@@ -357,280 +465,203 @@ export default function AssistedAssemblyClient({ availableVariants={variants} onChange={setWorkspace} /> - ) : !hasAnySelection ? ( + ) : (

- 从右侧开始选模板和资产族 + 正在准备工作台

- 这条链路现在不再先生成候选组合。选好资产族后会直接进入手动拼装工作台,再在工作台里选具体规格款。 -

-
- ) : ( -
-
- -
-

- 选料完成,进入工作台 -

-

- 已选 {desktopIds.length + legIds.length + connectorIds.length} 个资产族。点击右侧按钮后,左侧会直接切换到手动拼装工作台。 + 当前模板与选料正在同步到左侧工作台。

)}
) - const steps = [ - { num: 1, label: '选模板', done: Boolean(templateId) }, - { num: 2, label: '勾资产族', done: desktopIds.length + legIds.length + connectorIds.length > 0 }, - { num: 3, label: '进工作台', done: Boolean(workspace) }, - { num: 4, label: '存草稿组', done: false }, - ] - const rightContent = ( <> -
- {steps.map((step, index) => ( -
-
-
- {step.done ? '✓' : step.num} +
+
+
+

当前模板

+

+ {selectedTemplate?.name ?? '未选择模板'} +

+
+ 0 ? 'success' : 'default'} variant="subtle"> + {workspace ? '工作台中' : `已选 ${selectedFamilyCount}`} + +
+

{nextActionText}

+
+ 桌面板 {selectedFamilyMap.desktops.length} + 支撑件 {selectedFamilyMap.legs.length} + 连接件 {selectedFamilyMap.connectors.length} +
+
+ 右侧只负责选资产族,左侧始终是同一套 3D 操作区和物料区。 +
+
+ +
+

参与资产

+ +
+
+

系列模板

+ {templates.length <= 1 ? ( +

+ {selectedTemplate?.name ?? templates[0]?.name ?? '暂无模板'} +

+ ) : ( +
+ {templates.map((template) => ( + + ))}
- - {step.label} - -
- {index < steps.length - 1 ? ( -
- ) : null} + )}
- ))} -
- } - defaultExpanded - > -
- {templates.map((template) => ( - - ))} + {renderFamilySelectionBlock({ + title: '桌面板', + emptyText: '暂无可用桌面板资产族', + families: desktopFamilies, + selectedIds: desktopIds, + onToggle: (familyId) => setDesktopIds(toggleSelection(desktopIds, familyId)), + })} + + {renderFamilySelectionBlock({ + title: '支撑件', + emptyText: '暂无可用支撑件资产族', + families: legFamilies, + selectedIds: legIds, + onToggle: (familyId) => setLegIds(toggleSelection(legIds, familyId)), + })} + + {renderFamilySelectionBlock({ + title: '连接件', + emptyText: '暂无可用连接件资产族', + families: connectorFamilies, + selectedIds: connectorIds, + onToggle: (familyId) => setConnectorIds(toggleSelection(connectorIds, familyId)), + })}
-
+
- } - aside={ - desktopIds.length > 0 ? ( - - 已选 {desktopIds.length} - - ) : null - } - defaultExpanded - > - {desktopFamilies.length === 0 ? ( -

暂无可用桌面板资产族

- ) : ( -
- {desktopFamilies.map((family) => ( - setDesktopIds(toggleSelection(desktopIds, family.id))} - label={family.name} - description={`${family.code} · ${availableVariantMap.get(family.id)?.length ?? 0} 个可用规格款`} - /> - ))} -
- )} -
- - } - aside={ - legIds.length > 0 ? ( - - 已选 {legIds.length} - - ) : null - } - defaultExpanded - > - {legFamilies.length === 0 ? ( -

暂无可用支撑件资产族

- ) : ( -
- {legFamilies.map((family) => ( - setLegIds(toggleSelection(legIds, family.id))} - label={family.name} - description={`${family.code} · ${availableVariantMap.get(family.id)?.length ?? 0} 个可用规格款`} - /> - ))} -
- )} -
- - } - aside={ - connectorIds.length > 0 ? ( - - 已选 {connectorIds.length} - - ) : null - } - defaultExpanded - > - {connectorFamilies.length === 0 ? ( -

暂无可用连接件资产族

- ) : ( -
- {connectorFamilies.map((family) => ( - setConnectorIds(toggleSelection(connectorIds, family.id))} - label={family.name} - description={`${family.code} · ${availableVariantMap.get(family.id)?.length ?? 0} 个可用规格款`} - /> - ))} -
- )} -
- -
-
-
-

- 手动拼装入口 -

-

- 选好资产族后直接进入工作台,真正的规格款在工作台里选。 +

+
+
+

草稿组输出

+

+ {workspace ? '当前结果可在这里保存' : '工作台就绪后再保存'}

- + {workspace ? ( + + {canCreateDraftGroup ? '可保存' : '待处理'} + + ) : null}
-
- } - defaultExpanded - > -
+ {!workspace ? ( +
+ 进入工作台后,再命名并保存草稿组。 +
+ ) : ( +
{feedback ? (
{feedback.text}
) : null} - {saveValidation?.level === 'BLOCKED' ? ( -
- {saveValidation.blockingIssues[0]} +
+ {saveChecklist.map((item) => ( +
+ {item.label} + + {item.passed ? '已满足' : '未满足'} + +
+ ))} + {saveValidation?.level === 'BLOCKED' ? ( +
+ {saveValidation.blockingIssues[0]} +
+ ) : null} + {saveValidation?.level === 'WARNING' ? ( +
+ {saveValidation.warnings[0]} +
+ ) : null} + {workspace.validation.level === 'WARNING' ? ( +
+ 编辑态还有装配提示,先在工作台里继续处理。 +
+ ) : null}
- ) : null} - {workspace ? ( -
- 当前工作台已进入编辑阶段。 -
- 编辑校验:{workspace.validation.level === 'PASSED' ? '成立' : '需继续确认'} -
- ) : null} - - setDraftGroupName(event.target.value)} - placeholder="例如 云序-手动拼装草稿组" - /> -