feat(installation-demo): 工作台顶栏改为目录/制作/分享并新增工具抽屉与制作流程卡

- 标签行:模型→目录、详情→制作;工具从跳转链接改为面板内抽屉,勾选即保存,完整工具箱保留次级入口
This commit is contained in:
WuYongJie 2026-10-06 18:20:36 +08:00
parent 614e3dabe2
commit b0a6cdd1e7
5 changed files with 326 additions and 54 deletions

View File

@ -109,6 +109,7 @@ import { useInstallationStepCameraPlayback } from "./hooks/useInstallationStepCa
import { useInstallationStepEditor } from "./hooks/useInstallationStepEditor";
import { useInstallationStepMaterialThumbnails } from "./hooks/useInstallationStepMaterialThumbnails";
import { InstallationInspector } from "./components/InstallationInspector";
import { computeInstallationStageReadiness } from "./lib/installationWorkflowReadiness";
import { InstallationPreviewStage } from "./components/InstallationPreviewStage";
import { InstallationFinishedMiniPreview } from "./components/InstallationFinishedMiniPreview";
import { InstallationPartTexturePanel } from "./components/InstallationPartTexturePanel";
@ -2005,6 +2006,19 @@ export default function InstallationDemoWorkbenchClient({
buildShareInput: buildCurrentShareInput,
});
// 制作流程完成度(工作台体验移植自 V2)
const stageReadiness = useMemo(
() =>
computeInstallationStageReadiness({
modelLoaded: Boolean(modelUrl),
parts: partCatalog.parts,
numberingParts: numberingCatalog.parts,
steps: stepEditor.steps,
materialCount: Object.keys(nodeMaterials.nodeMaterialConfigs ?? {}).length,
}),
[modelUrl, partCatalog.parts, numberingCatalog.parts, stepEditor.steps, nodeMaterials.nodeMaterialConfigs],
);
const handleConfirmedQrShare = useCallback(
(share: InstallationDemoQrShareDTO) => {
setQrShareUrl(share.url);
@ -2877,6 +2891,7 @@ export default function InstallationDemoWorkbenchClient({
hardwareHingeContext={{ engine: previewEngine, modelNodeNames, parts: numberingCatalog.parts,
steps: stepEditor.steps, templates: drawingTools.tools }}
activeTab={activeTab}
stages={stageReadiness}
onTabChange={(nextTab) => {
setActiveTab(nextTab);
if (nextTab === "share") {

View File

@ -14,14 +14,17 @@ import type {
import { ExplosionEditorPanel } from './ExplosionEditorPanel'
import { InstallationDrawingToolSelection } from './InstallationDrawingToolSelection'
import { InstallationToolsInventoryBoard } from './InstallationToolsInventoryBoard'
import { InstallationWorkflowFooterNav, InstallationWorkflowNavigator } from './InstallationWorkflowNavigator'
import { PartCatalogSection } from './PartCatalogSection'
import { StepEditingPanel } from './StepEditingPanel'
import type { InstallationStageReadiness } from '../lib/installationWorkflowReadiness'
import type { InstallationHardwareHingeEditorContext } from './InstallationHardwareHingeEditor'
interface InstallationDetailsPanelProps {
hardwareHingeContext?: InstallationHardwareHingeEditorContext
detailView: InstallationDetailView
onDetailViewChange: (view: InstallationDetailView) => void
stages: InstallationStageReadiness[]
canEdit: boolean
modelNodeNames: string[]
parts: InstallationPartItem[]
@ -88,18 +91,6 @@ interface InstallationDetailsPanelProps {
partTextureEditor?: ReactNode
}
const DETAIL_STAGE_ITEMS: Array<{
value: InstallationDetailView
title: string
description: string
}> = [
{ value: 'finished', title: '第一步:成品展示', description: '固定展示产品安装完成后的样子。' },
{ value: 'exploded', title: '第二步:爆炸图', description: '固定展示产品拆开的状态。' },
{ value: 'numbering', title: '第三步:部件编号', description: '按编号顺序逐组展示,并支持折角引线标注。' },
{ value: 'materials', title: '第四步:部件贴图', description: '按板件编号绑定颜色、纹理和方向。' },
{ value: 'steps', title: '第五步:安装步骤', description: '进入后续安装步骤与动画设置。' },
]
const NUMBERING_CATEGORY_TABS: Array<{
value: InstallationNumberingDisplayCategory
label: string
@ -204,6 +195,7 @@ export function InstallationDetailsPanel({
hardwareHingeContext,
detailView,
onDetailViewChange,
stages,
canEdit,
modelNodeNames,
parts,
@ -272,36 +264,9 @@ export function InstallationDetailsPanel({
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<section className="mb-3 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="grid gap-2 p-3">
{DETAIL_STAGE_ITEMS.map((item) => {
const active = item.value === detailView
return (
<button
key={item.value}
type="button"
onClick={() => onDetailViewChange(item.value)}
className={[
'rounded-[8px] border px-3 py-2 text-left transition',
active
? 'border-[#2d3335]/30 bg-[#f5f7f8]'
: 'border-black/[0.06] bg-white hover:bg-black/[0.02]',
].join(' ')}
>
<span className="block text-[12px] font-black text-[#2d3335]">{item.title}</span>
<span className="mt-1 block text-[10px] font-semibold text-[#8b9092]">
{item.description}
</span>
</button>
)
})}
</div>
</section>
<InstallationWorkflowNavigator stages={stages} current={detailView} onChange={onDetailViewChange} />
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{detailView === 'numbering' || detailView === 'materials' ? (
<div className="mb-3">{finishedPreview}</div>
) : null}
@ -436,9 +401,9 @@ export function InstallationDetailsPanel({
<section className="mt-3 rounded-[8px] border border-[#2d3335]/15 bg-white p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-[12px] font-black text-[#2d3335]">安装图纸</p>
<p className="text-[12px] font-black text-[#2d3335]">出图与分享</p>
<p className="mt-1 text-[10px] font-semibold leading-4 text-[#8b9092]">
先打开固定模板,再手动截取成品、爆炸图和每个动作画面。
先打开固定模板,再手动截取成品、爆炸图和每个动作画面;分享入口在顶部「分享」标签。
</p>
</div>
<button
@ -452,6 +417,8 @@ export function InstallationDetailsPanel({
</div>
</section>
</div>
<InstallationWorkflowFooterNav current={detailView} onChange={onDetailViewChange} />
</div>
)
}

View File

@ -1,8 +1,9 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import type { ReactNode } from 'react'
import { Share2, Wrench } from 'lucide-react'
import { ArrowLeft, Share2, Wrench } from 'lucide-react'
import type {
InstallationDetailView,
InstallationDrawingNode,
@ -17,14 +18,17 @@ import type {
InstallationInspectorTab,
} from '../types'
import { InstallationDetailsPanel } from './InstallationDetailsPanel'
import { InstallationDrawingToolSelection } from './InstallationDrawingToolSelection'
import { InstallationSharePanel } from './InstallationSharePanel'
import { ProductStructurePanel } from './ProductStructurePanel'
import type { InstallationStageReadiness } from '../lib/installationWorkflowReadiness'
import type { InstallationHardwareHingeEditorContext } from './InstallationHardwareHingeEditor'
interface InstallationInspectorProps {
hardwareHingeContext?: InstallationHardwareHingeEditorContext
activeTab: InstallationInspectorTab
onTabChange: (tab: InstallationInspectorTab) => void
stages: InstallationStageReadiness[]
spus: InstallationSpuNode[]
selectedSpu: InstallationSpuNode | null
selectedModel: InstallationModelNode | null
@ -119,9 +123,10 @@ interface InstallationInspectorProps {
partTextureEditor?: ReactNode
}
// 标签名与内容对齐:目录=SPU/型号/图纸树,制作=五阶段流程;工具改为面板内抽屉入口
const TABS: Array<{ value: InstallationInspectorTab; label: string }> = [
{ value: 'structure', label: '模型' },
{ value: 'steps', label: '详情' },
{ value: 'structure', label: '目录' },
{ value: 'steps', label: '制作' },
{ value: 'share', label: '分享' },
]
@ -216,9 +221,11 @@ export function InstallationInspector({
stepActionEditor,
finishedPreview,
partTextureEditor,
stages,
}: InstallationInspectorProps) {
const [toolsDrawerOpen, setToolsDrawerOpen] = useState(false)
return (
<aside className="flex h-full w-[460px] shrink-0 flex-col border-l border-black/[0.06] bg-white">
<aside className="relative flex h-full w-[460px] shrink-0 flex-col border-l border-black/[0.06] bg-white">
<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]">
@ -228,13 +235,6 @@ export function InstallationInspector({
</div>
<div className="flex gap-1 border-b border-black/[0.06] px-4 py-2">
<Link
href="/installation-demo-workbench/tools"
className="inline-flex h-8 items-center gap-1 rounded-[6px] px-3 text-[12px] font-black text-[#5a6062] transition hover:bg-black/[0.04]"
>
<Wrench size={13} strokeWidth={1.8} />
工具
</Link>
{TABS.map((tab) => (
<button
key={tab.value}
@ -254,8 +254,58 @@ export function InstallationInspector({
{tab.label}
</button>
))}
<button
type="button"
onClick={() => setToolsDrawerOpen(true)}
aria-expanded={toolsDrawerOpen}
className="ml-auto inline-flex h-8 items-center gap-1 rounded-[6px] px-3 text-[12px] font-black text-[#5a6062] transition hover:bg-black/[0.04]"
>
<Wrench size={13} strokeWidth={1.8} />
工具
{selectedDrawingToolIds.length > 0 ? (
<span className="rounded-full bg-[#2d3335] px-1.5 text-[10px] font-black leading-4 text-white">
{selectedDrawingToolIds.length}
</span>
) : null}
</button>
</div>
{toolsDrawerOpen ? (
<div className="absolute inset-0 z-20 flex flex-col bg-white">
<div className="flex items-center gap-3 border-b border-black/[0.06] px-4 py-3">
<button
type="button"
aria-label="关闭工具抽屉"
onClick={() => setToolsDrawerOpen(false)}
className="flex h-8 w-8 items-center justify-center rounded-[6px] text-[#5a6062] transition hover:bg-black/[0.04]"
>
<ArrowLeft size={15} strokeWidth={1.8} />
</button>
<div className="min-w-0">
<p className="text-[13px] font-black text-[#2d3335]">当前图纸工具</p>
<p className="mt-0.5 truncate text-[10px] font-semibold text-[#8b9092]">
已选 {selectedDrawingToolIds.length} 个 · 勾选立即保存;编辑工具模型去完整工具箱
</p>
</div>
<Link
href="/installation-demo-workbench/tools"
className="ml-auto shrink-0 rounded-[6px] border border-black/[0.08] px-2.5 py-1.5 text-[11px] font-semibold text-[#2d3335] transition hover:bg-black/[0.03]"
>
完整工具箱
</Link>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<InstallationDrawingToolSelection
tools={allTools}
selectedToolIds={selectedDrawingToolIds}
canEdit={canEdit}
loading={drawingToolsLoading}
onToggleTool={onToggleDrawingTool}
/>
</div>
</div>
) : null}
{catalogMessage || uploadMessage ? (
<div className="border-b border-black/[0.06] bg-[#f5f7f8] px-4 py-2 text-[11px] font-semibold text-[#5a6062]">
{uploadMessage ?? catalogMessage}
@ -284,6 +334,7 @@ export function InstallationInspector({
hardwareHingeContext={hardwareHingeContext}
detailView={detailView}
onDetailViewChange={onDetailViewChange}
stages={stages}
canEdit={canEdit}
modelNodeNames={modelNodeNames}
parts={parts}

View File

@ -0,0 +1,96 @@
'use client'
import { Check, ChevronLeft, ChevronRight, CircleDashed } from 'lucide-react'
import type { InstallationDetailView } from '../types'
import {
findAdjacentStage,
type InstallationStageReadiness,
} from '../lib/installationWorkflowReadiness'
const STATUS_LABEL: Record<InstallationStageReadiness['status'], string> = {
done: '完成',
partial: '有缺项',
empty: '未开始',
}
const STATUS_CHIP: Record<InstallationStageReadiness['status'], string> = {
done: 'bg-[#e7f0e9] text-[#3a7a4d]',
partial: 'bg-[#fbf3df] text-[#9a6b00]',
empty: 'bg-black/[0.04] text-[#8b9092]',
}
// 紧凑制作流程条:五个编辑阶段横向扁卡,状态徽标 + 原因一行;自由跳转,不做强制向导。
export function InstallationWorkflowNavigator({ stages, current, onChange }: {
stages: InstallationStageReadiness[]
current: InstallationDetailView
onChange: (view: InstallationDetailView) => void
}) {
const activeStage = stages.find((stage) => stage.key === current)
return (
<nav aria-label="制作流程" className="border-b border-black/[0.06] px-3 py-3">
<div className="grid grid-cols-5 gap-1.5">
{stages.map((stage, index) => {
const active = stage.key === current
return (
<button
key={stage.key}
type="button"
onClick={() => onChange(stage.key)}
aria-current={active ? 'step' : undefined}
title={stage.detail}
className={[
'flex min-w-0 flex-col gap-1.5 rounded-[8px] border px-2.5 py-2 text-left transition',
active
? 'border-[#2d3335]/30 bg-[#f5f7f8]'
: 'border-black/[0.06] bg-white hover:border-black/[0.12]',
].join(' ')}
>
<span className="flex min-w-0 items-center gap-1">
<span className={['shrink-0 text-[11px] font-black',
active ? 'text-[#2d3335]' : 'text-[#b8bec0]'].join(' ')}>
{index + 1}
</span>
<span className="min-w-0 flex-1 truncate text-[12px] font-black text-[#2d3335]">
{stage.label}
</span>
</span>
<span className={['inline-flex w-fit items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-semibold leading-none',
STATUS_CHIP[stage.status]].join(' ')}>
{stage.status === 'done'
? <Check size={9} strokeWidth={2.5} />
: <CircleDashed size={9} strokeWidth={2} />}
{STATUS_LABEL[stage.status]}
</span>
</button>
)
})}
</div>
{activeStage && activeStage.status !== 'done' ? (
<p className="mt-2 truncate text-[10px] font-semibold text-[#8b9092]">
{activeStage.label}:{activeStage.detail}
</p>
) : null}
</nav>
)
}
// 底部上一步/下一步:只在五个编辑阶段之间切换。
export function InstallationWorkflowFooterNav({ current, onChange }: {
current: InstallationDetailView
onChange: (view: InstallationDetailView) => void
}) {
const previous = findAdjacentStage(current, -1)
const next = findAdjacentStage(current, 1)
return (
<div className="flex items-center justify-between gap-2 border-t border-black/[0.06] px-3 py-2">
<button type="button" disabled={!previous} onClick={() => previous && onChange(previous)}
className="inline-flex h-8 items-center gap-1 rounded-md border border-black/[0.08] px-3 text-[12px] font-semibold text-[#2d3335] transition hover:bg-black/[0.03] disabled:opacity-40">
<ChevronLeft size={13} />上一步
</button>
<button type="button" disabled={!next} onClick={() => next && onChange(next)}
className="inline-flex h-8 items-center gap-1 rounded-md bg-[#2d3335] px-3 text-[12px] font-semibold text-white transition hover:bg-[#2d3335]/90 disabled:opacity-40">
下一步<ChevronRight size={13} />
</button>
</div>
)
}

View File

@ -0,0 +1,143 @@
'use client'
import type { InstallationDetailView } from '../types'
// 制作流程五阶段的完成度判定:纯函数,输入工作台现有数据,输出每个阶段的状态与原因。
// 状态含义:done=满足完成条件;partial=已开始但有缺项;empty=未设置。
// 贴图是可选阶段,永远不阻塞流程,只报告设置数量。
export type InstallationStageStatus = 'done' | 'partial' | 'empty'
export interface InstallationStageReadiness {
key: InstallationDetailView
label: string
status: InstallationStageStatus
detail: string
}
export interface InstallationStageReadinessInput {
modelLoaded: boolean
// 爆炸图判定用部件清单(partCatalog.parts,爆炸编辑器直接操作的对象)
parts: Array<{
offset: number[] | [number, number, number]
rotation?: number[] | [number, number, number, number]
nodeTransforms?: Record<string, { offset: number[]; rotation: number[] }>
}>
numberingParts: Array<{
nodeNames: string[]
}>
steps: Array<{ id: string; actions?: unknown[] | undefined }>
materialCount: number
}
// 与 ExplosionEditorPanel.hasPartTransform 同口径:偏移或旋转偏离初始值(ε=0.001)即视为已设置变换。
// nodeTransforms 只认"非恒等"条目——工作流会自动填充恒等变换,不能算已爆炸。
export function hasInstallationPartTransform(part: {
offset: number[] | [number, number, number]
rotation?: number[] | [number, number, number, number]
nodeTransforms?: Record<string, { offset: number[]; rotation: number[] }>
}): boolean {
const hasBaseOffset = (part.offset ?? []).some((value) => Math.abs(value) > 0.001)
const rotation = part.rotation ?? [0, 0, 0, 1]
const hasBaseRotation =
Math.abs(rotation[0]) > 0.001 ||
Math.abs(rotation[1]) > 0.001 ||
Math.abs(rotation[2]) > 0.001 ||
Math.abs((rotation[3] ?? 1) - 1) > 0.001
const hasNodeTransform = Object.values(part.nodeTransforms ?? {}).some((transform) => {
const moved = (transform.offset ?? []).some((value) => Math.abs(value) > 0.001)
const tRotation = transform.rotation ?? [0, 0, 0, 1]
const rotated =
Math.abs(tRotation[0]) > 0.001 ||
Math.abs(tRotation[1]) > 0.001 ||
Math.abs(tRotation[2]) > 0.001 ||
Math.abs((tRotation[3] ?? 1) - 1) > 0.001
return moved || rotated
})
return hasBaseOffset || hasBaseRotation || hasNodeTransform
}
export function computeInstallationStageReadiness(
input: InstallationStageReadinessInput,
): InstallationStageReadiness[] {
// 爆炸图完成口径与编辑器一致:任一部件已记录变换即"爆炸状态已记录"(部件可有意留在原位,不要求全部)
const explodedCount = input.parts.filter(hasInstallationPartTransform).length
const unboundCount = input.numberingParts.filter((part) => part.nodeNames.length === 0).length
const stepsWithoutActions = input.steps.filter(
(step) => !step.actions || step.actions.length === 0,
).length
return [
{
key: 'finished',
label: '成品展示',
status: input.modelLoaded ? 'done' : 'empty',
detail: input.modelLoaded ? '模型已加载' : '先上传模型',
},
{
key: 'exploded',
label: '爆炸图',
status: explodedCount > 0 ? 'done' : 'empty',
detail:
explodedCount > 0
? `爆炸状态已记录(${explodedCount} 项)`
: '未设置爆炸位置',
},
{
key: 'numbering',
label: '部件编号',
status:
input.numberingParts.length === 0
? 'empty'
: unboundCount > 0
? 'partial'
: 'done',
detail:
input.numberingParts.length === 0
? '还没有编号'
: unboundCount > 0
? `还有 ${unboundCount} 个编号没有模型节点`
: `${input.numberingParts.length} 项`,
},
{
key: 'materials',
label: '部件贴图',
status: input.materialCount > 0 ? 'done' : 'empty',
detail: input.materialCount > 0 ? `${input.materialCount} 项已设置` : '未设置(可选)',
},
{
key: 'steps',
label: '安装步骤',
status:
input.steps.length === 0
? 'empty'
: stepsWithoutActions > 0
? 'partial'
: 'done',
detail:
input.steps.length === 0
? '还没有步骤'
: stepsWithoutActions > 0
? `${input.steps.length} 步 · ${stepsWithoutActions} 步未设置动作`
: `${input.steps.length} 步`,
},
]
}
export const INSTALLATION_STAGE_ORDER: InstallationDetailView[] = [
'finished',
'exploded',
'numbering',
'materials',
'steps',
]
export function findAdjacentStage(
current: InstallationDetailView,
direction: -1 | 1,
): InstallationDetailView | null {
const index = INSTALLATION_STAGE_ORDER.indexOf(current)
if (index < 0) return null
const next = index + direction
if (next < 0 || next >= INSTALLATION_STAGE_ORDER.length) return null
return INSTALLATION_STAGE_ORDER[next]
}