feat: streamline assisted assembly direct workspace flow
This commit is contained in:
parent
4b4173abde
commit
3f6a67afde
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user