feat(installation-demo): add manual catalog, permanent shares and hinge binding
This commit is contained in:
parent
aa1ca0afcf
commit
46a732d892
@ -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,
|
||||
|
||||
@ -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 = '该历史链接已过期,请联系业务人员重新生成永久分享链接。'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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' },
|
||||
}))
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
@ -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) }
|
||||
}
|
||||
@ -54,7 +54,7 @@ export function useInstallationDrawingConfirmation({
|
||||
}
|
||||
const share = await syncInstallationDemoQrShare(shareInput);
|
||||
onQrShareSynced(share);
|
||||
setMessage("图纸样式已确认,永久二维码已同步;临时链接请重新生成");
|
||||
setMessage("图纸样式已确认,永久二维码已同步;分享链接请重新生成");
|
||||
return true;
|
||||
} catch (error) {
|
||||
const detail =
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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('门')
|
||||
})
|
||||
})
|
||||
@ -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 }] : []
|
||||
}),
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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()]
|
||||
}
|
||||
@ -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')
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
105
furniture-3d-system/src/lib/installation-demo-hardware-hinge.ts
Normal file
105
furniture-3d-system/src/lib/installation-demo-hardware-hinge.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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()
|
||||
@ -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()
|
||||
})
|
||||
@ -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()
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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, "该安装演示链接已过期");
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user