feat: streamline assisted assembly direct workspace flow

This commit is contained in:
WuYongJie 2026-04-10 20:16:55 +08:00
parent 4b4173abde
commit 3f6a67afde
8 changed files with 677 additions and 295 deletions

View File

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

View File

@ -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<string>) {
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<string[]>([])
@ -127,7 +135,7 @@ export default function AssistedAssemblyClient({
const [connectorIds, setConnectorIds] = useState<string[]>([])
const [workspace, setWorkspace] = useState<AssemblyWorkspaceSnapshot | null>(null)
const [workspaceSource, setWorkspaceSource] =
useState<AssemblyWorkspaceSource>('assisted-assembly')
useState<AssemblyWorkspaceSource>(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 (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-apple-medium text-apple-label-primary">{title}</p>
<p className="mt-0.5 text-[11px] text-apple-label-tertiary">
{blockFamilies.length} 个资产族
</p>
</div>
{selectedIds.length > 0 ? (
<span className="rounded-full bg-apple-bg-secondary px-2 py-0.5 text-[11px] text-apple-label-secondary">
已选 {selectedIds.length}
</span>
) : null}
</div>
{blockFamilies.length === 0 ? (
<p className="text-sm text-apple-label-tertiary">{emptyText}</p>
) : (
<div className="space-y-2">
{blockFamilies.map((family) => (
<Checkbox
key={family.id}
checked={includesValue(selectedIds, family.id)}
onChange={() => 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"
/>
))}
</div>
)}
</div>
)
}
const leftContent = (
<div className="flex h-full flex-col gap-3 overflow-y-auto p-2">
@ -357,280 +465,203 @@ export default function AssistedAssemblyClient({
availableVariants={variants}
onChange={setWorkspace}
/>
) : !hasAnySelection ? (
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-12 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full" style={{ backgroundColor: 'var(--color-surface-container-low)', color: 'var(--color-label-tertiary)' }}>
<Sparkles className="h-7 w-7" strokeWidth={1.2} />
</div>
<p className="text-sm font-medium" style={{ color: 'var(--color-label-secondary)' }}>
从右侧开始选模板和资产族
正在准备工作台
</p>
<p className="max-w-[280px] text-xs leading-5" style={{ color: 'var(--color-label-tertiary)' }}>
这条链路现在不再先生成候选组合。选好资产族后会直接进入手动拼装工作台,再在工作台里选具体规格款。
</p>
</div>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-12 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full" style={{ backgroundColor: 'var(--color-surface-container-low)', color: 'var(--color-label-tertiary)' }}>
<Layers3 className="h-7 w-7" strokeWidth={1.2} />
</div>
<p className="text-sm font-medium" style={{ color: 'var(--color-label-secondary)' }}>
选料完成,进入工作台
</p>
<p className="max-w-[280px] text-xs leading-5" style={{ color: 'var(--color-label-tertiary)' }}>
已选 {desktopIds.length + legIds.length + connectorIds.length} 个资产族。点击右侧按钮后,左侧会直接切换到手动拼装工作台。
当前模板与选料正在同步到左侧工作台。
</p>
</div>
)}
</div>
)
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 = (
<>
<div className="flex items-center gap-1 rounded-apple-lg border border-apple-separator bg-apple-bg-primary px-3 py-2">
{steps.map((step, index) => (
<div key={step.num} className="flex items-center gap-1">
<div className="flex items-center gap-1.5">
<div
className={[
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-apple-semibold',
currentStep === step.num
? 'bg-apple-blue text-white'
: step.done
? 'bg-apple-green/20 text-apple-green'
: 'bg-apple-bg-tertiary text-apple-label-tertiary',
].join(' ')}
>
{step.done ? '✓' : step.num}
<section className="rounded-apple-xl bg-apple-bg-primary px-4 py-4 shadow-apple-xs">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] uppercase tracking-[0.05em] text-apple-label-tertiary">当前模板</p>
<p className="mt-1 text-sm font-apple-semibold text-apple-label-primary">
{selectedTemplate?.name ?? '未选择模板'}
</p>
</div>
<Badge type={workspace ? 'info' : selectedFamilyCount > 0 ? 'success' : 'default'} variant="subtle">
{workspace ? '工作台中' : `已选 ${selectedFamilyCount}`}
</Badge>
</div>
<p className="mt-3 text-xs leading-5 text-apple-label-secondary">{nextActionText}</p>
<div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-apple-label-tertiary">
<span>桌面板 {selectedFamilyMap.desktops.length}</span>
<span>支撑件 {selectedFamilyMap.legs.length}</span>
<span>连接件 {selectedFamilyMap.connectors.length}</span>
</div>
<div className="mt-3 rounded-apple-lg bg-apple-bg-secondary/50 px-3 py-3 text-xs leading-5 text-apple-label-secondary">
右侧只负责选资产族,左侧始终是同一套 3D 操作区和物料区。
</div>
</section>
<section className="rounded-apple-xl bg-apple-bg-primary px-4 py-4 shadow-apple-xs">
<p className="text-[11px] uppercase tracking-[0.05em] text-apple-label-tertiary">参与资产</p>
<div className="mt-4 space-y-5">
<div className="space-y-2">
<p className="text-sm font-apple-medium text-apple-label-primary">系列模板</p>
{templates.length <= 1 ? (
<p className="text-sm text-apple-label-secondary">
{selectedTemplate?.name ?? templates[0]?.name ?? '暂无模板'}
</p>
) : (
<div className="flex flex-wrap gap-2">
{templates.map((template) => (
<Button
key={template.id}
type="button"
variant={template.id === templateId ? 'filled' : 'plain'}
size="small"
onClick={() => setTemplateId(template.id)}
className="rounded-full"
>
{template.name}
</Button>
))}
</div>
<span
className={[
'whitespace-nowrap text-[11px]',
currentStep === step.num
? 'font-apple-semibold text-apple-label-primary'
: 'text-apple-label-tertiary',
].join(' ')}
>
{step.label}
</span>
</div>
{index < steps.length - 1 ? (
<div className="mx-1 h-px w-3 bg-apple-separator" />
) : null}
)}
</div>
))}
</div>
<AccordionSection
title="系列模板"
description="当前单板桌只作为手动拼装模板使用,不再先生成候选组合。"
icon={<Sparkles className="h-4 w-4" />}
defaultExpanded
>
<div className="flex flex-wrap gap-2">
{templates.map((template) => (
<Button
key={template.id}
type="button"
variant={template.id === templateId ? 'filled' : 'plain'}
size="small"
onClick={() => setTemplateId(template.id)}
className="rounded-full"
>
{template.name}
</Button>
))}
{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)),
})}
</div>
</AccordionSection>
</section>
<AccordionSection
title="桌面板资产族"
description="保存前主装配区必须且只能保留 1 个桌面板,先把候选桌面板资产族带进工作台。"
icon={<Table2 className="h-4 w-4" />}
aside={
desktopIds.length > 0 ? (
<Badge type="success" variant="subtle">
已选 {desktopIds.length}
</Badge>
) : null
}
defaultExpanded
>
{desktopFamilies.length === 0 ? (
<p className="text-sm text-apple-label-tertiary">暂无可用桌面板资产族</p>
) : (
<div className="space-y-3">
{desktopFamilies.map((family) => (
<Checkbox
key={family.id}
checked={includesValue(desktopIds, family.id)}
onChange={() => setDesktopIds(toggleSelection(desktopIds, family.id))}
label={family.name}
description={`${family.code} · ${availableVariantMap.get(family.id)?.length ?? 0} 个可用规格款`}
/>
))}
</div>
)}
</AccordionSection>
<AccordionSection
title="支撑件资产族"
description="支撑件不再固定映射到前左/前右等腿位,当前只按类型带入工作台。"
icon={<SquareAsterisk className="h-4 w-4" />}
aside={
legIds.length > 0 ? (
<Badge type="info" variant="subtle">
已选 {legIds.length}
</Badge>
) : null
}
defaultExpanded
>
{legFamilies.length === 0 ? (
<p className="text-sm text-apple-label-tertiary">暂无可用支撑件资产族</p>
) : (
<div className="space-y-3">
{legFamilies.map((family) => (
<Checkbox
key={family.id}
checked={includesValue(legIds, family.id)}
onChange={() => setLegIds(toggleSelection(legIds, family.id))}
label={family.name}
description={`${family.code} · ${availableVariantMap.get(family.id)?.length ?? 0} 个可用规格款`}
/>
))}
</div>
)}
</AccordionSection>
<AccordionSection
title="连接件资产族"
description="连接件也改成手动决定,不再由系统按候选组合自动推荐。"
icon={<Plug className="h-4 w-4" />}
aside={
connectorIds.length > 0 ? (
<Badge type="default" variant="subtle">
已选 {connectorIds.length}
</Badge>
) : null
}
defaultExpanded
>
{connectorFamilies.length === 0 ? (
<p className="text-sm text-apple-label-tertiary">暂无可用连接件资产族</p>
) : (
<div className="space-y-3">
{connectorFamilies.map((family) => (
<Checkbox
key={family.id}
checked={includesValue(connectorIds, family.id)}
onChange={() => setConnectorIds(toggleSelection(connectorIds, family.id))}
label={family.name}
description={`${family.code} · ${availableVariantMap.get(family.id)?.length ?? 0} 个可用规格款`}
/>
))}
</div>
)}
</AccordionSection>
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-apple-semibold text-apple-label-primary">
手动拼装入口
</p>
<p className="mt-1 text-xs leading-5 text-apple-label-secondary">
选好资产族后直接进入工作台,真正的规格款在工作台里选。
<section className="rounded-apple-xl bg-apple-bg-primary px-4 py-4 shadow-apple-xs">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] uppercase tracking-[0.05em] text-apple-label-tertiary">草稿组输出</p>
<p className="mt-1 text-sm font-apple-medium text-apple-label-primary">
{workspace ? '当前结果可在这里保存' : '工作台就绪后再保存'}
</p>
</div>
<Button
type="button"
variant="filled"
size="small"
onClick={handleOpenWorkspace}
disabled={!canStartWorkspace}
>
{workspace ? '按当前选料重建工作台' : '进入手动拼装工作台'}
</Button>
{workspace ? (
<Badge type={canCreateDraftGroup ? 'success' : 'default'} variant="subtle">
{canCreateDraftGroup ? '可保存' : '待处理'}
</Badge>
) : null}
</div>
</div>
<AccordionSection
title="草稿组输出"
description="当前工作台结果会直接落到草稿组,不再先走候选组合卡片。"
icon={<CheckSquare2 className="h-4 w-4" />}
defaultExpanded
>
<div className="space-y-3">
{!workspace ? (
<div className="mt-3 text-xs leading-5 text-apple-label-secondary">
进入工作台后,再命名并保存草稿组。
</div>
) : (
<div className="mt-4 space-y-3">
{feedback ? (
<div
className={`rounded-apple-lg border px-3 py-3 text-sm ${
className={`rounded-apple-lg px-3 py-3 text-sm ${
feedback.tone === 'success'
? 'border-apple-green/30 bg-apple-green/5 text-apple-green'
: 'border-apple-red/30 bg-apple-red/5 text-apple-red'
? 'bg-apple-green/8 text-apple-green'
: 'bg-apple-red/8 text-apple-red'
}`}
>
{feedback.text}
</div>
) : null}
{saveValidation?.level === 'BLOCKED' ? (
<div className="rounded-apple-lg border border-apple-orange/30 bg-apple-orange/5 px-3 py-3 text-sm text-apple-orange">
{saveValidation.blockingIssues[0]}
<div className="space-y-2 rounded-apple-lg bg-apple-bg-secondary/50 px-3 py-3">
{saveChecklist.map((item) => (
<div key={item.key} className="flex items-center justify-between gap-3 text-sm">
<span className="text-apple-label-secondary">{item.label}</span>
<span
className={
item.passed
? 'text-apple-green'
: 'text-apple-orange'
}
>
{item.passed ? '已满足' : '未满足'}
</span>
</div>
))}
{saveValidation?.level === 'BLOCKED' ? (
<div className="pt-1 text-xs leading-5 text-apple-orange">
{saveValidation.blockingIssues[0]}
</div>
) : null}
{saveValidation?.level === 'WARNING' ? (
<div className="pt-1 text-xs leading-5 text-apple-label-secondary">
{saveValidation.warnings[0]}
</div>
) : null}
{workspace.validation.level === 'WARNING' ? (
<div className="pt-1 text-xs leading-5 text-apple-label-secondary">
编辑态还有装配提示,先在工作台里继续处理。
</div>
) : null}
</div>
) : null}
{workspace ? (
<div className="rounded-apple-lg bg-apple-bg-secondary/50 px-3 py-3 text-xs leading-5 text-apple-label-secondary">
当前工作台已进入编辑阶段。
<br />
编辑校验:{workspace.validation.level === 'PASSED' ? '成立' : '需继续确认'}
</div>
) : null}
<Input
label="草稿组名称"
value={draftGroupName}
onChange={(event) => setDraftGroupName(event.target.value)}
placeholder="例如 云序-手动拼装草稿组"
/>
<Textarea
label="备注"
value={notes}
onChange={(event) => setNotes(event.target.value)}
placeholder="例如:当前先验证两腿和四腿方案,后续继续补包装。"
/>
<Button
type="button"
variant="filled"
onClick={handleCreateDraftGroup}
disabled={!canCreateDraftGroup || loading}
>
生成草稿组
</Button>
<Link
href="/draft-groups"
className="inline-flex items-center gap-2 text-sm text-apple-blue"
>
草稿组确认完毕?去查看草稿组列表
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</AccordionSection>
<Input
label="草稿组名称"
value={draftGroupName}
onChange={(event) => setDraftGroupName(event.target.value)}
placeholder="云序-手动拼装草稿组"
/>
<Textarea
label="备注"
value={notes}
onChange={(event) => setNotes(event.target.value)}
placeholder="可留空"
/>
<Button
type="button"
variant="filled"
onClick={handleCreateDraftGroup}
disabled={!canCreateDraftGroup || loading}
>
生成草稿组
</Button>
<Link
href="/draft-groups"
className="inline-flex items-center gap-2 text-sm text-apple-blue"
>
去查看草稿组列表
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
)}
</section>
</>
)
return (
<PlatformSplitLayout
title="辅助装配"
description="现在改成手动拼装主链:先选资产族,再进工作台选规格款并完成装配。"
description="先选资产,再在工作台完成拼装并保存到草稿组。"
metrics={metrics}
leftContent={leftContent}
rightContent={rightContent}

View File

@ -1,5 +1,6 @@
import { CheckSquare2, Sofa, Sparkles, Table2 } from 'lucide-react'
import { requireSession } from '@/lib/server-session'
import { parseManualAssemblyLaunchParams } from '@/lib/open-product/manual-assembly-launch'
import { assetCenterService, seriesTemplateService } from '@/server/services'
import AssistedAssemblyClient from './AssistedAssemblyClient'
@ -20,6 +21,15 @@ export default async function AssistedAssemblyPage({
} = {}) {
const { user } = await requireSession('/login')
const params = searchParams ? await searchParams : {}
const launchParams = parseManualAssemblyLaunchParams({
get(name) {
const value = params[name]
if (Array.isArray(value)) {
return value[0] ?? null
}
return value ?? null
},
})
const launchSelectedFamilyIds = new Set([
...normalizeFamilyIds(params.desktopFamilyIds),
...normalizeFamilyIds(params.legFamilyIds),
@ -79,6 +89,7 @@ export default async function AssistedAssemblyPage({
families={availableFamilies}
variants={selectableVariants}
metrics={metrics}
initialLaunchParams={launchParams}
/>
)
}

View File

@ -92,6 +92,22 @@ function getZoneLabel(zone: AssemblyWorkspaceNode['zone']) {
return '待装区'
}
function getVariantStatusPriority(status: AssetVariantDTO['status']) {
if (status === 'AVAILABLE') return 0
if (status === 'DRAFT') return 1
if (status === 'PENDING_REVIEW') return 2
if (status === 'REJECTED') return 3
return 4
}
function getVariantStatusLabel(status: AssetVariantDTO['status']) {
if (status === 'AVAILABLE') return '可用'
if (status === 'DRAFT') return '草稿'
if (status === 'PENDING_REVIEW') return '待审核'
if (status === 'REJECTED') return '已驳回'
return '停用'
}
function getSourceLabel(source: AssemblyWorkspaceNode['source']) {
if (source === 'desktop') return '桌面板'
if (source === 'leg') return '支撑件'
@ -154,9 +170,15 @@ function InventoryCard({
<Select
label="规格款"
value={item.selectedVariantId ?? ''}
options={variants.map((variant) => ({ value: variant.id, label: `${variant.code} · ${variant.name}` }))}
options={variants.map((variant) => ({
value: variant.id,
label:
variant.status === 'AVAILABLE'
? `${variant.code} · ${variant.name}`
: `${variant.code} · ${variant.name} · ${getVariantStatusLabel(variant.status)}`,
}))}
onChange={(event) => onVariantChange(item.inventoryId, event.target.value || null)}
placeholder={variants.length === 0 ? '暂无可用规格款' : '请选择规格款'}
placeholder={variants.length === 0 ? '暂无规格款' : '请选择规格款'}
/>
<Button type="button" variant="tinted" size="small" onClick={() => onInstantiate(item.inventoryId)} disabled={!item.selectedVariantId}>
<PackagePlus className="h-3.5 w-3.5" />
@ -266,11 +288,24 @@ export function AssemblyWorkbench({
const availableVariantMap = useMemo(() => {
const map = new Map<string, AssetVariantDTO[]>()
availableVariants.filter((variant) => variant.status === 'AVAILABLE').forEach((variant) => {
availableVariants.forEach((variant) => {
const current = map.get(variant.assetFamilyId) ?? []
current.push(variant)
map.set(variant.assetFamilyId, current)
})
map.forEach((familyVariants, familyId) => {
map.set(
familyId,
[...familyVariants].sort((left, right) => {
const priorityDiff =
getVariantStatusPriority(left.status) - getVariantStatusPriority(right.status)
if (priorityDiff !== 0) {
return priorityDiff
}
return left.name.localeCompare(right.name, 'zh-CN')
}),
)
})
return map
}, [availableVariants])
@ -280,6 +315,41 @@ export function AssemblyWorkbench({
const selectedNodeRelations = selectedSingleNode
? presentWorkspace.relations.filter((relation) => relation.sourceNodeId === selectedSingleNode.nodeId || relation.targetNodeId === selectedSingleNode.nodeId)
: []
const mainNodes = presentWorkspace.nodes.filter((node) => node.zone === 'main')
const mainDesktopCount = mainNodes.filter((node) => node.source === 'desktop').length
const connectedNodeIdSet = useMemo(
() =>
new Set(
presentWorkspace.relations
.filter((relation) => relation.status === 'CONNECTED')
.map((relation) => relation.sourceNodeId),
),
[presentWorkspace.relations],
)
const pendingMainNodeCount = mainNodes.filter(
(node) => node.source !== 'desktop' && !connectedNodeIdSet.has(node.nodeId),
).length
const stagingNodeCount = presentWorkspace.nodes.filter((node) => node.zone === 'staging').length
const subassemblyNodeCount = presentWorkspace.nodes.filter((node) => node.zone === 'subassembly').length
const saveChecklist = [
{
key: 'desktop',
label: '主装配区保留 1 个桌面板',
passed: mainDesktopCount === 1,
},
{
key: 'connections',
label: '主装配区里的非桌面零件都已连接',
passed: pendingMainNodeCount === 0,
},
]
const nextActionHint = presentWorkspace.nodes.length === 0
? '先从左侧选规格款,再加入工作台。'
: mainDesktopCount === 0
? '先把 1 个桌面板放进主装配区。'
: pendingMainNodeCount > 0
? `还有 ${pendingMainNodeCount} 个主装配零件未连接,先吸附连接。`
: '当前装配结果已经可以保存到草稿组。'
function syncWorkspace(next: AssemblyWorkspaceSnapshot) {
setPresentWorkspace(next)
@ -409,7 +479,7 @@ export function AssemblyWorkbench({
<div className="absolute left-6 right-6 top-6 z-10 flex flex-wrap items-start justify-between gap-4 pointer-events-none">
<div className="flex flex-col gap-2">
<div className="rounded-apple-xl border border-white/50 bg-white/70 px-4 py-2 shadow-apple-sm backdrop-blur-[20px] pointer-events-auto">
<span className="block text-[10px] font-apple-bold uppercase tracking-widest text-apple-label-tertiary">Workspace</span>
<span className="block text-[10px] font-apple-bold uppercase tracking-widest text-apple-label-tertiary">工作台</span>
<h1 className="mt-0.5 text-lg font-display font-apple-bold leading-none tracking-apple-tight text-apple-label-primary">手动拼装工作台</h1>
</div>
<div className="flex gap-2 pointer-events-auto">
@ -456,13 +526,54 @@ export function AssemblyWorkbench({
<div className="border-b border-apple-separator px-4 py-4">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-apple-semibold text-apple-label-primary">Inspector</p>
<p className="text-sm font-apple-semibold text-apple-label-primary">检查区</p>
<p className="mt-0.5 text-xs leading-5 text-apple-label-secondary">{selectedSingleNode ? selectedSingleNode.name : '选择零件查看详情'}</p>
</div>
{selectedNodeIds.length > 1 ? <Badge type="info" variant="subtle">已选 {selectedNodeIds.length} 件</Badge> : null}
</div>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4">
<div className="space-y-2">
<p className="text-xs font-apple-semibold uppercase tracking-[0.16em] text-apple-label-tertiary">装配进度</p>
<div className="grid grid-cols-2 gap-2">
<div className="rounded-apple-xl bg-apple-bg-primary px-3 py-3">
<p className="text-[11px] text-apple-label-tertiary">主装配区</p>
<p className="mt-1 text-sm font-apple-semibold text-apple-label-primary">{mainNodes.length} 件</p>
</div>
<div className="rounded-apple-xl bg-apple-bg-primary px-3 py-3">
<p className="text-[11px] text-apple-label-tertiary">待连接</p>
<p className="mt-1 text-sm font-apple-semibold text-apple-label-primary">{pendingMainNodeCount} 件</p>
</div>
<div className="rounded-apple-xl bg-apple-bg-primary px-3 py-3">
<p className="text-[11px] text-apple-label-tertiary">待装区</p>
<p className="mt-1 text-sm font-apple-semibold text-apple-label-primary">{stagingNodeCount} 件</p>
</div>
<div className="rounded-apple-xl bg-apple-bg-primary px-3 py-3">
<p className="text-[11px] text-apple-label-tertiary">分子装配</p>
<p className="mt-1 text-sm font-apple-semibold text-apple-label-primary">{subassemblyNodeCount} 件</p>
</div>
</div>
<div className="rounded-apple-xl bg-apple-bg-primary px-3 py-3 text-sm text-apple-label-secondary">
{nextActionHint}
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-apple-semibold uppercase tracking-[0.16em] text-apple-label-tertiary">保存检查</p>
<div className="rounded-apple-xl bg-apple-bg-primary px-3 py-3">
<div className="space-y-2">
{saveChecklist.map((item) => (
<div key={item.key} className="flex items-center justify-between gap-3 text-sm">
<span className="text-apple-label-secondary">{item.label}</span>
<span className={item.passed ? 'text-apple-green' : 'text-apple-orange'}>
{item.passed ? '已满足' : '未满足'}
</span>
</div>
))}
</div>
</div>
</div>
{selectedSingleNode ? (
<>
<div className="grid grid-cols-3 gap-2">
@ -534,7 +645,12 @@ export function AssemblyWorkbench({
<p className="text-xs font-apple-semibold uppercase tracking-[0.16em] text-apple-label-tertiary">连接关系</p>
{selectedNodeRelations.length > 0 ? selectedNodeRelations.map((relation) => (
<div key={relation.relationId} className="rounded-apple-xl bg-apple-bg-primary px-3 py-3 text-sm text-apple-label-secondary">
{relation.description}
<div className="flex items-start justify-between gap-3">
<span>{relation.description}</span>
<span className="shrink-0 text-xs text-apple-label-tertiary">
{relation.status === 'CONNECTED' ? '已连接' : relation.status === 'PENDING' ? '待连接' : '建议连接'}
</span>
</div>
</div>
)) : (
<div className="rounded-apple-xl bg-apple-bg-primary px-3 py-3 text-sm text-apple-label-secondary">当前没有可显示的连接关系。</div>

View File

@ -2,8 +2,9 @@
import { Suspense, useEffect, useMemo, useRef, useState } from 'react'
import { Bounds, Clone, OrbitControls, useGLTF, TransformControls } from '@react-three/drei'
import { Canvas } from '@react-three/fiber'
import { Canvas, useLoader } from '@react-three/fiber'
import * as THREE from 'three'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'
import type { AssemblyWorkspaceNode } from '@/lib/open-product/assembly-workspace'
export type AssemblyWorkbenchTransformMode = 'translate' | 'rotate'
@ -25,6 +26,96 @@ interface AssemblyWorkbenchCanvasProps {
onTransformEnd?: (nodeId: string) => void
}
function getModelExtension(url: string) {
try {
const parsed = new URL(url, 'http://localhost')
const pathname = parsed.pathname.toLowerCase()
if (pathname.endsWith('.obj')) return '.obj'
if (pathname.endsWith('.gltf')) return '.gltf'
if (pathname.endsWith('.glb')) return '.glb'
return ''
} catch {
const normalized = url.toLowerCase()
if (normalized.includes('.obj')) return '.obj'
if (normalized.includes('.gltf')) return '.gltf'
if (normalized.includes('.glb')) return '.glb'
return ''
}
}
function prepareObjScene(object: THREE.Object3D) {
object.traverse((child) => {
if (!(child as THREE.Mesh).isMesh) {
return
}
const mesh = child as THREE.Mesh
const geometry = mesh.geometry
if (geometry) {
if (!geometry.attributes.normal) {
geometry.computeVertexNormals()
}
geometry.computeBoundingBox()
geometry.computeBoundingSphere()
}
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
materials.forEach((material) => {
if (!material) {
return
}
material.side = THREE.DoubleSide
const standardLikeMaterial = material as THREE.MeshStandardMaterial & {
color?: THREE.Color
roughness?: number
metalness?: number
envMapIntensity?: number
}
if (!('map' in standardLikeMaterial) || !standardLikeMaterial.map) {
standardLikeMaterial.color?.set(0x93a1ad)
if (typeof standardLikeMaterial.roughness === 'number') {
standardLikeMaterial.roughness = 0.78
}
if (typeof standardLikeMaterial.metalness === 'number') {
standardLikeMaterial.metalness = 0.08
}
if (typeof standardLikeMaterial.envMapIntensity === 'number') {
standardLikeMaterial.envMapIntensity = 0.65
}
}
material.needsUpdate = true
})
})
}
function AssetNodeModel({ modelUrl }: { modelUrl: string }) {
const extension = useMemo(() => getModelExtension(modelUrl), [modelUrl])
if (extension === '.obj') {
return <ObjAssetNodeModel modelUrl={modelUrl} />
}
return <GltfAssetNodeModel modelUrl={modelUrl} />
}
function GltfAssetNodeModel({ modelUrl }: { modelUrl: string }) {
const { scene } = useGLTF(modelUrl)
return <Clone object={scene} />
}
function ObjAssetNodeModel({ modelUrl }: { modelUrl: string }) {
const scene = useLoader(OBJLoader, modelUrl)
const preparedScene = useMemo(() => {
prepareObjScene(scene)
return scene
}, [scene])
return <Clone object={preparedScene} />
}
function AssetNodeMesh({
node,
selected,
@ -52,7 +143,6 @@ function AssetNodeMesh({
onTransformEnd?: (nodeId: string) => void
onDraggingChange?: (dragging: boolean) => void
}) {
const { scene } = useGLTF(node.modelUrl)
const groupRef = useRef<THREE.Group>(null)
const controlsRef = useRef<any>(null)
const interactionActiveRef = useRef(false)
@ -141,7 +231,7 @@ function AssetNodeMesh({
onSelectNode?.(node.nodeId)
}}
>
<Clone object={scene} />
<AssetNodeModel modelUrl={node.modelUrl} />
</group>
{selected && mounted && (
<TransformControls

View File

@ -63,4 +63,56 @@ describe('AssemblyWorkbench', () => {
expect(onChange).toHaveBeenCalled()
})
it('会保留资产中心带入的草稿规格款,并允许直接加入工作台', async () => {
const actor = userEvent.setup()
const onChange = jest.fn()
render(
<AssemblyWorkbench
value={buildManualAssemblyWorkspace({
templateCode: 'single-board-desk',
source: 'asset-center',
families: [
{
familyId: 'family-board-draft',
familyCode: 'BOARD-DRAFT',
familyName: '草稿桌面',
assetType: 'BOARD',
source: 'desktop',
defaultVariantId: 'variant-board-draft',
},
],
})}
workspaceKey="workspace-draft"
candidateName="草稿工作台"
statusTag="手动拼装工作台"
warnings={[]}
availableVariants={[
{
id: 'variant-board-draft',
assetFamilyId: 'family-board-draft',
assetType: 'BOARD',
code: 'BOARD-DRAFT-001',
name: '草稿桌面A',
status: 'DRAFT',
modelUrl: '/board-draft.glb',
thumbnailUrl: null,
dimensions: {},
mountPoints: [],
assemblyBasis: null,
} as never,
]}
onChange={onChange}
/>,
)
expect(screen.getByRole('combobox', { name: '规格款' })).toHaveTextContent(
'BOARD-DRAFT-001 · 草稿桌面A · 草稿',
)
await actor.click(screen.getByRole('button', { name: /加入工作台/ }))
expect(onChange).toHaveBeenCalled()
})
})

View File

@ -4,6 +4,7 @@ import {
instantiateAssemblyNode,
moveAssemblyNodes,
snapNodesIntoMainAssembly,
syncManualAssemblyWorkspace,
} from '../assembly-workspace'
describe('assembly-workspace', () => {
@ -138,4 +139,43 @@ describe('assembly-workspace', () => {
expect(deriveAssemblySaveValidation(moved).level).toBe('BLOCKED')
expect(deriveAssemblySaveValidation(moved).blockingIssues[0]).toContain('只能保留 1 个桌面板')
})
it('同步选料到工作台时,会保留仍在选中范围内的节点并移除被取消的资产族', () => {
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
inventoryId: 'desktop:family-desktop:1',
variant: {
id: 'variant-desktop',
assetFamilyId: 'family-desktop',
code: 'BOARD-A-001',
name: '桌面A',
assetType: 'BOARD',
status: 'AVAILABLE',
modelUrl: '/board.glb',
thumbnailUrl: null,
dimensions: {},
mountPoints: [],
assemblyBasis: null,
},
})
const synced = syncManualAssemblyWorkspace(withDesktop, {
templateCode: 'single-board-desk',
source: 'assisted-assembly',
families: [
{
familyId: 'family-desktop',
familyCode: 'BOARD-A',
familyName: '桌面A',
assetType: 'BOARD',
source: 'desktop',
defaultVariantId: 'variant-desktop',
},
],
})
expect(synced.inventory).toHaveLength(1)
expect(synced.inventory[0]?.familyId).toBe('family-desktop')
expect(synced.nodes).toHaveLength(1)
expect(synced.nodes[0]?.assetFamilyId).toBe('family-desktop')
})
})

View File

@ -438,6 +438,53 @@ export function buildManualAssemblyWorkspace(
return rebuildWorkspaceSnapshot(snapshot, [])
}
export function syncManualAssemblyWorkspace(
snapshot: AssemblyWorkspaceSnapshot | null,
input: BuildManualAssemblyWorkspaceInput,
): AssemblyWorkspaceSnapshot {
if (!snapshot) {
return buildManualAssemblyWorkspace(input)
}
if (snapshot.templateCode !== input.templateCode || snapshot.source !== input.source) {
return buildManualAssemblyWorkspace(input)
}
const existingInventoryByFamilyId = new Map(
snapshot.inventory.map((item) => [item.familyId, item]),
)
const inventory = input.families.map<AssemblyWorkspaceInventoryItem>((family, index) => {
const existing = existingInventoryByFamilyId.get(family.familyId)
return {
inventoryId: existing?.inventoryId ?? buildInventoryId(family.source, family.familyId, index),
familyId: family.familyId,
familyCode: family.familyCode,
familyName: family.familyName,
assetType: family.assetType,
source: family.source,
selectedVariantId: existing?.selectedVariantId ?? family.defaultVariantId ?? null,
defaultVariantId: family.defaultVariantId ?? null,
instanceCount: existing?.instanceCount ?? 0,
}
})
const validInventoryIds = new Set(inventory.map((item) => item.inventoryId))
const nextNodes = snapshot.nodes.filter(
(node) => node.inventoryId !== null && validInventoryIds.has(node.inventoryId),
)
return rebuildWorkspaceSnapshot(
{
...snapshot,
workflowMode: 'manual-assembly',
inventory,
},
nextNodes,
snapshot.subassemblies,
)
}
export function buildSingleBoardDeskAssemblyWorkspace(
input: BuildSingleBoardDeskAssemblyWorkspaceInput,
): AssemblyWorkspaceSnapshot {