feat: land assembly workspace and share fixes

This commit is contained in:
WuYongJie 2026-04-08 23:07:58 +08:00
parent ee5cb3a900
commit a80cb5498f
23 changed files with 2040 additions and 285 deletions

View File

@ -12,9 +12,3 @@
---
## 当前未完成项
### 待前端修正 (前端 UI Agent 负责)
- [ ] **包材资料库页面视觉联调**
归属:`前端负责`
说明:确认该页面视觉是否已统一到主线风格。

View File

@ -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) {

View File

@ -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(),

View File

@ -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}

View File

@ -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,

View File

@ -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>

View File

@ -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}

View File

@ -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) => {

View File

@ -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"
>

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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')
})
})

View 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,
}),
}
}

View File

@ -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' }),
]),
}),
}),
)
})

View File

@ -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')
})
})

View File

@ -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,
}),
}
})

View File

@ -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 {

View File

@ -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,
),

View File

@ -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[]

View File

@ -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,

View File

@ -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 {

View 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 ', '
}