feat: land assembly workspace and share fixes
This commit is contained in:
parent
ee5cb3a900
commit
a80cb5498f
@ -12,9 +12,3 @@
|
||||
---
|
||||
|
||||
## 当前未完成项
|
||||
|
||||
### 待前端修正 (前端 UI Agent 负责)
|
||||
|
||||
- [ ] **包材资料库页面视觉联调**
|
||||
归属:`前端负责`
|
||||
说明:确认该页面视觉是否已统一到主线风格。
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { draftGroupService } from '@/server/services'
|
||||
import type { CreateDraftGroupFromAutoCombinationInput } from '@/server/services'
|
||||
import { createDraftGroupSchema } from '../platform/schemas'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@ -38,7 +39,10 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await draftGroupService.createFromAutoCombination(parsed.data, ctx.user)
|
||||
const result = await draftGroupService.createFromAutoCombination(
|
||||
parsed.data as unknown as CreateDraftGroupFromAutoCombinationInput,
|
||||
ctx.user,
|
||||
)
|
||||
return jsonSuccess({ result }, 201, ctx.requestId)
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
|
||||
@ -237,6 +237,8 @@ export const createDraftGroupSchema = z.object({
|
||||
z.object({
|
||||
key: z.string().trim().min(1),
|
||||
defaultName: z.string().trim().min(1),
|
||||
candidateSnapshot: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
assemblyWorkspace: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
familySelection: z.object({
|
||||
desktopFamilyId: z.string().trim().min(1),
|
||||
legGroupFamilyId: z.string().trim().min(1).optional().nullable(),
|
||||
|
||||
@ -177,6 +177,10 @@ function dictionaryLabel(type: SubstrateDictionaryType) {
|
||||
}
|
||||
}
|
||||
|
||||
function isLegacySeedOptionId(id: string) {
|
||||
return id.startsWith('seed_substrate_')
|
||||
}
|
||||
|
||||
export default function MaterialLibraryClient({
|
||||
initialSubstrates,
|
||||
initialFinishes,
|
||||
@ -255,6 +259,33 @@ export default function MaterialLibraryClient({
|
||||
})
|
||||
}, [substrateDictionaries, substrateForm.environmentGradeId, substrateForm.systemNameOptionId, substrateForm.thicknessSpecId])
|
||||
|
||||
const usedDictionaryIds = useMemo(() => {
|
||||
return {
|
||||
CATEGORY: new Set(substrates.map((item) => item.categoryId).filter(Boolean)),
|
||||
ENVIRONMENT_GRADE: new Set(substrates.map((item) => item.environmentGradeId).filter(Boolean)),
|
||||
THICKNESS_SPEC: new Set(substrates.map((item) => item.thicknessSpecId).filter(Boolean)),
|
||||
SURFACE_OPTION: new Set(substrates.map((item) => item.surfaceOptionId).filter(Boolean)),
|
||||
SYSTEM_NAME_OPTION: new Set(substrates.map((item) => item.systemNameOptionId).filter(Boolean)),
|
||||
} as Record<SubstrateDictionaryType, Set<string>>
|
||||
}, [substrates])
|
||||
|
||||
const visibleDictionaryOptions = useMemo(() => {
|
||||
const filterOptions = (type: SubstrateDictionaryType, items: SubstrateDictionaryOptionDTO[]) =>
|
||||
items.filter((item) => !isLegacySeedOptionId(item.id) || usedDictionaryIds[type].has(item.id))
|
||||
return {
|
||||
categories: filterOptions('CATEGORY', substrateDictionaries.categories),
|
||||
environmentGrades: filterOptions('ENVIRONMENT_GRADE', substrateDictionaries.environmentGrades),
|
||||
thicknessSpecs: filterOptions('THICKNESS_SPEC', substrateDictionaries.thicknessSpecs),
|
||||
surfaceOptions: filterOptions('SURFACE_OPTION', substrateDictionaries.surfaceOptions),
|
||||
systemNameOptions: filterOptions('SYSTEM_NAME_OPTION', substrateDictionaries.systemNameOptions),
|
||||
}
|
||||
}, [substrateDictionaries, usedDictionaryIds])
|
||||
|
||||
const activeDictionaryOptions = useMemo(
|
||||
() => dictionaryOptionsByType(substrateDictionaries, activeDictionaryType),
|
||||
[activeDictionaryType, substrateDictionaries],
|
||||
)
|
||||
|
||||
const filteredFinishes = useMemo(() => {
|
||||
const keyword = searchText.trim().toLowerCase()
|
||||
return finishes.filter((item) => {
|
||||
@ -270,7 +301,16 @@ export default function MaterialLibraryClient({
|
||||
function openCreateSubstrateModal() {
|
||||
setSubstrateModalMode('create')
|
||||
setEditingSubstrateId(null)
|
||||
setSubstrateForm(emptySubstrateForm(substrateDictionaries))
|
||||
setSubstrateForm({
|
||||
code: '',
|
||||
description: '',
|
||||
categoryId: visibleDictionaryOptions.categories[0]?.id ?? '',
|
||||
environmentGradeId: visibleDictionaryOptions.environmentGrades[0]?.id ?? '',
|
||||
thicknessSpecId: visibleDictionaryOptions.thicknessSpecs[0]?.id ?? '',
|
||||
surfaceOptionId: visibleDictionaryOptions.surfaceOptions[0]?.id ?? '',
|
||||
systemNameOptionId: visibleDictionaryOptions.systemNameOptions[0]?.id ?? '',
|
||||
suggestedPrice: '',
|
||||
})
|
||||
setSubstrateModalOpen(true)
|
||||
}
|
||||
|
||||
@ -366,6 +406,12 @@ export default function MaterialLibraryClient({
|
||||
setDictionaryModalOpen(true)
|
||||
}
|
||||
|
||||
function beginCreateDictionaryOption() {
|
||||
setDictionaryModalMode('create')
|
||||
setEditingDictionaryId(null)
|
||||
setDictionaryForm(emptyDictionaryForm())
|
||||
}
|
||||
|
||||
async function handleSaveDictionary() {
|
||||
if (!dictionaryForm.name.trim()) {
|
||||
toast.error(`请填写${dictionaryLabel(activeDictionaryType)}名称`)
|
||||
@ -704,9 +750,9 @@ export default function MaterialLibraryClient({
|
||||
</div>
|
||||
{activeTab === 'substrate' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex gap-2 overflow-x-auto whitespace-nowrap pb-1">
|
||||
{(['CATEGORY', 'ENVIRONMENT_GRADE', 'THICKNESS_SPEC', 'SURFACE_OPTION', 'SYSTEM_NAME_OPTION'] as SubstrateDictionaryType[]).map((type) => (
|
||||
<Button key={type} variant="plain" size="small" onClick={() => openDictionaryModal(type)}>
|
||||
<Button key={type} variant="plain" size="small" onClick={() => openDictionaryModal(type)} className="shrink-0">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
管理{dictionaryLabel(type)}
|
||||
</Button>
|
||||
@ -803,18 +849,49 @@ export default function MaterialLibraryClient({
|
||||
<Input label="基材名称" value={generatedSubstrateName} readOnly placeholder="按规则自动生成" />
|
||||
</div>
|
||||
<div className="grid gap-3 grid-cols-2">
|
||||
<Select label="材质主类" value={substrateForm.categoryId} onChange={(event) => setSubstrateForm((current) => ({ ...current, categoryId: event.target.value }))} options={substrateDictionaries.categories.map((item) => ({ value: item.id, label: item.name }))} />
|
||||
<Select label="环保等级" value={substrateForm.environmentGradeId} onChange={(event) => setSubstrateForm((current) => ({ ...current, environmentGradeId: event.target.value }))} options={substrateDictionaries.environmentGrades.map((item) => ({ value: item.id, label: item.name }))} />
|
||||
<Select label="厚度规格" value={substrateForm.thicknessSpecId} onChange={(event) => setSubstrateForm((current) => ({ ...current, thicknessSpecId: event.target.value }))} options={substrateDictionaries.thicknessSpecs.map((item) => ({ value: item.id, label: item.name }))} />
|
||||
<Select label="贴面类型" value={substrateForm.surfaceOptionId} onChange={(event) => setSubstrateForm((current) => ({ ...current, surfaceOptionId: event.target.value }))} options={substrateDictionaries.surfaceOptions.map((item) => ({ value: item.id, label: item.name }))} />
|
||||
<Select label="系统名称" value={substrateForm.systemNameOptionId} onChange={(event) => setSubstrateForm((current) => ({ ...current, systemNameOptionId: event.target.value }))} options={substrateDictionaries.systemNameOptions.map((item) => ({ value: item.id, label: item.name }))} />
|
||||
<Select label="材质主类" value={substrateForm.categoryId} onChange={(event) => setSubstrateForm((current) => ({ ...current, categoryId: event.target.value }))} options={visibleDictionaryOptions.categories.map((item) => ({ value: item.id, label: item.name }))} />
|
||||
<Select label="环保等级" value={substrateForm.environmentGradeId} onChange={(event) => setSubstrateForm((current) => ({ ...current, environmentGradeId: event.target.value }))} options={visibleDictionaryOptions.environmentGrades.map((item) => ({ value: item.id, label: item.name }))} />
|
||||
<Select label="厚度规格" value={substrateForm.thicknessSpecId} onChange={(event) => setSubstrateForm((current) => ({ ...current, thicknessSpecId: event.target.value }))} options={visibleDictionaryOptions.thicknessSpecs.map((item) => ({ value: item.id, label: item.name }))} />
|
||||
<Select label="贴面类型" value={substrateForm.surfaceOptionId} onChange={(event) => setSubstrateForm((current) => ({ ...current, surfaceOptionId: event.target.value }))} options={visibleDictionaryOptions.surfaceOptions.map((item) => ({ value: item.id, label: item.name }))} />
|
||||
<Select label="系统名称" value={substrateForm.systemNameOptionId} onChange={(event) => setSubstrateForm((current) => ({ ...current, systemNameOptionId: event.target.value }))} options={visibleDictionaryOptions.systemNameOptions.map((item) => ({ value: item.id, label: item.name }))} />
|
||||
<Input label="建议售价" value={substrateForm.suggestedPrice} onChange={(event) => setSubstrateForm((current) => ({ ...current, suggestedPrice: event.target.value }))} placeholder="例如 180" />
|
||||
</div>
|
||||
<Input label="备注" value={substrateForm.description} onChange={(event) => setSubstrateForm((current) => ({ ...current, description: event.target.value }))} placeholder="可选" />
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal isOpen={dictionaryModalOpen} onClose={() => setDictionaryModalOpen(false)} title={`${dictionaryModalMode === 'create' ? '新增' : '编辑'}${dictionaryLabel(activeDictionaryType)}`} size="small" footer={<div className="flex justify-end gap-2"><Button variant="plain" size="small" onClick={() => setDictionaryModalOpen(false)}>取消</Button><Button size="small" onClick={handleSaveDictionary} loading={submitting}>{dictionaryModalMode === 'create' ? '新增' : '保存'}</Button></div>}>
|
||||
<Modal isOpen={dictionaryModalOpen} onClose={() => setDictionaryModalOpen(false)} title={`管理${dictionaryLabel(activeDictionaryType)}`} size="medium" footer={<div className="flex justify-end gap-2"><Button variant="plain" size="small" onClick={() => setDictionaryModalOpen(false)}>取消</Button><Button size="small" onClick={handleSaveDictionary} loading={submitting}>{dictionaryModalMode === 'create' ? '新增' : '保存修改'}</Button></div>}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-apple-md border border-apple-separator bg-apple-bg-secondary/40 px-3 py-2">
|
||||
<p className="text-xs text-apple-label-secondary">在这里可以查看已有选项,并直接新增/编辑/删除。</p>
|
||||
<Button variant="plain" size="small" onClick={beginCreateDictionaryOption}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-40 space-y-2 overflow-y-auto rounded-apple-md border border-apple-separator bg-apple-bg-primary p-2">
|
||||
{activeDictionaryOptions.length === 0 ? (
|
||||
<p className="px-2 py-2 text-xs text-apple-label-tertiary">暂无选项</p>
|
||||
) : activeDictionaryOptions.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-2 rounded-apple-sm border border-apple-separator/70 px-2 py-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-apple-label-primary">
|
||||
{item.name}
|
||||
{isLegacySeedOptionId(item.id) ? '(历史默认)' : ''}
|
||||
</p>
|
||||
<p className="text-[11px] text-apple-label-tertiary">
|
||||
排序 {item.sortOrder}
|
||||
{item.type === 'THICKNESS_SPEC' && item.thicknessMm != null ? ` · ${item.thicknessMm}mm` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => openDictionaryModal(activeDictionaryType, item)} className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary" title="编辑">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={() => setDeleteTarget({ type: 'dictionary', id: item.id, name: item.name, dictionaryType: activeDictionaryType })} className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary hover:text-apple-red" title="删除">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Input label="名称" value={dictionaryForm.name} onChange={(event) => setDictionaryForm((current) => ({ ...current, name: event.target.value }))} />
|
||||
<Input label="排序值" value={dictionaryForm.sortOrder} onChange={(event) => setDictionaryForm((current) => ({ ...current, sortOrder: event.target.value }))} />
|
||||
{activeDictionaryType === 'THICKNESS_SPEC' ? <Input label="厚度 mm" value={dictionaryForm.thicknessMm} onChange={(event) => setDictionaryForm((current) => ({ ...current, thicknessMm: event.target.value }))} /> : null}
|
||||
|
||||
@ -1748,24 +1748,21 @@ export default function PackagingMaterialsClient({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="编辑"
|
||||
className="rounded-apple-md p-1.5 text-apple-label-secondary transition hover:bg-apple-blue/10 hover:text-apple-blue"
|
||||
onClick={() => startEditVariant(variant)}
|
||||
className="rounded-apple-md p-1.5 text-apple-label-primary/60 transition hover:bg-apple-bg-secondary hover:text-apple-label-primary bg-apple-bg-secondary/40 shadow-apple-sm border border-apple-separator/10" onClick={() => startEditVariant(variant)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="复制"
|
||||
className="rounded-apple-md p-1.5 text-apple-label-secondary transition hover:bg-apple-blue/10 hover:text-apple-blue"
|
||||
onClick={() => openCreateDrawer(variant)}
|
||||
className="rounded-apple-md p-1.5 text-apple-label-primary/60 transition hover:bg-apple-bg-secondary hover:text-apple-label-primary bg-apple-bg-secondary/40 shadow-apple-sm border border-apple-separator/10" onClick={() => openCreateDrawer(variant)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="删除包材记录"
|
||||
className="rounded-apple-md p-1.5 text-apple-label-secondary transition hover:bg-apple-red/10 hover:text-apple-red"
|
||||
onClick={() =>
|
||||
className="rounded-apple-md p-1.5 text-apple-label-primary/60 transition hover:bg-apple-red/10 hover:text-apple-red bg-apple-bg-secondary/40 shadow-apple-sm border border-apple-separator/10" onClick={() =>
|
||||
setDeleteTarget({
|
||||
type: 'variant',
|
||||
id: variant.id,
|
||||
|
||||
@ -16,6 +16,7 @@ import Button from '@/components/ui/Button'
|
||||
import Checkbox from '@/components/ui/Checkbox'
|
||||
import Input, { Textarea } from '@/components/ui/Input'
|
||||
import { AccordionSection } from '@/components/platform/AccordionSection'
|
||||
import { AssemblyWorkbench } from '@/components/platform/AssemblyWorkbench'
|
||||
import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel'
|
||||
import { PlatformSplitLayout } from '@/components/platform/PlatformSplitLayout'
|
||||
import type { PlatformMetricItem } from '@/components/platform/types'
|
||||
@ -24,6 +25,7 @@ import {
|
||||
generateAutoCombinations,
|
||||
} from '@/lib/open-product/platform-api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AssemblyWorkspaceSnapshot } from '@/lib/open-product/assembly-workspace'
|
||||
import type { AssetFamilyDTO, SeriesTemplateDTO } from '@/server/services'
|
||||
import type { CombinationCandidateDTO } from '@/server/services'
|
||||
|
||||
@ -82,6 +84,9 @@ export default function AutoCombinationsClient({
|
||||
} | null>(null)
|
||||
const [candidatesExpanded, setCandidatesExpanded] = useState(false)
|
||||
const [previewCandidateKey, setPreviewCandidateKey] = useState<string | null>(null)
|
||||
const [candidateWorkspaces, setCandidateWorkspaces] = useState<
|
||||
Record<string, AssemblyWorkspaceSnapshot>
|
||||
>({})
|
||||
|
||||
const selectedTemplate = templates.find((t) => t.id === templateId)
|
||||
const selectedTemplateCode = selectedTemplate?.code ?? null
|
||||
@ -137,8 +142,17 @@ export default function AutoCombinationsClient({
|
||||
|
||||
startTransition(() => {
|
||||
setCandidates(result.candidates)
|
||||
setCandidateWorkspaces(
|
||||
Object.fromEntries(
|
||||
result.candidates.map((candidate) => [
|
||||
candidate.key,
|
||||
candidate.assemblyWorkspace,
|
||||
]),
|
||||
),
|
||||
)
|
||||
setSelectedCandidateKeys([])
|
||||
setCandidatesExpanded(true)
|
||||
setPreviewCandidateKey(result.candidates[0]?.key ?? null)
|
||||
if (!draftGroupName) {
|
||||
setDraftGroupName(
|
||||
`${templates.find((template) => template.id === templateId)?.seriesName || '新系列'}-草稿组`,
|
||||
@ -170,6 +184,9 @@ export default function AutoCombinationsClient({
|
||||
candidates: selectedCandidates.map((candidate) => ({
|
||||
key: candidate.key,
|
||||
defaultName: candidate.defaultName,
|
||||
candidateSnapshot: candidate.candidateSnapshot,
|
||||
assemblyWorkspace:
|
||||
candidateWorkspaces[candidate.key] ?? candidate.assemblyWorkspace,
|
||||
familySelection: candidate.familySelection,
|
||||
})),
|
||||
})
|
||||
@ -191,54 +208,37 @@ export default function AutoCombinationsClient({
|
||||
...connectorFamilies.filter((f) => connectorIds.includes(f.id)),
|
||||
]
|
||||
const previewCandidate = candidates.find((c) => c.key === previewCandidateKey) ?? null
|
||||
const previewWorkspace =
|
||||
(previewCandidateKey ? candidateWorkspaces[previewCandidateKey] : null) ??
|
||||
previewCandidate?.assemblyWorkspace ??
|
||||
null
|
||||
|
||||
const hasAnySelection = templateId || allSelectedFamilies.length > 0 || candidates.length > 0
|
||||
|
||||
const leftContent = (
|
||||
<div className="flex h-full flex-col gap-3 overflow-y-auto p-2">
|
||||
{previewCandidate ? (
|
||||
<>
|
||||
{/* 选中候选的 3D 预览 */}
|
||||
previewWorkspace ? (
|
||||
<AssemblyWorkbench
|
||||
value={previewWorkspace}
|
||||
candidateName={previewCandidate.defaultName}
|
||||
statusTag={previewCandidate.statusTag}
|
||||
warnings={previewCandidate.assemblyWarnings}
|
||||
onChange={(next) =>
|
||||
setCandidateWorkspaces((current) => ({
|
||||
...current,
|
||||
[previewCandidate.key]: next,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ModelPreviewPanel
|
||||
previewNodes={previewCandidate.previewNodes}
|
||||
emptyTitle="暂无可预览的组合"
|
||||
emptyDescription="该候选缺少 3D 预览数据,请检查资产族规格款是否齐全。"
|
||||
className="min-h-[280px] flex-1"
|
||||
/>
|
||||
|
||||
{/* 组合关系展示 */}
|
||||
{previewCandidate.combinationItems.length > 0 && (
|
||||
<div className="rounded-lg border p-3" style={{ borderColor: 'var(--color-outline-variant)', backgroundColor: 'var(--color-surface-container-low)' }}>
|
||||
<p className="mb-2 text-xs font-semibold" style={{ color: 'var(--color-label-secondary)' }}>组合关系</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{previewCandidate.combinationItems.map((item, i) => (
|
||||
<span
|
||||
key={`${item.label}-${i}`}
|
||||
className="rounded-full px-2.5 py-0.5 text-xs"
|
||||
style={{
|
||||
backgroundColor: item.role === 'desktop' ? 'var(--color-blue)' : 'var(--color-surface-container)',
|
||||
color: item.role === 'desktop' ? 'white' : 'var(--color-label-primary)',
|
||||
}}
|
||||
>
|
||||
{item.label}:{item.value}{item.quantity > 1 ? ` ×${item.quantity}` : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 候选状态 */}
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Badge type={previewCandidate.assemblyResultStatus === 'BLOCKED' ? 'warning' : 'success'} variant="subtle" size="small">
|
||||
{previewCandidate.statusTag}
|
||||
</Badge>
|
||||
{previewCandidate.assemblyWarnings.length > 0 && (
|
||||
<span className="text-xs" style={{ color: 'var(--color-label-tertiary)' }}>
|
||||
{previewCandidate.assemblyWarnings.length} 条警告
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : candidates.length > 0 ? (
|
||||
/* 有候选但没选中的引导 */
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
@ -246,10 +246,10 @@ export default function AutoCombinationsClient({
|
||||
<Layers3 className="h-7 w-7" strokeWidth={1.2} />
|
||||
</div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--color-label-secondary)' }}>
|
||||
点击右侧候选卡片预览 3D 组合
|
||||
点击右侧候选卡片进入装配工作台
|
||||
</p>
|
||||
<p className="max-w-[260px] text-xs leading-5" style={{ color: 'var(--color-label-tertiary)' }}>
|
||||
已生成 {candidates.length} 条候选,点击任意一条即可在此处查看 3D 拼装效果和组合关系。
|
||||
已生成 {candidates.length} 条候选,点击任意一条即可在此处完成待装区、分子装配区和主装配区的装配确认。
|
||||
</p>
|
||||
</div>
|
||||
) : !hasAnySelection ? (
|
||||
@ -260,7 +260,7 @@ export default function AutoCombinationsClient({
|
||||
</div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--color-label-secondary)' }}>从右侧面板开始配置</p>
|
||||
<p className="max-w-[260px] text-xs leading-5" style={{ color: 'var(--color-label-tertiary)' }}>
|
||||
选择系列模板并勾选资产族后,生成候选并点击即可预览 3D 组合。
|
||||
选择系列模板并勾选资产族后,生成候选并点击即可进入辅助式手动装配。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@ -273,7 +273,7 @@ export default function AutoCombinationsClient({
|
||||
配置就绪,点击右侧「生成候选列表」
|
||||
</p>
|
||||
<p className="max-w-[260px] text-xs leading-5" style={{ color: 'var(--color-label-tertiary)' }}>
|
||||
已选 {allSelectedFamilies.length} 个资产族,生成后可逐条预览 3D 组合效果。
|
||||
已选 {allSelectedFamilies.length} 个资产族,生成后可逐条进入装配工作台确认最终结构。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -479,7 +479,7 @@ export default function AutoCombinationsClient({
|
||||
{/* 候选结果 */}
|
||||
<AccordionSection
|
||||
title="候选结果"
|
||||
description="点击候选卡片即可在左侧预览 3D 拼装效果和真实组合关系。满意的候选勾选后统一保存到草稿组。"
|
||||
description="点击候选卡片即可在左侧进入装配工作台。满意的候选勾选后统一保存到草稿组。"
|
||||
icon={<Layers3 className="h-4 w-4" />}
|
||||
aside={
|
||||
candidates.length > 0 ? (
|
||||
@ -494,6 +494,7 @@ export default function AutoCombinationsClient({
|
||||
{candidates.length === 0 ? (
|
||||
<div className="rounded-apple-xl border border-dashed border-apple-separator bg-apple-bg-primary/60 px-4 py-6 text-sm leading-7 text-apple-label-secondary">
|
||||
还没有候选结果。先选好模板和资产族,生成后点击候选卡片即可预览 3D 拼装效果。
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@ -550,6 +551,29 @@ export default function AutoCombinationsClient({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge
|
||||
type={
|
||||
(candidateWorkspaces[candidate.key] ?? candidate.assemblyWorkspace).validation.level ===
|
||||
'BLOCKED'
|
||||
? 'warning'
|
||||
: (candidateWorkspaces[candidate.key] ?? candidate.assemblyWorkspace).validation.level ===
|
||||
'WARNING'
|
||||
? 'info'
|
||||
: 'success'
|
||||
}
|
||||
variant="subtle"
|
||||
>
|
||||
工作台
|
||||
{(candidateWorkspaces[candidate.key] ?? candidate.assemblyWorkspace).validation.level ===
|
||||
'PASSED'
|
||||
? '已成立'
|
||||
: (candidateWorkspaces[candidate.key] ?? candidate.assemblyWorkspace).validation.level ===
|
||||
'WARNING'
|
||||
? '有警告'
|
||||
: '未成立'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -91,7 +91,7 @@ export function HomeModelsSection({
|
||||
</Body>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-apple-separator">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-6 bg-apple-bg-secondary/30">
|
||||
{filteredProducts.map((product) => (
|
||||
<ProductListItem
|
||||
key={product.id}
|
||||
|
||||
@ -19,6 +19,11 @@ interface ShareLinkData {
|
||||
viewCount: number
|
||||
}
|
||||
|
||||
interface ShareLinkApiPayload {
|
||||
shareLink: ShareLinkData
|
||||
createdBy?: string
|
||||
}
|
||||
|
||||
// SKU 数据类型
|
||||
interface SKUData {
|
||||
id: string
|
||||
@ -174,6 +179,49 @@ export default function ProductDetailClient({
|
||||
setTimeout(() => setCopiedToken(null), 2000)
|
||||
}, [])
|
||||
|
||||
const unwrapShareLinkPayload = useCallback((payload: unknown): ShareLinkApiPayload => {
|
||||
const resolved =
|
||||
payload &&
|
||||
typeof payload === 'object' &&
|
||||
'data' in payload &&
|
||||
payload.data &&
|
||||
typeof payload.data === 'object'
|
||||
? payload.data
|
||||
: payload
|
||||
|
||||
if (
|
||||
!resolved ||
|
||||
typeof resolved !== 'object' ||
|
||||
!('shareLink' in resolved) ||
|
||||
!resolved.shareLink ||
|
||||
typeof resolved.shareLink !== 'object'
|
||||
) {
|
||||
throw new Error('分享链接返回数据不完整')
|
||||
}
|
||||
|
||||
const shareLink = resolved.shareLink as Record<string, unknown>
|
||||
if (
|
||||
typeof shareLink.id !== 'string' ||
|
||||
typeof shareLink.token !== 'string' ||
|
||||
typeof shareLink.viewCount !== 'number'
|
||||
) {
|
||||
throw new Error('分享链接返回字段缺失')
|
||||
}
|
||||
|
||||
return {
|
||||
shareLink: {
|
||||
id: shareLink.id,
|
||||
token: shareLink.token,
|
||||
expiresAt: typeof shareLink.expiresAt === 'string' ? shareLink.expiresAt : null,
|
||||
viewCount: shareLink.viewCount,
|
||||
},
|
||||
createdBy:
|
||||
'createdBy' in resolved && typeof resolved.createdBy === 'string'
|
||||
? resolved.createdBy
|
||||
: undefined,
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 创建分享链接
|
||||
const handleCreateShareLink = useCallback(async () => {
|
||||
setIsCreatingLink(true)
|
||||
@ -195,12 +243,14 @@ export default function ProductDetailClient({
|
||||
throw new Error(getApiErrorMessage(data.error, '创建分享链接失败'))
|
||||
}
|
||||
|
||||
const payload = unwrapShareLinkPayload(data)
|
||||
|
||||
// 添加新链接到列表
|
||||
const newLink: ShareLinkData = {
|
||||
id: data.shareLink.id,
|
||||
token: data.shareLink.token,
|
||||
expiresAt: data.shareLink.expiresAt,
|
||||
viewCount: data.shareLink.viewCount,
|
||||
id: payload.shareLink.id,
|
||||
token: payload.shareLink.token,
|
||||
expiresAt: payload.shareLink.expiresAt,
|
||||
viewCount: payload.shareLink.viewCount,
|
||||
}
|
||||
setLinks(prev => [newLink, ...prev])
|
||||
setShowCreateModal(false)
|
||||
@ -213,7 +263,7 @@ export default function ProductDetailClient({
|
||||
} finally {
|
||||
setIsCreatingLink(false)
|
||||
}
|
||||
}, [product.id, expiresInDays, router])
|
||||
}, [product.id, expiresInDays, router, unwrapShareLinkPayload])
|
||||
|
||||
// 删除分享链接
|
||||
const handleDeleteShareLink = useCallback(async (linkId: string) => {
|
||||
|
||||
@ -21,6 +21,8 @@ export function ProductDetailTopBar({
|
||||
hasAssemblyPreview,
|
||||
previewToken,
|
||||
}: ProductDetailTopBarProps) {
|
||||
const previewHref = previewToken ? `/share/${previewToken}` : null
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
@ -45,9 +47,9 @@ export function ProductDetailTopBar({
|
||||
>
|
||||
编辑信息
|
||||
</Link>
|
||||
{hasAssemblyPreview && (
|
||||
{hasAssemblyPreview && previewHref && (
|
||||
<Link
|
||||
href={`/share/${previewToken || ''}`}
|
||||
href={previewHref}
|
||||
target="_blank"
|
||||
className="px-4 py-2 text-apple-md font-apple-system font-apple-medium tracking-apple-tighter text-white bg-apple-blue hover:opacity-90 rounded-apple-md transition-colors shadow-xs"
|
||||
>
|
||||
|
||||
@ -0,0 +1,362 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { CheckCircle2, MoveRight, PackagePlus, RotateCcw, Workflow } from 'lucide-react'
|
||||
import type {
|
||||
AssemblyWorkspaceNode,
|
||||
AssemblyWorkspaceSnapshot,
|
||||
} from '@/lib/open-product/assembly-workspace'
|
||||
import {
|
||||
applyNodeRoleConfirmation,
|
||||
attemptSnapNode,
|
||||
createSubassembly,
|
||||
moveAssemblyNodes,
|
||||
snapNodesIntoMainAssembly,
|
||||
updateAssemblyNodePosition,
|
||||
} from '@/lib/open-product/assembly-workspace'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Checkbox from '@/components/ui/Checkbox'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const AssemblyWorkbenchCanvas = dynamic(
|
||||
() =>
|
||||
import('./AssemblyWorkbenchCanvas').then((module) => ({
|
||||
default: module.AssemblyWorkbenchCanvas,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full items-center justify-center text-sm" style={{ color: 'var(--color-label-secondary)' }}>
|
||||
装配工作区加载中...
|
||||
</div>
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
interface AssemblyWorkbenchProps {
|
||||
value: AssemblyWorkspaceSnapshot
|
||||
candidateName: string
|
||||
statusTag: string
|
||||
warnings: string[]
|
||||
onChange: (next: AssemblyWorkspaceSnapshot) => void
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'desktop', label: '桌面板' },
|
||||
{ value: 'frontLeft', label: '前左腿' },
|
||||
{ value: 'frontRight', label: '前右腿' },
|
||||
{ value: 'rearLeft', label: '后左腿' },
|
||||
{ value: 'rearRight', label: '后右腿' },
|
||||
]
|
||||
|
||||
function getValidationTone(level: AssemblyWorkbenchProps['value']['validation']['level']) {
|
||||
if (level === 'PASSED') return 'success'
|
||||
if (level === 'WARNING') return 'warning'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
function getValidationLabel(level: AssemblyWorkbenchProps['value']['validation']['level']) {
|
||||
if (level === 'PASSED') return '成立'
|
||||
if (level === 'WARNING') return '有警告'
|
||||
return '不成立'
|
||||
}
|
||||
|
||||
function ZoneColumn({
|
||||
title,
|
||||
description,
|
||||
nodes,
|
||||
selectedNodeIds,
|
||||
onToggle,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
nodes: AssemblyWorkspaceNode[]
|
||||
selectedNodeIds: string[]
|
||||
onToggle: (nodeId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-apple-xl border border-apple-separator bg-apple-bg-primary/90 p-4">
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-apple-semibold text-apple-label-primary">{title}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-apple-label-secondary">{description}</p>
|
||||
</div>
|
||||
|
||||
{nodes.length === 0 ? (
|
||||
<div className="rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/60 px-3 py-4 text-xs text-apple-label-tertiary">
|
||||
当前没有部件
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{nodes.map((node) => (
|
||||
<div
|
||||
key={node.nodeId}
|
||||
className={cn(
|
||||
'rounded-apple-lg border px-3 py-3',
|
||||
selectedNodeIds.includes(node.nodeId)
|
||||
? 'border-apple-blue bg-apple-blue/5'
|
||||
: 'border-apple-separator bg-apple-bg-secondary/60',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedNodeIds.includes(node.nodeId)}
|
||||
onChange={() => onToggle(node.nodeId)}
|
||||
label={node.name}
|
||||
description={
|
||||
[
|
||||
node.roleConfirmed ? `已确认:${node.roleConfirmed}` : null,
|
||||
node.roleRecommendation ? `推荐:${node.roleRecommendation}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / ')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AssemblyWorkbench({
|
||||
value,
|
||||
candidateName,
|
||||
statusTag,
|
||||
warnings,
|
||||
onChange,
|
||||
}: AssemblyWorkbenchProps) {
|
||||
const [selectedNodeIds, setSelectedNodeIds] = useState<string[]>([])
|
||||
const workspaceRef = useRef(value)
|
||||
|
||||
useEffect(() => {
|
||||
workspaceRef.current = value
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedNodeIds([])
|
||||
}, [value.version, candidateName])
|
||||
|
||||
const selectedNodes = value.nodes.filter((node) => selectedNodeIds.includes(node.nodeId))
|
||||
const selectedSingleNode = selectedNodes.length === 1 ? selectedNodes[0] : null
|
||||
const stagingNodes = value.nodes.filter((node) => node.zone === 'staging')
|
||||
const subassemblyNodes = value.nodes.filter((node) => node.zone === 'subassembly')
|
||||
const mainNodes = value.nodes.filter((node) => node.zone === 'main')
|
||||
const connectedRelations = value.relations.filter((relation) => relation.status === 'CONNECTED')
|
||||
const suggestedFastenerCount = connectedRelations.reduce(
|
||||
(sum, relation) =>
|
||||
sum + relation.suggestedFasteners.reduce((inner, item) => inner + item.quantity, 0),
|
||||
0,
|
||||
)
|
||||
|
||||
const validationMessages = useMemo(
|
||||
() => [...value.validation.blockingIssues, ...value.validation.warnings],
|
||||
[value.validation.blockingIssues, value.validation.warnings],
|
||||
)
|
||||
|
||||
function toggleNode(nodeId: string) {
|
||||
setSelectedNodeIds((current) =>
|
||||
current.includes(nodeId)
|
||||
? current.filter((id) => id !== nodeId)
|
||||
: [...current, nodeId],
|
||||
)
|
||||
}
|
||||
|
||||
function replaceWorkspace(next: AssemblyWorkspaceSnapshot) {
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
function handleMove(zone: 'staging' | 'subassembly' | 'main') {
|
||||
if (selectedNodeIds.length === 0) return
|
||||
replaceWorkspace(moveAssemblyNodes(value, selectedNodeIds, zone))
|
||||
}
|
||||
|
||||
function handleSnap() {
|
||||
if (selectedNodeIds.length === 0) return
|
||||
replaceWorkspace(snapNodesIntoMainAssembly(value, selectedNodeIds))
|
||||
}
|
||||
|
||||
function handleCreateSubassembly() {
|
||||
if (selectedNodeIds.length < 2) return
|
||||
replaceWorkspace(createSubassembly(value, selectedNodeIds))
|
||||
}
|
||||
|
||||
function handleRoleConfirm(role: string) {
|
||||
if (!selectedSingleNode) return
|
||||
replaceWorkspace(applyNodeRoleConfirmation(value, selectedSingleNode.nodeId, role))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 overflow-y-auto p-2">
|
||||
<div className="rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-4 shadow-apple-sm">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-apple-semibold uppercase tracking-[0.16em] text-apple-label-tertiary">
|
||||
自动组合工作台
|
||||
</p>
|
||||
<h3 className="mt-1 text-lg font-apple-semibold text-apple-label-primary">
|
||||
{candidateName}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-apple-label-secondary">
|
||||
现在不是自动拼完,而是系统先给候选和吸附建议。部件可以直接在画布里拖动,拖到目标位附近后会按挂载点/目标位尝试吸附。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge type={getValidationTone(value.validation.level)} variant="subtle">
|
||||
{getValidationLabel(value.validation.level)}
|
||||
</Badge>
|
||||
<Badge type="default" variant="subtle">
|
||||
{statusTag}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-apple-lg bg-apple-bg-secondary/70 px-3 py-3">
|
||||
<p className="text-xs text-apple-label-tertiary">待装区</p>
|
||||
<p className="mt-1 text-xl font-apple-semibold text-apple-label-primary">{stagingNodes.length}</p>
|
||||
</div>
|
||||
<div className="rounded-apple-lg bg-apple-bg-secondary/70 px-3 py-3">
|
||||
<p className="text-xs text-apple-label-tertiary">分子装配区</p>
|
||||
<p className="mt-1 text-xl font-apple-semibold text-apple-label-primary">{subassemblyNodes.length}</p>
|
||||
</div>
|
||||
<div className="rounded-apple-lg bg-apple-bg-secondary/70 px-3 py-3">
|
||||
<p className="text-xs text-apple-label-tertiary">主装配区</p>
|
||||
<p className="mt-1 text-xl font-apple-semibold text-apple-label-primary">{mainNodes.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-4 shadow-apple-sm">
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<Button type="button" size="small" variant="plain" onClick={() => handleMove('staging')}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
回待装区
|
||||
</Button>
|
||||
<Button type="button" size="small" variant="plain" onClick={() => handleMove('subassembly')}>
|
||||
<PackagePlus className="h-4 w-4" />
|
||||
送去分子装配区
|
||||
</Button>
|
||||
<Button type="button" size="small" variant="plain" onClick={() => handleMove('main')}>
|
||||
<MoveRight className="h-4 w-4" />
|
||||
送去主装配区
|
||||
</Button>
|
||||
<Button type="button" size="small" onClick={handleSnap}>
|
||||
<Workflow className="h-4 w-4" />
|
||||
智能吸附
|
||||
</Button>
|
||||
<Button type="button" size="small" variant="plain" onClick={handleCreateSubassembly}>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
封装成模块
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedSingleNode && (
|
||||
<div className="mb-3 rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/70 px-3 py-3">
|
||||
<p className="text-xs text-apple-label-tertiary">角色确认</p>
|
||||
<p className="mt-1 text-sm text-apple-label-secondary">
|
||||
系统当前推荐:{selectedSingleNode.roleRecommendation ?? '未推荐'},你可以在这里轻确认。
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{ROLE_OPTIONS.filter((option) =>
|
||||
selectedSingleNode.source === 'desktop'
|
||||
? option.value === 'desktop'
|
||||
: option.value !== 'desktop',
|
||||
).map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
size="small"
|
||||
variant={
|
||||
selectedSingleNode.roleConfirmed === option.value ? 'filled' : 'plain'
|
||||
}
|
||||
onClick={() => handleRoleConfirm(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-[360px] overflow-hidden rounded-apple-xl border border-apple-separator bg-[#f4f4f1]">
|
||||
<AssemblyWorkbenchCanvas
|
||||
nodes={value.nodes}
|
||||
selectedNodeIds={selectedNodeIds}
|
||||
onSelectNode={(nodeId) => setSelectedNodeIds([nodeId])}
|
||||
onMoveNode={(nodeId, position) =>
|
||||
replaceWorkspace(
|
||||
updateAssemblyNodePosition(workspaceRef.current, nodeId, position),
|
||||
)
|
||||
}
|
||||
onDragEndNode={(nodeId) =>
|
||||
replaceWorkspace(attemptSnapNode(workspaceRef.current, nodeId))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-3">
|
||||
<ZoneColumn
|
||||
title="待装区"
|
||||
description="还没进入装配流程的部件先放这里。"
|
||||
nodes={stagingNodes}
|
||||
selectedNodeIds={selectedNodeIds}
|
||||
onToggle={toggleNode}
|
||||
/>
|
||||
<ZoneColumn
|
||||
title="分子装配区"
|
||||
description="稳定模块先在这里封装,再送到总装。"
|
||||
nodes={subassemblyNodes}
|
||||
selectedNodeIds={selectedNodeIds}
|
||||
onToggle={toggleNode}
|
||||
/>
|
||||
<ZoneColumn
|
||||
title="主装配区"
|
||||
description="主件与最终成品结构在这里确认。"
|
||||
nodes={mainNodes}
|
||||
selectedNodeIds={selectedNodeIds}
|
||||
onToggle={toggleNode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-4 shadow-apple-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-apple-semibold text-apple-label-primary">装配校验</p>
|
||||
<p className="mt-1 text-xs leading-5 text-apple-label-secondary">
|
||||
只拦截致命错误,其他问题先给警告,不会一刀切把流程卡死。
|
||||
</p>
|
||||
</div>
|
||||
<Badge type={getValidationTone(value.validation.level)} variant="subtle">
|
||||
{getValidationLabel(value.validation.level)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{validationMessages.length === 0 && warnings.length === 0 ? (
|
||||
<div className="rounded-apple-lg border border-dashed border-apple-separator px-3 py-3 text-sm text-apple-label-secondary">
|
||||
当前没有额外警告,主线可以继续往草稿组走。
|
||||
</div>
|
||||
) : (
|
||||
[...validationMessages, ...warnings].map((message, index) => (
|
||||
<div
|
||||
key={`${message}-${index}`}
|
||||
className="rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/70 px-3 py-3 text-sm text-apple-label-secondary"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/70 px-3 py-3">
|
||||
<p className="text-xs text-apple-label-tertiary">连接件建议</p>
|
||||
<p className="mt-1 text-sm text-apple-label-secondary">
|
||||
当前已成立关系 {connectedRelations.length} 条,系统建议补齐连接件 {suggestedFastenerCount} 个。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useMemo, useRef, useState } from 'react'
|
||||
import { Bounds, Clone, OrbitControls, useGLTF } from '@react-three/drei'
|
||||
import { Canvas } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import type { AssemblyWorkspaceNode } from '@/lib/open-product/assembly-workspace'
|
||||
|
||||
interface AssemblyWorkbenchCanvasProps {
|
||||
nodes: AssemblyWorkspaceNode[]
|
||||
selectedNodeIds: string[]
|
||||
onSelectNode?: (nodeId: string) => void
|
||||
onMoveNode?: (nodeId: string, position: [number, number, number]) => void
|
||||
onDragEndNode?: (nodeId: string) => void
|
||||
}
|
||||
|
||||
function AssetNodeMesh({
|
||||
node,
|
||||
selected,
|
||||
onSelectNode,
|
||||
onMoveNode,
|
||||
onDragEndNode,
|
||||
}: {
|
||||
node: AssemblyWorkspaceNode
|
||||
selected: boolean
|
||||
onSelectNode?: (nodeId: string) => void
|
||||
onMoveNode?: (nodeId: string, position: [number, number, number]) => void
|
||||
onDragEndNode?: (nodeId: string) => void
|
||||
}) {
|
||||
const { scene } = useGLTF(node.modelUrl)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const planeRef = useRef(new THREE.Plane(new THREE.Vector3(0, 1, 0), -node.position[1]))
|
||||
|
||||
return (
|
||||
<group
|
||||
position={node.position}
|
||||
rotation={node.rotation}
|
||||
scale={node.scale}
|
||||
name={node.nodeId}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onSelectNode?.(node.nodeId)
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (node.source === 'desktop') {
|
||||
return
|
||||
}
|
||||
planeRef.current = new THREE.Plane(new THREE.Vector3(0, 1, 0), -node.position[1])
|
||||
setDragging(true)
|
||||
// @ts-expect-error R3F pointer capture typing is narrower than runtime
|
||||
event.target.setPointerCapture?.(event.pointerId)
|
||||
onSelectNode?.(node.nodeId)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!dragging) return
|
||||
event.stopPropagation()
|
||||
const point = new THREE.Vector3()
|
||||
if (!event.ray.intersectPlane(planeRef.current, point)) {
|
||||
return
|
||||
}
|
||||
onMoveNode?.(node.nodeId, [point.x, node.position[1], point.z])
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!dragging) return
|
||||
event.stopPropagation()
|
||||
setDragging(false)
|
||||
// @ts-expect-error R3F pointer capture typing is narrower than runtime
|
||||
event.target.releasePointerCapture?.(event.pointerId)
|
||||
onDragEndNode?.(node.nodeId)
|
||||
}}
|
||||
>
|
||||
<Clone object={scene} />
|
||||
{selected ? (
|
||||
<mesh position={[0, 120, 0]}>
|
||||
<sphereGeometry args={[28, 16, 16]} />
|
||||
<meshStandardMaterial color="#2f6fed" />
|
||||
</mesh>
|
||||
) : null}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function SceneContent({
|
||||
nodes,
|
||||
selectedNodeIds,
|
||||
onSelectNode,
|
||||
onMoveNode,
|
||||
onDragEndNode,
|
||||
}: AssemblyWorkbenchCanvasProps) {
|
||||
const selectedSet = useMemo(() => new Set(selectedNodeIds), [selectedNodeIds])
|
||||
|
||||
return (
|
||||
<>
|
||||
<color attach="background" args={['#f4f4f1']} />
|
||||
<ambientLight intensity={2} />
|
||||
<directionalLight position={[1500, 1500, 900]} intensity={2.4} />
|
||||
<directionalLight position={[-1200, 900, -700]} intensity={1.2} />
|
||||
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -1, 0]} receiveShadow>
|
||||
<planeGeometry args={[12000, 12000]} />
|
||||
<meshStandardMaterial color="#ece9e3" />
|
||||
</mesh>
|
||||
|
||||
<gridHelper args={[8000, 20, '#cbc4b8', '#e4ddd2']} position={[0, 0, 0]} />
|
||||
|
||||
<Bounds fit clip observe margin={1.2}>
|
||||
<group>
|
||||
{nodes.map((node) => (
|
||||
<AssetNodeMesh
|
||||
key={node.nodeId}
|
||||
node={node}
|
||||
selected={selectedSet.has(node.nodeId)}
|
||||
onSelectNode={onSelectNode}
|
||||
onMoveNode={onMoveNode}
|
||||
onDragEndNode={onDragEndNode}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
</Bounds>
|
||||
|
||||
<OrbitControls enableDamping dampingFactor={0.08} minDistance={400} maxDistance={12000} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function AssemblyWorkbenchCanvas(props: AssemblyWorkbenchCanvasProps) {
|
||||
return (
|
||||
<Canvas camera={{ position: [1800, 1400, 1800], fov: 32 }}>
|
||||
<Suspense fallback={null}>
|
||||
<SceneContent {...props} />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
)
|
||||
}
|
||||
@ -1,22 +1,18 @@
|
||||
/**
|
||||
* 产品列表项组件 - 可折叠的产品+SKU列表
|
||||
* 产品列表项组件 - 可折叠的产品+SKU卡片 (Precision Atelier 风格)
|
||||
*
|
||||
* 基于 Apple 视觉规范重构
|
||||
* 基于 Apple 视觉规范与 Precision Atelier 设计系统重构
|
||||
*
|
||||
* @updated 2026-01-29 - 修复 Tailwind 类名问题
|
||||
* @updated 2026-04-08 - 重构为网格卡片布局,优化了高宽比与视觉密度
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { memo, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ChevronDown, ChevronRight, Package, User, Plus, Trash2 } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, User, Plus, Trash2, Pencil } from 'lucide-react'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { Caption1 } from '@/components/ui/Typography'
|
||||
import { cn } from '@/lib/utils'
|
||||
// 动画库已在 globals.css 中定义,此处仅保留类型导入备用
|
||||
// import { getAccordionAnimation, getListItemAnimation } from '@/lib/design-tokens/animations'
|
||||
|
||||
// 产品列表项组件的入参类型
|
||||
interface ProductListItemProps {
|
||||
@ -29,7 +25,6 @@ interface ProductListItemProps {
|
||||
updatedAt?: Date | string
|
||||
description?: string | null
|
||||
visibility?: 'TEAM' | 'DEPARTMENT' | 'PUBLIC'
|
||||
// 添加创建者信息
|
||||
creator?: {
|
||||
id: string
|
||||
name: string
|
||||
@ -55,17 +50,11 @@ interface ProductListItemProps {
|
||||
}>
|
||||
}
|
||||
onEdit?: (product: ProductListItemProps['product']) => void
|
||||
/** 上传 SKU 回调 */
|
||||
onAddSKU?: (productId: string) => void
|
||||
/** 删除产品回调 */
|
||||
onDelete?: (product: ProductListItemProps['product']) => void
|
||||
/** 删除 SKU 回调 */
|
||||
onDeleteSKU?: (sku: ProductListItemProps['product']['skus'][number], product: ProductListItemProps['product']) => void
|
||||
/** 是否展开(由父组件控制手风琴效果) */
|
||||
isExpanded?: boolean
|
||||
/** 展开/收起回调 */
|
||||
onToggleExpand?: (productId: string) => void
|
||||
/** 离开首页前标记需要恢复展开状态 */
|
||||
onNavigate?: () => void
|
||||
}
|
||||
|
||||
@ -79,13 +68,10 @@ const ProductListItem = memo(function ProductListItem({
|
||||
onToggleExpand,
|
||||
onNavigate,
|
||||
}: ProductListItemProps) {
|
||||
// 支持受控和非受控两种模式
|
||||
const [internalExpanded, setInternalExpanded] = React.useState(false)
|
||||
const isControlled = controlledExpanded !== undefined
|
||||
const isExpanded = isControlled ? controlledExpanded : internalExpanded
|
||||
|
||||
const [isHovered, setIsHovered] = React.useState(false)
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
if (isControlled && onToggleExpand) {
|
||||
onToggleExpand(product.id)
|
||||
@ -93,112 +79,87 @@ const ProductListItem = memo(function ProductListItem({
|
||||
setInternalExpanded(prev => !prev)
|
||||
}
|
||||
}, [isControlled, onToggleExpand, product.id])
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setIsHovered(true)
|
||||
}, [])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsHovered(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="transition-all duration-500 ease-[cubic-bezier(0.32,0.72,0,1)]">
|
||||
{/* 产品主行 */}
|
||||
<div className="group bg-white rounded-apple-xl p-3 border border-transparent hover:border-apple-separator/30 hover:shadow-apple-xl transition-all duration-300 flex flex-col h-full">
|
||||
{/* 卡片封面区域 (带有展开 SKU 控制) */}
|
||||
<div
|
||||
className={cn(
|
||||
"px-6 py-4 transition-colors duration-200 cursor-pointer",
|
||||
isHovered ? "bg-apple-bg-secondary/50" : "bg-transparent"
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="relative aspect-[4/3] rounded-apple-lg overflow-hidden bg-apple-bg-secondary mb-4 cursor-pointer"
|
||||
onClick={handleToggleExpand}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* 左侧:产品信息 */}
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
{/* 展开/折叠图标 */}
|
||||
<div className="shrink-0 text-apple-label-tertiary">
|
||||
{isExpanded ? <ChevronDown className="w-5 h-5" /> : <ChevronRight className="w-5 h-5" />}
|
||||
</div>
|
||||
|
||||
{/* 产品图标 */}
|
||||
<div className="w-12 h-12 rounded-apple-lg bg-apple-bg-tertiary flex items-center justify-center shrink-0">
|
||||
<Package className="w-6 h-6 text-apple-label-secondary" strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
{/* 产品文本信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
{/* 产品型号 */}
|
||||
<div className="text-apple-lg font-apple-system font-apple-semibold tracking-apple-tighter text-apple-label-primary truncate">
|
||||
{product.modelNumber}
|
||||
</div>
|
||||
{product.category && (
|
||||
<Badge type="default" variant="subtle" size="small">
|
||||
{product.category.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 增加间距到 gap-x-6 (24px) */}
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-1">
|
||||
<Caption1 className="text-apple-label-primary">
|
||||
{product.name}
|
||||
</Caption1>
|
||||
<span className="text-apple-label-tertiary hidden sm:inline">•</span>
|
||||
<Caption1 className="text-apple-label-secondary">
|
||||
{product._count.skus} 个 SKU
|
||||
</Caption1>
|
||||
{product.creator && (
|
||||
<>
|
||||
<span className="text-apple-label-tertiary hidden sm:inline">•</span>
|
||||
<div className="flex items-center gap-1 text-apple-label-secondary">
|
||||
<User className="w-3 h-3" />
|
||||
<Caption1>创建者: {product.creator.name}</Caption1>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-apple-label-tertiary group-hover:scale-105 transition-transform duration-500">
|
||||
{/* 占位图使用网格纹理 */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-20"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, currentColor 1px, transparent 1px),
|
||||
linear-gradient(to bottom, currentColor 1px, transparent 1px)`,
|
||||
backgroundSize: '20px 20px',
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 font-display font-apple-bold text-2xl text-apple-label-primary/30 tracking-widest uppercase">
|
||||
NO IMAGE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
<div className="shrink-0 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="gray"
|
||||
size="small"
|
||||
onClick={() => onEdit?.(product)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="gray"
|
||||
size="small"
|
||||
onClick={() => onDelete(product)}
|
||||
className="whitespace-nowrap text-apple-red hover:bg-apple-red/10"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{/* 右上角角标 */}
|
||||
<div className="absolute top-2 right-2 flex flex-col gap-1.5 items-end z-20">
|
||||
<span className="bg-apple-bg-primary/90 text-apple-label-primary text-[10px] font-apple-bold px-2 py-0.5 rounded-full uppercase tracking-wider backdrop-blur-md border border-white/50 shadow-apple-sm">
|
||||
{product._count.skus} SKUs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 底部悬浮展开提示 */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/20 to-transparent flex justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div className="bg-black/40 backdrop-blur-md rounded-full p-1 text-white shadow-apple-sm">
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SKU 列表(展开时显示) - 使用 CSS Grid 实现平滑高度过渡 */}
|
||||
{/* 卡片文本信息 */}
|
||||
<div className="px-1 flex-1 flex flex-col">
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<p className="text-[10px] text-apple-label-tertiary font-apple-semibold uppercase tracking-widest">
|
||||
{product.category?.name || 'Uncategorized'}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onEdit && (
|
||||
<button onClick={(e) => { e.stopPropagation(); onEdit(product); }} className="p-1 rounded-full text-apple-label-tertiary hover:bg-apple-bg-secondary hover:text-apple-label-primary transition">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button onClick={(e) => { e.stopPropagation(); onDelete(product); }} className="p-1 rounded-full text-apple-label-tertiary hover:bg-apple-red/10 hover:text-apple-red transition">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-display font-apple-bold text-apple-label-primary text-base mb-3 leading-tight truncate">
|
||||
{product.name}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-center mt-auto">
|
||||
<span className="flex items-center gap-1 bg-apple-bg-secondary text-apple-label-secondary text-[10px] px-2 py-1 rounded-apple-sm font-apple-medium">
|
||||
<span className="text-apple-label-tertiary">#</span> {product.modelNumber}
|
||||
</span>
|
||||
{product.creator && (
|
||||
<span className="flex items-center gap-1 bg-apple-bg-secondary text-apple-label-secondary text-[10px] px-2 py-1 rounded-apple-sm font-apple-medium">
|
||||
<User className="w-3 h-3" /> {product.creator.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SKU 列表展开区域 */}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-500 ease-[cubic-bezier(0.32,0.72,0,1)]"
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
className="grid transition-[grid-template-rows] duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] mt-3"
|
||||
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-apple-bg-secondary px-6 py-3 space-y-2",
|
||||
// 内容动画
|
||||
<div className={cn(
|
||||
"bg-apple-bg-secondary/50 rounded-apple-lg p-2 space-y-1.5 border border-apple-separator/50",
|
||||
"transition-opacity duration-300",
|
||||
isExpanded ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
@ -207,122 +168,66 @@ const ProductListItem = memo(function ProductListItem({
|
||||
<div
|
||||
key={sku.id}
|
||||
className={cn(
|
||||
"px-3 py-2.5 rounded-apple-md bg-white border border-apple-separator hover:border-apple-blue/30 transition-all duration-200 group",
|
||||
// 子项入场动画(仅展开时)
|
||||
"p-2 rounded-apple-md bg-white border border-apple-separator/50 hover:border-apple-separator transition-all duration-200 group/sku",
|
||||
isExpanded && "animate-fadeInUp"
|
||||
)}
|
||||
style={{
|
||||
animationDelay: isExpanded ? `${index * 60}ms` : '0ms',
|
||||
animationDelay: isExpanded ? `${index * 40}ms` : '0ms',
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* SKU 编码和属性 */}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-4">
|
||||
<div className="text-apple-sm font-apple-system font-apple-medium tracking-apple-tighter text-apple-label-primary min-w-[100px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-apple-system font-apple-semibold tracking-apple-tighter text-apple-label-primary truncate max-w-[120px]">
|
||||
{sku.skuCode}
|
||||
</div>
|
||||
<div className="text-xs font-apple-system font-apple-semibold tracking-apple-tighter text-apple-label-primary">
|
||||
¥{sku.price.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge type={sku.isActive ? 'success' : 'default'} variant="subtle" size="small" className="text-[9px] px-1.5 py-0 h-4">
|
||||
{sku.isActive ? '已上架' : '未上架'}
|
||||
</Badge>
|
||||
|
||||
{/* 规格属性展示 - 紧凑排列 */}
|
||||
{sku.attributes && Object.keys(sku.attributes).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(sku.attributes)
|
||||
.filter(([, value]) => typeof value === 'string' || typeof value === 'number')
|
||||
.map(([key, value]) => (
|
||||
<span
|
||||
key={key}
|
||||
className="text-apple-xs font-apple-system tracking-apple-tighter px-1.5 py-0.5 rounded-apple-sm bg-apple-blue/20 text-apple-blue"
|
||||
>
|
||||
{String(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 价格 */}
|
||||
<div className="text-apple-sm font-apple-system font-apple-semibold tracking-apple-tighter text-apple-blue">
|
||||
¥{sku.price.toFixed(2)}
|
||||
</div>
|
||||
|
||||
{/* 库存 */}
|
||||
<div className="text-apple-sm font-apple-system tracking-apple-tighter text-apple-label-secondary min-w-[50px] text-right">
|
||||
{sku.stock} 件
|
||||
</div>
|
||||
|
||||
{/* 状态 */}
|
||||
<Badge
|
||||
type={sku.isActive ? 'success' : 'default'}
|
||||
variant="subtle"
|
||||
size="small"
|
||||
>
|
||||
{sku.isActive ? '已上架' : '未上架'}
|
||||
</Badge>
|
||||
|
||||
{/* SKU 操作 - 统一使用 gray 变体 */}
|
||||
<div className="flex items-center gap-1.5 ml-2" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 预览按钮:跳转到 SKU 详情页(内部,需登录) */}
|
||||
<Link
|
||||
href={`/products/${product.id}/skus/${sku.id}`}
|
||||
prefetch={true}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
<Button variant="gray" size="small">
|
||||
预览
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/products/${product.id}/skus/${sku.id}/assembly`}
|
||||
prefetch={true}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
<Button variant="gray" size="small">
|
||||
编辑动画
|
||||
</Button>
|
||||
</Link>
|
||||
{onDeleteSKU && (
|
||||
<Button
|
||||
variant="gray"
|
||||
size="small"
|
||||
onClick={() => onDeleteSKU(sku, product)}
|
||||
className="text-apple-red hover:bg-apple-red/10"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Link href={`/products/${product.id}/skus/${sku.id}`} prefetch={true} onClick={onNavigate}>
|
||||
<button className="text-[10px] px-2 py-0.5 rounded-full bg-apple-bg-secondary text-apple-label-secondary hover:bg-apple-bg-tertiary hover:text-apple-label-primary transition">
|
||||
详情
|
||||
</button>
|
||||
</Link>
|
||||
<Link href={`/products/${product.id}/skus/${sku.id}/assembly`} prefetch={true} onClick={onNavigate}>
|
||||
<button className="text-[10px] px-2 py-0.5 rounded-full bg-apple-bg-secondary text-apple-label-secondary hover:bg-apple-bg-tertiary hover:text-apple-label-primary transition">
|
||||
动画
|
||||
</button>
|
||||
</Link>
|
||||
{onDeleteSKU && (
|
||||
<button onClick={(e) => { e.stopPropagation(); onDeleteSKU(sku, product) }} className="p-1 rounded-full text-apple-label-tertiary hover:bg-apple-red/10 hover:text-apple-red transition">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{product.skus.length === 0 && (
|
||||
<div className="text-center py-3 text-apple-sm font-apple-system tracking-apple-tighter text-apple-label-tertiary">
|
||||
<div className="text-center py-2 text-[10px] font-apple-system tracking-apple-tighter text-apple-label-tertiary">
|
||||
暂无 SKU 数据
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加 SKU 按钮 */}
|
||||
{onAddSKU && (
|
||||
<div
|
||||
className={cn(
|
||||
"pt-2 border-t border-apple-separator/50",
|
||||
isExpanded && "animate-fadeInUp"
|
||||
)}
|
||||
style={{
|
||||
animationDelay: isExpanded ? `${product.skus.length * 60 + 100}ms` : '0ms',
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onAddSKU(product.id) }}
|
||||
className="w-full mt-1 py-1.5 rounded-apple-md bg-transparent border border-dashed border-apple-separator hover:border-apple-label-tertiary hover:bg-white transition-all duration-200 flex items-center justify-center gap-1 text-[10px] font-apple-system font-apple-medium text-apple-label-secondary"
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onAddSKU(product.id)
|
||||
}}
|
||||
className="w-full px-3 py-2.5 rounded-apple-md bg-white border border-apple-separator hover:border-apple-blue/30 transition-all duration-200 flex items-center justify-center gap-2 text-apple-sm font-apple-system font-apple-medium tracking-apple-tighter text-apple-label-primary"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
添加 SKU
|
||||
</button>
|
||||
</div>
|
||||
<Plus className="w-3 h-3" />
|
||||
添加 SKU
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,137 @@
|
||||
import {
|
||||
applyNodeRoleConfirmation,
|
||||
buildSingleBoardDeskAssemblyWorkspace,
|
||||
createSubassembly,
|
||||
moveAssemblyNodes,
|
||||
snapNodesIntoMainAssembly,
|
||||
} from '../assembly-workspace'
|
||||
|
||||
describe('assembly-workspace', () => {
|
||||
const previewNodes = [
|
||||
{
|
||||
role: 'desktop',
|
||||
label: '桌面板',
|
||||
assetFamilyId: 'family-desktop',
|
||||
variantId: 'variant-desktop',
|
||||
code: 'TOP-A',
|
||||
name: '桌面A',
|
||||
modelUrl: '/models/top-a.glb',
|
||||
position: [0, 720, 0] as [number, number, number],
|
||||
rotation: [0, 0, 0] as [number, number, number],
|
||||
scale: [1, 1, 1] as [number, number, number],
|
||||
},
|
||||
{
|
||||
role: 'frontLeft',
|
||||
label: '前左腿',
|
||||
assetFamilyId: 'family-leg',
|
||||
variantId: 'variant-leg-1',
|
||||
code: 'LEG-A',
|
||||
name: '桌腿A',
|
||||
modelUrl: '/models/leg-a.glb',
|
||||
position: [-450, 360, 250] as [number, number, number],
|
||||
rotation: [0, 0, 0] as [number, number, number],
|
||||
scale: [1, 1, 1] as [number, number, number],
|
||||
},
|
||||
{
|
||||
role: 'frontRight',
|
||||
label: '前右腿',
|
||||
assetFamilyId: 'family-leg',
|
||||
variantId: 'variant-leg-2',
|
||||
code: 'LEG-A',
|
||||
name: '桌腿A',
|
||||
modelUrl: '/models/leg-a.glb',
|
||||
position: [450, 360, 250] as [number, number, number],
|
||||
rotation: [0, 0, 0] as [number, number, number],
|
||||
scale: [1, 1, 1] as [number, number, number],
|
||||
},
|
||||
]
|
||||
|
||||
it('会把桌面板默认放进主装配区,其它部件先放待装区', () => {
|
||||
const snapshot = buildSingleBoardDeskAssemblyWorkspace({
|
||||
templateCode: 'single-board-desk',
|
||||
candidateKey: 'candidate-1',
|
||||
defaultName: '云序-桌面A-桌腿A',
|
||||
previewNodes,
|
||||
connectorRecommendations: [],
|
||||
})
|
||||
|
||||
expect(snapshot.nodes.find((node) => node.role === 'desktop')?.zone).toBe('main')
|
||||
expect(snapshot.nodes.filter((node) => node.source === 'leg').every((node) => node.zone === 'staging')).toBe(true)
|
||||
expect(snapshot.validation.level).toBe('PASSED')
|
||||
})
|
||||
|
||||
it('腿进入主装配区但没有吸附时,会给出阻断提示', () => {
|
||||
const snapshot = buildSingleBoardDeskAssemblyWorkspace({
|
||||
templateCode: 'single-board-desk',
|
||||
candidateKey: 'candidate-1',
|
||||
defaultName: '云序-桌面A-桌腿A',
|
||||
previewNodes,
|
||||
connectorRecommendations: [],
|
||||
})
|
||||
|
||||
const moved = moveAssemblyNodes(snapshot, ['candidate-1:frontLeft:1'], 'main')
|
||||
|
||||
expect(moved.validation.level).toBe('BLOCKED')
|
||||
expect(moved.validation.blockingIssues).toContain('桌腿A 和主件之间还没有形成有效连接')
|
||||
})
|
||||
|
||||
it('智能吸附后会补齐连接关系和确认角色', () => {
|
||||
const snapshot = buildSingleBoardDeskAssemblyWorkspace({
|
||||
templateCode: 'single-board-desk',
|
||||
candidateKey: 'candidate-1',
|
||||
defaultName: '云序-桌面A-桌腿A',
|
||||
previewNodes,
|
||||
connectorRecommendations: [],
|
||||
})
|
||||
|
||||
const moved = moveAssemblyNodes(snapshot, ['candidate-1:frontLeft:1'], 'main')
|
||||
const snapped = snapNodesIntoMainAssembly(moved, ['candidate-1:frontLeft:1'])
|
||||
|
||||
expect(snapped.validation.level).toBe('PASSED')
|
||||
expect(
|
||||
snapped.relations.find((relation) => relation.sourceNodeId === 'candidate-1:frontLeft:1')?.status,
|
||||
).toBe('CONNECTED')
|
||||
expect(
|
||||
snapped.nodes.find((node) => node.nodeId === 'candidate-1:frontLeft:1')?.roleConfirmed,
|
||||
).toBe('frontLeft')
|
||||
})
|
||||
|
||||
it('支持在分子装配区把多个部件封装成模块', () => {
|
||||
const snapshot = buildSingleBoardDeskAssemblyWorkspace({
|
||||
templateCode: 'single-board-desk',
|
||||
candidateKey: 'candidate-1',
|
||||
defaultName: '云序-桌面A-桌腿A',
|
||||
previewNodes,
|
||||
connectorRecommendations: [],
|
||||
})
|
||||
|
||||
const moved = moveAssemblyNodes(
|
||||
snapshot,
|
||||
['candidate-1:frontLeft:1', 'candidate-1:frontRight:2'],
|
||||
'subassembly',
|
||||
)
|
||||
const sealed = createSubassembly(moved, ['candidate-1:frontLeft:1', 'candidate-1:frontRight:2'])
|
||||
|
||||
expect(sealed.subassemblies).toHaveLength(1)
|
||||
expect(sealed.subassemblies[0]?.nodeIds).toEqual([
|
||||
'candidate-1:frontLeft:1',
|
||||
'candidate-1:frontRight:2',
|
||||
])
|
||||
})
|
||||
|
||||
it('支持人工改最终角色', () => {
|
||||
const snapshot = buildSingleBoardDeskAssemblyWorkspace({
|
||||
templateCode: 'single-board-desk',
|
||||
candidateKey: 'candidate-1',
|
||||
defaultName: '云序-桌面A-桌腿A',
|
||||
previewNodes,
|
||||
connectorRecommendations: [],
|
||||
})
|
||||
|
||||
const updated = applyNodeRoleConfirmation(snapshot, 'candidate-1:frontLeft:1', 'rearLeft')
|
||||
|
||||
expect(
|
||||
updated.nodes.find((node) => node.nodeId === 'candidate-1:frontLeft:1')?.roleConfirmed,
|
||||
).toBe('rearLeft')
|
||||
})
|
||||
})
|
||||
564
furniture-3d-system/src/lib/open-product/assembly-workspace.ts
Normal file
564
furniture-3d-system/src/lib/open-product/assembly-workspace.ts
Normal file
@ -0,0 +1,564 @@
|
||||
import type { MaterializedSlotVariantSelection } from './single-board-desk'
|
||||
import type { OpenProductMountPoint, PreSkuPreviewNode } from './types'
|
||||
|
||||
export type AssemblyZone = 'staging' | 'subassembly' | 'main'
|
||||
|
||||
export type AssemblyValidationLevel = 'PASSED' | 'WARNING' | 'BLOCKED'
|
||||
|
||||
export type AssemblyNodeSource = 'desktop' | 'leg' | 'connector' | 'unknown'
|
||||
|
||||
export type AssemblyRelationType = 'hole-group' | 'surface' | 'connector'
|
||||
|
||||
export type AssemblyRelationStatus = 'CONNECTED' | 'SUGGESTED' | 'PENDING'
|
||||
|
||||
export interface WeakSlotAssignment {
|
||||
role: string
|
||||
parentRolePath: string | null
|
||||
instanceIndex: number
|
||||
}
|
||||
|
||||
export interface AssemblyFastenerRecommendation {
|
||||
selectionKey: string
|
||||
assetFamilyId: string
|
||||
variantId?: string | null
|
||||
quantity: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface AssemblyWorkspaceNode extends PreSkuPreviewNode {
|
||||
nodeId: string
|
||||
zone: AssemblyZone
|
||||
source: AssemblyNodeSource
|
||||
slotKey: string | null
|
||||
roleRecommendation: string | null
|
||||
roleConfirmed: string | null
|
||||
weakSlot: WeakSlotAssignment | null
|
||||
targetPosition: [number, number, number] | null
|
||||
targetRotation: [number, number, number] | null
|
||||
isSnapped: boolean
|
||||
subassemblyId: string | null
|
||||
mountPoints: OpenProductMountPoint[]
|
||||
assemblyBasis: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface AssemblySubassembly {
|
||||
id: string
|
||||
name: string
|
||||
nodeIds: string[]
|
||||
status: 'draft' | 'sealed'
|
||||
}
|
||||
|
||||
export interface AssemblyRelation {
|
||||
relationId: string
|
||||
sourceNodeId: string
|
||||
targetNodeId: string
|
||||
relationType: AssemblyRelationType
|
||||
status: AssemblyRelationStatus
|
||||
description: string
|
||||
minMatchCount: number | null
|
||||
matchedCount: number | null
|
||||
roleRecommendation: string | null
|
||||
roleConfirmed: string | null
|
||||
requiresFastener: boolean
|
||||
suggestedFasteners: AssemblyFastenerRecommendation[]
|
||||
}
|
||||
|
||||
export interface AssemblyValidationResult {
|
||||
level: AssemblyValidationLevel
|
||||
blockingIssues: string[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface AssemblyWorkspaceSnapshot {
|
||||
version: '2026-04-08'
|
||||
templateCode: string
|
||||
workflowMode: 'assisted-manual'
|
||||
nodes: AssemblyWorkspaceNode[]
|
||||
subassemblies: AssemblySubassembly[]
|
||||
relations: AssemblyRelation[]
|
||||
validation: AssemblyValidationResult
|
||||
}
|
||||
|
||||
export interface CombinationCandidateSnapshot {
|
||||
key: string
|
||||
defaultName: string
|
||||
templateCode: string
|
||||
combinationItems: Array<{
|
||||
label: string
|
||||
value: string
|
||||
quantity: number
|
||||
role?: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface BuildSingleBoardDeskAssemblyWorkspaceInput {
|
||||
templateCode: string
|
||||
candidateKey: string
|
||||
defaultName: string
|
||||
previewNodes: PreSkuPreviewNode[]
|
||||
slotSelections?: MaterializedSlotVariantSelection[]
|
||||
connectorRecommendations?: AssemblyFastenerRecommendation[]
|
||||
}
|
||||
|
||||
const SNAP_DISTANCE_THRESHOLD = 180
|
||||
|
||||
const MAIN_NODE_SOURCE_LABELS = new Set(['desktop'])
|
||||
|
||||
const LEG_ROLE_ORDER = ['frontLeft', 'frontRight', 'rearLeft', 'rearRight']
|
||||
|
||||
function cloneVector3(
|
||||
input: [number, number, number] | undefined,
|
||||
): [number, number, number] {
|
||||
return input ? [...input] as [number, number, number] : [0, 0, 0]
|
||||
}
|
||||
|
||||
function createNodeId(candidateKey: string, role: string, index: number) {
|
||||
return `${candidateKey}:${role}:${index}`
|
||||
}
|
||||
|
||||
function inferNodeSource(role: string): AssemblyNodeSource {
|
||||
if (role === 'desktop') return 'desktop'
|
||||
if (LEG_ROLE_ORDER.includes(role)) return 'leg'
|
||||
if (role.includes('connector')) return 'connector'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function buildWeakSlot(role: string, index: number): WeakSlotAssignment | null {
|
||||
if (!role) return null
|
||||
|
||||
return {
|
||||
role,
|
||||
parentRolePath: role === 'desktop' ? null : 'desktop',
|
||||
instanceIndex: index + 1,
|
||||
}
|
||||
}
|
||||
|
||||
function buildStagingPosition(index: number, role: string): [number, number, number] {
|
||||
if (role === 'desktop') {
|
||||
return [0, 720, 0]
|
||||
}
|
||||
|
||||
const row = Math.floor(index / 2)
|
||||
const col = index % 2
|
||||
return [-1500 + col * 520, 320, 900 - row * 520]
|
||||
}
|
||||
|
||||
function buildInitialNode(
|
||||
candidateKey: string,
|
||||
node: PreSkuPreviewNode,
|
||||
index: number,
|
||||
slotSelection?: MaterializedSlotVariantSelection,
|
||||
): AssemblyWorkspaceNode {
|
||||
const source = inferNodeSource(node.role)
|
||||
const isDesktop = MAIN_NODE_SOURCE_LABELS.has(node.role)
|
||||
|
||||
return {
|
||||
...node,
|
||||
nodeId: createNodeId(candidateKey, node.role, index),
|
||||
zone: isDesktop ? 'main' : 'staging',
|
||||
source,
|
||||
slotKey: source === 'desktop'
|
||||
? 'desktop'
|
||||
: source === 'leg'
|
||||
? `${node.role}Leg`
|
||||
: source === 'connector'
|
||||
? 'connectorGroup'
|
||||
: null,
|
||||
roleRecommendation: node.role ?? null,
|
||||
roleConfirmed: isDesktop ? node.role : null,
|
||||
weakSlot: buildWeakSlot(node.role, index),
|
||||
position: isDesktop ? cloneVector3(node.position) : buildStagingPosition(index, node.role),
|
||||
rotation: cloneVector3(node.rotation),
|
||||
scale: cloneVector3(node.scale),
|
||||
targetPosition: cloneVector3(node.position),
|
||||
targetRotation: cloneVector3(node.rotation),
|
||||
isSnapped: isDesktop,
|
||||
subassemblyId: null,
|
||||
mountPoints: slotSelection?.variant.mountPoints ?? [],
|
||||
assemblyBasis: slotSelection?.variant.assemblyBasis ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function findSlotSelectionForNode(
|
||||
node: PreSkuPreviewNode,
|
||||
slotSelections: MaterializedSlotVariantSelection[],
|
||||
) {
|
||||
return (
|
||||
slotSelections.find(
|
||||
(selection) =>
|
||||
selection.variant.id === node.variantId ||
|
||||
selection.role === node.role ||
|
||||
selection.slotKey === node.role,
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function distanceBetween(
|
||||
left: [number, number, number],
|
||||
right: [number, number, number],
|
||||
) {
|
||||
const dx = left[0] - right[0]
|
||||
const dy = left[1] - right[1]
|
||||
const dz = left[2] - right[2]
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
}
|
||||
|
||||
function isNodeCloseEnoughToSnap(node: AssemblyWorkspaceNode) {
|
||||
if (!node.targetPosition) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (node.mountPoints.length === 0 && node.source !== 'desktop') {
|
||||
return false
|
||||
}
|
||||
|
||||
return distanceBetween(node.position, node.targetPosition) <= SNAP_DISTANCE_THRESHOLD
|
||||
}
|
||||
|
||||
export function buildSingleBoardDeskAssemblyWorkspace(
|
||||
input: BuildSingleBoardDeskAssemblyWorkspaceInput,
|
||||
): AssemblyWorkspaceSnapshot {
|
||||
const nodes = input.previewNodes.map((node, index) =>
|
||||
buildInitialNode(
|
||||
input.candidateKey,
|
||||
node,
|
||||
index,
|
||||
input.slotSelections
|
||||
? findSlotSelectionForNode(node, input.slotSelections) ?? undefined
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
|
||||
const desktopNode = nodes.find((node) => node.source === 'desktop') ?? null
|
||||
|
||||
const relations = nodes
|
||||
.filter((node) => node.source === 'leg')
|
||||
.map<AssemblyRelation>((node) => ({
|
||||
relationId: `${node.nodeId}->${desktopNode?.nodeId ?? 'desktop'}`,
|
||||
sourceNodeId: node.nodeId,
|
||||
targetNodeId: desktopNode?.nodeId ?? '',
|
||||
relationType: 'hole-group',
|
||||
status: 'SUGGESTED',
|
||||
description: `${node.name} 可通过孔位组吸附到 ${desktopNode?.name ?? '桌面板'}`,
|
||||
minMatchCount: 3,
|
||||
matchedCount: 0,
|
||||
roleRecommendation: node.roleRecommendation,
|
||||
roleConfirmed: null,
|
||||
requiresFastener: input.connectorRecommendations
|
||||
? input.connectorRecommendations.length > 0
|
||||
: false,
|
||||
suggestedFasteners: input.connectorRecommendations ?? [],
|
||||
}))
|
||||
|
||||
return {
|
||||
version: '2026-04-08',
|
||||
templateCode: input.templateCode,
|
||||
workflowMode: 'assisted-manual',
|
||||
nodes,
|
||||
subassemblies: [],
|
||||
relations,
|
||||
validation: deriveAssemblyValidation({
|
||||
nodes,
|
||||
relations,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveAssemblyValidation(input: {
|
||||
nodes: AssemblyWorkspaceNode[]
|
||||
relations: AssemblyRelation[]
|
||||
}): AssemblyValidationResult {
|
||||
const blockingIssues: string[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
const mainNodes = input.nodes.filter((node) => node.zone === 'main')
|
||||
const desktopNode = mainNodes.find((node) => node.source === 'desktop') ?? null
|
||||
|
||||
if (!desktopNode) {
|
||||
blockingIssues.push('主装配区缺少桌面板,当前装配不成立')
|
||||
}
|
||||
|
||||
const mainLegNodes = mainNodes.filter((node) => node.source === 'leg')
|
||||
const connectedRelations = new Map(
|
||||
input.relations
|
||||
.filter((relation) => relation.status === 'CONNECTED')
|
||||
.map((relation) => [relation.sourceNodeId, relation]),
|
||||
)
|
||||
|
||||
mainLegNodes.forEach((node) => {
|
||||
if (!node.isSnapped) {
|
||||
warnings.push(`${node.name} 已进入主装配区,但还没有完成吸附`)
|
||||
}
|
||||
if (!node.roleConfirmed) {
|
||||
warnings.push(`${node.name} 还没有确认最终角色`)
|
||||
}
|
||||
if (!connectedRelations.has(node.nodeId)) {
|
||||
blockingIssues.push(`${node.name} 和主件之间还没有形成有效连接`)
|
||||
}
|
||||
})
|
||||
|
||||
const confirmedRoles = mainLegNodes
|
||||
.map((node) => node.roleConfirmed)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
const duplicateRoles = confirmedRoles.filter(
|
||||
(role, index) => confirmedRoles.indexOf(role) !== index,
|
||||
)
|
||||
Array.from(new Set(duplicateRoles)).forEach((role) => {
|
||||
warnings.push(`主装配区里存在重复角色:${role}`)
|
||||
})
|
||||
|
||||
const level: AssemblyValidationLevel =
|
||||
blockingIssues.length > 0 ? 'BLOCKED' : warnings.length > 0 ? 'WARNING' : 'PASSED'
|
||||
|
||||
return {
|
||||
level,
|
||||
blockingIssues,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
export function moveAssemblyNodes(
|
||||
snapshot: AssemblyWorkspaceSnapshot,
|
||||
nodeIds: string[],
|
||||
zone: AssemblyZone,
|
||||
): AssemblyWorkspaceSnapshot {
|
||||
const nodeIdSet = new Set(nodeIds)
|
||||
|
||||
const nodes = snapshot.nodes.map((node) => {
|
||||
if (!nodeIdSet.has(node.nodeId)) {
|
||||
return node
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
zone,
|
||||
subassemblyId: zone === 'subassembly' ? node.subassemblyId : null,
|
||||
isSnapped: zone === 'main' ? node.isSnapped : false,
|
||||
roleConfirmed: zone === 'main' && node.source === 'desktop' ? 'desktop' : node.roleConfirmed,
|
||||
}
|
||||
})
|
||||
|
||||
const relations = snapshot.relations.map((relation) => ({
|
||||
...relation,
|
||||
status:
|
||||
zone === 'main' && nodeIdSet.has(relation.sourceNodeId)
|
||||
? relation.status
|
||||
: nodeIdSet.has(relation.sourceNodeId)
|
||||
? 'SUGGESTED'
|
||||
: relation.status,
|
||||
matchedCount:
|
||||
zone === 'main' && nodeIdSet.has(relation.sourceNodeId)
|
||||
? relation.matchedCount
|
||||
: nodeIdSet.has(relation.sourceNodeId)
|
||||
? 0
|
||||
: relation.matchedCount,
|
||||
roleConfirmed:
|
||||
zone === 'main' && nodeIdSet.has(relation.sourceNodeId)
|
||||
? relation.roleConfirmed
|
||||
: nodeIdSet.has(relation.sourceNodeId)
|
||||
? null
|
||||
: relation.roleConfirmed,
|
||||
}))
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
nodes,
|
||||
relations,
|
||||
validation: deriveAssemblyValidation({ nodes, relations }),
|
||||
}
|
||||
}
|
||||
|
||||
export function applyNodeRoleConfirmation(
|
||||
snapshot: AssemblyWorkspaceSnapshot,
|
||||
nodeId: string,
|
||||
role: string,
|
||||
): AssemblyWorkspaceSnapshot {
|
||||
const nodes = snapshot.nodes.map((node) =>
|
||||
node.nodeId === nodeId
|
||||
? {
|
||||
...node,
|
||||
roleConfirmed: role,
|
||||
weakSlot: {
|
||||
role,
|
||||
parentRolePath: role === 'desktop' ? null : 'desktop',
|
||||
instanceIndex: node.weakSlot?.instanceIndex ?? 1,
|
||||
},
|
||||
}
|
||||
: node,
|
||||
)
|
||||
|
||||
const relations = snapshot.relations.map((relation) =>
|
||||
relation.sourceNodeId === nodeId
|
||||
? {
|
||||
...relation,
|
||||
roleConfirmed: role,
|
||||
}
|
||||
: relation,
|
||||
)
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
nodes,
|
||||
relations,
|
||||
validation: deriveAssemblyValidation({ nodes, relations }),
|
||||
}
|
||||
}
|
||||
|
||||
export function snapNodesIntoMainAssembly(
|
||||
snapshot: AssemblyWorkspaceSnapshot,
|
||||
nodeIds: string[],
|
||||
): AssemblyWorkspaceSnapshot {
|
||||
const nodeIdSet = new Set(nodeIds)
|
||||
|
||||
const nodes = snapshot.nodes.map((node) => {
|
||||
if (!nodeIdSet.has(node.nodeId) || node.zone !== 'main') {
|
||||
return node
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: node.targetPosition ? cloneVector3(node.targetPosition) : cloneVector3(node.position),
|
||||
rotation: node.targetRotation ? cloneVector3(node.targetRotation) : cloneVector3(node.rotation),
|
||||
isSnapped: true,
|
||||
roleConfirmed: node.roleConfirmed ?? node.roleRecommendation,
|
||||
}
|
||||
})
|
||||
|
||||
const relations = snapshot.relations.map<AssemblyRelation>((relation) =>
|
||||
nodeIdSet.has(relation.sourceNodeId)
|
||||
? {
|
||||
...relation,
|
||||
status: 'CONNECTED',
|
||||
matchedCount: relation.minMatchCount,
|
||||
roleConfirmed: relation.roleConfirmed ?? relation.roleRecommendation,
|
||||
}
|
||||
: relation,
|
||||
)
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
nodes,
|
||||
relations,
|
||||
validation: deriveAssemblyValidation({ nodes, relations }),
|
||||
}
|
||||
}
|
||||
|
||||
export function updateAssemblyNodePosition(
|
||||
snapshot: AssemblyWorkspaceSnapshot,
|
||||
nodeId: string,
|
||||
position: [number, number, number],
|
||||
): AssemblyWorkspaceSnapshot {
|
||||
const nodes = snapshot.nodes.map((node) =>
|
||||
node.nodeId === nodeId
|
||||
? {
|
||||
...node,
|
||||
position,
|
||||
isSnapped: node.zone === 'main' ? false : node.isSnapped,
|
||||
}
|
||||
: node,
|
||||
)
|
||||
|
||||
const relations = snapshot.relations.map<AssemblyRelation>((relation) =>
|
||||
relation.sourceNodeId === nodeId
|
||||
? {
|
||||
...relation,
|
||||
status: 'PENDING',
|
||||
matchedCount: 0,
|
||||
roleConfirmed:
|
||||
snapshot.nodes.find((node) => node.nodeId === nodeId)?.zone === 'main'
|
||||
? relation.roleConfirmed
|
||||
: null,
|
||||
}
|
||||
: relation,
|
||||
)
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
nodes,
|
||||
relations,
|
||||
validation: deriveAssemblyValidation({ nodes, relations }),
|
||||
}
|
||||
}
|
||||
|
||||
export function attemptSnapNode(
|
||||
snapshot: AssemblyWorkspaceSnapshot,
|
||||
nodeId: string,
|
||||
): AssemblyWorkspaceSnapshot {
|
||||
const targetNode = snapshot.nodes.find((node) => node.nodeId === nodeId)
|
||||
if (!targetNode || targetNode.zone !== 'main' || !isNodeCloseEnoughToSnap(targetNode)) {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const nodes = snapshot.nodes.map((node) =>
|
||||
node.nodeId === nodeId
|
||||
? {
|
||||
...node,
|
||||
position: targetNode.targetPosition
|
||||
? cloneVector3(targetNode.targetPosition)
|
||||
: cloneVector3(node.position),
|
||||
rotation: targetNode.targetRotation
|
||||
? cloneVector3(targetNode.targetRotation)
|
||||
: cloneVector3(node.rotation),
|
||||
isSnapped: true,
|
||||
roleConfirmed: node.roleConfirmed ?? node.roleRecommendation,
|
||||
}
|
||||
: node,
|
||||
)
|
||||
|
||||
const relations = snapshot.relations.map<AssemblyRelation>((relation) =>
|
||||
relation.sourceNodeId === nodeId
|
||||
? {
|
||||
...relation,
|
||||
status: 'CONNECTED',
|
||||
matchedCount: relation.minMatchCount,
|
||||
roleConfirmed: relation.roleConfirmed ?? relation.roleRecommendation,
|
||||
}
|
||||
: relation,
|
||||
)
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
nodes,
|
||||
relations,
|
||||
validation: deriveAssemblyValidation({ nodes, relations }),
|
||||
}
|
||||
}
|
||||
|
||||
export function createSubassembly(
|
||||
snapshot: AssemblyWorkspaceSnapshot,
|
||||
nodeIds: string[],
|
||||
): AssemblyWorkspaceSnapshot {
|
||||
const eligibleNodes = snapshot.nodes.filter(
|
||||
(node) => nodeIds.includes(node.nodeId) && node.zone === 'subassembly',
|
||||
)
|
||||
if (eligibleNodes.length < 2) {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const subassemblyId = `subassembly-${snapshot.subassemblies.length + 1}`
|
||||
const subassembly: AssemblySubassembly = {
|
||||
id: subassemblyId,
|
||||
name: `模块 ${snapshot.subassemblies.length + 1}`,
|
||||
nodeIds: eligibleNodes.map((node) => node.nodeId),
|
||||
status: 'sealed',
|
||||
}
|
||||
|
||||
const nodes = snapshot.nodes.map((node) =>
|
||||
nodeIds.includes(node.nodeId)
|
||||
? {
|
||||
...node,
|
||||
subassemblyId,
|
||||
}
|
||||
: node,
|
||||
)
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
nodes,
|
||||
subassemblies: [...snapshot.subassemblies, subassembly],
|
||||
validation: deriveAssemblyValidation({
|
||||
nodes,
|
||||
relations: snapshot.relations,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@ -141,6 +141,18 @@ describe('combinationService', () => {
|
||||
expect.objectContaining({ role: 'desktop', modelUrl: '/models/top-a.glb' }),
|
||||
expect.objectContaining({ role: 'frontLeft', modelUrl: '/models/leg-a.glb' }),
|
||||
]),
|
||||
candidateSnapshot: expect.objectContaining({
|
||||
key: 'family-desktop-a::family-leg-a',
|
||||
templateCode: 'single-board-desk',
|
||||
}),
|
||||
assemblyWorkspace: expect.objectContaining({
|
||||
templateCode: 'single-board-desk',
|
||||
workflowMode: 'assisted-manual',
|
||||
nodes: expect.arrayContaining([
|
||||
expect.objectContaining({ role: 'desktop', zone: 'main' }),
|
||||
expect.objectContaining({ role: 'frontLeft', zone: 'staging' }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@ -180,4 +180,52 @@ describe('ShareService.getShareDetail', () => {
|
||||
expect(result.product.assemblySteps).toEqual([productLevelStep])
|
||||
expect(result.product.panels).toEqual([productLevelPanel])
|
||||
})
|
||||
|
||||
it('传入不存在的 sku 时回落到产品级分享数据,而不是使用无效 sku', async () => {
|
||||
mockedAssemblyStepRepository.findByProductId.mockResolvedValue([productLevelStep])
|
||||
mockedPanelRepository.findByProductId.mockResolvedValue([productLevelPanel])
|
||||
|
||||
const result = await service.getShareDetail('token-1', 'sku-missing')
|
||||
|
||||
expect(mockedAssemblyStepRepository.findByProductId).toHaveBeenCalledTimes(1)
|
||||
expect(mockedAssemblyStepRepository.findByProductId).toHaveBeenCalledWith('product-1', null)
|
||||
expect(mockedPanelRepository.findByProductId).toHaveBeenCalledTimes(1)
|
||||
expect(mockedPanelRepository.findByProductId).toHaveBeenCalledWith('product-1', {
|
||||
skuId: null,
|
||||
})
|
||||
expect(result.currentSkuId).toBeNull()
|
||||
expect(result.product.modelUrl).toBe('https://example.com/product.glb')
|
||||
expect(result.product.assemblySteps).toEqual([productLevelStep])
|
||||
expect(result.product.panels).toEqual([productLevelPanel])
|
||||
})
|
||||
|
||||
it('指定 sku 且该 sku 没有模型时,只回退到产品级模型,不回退到其他 sku 的模型', async () => {
|
||||
mockedShareRepository.findPublicDetailByToken.mockResolvedValue({
|
||||
...baseLink,
|
||||
product: {
|
||||
...baseLink.product,
|
||||
modelUrl: 'https://example.com/product.glb',
|
||||
skus: [
|
||||
{
|
||||
...baseLink.product.skus[0],
|
||||
id: 'sku-1',
|
||||
modelUrl: null,
|
||||
},
|
||||
{
|
||||
...baseLink.product.skus[0],
|
||||
id: 'sku-2',
|
||||
skuCode: 'SKU-002',
|
||||
modelUrl: 'https://example.com/sku-2.glb',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never)
|
||||
mockedAssemblyStepRepository.findByProductId.mockResolvedValue([skuLevelStep])
|
||||
mockedPanelRepository.findByProductId.mockResolvedValue([skuLevelPanel])
|
||||
|
||||
const result = await service.getShareDetail('token-1', 'sku-1')
|
||||
|
||||
expect(result.currentSkuId).toBe('sku-1')
|
||||
expect(result.product.modelUrl).toBe('https://example.com/product.glb')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import { generateSingleBoardDeskCandidates } from '@/lib/open-product/single-board-desk'
|
||||
import {
|
||||
buildSingleBoardDeskAssemblyWorkspace,
|
||||
type AssemblyFastenerRecommendation,
|
||||
} from '@/lib/open-product/assembly-workspace'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import {
|
||||
assetFamilyRepository,
|
||||
@ -62,6 +66,27 @@ function buildCombinationItems(candidate: ReturnType<typeof generateSingleBoardD
|
||||
return items
|
||||
}
|
||||
|
||||
function buildFastenerRecommendations(
|
||||
candidate: ReturnType<typeof generateSingleBoardDeskCandidates>['candidates'][number],
|
||||
componentCodes: string[],
|
||||
): AssemblyFastenerRecommendation[] {
|
||||
const connectorFamilyIds = candidate.familySelection.connectorFamilyIds
|
||||
if (connectorFamilyIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const quantity = Object.values(candidate.familySelection.legRoles).filter(Boolean).length
|
||||
|
||||
return connectorFamilyIds.map((assetFamilyId, index) => ({
|
||||
selectionKey: `connector:${assetFamilyId}:${index + 1}`,
|
||||
assetFamilyId,
|
||||
quantity,
|
||||
label:
|
||||
componentCodes.find((code) => code.includes('CT-')) ??
|
||||
`推荐连接件 ${index + 1}`,
|
||||
}))
|
||||
}
|
||||
|
||||
class CombinationService {
|
||||
async generateCandidates(input: GenerateCombinationInput): Promise<CombinationGenerationResult> {
|
||||
const template = await seriesTemplateRepository.findByIdWithRelations(input.seriesTemplateId)
|
||||
@ -140,6 +165,11 @@ class CombinationService {
|
||||
: assemblyWarnings.length > 0
|
||||
? '可预览但需确认'
|
||||
: '可直接预览'
|
||||
const combinationItems = buildCombinationItems(candidate)
|
||||
const fastenerRecommendations = buildFastenerRecommendations(
|
||||
candidate,
|
||||
materialized.componentCodes,
|
||||
)
|
||||
|
||||
return {
|
||||
...candidate,
|
||||
@ -152,7 +182,21 @@ class CombinationService {
|
||||
componentCodes: materialized.componentCodes,
|
||||
bomPreview: materialized.bom,
|
||||
installStepPreview: materialized.installStepPreview,
|
||||
combinationItems: buildCombinationItems(candidate),
|
||||
combinationItems,
|
||||
candidateSnapshot: {
|
||||
key: candidate.key,
|
||||
defaultName: candidate.defaultName,
|
||||
templateCode: templateConfig.code,
|
||||
combinationItems,
|
||||
},
|
||||
assemblyWorkspace: buildSingleBoardDeskAssemblyWorkspace({
|
||||
templateCode: templateConfig.code,
|
||||
candidateKey: candidate.key,
|
||||
defaultName: candidate.defaultName,
|
||||
previewNodes: materialized.previewNodes,
|
||||
slotSelections: materialized.selectedSlots,
|
||||
connectorRecommendations: fastenerRecommendations,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import type { SingleBoardDeskCandidate } from '@/lib/open-product/single-board-desk'
|
||||
import type { AssemblyResultStatus } from '@/lib/open-product/assembly-contract'
|
||||
import type {
|
||||
AssemblyWorkspaceSnapshot,
|
||||
CombinationCandidateSnapshot,
|
||||
} from '@/lib/open-product/assembly-workspace'
|
||||
import type {
|
||||
InstallPreviewStep,
|
||||
PreSkuBomItem,
|
||||
@ -29,6 +33,8 @@ export interface CombinationCandidateDTO extends SingleBoardDeskCandidate {
|
||||
bomPreview: PreSkuBomItem[]
|
||||
installStepPreview: InstallPreviewStep[]
|
||||
combinationItems: CombinationCandidateItemDTO[]
|
||||
candidateSnapshot: CombinationCandidateSnapshot
|
||||
assemblyWorkspace: AssemblyWorkspaceSnapshot
|
||||
}
|
||||
|
||||
export interface CombinationGenerationResult {
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import type { DraftGroupWithRelations, PreSkuWithRelations } from '../repositories'
|
||||
import type {
|
||||
AssemblyWorkspaceSnapshot,
|
||||
CombinationCandidateSnapshot,
|
||||
AssemblyRelation,
|
||||
AssemblyWorkspaceNode,
|
||||
} from '@/lib/open-product/assembly-workspace'
|
||||
import type {
|
||||
DraftGroupDTO,
|
||||
DraftGroupPreSkuDTO,
|
||||
@ -28,6 +34,150 @@ function toNullableNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
function normalizeCandidateSnapshot(value: unknown): CombinationCandidateSnapshot | null {
|
||||
if (!isRecord(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const combinationItems = Array.isArray(value.combinationItems)
|
||||
? value.combinationItems
|
||||
.filter((item): item is Record<string, unknown> => isRecord(item))
|
||||
.map((item) => ({
|
||||
label: typeof item.label === 'string' ? item.label : '',
|
||||
value: typeof item.value === 'string' ? item.value : '',
|
||||
quantity: typeof item.quantity === 'number' ? item.quantity : 0,
|
||||
role: typeof item.role === 'string' ? item.role : undefined,
|
||||
}))
|
||||
: []
|
||||
|
||||
return {
|
||||
key: typeof value.key === 'string' ? value.key : '',
|
||||
defaultName: typeof value.defaultName === 'string' ? value.defaultName : '',
|
||||
templateCode: typeof value.templateCode === 'string' ? value.templateCode : '',
|
||||
combinationItems,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAssemblyWorkspace(value: unknown): AssemblyWorkspaceSnapshot | null {
|
||||
if (!isRecord(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nodes = asArray<Record<string, unknown>>(value.nodes).map<AssemblyWorkspaceNode>((node) => ({
|
||||
nodeId: typeof node.nodeId === 'string' ? node.nodeId : '',
|
||||
role: typeof node.role === 'string' ? node.role : '',
|
||||
label: typeof node.label === 'string' ? node.label : '',
|
||||
assetFamilyId: typeof node.assetFamilyId === 'string' ? node.assetFamilyId : '',
|
||||
variantId: typeof node.variantId === 'string' ? node.variantId : '',
|
||||
code: typeof node.code === 'string' ? node.code : '',
|
||||
name: typeof node.name === 'string' ? node.name : '',
|
||||
modelUrl: typeof node.modelUrl === 'string' ? node.modelUrl : '',
|
||||
position: Array.isArray(node.position) ? node.position as [number, number, number] : [0, 0, 0],
|
||||
rotation: Array.isArray(node.rotation) ? node.rotation as [number, number, number] : [0, 0, 0],
|
||||
scale: Array.isArray(node.scale) ? node.scale as [number, number, number] : [1, 1, 1],
|
||||
zone:
|
||||
node.zone === 'main' || node.zone === 'subassembly' || node.zone === 'staging'
|
||||
? node.zone
|
||||
: 'staging',
|
||||
source:
|
||||
node.source === 'desktop' ||
|
||||
node.source === 'leg' ||
|
||||
node.source === 'connector' ||
|
||||
node.source === 'unknown'
|
||||
? node.source
|
||||
: 'unknown',
|
||||
slotKey: typeof node.slotKey === 'string' ? node.slotKey : null,
|
||||
roleRecommendation: typeof node.roleRecommendation === 'string' ? node.roleRecommendation : null,
|
||||
roleConfirmed: typeof node.roleConfirmed === 'string' ? node.roleConfirmed : null,
|
||||
weakSlot: isRecord(node.weakSlot)
|
||||
? {
|
||||
role: typeof node.weakSlot.role === 'string' ? node.weakSlot.role : '',
|
||||
parentRolePath:
|
||||
typeof node.weakSlot.parentRolePath === 'string' ? node.weakSlot.parentRolePath : null,
|
||||
instanceIndex:
|
||||
typeof node.weakSlot.instanceIndex === 'number' ? node.weakSlot.instanceIndex : 1,
|
||||
}
|
||||
: null,
|
||||
targetPosition: Array.isArray(node.targetPosition)
|
||||
? node.targetPosition as [number, number, number]
|
||||
: null,
|
||||
targetRotation: Array.isArray(node.targetRotation)
|
||||
? node.targetRotation as [number, number, number]
|
||||
: null,
|
||||
isSnapped: Boolean(node.isSnapped),
|
||||
subassemblyId: typeof node.subassemblyId === 'string' ? node.subassemblyId : null,
|
||||
mountPoints: asArray(node.mountPoints),
|
||||
assemblyBasis: isRecord(node.assemblyBasis)
|
||||
? (node.assemblyBasis as Record<string, unknown>)
|
||||
: null,
|
||||
materialConfig: isRecord(node.materialConfig)
|
||||
? (node.materialConfig as unknown as AssemblyWorkspaceNode['materialConfig'])
|
||||
: null,
|
||||
}))
|
||||
|
||||
const relations = asArray<Record<string, unknown>>(value.relations).map<AssemblyRelation>((relation) => ({
|
||||
relationId: typeof relation.relationId === 'string' ? relation.relationId : '',
|
||||
sourceNodeId: typeof relation.sourceNodeId === 'string' ? relation.sourceNodeId : '',
|
||||
targetNodeId: typeof relation.targetNodeId === 'string' ? relation.targetNodeId : '',
|
||||
relationType:
|
||||
relation.relationType === 'hole-group' ||
|
||||
relation.relationType === 'surface' ||
|
||||
relation.relationType === 'connector'
|
||||
? relation.relationType
|
||||
: 'surface',
|
||||
status:
|
||||
relation.status === 'CONNECTED' ||
|
||||
relation.status === 'SUGGESTED' ||
|
||||
relation.status === 'PENDING'
|
||||
? relation.status
|
||||
: 'PENDING',
|
||||
description: typeof relation.description === 'string' ? relation.description : '',
|
||||
minMatchCount: toNullableNumber(relation.minMatchCount),
|
||||
matchedCount: toNullableNumber(relation.matchedCount),
|
||||
roleRecommendation:
|
||||
typeof relation.roleRecommendation === 'string' ? relation.roleRecommendation : null,
|
||||
roleConfirmed: typeof relation.roleConfirmed === 'string' ? relation.roleConfirmed : null,
|
||||
requiresFastener: Boolean(relation.requiresFastener),
|
||||
suggestedFasteners: asArray<Record<string, unknown>>(relation.suggestedFasteners).map((item) => ({
|
||||
selectionKey: typeof item.selectionKey === 'string' ? item.selectionKey : '',
|
||||
assetFamilyId: typeof item.assetFamilyId === 'string' ? item.assetFamilyId : '',
|
||||
variantId: typeof item.variantId === 'string' ? item.variantId : null,
|
||||
quantity: typeof item.quantity === 'number' ? item.quantity : 0,
|
||||
label: typeof item.label === 'string' ? item.label : '',
|
||||
})),
|
||||
}))
|
||||
|
||||
return {
|
||||
version: value.version === '2026-04-08' ? '2026-04-08' : '2026-04-08',
|
||||
templateCode: typeof value.templateCode === 'string' ? value.templateCode : '',
|
||||
workflowMode: 'assisted-manual',
|
||||
nodes,
|
||||
subassemblies: asArray<Record<string, unknown>>(value.subassemblies).map((item) => ({
|
||||
id: typeof item.id === 'string' ? item.id : '',
|
||||
name: typeof item.name === 'string' ? item.name : '',
|
||||
nodeIds: asArray<string>(item.nodeIds).filter((entry): entry is string => typeof entry === 'string'),
|
||||
status: item.status === 'sealed' ? 'sealed' : 'draft',
|
||||
})),
|
||||
relations,
|
||||
validation: isRecord(value.validation)
|
||||
? {
|
||||
level:
|
||||
value.validation.level === 'PASSED' ||
|
||||
value.validation.level === 'WARNING' ||
|
||||
value.validation.level === 'BLOCKED'
|
||||
? value.validation.level
|
||||
: 'WARNING',
|
||||
blockingIssues: asArray<string>(value.validation.blockingIssues).filter(
|
||||
(entry): entry is string => typeof entry === 'string',
|
||||
),
|
||||
warnings: asArray<string>(value.validation.warnings).filter(
|
||||
(entry): entry is string => typeof entry === 'string',
|
||||
),
|
||||
}
|
||||
: { level: 'WARNING', blockingIssues: [], warnings: [] },
|
||||
}
|
||||
}
|
||||
|
||||
export function toDraftGroupDTO(record: DraftGroupWithRelations): DraftGroupDTO {
|
||||
return {
|
||||
id: record.id,
|
||||
@ -302,6 +452,9 @@ export function toDraftGroupPreSkuDTO(record: PreSkuWithRelations): DraftGroupPr
|
||||
: []
|
||||
const assemblyWarnings = Array.from(new Set([...summaryWarnings, ...noteWarnings]))
|
||||
|
||||
const candidateSnapshot = normalizeCandidateSnapshot(displaySummary?.candidateSnapshot)
|
||||
const assemblyWorkspace = normalizeAssemblyWorkspace(displaySummary?.assemblyWorkspace)
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
draftGroupId: record.draftGroupId,
|
||||
@ -317,6 +470,8 @@ export function toDraftGroupPreSkuDTO(record: PreSkuWithRelations): DraftGroupPr
|
||||
: null,
|
||||
previewNodes: Array.isArray(record.previewNodes) ? (record.previewNodes as unknown[]) : [],
|
||||
displaySummary,
|
||||
candidateSnapshot,
|
||||
assemblyWorkspace,
|
||||
assemblyResultStatus: normalizeAssemblyResultStatus(
|
||||
displaySummary?.resultStatus,
|
||||
),
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import type { DraftGroupStatus, PreSkuStatus } from '@prisma/client'
|
||||
import type { AssetFamilyDTO, AssetVariantDTO } from './asset-center.service.types'
|
||||
import type { AssemblyResultStatus } from '@/lib/open-product/assembly-contract'
|
||||
import type {
|
||||
AssemblyWorkspaceSnapshot,
|
||||
CombinationCandidateSnapshot,
|
||||
} from '@/lib/open-product/assembly-workspace'
|
||||
|
||||
type PackagingSourceType = 'PRODUCT' | 'PRE_SKU' | 'DRAFT_GROUP'
|
||||
type PackagingItemType = 'panel' | 'component' | 'hardware' | 'accessory'
|
||||
@ -8,6 +12,8 @@ type PackagingItemType = 'panel' | 'component' | 'hardware' | 'accessory'
|
||||
export interface DraftGroupCandidateInput {
|
||||
key: string
|
||||
defaultName: string
|
||||
candidateSnapshot?: CombinationCandidateSnapshot | null
|
||||
assemblyWorkspace?: AssemblyWorkspaceSnapshot | null
|
||||
familySelection: {
|
||||
desktopFamilyId: string
|
||||
legGroupFamilyId?: string | null
|
||||
@ -59,6 +65,8 @@ export interface DraftGroupPreSkuDTO {
|
||||
recommendedHardware: unknown[] | null
|
||||
previewNodes: unknown[]
|
||||
displaySummary: Record<string, unknown> | null
|
||||
candidateSnapshot: CombinationCandidateSnapshot | null
|
||||
assemblyWorkspace: AssemblyWorkspaceSnapshot | null
|
||||
assemblyResultStatus: AssemblyResultStatus | null
|
||||
assemblyWarnings: string[]
|
||||
bomPreview: unknown[]
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { materializeSingleBoardDeskPreSku } from '@/lib/open-product/single-board-desk'
|
||||
import {
|
||||
buildSingleBoardDeskAssemblyWorkspace,
|
||||
deriveAssemblyValidation,
|
||||
type AssemblyWorkspaceSnapshot,
|
||||
type CombinationCandidateSnapshot,
|
||||
} from '@/lib/open-product/assembly-workspace'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import {
|
||||
assetFamilyRepository,
|
||||
@ -36,6 +42,50 @@ function normalizeCandidate(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCandidateSnapshot(
|
||||
candidate: CreateDraftGroupFromAutoCombinationInput['candidates'][number],
|
||||
templateCode: string,
|
||||
): CombinationCandidateSnapshot {
|
||||
if (candidate.candidateSnapshot) {
|
||||
return candidate.candidateSnapshot
|
||||
}
|
||||
|
||||
return {
|
||||
key: candidate.key,
|
||||
defaultName: candidate.defaultName,
|
||||
templateCode,
|
||||
combinationItems: [],
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAssemblyWorkspace(
|
||||
candidate: CreateDraftGroupFromAutoCombinationInput['candidates'][number],
|
||||
templateCode: string,
|
||||
previewNodes: ReturnType<typeof materializeSingleBoardDeskPreSku>['previewNodes'],
|
||||
selectedSlots: ReturnType<typeof materializeSingleBoardDeskPreSku>['selectedSlots'],
|
||||
): AssemblyWorkspaceSnapshot {
|
||||
if (candidate.assemblyWorkspace) {
|
||||
const validation = deriveAssemblyValidation({
|
||||
nodes: candidate.assemblyWorkspace.nodes,
|
||||
relations: candidate.assemblyWorkspace.relations,
|
||||
})
|
||||
|
||||
return {
|
||||
...candidate.assemblyWorkspace,
|
||||
templateCode,
|
||||
validation,
|
||||
}
|
||||
}
|
||||
|
||||
return buildSingleBoardDeskAssemblyWorkspace({
|
||||
templateCode,
|
||||
candidateKey: candidate.key,
|
||||
defaultName: candidate.defaultName,
|
||||
previewNodes,
|
||||
slotSelections: selectedSlots,
|
||||
})
|
||||
}
|
||||
|
||||
function collectCandidateFamilyIds(
|
||||
candidate: CreateDraftGroupFromAutoCombinationInput['candidates'][number],
|
||||
): string[] {
|
||||
@ -111,6 +161,13 @@ class PreSkuMaterializationService {
|
||||
familyNameById: familyNames,
|
||||
})
|
||||
const variantSelection = buildVariantSelection(materialized)
|
||||
const candidateSnapshot = normalizeCandidateSnapshot(candidate, templateRecord.code)
|
||||
const assemblyWorkspace = normalizeAssemblyWorkspace(
|
||||
candidate,
|
||||
templateRecord.code,
|
||||
materialized.previewNodes,
|
||||
materialized.selectedSlots,
|
||||
)
|
||||
|
||||
return {
|
||||
draftGroupId,
|
||||
@ -123,13 +180,27 @@ class PreSkuMaterializationService {
|
||||
variantSelection: variantSelection as unknown as Prisma.InputJsonValue,
|
||||
recommendedHardware:
|
||||
materialized.recommendedHardware as unknown as Prisma.InputJsonValue,
|
||||
previewNodes: materialized.previewNodes as unknown as Prisma.InputJsonValue,
|
||||
previewNodes: assemblyWorkspace.nodes.map((node) => ({
|
||||
role: node.roleConfirmed ?? node.roleRecommendation ?? node.role,
|
||||
label: node.label,
|
||||
assetFamilyId: node.assetFamilyId,
|
||||
variantId: node.variantId,
|
||||
code: node.code,
|
||||
name: node.name,
|
||||
modelUrl: node.modelUrl,
|
||||
position: node.position,
|
||||
rotation: node.rotation,
|
||||
scale: node.scale,
|
||||
materialConfig: node.materialConfig ?? null,
|
||||
})) as unknown as Prisma.InputJsonValue,
|
||||
displaySummary: {
|
||||
defaultName: candidate.defaultName,
|
||||
componentCodes: materialized.componentCodes,
|
||||
resultStatus: assemblyResultStatus,
|
||||
warnings: assemblyWarnings,
|
||||
} as Prisma.InputJsonValue,
|
||||
candidateSnapshot,
|
||||
assemblyWorkspace,
|
||||
} as unknown as Prisma.InputJsonValue,
|
||||
bomPreview: materialized.bom as unknown as Prisma.InputJsonValue,
|
||||
weightPreview: materialized.weightKg,
|
||||
componentCodes: materialized.componentCodes,
|
||||
|
||||
@ -147,18 +147,16 @@ class ShareService {
|
||||
|
||||
const normalizedRequestedSkuId = this.normalizeSkuId(requestedSkuId)
|
||||
const currentSku = this.resolveCurrentSku(link.product.skus, normalizedRequestedSkuId)
|
||||
const scopedSkuId = normalizedRequestedSkuId ?? currentSku?.id ?? null
|
||||
const scopedSkuId = currentSku?.id ?? null
|
||||
|
||||
const [assemblySteps, panels] = await Promise.all([
|
||||
this.loadScopedAssemblySteps(link.product.id, scopedSkuId),
|
||||
this.loadScopedPanels(link.product.id, scopedSkuId),
|
||||
])
|
||||
|
||||
const resolvedModelUrl =
|
||||
currentSku?.modelUrl ||
|
||||
link.product.modelUrl ||
|
||||
link.product.skus.find((sku) => Boolean(sku.modelUrl))?.modelUrl ||
|
||||
null
|
||||
const resolvedModelUrl = currentSku
|
||||
? currentSku.modelUrl || link.product.modelUrl || null
|
||||
: link.product.modelUrl || null
|
||||
|
||||
// 返回白名单字段(公开分享数据)
|
||||
return {
|
||||
|
||||
160
scripts/create-wyj-test-deploy.ps1
Normal file
160
scripts/create-wyj-test-deploy.ps1
Normal file
@ -0,0 +1,160 @@
|
||||
param(
|
||||
[string]$ProjectRoot = "D:\AI Code\3danzhuang\furniture-3d-system",
|
||||
[string]$OutputRoot = "D:\AI Code\3danzhuang"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ProjectRoot)) {
|
||||
throw "项目目录不存在: $ProjectRoot"
|
||||
}
|
||||
|
||||
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$archiveName = "deploy-furniture-wyj-test-$timestamp.tar.gz"
|
||||
$scriptName = "deploy-furniture-wyj-test-$timestamp.sh"
|
||||
$archivePath = Join-Path $OutputRoot $archiveName
|
||||
$scriptPath = Join-Path $OutputRoot $scriptName
|
||||
|
||||
$includePaths = @(
|
||||
'furniture-3d-system/package.json'
|
||||
'furniture-3d-system/package-lock.json'
|
||||
'furniture-3d-system/next.config.js'
|
||||
'furniture-3d-system/postcss.config.mjs'
|
||||
'furniture-3d-system/tsconfig.json'
|
||||
'furniture-3d-system/next-env.d.ts'
|
||||
'furniture-3d-system/prisma.config.ts'
|
||||
'furniture-3d-system/src'
|
||||
'furniture-3d-system/public'
|
||||
'furniture-3d-system/prisma'
|
||||
'furniture-3d-system/scripts'
|
||||
'furniture-3d-system/lib'
|
||||
)
|
||||
|
||||
Push-Location (Split-Path $ProjectRoot -Parent)
|
||||
try {
|
||||
if (Test-Path -LiteralPath $archivePath) {
|
||||
Remove-Item -LiteralPath $archivePath -Force
|
||||
}
|
||||
|
||||
tar -czf $archivePath @includePaths
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
$shellScript = @'
|
||||
set -e
|
||||
BASE=/root/codex-workspaces/furniture-3d-system-wyj-test
|
||||
APP=$BASE/furniture-3d-system
|
||||
ARCHIVE=/root/codex-workspaces/__ARCHIVE_NAME__
|
||||
DB_NAME=furniture3d_wyj_test
|
||||
DB_USER=furniture3d_wyj_test
|
||||
DB_PASS='F3dWyjTest_3200_20260407'
|
||||
APP_PORT=3200
|
||||
BUCKET=furniture-3d-models-wyj-test
|
||||
MINIO_USER=furniture3dtest
|
||||
MINIO_PASS='F3dMinio_39100_20260331'
|
||||
|
||||
mkdir -p $BASE
|
||||
rm -rf $APP
|
||||
cd $BASE
|
||||
tar -xzf $ARCHIVE
|
||||
|
||||
sudo -u postgres psql -d postgres <<SQL
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$DB_USER') THEN
|
||||
CREATE ROLE $DB_USER LOGIN PASSWORD '$DB_PASS';
|
||||
END IF;
|
||||
END
|
||||
\$\$;
|
||||
SQL
|
||||
|
||||
if ! sudo -u postgres psql -d postgres -Atqc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1; then
|
||||
sudo -u postgres createdb -O $DB_USER $DB_NAME
|
||||
sudo -u postgres pg_dump -d furniture3d_test --no-owner --no-privileges | sudo -u postgres psql -d $DB_NAME
|
||||
fi
|
||||
|
||||
sudo -u postgres psql -d postgres <<SQL
|
||||
GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;
|
||||
ALTER DATABASE $DB_NAME OWNER TO $DB_USER;
|
||||
SQL
|
||||
|
||||
sudo -u postgres psql -d $DB_NAME <<SQL
|
||||
GRANT ALL PRIVILEGES ON SCHEMA public TO $DB_USER;
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO $DB_USER;
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO $DB_USER;
|
||||
GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO $DB_USER;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO $DB_USER;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO $DB_USER;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO $DB_USER;
|
||||
SQL
|
||||
|
||||
cp /root/codex-workspaces/furniture-3d-system-test/.env $BASE/.env
|
||||
python3 - <<PY
|
||||
from pathlib import Path
|
||||
p = Path('$BASE/.env')
|
||||
lines = p.read_text().splitlines()
|
||||
repl = {
|
||||
'DATABASE_URL': '"postgresql://$DB_USER:$DB_PASS@127.0.0.1:5432/$DB_NAME"',
|
||||
'DIRECT_URL': '"postgresql://$DB_USER:$DB_PASS@127.0.0.1:5432/$DB_NAME"',
|
||||
'NEXTAUTH_URL': 'http://192.168.42.134:$APP_PORT',
|
||||
'NEXT_PUBLIC_APP_URL': 'http://192.168.42.134:$APP_PORT',
|
||||
'S3_BUCKET': '"$BUCKET"',
|
||||
}
|
||||
out=[]
|
||||
seen=set()
|
||||
for line in lines:
|
||||
if '=' in line and not line.lstrip().startswith('#'):
|
||||
k,_ = line.split('=',1)
|
||||
if k in repl:
|
||||
out.append(f'{k}={repl[k]}')
|
||||
seen.add(k)
|
||||
else:
|
||||
out.append(line)
|
||||
else:
|
||||
out.append(line)
|
||||
for k,v in repl.items():
|
||||
if k not in seen:
|
||||
out.append(f'{k}={v}')
|
||||
p.write_text('\n'.join(out)+'\n')
|
||||
PY
|
||||
ln -sfn $BASE/.env $APP/.env
|
||||
|
||||
docker exec furniture-3d-system-test-minio sh -lc "mc alias set local http://127.0.0.1:9000 $MINIO_USER $MINIO_PASS >/dev/null && mc mb -p local/$BUCKET >/dev/null 2>&1 || true && mc anonymous set download local/$BUCKET >/dev/null"
|
||||
|
||||
cd $APP
|
||||
npm install --no-audit --no-fund
|
||||
sudo -u postgres psql -d $DB_NAME <<SQL
|
||||
ALTER TABLE "User"
|
||||
ADD COLUMN IF NOT EXISTS "loginSource" TEXT NOT NULL DEFAULT 'LOCAL',
|
||||
ADD COLUMN IF NOT EXISTS "dingtalkUserId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "dingtalkUnionId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "dingtalkBoundAt" TIMESTAMP(3);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "User_dingtalkUserId_key" ON "User"("dingtalkUserId");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "User_dingtalkUnionId_key" ON "User"("dingtalkUnionId");
|
||||
CREATE INDEX IF NOT EXISTS "User_loginSource_idx" ON "User"("loginSource");
|
||||
SQL
|
||||
npx prisma generate
|
||||
npm run build
|
||||
|
||||
pm2 delete furniture-3d-system-wyj-test >/dev/null 2>&1 || true
|
||||
pm2 start /bin/bash --name furniture-3d-system-wyj-test --cwd $APP -- -lc "set -a; source ./.env; set +a; mkdir -p .next/standalone/.next; rm -rf .next/standalone/.next/static .next/standalone/public; cp -R .next/static .next/standalone/.next/static; cp -R public .next/standalone/public; exec env PORT=$APP_PORT HOSTNAME=0.0.0.0 node .next/standalone/server.js"
|
||||
pm2 save >/dev/null
|
||||
sleep 5
|
||||
curl -I -s http://127.0.0.1:$APP_PORT | sed -n '1,12p'
|
||||
ls -1dt /root/codex-workspaces/deploy-furniture-wyj-test-*.tar.gz 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||
echo '=== DONE ==='
|
||||
pm2 describe furniture-3d-system-wyj-test | sed -n '1,35p'
|
||||
'@
|
||||
|
||||
$shellScript = $shellScript.Replace('__ARCHIVE_NAME__', $archiveName)
|
||||
|
||||
[System.IO.File]::WriteAllText($scriptPath, $shellScript, [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
$archive = Get-Item -LiteralPath $archivePath
|
||||
[PSCustomObject]@{
|
||||
Archive = $archive.FullName
|
||||
ArchiveMB = [math]::Round($archive.Length / 1MB, 2)
|
||||
Script = $scriptPath
|
||||
IncludedPaths = $includePaths -join ', '
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user