feat(installation-demo): add manual catalog, permanent shares and hinge binding

This commit is contained in:
WuYongJie 2026-09-10 17:59:58 +08:00
parent aa1ca0afcf
commit 46a732d892
51 changed files with 1738 additions and 621 deletions

View File

@ -3,6 +3,22 @@ import { AppError } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { installationDemoCatalogService } from '@/server/services'
export async function DELETE(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const input = await request.json().catch(() => null)
const result = await installationDemoCatalogService.delete(ctx.user, input)
return jsonSuccess(result, 200, ctx.requestId)
}, { requireAuth: true })
}
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const input = await request.json().catch(() => null)
const drawing = await installationDemoCatalogService.create(ctx.user, input)
return jsonSuccess({ drawing }, 201, ctx.requestId)
}, { requireAuth: true })
}
export async function GET(request: NextRequest) {
return handleRequest(
request,

View File

@ -93,7 +93,7 @@ export default async function InstallationDemoSharePage({
} catch (error) {
if (error instanceof AppError && error.code === ErrorCode.SHARE_LINK_EXPIRED) {
unavailableTitle = '安装演示链接已过期'
unavailableDescription = '该链接仅 24 小时有效,请联系业务人员重新分享。'
unavailableDescription = '该历史链接已过期,请联系业务人员重新生成永久分享链接。'
}
}

View File

@ -1927,7 +1927,7 @@ export default function InstallationDemoWorkbenchClient({
setShareUrl(null);
setShareExpiresAt(null);
setShareCopied(false);
setShareMessage("图纸已更新,请重新生成临时链接");
setShareMessage("图纸已更新,请重新生成分享链接");
},
[],
);
@ -1980,7 +1980,7 @@ export default function InstallationDemoWorkbenchClient({
setShareExpiresAt(share.expiresAt);
setShareMessage(
input.drawingDocument
? "分享链接已生成,24 小时后自动过期"
? "分享链接已生成,永久有效"
: "分享链接已生成;图纸尚未确认,当前仅开放安装动画",
);
} catch (error) {
@ -2854,6 +2854,8 @@ export default function InstallationDemoWorkbenchClient({
) : null}
<InstallationInspector
hardwareHingeContext={{ engine: previewEngine, modelNodeNames, parts: numberingCatalog.parts,
steps: stepEditor.steps, templates: drawingTools.tools }}
activeTab={activeTab}
onTabChange={(nextTab) => {
setActiveTab(nextTab);
@ -2870,6 +2872,8 @@ export default function InstallationDemoWorkbenchClient({
selectedDrawing={selectedDrawing}
catalogLoading={catalog.loading}
catalogMessage={
preview.hardwareHingeMessage ??
numberingCatalog.saveMessage ??
numberingSnapshotMessage ??
catalog.message ??
(preview.timeline.length > 0 && mode === "preview"

View File

@ -0,0 +1,30 @@
'use client'
import { useEffect, useState } from 'react'
import { X } from 'lucide-react'
import type { InstallationDemoWorkbenchAccess } from '@/lib/installation-demo-workbench-permissions'
import InstallationDemoWorkbenchClient from '../InstallationDemoWorkbenchClient'
import { resetLegacyInstallationData } from '../lib/installationLegacyDataReset'
import { useInstallationCatalogDeletion } from '../hooks/useInstallationCatalogDeletion'
export function InstallationCatalogBootstrap({ initialAccess }: { initialAccess: InstallationDemoWorkbenchAccess }) {
const [ready, setReady] = useState(false)
const [error, setError] = useState<string | null>(null)
const deletion = useInstallationCatalogDeletion()
useEffect(() => {
let cancelled = false
void resetLegacyInstallationData().then(() => { if (!cancelled) setReady(true) })
.catch((cause) => { if (!cancelled) setError(cause instanceof Error ? cause.message : '旧数据清理失败') })
return () => { cancelled = true }
}, [])
if (!ready) return <div className="p-6 text-sm" role={error ? 'alert' : 'status'}>{error ?? '正在准备安装演示…'}</div>
if (deletion.deleting) return <div className="p-6 text-sm" role="status">正在删除图纸…</div>
return <>
<InstallationDemoWorkbenchClient key={deletion.revision} initialAccess={initialAccess} />
{deletion.notice ? <div role={deletion.notice.error ? 'alert' : 'status'}
className="fixed bottom-16 right-4 z-50 flex max-w-[calc(100%-2rem)] items-center gap-3 rounded-md border border-black/10 bg-white px-4 py-3 text-sm shadow-md">
<span>{deletion.notice.text}</span>
<button type="button" aria-label="关闭删除提示" onClick={deletion.dismissNotice} className="flex h-7 w-7 shrink-0 items-center justify-center"><X size={15} /></button>
</div> : null}
</>
}

View File

@ -0,0 +1,41 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { INSTALLATION_CATALOG_CREATED_EVENT } from '@/lib/installation-demo-manual-catalog'
import { InstallationCatalogCreateDialog } from './InstallationCatalogCreateDialog'
const originalFetch = global.fetch
afterEach(() => { global.fetch = originalFetch; jest.restoreAllMocks() })
it('creates with only the two identifiers and announces the new selection', async () => {
const actor = userEvent.setup()
const onClose = jest.fn()
const drawing = { id: 'manual-test', spuCode: 'SPU-01', drawingCode: 'DRAW-01' }
const fetchMock = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true, data: { drawing } }) })
global.fetch = fetchMock
const events = jest.spyOn(window, 'dispatchEvent')
render(<InstallationCatalogCreateDialog initialSpu="SPU-01" onClose={onClose} />)
await waitFor(() => expect(screen.getByRole('button', { name: '关闭' })).toHaveFocus())
expect(screen.getAllByRole('textbox')).toHaveLength(2)
expect(screen.getByRole('button', { name: '创建', exact: true })).toBeDisabled()
await actor.type(screen.getByLabelText('图纸 ID'), 'DRAW-01')
await actor.click(screen.getByRole('button', { name: '创建', exact: true }))
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
expect(fetchMock).toHaveBeenCalledWith('/api/installation-demo-workbench/catalog', expect.objectContaining({
method: 'POST', body: JSON.stringify({ spuCode: 'SPU-01', drawingCode: 'DRAW-01' }),
}))
expect(events).toHaveBeenCalledWith(expect.objectContaining({ type: INSTALLATION_CATALOG_CREATED_EVENT, detail: drawing }))
})
it('shows server failure without losing the entered identifiers or closing', async () => {
const actor = userEvent.setup()
const onClose = jest.fn()
global.fetch = jest.fn().mockResolvedValue({ ok: false, json: async () => ({ success: false, error: { message: 'Duplicate drawing' } }) })
render(<InstallationCatalogCreateDialog initialSpu="SPU-01" onClose={onClose} />)
await waitFor(() => expect(screen.getByRole('button', { name: '关闭' })).toHaveFocus())
await actor.type(screen.getByLabelText('图纸 ID'), 'DRAW-01')
await actor.click(screen.getByRole('button', { name: '创建', exact: true }))
expect(await screen.findByRole('alert')).toHaveTextContent('Duplicate drawing')
expect(screen.getByLabelText('图纸 ID')).toHaveValue('DRAW-01')
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '创建', exact: true })).toBeEnabled()
})

View File

@ -0,0 +1,60 @@
'use client'
import { useState } from 'react'
import { LoaderCircle, Plus } from 'lucide-react'
import Modal from '@/components/ui/Modal'
import { INSTALLATION_CATALOG_CREATED_EVENT, type InstallationManualDrawing } from '@/lib/installation-demo-manual-catalog'
export function InstallationCatalogCreateDialog({ initialSpu, onClose }: { initialSpu: string; onClose: () => void }) {
const [spuCode, setSpuCode] = useState(initialSpu)
const [drawingCode, setDrawingCode] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const submit = async (event: React.FormEvent) => {
event.preventDefault()
if (saving) return
setSaving(true)
setError(null)
try {
const response = await fetch('/api/installation-demo-workbench/catalog', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spuCode, drawingCode }),
})
const result = await response.json() as { success?: boolean; data?: { drawing: InstallationManualDrawing }; error?: { message?: string } }
if (!response.ok || !result.success || !result.data) throw new Error(result.error?.message || '新建失败')
window.dispatchEvent(new CustomEvent(INSTALLATION_CATALOG_CREATED_EVENT, { detail: result.data.drawing }))
onClose()
} catch (cause) {
setError(cause instanceof Error ? cause.message : '新建失败')
} finally {
setSaving(false)
}
}
return (
<Modal isOpen onClose={() => { if (!saving) onClose() }} title="新建安装演示" size="small">
<form onSubmit={submit} className="space-y-4">
<label className="block text-sm font-semibold text-[#2d3335]">
SPU
<input required maxLength={120} value={spuCode} disabled={saving} onChange={(e) => setSpuCode(e.target.value)}
className="mt-2 h-9 w-full rounded-md border border-black/10 bg-white px-3 font-normal" />
</label>
<label className="block text-sm font-semibold text-[#2d3335]">
图纸 ID
<input required maxLength={120} value={drawingCode} disabled={saving} onChange={(e) => setDrawingCode(e.target.value)}
className="mt-2 h-9 w-full rounded-md border border-black/10 bg-white px-3 font-normal" />
</label>
{error ? <p role="alert" className="text-sm text-red-700">{error}</p> : null}
<div className="flex justify-end gap-2 pt-2">
<button type="button" disabled={saving} onClick={onClose} className="h-9 rounded-md border border-black/10 px-4 text-sm">取消</button>
<button type="submit" disabled={saving || !spuCode.trim() || !drawingCode.trim()}
className="inline-flex h-9 items-center gap-2 rounded-md bg-[#2d3335] px-4 text-sm text-white disabled:opacity-50">
{saving ? <LoaderCircle size={14} className="animate-spin" /> : <Plus size={14} />}
{saving ? '创建中' : '创建'}
</button>
</div>
</form>
</Modal>
)
}

View File

@ -0,0 +1,32 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { INSTALLATION_CATALOG_DELETE_REQUEST_EVENT } from '@/lib/installation-demo-manual-catalog'
import { InstallationCatalogDeleteDialog } from './InstallationCatalogDeleteDialog'
import type { InstallationDrawingNode } from '../types'
const drawing = { id: 'manual-a', spuCode: 'TEST-SPU', code: 'DRAW-A' } as InstallationDrawingNode
afterEach(() => jest.restoreAllMocks())
it('does not delete until confirmed and cancellation does not dispatch', async () => {
const actor = userEvent.setup()
const onClose = jest.fn()
const dispatch = jest.spyOn(window, 'dispatchEvent')
render(<InstallationCatalogDeleteDialog drawing={drawing} onClose={onClose} />)
await waitFor(() => expect(screen.getByRole('button', { name: '关闭' })).toHaveFocus())
expect(screen.getByText('TEST-SPU / DRAW-A')).toBeInTheDocument()
expect(dispatch).not.toHaveBeenCalled()
await actor.click(screen.getByRole('button', { name: '取消', exact: true }))
expect(onClose).toHaveBeenCalledTimes(1)
expect(dispatch).not.toHaveBeenCalled()
})
it('dispatches exactly the selected drawing ID after confirmation', async () => {
const actor = userEvent.setup()
const dispatch = jest.spyOn(window, 'dispatchEvent')
render(<InstallationCatalogDeleteDialog drawing={drawing} onClose={jest.fn()} />)
await waitFor(() => expect(screen.getByRole('button', { name: '关闭' })).toHaveFocus())
await actor.click(screen.getByRole('button', { name: '确认删除', exact: true }))
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({
type: INSTALLATION_CATALOG_DELETE_REQUEST_EVENT, detail: { drawingId: 'manual-a' },
}))
})

View File

@ -0,0 +1,29 @@
'use client'
import { Trash2 } from 'lucide-react'
import Modal from '@/components/ui/Modal'
import Button from '@/components/ui/Button'
import { INSTALLATION_CATALOG_DELETE_REQUEST_EVENT } from '@/lib/installation-demo-manual-catalog'
import type { InstallationDrawingNode } from '../types'
export function InstallationCatalogDeleteDialog({ drawing, onClose }: {
drawing: InstallationDrawingNode
onClose: () => void
}) {
return (
<Modal isOpen onClose={onClose} title="删除图纸" size="small">
<p className="break-all text-sm font-semibold">{drawing.spuCode} / {drawing.code}</p>
<p className="mt-3 text-sm leading-6 text-[#5a6062]">
将删除该图纸的安装配置、模型绑定和分享链接,无法撤销。其他图纸、独立五金和模型源文件不受影响。
</p>
<div className="mt-5 flex justify-end gap-2">
<button type="button" onClick={onClose} className="h-9 rounded-md border border-black/10 px-4 text-sm">取消</button>
<Button type="button" tone="danger" size="small" onClick={() => window.dispatchEvent(new CustomEvent(INSTALLATION_CATALOG_DELETE_REQUEST_EVENT, {
detail: { drawingId: drawing.id },
}))}>
<Trash2 size={14} />确认删除
</Button>
</div>
</Modal>
)
}

View File

@ -16,8 +16,10 @@ import { InstallationDrawingToolSelection } from './InstallationDrawingToolSelec
import { InstallationToolsInventoryBoard } from './InstallationToolsInventoryBoard'
import { PartCatalogSection } from './PartCatalogSection'
import { StepEditingPanel } from './StepEditingPanel'
import type { InstallationHardwareHingeEditorContext } from './InstallationHardwareHingeEditor'
interface InstallationDetailsPanelProps {
hardwareHingeContext?: InstallationHardwareHingeEditorContext
detailView: InstallationDetailView
onDetailViewChange: (view: InstallationDetailView) => void
canEdit: boolean
@ -194,6 +196,7 @@ function ExplosionCategoryTabs({
}
export function InstallationDetailsPanel({
hardwareHingeContext,
detailView,
onDetailViewChange,
canEdit,
@ -334,6 +337,7 @@ export function InstallationDetailsPanel({
) : (
<PartCatalogSection
parts={explosionNumberingParts}
hardwareHingeContext={hardwareHingeContext}
canEdit={canEdit}
selectedPartId={explosionSelectedNumberingPartId}
selectedNodeNames={selectedNumberingNodeNames}
@ -382,6 +386,7 @@ export function InstallationDetailsPanel({
) : (
<PartCatalogSection
parts={numberingParts}
hardwareHingeContext={hardwareHingeContext}
canEdit={canEdit}
selectedPartId={selectedNumberingPartId}
selectedNodeNames={selectedNumberingNodeNames}

View File

@ -0,0 +1,34 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { InstallationHardwareHingeEditor, type InstallationHardwareHingeEditorContext } from './InstallationHardwareHingeEditor'
import { createInstallationHardwareHinge } from '@/lib/installation-demo-hardware-hinge'
import type { InstallationPartItem } from '../types'
const part: InstallationPartItem = { id: 'hardware', name: '铰链', code: 'H1', kind: 'hardware', nodeName: '固定座', nodeNames: ['固定座', '连杆', '杯座'],
quantity: 1, offset: [0, 0, 0], rotation: [0, 0, 0, 1], nodeTransforms: {}, createdAt: '' }
const context: InstallationHardwareHingeEditorContext = { engine: null, modelNodeNames: ['固定座', '连杆', '杯座', '左柜门', '右柜门', '左侧板'], parts: [part], steps: [], templates: [] }
it('exposes a hardware binding, not an auxiliary tool attachment', () => {
const onChange = jest.fn()
render(<InstallationHardwareHingeEditor part={part} context={context} disabled={false} onChange={onChange} />)
fireEvent.click(screen.getByText('柜门铰链联动'))
fireEvent.click(screen.getByLabelText('此五金是可开合铰链'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ version: 1, motion: expect.objectContaining({ baseNodeName: '固定座', linkNodeName: '连杆', leafNodeName: '杯座' }) }))
expect(onChange.mock.calls[0][0]).not.toHaveProperty('toolId')
})
it('offers searchable door choices from the whole model, not only hardware nodes', () => {
const onChange = jest.fn()
render(<InstallationHardwareHingeEditor part={{ ...part, hardwareHinge: createInstallationHardwareHinge(part.nodeNames) }} context={context} disabled={false} onChange={onChange} />)
fireEvent.click(screen.getByText('柜门铰链联动 · 待配置'))
fireEvent.click(screen.getByText('柜门:未指定'))
fireEvent.change(screen.getByLabelText('搜索柜门'), { target: { value: '左柜门' } })
fireEvent.click(screen.getAllByLabelText('左柜门')[0])
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ doorNodeNames: ['左柜门'], calibration: null }))
expect(screen.getByRole('button', { name: '读取本模型铰链尺寸' })).toBeDisabled()
})
it('does not permit mutations for a read-only user', () => {
render(<InstallationHardwareHingeEditor part={part} context={context} disabled onChange={jest.fn()} />)
fireEvent.click(screen.getByText('柜门铰链联动'))
expect(screen.getByLabelText('此五金是可开合铰链')).toBeDisabled()
})

View File

@ -0,0 +1,101 @@
'use client'
import { useState } from 'react'
import type { ThreeDEngine } from '@/lib/3d/engine'
import {
createInstallationHardwareHinge, copyHardwareHingeMotion,
type InstallationHardwareHingeBinding,
} from '@/lib/installation-demo-hardware-hinge'
import type { InstallationPartItem, InstallationStepDraft, InstallationToolItem } from '../types'
import { getPartNodeNames } from '../lib/installationPartCatalogHelpers'
import { captureHardwareHingeCalibration } from '../lib/installationHardwareHingeCalibration'
import { getHardwareHingePartIssue } from '../lib/installationHardwareHingeBinding'
import { InstallationHardwareHingeNodePicker as NodePicker } from './InstallationHardwareHingeNodePicker'
import { InstallationHardwareHingeJointFields } from './InstallationHardwareHingeJointFields'
export interface InstallationHardwareHingeEditorContext {
engine: ThreeDEngine | null
modelNodeNames: string[]
parts: InstallationPartItem[]
steps: InstallationStepDraft[]
templates: InstallationToolItem[]
}
export function InstallationHardwareHingeEditor({ part, context, disabled, onChange }: {
part: InstallationPartItem; context: InstallationHardwareHingeEditorContext
disabled: boolean; onChange: (binding: InstallationHardwareHingeBinding | null) => void
}) {
const [message, setMessage] = useState<string | null>(null)
const binding = part.hardwareHinge
const nodeNames = getPartNodeNames(part)
const update = (patch: Partial<InstallationHardwareHingeBinding>, invalidate = false) => {
if (!binding) return
setMessage(null)
onChange({ ...binding, ...patch, ...(invalidate ? { calibration: null } : {}) })
}
const issue = getHardwareHingePartIssue(part, context.parts, context.steps)
const templates = context.templates.filter((item) => item.motionType === 'hinge' && item.articulationConfig)
return (
<details className="rounded-[6px] border border-black/[0.08] bg-[#f8fafb] p-2">
<summary className="cursor-pointer text-[11px] font-bold">柜门铰链联动{binding ? issue ? ' · 待配置' : ' · 已配置' : ''}</summary>
<div className="mt-2 grid gap-2">
<label className="flex items-center gap-2 text-[11px]">
<input type="checkbox" checked={Boolean(binding)} disabled={disabled}
onChange={(event) => { setMessage(null); onChange(event.target.checked ? createInstallationHardwareHinge(nodeNames) : null) }} />
此五金是可开合铰链
</label>
{binding ? <>
<p className="text-[10px] leading-5 text-[#777d7f]">先以柜门为固定件安装打开的铰链;再把预装组件整体导入侧板安装步骤。组合姿态保存为打开状态,系统只在指定步骤末尾闭合一次并停住。</p>
<p className="text-[10px] text-[#777d7f]">每个铰链实例单独建五金编号并对应三个节点;同门多个铰链使用同一闭合步骤。</p>
{([['baseNodeName', '固定座'], ['linkNodeName', '连杆'], ['leafNodeName', '门端杯座']] as const).map(([field, label]) => (
<NodePicker key={field} label={label} options={nodeNames} disabled={disabled}
value={binding.motion[field] ? [binding.motion[field]!] : []}
onChange={(names) => update({ motion: { ...binding.motion, [field]: names[0] ?? null } }, true)} />
))}
<NodePicker label="柜门" options={context.modelNodeNames} value={binding.doorNodeNames} multiple disabled={disabled}
onChange={(doorNodeNames) => update({ doorNodeNames }, true)} />
<NodePicker label="柜体侧板" options={context.modelNodeNames} value={binding.sideNodeName ? [binding.sideNodeName] : []} disabled={disabled}
onChange={(names) => update({ sideNodeName: names[0] ?? null }, true)} />
<NodePicker label="门侧随动螺丝或附件(可选)" options={context.modelNodeNames} value={binding.followerNodeNames} multiple disabled={disabled}
onChange={(followerNodeNames) => update({ followerNodeNames }, true)} />
<p className="text-[10px] text-[#777d7f]">固定座上的螺丝仍属于侧板安装动作,不加入门侧随动件。</p>
<label className="flex items-center gap-2 text-[11px]">
<input type="checkbox" checked={binding.drivesDoor} disabled={disabled}
onChange={(event) => update({ drivesDoor: event.target.checked })} />
驱动柜门(同一扇门只选一个主铰链)
</label>
<label className="grid gap-1 text-[11px]">闭合步骤
<select aria-label="闭合步骤" value={binding.closeStepId ?? ''} disabled={disabled}
onChange={(event) => update({ closeStepId: event.target.value || null })}
className="h-8 min-w-0 rounded border border-black/[0.08] bg-white px-2">
<option value="">选择安装到侧板的步骤</option>
{context.steps.map((step) => <option key={step.id} value={step.id}>{step.order}. {step.title}</option>)}
</select>
</label>
{templates.length ? <label className="grid gap-1 text-[11px]">沿用已保存的铰链曲线
<select aria-label="沿用铰链曲线" value="" disabled={disabled}
onChange={(event) => {
const template = templates.find((item) => item.id === event.target.value)
if (template?.articulationConfig) onChange(copyHardwareHingeMotion(binding, template.articulationConfig))
}} className="h-8 min-w-0 rounded border border-black/[0.08] bg-white px-2">
<option value="">仅复制曲线,不添加工具实例</option>
{templates.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</label> : null}
<button type="button" disabled={disabled || !context.engine}
onClick={() => {
try {
const calibration = captureHardwareHingeCalibration(context.engine!, binding)
onChange({ ...binding, calibration }); setMessage('已读取本铰链原始尺寸;不会使用爆炸位移。请在安装步骤预览中核对开合方向。')
} catch (error) { setMessage(error instanceof Error ? error.message : '读取尺寸失败') }
}} className="h-8 rounded border border-black/[0.08] bg-white text-[11px] font-semibold disabled:opacity-40">
读取本模型铰链尺寸
</button>
<InstallationHardwareHingeJointFields motion={binding.motion} disabled={disabled} onChange={(motion) => update({ motion })} />
{issue ? <p role="status" className="text-[10px] leading-5 text-amber-700">{issue}</p> : <p role="status" className="text-[10px] text-[#5a6062]">配置就绪,等待安装步骤预览验证。</p>}
{message ? <p role="status" className="text-[10px] leading-5 text-[#5a6062]">{message}</p> : null}
</> : null}
</div>
</details>
)
}

View File

@ -0,0 +1,52 @@
'use client'
import type { InstallationHardwareHingeBinding } from '@/lib/installation-demo-hardware-hinge'
type Motion = InstallationHardwareHingeBinding['motion']
export function InstallationHardwareHingeJointFields({ motion, disabled, onChange }: {
motion: Motion; disabled: boolean; onChange: (motion: Motion) => void
}) {
return (
<details>
<summary className="cursor-pointer text-[11px] font-semibold">轴心、镜像方向与曲线</summary>
<p className="my-2 text-[10px] text-[#777d7f]">位置为本铰链尺寸内的 0~1 比例;轴方向在固定座局部坐标系中。镜像实例须核对轴方向,不能仅凭左右名称套用。</p>
{(['primaryJoint', 'secondaryJoint'] as const).map((key) => {
const joint = motion[key]
return <fieldset key={key} disabled={disabled} className="mb-2 grid gap-2 rounded border border-black/[0.08] p-2">
<legend className="text-[11px]">{key === 'primaryJoint' ? '主轴' : '门轴'}</legend>
{(['pivot', 'axis'] as const).map((field) => (
<label key={field} className="grid gap-1 text-[10px]">
{field === 'pivot' ? '轴心位置 X / Y / Z' : '轴方向 X / Y / Z'}
<span className="grid grid-cols-3 gap-1">
{joint[field].map((number, index) => (
<input key={index} type="number" step="0.01" value={number}
aria-label={`${key}-${field}-${index}`}
min={field === 'pivot' ? 0 : undefined} max={field === 'pivot' ? 1 : undefined}
onChange={(event) => {
const value = event.target.valueAsNumber
if (!Number.isFinite(value) || (field === 'pivot' && (value < 0 || value > 1))) return
const vector = [...joint[field]] as [number, number, number]
vector[index] = value
if (field === 'axis' && vector.every((n) => Math.abs(n) < 1e-8)) return
onChange({ ...motion, [key]: { ...joint, [field]: vector } })
}} className="h-7 min-w-0 rounded border border-black/[0.08] px-1" />
))}
</span>
</label>
))}
{(['middleAngleDegrees', 'closedAngleDegrees'] as const).map((field) => (
<label key={field} className="flex items-center justify-between text-[10px]">
{field === 'middleAngleDegrees' ? '中间角度' : '闭合角度'}(°)
<input type="number" min={-180} max={180} value={joint[field]}
onChange={(event) => {
const value = event.target.valueAsNumber
if (Number.isFinite(value) && Math.abs(value) <= 180) onChange({ ...motion, [key]: { ...joint, [field]: value } })
}} className="h-7 w-20 rounded border border-black/[0.08] px-1" />
</label>
))}
</fieldset>
})}
</details>
)
}

View File

@ -0,0 +1,33 @@
'use client'
import { useState } from 'react'
export function InstallationHardwareHingeNodePicker({ label, options, value, multiple = false, disabled, onChange }: {
label: string; options: string[]; value: string[]; multiple?: boolean; disabled: boolean
onChange: (names: string[]) => void
}) {
const [search, setSearch] = useState('')
const names = [...new Set([...value, ...options])]
return (
<details className="rounded-[6px] border border-black/[0.08] bg-white">
<summary className="cursor-pointer px-2 py-2 text-[11px] font-semibold">
{label}:{value.length ? value.join('、') : '未指定'}
</summary>
<div className="grid gap-2 border-t border-black/[0.06] p-2">
<input aria-label={`搜索${label}`} placeholder="搜索原始节点名称" value={search}
onChange={(event) => setSearch(event.target.value)}
className="h-8 min-w-0 rounded border border-black/[0.08] px-2 text-[11px]" />
<div className="max-h-36 overflow-y-auto">
{names.filter((name) => name.toLowerCase().includes(search.toLowerCase())).map((name) => (
<label key={name} className="flex items-start gap-2 py-1 text-[11px] break-all">
<input type="checkbox" disabled={disabled} checked={value.includes(name)}
onChange={(event) => onChange(event.target.checked
? multiple ? [...value, name] : [name] : value.filter((n) => n !== name))}
className="mt-0.5 accent-[#2d3335]" />{name}
</label>
))}
</div>
</div>
</details>
)
}

View File

@ -19,8 +19,10 @@ import type {
import { InstallationDetailsPanel } from './InstallationDetailsPanel'
import { InstallationSharePanel } from './InstallationSharePanel'
import { ProductStructurePanel } from './ProductStructurePanel'
import type { InstallationHardwareHingeEditorContext } from './InstallationHardwareHingeEditor'
interface InstallationInspectorProps {
hardwareHingeContext?: InstallationHardwareHingeEditorContext
activeTab: InstallationInspectorTab
onTabChange: (tab: InstallationInspectorTab) => void
spus: InstallationSpuNode[]
@ -119,6 +121,7 @@ const TABS: Array<{ value: InstallationInspectorTab; label: string }> = [
]
export function InstallationInspector({
hardwareHingeContext,
activeTab,
onTabChange,
spus,
@ -214,7 +217,7 @@ export function InstallationInspector({
<div className="border-b border-black/[0.06] px-5 py-4">
<p className="text-[17px] font-black text-[#2d3335]">安装演示工作台</p>
<p className="mt-1 truncate text-[12px] font-semibold text-[#5a6062]">
{selectedSpu?.code ?? '未选择 SPU'} / {selectedModel?.code ?? '-'} /{' '}
{selectedSpu?.code ?? '未选择 SPU'} /{' '}
{selectedDrawing?.code ?? '-'}
</p>
</div>
@ -273,6 +276,7 @@ export function InstallationInspector({
{activeTab === 'steps' ? (
<InstallationDetailsPanel
hardwareHingeContext={hardwareHingeContext}
detailView={detailView}
onDetailViewChange={onDetailViewChange}
canEdit={canEdit}

View File

@ -641,7 +641,7 @@ export function InstallationPreviewStage({
!isToolPreview && view === 'steps' && resolvedStepWorkspaceMode === 'edit'
}
emptyTitle="选择图纸 ID 后加载模型"
emptyDescription="先在右侧选择 SPU、型号和图纸 ID,再上传对应模型进入安装步骤编辑。"
emptyDescription=""
emptyIcon={<Box size={30} strokeWidth={1.6} />}
emptyBackdrop={<AssetWorkbenchEmptyBackdrop />}
onModelNodesChange={

View File

@ -24,7 +24,7 @@ interface InstallationSharePanelProps {
}
function formatExpiresAt(value: string | null) {
if (!value) return '-'
if (!value) return '永久有效'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString('zh-CN', {
@ -147,7 +147,7 @@ export function InstallationSharePanel({
].join(' ')}
>
<Link2 size={13} strokeWidth={1.8} />
临时链接
分享链接
</button>
<button
type="button"
@ -173,7 +173,7 @@ export function InstallationSharePanel({
<div className="flex min-h-12 items-center justify-between gap-4 py-3">
<span className="text-[11px] font-black text-[#5a6062]">有效期</span>
<span className="text-right text-[12px] font-semibold text-[#2d3335]">
{shareUrl ? formatExpiresAt(expiresAt) : '生成后 24 小时'}
{shareUrl ? formatExpiresAt(expiresAt) : '永久有效'}
</span>
</div>

View File

@ -14,6 +14,7 @@ import {
resolveHardwareCompositeGroupQuantity,
} from '../lib/installationHardwareComposite'
import { createInstallationToolBinding } from '../lib/installationToolBindingDefaults'
import { InstallationHardwareHingeEditor, type InstallationHardwareHingeEditorContext } from './InstallationHardwareHingeEditor'
interface HardwareCompositeCandidateSummary {
partId: string
@ -22,6 +23,7 @@ interface HardwareCompositeCandidateSummary {
}
interface PartCatalogSectionProps {
hardwareHingeContext?: InstallationHardwareHingeEditorContext
parts: InstallationPartItem[]
canEdit: boolean
selectedPartId?: string | null
@ -70,6 +72,7 @@ function getPartIcon(kind: InstallationPartItem['kind']) {
}
export function PartCatalogSection({
hardwareHingeContext,
parts,
canEdit,
selectedPartId = null,
@ -384,6 +387,10 @@ export function PartCatalogSection({
找同型
</button>
) : null}
{part.kind === 'hardware' && hardwareHingeContext ? (
<InstallationHardwareHingeEditor part={part} context={hardwareHingeContext} disabled={!editable}
onChange={(hardwareHinge) => onUpdatePart?.(part.id, { hardwareHinge })} />
) : null}
{part.kind === 'hardware' && showScrewDriveControl ? (
<div className="grid gap-2" onClick={(event) => event.stopPropagation()}>
<label

View File

@ -37,6 +37,15 @@ describe('ProductStructurePanel', () => {
jest.clearAllMocks()
})
it('shows both commands and disables delete without a drawing or edit permission', () => {
const { rerender } = render(<ProductStructurePanel {...defaultProps} spus={[]} />)
expect(screen.getByRole('button', { name: '新增', exact: true })).toBeEnabled()
expect(screen.getByRole('button', { name: '删除', exact: true })).toBeDisabled()
rerender(<ProductStructurePanel {...defaultProps} canEdit={false} spus={[]} />)
expect(screen.getByRole('button', { name: '新增', exact: true })).toBeDisabled()
expect(screen.getByRole('button', { name: '删除', exact: true })).toBeDisabled()
})
it('paginates SPUs at 50 per page', async () => {
const actor = userEvent.setup()
render(<ProductStructurePanel {...defaultProps} spus={Array.from({ length: 51 }, (_, index) => createSpu(index + 1))} />)
@ -57,7 +66,7 @@ describe('ProductStructurePanel', () => {
render(<ProductStructurePanel {...defaultProps} spus={Array.from({ length: 51 }, (_, index) => createSpu(index + 1))} />)
await actor.click(screen.getByRole('button', { name: '下一页' }))
await actor.type(screen.getByPlaceholderText('搜索 SPU / 型号 / 图纸ID'), 'SPU-001')
await actor.type(screen.getByPlaceholderText('搜索 SPU / 图纸 ID'), 'SPU-001')
expect(screen.getByText('SPU-001')).toBeInTheDocument()
expect(screen.getByText('1 条')).toBeInTheDocument()

View File

@ -1,16 +1,11 @@
'use client'
import { ChevronRight, RefreshCw, Upload } from 'lucide-react'
import { useMemo, useState } from 'react'
import type {
InstallationDrawingNode,
InstallationModelNode,
InstallationSpuNode,
} from '../types'
import {
INSTALLATION_CATALOG_PAGE_SIZE,
ProductCatalogPagination,
} from './ProductCatalogPagination'
import { ChevronRight, Plus, RefreshCw, Trash2, Upload } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import type { InstallationDrawingNode, InstallationModelNode, InstallationSpuNode } from '../types'
import { InstallationCatalogCreateDialog } from './InstallationCatalogCreateDialog'
import { InstallationCatalogDeleteDialog } from './InstallationCatalogDeleteDialog'
import { INSTALLATION_CATALOG_PAGE_SIZE, ProductCatalogPagination } from './ProductCatalogPagination'
interface ProductStructurePanelProps {
spus: InstallationSpuNode[]
@ -27,267 +22,105 @@ interface ProductStructurePanelProps {
onRefresh: () => void
}
function normalizeSearchText(value: string) {
return value.trim().toUpperCase()
}
function modelMatchesKeyword(model: InstallationModelNode, keyword: string) {
if (!keyword) return true
return [
model.code,
...model.drawings.map((drawing) => drawing.code),
].join(' ').toUpperCase().includes(keyword)
}
function drawingMatchesKeyword(drawing: InstallationDrawingNode, keyword: string) {
if (!keyword) return true
return [
drawing.code,
...drawing.skuCodes,
].join(' ').toUpperCase().includes(keyword)
}
export function ProductStructurePanel({
spus,
selectedSpu,
selectedModel,
selectedDrawing,
loading,
canEdit,
uploading,
onSelectDrawing,
onUploadModel,
onRefresh,
spus, selectedSpu, selectedDrawing, loading, canEdit, uploading,
onSelectDrawing, onUploadModel, onRefresh,
}: ProductStructurePanelProps) {
const [keyword, setKeyword] = useState('')
const [currentPage, setCurrentPage] = useState(1)
const [creating, setCreating] = useState(false)
const [deletingDrawing, setDeletingDrawing] = useState<InstallationDrawingNode | null>(null)
const [expandedSpuIds, setExpandedSpuIds] = useState<Set<string>>(new Set())
const [expandedModelIds, setExpandedModelIds] = useState<Set<string>>(new Set())
const normalizedKeyword = normalizeSearchText(keyword)
const filteredSpus = useMemo(() => {
if (!normalizedKeyword) return spus
return spus.filter((spu) => {
const haystack = [
spu.code,
spu.productName,
spu.productCategory,
spu.brand,
...spu.models.map((model) => model.code),
...spu.models.flatMap((model) => model.drawings.map((drawing) => drawing.code)),
]
.join(' ')
.toUpperCase()
return haystack.includes(normalizedKeyword)
})
}, [normalizedKeyword, spus])
const totalPages = Math.max(
1,
Math.ceil(filteredSpus.length / INSTALLATION_CATALOG_PAGE_SIZE),
)
const normalizedKeyword = keyword.trim().toUpperCase()
const filteredSpus = useMemo(() => spus.filter((spu) =>
[spu.code, ...spu.models.flatMap((model) => model.drawings.map((drawing) => drawing.code))]
.join(' ').toUpperCase().includes(normalizedKeyword),
), [normalizedKeyword, spus])
const totalPages = Math.max(1, Math.ceil(filteredSpus.length / INSTALLATION_CATALOG_PAGE_SIZE))
const safeCurrentPage = Math.min(currentPage, totalPages)
const visibleSpus = filteredSpus.slice(
(safeCurrentPage - 1) * INSTALLATION_CATALOG_PAGE_SIZE,
safeCurrentPage * INSTALLATION_CATALOG_PAGE_SIZE,
)
const visibleSpus = filteredSpus.slice((safeCurrentPage - 1) * INSTALLATION_CATALOG_PAGE_SIZE, safeCurrentPage * INSTALLATION_CATALOG_PAGE_SIZE)
const toggleSpu = (spuId: string) => {
setExpandedSpuIds((current) => {
const next = new Set(current)
if (next.has(spuId)) {
next.delete(spuId)
} else {
next.add(spuId)
}
return next
})
}
useEffect(() => {
if (!selectedSpu) return
// Reveal the newly selected drawing after a catalog refresh.
// eslint-disable-next-line react-hooks/set-state-in-effect
setExpandedSpuIds((current) => new Set([...current, selectedSpu.id]))
const index = spus.findIndex((spu) => spu.id === selectedSpu.id)
if (index >= 0) setCurrentPage(Math.floor(index / INSTALLATION_CATALOG_PAGE_SIZE) + 1)
}, [selectedSpu, spus])
const toggleModel = (modelId: string) => {
setExpandedModelIds((current) => {
const next = new Set(current)
if (next.has(modelId)) {
next.delete(modelId)
} else {
next.add(modelId)
}
return next
})
}
const getVisibleModels = (spu: InstallationSpuNode) => {
if (!normalizedKeyword) return spu.models
const spuMatched = [
spu.code,
spu.productName,
spu.productCategory,
spu.brand,
].join(' ').toUpperCase().includes(normalizedKeyword)
return spuMatched
? spu.models
: spu.models.filter((model) => modelMatchesKeyword(model, normalizedKeyword))
}
const getVisibleDrawings = (model: InstallationModelNode) => {
if (!normalizedKeyword) return model.drawings
const modelMatched = model.code.toUpperCase().includes(normalizedKeyword)
return modelMatched
? model.drawings
: model.drawings.filter((drawing) => drawingMatchesKeyword(drawing, normalizedKeyword))
}
const toggleSpu = (id: string) => setExpandedSpuIds((current) => {
const next = new Set(current)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<div className="flex items-center gap-2 border-b border-black/[0.06] px-4 py-3">
<input
value={keyword}
onChange={(event) => {
setKeyword(event.target.value)
setCurrentPage(1)
}}
placeholder="搜索 SPU / 型号 / 图纸ID"
className="h-9 min-w-0 flex-1 rounded-[6px] border border-black/[0.08] bg-white px-3 text-[12px] font-semibold text-[#2d3335] outline-none transition placeholder:text-[#8b9092] focus:border-[#2d3335]/40"
/>
<button
type="button"
title="刷新"
onClick={onRefresh}
className="flex h-9 w-9 items-center justify-center rounded-[6px] border border-black/[0.08] bg-white text-[#5a6062] transition hover:bg-black/[0.03]"
>
<RefreshCw size={15} strokeWidth={1.8} className={loading ? 'animate-spin' : ''} />
<div className="flex flex-wrap items-center gap-2 border-b border-black/[0.06] px-4 py-3">
<input value={keyword} onChange={(event) => { setKeyword(event.target.value); setCurrentPage(1) }}
placeholder="搜索 SPU / 图纸 ID"
className="h-9 min-w-24 flex-1 rounded-md border border-black/[0.08] bg-white px-3 text-xs outline-none focus:border-[#2d3335]/40" />
<button type="button" title="刷新" onClick={onRefresh} disabled={loading}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-black/[0.08] hover:bg-black/[0.03]">
<RefreshCw size={15} className={loading ? 'animate-spin' : ''} />
</button>
<label
title={!selectedDrawing ? '请先在产品目录中选择图纸' : '上传模型'}
className={[
'flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-[6px] px-3 text-[11px] font-black transition',
canEdit && selectedDrawing && !uploading
? 'bg-[#2d3335] text-white'
: 'cursor-not-allowed bg-black/[0.06] text-[#8b9092]',
].join(' ')}
>
<Upload size={14} strokeWidth={1.8} />
{uploading ? '加载中' : '上传'}
<input
type="file"
accept=".zip,.glb,.gltf,.obj"
disabled={!canEdit || !selectedDrawing || uploading}
onChange={(event) => {
const file = event.target.files?.[0]
event.target.value = ''
if (file) onUploadModel(file)
}}
className="hidden"
/>
<button type="button" onClick={() => setCreating(true)} disabled={!canEdit || loading}
className="inline-flex h-9 shrink-0 items-center gap-1 rounded-md border border-black/[0.08] px-3 text-xs font-semibold disabled:opacity-40">
<Plus size={14} />新增
</button>
<button type="button" onClick={() => setDeletingDrawing(selectedDrawing)}
disabled={!canEdit || !selectedDrawing || loading || uploading}
title={selectedDrawing ? '删除当前图纸' : '请先选择图纸'}
className="inline-flex h-9 shrink-0 items-center gap-1 rounded-md border border-red-200 px-3 text-xs font-semibold text-red-700 disabled:opacity-40">
<Trash2 size={14} />删除
</button>
<label title={!selectedDrawing ? '请先选择图纸' : '上传模型'}
className={['inline-flex h-9 shrink-0 items-center gap-1 rounded-md px-3 text-xs font-semibold',
canEdit && selectedDrawing && !uploading ? 'cursor-pointer bg-[#2d3335] text-white' : 'bg-black/[0.06] text-[#8b9092]'].join(' ')}>
<Upload size={14} />{uploading ? '上传中' : '上传'}
<input type="file" accept=".zip,.glb,.gltf,.obj" disabled={!canEdit || !selectedDrawing || uploading}
onChange={(event) => { const file = event.target.files?.[0]; event.target.value = ''; if (file) onUploadModel(file) }}
className="hidden" />
</label>
</div>
<section className="mx-3 mb-3 mt-3 flex min-h-0 flex-1 flex-col overflow-hidden rounded-[8px] border border-black/[0.06] bg-white">
<div className="border-b border-black/[0.06] px-3 py-2">
<p className="text-[11px] font-black text-[#2d3335]">产品目录</p>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2 pb-14">
<div className="grid gap-1">
{filteredSpus.length === 0 ? (
<p className="rounded-[6px] bg-[#f5f7f8] px-3 py-2 text-[11px] font-semibold text-[#8b9092]">
没有找到匹配的产品目录。
</p>
) : null}
<section className="flex min-h-0 flex-1 flex-col bg-white">
<div className="border-b border-black/[0.06] px-4 py-3 text-xs font-semibold">产品目录</div>
<div className="min-h-0 flex-1 overflow-y-auto px-3 pt-2 pb-20">
{filteredSpus.length === 0 && !loading ? <p className="px-3 py-4 text-xs text-[#8b9092]">{keyword ? '没有匹配的目录' : '暂无安装演示'}</p> : null}
<div className="grid gap-1">
{visibleSpus.map((spu) => {
const visibleModels = getVisibleModels(spu)
const expanded = normalizedKeyword ? true : expandedSpuIds.has(spu.id)
const expanded = Boolean(normalizedKeyword) || expandedSpuIds.has(spu.id)
const drawings = spu.models.flatMap((model) => model.drawings).filter((drawing) =>
spu.code.includes(normalizedKeyword) || drawing.code.toUpperCase().includes(normalizedKeyword))
return (
<div key={spu.id}>
<button
type="button"
onClick={() => toggleSpu(spu.id)}
className={[
'flex w-full items-center gap-2 rounded-[6px] px-3 py-2 text-left transition',
selectedSpu?.id === spu.id ? 'bg-[#2d3335] text-white' : 'hover:bg-black/[0.04]',
].join(' ')}
>
<ChevronRight
size={14}
strokeWidth={2}
className={['shrink-0 transition', expanded ? 'rotate-90' : ''].join(' ')}
/>
<button type="button" onClick={() => toggleSpu(spu.id)} aria-expanded={expanded}
className={['flex w-full items-center gap-2 rounded-md px-3 py-2 text-left',
selectedSpu?.id === spu.id ? 'bg-[#2d3335] text-white' : 'hover:bg-black/[0.04]'].join(' ')}>
<ChevronRight size={14} className={expanded ? 'shrink-0 rotate-90' : 'shrink-0'} />
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] font-black">{spu.code}</span>
<span className="mt-0.5 block truncate text-[10px] opacity-70">
{spu.modelCount} 型号 · {spu.drawingCount} 图纸
</span>
<span className="block break-all text-xs font-semibold">{spu.code}</span>
<span className="mt-1 block text-[10px] opacity-70">{spu.drawingCount} 图纸</span>
</span>
</button>
{expanded ? (
<div className="ml-5 mt-1 grid gap-1 border-l border-black/[0.06] pl-2">
{visibleModels.map((model) => {
const visibleDrawings = getVisibleDrawings(model)
const modelExpanded = normalizedKeyword ? true : expandedModelIds.has(model.id)
return (
<div key={model.id}>
<button
type="button"
onClick={() => toggleModel(model.id)}
className={[
'flex w-full items-center gap-2 rounded-[6px] px-3 py-2 text-left transition',
selectedModel?.id === model.id
? 'bg-[#e9edee] text-[#2d3335]'
: 'hover:bg-black/[0.04]',
].join(' ')}
>
<ChevronRight
size={13}
strokeWidth={2}
className={['shrink-0 transition', modelExpanded ? 'rotate-90' : ''].join(' ')}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] font-black">{model.code}</span>
<span className="mt-0.5 block truncate text-[10px] text-[#5a6062]">
{model.drawingCount} 图纸 · {model.skuCount} SKU
</span>
</span>
</button>
{modelExpanded ? (
<div className="ml-5 mt-1 grid gap-1 border-l border-black/[0.06] pl-2">
{visibleDrawings.map((drawing) => (
<button
key={drawing.id}
type="button"
onClick={() => onSelectDrawing(drawing.id)}
className={[
'block w-full rounded-[6px] px-3 py-2 text-left transition',
selectedDrawing?.id === drawing.id
? 'bg-[#e9edee] text-[#2d3335]'
: 'hover:bg-black/[0.04]',
].join(' ')}
>
<span className="block truncate text-[12px] font-black">{drawing.code}</span>
<span className="mt-0.5 block truncate text-[10px] text-[#5a6062]">
{drawing.skuCount} SKU
</span>
</button>
))}
</div>
) : null}
</div>
)
})}
</div>
) : null}
{expanded ? <div className="ml-5 mt-1 grid gap-1 border-l border-black/[0.06] pl-2">
{drawings.map((drawing) => <button key={drawing.id} type="button" onClick={() => onSelectDrawing(drawing.id)}
className={['w-full break-all rounded-md px-3 py-2 text-left text-xs font-semibold',
selectedDrawing?.id === drawing.id ? 'bg-[#e9edee] text-[#2d3335]' : 'hover:bg-black/[0.04]'].join(' ')}>
{drawing.code}
</button>)}
</div> : null}
</div>
)
})}
</div>
</div>
</div>
</section>
<ProductCatalogPagination
currentPage={safeCurrentPage}
totalItems={filteredSpus.length}
totalPages={totalPages}
loading={loading}
onPageChange={setCurrentPage}
/>
<ProductCatalogPagination currentPage={safeCurrentPage} totalItems={filteredSpus.length} totalPages={totalPages} loading={loading} onPageChange={setCurrentPage} />
{creating ? <InstallationCatalogCreateDialog initialSpu={selectedSpu?.code ?? ''} onClose={() => { setCreating(false); setKeyword('') }} /> : null}
{deletingDrawing ? <InstallationCatalogDeleteDialog drawing={deletingDrawing} onClose={() => setDeletingDrawing(null)} /> : null}
</div>
)
}

View File

@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { fetchInstallationDemoWorkbenchCatalog } from '@/lib/open-product/platform-api'
import { INSTALLATION_CATALOG_CREATED_EVENT, type InstallationManualDrawing } from '@/lib/installation-demo-manual-catalog'
import type { InstallationDemoWorkbenchAccess } from '@/lib/installation-demo-workbench-permissions'
import type {
InstallationDrawingNode,
@ -53,7 +54,7 @@ export function useInstallationCatalogController(initialAccess: InstallationDemo
drawingId: null,
})
const loadCatalog = useCallback(async () => {
const loadCatalog = useCallback(async (created?: InstallationManualDrawing) => {
setLoading(true)
setMessage(null)
try {
@ -61,7 +62,8 @@ export function useInstallationCatalogController(initialAccess: InstallationDemo
setAccess(catalog.access)
setSpus(catalog.spus)
setSelection((current) =>
current.spuId ? resolveSelection(catalog.spus, current) : resolveInitialSelection(catalog.spus),
created ? { spuId: created.spuCode, modelId: created.spuCode, drawingId: created.id }
: current.spuId ? resolveSelection(catalog.spus, current) : resolveInitialSelection(catalog.spus),
)
} catch (error) {
setMessage(error instanceof Error ? error.message : '安装演示目录加载失败')
@ -76,6 +78,12 @@ export function useInstallationCatalogController(initialAccess: InstallationDemo
void loadCatalog()
}, [loadCatalog])
useEffect(() => {
const onCreated = (event: Event) => { void loadCatalog((event as CustomEvent<InstallationManualDrawing>).detail) }
window.addEventListener(INSTALLATION_CATALOG_CREATED_EVENT, onCreated)
return () => window.removeEventListener(INSTALLATION_CATALOG_CREATED_EVENT, onCreated)
}, [loadCatalog])
const selectedSpu = useMemo<InstallationSpuNode | null>(
() => spus.find((spu) => spu.id === selection.spuId) ?? null,
[selection.spuId, spus],
@ -147,6 +155,6 @@ export function useInstallationCatalogController(initialAccess: InstallationDemo
selectSpu,
selectModel,
selectDrawing,
refresh: loadCatalog,
refresh: () => loadCatalog(),
}
}

View File

@ -0,0 +1,43 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { INSTALLATION_CATALOG_DELETE_REQUEST_EVENT } from '@/lib/installation-demo-manual-catalog'
import { clearDeletedDrawingStorage } from '../lib/installationDrawingDeletionStorage'
import { useInstallationCatalogDeletion } from './useInstallationCatalogDeletion'
jest.mock('../lib/installationDrawingDeletionStorage', () => ({ clearDeletedDrawingStorage: jest.fn() }))
const originalFetch = global.fetch
const dispatch = () => window.dispatchEvent(new CustomEvent(INSTALLATION_CATALOG_DELETE_REQUEST_EVENT, { detail: { drawingId: 'manual-one' } }))
beforeEach(() => { jest.clearAllMocks(); jest.mocked(clearDeletedDrawingStorage).mockResolvedValue(undefined) })
afterEach(() => { global.fetch = originalFetch })
it('sends only one request while pending and cleans only the deleted drawing', async () => {
let resolveResponse!: (value: unknown) => void
const request = new Promise(resolve => { resolveResponse = resolve })
global.fetch = jest.fn().mockReturnValue(request)
const { result } = renderHook(() => useInstallationCatalogDeletion())
act(() => { dispatch(); dispatch() })
expect(result.current.deleting).toBe(true)
expect(global.fetch).toHaveBeenCalledTimes(1)
await act(async () => { resolveResponse({ ok: true, json: async () => ({ success: true }) }) })
await waitFor(() => expect(result.current.deleting).toBe(false))
expect(clearDeletedDrawingStorage).toHaveBeenCalledWith('manual-one')
expect(result.current.notice).toEqual({ text: '图纸已删除', error: false })
})
it('reports server failure and preserves browser data', async () => {
global.fetch = jest.fn().mockResolvedValue({ ok: false, json: async () => ({ success: false, error: { message: 'No permission' } }) })
const { result } = renderHook(() => useInstallationCatalogDeletion())
act(dispatch)
await waitFor(() => expect(result.current.deleting).toBe(false))
expect(clearDeletedDrawingStorage).not.toHaveBeenCalled()
expect(result.current.notice).toEqual({ text: 'No permission', error: true })
})
it('distinguishes successful server deletion from local cache failure', async () => {
global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true }) })
jest.mocked(clearDeletedDrawingStorage).mockRejectedValueOnce(new Error('Storage unavailable'))
const { result } = renderHook(() => useInstallationCatalogDeletion())
act(dispatch)
await waitFor(() => expect(result.current.deleting).toBe(false))
expect(result.current.notice?.text).toContain('图纸已删除,但本机缓存未完全清理')
expect(result.current.notice?.error).toBe(true)
})

View File

@ -0,0 +1,48 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { INSTALLATION_CATALOG_DELETE_REQUEST_EVENT, type DeleteInstallationManualDrawingInput } from '@/lib/installation-demo-manual-catalog'
import { clearDeletedDrawingStorage } from '../lib/installationDrawingDeletionStorage'
export function useInstallationCatalogDeletion() {
const [deleting, setDeleting] = useState(false)
const [notice, setNotice] = useState<{ text: string; error: boolean } | null>(null)
const [revision, setRevision] = useState(0)
const busy = useRef(false)
useEffect(() => {
const onDelete = (event: Event) => {
if (busy.current) return
const { drawingId } = (event as CustomEvent<DeleteInstallationManualDrawingInput>).detail
busy.current = true
setDeleting(true)
setNotice(null)
void (async () => {
try {
const response = await fetch('/api/installation-demo-workbench/catalog', {
method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ drawingId }),
})
const result = await response.json()
if (!response.ok || !result.success) throw new Error(result.error?.message || '删除失败,请重试')
try {
await clearDeletedDrawingStorage(drawingId)
setNotice({ text: '图纸已删除', error: false })
} catch {
setNotice({ text: '图纸已删除,但本机缓存未完全清理,请刷新页面', error: true })
}
} catch (error) {
setNotice({ text: error instanceof Error ? error.message : '删除失败,请重试', error: true })
} finally {
// Remount after removal so live editor maps cannot restore deleted entries.
setRevision(current => current + 1)
setDeleting(false)
busy.current = false
}
})()
}
window.addEventListener(INSTALLATION_CATALOG_DELETE_REQUEST_EVENT, onDelete)
return () => window.removeEventListener(INSTALLATION_CATALOG_DELETE_REQUEST_EVENT, onDelete)
}, [])
return { deleting, notice, revision, dismissNotice: () => setNotice(null) }
}

View File

@ -54,7 +54,7 @@ export function useInstallationDrawingConfirmation({
}
const share = await syncInstallationDemoQrShare(shareInput);
onQrShareSynced(share);
setMessage("图纸样式已确认,永久二维码已同步;临时链接请重新生成");
setMessage("图纸样式已确认,永久二维码已同步;分享链接请重新生成");
return true;
} catch (error) {
const detail =

View File

@ -38,6 +38,8 @@ export function useInstallationNumberingCatalog(drawingId: string | null, canEdi
const [selectedPartId, setSelectedPartId] = useState<string | null>(null)
const partsByDrawingIdRef = useRef(partsByDrawingId)
const serverLoadedDrawingIdsRef = useRef<Set<string>>(new Set())
const [saveFeedback, setSaveFeedback] = useState<{ drawingId: string; message: string } | null>(null)
const saveRevisionRef = useRef(0)
useEffect(() => {
partsByDrawingIdRef.current = partsByDrawingId
@ -100,11 +102,16 @@ export function useInstallationNumberingCatalog(drawingId: string | null, canEdi
if (!drawingId || !serverLoadedDrawingIdsRef.current.has(drawingId)) return
const timer = window.setTimeout(() => {
const revision = ++saveRevisionRef.current
const hasHinge = parts.some((part) => Boolean(part.hardwareHinge))
if (hasHinge) setSaveFeedback({ drawingId, message: '五金铰链配置保存中…' })
void saveInstallationDemoNumberingConfig({
drawingId,
parts,
}).then(() => {
if (revision === saveRevisionRef.current) setSaveFeedback(hasHinge ? { drawingId, message: '五金铰链配置已保存' } : null)
}).catch(() => {
// Keep local editing responsive even if the backend save is temporarily unavailable.
if (revision === saveRevisionRef.current) setSaveFeedback({ drawingId, message: '部件编号配置保存失败,当前修改仅保留在本浏览器,请检查连接后重试。' })
})
}, 400)
@ -115,6 +122,10 @@ export function useInstallationNumberingCatalog(drawingId: string | null, canEdi
(nextParts: InstallationPartItem[]) => {
if (!canEdit) return
if (!drawingId) return
// Invalidate an older in-flight acknowledgement as soon as the user edits,
// not only after the next debounce timer starts.
++saveRevisionRef.current
setSaveFeedback({ drawingId, message: '部件编号配置待保存…' })
setPartsByDrawingId((current) => ({
...current,
[drawingId]: nextParts,
@ -151,7 +162,10 @@ export function useInstallationNumberingCatalog(drawingId: string | null, canEdi
)
const part = createPartItem({
code: '',
name: buildDefaultName(kind),
name: kind === 'hardware' && normalizedNodeNames.length === 1
? normalizedNodeNames[0]
: kind === 'hardware' && normalizedNodeNames.some((name) => /铰链|hinge/i.test(name))
? '铰链' : buildDefaultName(kind),
kind,
nodeNames: normalizedNodeNames,
quantity: resolvePartQuantityFromNodeNames(normalizedNodeNames),
@ -339,6 +353,7 @@ export function useInstallationNumberingCatalog(drawingId: string | null, canEdi
return {
parts,
saveMessage: saveFeedback?.drawingId === drawingId ? saveFeedback.message : null,
selectedPart,
selectedPartId,
setSelectedPartId,

View File

@ -21,6 +21,7 @@ import { collectStepRoleTokens } from '../lib/installationStepGroups'
import { buildInstallationTimeline } from '../lib/installationTimelineBuilder'
import { collectCompletedNodeNames } from '../lib/installationTransparencyState'
import { deriveInstallationFinishClosePlaybackStep } from '../lib/installationFinishCloseBinding'
import { getHardwareHingePartIssue } from '../lib/installationHardwareHingeBinding'
import { isInstallationStepLocalTransformWorkflow } from '../lib/installationStepActionWorkflow'
import {
HARDWARE_PHASE_END,
@ -374,21 +375,28 @@ export function useInstallationPreviewController({
)
const activeStep = steps.find((step) => step.id === activeStepId) ?? steps[0] ?? null
const nextStep = steps[activeStepIndex + 1] ?? null
const hardwareHingeMessage = useMemo(() => numberingParts.flatMap((part) => {
const issue = part.kind === 'hardware' && part.hardwareHinge
? getHardwareHingePartIssue(part, numberingParts, steps) : null
return issue ? [`${part.name || part.code}:${issue}`] : []
})[0] ?? null, [numberingParts, steps])
const playbackActiveStep = useMemo(
() =>
deriveInstallationFinishClosePlaybackStep({
step: activeStep,
numberingParts,
steps,
}),
[activeStep, numberingParts],
[activeStep, numberingParts, steps],
)
const playbackNextStep = useMemo(
() =>
deriveInstallationFinishClosePlaybackStep({
step: nextStep,
numberingParts,
steps,
}),
[nextStep, numberingParts],
[nextStep, numberingParts, steps],
)
const timeline = useMemo(() => buildInstallationTimeline(steps), [steps])
const selectedNodeNames = useMemo(
@ -719,6 +727,7 @@ export function useInstallationPreviewController({
return {
activeStep,
hardwareHingeMessage,
activeStepIndex,
animatedNodePositionOffsets:
stageView === 'structure'

View File

@ -153,6 +153,7 @@ export function resolveInstallationDrawingTechnicalLineAnnotation({
const step = deriveInstallationFinishClosePlaybackStep({
step: storedStep,
numberingParts,
steps,
})
const action = step?.actions?.find((item) => item.id === target.actionId) ?? null
if (!step || !action) return undefined
@ -189,6 +190,7 @@ export function resolveInstallationDrawingActionCaptureFrame({
const step = deriveInstallationFinishClosePlaybackStep({
step: storedStep,
numberingParts,
steps,
})
if (!step) return null

View File

@ -0,0 +1,26 @@
import { clearDeletedDrawingStorage, clearDrawingStorage } from './installationDrawingDeletionStorage'
jest.mock('./installationNumberingSnapshotStorage', () => ({ deleteStoredInstallationNumberingSnapshotBatch: jest.fn() }))
const prefix = 'installation-demo-workbench:'
beforeEach(() => { localStorage.clear(); sessionStorage.clear() })
it('removes only exact drawing keys and map entries', () => {
localStorage.setItem(`${prefix}local-config:v2:manual-one`, 'delete')
localStorage.setItem(`${prefix}local-config:v2:manual-one-other`, 'keep')
localStorage.setItem(`${prefix}uploaded-models:v1`, JSON.stringify({ 'manual-one': { modelUrl: 'shared-model' }, 'manual-two': { modelUrl: 'shared-model' } }))
localStorage.setItem('product-development:manual-one', 'keep')
localStorage.setItem(`${prefix}theme`, 'light')
clearDrawingStorage(localStorage, 'manual-one')
expect(localStorage.getItem(`${prefix}local-config:v2:manual-one`)).toBeNull()
expect(localStorage.getItem(`${prefix}local-config:v2:manual-one-other`)).toBe('keep')
expect(JSON.parse(localStorage.getItem(`${prefix}uploaded-models:v1`)!)).toEqual({ 'manual-two': { modelUrl: 'shared-model' } })
expect(localStorage.getItem('product-development:manual-one')).toBe('keep')
expect(localStorage.getItem(`${prefix}theme`)).toBe('light')
})
it('clears both browser stores without a global reset', async () => {
sessionStorage.setItem(`${prefix}numbering-snapshot-drafts:v2`, JSON.stringify({ 'manual-one': {}, 'manual-two': {} }))
localStorage.setItem(`${prefix}manual-catalog-reset:20260910`, 'done')
await clearDeletedDrawingStorage('manual-one')
expect(JSON.parse(sessionStorage.getItem(`${prefix}numbering-snapshot-drafts:v2`)!)).toEqual({ 'manual-two': {} })
expect(localStorage.getItem(`${prefix}manual-catalog-reset:20260910`)).toBe('done')
})

View File

@ -0,0 +1,27 @@
import { deleteStoredInstallationNumberingSnapshotBatch } from './installationNumberingSnapshotStorage'
const PREFIX = 'installation-demo-workbench:'
export function clearDrawingStorage(storage: Storage, drawingId: string) {
const keys = Array.from({ length: storage.length }, (_, index) => storage.key(index))
for (const key of keys) {
if (!key?.startsWith(PREFIX)) continue
if (key.endsWith(`:${drawingId}`)) {
storage.removeItem(key)
continue
}
try {
const value: unknown = JSON.parse(storage.getItem(key) ?? 'null')
if (value && typeof value === 'object' && !Array.isArray(value) && Object.hasOwn(value, drawingId)) {
delete (value as Record<string, unknown>)[drawingId]
storage.setItem(key, JSON.stringify(value))
}
} catch { /* A non-JSON preference does not belong to a drawing. */ }
}
}
export async function clearDeletedDrawingStorage(drawingId: string) {
clearDrawingStorage(window.localStorage, drawingId)
clearDrawingStorage(window.sessionStorage, drawingId)
if (window.indexedDB) await deleteStoredInstallationNumberingSnapshotBatch(drawingId)
}

View File

@ -1,6 +1,8 @@
import type { InstallationPartItem, InstallationStepDraft } from '../types'
import { getPartNodeNames } from './installationPartCatalogHelpers'
import { isInstallationToolInstanceNodeName } from './installationToolAttachmentIdentity'
import { deriveHardwareHingePlaybackStep } from './installationHardwareHingeBinding'
import { getHardwareHingeMovingNodes } from '@/lib/installation-demo-hardware-hinge'
function uniqueNodeNames(nodeNames: Array<string | null | undefined>) {
return Array.from(
@ -67,6 +69,19 @@ function collectFinishCloseActionGroupNodeNames(params: {
export function deriveInstallationFinishClosePlaybackStep(params: {
step: InstallationStepDraft | null
numberingParts?: InstallationPartItem[]
steps?: InstallationStepDraft[]
}) {
const boundNodes = new Set((params.numberingParts ?? []).flatMap((part) =>
part.kind === 'hardware' && part.hardwareHinge ? getHardwareHingeMovingNodes(part.hardwareHinge) : []))
const legacyParts = (params.numberingParts ?? []).map((part) =>
getPartNodeNames(part).some((name) => boundNodes.has(name)) ? { ...part, finishClose: false } : part)
const legacyStep = deriveLegacyFinishClosePlaybackStep({ ...params, numberingParts: legacyParts })
return legacyStep ? deriveHardwareHingePlaybackStep(legacyStep, params.numberingParts ?? [], params.steps ?? [legacyStep]) : null
}
function deriveLegacyFinishClosePlaybackStep(params: {
step: InstallationStepDraft | null
numberingParts?: InstallationPartItem[]
}) {
const { step, numberingParts = [] } = params
if (!step) return null

View File

@ -0,0 +1,152 @@
import * as THREE from 'three'
import type { NodeLocalTransformState, ThreeDEngine } from '@/lib/3d/engine'
import { createInstallationHardwareHinge, parseInstallationHardwareHinge, copyHardwareHingeMotion } from '@/lib/installation-demo-hardware-hinge'
import { createDefaultInstallationHingeMotionConfig } from '@/lib/installation-demo-tool-motion'
import type { InstallationPartItem, InstallationStepDraft, InstallationNodeTransformState } from '../types'
import { buildHardwareHingeTransforms, hingeTransformMatrix, applyHardwareHingePlayback } from './installationHardwareHingeRuntime'
import { captureHardwareHingeCalibration } from './installationHardwareHingeCalibration'
import { deriveInstallationFinishClosePlaybackStep } from './installationFinishCloseBinding'
import { getHardwareHingeStepIssue } from './installationHardwareHingeBinding'
import { buildInstallationStepActionPlaybackFrame } from './installationStepActionPlayback'
const names = ['固定座', '连杆', '杯座', '门', '侧板', '门螺丝', '底座螺丝']
const binding = {
...createInstallationHardwareHinge(names.slice(0, 3)),
doorNodeNames: ['门'], sideNodeName: '侧板', followerNodeNames: ['门螺丝'], closeStepId: 'install',
calibration: { min: [0, 0, 0] as [number, number, number], max: [1, 1, 1] as [number, number, number], nodeNames: names },
}
const transforms: Record<string, InstallationNodeTransformState> = Object.fromEntries(names.map((name, i) => [name, {
offset: [0, 0, 0], position: [0, i * 0.1, i * 0.2], rotation: [0, 0, 0, 1],
}]))
const open: NodeLocalTransformState[] = names.map((nodeName) => ({ nodeName, position: transforms[nodeName].position!, rotation: transforms[nodeName].rotation }))
const part: InstallationPartItem = {
id: 'hinge', nodeName: names[0], nodeNames: names.slice(0, 3), code: 'H', name: '铰链', quantity: 1,
kind: 'hardware', hardwareHinge: binding, offset: [0, 0, 0], rotation: [0, 0, 0, 1], nodeTransforms: {}, createdAt: '',
}
const nodes = ['门', '固定座', '连杆', '杯座', '门螺丝']
const step: InstallationStepDraft = {
id: 'install', title: '安装预装柜门', order: 2, direction: 'front', cameraPreset: 'wide', durationMs: 5000, note: '',
workflowVersion: 2, transformSpace: 'local', scopeNodeNames: names, fixedNodeNames: ['侧板'], installNodeNames: nodes,
hardwareNodeNames: ['底座螺丝'], assembledTransforms: transforms, expandedTransforms: transforms,
actions: [{ id: 'door', order: 1, title: '柜门组件', nodeNames: nodes, startTransforms: transforms }],
}
const pre: InstallationStepDraft = { ...step, id: 'pre', order: 1, fixedNodeNames: ['门'],
actions: [{ id: 'hinge', order: 1, title: '装铰链', nodeNames: ['固定座', '连杆', '杯座', '门螺丝'], startTransforms: transforms }] }
const next: InstallationStepDraft = { ...step, id: 'next', order: 3, fixedNodeNames: names, actions: [] }
const steps = [pre, step, next]
function matrixClose(actual: THREE.Matrix4, expected: THREE.Matrix4) {
actual.elements.forEach((n, i) => expect(n).toBeCloseTo(expected.elements[i], 6))
}
describe('imported hardware hinge', () => {
it('suggests explicit original names only; does not guess anonymous or ambiguous roles', () => {
expect(binding.motion.baseNodeName).toBe('固定座')
expect(createInstallationHardwareHinge(['asset_000', 'asset_001', 'asset_002']).motion.baseNodeName).toBeNull()
expect(createInstallationHardwareHinge(['左固定座', '右固定座']).motion.baseNodeName).toBeNull()
})
it('roundtrips complete and incomplete drafts, validates vectors, strips unrelated identity', () => {
expect(parseInstallationHardwareHinge(JSON.parse(JSON.stringify(binding)))).toEqual(binding)
expect(parseInstallationHardwareHinge(createInstallationHardwareHinge([]))).toBeTruthy()
expect(parseInstallationHardwareHinge({ ...binding, spuId: 'unrelated' })).not.toHaveProperty('spuId')
expect(() => parseInstallationHardwareHinge({ ...binding, motion: { ...binding.motion, primaryJoint: { ...binding.motion.primaryJoint, axis: [0, 0, 0] } } })).toThrow()
})
it('copies only verified motion data, never source model node IDs', () => {
const copy = copyHardwareHingeMotion(binding, createDefaultInstallationHingeMotionConfig(['a', 'b', 'c']))
expect(copy.motion.baseNodeName).toBe('固定座')
expect(copy.motion.secondaryJoint.closedAngleDegrees).toBe(-122)
expect(copy).not.toHaveProperty('toolId')
})
it('keeps the base fixed and drives the door and its screws from exactly the leaf transform', () => {
for (const progress of [0, 0.25, 0.5, 0.75, 1]) {
const result = buildHardwareHingeTransforms(binding, open, progress)
expect(result.map((n) => n.nodeName)).not.toContain('固定座')
expect(result.map((n) => n.nodeName)).not.toContain('底座螺丝')
const leaf = result.find((n) => n.nodeName === '杯座')!
const delta = hingeTransformMatrix(leaf).multiply(hingeTransformMatrix(open[2]).invert())
for (const name of ['门', '门螺丝']) {
const original = open.find((n) => n.nodeName === name)!
matrixClose(hingeTransformMatrix(result.find((n) => n.nodeName === name)!), delta.clone().multiply(hingeTransformMatrix(original)))
}
}
})
it('is equivariant under rotated and translated assembly placement, without accumulation', () => {
const placement = new THREE.Matrix4().makeRotationY(Math.PI / 2).setPosition(4, 5, 6)
const moved = open.map((state) => {
const matrix = placement.clone().multiply(hingeTransformMatrix(state))
const p = new THREE.Vector3(), q = new THREE.Quaternion()
matrix.decompose(p, q, new THREE.Vector3())
return { nodeName: state.nodeName, position: p.toArray(), rotation: q.toArray() }
})
const expected = buildHardwareHingeTransforms(binding, open, 0.5)
buildHardwareHingeTransforms(binding, moved, 1)
const actual = buildHardwareHingeTransforms(binding, moved, 0.5)
actual.forEach((state, i) => matrixClose(hingeTransformMatrix(state), placement.clone().multiply(hingeTransformMatrix(expected[i]))))
})
it('stays open during preassembly and closes only after mounting to the side panel', () => {
expect(getHardwareHingeStepIssue(binding, steps)).toBeNull()
const playback = steps.map((s) => deriveInstallationFinishClosePlaybackStep({ step: s, numberingParts: [part], steps })!)
expect(playback.map((s) => s.hardwareHinges![0].phase)).toEqual(['open', 'closing', 'closed'])
const before = buildInstallationStepActionPlaybackFrame(playback[0], 1)
matrixClose(hingeTransformMatrix(before.transforms.find((n) => n.nodeName === '门')!), hingeTransformMatrix(open[3]))
const during = buildInstallationStepActionPlaybackFrame(playback[1], 0.45)
matrixClose(hingeTransformMatrix(during.transforms.find((n) => n.nodeName === '门')!), hingeTransformMatrix(open[3]))
const finished = buildInstallationStepActionPlaybackFrame(playback[1], 1)
const repeated = buildInstallationStepActionPlaybackFrame(playback[1], 1)
expect(finished.transforms).toEqual(repeated.transforms)
const closedDoor = buildHardwareHingeTransforms(binding, open, 1).find((n) => n.nodeName === '门')!
matrixClose(hingeTransformMatrix(finished.transforms.find((n) => n.nodeName === '门')!), hingeTransformMatrix(closedDoor))
const later = buildInstallationStepActionPlaybackFrame(playback[2], 1)
matrixClose(hingeTransformMatrix(later.transforms.find((n) => n.nodeName === '门')!), hingeTransformMatrix(closedDoor))
})
it('does not also run a legacy finish-close arc and refuses incomplete mount groups', () => {
const markedDoor = { ...part, id: 'door', nodeName: '门', nodeNames: ['门'], kind: 'part' as const, hardwareHinge: null, finishClose: true }
const derived = deriveInstallationFinishClosePlaybackStep({ step, numberingParts: [part, markedDoor], steps })!
expect(derived.hingeCloseActions ?? []).toEqual([])
expect(derived.holdExpandedNodeNames ?? []).toEqual([])
expect(getHardwareHingeStepIssue(binding, [{ ...step, fixedNodeNames: ['门'] }])).toContain('侧板')
expect(getHardwareHingeStepIssue(binding, [{ ...step, actions: [] }])).toContain('动作组')
})
it('preserves the rigid hinge/door group during a rotated carry-in', () => {
const startTransforms = Object.fromEntries(names.map((name) => [name, { ...transforms[name], position: [2, 1, 3] as [number, number, number], rotation: new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), 1.1).toArray() }]))
const mounted = { ...step, actions: [{ ...step.actions![0], startTransforms }] }
const derived = deriveInstallationFinishClosePlaybackStep({ step: mounted, numberingParts: [part], steps: [pre, mounted] })!
const frame = buildInstallationStepActionPlaybackFrame(derived, 0.3)
const base = frame.transforms.find((n) => n.nodeName === '固定座')!
const door = frame.transforms.find((n) => n.nodeName === '门')!
matrixClose(hingeTransformMatrix(base).invert().multiply(hingeTransformMatrix(door)), hingeTransformMatrix(open[0]).invert().multiply(hingeTransformMatrix(open[3])))
})
it('reads bounds for only this hinge from originals, never the exploded furniture bounds', () => {
const root = new THREE.Group()
const originals = new Map<string, { position: [number, number, number]; rotation: [number, number, number, number] }>()
names.forEach((name, index) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1))
mesh.name = name; mesh.position.x = index; root.add(mesh)
originals.set(name, { position: mesh.position.toArray(), rotation: mesh.quaternion.toArray() })
mesh.position.x += 1000
})
const engine = { resolveObjectByNodeName: (n: string) => root.getObjectByName(n), getModelRoot: () => root,
getNodeOriginalLocalTransform: (n: string) => originals.get(n) ?? null } as unknown as ThreeDEngine
expect(captureHardwareHingeCalibration(engine, binding)).toMatchObject({ min: [-0.5, -0.5, -0.5], max: [2.5, 0.5, 0.5] })
})
it('closes two hinges in parallel without restoring or rotating the door twice', () => {
const second = { ...binding, drivesDoor: false, followerNodeNames: [], motion: { ...binding.motion,
baseNodeName: '固定座2', linkNodeName: '连杆2', leafNodeName: '杯座2' } }
const extra: NodeLocalTransformState[] = open.slice(0, 3).map((state) => ({ ...state, nodeName: `${state.nodeName}2`, position: [state.position[0] + 0.4, state.position[1], state.position[2]] }))
const all = [...open, ...extra]
const rigStep: InstallationStepDraft = { ...step,
actions: [{ ...step.actions![0], nodeNames: [...nodes, ...extra.map((n) => n.nodeName)] }],
hardwareHinges: [{ binding, phase: 'closing' }, { binding: second, phase: 'closing' }],
}
const result = applyHardwareHingePlayback({ step: rigStep, transforms: all, installationTargets: all, closingProgress: 0.5 })
const expectedDoor = buildHardwareHingeTransforms(binding, all, 0.5).find((n) => n.nodeName === '门')!
matrixClose(hingeTransformMatrix(result.find((n) => n.nodeName === '门')!), hingeTransformMatrix(expectedDoor))
const expectedSecond = buildHardwareHingeTransforms(second, all, 0.5).find((n) => n.nodeName === '杯座2')!
matrixClose(hingeTransformMatrix(result.find((n) => n.nodeName === '杯座2')!), hingeTransformMatrix(expectedSecond))
})
it('leaves ordinary finish-close bindings on the legacy path', () => {
const door = { ...part, id: 'door', kind: 'part' as const, nodeName: '门', nodeNames: ['门'], hardwareHinge: null, finishClose: true }
const derived = deriveInstallationFinishClosePlaybackStep({ step, numberingParts: [door], steps })!
expect(derived.hardwareHinges).toBeUndefined()
expect(derived.hingeCloseActions?.flatMap((action) => action.nodeNames)).toContain('门')
})
})

View File

@ -0,0 +1,79 @@
import {
getHardwareHingeBindingIssue, getHardwareHingeMovingNodes, getHardwareHingeNodes,
type InstallationHardwareHingeBinding,
} from '@/lib/installation-demo-hardware-hinge'
import type { InstallationPartItem, InstallationStepDraft } from '../types'
import { getPartNodeNames } from './installationPartCatalogHelpers'
export function getHardwareHingePartIssue(part: InstallationPartItem, parts: InstallationPartItem[], steps: InstallationStepDraft[]) {
const binding = part.hardwareHinge
if (!binding) return null
if (getHardwareHingeNodes(binding).some((name) => !getPartNodeNames(part).includes(name))) {
return '铰链节点已不属于当前五金编号,请重新指定。'
}
const moving = getHardwareHingeMovingNodes(binding)
const sameDoor = parts.filter((other) => other.id !== part.id && other.hardwareHinge &&
other.hardwareHinge.doorNodeNames.some((name) => binding.doorNodeNames.includes(name)))
if (!binding.drivesDoor && !sameDoor.some((other) => other.hardwareHinge?.drivesDoor)) {
return '请为这扇门指定一个主驱动铰链。'
}
if (sameDoor.some((other) => other.hardwareHinge?.closeStepId !== binding.closeStepId)) {
return '同一柜门的多个铰链必须在同一步末尾同步闭合。'
}
for (const other of parts) {
if (other.id === part.id || !other.hardwareHinge) continue
const otherBinding = other.hardwareHinge
if (getHardwareHingeNodes(otherBinding).some((name) => moving.includes(name))) return '不同铰链不能重复使用同一个内部节点。'
if (binding.drivesDoor && otherBinding.drivesDoor && otherBinding.doorNodeNames.some((name) => binding.doorNodeNames.includes(name))) {
return '同一柜门只能有一个主驱动铰链,其余请取消“驱动柜门”。'
}
}
return getHardwareHingeStepIssue(binding, steps)
}
export function getHardwareHingeStepIssue(binding: InstallationHardwareHingeBinding, steps: InstallationStepDraft[]) {
const issue = getHardwareHingeBindingIssue(binding)
if (issue) return issue
const step = steps.find((item) => item.id === binding.closeStepId)
if (!step) return '闭合步骤已不存在,请重新选择。'
if (step.workflowVersion !== 2 || step.transformSpace !== 'local') return '请先进入安装步骤编辑,保存组合姿态和动作组。'
if (!step.fixedNodeNames.includes(binding.sideNodeName!)) return '闭合步骤须以所选侧板为固定件。'
if (binding.doorNodeNames.some((name) => step.fixedNodeNames.includes(name))) return '闭合步骤不能把柜门设为固定件。'
const nodes = getHardwareHingeMovingNodes(binding)
if ([...nodes, binding.sideNodeName!].some((name) => !step.scopeNodeNames?.includes(name))) return '闭合步骤的范围缺少柜门、侧板或铰链节点。'
if (!step.actions?.some((action) => nodes.every((n) => action.nodeNames.includes(n)))) {
return '请在闭合步骤中,将预装好的柜门、铰链和门侧随动件导入为同一个安装动作组。'
}
if (nodes.some((name) => !step.assembledTransforms?.[name]?.position)) return '请保存闭合步骤的打开安装姿态。'
return null
}
export function deriveHardwareHingePlaybackStep(
step: InstallationStepDraft, parts: InstallationPartItem[], steps: InstallationStepDraft[],
): InstallationStepDraft {
const bindings = parts.flatMap((part) => part.kind === 'hardware' && part.hardwareHinge ? [part.hardwareHinge] : [])
const ordered = [...steps].sort((a, b) => a.order - b.order)
const currentIndex = ordered.findIndex((item) => item.id === step.id)
const scope = new Set(step.scopeNodeNames ?? [])
const hardwareHinges: NonNullable<InstallationStepDraft['hardwareHinges']> = []
for (const part of parts) {
const binding = part.kind === 'hardware' ? part.hardwareHinge : null
if (!binding || getHardwareHingePartIssue(part, parts, steps)) continue
if (!getHardwareHingeNodes(binding).every((name) => scope.has(name))) continue
const closeIndex = ordered.findIndex((item) => item.id === binding.closeStepId)
const phase = step.id === binding.closeStepId ? 'closing'
: currentIndex > closeIndex && closeIndex >= 0 ? 'closed' : 'open'
hardwareHinges.push({ binding, phase })
}
if (!bindings.length) return step
// A hardware hinge owns its door trajectory. The older inferred finish-close
// arc must not operate on any of these nodes, including incomplete bindings.
const bound = new Set(bindings.flatMap(getHardwareHingeMovingNodes))
return { ...step, hardwareHinges,
holdExpandedNodeNames: step.holdExpandedNodeNames?.filter((name) => !bound.has(name)),
hingeCloseActions: step.hingeCloseActions?.flatMap((action) => {
const nodeNames = action.nodeNames.filter((name) => !bound.has(name))
return nodeNames.length ? [{ ...action, nodeNames }] : []
}),
}
}

View File

@ -0,0 +1,64 @@
import * as THREE from 'three'
import type { ThreeDEngine } from '@/lib/3d/engine'
import {
getHardwareHingeNodes, getHardwareHingeMovingNodes,
type InstallationHardwareHingeBinding,
} from '@/lib/installation-demo-hardware-hinge'
/** Read original geometry, not whichever exploded/playback pose is on screen. */
export function captureHardwareHingeCalibration(
engine: ThreeDEngine, binding: InstallationHardwareHingeBinding,
): NonNullable<InstallationHardwareHingeBinding['calibration']> {
const hingeNodes = getHardwareHingeNodes(binding)
if (hingeNodes.length !== 3 || new Set(hingeNodes).size !== 3) throw new Error('请先指定三个不同的铰链节点。')
const names = [...new Set([...getHardwareHingeMovingNodes(binding), ...(binding.sideNodeName ? [binding.sideNodeName] : [])])]
const objects = names.map((name) => {
const object = engine.resolveObjectByNodeName(name)
if (!object) throw new Error(`模型中找不到节点:${name}`)
return object
})
const localMatrix = (object: THREE.Object3D) => {
const original = engine.getNodeOriginalLocalTransform(object.name)
return new THREE.Matrix4().compose(
original ? new THREE.Vector3(...original.position) : object.position,
original ? new THREE.Quaternion(...original.rotation) : object.quaternion,
object.scale,
)
}
if (new Set(objects.map((object) => object.uuid)).size !== objects.length) {
throw new Error('多个名称指向同一个模型对象,请重新指定独立节点。')
}
const root = engine.getModelRoot()
const matrixToRoot = (object: THREE.Object3D | null): THREE.Matrix4 =>
!object || object === root ? new THREE.Matrix4() : matrixToRoot(object.parent).multiply(localMatrix(object))
const parentMatrix = matrixToRoot(objects[0].parent)
for (const object of objects) {
if (objects.some((other) => other !== object && other.getObjectById(object.id))) {
throw new Error('铰链角色或柜门包含了彼此的父子节点,请选择互不包含的独立零件。')
}
if (matrixToRoot(object.parent).elements.some((n, i) => Math.abs(n - parentMatrix.elements[i]) > 1e-6)) {
throw new Error('这些零件的父坐标系不同,暂不能安全联动;请导出到同一装配坐标系后再配置。')
}
}
const base = engine.getNodeOriginalLocalTransform(binding.motion.baseNodeName!)
if (!base) throw new Error('无法读取固定座的原始位置。')
const inverseBase = new THREE.Matrix4().compose(
new THREE.Vector3(...base.position), new THREE.Quaternion(...base.rotation), new THREE.Vector3(1, 1, 1),
).invert()
const bounds = new THREE.Box3().makeEmpty()
for (const nodeName of hingeNodes) {
const object = engine.resolveObjectByNodeName(nodeName)!
const visit = (child: THREE.Object3D, parent: THREE.Matrix4) => {
const matrix = parent.clone().multiply(localMatrix(child))
const mesh = child as THREE.Mesh
if (mesh.isMesh && mesh.geometry && !child.name.startsWith('__')) {
mesh.geometry.computeBoundingBox()
if (mesh.geometry.boundingBox) bounds.union(mesh.geometry.boundingBox.clone().applyMatrix4(matrix))
}
child.children.forEach((next) => visit(next, matrix))
}
visit(object, inverseBase)
}
if (bounds.isEmpty()) throw new Error('所选铰链节点没有可读取的几何尺寸。')
return { min: bounds.min.toArray(), max: bounds.max.toArray(), nodeNames: names }
}

View File

@ -0,0 +1,94 @@
import * as THREE from 'three'
import type { NodeLocalTransformState } from '@/lib/3d/engine'
import {
getHardwareHingeNodes, type InstallationHardwareHingeBinding,
} from '@/lib/installation-demo-hardware-hinge'
import type { InstallationStepDraft } from '../types'
import { buildInstallationHingeNodeTransforms } from '../tools/lib/installationToolHingeRuntime'
export function hingeTransformMatrix(state: Pick<NodeLocalTransformState, 'position' | 'rotation'>) {
return new THREE.Matrix4().compose(new THREE.Vector3(...state.position),
new THREE.Quaternion(...state.rotation).normalize(), new THREE.Vector3(1, 1, 1))
}
export function hingeMatrixTransform(nodeName: string, matrix: THREE.Matrix4): NodeLocalTransformState {
const position = new THREE.Vector3(), rotation = new THREE.Quaternion()
matrix.decompose(position, rotation, new THREE.Vector3())
return { nodeName, position: position.toArray(), rotation: rotation.toArray() }
}
/** All inputs are the OPEN installed poses, in the same assembly parent space. */
export function buildHardwareHingeTransforms(
binding: InstallationHardwareHingeBinding, open: NodeLocalTransformState[], progress: number,
): NodeLocalTransformState[] {
if (!binding.calibration) return []
const map = new Map(open.map((state) => [state.nodeName, state]))
const { baseNodeName, linkNodeName, leafNodeName } = binding.motion
if (!baseNodeName || !linkNodeName || !leafNodeName) return []
const base = map.get(baseNodeName), link = map.get(linkNodeName), leaf = map.get(leafNodeName)
if (!base || !link || !leaf) return []
const baseMatrix = hingeTransformMatrix(base)
const inverseBase = baseMatrix.clone().invert()
const originals = Object.fromEntries([link, leaf].map((state) => [state.nodeName,
hingeMatrixTransform(state.nodeName, inverseBase.clone().multiply(hingeTransformMatrix(state)))]))
const local = buildInstallationHingeNodeTransforms({
config: binding.motion, progress,
bounds: new THREE.Box3(new THREE.Vector3(...binding.calibration.min), new THREE.Vector3(...binding.calibration.max)),
originals,
})
const result = local.map((state) => hingeMatrixTransform(state.nodeName,
baseMatrix.clone().multiply(hingeTransformMatrix(state))))
const movedLeaf = result.find((state) => state.nodeName === leafNodeName)
if (!movedLeaf) return []
const leafDelta = hingeTransformMatrix(movedLeaf).multiply(hingeTransformMatrix(leaf).invert())
const followers = [...(binding.drivesDoor ? binding.doorNodeNames : []), ...binding.followerNodeNames]
for (const name of followers) {
const original = map.get(name)
if (original) result.push(hingeMatrixTransform(name, leafDelta.clone().multiply(hingeTransformMatrix(original))))
}
// Fixed base intentionally has no motion. No accumulation from last frame.
return result
}
export function applyHardwareHingePlayback(params: {
step: InstallationStepDraft
transforms: NodeLocalTransformState[]
installationTargets: NodeLocalTransformState[]
closingProgress: number
rigidCarry?: boolean
}) {
const { step, installationTargets, closingProgress } = params
if (!step.hardwareHinges?.length) return params.transforms
const result = new Map(params.transforms.map((state) => [state.nodeName, state]))
const targets = new Map(installationTargets.map((state) => [state.nodeName, state]))
for (const { binding } of step.hardwareHinges ?? []) {
const hingeNodes = getHardwareHingeNodes(binding)
// A hinge (and, when imported as one group, its door) moves as one rigid
// component. Interpolating each node's position independently breaks rotation.
const group = step.actions?.find((action) => hingeNodes.every((n) => action.nodeNames.includes(n)))
if (group && params.rigidCarry !== false) {
const anchorName = binding.motion.baseNodeName!
const anchor = result.get(anchorName), target = targets.get(anchorName)
if (anchor && target) {
const delta = hingeTransformMatrix(anchor).multiply(hingeTransformMatrix(target).invert())
const attached = binding.doorNodeNames.every((n) => group.nodeNames.includes(n))
? group.nodeNames
: [...hingeNodes, ...binding.followerNodeNames]
for (const name of attached.filter((n) => group.nodeNames.includes(n))) {
const original = targets.get(name)
if (original) result.set(name, hingeMatrixTransform(name, delta.clone().multiply(hingeTransformMatrix(original))))
}
}
}
}
// All hinges read the same open frame; a secondary hinge must not restore
// the already-closed door or accumulate another hinge's rotation.
const openFrame = [...result.values()]
for (const { binding, phase } of step.hardwareHinges ?? []) {
const progress = phase === 'closed' ? 1 : phase === 'closing' ? closingProgress : 0
if (progress > 0) {
for (const state of buildHardwareHingeTransforms(binding, openFrame, progress)) result.set(state.nodeName, state)
}
}
return [...result.values()]
}

View File

@ -0,0 +1,40 @@
import { clearLegacyInstallationStorage, resetLegacyInstallationData } from './installationLegacyDataReset'
const prefix = 'installation-demo-workbench:'
beforeEach(() => { window.localStorage.clear(); window.sessionStorage.clear() })
it('clears only installation caches, leaving other modules alone', () => {
localStorage.setItem(`${prefix}local-config:v2:old-drawing`, '{"steps":[]}')
localStorage.setItem('product-development:cache', 'keep')
clearLegacyInstallationStorage(localStorage)
expect(localStorage.getItem(`${prefix}local-config:v2:old-drawing`)).toBeNull()
expect(localStorage.getItem('product-development:cache')).toBe('keep')
})
it('does not erase newly created configurations on later loads', async () => {
await resetLegacyInstallationData()
localStorage.setItem(`${prefix}local-config:v2:manual-123`, 'keep')
sessionStorage.setItem(`${prefix}numbering-snapshot-drafts:v2`, 'keep')
await resetLegacyInstallationData()
expect(localStorage.getItem(`${prefix}local-config:v2:manual-123`)).toBe('keep')
expect(sessionStorage.getItem(`${prefix}numbering-snapshot-drafts:v2`)).toBe('keep')
})
it('shares one reset operation across simultaneous mounts', async () => {
const first = resetLegacyInstallationData()
const second = resetLegacyInstallationData()
expect(second).toBe(first)
await first
})
it('keeps the old data when its backup fails', async () => {
const original = Object.getOwnPropertyDescriptor(window, 'indexedDB')
Object.defineProperty(window, 'indexedDB', { configurable: true, value: {
open: () => { throw new Error('Backup unavailable') },
} })
localStorage.setItem(`${prefix}local-config:v2:old-drawing`, 'keep')
try {
await expect(resetLegacyInstallationData()).rejects.toThrow('Backup unavailable')
expect(localStorage.getItem(`${prefix}local-config:v2:old-drawing`)).toBe('keep')
expect(localStorage.getItem(`${prefix}manual-catalog-reset:20260910`)).toBeNull()
} finally {
if (original) Object.defineProperty(window, 'indexedDB', original)
else Reflect.deleteProperty(window, 'indexedDB')
}
})

View File

@ -0,0 +1,87 @@
const PREFIX = 'installation-demo-workbench:'
const RESET_KEY = `${PREFIX}manual-catalog-reset:20260910`
let resetInFlight: Promise<void> | null = null
async function backupLegacyData() {
if (!window.indexedDB) return
const legacy = await new Promise<IDBDatabase>((resolve, reject) => {
const request = window.indexedDB.open('installation-demo-workbench-local')
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
})
const snapshots: Record<string, unknown> = {}
try {
for (const name of Array.from(legacy.objectStoreNames)) {
snapshots[name] = await new Promise((resolve, reject) => {
const transaction = legacy.transaction(name, 'readonly')
const store = transaction.objectStore(name)
const keys = store.getAllKeys()
const values = store.getAll()
transaction.oncomplete = () => resolve({ keys: keys.result, values: values.result })
transaction.onerror = () => reject(transaction.error)
transaction.onabort = () => reject(transaction.error)
})
}
} finally { legacy.close() }
const pick = (storage: Storage) => Object.fromEntries(
Array.from({ length: storage.length }, (_, i) => storage.key(i))
.filter((key): key is string => Boolean(key?.startsWith(PREFIX)))
.map((key) => [key, storage.getItem(key)]),
)
const backup = await new Promise<IDBDatabase>((resolve, reject) => {
const request = window.indexedDB.open('installation-demo-reset-backups', 1)
request.onupgradeneeded = () => request.result.createObjectStore('backups')
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
})
try {
await new Promise<void>((resolve, reject) => {
const transaction = backup.transaction('backups', 'readwrite')
transaction.objectStore('backups').put({
createdAt: new Date().toISOString(), local: pick(window.localStorage),
session: pick(window.sessionStorage), snapshots,
}, `${Date.now()}-${Math.random().toString(36).slice(2)}`)
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error)
transaction.onabort = () => reject(transaction.error)
})
} finally { backup.close() }
}
export function clearLegacyInstallationStorage(storage: Storage) {
if (storage.getItem(RESET_KEY) === 'done') return
const keys = Array.from({ length: storage.length }, (_, i) => storage.key(i))
for (const key of keys) {
if (key?.startsWith(PREFIX) && key !== RESET_KEY) storage.removeItem(key)
}
}
async function performLegacyReset() {
if (window.localStorage.getItem(RESET_KEY) !== 'done') await backupLegacyData()
clearLegacyInstallationStorage(window.sessionStorage)
window.sessionStorage.setItem(RESET_KEY, 'done')
if (window.localStorage.getItem(RESET_KEY) === 'done') return
clearLegacyInstallationStorage(window.localStorage)
if (window.indexedDB) {
await new Promise<void>((resolve, reject) => {
const request = window.indexedDB.deleteDatabase('installation-demo-workbench-local')
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error ?? new Error('旧安装演示快照清理失败'))
request.onblocked = () => reject(new Error('请关闭其他安装演示页面后重试'))
})
}
window.localStorage.setItem(RESET_KEY, 'done')
}
export function resetLegacyInstallationData(): Promise<void> {
if (!resetInFlight) {
// Serialize across tabs and React remounts before any editor can write new data.
const operation = (async () => {
if (navigator.locks) await navigator.locks.request(RESET_KEY, performLegacyReset)
else await performLegacyReset()
})()
resetInFlight = operation.finally(() => { resetInFlight = null })
}
return resetInFlight
}

View File

@ -20,6 +20,7 @@ import {
} from './installationPreviewTiming'
import { resolveInstallationHingeCloseTransform } from './installationHingeCloseMotion'
import { resolveInstallationStepActionCarryInSummary } from './installationStepActionHistory'
import { applyHardwareHingePlayback } from './installationHardwareHingeRuntime'
const ACTION_APPEAR_END = 0.22
const ACTION_MOTION_START = 0.38
@ -307,6 +308,8 @@ export function buildInstallationStepEditFrame(
)
const activeAction = (step.actions ?? []).find((action) => action.id === activeActionId) ?? null
return {
// Hinge authoring always stores the open pose. Never bake a runtime closed
// frame into a later step and then apply the articulation a second time.
transforms,
hiddenNodeNames: collectInstallationStepActionEditorHiddenNodeNames(
step,
@ -452,7 +455,8 @@ export function buildInstallationStepActionPlaybackFrame(
const endMap = toMap(buildInstallationStepActionPreviewEndLocalTransforms(step))
if (actions.length === 0) {
return {
transforms: Array.from(endMap.values()),
transforms: applyHardwareHingePlayback({ step, transforms: [...endMap.values()],
installationTargets: [...endMap.values()], closingProgress: 0 }),
hiddenNodeNames: [],
activeActionIndex: null,
activeActionId: null,
@ -578,7 +582,8 @@ export function buildInstallationStepActionPlaybackFrame(
actionProgress < ACTION_MOTION_START
return {
transforms,
transforms: applyHardwareHingePlayback({ step, transforms,
installationTargets: [...inProgressMap.values()], closingProgress: complete ? 1 : finalAssemblyProgress }),
hiddenNodeNames,
activeActionIndex,
activeActionId: activeAction?.id ?? null,

View File

@ -1,7 +1,7 @@
import { redirect } from 'next/navigation'
import { requireSession } from '@/lib/server-session'
import { installationDemoAccessService } from '@/server/services/installation-demo/installation-demo-access.service'
import InstallationDemoWorkbenchClient from './InstallationDemoWorkbenchClient'
import { InstallationCatalogBootstrap } from './components/InstallationCatalogBootstrap'
export const metadata = {
title: '安装演示工作台',
@ -14,5 +14,5 @@ export default async function InstallationDemoWorkbenchPage() {
redirect('/intake')
}
return <InstallationDemoWorkbenchClient initialAccess={access} />
return <InstallationCatalogBootstrap initialAccess={access} />
}

View File

@ -8,6 +8,7 @@ import type {
InstallationToolMotionVector3,
InstallationHingeMotionConfig,
} from '@/lib/installation-demo-tool-motion'
import type { InstallationHardwareHingeBinding } from '@/lib/installation-demo-hardware-hinge'
export type InstallationSpuNode = InstallationDemoSpuDTO
export type InstallationModelNode = InstallationDemoModelDTO
@ -132,6 +133,7 @@ export interface InstallationPartItem {
// A door stays in its saved expanded pose until the final step that uses it,
// then follows the inferred hinge arc back to its saved assembled pose.
finishClose?: boolean
hardwareHinge?: InstallationHardwareHingeBinding | null
toolBindings?: InstallationHardwareToolBinding[]
compositeGroups?: string[][]
compositeTemplate?: InstallationHardwareCompositeTemplate
@ -187,6 +189,8 @@ export interface InstallationStepDraft {
// Runtime-only playback state derived from numbering bindings. It is not
// authored as an installation-step setting.
holdExpandedNodeNames?: string[]
// Derived only for preview; never persisted into step configuration.
hardwareHinges?: Array<{ binding: InstallationHardwareHingeBinding; phase: 'open' | 'closing' | 'closed' }>
}
export interface InstallationSelectionState {

View File

@ -0,0 +1,105 @@
import { z } from 'zod'
import {
createDefaultInstallationHingeMotionConfig,
type InstallationHingeMotionConfig,
} from './installation-demo-tool-motion'
const vector = z.tuple([z.number().finite(), z.number().finite(), z.number().finite()])
const name = z.string().trim().min(1).max(240)
const names = z.array(name).max(200).transform((items) => [...new Set(items)])
const joint = z.object({
pivot: vector.refine((v) => v.every((n) => n >= 0 && n <= 1)),
axis: vector.refine((v) => v.some((n) => Math.abs(n) > 1e-8)),
middleAngleDegrees: z.number().finite().min(-180).max(180),
closedAngleDegrees: z.number().finite().min(-180).max(180),
})
export const installationHardwareHingeSchema = z.object({
version: z.literal(1),
doorNodeNames: names,
sideNodeName: name.nullable(),
// Door-side screws/handles follow the leaf; base screws must not be listed here.
followerNodeNames: names,
closeStepId: name.nullable(),
drivesDoor: z.boolean(),
motion: z.object({
version: z.literal(1),
baseNodeName: name.nullable(),
linkNodeName: name.nullable(),
leafNodeName: name.nullable(),
primaryJoint: joint,
secondaryJoint: joint,
}),
calibration: z.object({
// Bounds of this hinge only, expressed in the base's unscaled local frame.
min: vector,
max: vector,
nodeNames: names,
}).refine((v) => v.max.every((n, i) => n >= v.min[i]) &&
v.max.some((n, i) => n > v.min[i] + 1e-8)).nullable(),
})
export type InstallationHardwareHingeBinding = z.infer<typeof installationHardwareHingeSchema>
export function parseInstallationHardwareHinge(value: unknown) {
if (value == null) return null
return installationHardwareHingeSchema.parse(value)
}
export function getHardwareHingeNodes(binding: InstallationHardwareHingeBinding) {
const { baseNodeName, linkNodeName, leafNodeName } = binding.motion
return [baseNodeName, linkNodeName, leafNodeName].filter((n): n is string => Boolean(n))
}
export function getHardwareHingeMovingNodes(binding: InstallationHardwareHingeBinding) {
return [...new Set([
...getHardwareHingeNodes(binding), ...binding.doorNodeNames, ...binding.followerNodeNames,
])]
}
/** Name matching is only a suggestion. Ambiguous and anonymous nodes stay unassigned. */
export function createInstallationHardwareHinge(nodeNames: string[]): InstallationHardwareHingeBinding {
const suggest = (pattern: RegExp) => {
const matches = nodeNames.filter((nodeName) => pattern.test(nodeName))
return matches.length === 1 ? matches[0] : null
}
const motion = createDefaultInstallationHingeMotionConfig([])
return {
version: 1, doorNodeNames: [], sideNodeName: null, followerNodeNames: [],
closeStepId: null, drivesDoor: true, calibration: null,
motion: {
...motion,
primaryJoint: joint.parse(motion.primaryJoint),
secondaryJoint: joint.parse(motion.secondaryJoint),
baseNodeName: suggest(/固定座|底座|hinge[_\s-]*base/i),
linkNodeName: suggest(/连杆|hinge[_\s-]*link/i),
leafNodeName: suggest(/杯座|门侧件|hinge[_\s-]*(leaf|cup)/i),
},
}
}
export function copyHardwareHingeMotion(
binding: InstallationHardwareHingeBinding, template: InstallationHingeMotionConfig,
): InstallationHardwareHingeBinding {
// Copy the verified curve, never the template asset's node identities or product identity.
const copyJoint = (value: InstallationHingeMotionConfig['primaryJoint'], fallback: typeof binding.motion.primaryJoint) => joint.parse({
...value, pivot: value.pivot ?? fallback.pivot, axis: value.axis ?? fallback.axis,
})
return { ...binding, motion: { ...binding.motion,
primaryJoint: copyJoint(template.primaryJoint, binding.motion.primaryJoint),
secondaryJoint: copyJoint(template.secondaryJoint, binding.motion.secondaryJoint),
} }
}
export function getHardwareHingeBindingIssue(binding: InstallationHardwareHingeBinding): string | null {
const nodes = getHardwareHingeNodes(binding)
if (nodes.length !== 3 || new Set(nodes).size !== 3) return '请分别指定三个不同的铰链节点。'
if (!binding.doorNodeNames.length || !binding.sideNodeName) return '请选择柜门和柜体侧板。'
const roles = [...nodes, ...binding.doorNodeNames, ...binding.followerNodeNames, binding.sideNodeName]
if (new Set(roles).size !== roles.length) return '固定座、连杆、杯座、柜门、侧板和随动件不能重复绑定。'
if (!binding.closeStepId) return '请选择安装到侧板后执行闭合的步骤。'
if (!binding.calibration || roles.some((n) => !binding.calibration!.nodeNames.includes(n))) {
return '请读取本模型铰链的尺寸,完成轴心校准。'
}
return null
}

View File

@ -0,0 +1,19 @@
export const INSTALLATION_MANUAL_CATALOG_PREFIX = 'installation-demo-workbench:manual-drawing:v1:'
export const INSTALLATION_CATALOG_CREATED_EVENT = 'installation-demo-catalog-created'
export const INSTALLATION_CATALOG_DELETE_REQUEST_EVENT = 'installation-demo-catalog-delete-request'
export interface DeleteInstallationManualDrawingInput { drawingId: string }
export interface DeleteInstallationManualDrawingResult { drawingId: string; deleted: true }
export interface InstallationManualDrawing {
id: string
spuCode: string
drawingCode: string
createdAt: string
createdBy: string
}
export interface CreateInstallationManualDrawingInput {
spuCode: string
drawingCode: string
}

View File

@ -0,0 +1,43 @@
import 'server-only'
import { createHash, randomUUID } from 'node:crypto'
import { prisma } from '@/lib/prisma'
import { INSTALLATION_MANUAL_CATALOG_PREFIX, type InstallationManualDrawing } from '@/lib/installation-demo-manual-catalog'
export class InstallationDemoCatalogRepository {
async exists(drawingId: string): Promise<boolean> {
return Boolean(await prisma.systemConfig.findFirst({
where: { key: { startsWith: INSTALLATION_MANUAL_CATALOG_PREFIX }, value: { path: ['id'], equals: drawingId } },
select: { key: true },
}))
}
async list(): Promise<InstallationManualDrawing[]> {
const rows = await prisma.systemConfig.findMany({
where: { key: { startsWith: INSTALLATION_MANUAL_CATALOG_PREFIX } },
orderBy: { key: 'asc' },
select: { value: true },
})
return rows.map(({ value }) => value as unknown as InstallationManualDrawing)
}
async create(spuCode: string, drawingCode: string, createdBy: string) {
const identity = createHash('sha256').update(JSON.stringify([spuCode, drawingCode])).digest('hex')
const drawing: InstallationManualDrawing = {
id: `manual-${randomUUID()}`,
spuCode,
drawingCode,
createdAt: new Date().toISOString(),
createdBy,
}
// One unique database key per pair also prevents concurrent duplicate creation.
await prisma.systemConfig.create({ data: {
key: `${INSTALLATION_MANUAL_CATALOG_PREFIX}${identity}`,
value: { ...drawing },
description: `Installation drawing ${spuCode}/${drawingCode}`,
} })
return drawing
}
}
export const installationDemoCatalogRepository = new InstallationDemoCatalogRepository()

View File

@ -0,0 +1,34 @@
jest.mock('server-only', () => ({}))
jest.mock('@/lib/prisma', () => ({ prisma: { $transaction: jest.fn() } }))
import { prisma } from '@/lib/prisma'
import { InstallationDemoDrawingDeletionRepository } from './installation-demo-drawing-deletion.repository'
const tx = {
systemConfig: { findFirst: jest.fn(), findMany: jest.fn(), deleteMany: jest.fn() },
installationDemoConfig: { deleteMany: jest.fn() },
}
beforeEach(() => {
jest.clearAllMocks()
jest.mocked(prisma.$transaction).mockImplementation((async (run: (client: typeof tx) => Promise<unknown>) => run(tx)) as never)
})
it('deletes drawing-owned records in one serializable transaction using exact IDs', async () => {
tx.systemConfig.findFirst.mockResolvedValue({ key: 'catalog-key' })
tx.systemConfig.findMany.mockResolvedValue([{ key: 'catalog-key' }, { key: 'share-key' }])
await expect(new InstallationDemoDrawingDeletionRepository().deleteDrawing('manual-a')).resolves.toEqual(['catalog-key', 'share-key'])
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: 'Serializable' })
expect(tx.installationDemoConfig.deleteMany).toHaveBeenCalledWith({ where: { drawingId: 'manual-a' } })
const where = tx.systemConfig.deleteMany.mock.calls[0][0].where
expect(where.OR[0].key.in).toEqual(['catalog-key',
'installation-demo-workbench:numbering-config:manual-a', 'installation-demo-workbench:numbering-snapshots:manual-a',
'installation-demo-workbench:node-material-config:manual-a', 'installation-demo-workbench:drawing-captures:manual-a',
'installation-demo-workbench:drawing-text-annotations:manual-a'])
expect(where.OR[1].AND[1]).toEqual({ value: { path: ['drawing', 'id'], equals: 'manual-a' } })
})
it('does not delete anything when the manual catalog record is missing', async () => {
tx.systemConfig.findFirst.mockResolvedValue(null)
expect(await new InstallationDemoDrawingDeletionRepository().deleteDrawing('manual-missing')).toBeNull()
expect(tx.systemConfig.deleteMany).not.toHaveBeenCalled()
expect(tx.installationDemoConfig.deleteMany).not.toHaveBeenCalled()
})

View File

@ -0,0 +1,35 @@
import 'server-only'
import type { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { INSTALLATION_MANUAL_CATALOG_PREFIX } from '@/lib/installation-demo-manual-catalog'
const PREFIX = 'installation-demo-workbench:'
const DRAWING_CONFIGS = ['numbering-config:', 'numbering-snapshots:', 'node-material-config:',
'drawing-captures:', 'drawing-text-annotations:']
export class InstallationDemoDrawingDeletionRepository {
async deleteDrawing(drawingId: string): Promise<string[] | null> {
return prisma.$transaction(async (tx) => {
const catalog = await tx.systemConfig.findFirst({
where: { key: { startsWith: INSTALLATION_MANUAL_CATALOG_PREFIX }, value: { path: ['id'], equals: drawingId } },
select: { key: true },
})
if (!catalog) return null
const ownedSettings: Prisma.SystemConfigWhereInput = { OR: [
{ key: { in: [catalog.key, ...DRAWING_CONFIGS.map(prefix => `${PREFIX}${prefix}${drawingId}`)] } },
{ AND: [
{ OR: [{ key: { startsWith: `${PREFIX}share:` } }, { key: { startsWith: `${PREFIX}qr-share:` } }] },
{ value: { path: ['drawing', 'id'], equals: drawingId } },
] },
] }
const keys = await tx.systemConfig.findMany({ where: ownedSettings, select: { key: true } })
await tx.installationDemoConfig.deleteMany({ where: { drawingId } })
await tx.systemConfig.deleteMany({ where: ownedSettings })
return keys.map(row => row.key)
}, { isolationLevel: 'Serializable' })
}
}
export const installationDemoDrawingDeletionRepository = new InstallationDemoDrawingDeletionRepository()

View File

@ -1,113 +1,82 @@
jest.mock('server-only', () => ({}))
jest.mock('../intake-non-integrated-product.service', () => ({
intakeNonIntegratedProductService: {
listPage: jest.fn(),
},
jest.mock('@/server/repositories/installation-demo-catalog.repository', () => ({
installationDemoCatalogRepository: { list: jest.fn(), create: jest.fn() },
}))
jest.mock('@/server/repositories/installation-demo-drawing-deletion.repository', () => ({
installationDemoDrawingDeletionRepository: { deleteDrawing: jest.fn() },
}))
jest.mock('../system-config.service', () => ({ systemConfigService: { invalidate: jest.fn() } }))
jest.mock('./installation-demo-access.service', () => ({
installationDemoAccessService: {
getAccess: jest.fn(),
},
installationDemoAccessService: { getAccess: jest.fn() },
}))
import { intakeNonIntegratedProductService } from '../intake-non-integrated-product.service'
import { installationDemoCatalogRepository } from '@/server/repositories/installation-demo-catalog.repository'
import { installationDemoDrawingDeletionRepository } from '@/server/repositories/installation-demo-drawing-deletion.repository'
import { systemConfigService } from '../system-config.service'
import { installationDemoAccessService } from './installation-demo-access.service'
import { InstallationDemoCatalogService } from './installation-demo-catalog.service'
const user = {
id: 'user-1',
email: 'user@example.com',
name: 'Test user',
role: 'USER' as const,
businessRoles: [],
status: 'ACTIVE' as const,
}
function createProduct(id: string, spu: string, drawingId: string) {
return {
id,
spu,
productCode: spu,
productCategory: '柜类',
brand: '测试品牌',
previewImage: null,
openingReference: { productName: spu },
skuItems: [
{
skuCode: `${spu}-SKU`,
barcode: '',
model: `${spu}K01`,
drawingId,
model3dDataUrl: `/models/${spu}.glb`,
model3dFileName: `${spu}.glb`,
image: null,
},
],
}
}
function page(products: ReturnType<typeof createProduct>[], current: number, totalPages: number) {
return {
products,
pageInfo: {
page: current,
pageSize: 100,
total: products.length,
totalPages,
},
}
}
describe('InstallationDemoCatalogService', () => {
const mockedAccessService = jest.mocked(installationDemoAccessService)
const mockedProductService = jest.mocked(intakeNonIntegratedProductService)
beforeEach(() => {
jest.clearAllMocks()
mockedAccessService.getAccess.mockResolvedValue({ canView: true, canEdit: true, canShare: true })
})
it('loads every regular product page and supplements only the exact CJ0267 SPU', async () => {
mockedProductService.listPage
.mockResolvedValueOnce(page([createProduct('p-1', 'BJ0218', 'BJ0218K01AV01')], 1, 2) as never)
.mockResolvedValueOnce(page([createProduct('p-2', 'CY0123', 'CY0123K01AV01')], 2, 2) as never)
.mockResolvedValueOnce(
page(
[
createProduct('p-3', 'CJ0267', 'CJ0267K01AV01'),
createProduct('p-4', 'CJ02670', 'CJ02670K01AV01'),
],
1,
1,
) as never,
)
const result = await new InstallationDemoCatalogService().list(user)
expect(result.spus.map((spu) => spu.code)).toEqual(['BJ0218', 'CJ0267', 'CY0123'])
expect(mockedProductService.listPage).toHaveBeenNthCalledWith(
2,
user,
expect.objectContaining({ page: 2, pageSize: 100, productDevelopmentKind: 'product' }),
)
expect(mockedProductService.listPage).toHaveBeenNthCalledWith(
3,
user,
expect.objectContaining({ page: 1, pageSize: 100, productKind: 'all', spu: 'CJ0267' }),
)
})
it('deduplicates CJ0267 when it is already present in the regular product result', async () => {
const cj0267 = createProduct('p-cj0267', 'CJ0267', 'CJ0267K01AV01')
mockedProductService.listPage
.mockResolvedValueOnce(page([cj0267], 1, 1) as never)
.mockResolvedValueOnce(page([cj0267], 1, 1) as never)
const result = await new InstallationDemoCatalogService().list(user)
expect(result.spus).toHaveLength(1)
expect(result.spus[0]?.skuCount).toBe(1)
})
const user = { id: 'user-1', email: 'user@example.com', name: 'Test', role: 'USER' as const, businessRoles: [], status: 'ACTIVE' as const }
const repo = jest.mocked(installationDemoCatalogRepository)
const access = jest.mocked(installationDemoAccessService)
const service = new InstallationDemoCatalogService()
beforeEach(() => {
jest.clearAllMocks()
access.getAccess.mockResolvedValue({ canView: true, canEdit: true, canShare: true })
repo.list.mockResolvedValue([])
})
it('starts empty without reading any product development data', async () => {
expect((await service.list(user)).spus).toEqual([])
expect(repo.list).toHaveBeenCalledTimes(1)
})
it('groups only manually created drawings under SPU without model or SKU data', async () => {
repo.list.mockResolvedValue([
{ id: 'manual-b', spuCode: 'TEST', drawingCode: 'DRAW-B', createdAt: '', createdBy: user.id },
{ id: 'manual-a', spuCode: 'TEST', drawingCode: 'DRAW-A', createdAt: '', createdBy: user.id },
])
const spu = (await service.list(user)).spus[0]
expect(spu).toMatchObject({ code: 'TEST', drawingCount: 2, modelCount: 0, skuCount: 0 })
expect(spu.models[0].drawings.map(x => x.code)).toEqual(['DRAW-A', 'DRAW-B'])
expect(spu.models[0].drawings[0]).toMatchObject({ modelCode: '', modelUrl: null, productIds: [] })
})
it('normalizes the two input codes and records the actor', async () => {
await service.create(user, { spuCode: ' test ', drawingCode: ' drawing-a ' })
expect(repo.create).toHaveBeenCalledWith('TEST', 'DRAWING-A', user.id)
})
it.each([{ spuCode: '', drawingCode: 'A' }, { spuCode: 'A', drawingCode: '' }, { spuCode: 'A', drawingCode: 'a'.repeat(121) }, { spuCode: 'A', drawingCode: 'a\u0001b' }])('rejects invalid input %j', async input => {
await expect(service.create(user, input)).rejects.toThrow()
expect(repo.create).not.toHaveBeenCalled()
})
it('reports duplicate pairs without replacing existing data', async () => {
repo.create.mockRejectedValueOnce({ code: 'P2002' })
await expect(service.create(user, { spuCode: 'A', drawingCode: 'B' })).rejects.toThrow('已存在相同图纸')
})
it('rejects read and write without the corresponding permission', async () => {
access.getAccess.mockResolvedValue({ canView: false, canEdit: false, canShare: false })
await expect(service.list(user)).rejects.toThrow('没有权限')
await expect(service.create(user, { spuCode: 'A', drawingCode: 'B' })).rejects.toThrow('没有权限')
expect(repo.list).not.toHaveBeenCalled()
expect(repo.create).not.toHaveBeenCalled()
})
it('deletes one manual drawing and invalidates only committed keys', async () => {
jest.mocked(installationDemoDrawingDeletionRepository.deleteDrawing).mockResolvedValueOnce(['owned-config', 'owned-share'])
await expect(service.delete(user, { drawingId: 'manual-test' })).resolves.toEqual({ drawingId: 'manual-test', deleted: true })
expect(installationDemoDrawingDeletionRepository.deleteDrawing).toHaveBeenCalledWith('manual-test')
expect(systemConfigService.invalidate).toHaveBeenCalledTimes(2)
expect(systemConfigService.invalidate).toHaveBeenCalledWith('owned-share')
})
it('refuses delete without edit access and refuses non-manual identifiers', async () => {
await expect(service.delete(user, { drawingId: 'product-development-drawing' })).rejects.toThrow('手动创建')
access.getAccess.mockResolvedValue({ canView: true, canEdit: false, canShare: true })
await expect(service.delete(user, { drawingId: 'manual-test' })).rejects.toThrow('没有权限')
expect(installationDemoDrawingDeletionRepository.deleteDrawing).not.toHaveBeenCalled()
})
it('reports missing drawings and does not invalidate on transaction failure', async () => {
const deletion = jest.mocked(installationDemoDrawingDeletionRepository.deleteDrawing)
deletion.mockResolvedValueOnce(null)
await expect(service.delete(user, { drawingId: 'manual-test' })).rejects.toThrow('不存在')
deletion.mockRejectedValueOnce(new Error('Transaction aborted'))
await expect(service.delete(user, { drawingId: 'manual-test' })).rejects.toThrow('Transaction aborted')
expect(systemConfigService.invalidate).not.toHaveBeenCalled()
})

View File

@ -2,23 +2,13 @@ import 'server-only'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import {
type InstallationDemoWorkbenchAccess,
} from '@/lib/installation-demo-workbench-permissions'
import { stripNonIntegratedBrandPrefix } from '@/app/intake/non-integrated/nonIntegratedStorage'
import {
intakeNonIntegratedProductService,
type IntakeNonIntegratedProductListParams,
} from '../intake-non-integrated-product.service'
import type { InstallationDemoWorkbenchAccess } from '@/lib/installation-demo-workbench-permissions'
import type { InstallationManualDrawing, DeleteInstallationManualDrawingResult } from '@/lib/installation-demo-manual-catalog'
import { installationDemoCatalogRepository } from '@/server/repositories/installation-demo-catalog.repository'
import { installationDemoDrawingDeletionRepository } from '@/server/repositories/installation-demo-drawing-deletion.repository'
import { systemConfigService } from '../system-config.service'
import { installationDemoAccessService } from './installation-demo-access.service'
type ProductListResult = Awaited<ReturnType<typeof intakeNonIntegratedProductService.listPage>>
type ProductListItem = ProductListResult['products'][number]
type SkuItem = ProductListItem['skuItems'][number]
const CATALOG_FETCH_PAGE_SIZE = 100
const INSTALLATION_DEMO_EXTRA_SPUS = ['CJ0267'] as const
export interface InstallationDemoDrawingDTO {
id: string
code: string
@ -58,243 +48,73 @@ export interface InstallationDemoWorkbenchCatalogDTO {
spus: InstallationDemoSpuDTO[]
}
function normalizeCode(value: unknown) {
return typeof value === 'string' ? value.trim().toUpperCase() : ''
}
function normalizeText(value: unknown) {
return typeof value === 'string' ? value.trim() : ''
}
function uniqueStrings(values: string[]) {
return Array.from(new Set(values.filter(Boolean)))
}
function inferModelCode(item: SkuItem) {
const explicitModel = normalizeCode(item.model)
if (explicitModel) return stripNonIntegratedBrandPrefix(explicitModel) || explicitModel
const drawingId = normalizeCode(item.drawingId)
const drawingModelMatch = drawingId.match(/^(.+?K\d{2})/)
if (drawingModelMatch?.[1]) {
return stripNonIntegratedBrandPrefix(drawingModelMatch[1]) || drawingModelMatch[1]
function normalizeCode(value: unknown, label: string) {
if (typeof value !== 'string' || !value.trim()) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `请填写${label}`)
}
return ''
}
function resolveDrawingCode(item: SkuItem) {
const drawingId = normalizeCode(item.drawingId)
return drawingId ? stripNonIntegratedBrandPrefix(drawingId) || drawingId : ''
}
function resolveProductName(product: ProductListItem) {
return (
normalizeText(product.openingReference?.productName) ||
normalizeText(product.productCode) ||
normalizeCode(product.spu)
)
}
function pushUnique(target: string[], value: string) {
if (value && !target.includes(value)) {
target.push(value)
const code = value.trim().toUpperCase()
if (code.length > 120 || /[\u0000-\u001f\u007f]/.test(code)) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `${label}格式不正确,最多120个字符`)
}
return code
}
function addSkuToCatalog(
catalog: Map<string, InstallationDemoSpuDTO>,
product: ProductListItem,
item: SkuItem,
) {
const persistedSpuCode = normalizeCode(product.spu)
const spuCode = stripNonIntegratedBrandPrefix(persistedSpuCode) || persistedSpuCode
if (!spuCode) return
const modelCode = inferModelCode(item)
const drawingCode = resolveDrawingCode(item)
if (!modelCode || !drawingCode) return
const productName = resolveProductName(product)
const productCategory = normalizeText(product.productCategory)
const brand = normalizeText(product.brand)
const skuCode = normalizeCode(item.skuCode) || normalizeCode(item.barcode)
let spuNode = catalog.get(spuCode)
if (!spuNode) {
spuNode = {
id: spuCode,
code: spuCode,
productName,
productCategory,
brand,
modelCount: 0,
drawingCount: 0,
skuCount: 0,
models: [],
function buildCatalog(rows: InstallationManualDrawing[]): InstallationDemoSpuDTO[] {
const groups = new Map<string, InstallationDemoSpuDTO>()
for (const row of rows) {
let spu = groups.get(row.spuCode)
if (!spu) {
// The internal grouping slot preserves existing editor contracts; it is not a model code.
spu = { id: row.spuCode, code: row.spuCode, productName: '', productCategory: '', brand: '',
modelCount: 0, drawingCount: 0, skuCount: 0,
models: [{ id: row.spuCode, code: '', drawingCount: 0, skuCount: 0, drawings: [] }] }
groups.set(row.spuCode, spu)
}
catalog.set(spuCode, spuNode)
spu.models[0].drawings.push({ id: row.id, code: row.drawingCode, modelCode: '', spuCode: row.spuCode,
skuCount: 0, skuCodes: [], productIds: [], productNames: [], modelUrl: null, modelFileName: null, previewImage: null })
spu.drawingCount += 1
spu.models[0].drawingCount += 1
}
const existingProductName = spuNode.productName
if (!existingProductName && productName) {
spuNode.productName = productName
for (const spu of groups.values()) {
spu.models[0].drawings.sort((a, b) => a.code.localeCompare(b.code, 'zh-CN'))
}
if (!spuNode.productCategory && productCategory) {
spuNode.productCategory = productCategory
}
if (!spuNode.brand && brand) {
spuNode.brand = brand
}
let modelNode = spuNode.models.find((model) => model.code === modelCode)
if (!modelNode) {
modelNode = {
id: `${spuCode}:${modelCode}`,
code: modelCode,
drawingCount: 0,
skuCount: 0,
drawings: [],
}
spuNode.models.push(modelNode)
}
let drawingNode = modelNode.drawings.find((drawing) => drawing.code === drawingCode)
if (!drawingNode) {
drawingNode = {
id: `${spuCode}:${modelCode}:${drawingCode}`,
code: drawingCode,
modelCode,
spuCode,
skuCount: 0,
skuCodes: [],
productIds: [],
productNames: [],
modelUrl: normalizeText(item.model3dDataUrl) || null,
modelFileName: normalizeText(item.model3dFileName) || null,
previewImage: normalizeText(item.image) || normalizeText(product.previewImage) || null,
}
modelNode.drawings.push(drawingNode)
}
drawingNode.skuCount += 1
pushUnique(drawingNode.skuCodes, skuCode)
pushUnique(drawingNode.productIds, product.id)
pushUnique(drawingNode.productNames, productName)
if (!drawingNode.modelUrl && normalizeText(item.model3dDataUrl)) {
drawingNode.modelUrl = normalizeText(item.model3dDataUrl)
drawingNode.modelFileName = normalizeText(item.model3dFileName) || null
}
if (!drawingNode.previewImage) {
drawingNode.previewImage = normalizeText(item.image) || normalizeText(product.previewImage) || null
}
}
function sortCatalog(spus: InstallationDemoSpuDTO[]) {
return spus
.map((spu) => {
const models = spu.models
.map((model) => {
const drawings = model.drawings
.map((drawing) => ({
...drawing,
skuCodes: uniqueStrings(drawing.skuCodes),
productIds: uniqueStrings(drawing.productIds),
productNames: uniqueStrings(drawing.productNames),
}))
.sort((left, right) => left.code.localeCompare(right.code, 'zh-Hans-CN'))
return {
...model,
drawingCount: drawings.length,
skuCount: drawings.reduce((total, drawing) => total + drawing.skuCount, 0),
drawings,
}
})
.sort((left, right) => left.code.localeCompare(right.code, 'zh-Hans-CN'))
return {
...spu,
modelCount: models.length,
drawingCount: models.reduce((total, model) => total + model.drawingCount, 0),
skuCount: models.reduce((total, model) => total + model.skuCount, 0),
models,
}
})
.sort((left, right) => left.code.localeCompare(right.code, 'zh-Hans-CN'))
}
function buildCatalog(products: ProductListItem[]) {
const catalog = new Map<string, InstallationDemoSpuDTO>()
products.forEach((product) => {
const skuItems = Array.isArray(product.skuItems) ? product.skuItems : []
skuItems.forEach((item) => addSkuToCatalog(catalog, product, item))
})
return sortCatalog(Array.from(catalog.values()))
}
async function listAllProductPages(
user: AuthUser,
params: Omit<IntakeNonIntegratedProductListParams, 'page' | 'pageSize'>,
) {
const products: ProductListItem[] = []
let pageNumber = 1
let totalPages = 1
do {
const page = await intakeNonIntegratedProductService.listPage(user, {
...params,
page: pageNumber,
pageSize: CATALOG_FETCH_PAGE_SIZE,
})
products.push(...page.products)
totalPages = Math.max(1, page.pageInfo.totalPages)
pageNumber += 1
} while (pageNumber <= totalPages)
return products
}
function uniqueProducts(products: ProductListItem[]) {
return Array.from(new Map(products.map((product) => [product.id, product])).values())
return [...groups.values()].sort((a, b) => a.code.localeCompare(b.code, 'zh-CN'))
}
export class InstallationDemoCatalogService {
async delete(user: AuthUser, input: unknown): Promise<DeleteInstallationManualDrawingResult> {
const access = await installationDemoAccessService.getAccess(user)
if (!access.canEdit) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限删除安装演示')
const value = input && typeof input === 'object' ? input as Record<string, unknown> : {}
const drawingId = typeof value.drawingId === 'string' ? value.drawingId.trim() : ''
if (!/^manual-[a-z0-9-]{1,80}$/i.test(drawingId)) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '请选择手动创建的图纸')
}
const keys = await installationDemoDrawingDeletionRepository.deleteDrawing(drawingId)
if (!keys) throw new AppError(ErrorCode.FILE_NOT_FOUND, '图纸已删除或不存在')
keys.forEach(key => systemConfigService.invalidate(key))
return { drawingId, deleted: true }
}
async list(user: AuthUser): Promise<InstallationDemoWorkbenchCatalogDTO> {
const access = await installationDemoAccessService.getAccess(user)
if (!access.canView) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限查看安装演示工作台')
}
if (!access.canView) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限查看安装演示工作台')
return { access, spus: buildCatalog(await installationDemoCatalogRepository.list()) }
}
const regularProducts = await listAllProductPages(user, {
productDevelopmentKind: 'product',
productKind: 'new',
quickScope: 'all',
})
const extraProducts = (
await Promise.all(
INSTALLATION_DEMO_EXTRA_SPUS.map((spu) =>
listAllProductPages(user, {
productKind: 'all',
quickScope: 'all',
spu,
}),
),
)
)
.flat()
.filter((product) =>
INSTALLATION_DEMO_EXTRA_SPUS.includes(
normalizeCode(product.spu) as (typeof INSTALLATION_DEMO_EXTRA_SPUS)[number],
),
)
return {
access,
spus: buildCatalog(uniqueProducts([...regularProducts, ...extraProducts])),
async create(user: AuthUser, input: unknown): Promise<InstallationManualDrawing> {
const access = await installationDemoAccessService.getAccess(user)
if (!access.canEdit) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限新建安装演示')
const value = input && typeof input === 'object' ? input as Record<string, unknown> : {}
const spuCode = normalizeCode(value.spuCode, 'SPU')
const drawingCode = normalizeCode(value.drawingCode, '图纸 ID')
try {
return await installationDemoCatalogRepository.create(spuCode, drawingCode, user.id)
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') {
throw new AppError(ErrorCode.VALIDATION_FAILED, '该 SPU 下已存在相同图纸 ID')
}
throw error
}
}
}

View File

@ -14,6 +14,7 @@ jest.mock('./installation-demo-access.service', () => ({
import { systemConfigService } from '../system-config.service'
import { installationDemoAccessService } from './installation-demo-access.service'
import { InstallationDemoNumberingConfigService } from './installation-demo-numbering-config.service'
import { createInstallationHardwareHinge } from '@/lib/installation-demo-hardware-hinge'
const user = {
id: 'user-1',
@ -51,6 +52,24 @@ describe('InstallationDemoNumberingConfigService', () => {
mockedSystemConfigService.set.mockResolvedValue(undefined)
})
it('persists and reloads a hardware hinge draft without tool bindings or product coupling', async () => {
const service = new InstallationDemoNumberingConfigService()
const hardwareHinge = createInstallationHardwareHinge(['固定座', '连杆', '杯座'])
const saved = await service.save(user, { drawingId: 'hinge-drawing', parts: [{ ...d14Part, hardwareHinge }] })
expect(saved.parts[0].hardwareHinge).toEqual(hardwareHinge)
expect(saved.parts[0].toolBindings).toBeUndefined()
mockedSystemConfigService.get.mockResolvedValue(saved)
expect((await service.get(user, 'hinge-drawing')).parts[0].hardwareHinge).toEqual(hardwareHinge)
})
it('rejects malformed hinge vectors and hinge bindings on boards', async () => {
const service = new InstallationDemoNumberingConfigService()
const hardwareHinge = createInstallationHardwareHinge(['固定座', '连杆', '杯座'])
await expect(service.save(user, { drawingId: 'bad', parts: [{ ...d14Part, kind: 'part', hardwareHinge }] })).rejects.toThrow('五金')
await expect(service.save(user, { drawingId: 'bad', parts: [{ ...d14Part, hardwareHinge: { ...hardwareHinge, calibration: { min: [0, 0, 0], max: [NaN, 1, 1], nodeNames: [] } } }] })).rejects.toThrow('格式')
expect(mockedSystemConfigService.set).not.toHaveBeenCalled()
})
it('persists a D14 composite group and template with the numbering configuration', async () => {
const service = new InstallationDemoNumberingConfigService()

View File

@ -1,4 +1,5 @@
import 'server-only'
import { parseInstallationHardwareHinge, type InstallationHardwareHingeBinding } from '@/lib/installation-demo-hardware-hinge'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
@ -38,6 +39,7 @@ export interface InstallationDemoNumberingPartDTO {
kind: InstallationDemoPartKindDTO
screwDrive?: boolean
finishClose?: boolean
hardwareHinge?: InstallationHardwareHingeBinding
toolBindings?: InstallationDemoHardwareToolBindingDTO[]
offset: InstallationDemoVector3DTO
rotation: InstallationDemoQuaternionDTO
@ -266,6 +268,15 @@ function normalizePart(value: unknown): InstallationDemoNumberingPartDTO {
const screwDrive = normalizeScrewDrive(value.screwDrive)
const finishClose = normalizeFinishClose(value.finishClose)
const toolBindings = normalizeToolBindings(value.toolBindings)
let hardwareHinge: InstallationHardwareHingeBinding | null = null
try {
hardwareHinge = parseInstallationHardwareHinge(value.hardwareHinge)
} catch {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '铰链配置格式无效')
}
if (hardwareHinge && kind !== 'hardware') {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '铰链只能配置在五金编号中')
}
if (screwDrive && kind !== 'hardware') {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, 'screwDrive is only available for hardware')
}
@ -290,6 +301,7 @@ function normalizePart(value: unknown): InstallationDemoNumberingPartDTO {
kind,
...(screwDrive ? { screwDrive: true } : {}),
...(finishClose ? { finishClose: true } : {}),
...(hardwareHinge ? { hardwareHinge } : {}),
...(toolBindings ? { toolBindings } : {}),
offset: normalizeVector3(value.offset, 'part.offset'),
rotation,

View File

@ -1,4 +1,8 @@
jest.mock('server-only', () => ({}))
jest.mock('@/server/repositories/installation-demo-catalog.repository', () => ({
installationDemoCatalogRepository: { exists: jest.fn() },
}))
import { installationDemoCatalogRepository } from '@/server/repositories/installation-demo-catalog.repository'
jest.mock('../system-config.service', () => ({
systemConfigService: {
@ -45,11 +49,21 @@ describe('InstallationDemoQrShareService', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.mocked(installationDemoCatalogRepository.exists).mockResolvedValue(true)
mockedAccessService.getAccess.mockResolvedValue({ canView: true, canEdit: true, canShare: true })
mockedConfigService.get.mockResolvedValue(null)
mockedConfigService.set.mockResolvedValue(undefined)
})
it('revokes a cached QR snapshot after deleting its drawing', async () => {
const service = new InstallationDemoQrShareService()
const snapshot = await service.sync(user, createInput('manual-test'))
mockedConfigService.get.mockResolvedValue(snapshot)
jest.mocked(installationDemoCatalogRepository.exists).mockResolvedValue(false)
expect(await service.findPublic(snapshot.token)).toBeNull()
await expect(service.sync(user, createInput('manual-test'))).rejects.toThrow('已删除')
})
it('keeps the token and storage key fixed for the same drawing', async () => {
const service = new InstallationDemoQrShareService()
const first = await service.sync(user, createInput('drawing-1'))

View File

@ -1,4 +1,5 @@
import 'server-only'
import { installationDemoCatalogRepository } from '@/server/repositories/installation-demo-catalog.repository'
import { createHash } from 'node:crypto'
import type { AuthUser } from '@/server/core/types'
@ -69,6 +70,7 @@ export class InstallationDemoQrShareService {
const snapshot = normalizeStoredInstallationDemoShareSnapshot(
await systemConfigService.get<unknown>(buildQrConfigKey(token)),
)
if (snapshot?.drawing.id.startsWith('manual-') && !await installationDemoCatalogRepository.exists(snapshot.drawing.id)) return null
return snapshot?.expiresAt === null ? snapshot : null
}
}

View File

@ -1,4 +1,8 @@
jest.mock("server-only", () => ({}));
jest.mock('@/server/repositories/installation-demo-catalog.repository', () => ({
installationDemoCatalogRepository: { exists: jest.fn() },
}));
import { installationDemoCatalogRepository } from '@/server/repositories/installation-demo-catalog.repository';
jest.mock("../system-config.service", () => ({
systemConfigService: {
@ -92,6 +96,7 @@ describe("InstallationDemoShareService", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(installationDemoCatalogRepository.exists).mockResolvedValue(true);
mockedAccessService.getAccess.mockResolvedValue({
canView: true,
canEdit: true,
@ -104,6 +109,7 @@ describe("InstallationDemoShareService", () => {
it("persists tool snapshots together with part snapshots for a shared demo", async () => {
const service = new InstallationDemoShareService();
const snapshot = await service.create(user, createInput());
expect(snapshot.expiresAt).toBeNull();
expect(snapshot.numberingToolSnapshotUrls).toEqual({
tool: "/api/storage/object?key=snapshots%2Ftool.png",
@ -122,6 +128,36 @@ describe("InstallationDemoShareService", () => {
);
});
it('revokes cached permanent snapshots and refuses new shares for a deleted manual drawing', async () => {
const service = new InstallationDemoShareService();
const input = createInput();
input.drawing.id = 'manual-test';
const snapshot = await service.create(user, input);
mockedConfigService.get.mockResolvedValue(snapshot);
jest.mocked(installationDemoCatalogRepository.exists).mockResolvedValue(false);
await expect(service.getPublic(snapshot.token)).rejects.toThrow('不存在');
await expect(service.create(user, input)).rejects.toThrow('已删除');
});
it("keeps new links readable years later without requiring a model code", async () => {
const service = new InstallationDemoShareService();
const input = createInput();
input.drawing.modelCode = "";
const snapshot = await service.create(user, input);
mockedConfigService.get.mockResolvedValue(snapshot);
const now = jest.spyOn(Date, "now").mockReturnValue(Date.now() + 10 * 365 * 86400000);
try {
await expect(service.getPublic(snapshot.token)).resolves.toMatchObject({ expiresAt: null, drawing: { modelCode: "" } });
} finally { now.mockRestore(); }
});
it("does not revive expired historical shares", async () => {
const service = new InstallationDemoShareService();
const snapshot = await service.create(user, createInput());
mockedConfigService.get.mockResolvedValue({ ...snapshot, expiresAt: "2020-01-01T00:00:00.000Z" });
await expect(service.getPublic(snapshot.token)).rejects.toThrow("已过期");
});
it("freezes the completed drawing document into the share snapshot", async () => {
const service = new InstallationDemoShareService();
const snapshot = await service.create(user, createInput());

View File

@ -1,4 +1,5 @@
import "server-only";
import { installationDemoCatalogRepository } from '@/server/repositories/installation-demo-catalog.repository';
import { nanoid } from "nanoid";
import { isInstallationCameraViewSnapshot } from "@/app/installation-demo-workbench/lib/installationCameraView";
@ -19,7 +20,6 @@ import { installationDemoAccessService } from "./installation-demo-access.servic
const SHARE_CONFIG_KEY_PREFIX = "installation-demo-workbench:share:";
const SHARE_TOKEN_LENGTH = 32;
const SHARE_TTL_MS = 24 * 60 * 60 * 1000;
function buildShareConfigKey(token: string) {
return `${SHARE_CONFIG_KEY_PREFIX}${token}`;
@ -68,7 +68,7 @@ function normalizeDrawing(
return {
id: normalizeText(value.id, "图纸 ID"),
code: normalizeText(value.code, "图纸编号"),
modelCode: normalizeText(value.modelCode, "型号"),
modelCode: normalizeNullableText(value.modelCode, "型号") ?? "",
spuCode: normalizeText(value.spuCode, "SPU"),
};
}
@ -495,6 +495,9 @@ export class InstallationDemoShareService {
): Promise<InstallationDemoShareSnapshot> {
await assertCanShare(user);
const input = normalizeCreateInput(rawInput);
if (input.drawing.id.startsWith('manual-') && !await installationDemoCatalogRepository.exists(input.drawing.id)) {
throw new AppError(ErrorCode.FILE_NOT_FOUND, '图纸已删除或不存在');
}
const token = normalizeInstallationDemoShareToken(options.token);
const now = new Date();
@ -529,17 +532,15 @@ export class InstallationDemoShareService {
rawInput: unknown,
): Promise<InstallationDemoShareSnapshot> {
const token = nanoid(SHARE_TOKEN_LENGTH);
const now = new Date();
const expiresAt = new Date(now.getTime() + SHARE_TTL_MS);
const snapshot = await this.createSnapshot(user, rawInput, {
token,
expiresAt: expiresAt.toISOString(),
expiresAt: null,
});
await systemConfigService.set(
buildShareConfigKey(token),
snapshot,
`Installation demo temporary share ${snapshot.drawing.code}`,
`Installation demo permanent share ${snapshot.drawing.code}`,
);
return snapshot;
@ -551,11 +552,11 @@ export class InstallationDemoShareService {
await systemConfigService.get<unknown>(buildShareConfigKey(token)),
);
if (!snapshot || !snapshot.expiresAt) {
if (!snapshot || (snapshot.drawing.id.startsWith('manual-') && !await installationDemoCatalogRepository.exists(snapshot.drawing.id))) {
throw new AppError(ErrorCode.SHARE_LINK_NOT_FOUND, "安装演示链接不存在");
}
if (Date.parse(snapshot.expiresAt) <= Date.now()) {
if (snapshot.expiresAt !== null && Date.parse(snapshot.expiresAt) <= Date.now()) {
throw new AppError(ErrorCode.SHARE_LINK_EXPIRED, "该安装演示链接已过期");
}