diff --git a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx new file mode 100644 index 00000000..937f69ee --- /dev/null +++ b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx @@ -0,0 +1,365 @@ +'use client' + +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { ReactNode } from 'react' +import IntakeClient from './IntakeClient' +import { NON_INTEGRATED_STORAGE_KEY } from './non-integrated/nonIntegratedStorage' + +jest.mock('@/components/platform/PlatformSplitLayout', () => ({ + __esModule: true, + PlatformSplitLayout: ({ + leftContent, + rightContent, + }: { + leftContent: ReactNode + rightContent: ReactNode + }) => ( +
+
{leftContent}
+
{rightContent}
+
+ ), +})) + +jest.mock('@/components/platform/AccordionSection', () => ({ + __esModule: true, + AccordionSection: ({ + title, + children, + onToggle, + }: { + title: ReactNode + children: ReactNode + onToggle?: () => void + }) => ( +
+ +
{children}
+
+ ), +})) + +jest.mock('@/lib/open-product/platform-view', () => ({ + __esModule: true, + readPreviewNodes: () => [], +})) + +jest.mock('@/components/platform/ModelPreviewPanel', () => ({ + __esModule: true, + ModelPreviewPanel: ({ + title, + emptyTitle, + emptyDescription, + }: { + title?: string + emptyTitle?: string + emptyDescription?: string + }) => ( +
+
{title ?? '3D Preview'}
+
{emptyTitle}
+
{emptyDescription}
+
+ ), +})) + +function renderSelector(props?: { eligibleSkuList?: Array> }) { + return render( + , + ) +} + +function renderEditor() { + return render( + , + ) +} + +describe('IntakeClient', () => { + beforeEach(() => { + window.localStorage.clear() + jest.restoreAllMocks() + }) + + it('shows the renamed section buttons', () => { + renderSelector() + + const rightPanel = screen.getByTestId('right-panel') + expect(within(rightPanel).getByRole('button', { name: '\u8d44\u6599\u5165\u5e93' })).toBeInTheDocument() + expect(within(rightPanel).getByRole('button', { name: 'BOM \u6e05\u5355' })).toBeInTheDocument() + expect(within(rightPanel).queryByRole('button', { name: '\u6267\u884c\u51c6\u5907' })).not.toBeInTheDocument() + expect(within(rightPanel).queryByRole('button', { name: '\u5305\u88c5\u6267\u884c' })).not.toBeInTheDocument() + expect(within(rightPanel).queryByRole('button', { name: '\u8d28\u68c0\u786e\u8ba4' })).not.toBeInTheDocument() + }) + + it('defaults to the non-integrated intake view with the pending section expanded', () => { + renderSelector({ eligibleSkuList: [] }) + + const rightPanel = screen.getByTestId('right-panel') + expect(within(rightPanel).getByRole('button', { name: '\u975e\u96c6\u6210\u7cfb\u5217\u4ea7\u54c1' })).toBeInTheDocument() + expect(within(rightPanel).getByRole('button', { name: '\u96c6\u6210\u7cfb\u5217\u4ea7\u54c1' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '\u65b0\u589e\u4ea7\u54c1' })).toBeInTheDocument() + }) + + it('shows intake entry tabs and the integrated pending list', async () => { + const actor = userEvent.setup() + renderSelector() + + const rightPanel = screen.getByTestId('right-panel') + const leftPanel = screen.getByTestId('left-panel') + expect(within(rightPanel).getByRole('button', { name: '\u96c6\u6210\u7cfb\u5217\u4ea7\u54c1' })).toBeInTheDocument() + expect(within(rightPanel).getByRole('button', { name: '\u975e\u96c6\u6210\u7cfb\u5217\u4ea7\u54c1' })).toBeInTheDocument() + await actor.click(within(rightPanel).getByRole('button', { name: '\u96c6\u6210\u7cfb\u5217\u4ea7\u54c1' })) + expect(within(leftPanel).getByRole('button', { name: '\u672a\u64cd\u4f5c' })).toBeInTheDocument() + expect(within(leftPanel).getByRole('button', { name: '\u5df2\u64cd\u4f5c' })).toBeInTheDocument() + expect(screen.getByRole('columnheader', { name: 'SPU' })).toBeInTheDocument() + expect(screen.getByRole('columnheader', { name: 'SKU' })).toBeInTheDocument() + }) + + it('does not show preparation packaging or quality sections in editor mode', () => { + renderEditor() + + const rightPanel = screen.getByTestId('right-panel') + expect(within(rightPanel).queryByRole('button', { name: '\u6267\u884c\u51c6\u5907' })).not.toBeInTheDocument() + expect(within(rightPanel).queryByRole('button', { name: '\u5305\u88c5\u6267\u884c' })).not.toBeInTheDocument() + expect(within(rightPanel).queryByRole('button', { name: '\u8d28\u68c0\u786e\u8ba4' })).not.toBeInTheDocument() + }) + + it('paginates integrated sku data by 25 rows', async () => { + const actor = userEvent.setup() + const eligibleSkuList = Array.from({ length: 26 }, (_, index) => ({ + id: `pre-sku-${index + 1}`, + spuCode: 'GBGJ-DNZ0001', + code: `GBGJ-DNZ0001${String(index + 1).padStart(2, '0')}`, + name: `Test SKU ${index + 1}`, + seriesName: 'Single board desk', + draftGroupName: 'Single board desk', + status: '\u5f85\u5904\u7406', + componentCodes: [], + })) + + renderSelector({ eligibleSkuList }) + + await actor.click(screen.getByRole('button', { name: '\u96c6\u6210\u7cfb\u5217\u4ea7\u54c1' })) + + expect(screen.getByText(/\u6bcf\u9875 25 \u6761/)).toBeInTheDocument() + expect(screen.getByText('GBGJ-DNZ000125')).toBeInTheDocument() + expect(screen.queryByText('GBGJ-DNZ000126')).not.toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '\u4e0b\u4e00\u9875' })) + + expect(screen.getByText('GBGJ-DNZ000126')).toBeInTheDocument() + }) + + it('shows locally saved non-integrated products and supports delete', async () => { + const actor = userEvent.setup() + jest.spyOn(window, 'confirm').mockReturnValue(true) + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'DNZ0001-1', + spu: 'DNZ0001', + designer: 'Designer A', + previewImage: 'data:image/png;base64,test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + createdAt: '2026-04-14T10:00:00.000Z', + updatedAt: '2026-04-14T10:00:00.000Z', + }, + ]), + ) + + renderSelector({ eligibleSkuList: [] }) + + const rightPanel = screen.getByTestId('right-panel') + expect(within(rightPanel).getByRole('button', { name: '\u975e\u96c6\u6210\u7cfb\u5217\u4ea7\u54c1' })).toBeInTheDocument() + + expect(screen.getByText('DNZ0001')).toBeInTheDocument() + expect(screen.getByText('Designer A')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '\u4fee\u6539' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '\u5220\u9664' })).toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '\u5220\u9664' })) + + expect(screen.queryByText('DNZ0001')).not.toBeInTheDocument() + }) + + it('shows operation and purchaser fields and filters non-integrated products by query button', async () => { + const actor = userEvent.setup() + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'DNZ0002-1', + spu: 'GBGJ-DNZ0002', + designer: 'Designer B', + operator: 'Operator B', + purchaser: 'Buyer B', + previewImage: 'data:image/png;base64,test-image-2', + previewImageName: 'preview-2.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + createdAt: '2026-04-14T10:00:00.000Z', + updatedAt: '2026-04-14T10:00:00.000Z', + }, + { + id: 'DNZ0001-1', + spu: 'DNZ0001', + designer: 'Designer A', + operator: 'Operator A', + purchaser: 'Buyer A', + previewImage: 'data:image/png;base64:test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + ]), + ) + + renderSelector({ eligibleSkuList: [] }) + + expect(screen.getByText('DNZ0001')).toBeInTheDocument() + expect(screen.getByText('Operator A')).toBeInTheDocument() + expect(screen.getByText('Buyer A')).toBeInTheDocument() + expect(screen.getByLabelText('SPU')).toBeInTheDocument() + expect(screen.getByLabelText('\u8bbe\u8ba1\u5e08')).toBeInTheDocument() + expect(screen.getByLabelText('\u8fd0\u8425')).toBeInTheDocument() + expect(screen.getByLabelText('\u91c7\u8d2d')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '\u67e5\u8be2' })).toBeInTheDocument() + + await actor.type(screen.getByLabelText('\u8fd0\u8425'), 'Operator B') + await actor.click(screen.getByRole('button', { name: '\u67e5\u8be2' })) + + expect(screen.getByText('GBGJ-DNZ0002')).toBeInTheDocument() + expect(screen.queryByText('DNZ0001')).not.toBeInTheDocument() + + await actor.clear(screen.getByLabelText('\u8fd0\u8425')) + await actor.type(screen.getByLabelText('SPU'), 'DNZ0002') + await actor.click(screen.getByRole('button', { name: '\u67e5\u8be2' })) + + expect(screen.getByText('GBGJ-DNZ0002')).toBeInTheDocument() + expect(screen.queryByText('DNZ0001')).not.toBeInTheDocument() + }) + + it('opens a larger preview when clicking the non-integrated product thumbnail', async () => { + const actor = userEvent.setup() + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'DNZ0001-1', + spu: 'DNZ0001', + designer: 'Designer A', + operator: 'Operator A', + purchaser: 'Buyer A', + previewImage: 'data:image/png;base64,test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + ]), + ) + + renderSelector({ eligibleSkuList: [] }) + + await actor.click(screen.getByAltText('DNZ0001 成品预览图')) + + expect(screen.getByRole('dialog', { name: '成品预览图' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '关闭预览' })).toBeInTheDocument() + }) +}) diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx new file mode 100644 index 00000000..ab663846 --- /dev/null +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -0,0 +1,782 @@ +'use client' + +import Link from 'next/link' +import { useEffect, useMemo, useState } from 'react' +import { PackagePlus, Printer } from 'lucide-react' +import { Badge } from '@/components/ui/Badge' +import Button from '@/components/ui/Button' +import { PlatformSplitLayout } from '@/components/platform/PlatformSplitLayout' +import { AccordionSection } from '@/components/platform/AccordionSection' +import { + deleteNonIntegratedProduct, + getSearchableNonIntegratedSpuKeywords, + listNonIntegratedProducts, + NON_INTEGRATED_PRODUCTS_UPDATED, + type NonIntegratedProductRecord, +} from './non-integrated/nonIntegratedStorage' +import type { + CategoryDTO, + DraftGroupDetailDTO, + IntakeProductRecommendationDTO, + WarehouseLocationDTO, +} from '@/server/services' +import type { PlatformMetricItem } from '@/components/platform/types' + +interface IntakeClientSelectorProps { + user: { + id: string + name: string + email: string + role: string + avatar: string | null + department: { id: string; name: string } | null + } + metrics: PlatformMetricItem[] + eligibleDraftGroups: { id: string; name: string; seriesTemplateName: string; code: string }[] + eligibleSkuList?: WorkbenchSku[] + draftGroupId: string | null + hasIneligibleSelected: boolean +} + +interface IntakeClientEditorProps { + user: { + id: string + name: string + email: string + role: string + avatar: string | null + department: { id: string; name: string } | null + } + metrics: PlatformMetricItem[] + detail: DraftGroupDetailDTO + recommendedProducts: IntakeProductRecommendationDTO[] + locations: WarehouseLocationDTO[] + categories: CategoryDTO[] + defaultAttachProductId: string | null +} + +type IntakeClientProps = IntakeClientSelectorProps | IntakeClientEditorProps +type IntakeSection = 'pending' | 'bom' | null +type PendingTabKey = 'pending' | 'completed' | 'paused' +type IntakeEntryTab = 'integrated' | 'nonIntegrated' + +type WorkbenchSku = { + id: string + spuCode: string + code: string + name: string + seriesName: string + draftGroupName: string + status: string + componentCodes: string[] +} + +const PENDING_PAGE_SIZE = 25 + +function isEditorMode(props: IntakeClientProps): props is IntakeClientEditorProps { + return 'detail' in props && props.detail !== undefined +} + +function buildSkuCode(seed: string, index: number) { + return `SKU-${String(index + 1).padStart(3, '0')}-${seed.slice(-4).toUpperCase()}` +} + +function readPreSkuTextField(preSku: DraftGroupDetailDTO['preSkus'][number], key: string) { + const displayValue = preSku.displaySummary?.[key] + if (typeof displayValue === 'string' && displayValue.trim().length > 0) { + return displayValue.trim() + } + + const familyValue = preSku.familySelection?.[key] + if (typeof familyValue === 'string' && familyValue.trim().length > 0) { + return familyValue.trim() + } + + return '' +} + +function mapStatus(status: string) { + if (status === 'FORMALIZED') return '已完成' + if (status === 'READY_FOR_INTAKE') return '待执行' + if (status === 'PACKAGING') return '待入库' + return '待处理' +} + +function mapPendingTab(status: string): PendingTabKey { + if (status === '已完成') return 'completed' + if (status === '暂缓') return 'paused' + return 'pending' +} + +function toggleSection(current: IntakeSection, section: Exclude) { + return current === section ? null : section +} + +function DetailPanel({ + title, + description, + children, +}: { + title: string + description: string + children?: React.ReactNode +}) { + return ( +
+
+

{title}

+

{description}

+
+
{children}
+
+ ) +} + +function ImagePreviewDialog({ + src, + alt, + onClose, +}: { + src: string + alt: string + onClose: () => void +}) { + return ( +
+
event.stopPropagation()} + > +
+

{alt}

+ +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {alt} +
+
+
+ ) +} + +function PendingSkuDetail({ skuList }: { skuList: WorkbenchSku[] }) { + const [activeTab, setActiveTab] = useState('pending') + const [spuKeyword, setSpuKeyword] = useState('') + const [skuKeyword, setSkuKeyword] = useState('') + const [seriesKeyword, setSeriesKeyword] = useState('') + const [currentPage, setCurrentPage] = useState(1) + + const filteredList = useMemo(() => { + return skuList.filter((sku) => { + if (mapPendingTab(sku.status) !== activeTab) return false + + const spuMatched = + spuKeyword.trim().length === 0 || + sku.spuCode.toLowerCase().includes(spuKeyword.trim().toLowerCase()) + const skuMatched = + skuKeyword.trim().length === 0 || + sku.code.toLowerCase().includes(skuKeyword.trim().toLowerCase()) || + sku.name.toLowerCase().includes(skuKeyword.trim().toLowerCase()) + const seriesMatched = + seriesKeyword.trim().length === 0 || + sku.seriesName.toLowerCase().includes(seriesKeyword.trim().toLowerCase()) + + return spuMatched && skuMatched && seriesMatched + }) + }, [activeTab, seriesKeyword, skuKeyword, skuList, spuKeyword]) + + const totalPages = Math.max(1, Math.ceil(filteredList.length / PENDING_PAGE_SIZE)) + const safeCurrentPage = Math.min(currentPage, totalPages) + const pagedList = filteredList.slice( + (safeCurrentPage - 1) * PENDING_PAGE_SIZE, + safeCurrentPage * PENDING_PAGE_SIZE, + ) + + const tabOptions: { key: PendingTabKey; label: string }[] = [ + { key: 'pending', label: '未操作' }, + { key: 'completed', label: '已操作' }, + { key: 'paused', label: '暂缓' }, + ] + return ( +
+
+
+ {tabOptions.map((tab) => ( + + ))} +
+ +
+ + + +
+ +
+
+ +
+
+
+ +
+ {skuList.length === 0 ? ( +
+ 当前还没有草稿组。先去辅助装配完成手动拼装,或回到草稿组继续补齐资料。 +
+ ) : ( +
+ + + + + + + + + + + + + + + {filteredList.length === 0 ? ( + + + + ) : ( + pagedList.map((sku, index) => ( + + + + + + + + + + + )) + )} + +
编号图片上传SPUSKU系列状态操作
+ 当前筛选条件下没有找到 SKU。 +
+ {(safeCurrentPage - 1) * PENDING_PAGE_SIZE + index + 1} + +
+ SKU 图 +
+
+ + {sku.spuCode} +

{sku.code}

+

{sku.name}

+
{sku.seriesName} + + {sku.status} + + +
+ + +
+
+
+ )} +
+ {filteredList.length > 0 ? ( +
+ + 第 {safeCurrentPage} / {totalPages} 页,每页 {PENDING_PAGE_SIZE} 条 + +
+ + +
+
+ ) : null} +
+ ) +} + +function NonIntegratedPendingDetail() { + const [products, setProducts] = useState([]) + const [previewProduct, setPreviewProduct] = useState<{ src: string; alt: string } | null>(null) + const [queryDraft, setQueryDraft] = useState({ + spu: '', + designer: '', + operator: '', + purchaser: '', + }) + const [appliedQuery, setAppliedQuery] = useState({ + spu: '', + designer: '', + operator: '', + purchaser: '', + }) + + useEffect(() => { + function syncProducts() { + setProducts(listNonIntegratedProducts()) + } + + syncProducts() + window.addEventListener(NON_INTEGRATED_PRODUCTS_UPDATED, syncProducts) + return () => window.removeEventListener(NON_INTEGRATED_PRODUCTS_UPDATED, syncProducts) + }, []) + + function handleDelete(productId: string) { + if (!window.confirm('确认删除这条非集成产品测试数据吗?')) { + return + } + + deleteNonIntegratedProduct(productId) + setProducts(listNonIntegratedProducts()) + } + + const filteredProducts = useMemo(() => { + return products.filter((product) => { + const normalizedSpuKeyword = appliedQuery.spu.trim().toUpperCase() + const spuMatched = + normalizedSpuKeyword.length === 0 || + getSearchableNonIntegratedSpuKeywords(product.spu).some((keyword) => + keyword.includes(normalizedSpuKeyword), + ) + const designerMatched = + appliedQuery.designer.trim().length === 0 || + product.designer.toLowerCase().includes(appliedQuery.designer.trim().toLowerCase()) + const operatorMatched = + appliedQuery.operator.trim().length === 0 || + product.operator.toLowerCase().includes(appliedQuery.operator.trim().toLowerCase()) + const purchaserMatched = + appliedQuery.purchaser.trim().length === 0 || + product.purchaser.toLowerCase().includes(appliedQuery.purchaser.trim().toLowerCase()) + + return spuMatched && designerMatched && operatorMatched && purchaserMatched + }) + }, [appliedQuery, products]) + + return ( +
+
+
+
+ + + + +
+ +
+
+ + + +
+
+
+
+ +
+
+ + + + + + + + + + + + + + {filteredProducts.length === 0 ? ( + + + + ) : ( + filteredProducts.map((product, index) => ( + + + + + + + + + + )) + )} + +
序号成品预览图SPU设计师运营采购操作
+ {products.length === 0 + ? '当前还没有非集成产品,点击右上角“新增产品”开始维护资料。' + : '当前查询条件下没有找到资料。'} +
{index + 1} + + {product.spu}{product.designer}{product.operator || '-'}{product.purchaser || '-'} +
+ + + + +
+
+
+
+ {previewProduct ? ( + setPreviewProduct(null)} + /> + ) : null} +
+ ) +} + +function EmptyDetail() { + return ( + +
+ + ) +} + +function BomDetail() { + return ( + +
+ + ) +} + +function FormalIntakeWorkbench({ + activeSection, + onToggle, + pendingEntryTab, + onPendingEntryTabChange, +}: { + activeSection: IntakeSection + onToggle: (section: Exclude) => void + pendingEntryTab: IntakeEntryTab + onPendingEntryTabChange: (tab: IntakeEntryTab) => void +}) { + const pendingEntryOptions: { key: IntakeEntryTab; label: string }[] = [ + { key: 'nonIntegrated', label: '非集成系列产品' }, + { key: 'integrated', label: '集成系列产品' }, + ] + + return ( +
+ } + expanded={activeSection === 'pending'} + onToggle={() => onToggle('pending')} + > +
+ {pendingEntryOptions.map((option) => ( + + ))} +
+
+ + } + expanded={activeSection === 'bom'} + onToggle={() => onToggle('bom')} + /> +
+ ) +} + +export default function IntakeClient(props: IntakeClientProps) { + const [activeSection, setActiveSection] = useState('pending') + const [pendingEntryTab, setPendingEntryTab] = useState('nonIntegrated') + + if (!isEditorMode(props)) { + const skuList = + props.eligibleSkuList && props.eligibleSkuList.length > 0 + ? props.eligibleSkuList + : props.eligibleDraftGroups.slice(0, 3).map((draftGroup, index) => ({ + id: draftGroup.id, + spuCode: draftGroup.code || `SPU-${String(index + 1).padStart(3, '0')}`, + code: `SKU-${String(index + 1).padStart(3, '0')}`, + name: draftGroup.name, + seriesName: draftGroup.seriesTemplateName, + draftGroupName: draftGroup.name, + status: '待确认', + componentCodes: [], + })) + + let leftContent: React.ReactNode = + if (activeSection === 'pending') { + leftContent = + pendingEntryTab === 'integrated' ? ( + + ) : ( + + ) + } else if (activeSection === 'bom') { + leftContent = + } + + return ( + setActiveSection((current) => toggleSection(current, section))} + pendingEntryTab={pendingEntryTab} + onPendingEntryTabChange={(tab) => { + setPendingEntryTab(tab) + setActiveSection('pending') + }} + /> + } + /> + ) + } + + const skuList = props.detail.preSkus.map((preSku, index) => ({ + id: preSku.id, + spuCode: + readPreSkuTextField(preSku, 'spuCode') || + readPreSkuTextField(preSku, 'spu') || + props.detail.draftGroup.code || + `SPU-${String(index + 1).padStart(3, '0')}`, + code: + readPreSkuTextField(preSku, 'skuCode') || + preSku.customName || + preSku.defaultName || + buildSkuCode(preSku.id, index), + name: preSku.customName || preSku.defaultName, + seriesName: + readPreSkuTextField(preSku, 'seriesName') || + props.detail.draftGroup.seriesTemplateName || + props.detail.draftGroup.name, + draftGroupName: props.detail.draftGroup.name, + status: mapStatus(preSku.status), + componentCodes: preSku.componentCodes ?? [], + })) + let leftContent: React.ReactNode = + if (activeSection === 'pending') { + leftContent = + pendingEntryTab === 'integrated' ? ( + + ) : ( + + ) + } else if (activeSection === 'bom') { + leftContent = + } + + return ( + setActiveSection((current) => toggleSection(current, section))} + pendingEntryTab={pendingEntryTab} + onPendingEntryTabChange={(tab) => { + setPendingEntryTab(tab) + setActiveSection('pending') + }} + /> + } + /> + ) +} diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx new file mode 100644 index 00000000..cc33c83b --- /dev/null +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx @@ -0,0 +1,1139 @@ +'use client' + +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import NonIntegratedDetailClient from './NonIntegratedDetailClient' +import { + NON_INTEGRATED_STORAGE_KEY, + type NonIntegratedProductRecord, +} from './nonIntegratedStorage' +import { parseZipFile } from '@/lib/zip-parser' +import { extractPreviewModelFromZip } from '../../../lib/model-preview-zip' + +const push = jest.fn() +const modelPreviewPanelMock = jest.fn() + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push }), +})) + +jest.mock('@/components/platform/ModelPreviewPanel', () => ({ + ModelPreviewPanel: (props: { + title?: string + modelUrl?: string | null + emptyTitle?: string + emptyDescription?: string + enableTranslateGizmo?: boolean + selectableNodeNames?: string[] + selectedNodeName?: string | null + nodePositionOffsets?: unknown[] + nodeRotationStates?: unknown[] + onNodePositionOffsetChange?: (...args: unknown[]) => void + onNodeRotationChange?: (...args: unknown[]) => void + overlayTopRight?: React.ReactNode + overlayBottomCenter?: React.ReactNode + }) => { + modelPreviewPanelMock(props) + return ( +
+
{props.title}
+
{props.modelUrl ? 'model-loaded' : 'model-empty'}
+
{props.emptyTitle}
+
{props.emptyDescription}
+
{props.overlayTopRight}
+
{props.overlayBottomCenter}
+
+ ) + }, +})) + +jest.mock('@/lib/zip-parser', () => ({ + parseZipFile: jest.fn(), +})) + +jest.mock('../../../lib/model-preview-zip', () => ({ + extractPreviewModelFromZip: jest.fn(), +})) + +describe('NonIntegratedDetailClient', () => { + let anchorClickSpy: jest.SpyInstance + + beforeEach(() => { + push.mockReset() + modelPreviewPanelMock.mockReset() + window.localStorage.clear() + anchorClickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + + class MockFileReader { + result: string | ArrayBuffer | null = null + onload: null | (() => void) = null + onerror: null | (() => void) = null + + readAsDataURL(file: File) { + this.result = `data:${file.type};base64,dGVzdA==` + if (this.onload) { + this.onload() + } + } + } + + Object.defineProperty(window, 'FileReader', { + writable: true, + value: MockFileReader, + }) + + Object.defineProperty(window.URL, 'createObjectURL', { + writable: true, + value: jest.fn(() => 'blob:mock-model-url'), + }) + Object.defineProperty(window.URL, 'revokeObjectURL', { + writable: true, + value: jest.fn(), + }) + + ;(parseZipFile as jest.Mock).mockReset() + ;(extractPreviewModelFromZip as jest.Mock).mockReset() + }) + + afterEach(() => { + anchorClickSpy.mockRestore() + }) + + it('creates a local product record and jumps to detail page after submit', async () => { + const actor = userEvent.setup() + + const { container } = render() + + const textInputs = screen.getAllByRole('textbox') + await actor.type(textInputs[0]!, 'DNZ0001') + await actor.type(textInputs[1]!, '测试设计师') + + const imageFile = new File(['image'], 'preview.png', { type: 'image/png' }) + const fileInputs = container.querySelectorAll('input[type="file"]') + await actor.upload(fileInputs[0] as HTMLInputElement, imageFile) + const actionButtons = screen.getAllByRole('button') + await actor.click(actionButtons[1]!) + + await waitFor(() => expect(push).toHaveBeenCalledTimes(1)) + + const savedProducts = JSON.parse( + window.localStorage.getItem(NON_INTEGRATED_STORAGE_KEY) ?? '[]', + ) as NonIntegratedProductRecord[] + + expect(savedProducts).toHaveLength(1) + expect(savedProducts[0]?.spu).toBe('DNZ0001') + expect(savedProducts[0]?.adaptableBrands).toEqual(['基本盘']) + expect(savedProducts[0]?.brand).toBe('基本盘') + expect(savedProducts[0]?.designer).toBe('测试设计师') + expect(savedProducts[0]?.previewImage).toContain('data:image/png;base64,dGVzdA==') + expect(push).toHaveBeenCalledWith( + `/intake/non-integrated/${encodeURIComponent(savedProducts[0]!.id)}`, + ) + }) + + it('shows brand field with adaptable brand control and only allows checked brands', async () => { + const actor = userEvent.setup() + + render() + + const brandSelect = screen.getByLabelText('品牌') + const adaptableBrandButton = screen.getByRole('button', { name: '适配品牌' }) + expect(brandSelect).toHaveValue('基本盘') + expect(adaptableBrandButton).toBeInTheDocument() + + await actor.click(adaptableBrandButton) + await actor.click(screen.getByLabelText('顾家')) + expect(adaptableBrandButton).toHaveAttribute('aria-expanded', 'false') + + await actor.selectOptions(brandSelect, '顾家') + expect(brandSelect).toHaveValue('顾家') + + await actor.click(adaptableBrandButton) + await actor.click(screen.getByLabelText('基本盘')) + await actor.click(screen.getByRole('button', { name: '适配品牌' })) + await actor.click(screen.getByLabelText('顾家')) + expect(brandSelect).toBeDisabled() + }) + + it('adds the selected brand prefix to spu, model and keeps drawing id without the prefix', async () => { + const actor = userEvent.setup() + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'DNZ0001-brand', + spu: 'DNZ0001', + brand: '基本盘', + adaptableBrands: ['基本盘', '顾家'], + designer: '测试设计师', + operator: '测试运营', + purchaser: '测试采购', + previewImage: 'data:image/png;base64,test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + styleDetails: [], + bomDetails: [], + skuItems: [ + { + id: 'sku-brand-1', + skuSequence: 1, + packageGroupId: '', + packageSuffix: '', + image: '', + imageName: '', + model: 'DNZ0001K01', + skuCode: '', + setCount: '1', + specName: '', + barcode: 'DNZ000101NQV01', + size: '1/1/1', + color: '胡桃', + material: 'ENF级胶胶木', + attributeCode: 'A', + drawingId: '', + installMethod: '整装', + executeStandard: '', + manufacturer: '', + manufacturerCode: 'NQ', + tagMaterial: '', + model3dFileName: '', + model3dDataUrl: '', + }, + ], + uploadFiles: [], + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + ]), + ) + + render() + + expect(screen.getByLabelText('SPU')).toHaveValue('DNZ0001') + expect(screen.getByDisplayValue('DNZ0001K01')).toBeInTheDocument() + + await actor.selectOptions(screen.getByLabelText('品牌'), '顾家') + + expect(screen.getByLabelText('SPU')).toHaveValue('GBGJ-DNZ0001') + expect(screen.getByDisplayValue('GBGJ-DNZ0001K01')).toBeInTheDocument() + expect(screen.getAllByDisplayValue('GBGJ-DNZ000101').length).toBeGreaterThan(0) + expect(screen.getByDisplayValue('GBGJ-DNZ000101NQV01')).toBeInTheDocument() + expect(screen.getByDisplayValue('GBGJ-款式一 1/1/1 胡桃')).toBeInTheDocument() + expect(screen.getByDisplayValue('DNZ0001K01AV01')).toBeInTheDocument() + }) + + it('refreshes the first sku barcode when manufacturer code is added after the old auto barcode already exists', async () => { + const actor = userEvent.setup() + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'YMJ0112-brand', + spu: 'GBGJ-YMJ0112', + brand: '顾家', + adaptableBrands: ['基本盘', '顾家'], + designer: '测试设计师', + operator: '测试运营', + purchaser: '测试采购', + previewImage: 'data:image/png;base64,test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + styleDetails: [], + bomDetails: [], + skuItems: [ + { + id: 'sku-barcode-1', + skuSequence: 1, + packageGroupId: '', + packageSuffix: '', + image: '', + imageName: '', + model: 'GBGJ-YMJ0112K01', + skuCode: '', + setCount: '1', + specName: '', + barcode: 'YMJ011201V01', + size: '1/1/1', + color: '1', + material: '1', + attributeCode: 'A', + drawingId: '', + installMethod: '', + executeStandard: '', + manufacturer: '', + manufacturerCode: 'N100', + tagMaterial: '', + model3dFileName: '', + model3dDataUrl: '', + }, + ], + uploadFiles: [], + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + ]), + ) + + render() + + expect(screen.getByDisplayValue('GBGJ-YMJ011201N100V01')).toBeInTheDocument() + }) + + it('opens a larger preview in edit mode when clicking the product image', async () => { + const actor = userEvent.setup() + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'DNZ0001-1', + spu: 'DNZ0001', + designer: '测试设计师', + operator: '测试运营', + purchaser: '测试采购', + previewImage: 'data:image/png;base64,test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + styleDetails: [], + bomDetails: [], + skuItems: [], + uploadFiles: [], + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + ]), + ) + + render() + + await actor.click(screen.getByAltText('成品预览图')) + + expect(screen.getByRole('dialog', { name: '成品预览图' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '关闭预览' })).toBeInTheDocument() + }) + + it('shows a small 3d preview inside supplementary section and loads the uploaded model in edit mode', async () => { + const actor = userEvent.setup() + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'DNZ0001-2', + spu: 'DNZ0001', + designer: '测试设计师', + operator: '测试运营', + purchaser: '测试采购', + previewImage: 'data:image/png;base64,test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + styleDetails: [], + bomDetails: [], + skuItems: [], + uploadFiles: [], + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + ]), + ) + + const { container } = render() + + expect(screen.getByText('补充内容')).toBeInTheDocument() + expect(screen.getByTestId('supplement-layout')).toHaveClass('xl:grid-cols-2') + expect(screen.getByTestId('supplement-form-panel')).not.toHaveClass('h-full') + expect(screen.getByTestId('supplement-preview-panel')).toHaveClass('self-start') + expect(screen.getByTestId('supplement-preview-panel')).toHaveClass('min-h-[360px]') + expect(screen.getByTestId('supplement-preview-panel')).toHaveClass('xl:h-[460px]') + expect(screen.getByTestId('non-integrated-model-preview')).toBeInTheDocument() + expect(screen.getByText('3D预览')).toBeInTheDocument() + expect(screen.getByText('model-empty')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '上传3D模型' })).toBeInTheDocument() + expect(modelPreviewPanelMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + enableTranslateGizmo: true, + selectableNodeNames: [], + selectedNodeName: null, + nodePositionOffsets: [], + nodeRotationStates: [], + onNodePositionOffsetChange: expect.any(Function), + onNodeRotationChange: expect.any(Function), + }), + ) + + const modelFile = new File(['glb-model'], 'chair.glb', { type: 'model/gltf-binary' }) + const fileInputs = container.querySelectorAll('input[type="file"]') + await actor.upload(fileInputs[fileInputs.length - 1] as HTMLInputElement, modelFile) + + await waitFor(() => expect(screen.getByText('model-loaded')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: '替换3D模型' })).toBeInTheDocument() + }) + it('parses zip upload and loads extracted glb model in edit mode', async () => { + const actor = userEvent.setup() + ;(extractPreviewModelFromZip as jest.Mock).mockResolvedValue({ + success: true, + modelFile: new File(['glb-model'], 'from-zip.glb', { type: 'model/gltf-binary' }), + }) + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'DNZ0001-3', + spu: 'DNZ0001', + designer: 'designer', + operator: '娴嬭瘯杩愯惀', + purchaser: '娴嬭瘯閲囪喘', + previewImage: 'data:image/png;base64,test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + styleDetails: [], + bomDetails: [], + skuItems: [], + uploadFiles: [], + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + ]), + ) + + const { container } = render() + + const zipInput = container.querySelector('input[accept*=".zip"]') as HTMLInputElement | null + expect(zipInput).not.toBeNull() + + const zipFile = new File(['zip-content'], 'model.zip', { type: 'application/zip' }) + await actor.upload(zipInput as HTMLInputElement, zipFile) + + await waitFor(() => expect(extractPreviewModelFromZip).toHaveBeenCalledWith(zipFile)) + await waitFor(() => expect(screen.getByText('model-loaded')).toBeInTheDocument()) + expect(screen.getByText('from-zip.glb')).toBeInTheDocument() + }) + + it('loads extracted obj model from zip in edit mode', async () => { + const actor = userEvent.setup() + ;(extractPreviewModelFromZip as jest.Mock).mockResolvedValue({ + success: true, + modelFile: new File(['obj-model'], 'from-zip.obj', { type: 'model/obj' }), + }) + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'DNZ0001-4', + spu: 'DNZ0001', + designer: 'designer', + operator: 'operator', + purchaser: 'purchaser', + previewImage: 'data:image/png;base64,test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + styleDetails: [], + bomDetails: [], + skuItems: [], + uploadFiles: [], + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + ]), + ) + + const { container } = render() + + const zipInput = container.querySelector('input[accept*=".zip"]') as HTMLInputElement | null + expect(zipInput).not.toBeNull() + + const zipFile = new File(['zip-content'], 'model.zip', { type: 'application/zip' }) + await actor.upload(zipInput as HTMLInputElement, zipFile) + + await waitFor(() => expect(extractPreviewModelFromZip).toHaveBeenCalledWith(zipFile)) + await waitFor(() => expect(screen.getByText('model-loaded')).toBeInTheDocument()) + expect(screen.getByText('from-zip.obj')).toBeInTheDocument() + expect(modelPreviewPanelMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + modelUrl: 'blob:mock-model-url#from-zip.obj', + }), + ) + }) + + it('shows 3d upload error beside the preview area', async () => { + const actor = userEvent.setup() + ;(extractPreviewModelFromZip as jest.Mock).mockResolvedValue({ + success: false, + error: '压缩包里没有可预览的 3D 模型', + }) + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'DNZ0001-5', + spu: 'DNZ0001', + designer: 'designer', + operator: 'operator', + purchaser: 'purchaser', + previewImage: 'data:image/png;base64,test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + styleDetails: [], + bomDetails: [], + skuItems: [], + uploadFiles: [], + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + ]), + ) + + const { container } = render() + + const zipInput = container.querySelector('input[accept*=".zip"]') as HTMLInputElement | null + expect(zipInput).not.toBeNull() + + const zipFile = new File(['zip-content'], 'model.zip', { type: 'application/zip' }) + await actor.upload(zipInput as HTMLInputElement, zipFile) + + const feedback = await screen.findByTestId('model-3d-feedback') + expect(feedback).toHaveTextContent('压缩包里没有可预览的 3D 模型') + }) + + it('reuses the style default model for another sku with the same model', async () => { + const actor = userEvent.setup() + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'DNZ0001-6', + spu: 'DNZ0001', + designer: 'designer', + operator: 'operator', + purchaser: 'purchaser', + previewImage: 'data:image/png;base64,test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + styleDetails: [ + { + styleKey: 'K01', + installMethod: '', + installSteps: '', + packageDetail: '', + craftDetail: '', + model3dFileName: 'style-default.glb', + model3dDataUrl: 'data:model/gltf-binary;base64,AAAA', + }, + ], + bomDetails: [], + skuItems: [ + { + id: 'sku-1', + skuSequence: 1, + packageGroupId: '', + packageSuffix: '', + image: '', + imageName: '', + model: 'YMJ0110K01', + skuCode: 'YMJ011001', + setCount: '1', + specName: '', + barcode: '', + size: '1/1/1', + color: '胡桃', + material: '板木', + attributeCode: '', + drawingId: '', + installMethod: '', + executeStandard: '', + manufacturer: '', + manufacturerCode: '', + tagMaterial: '', + }, + { + id: 'sku-2', + skuSequence: 2, + packageGroupId: '', + packageSuffix: '', + image: '', + imageName: '', + model: 'YMJ0110K01', + skuCode: 'YMJ011002', + setCount: '1', + specName: '', + barcode: '', + size: '1/1/2', + color: '原木', + material: '板木', + attributeCode: '', + drawingId: '', + installMethod: '', + executeStandard: '', + manufacturer: '', + manufacturerCode: '', + tagMaterial: '', + }, + ], + uploadFiles: [], + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + ]), + ) + + render() + + expect(screen.getByText('style-default.glb')).toBeInTheDocument() + await actor.selectOptions(screen.getByLabelText('选择SKU'), 'sku-2') + expect(screen.getByText('style-default.glb')).toBeInTheDocument() + }) + + it('uploads a sku-only model override when the style default model already exists', async () => { + const actor = userEvent.setup() + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'DNZ0001-7', + spu: 'DNZ0001', + designer: 'designer', + operator: 'operator', + purchaser: 'purchaser', + previewImage: 'data:image/png;base64,test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + styleDetails: [ + { + styleKey: 'K01', + installMethod: '', + installSteps: '', + packageDetail: '', + craftDetail: '', + model3dFileName: 'style-default.glb', + model3dDataUrl: 'data:model/gltf-binary;base64,AAAA', + }, + ], + bomDetails: [], + skuItems: [ + { + id: 'sku-1', + skuSequence: 1, + packageGroupId: '', + packageSuffix: '', + image: '', + imageName: '', + model: 'YMJ0110K01', + skuCode: 'YMJ011001', + setCount: '1', + specName: '', + barcode: '', + size: '1/1/1', + color: '胡桃', + material: '板木', + attributeCode: '', + drawingId: '', + installMethod: '', + executeStandard: '', + manufacturer: '', + manufacturerCode: '', + tagMaterial: '', + }, + { + id: 'sku-2', + skuSequence: 2, + packageGroupId: '', + packageSuffix: '', + image: '', + imageName: '', + model: 'YMJ0110K01', + skuCode: 'YMJ011002', + setCount: '1', + specName: '', + barcode: '', + size: '1/1/2', + color: '原木', + material: '板木', + attributeCode: '', + drawingId: '', + installMethod: '', + executeStandard: '', + manufacturer: '', + manufacturerCode: '', + tagMaterial: '', + }, + ], + uploadFiles: [], + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + ]), + ) + + const { container } = render() + + expect(screen.getByText('style-default.glb')).toBeInTheDocument() + + const modelFile = new File(['glb-model'], 'sku-only.glb', { type: 'model/gltf-binary' }) + const fileInputs = container.querySelectorAll('input[type="file"]') + await actor.upload(fileInputs[fileInputs.length - 1] as HTMLInputElement, modelFile) + + await waitFor(() => expect(screen.getByText('sku-only.glb')).toBeInTheDocument()) + await actor.selectOptions(screen.getByLabelText('选择SKU'), 'sku-2') + expect(screen.getByText('style-default.glb')).toBeInTheDocument() + }) + + it('shows a download action after uploading an attachment file', async () => { + const actor = userEvent.setup() + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'DNZ0001-8', + spu: 'DNZ0001', + designer: 'designer', + operator: 'operator', + purchaser: 'purchaser', + previewImage: 'data:image/png;base64,test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + styleDetails: [], + bomDetails: [], + skuItems: [], + uploadFiles: [], + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + ]), + ) + + const { container } = render() + + await actor.click(screen.getByRole('button', { name: '文件上传' })) + + const file = new File(['zip-content'], '100台面.zip', { type: 'application/zip' }) + const attachmentInput = container.querySelector('input[type="file"][multiple]') as HTMLInputElement | null + expect(attachmentInput).not.toBeNull() + await actor.upload(attachmentInput as HTMLInputElement, file) + + expect(await screen.findByText('1. 100台面.zip')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '下载' })).toBeInTheDocument() + }) + it('classifies uploaded files into the lower document panels', async () => { + const actor = userEvent.setup() + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'DNZ0001-9', + spu: 'DNZ0001', + designer: 'designer', + operator: 'operator', + purchaser: 'purchaser', + previewImage: 'data:image/png;base64,test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + styleDetails: [], + bomDetails: [], + skuItems: [], + uploadFiles: [], + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + ]), + ) + + const { container } = render() + + await actor.click(screen.getByRole('button', { name: '文件上传' })) + + const attachmentInput = container.querySelector('input[type="file"][multiple]') as HTMLInputElement | null + expect(attachmentInput).not.toBeNull() + + await actor.upload(attachmentInput as HTMLInputElement, [ + new File(['sheet'], '下单表-01.xlsx', { type: 'application/vnd.ms-excel' }), + new File(['pdf'], '安装说明.pdf', { type: 'application/pdf' }), + ]) + + expect(await screen.findByText('1. 下单表-01.xlsx')).toBeInTheDocument() + expect(screen.getByText('2. 安装说明.pdf')).toBeInTheDocument() + + const infoPanel = screen.getByTestId('upload-panel-产品资料') + const installPanel = screen.getByTestId('upload-panel-安装资料') + + expect(within(infoPanel).getByText('下单表-01.xlsx')).toBeInTheDocument() + expect(within(installPanel).getByText('安装说明.pdf')).toBeInTheDocument() + + await actor.selectOptions(screen.getByLabelText('安装说明.pdf 分类'), '检测报告') + + await waitFor(() => { + expect(screen.getByText('已归类到:检测报告')).toBeInTheDocument() + }) + + const reportPanel = screen.getByTestId('upload-panel-检测报告') + expect(within(reportPanel).getByText('安装说明.pdf')).toBeInTheDocument() + expect(within(installPanel).queryByText('安装说明.pdf')).not.toBeInTheDocument() + }) + + it('supports selecting multiple files and batch downloading them', async () => { + const actor = userEvent.setup() + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'DNZ0001-10', + spu: 'DNZ0001', + designer: 'designer', + operator: 'operator', + purchaser: 'purchaser', + previewImage: 'data:image/png;base64,test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + styleDetails: [], + bomDetails: [], + skuItems: [], + uploadFiles: [], + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + ]), + ) + + const { container } = render() + + await actor.click(screen.getByRole('button', { name: '文件上传' })) + + const attachmentInput = container.querySelector('input[type="file"][multiple]') as HTMLInputElement | null + expect(attachmentInput).not.toBeNull() + + await actor.upload(attachmentInput as HTMLInputElement, [ + new File(['sheet'], '资料一.xlsx', { type: 'application/vnd.ms-excel' }), + new File(['sheet'], '资料二.xlsx', { type: 'application/vnd.ms-excel' }), + ]) + + expect(await screen.findByText('1. 资料一.xlsx')).toBeInTheDocument() + expect(screen.getByText('2. 资料二.xlsx')).toBeInTheDocument() + + const batchDownloadButton = screen.getByRole('button', { name: '批量下载已选' }) + expect(batchDownloadButton).toBeDisabled() + + await actor.click(screen.getByLabelText('选择 资料一.xlsx')) + await actor.click(screen.getByLabelText('选择 资料二.xlsx')) + + expect(batchDownloadButton).toBeEnabled() + + await actor.click(batchDownloadButton) + + expect(anchorClickSpy).toHaveBeenCalledTimes(2) + }) + + it('supports batch updating category for selected files', async () => { + const actor = userEvent.setup() + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'DNZ0001-11', + spu: 'DNZ0001', + designer: 'designer', + operator: 'operator', + purchaser: 'purchaser', + previewImage: 'data:image/png;base64,test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + styleDetails: [], + bomDetails: [], + skuItems: [], + uploadFiles: [], + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + ]), + ) + + const { container } = render() + + await actor.click(screen.getByRole('button', { name: '文件上传' })) + + const attachmentInput = container.querySelector('input[type="file"][multiple]') as HTMLInputElement | null + expect(attachmentInput).not.toBeNull() + + await actor.upload(attachmentInput as HTMLInputElement, [ + new File(['img'], '图片一.png', { type: 'image/png' }), + new File(['img'], '图片二.png', { type: 'image/png' }), + ]) + + expect(await screen.findByText('1. 图片一.png')).toBeInTheDocument() + expect(screen.getByText('2. 图片二.png')).toBeInTheDocument() + + await actor.click(screen.getByLabelText('选择 图片一.png')) + await actor.click(screen.getByLabelText('选择 图片二.png')) + + await actor.selectOptions(screen.getByLabelText('批量分类'), '产品实拍') + + await waitFor(() => { + expect(screen.getAllByText('已归类到:产品实拍')).toHaveLength(2) + }) + }) + + it('supports batch downloading files inside a single category panel', async () => { + const actor = userEvent.setup() + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'DNZ0001-12', + spu: 'DNZ0001', + designer: 'designer', + operator: 'operator', + purchaser: 'purchaser', + previewImage: 'data:image/png;base64,test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + styleDetails: [], + bomDetails: [], + skuItems: [], + uploadFiles: [ + { + id: 'upload-1', + name: '资料一.xlsx', + category: '产品资料', + dataUrl: 'data:application/vnd.ms-excel;base64,dGVzdA==', + mimeType: 'application/vnd.ms-excel', + }, + { + id: 'upload-2', + name: '资料二.xlsx', + category: '产品资料', + dataUrl: 'data:application/vnd.ms-excel;base64,dGVzdA==', + mimeType: 'application/vnd.ms-excel', + }, + { + id: 'upload-3', + name: '安装说明.pdf', + category: '安装资料', + dataUrl: 'data:application/pdf;base64,dGVzdA==', + mimeType: 'application/pdf', + }, + ], + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + ]), + ) + + render() + + anchorClickSpy.mockClear() + + const infoPanel = screen.getByTestId('upload-panel-产品资料') + await actor.click(within(infoPanel).getByRole('button', { name: '批量下载' })) + + expect(anchorClickSpy).toHaveBeenCalledTimes(2) + }) + + it('keeps save and submit actions available in edit mode after uploading files', async () => { + const actor = userEvent.setup() + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'DNZ0001-13', + spu: 'DNZ0001', + designer: 'designer', + operator: 'operator', + purchaser: 'purchaser', + previewImage: 'data:image/png;base64,test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + styleDetails: [], + bomDetails: [], + skuItems: [], + uploadFiles: [], + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + ]), + ) + + const { container } = render() + + await actor.click(screen.getByRole('button', { name: '文件上传' })) + + const attachmentInput = container.querySelector('input[type="file"][multiple]') as HTMLInputElement | null + expect(attachmentInput).not.toBeNull() + + await actor.upload( + attachmentInput as HTMLInputElement, + new File(['sheet'], '资料一.xlsx', { type: 'application/vnd.ms-excel' }), + ) + + expect(await screen.findByText('1. 资料一.xlsx')).toBeInTheDocument() + + const saveButton = screen.getByRole('button', { name: '保存' }) + const submitButton = screen.getByRole('button', { name: '提交完成' }) + + expect(saveButton).toBeEnabled() + expect(submitButton).toBeEnabled() + }) + + it('shows a clear error when local test storage cannot persist uploaded files', async () => { + const actor = userEvent.setup() + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([ + { + id: 'DNZ0001-14', + spu: 'DNZ0001', + designer: 'designer', + operator: 'operator', + purchaser: 'purchaser', + previewImage: 'data:image/png;base64,test-image', + previewImageName: 'preview.png', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', + styleDetails: [], + bomDetails: [], + skuItems: [], + uploadFiles: [], + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + ]), + ) + + const setItemSpy = jest + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(() => { + throw new DOMException('Quota exceeded', 'QuotaExceededError') + }) + + const { container } = render() + + await actor.click(screen.getByRole('button', { name: '文件上传' })) + + const attachmentInput = container.querySelector('input[type="file"][multiple]') as HTMLInputElement | null + expect(attachmentInput).not.toBeNull() + + await actor.upload( + attachmentInput as HTMLInputElement, + new File(['sheet'], '资料一.xlsx', { type: 'application/vnd.ms-excel' }), + ) + + expect(await screen.findByText('1. 资料一.xlsx')).toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '保存' })) + + expect( + await screen.findByText('本地测试存储空间已满,当前上传文件过大或过多,保存失败。'), + ).toBeInTheDocument() + + setItemSpy.mockRestore() + }) +}) diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx new file mode 100644 index 00000000..181e9f77 --- /dev/null +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -0,0 +1,2435 @@ + +'use client' + +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import type { ChangeEvent, RefObject } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel' +import Button from '@/components/ui/Button' +import type { NodePositionOffset, NodeRotationState } from '@/lib/3d/engine' +import { extractPreviewModelFromZip } from '../../../lib/model-preview-zip' +import { + applyBrandPrefixToNonIntegratedCode, + buildNonIntegratedBomRowId, + buildNonIntegratedProductId, + buildNonIntegratedSkuId, + buildNonIntegratedUploadFileId, + NON_INTEGRATED_DUPLICATE_SPU_MESSAGE, + getNonIntegratedProductById, + saveNonIntegratedProduct, + stripNonIntegratedBrandPrefix, + type NonIntegratedBomDetail, + type NonIntegratedBomRow, + type NonIntegratedProductRecord, + type NonIntegratedSkuItem, + type NonIntegratedStyleDetail, + type NonIntegratedUploadFile, +} from './nonIntegratedStorage' + +type BasicFieldType = 'text' | 'textarea' | 'select' +type SkuTabKey = 'skuInfo' | 'fileUpload' +type UploadCategoryKey = + | '未分类' + | '产品资料' + | '产品模型' + | '打样资料' + | '设计卖点' + | '评审资料' + | '产品实拍' + | '安装资料' + | '检测报告' + +type FormValues = { + spu: string + brand: string + adaptableBrands: string[] + designer: string + operator: string + purchaser: string + previewImage: string + previewImageName: string + productCode: string + size: string + color: string + installMethod: string + installSteps: string + tagMaterial: string + packageDetail: string + craftDetail: string +} + +type FieldDefinition = { + key: keyof FormValues + label: string + required?: boolean + type?: BasicFieldType + placeholder?: string + options?: string[] +} + +type StyleDetailForm = { + installMethod: string + installSteps: string + packageDetail: string + craftDetail: string + model3dFileName: string + model3dDataUrl: string +} + +type PreviewNodeOffsetMap = Record +type PreviewNodeRotationMap = Record + +type BomRowFieldKey = Exclude + +const BOM_COLUMNS: { key: BomRowFieldKey; label: string; placeholder: string }[] = [ + { key: 'partCode', label: '配件编码', placeholder: '请输入配件编码' }, + { key: 'partImage', label: '部件图', placeholder: '可填写图片说明' }, + { key: 'partName', label: '部件名称', placeholder: '请输入部件名称' }, + { key: 'structure', label: '所属结构', placeholder: '请输入所属结构' }, + { key: 'material', label: '材质', placeholder: '请输入材质' }, + { key: 'process', label: '加工工艺', placeholder: '请输入加工工艺' }, + { key: 'remark', label: '备注', placeholder: '请输入备注' }, + { key: 'specSize', label: '规格尺寸', placeholder: '请输入规格尺寸' }, + { key: 'quantity', label: '数量', placeholder: '请输入数量' }, +] + +const SKU_TAB_OPTIONS: { key: SkuTabKey; label: string }[] = [ + { key: 'skuInfo', label: 'SKU信息' }, + { key: 'fileUpload', label: '文件上传' }, +] + +const UPLOAD_CATEGORY_OPTIONS: UploadCategoryKey[] = [ + '未分类', + '产品资料', + '产品模型', + '打样资料', + '设计卖点', + '评审资料', + '产品实拍', + '安装资料', + '检测报告', +] + +const UPLOAD_CATEGORY_PANEL_ORDER: Exclude[] = [ + '产品资料', + '产品模型', + '打样资料', + '设计卖点', + '评审资料', + '产品实拍', + '安装资料', + '检测报告', +] + +const BRAND_OPTIONS = ['基本盘', '顾家', '索菲亚', '朴美'] + +const REQUIRED_TOP_FIELDS: FieldDefinition[] = [ + { key: 'spu', label: 'SPU', required: true, placeholder: '请输入 SPU' }, + { key: 'designer', label: '设计师', required: true, placeholder: '请输入设计师' }, + { key: 'operator', label: '运营', placeholder: '请输入运营' }, + { key: 'purchaser', label: '采购', placeholder: '请输入采购' }, +] + +const EXTRA_FIELDS: FieldDefinition[] = [ + { key: 'productCode', label: '货号', placeholder: '请输入货号' }, + { key: 'size', label: '尺寸', placeholder: '请输入尺寸' }, + { key: 'color', label: '颜色', placeholder: '请输入颜色' }, + { key: 'installSteps', label: '安装步骤拆解', type: 'textarea', placeholder: '请输入安装步骤拆解' }, + { + key: 'packageDetail', + label: '包装细节(开箱到每一层)', + type: 'textarea', + placeholder: '请输入包装细节', + }, + { + key: 'craftDetail', + label: '产品独特工艺(选填)', + type: 'textarea', + placeholder: '请输入产品独特工艺', + }, +] + +const EMPTY_FORM_VALUES: FormValues = { + spu: '', + brand: '基本盘', + adaptableBrands: ['基本盘'], + designer: '', + operator: '', + purchaser: '', + previewImage: '', + previewImageName: '', + productCode: '', + size: '', + color: '', + installMethod: '', + installSteps: '', + tagMaterial: '', + packageDetail: '', + craftDetail: '', +} + +const EMPTY_STYLE_DETAIL: StyleDetailForm = { + installMethod: '', + installSteps: '', + packageDetail: '', + craftDetail: '', + model3dFileName: '', + model3dDataUrl: '', +} + +function createEmptySkuItem(): NonIntegratedSkuItem { + return { + id: buildNonIntegratedSkuId(), + skuSequence: 1, + packageGroupId: '', + packageSuffix: '', + image: '', + imageName: '', + model: '', + skuCode: '', + setCount: '1', + specName: '', + barcode: '', + size: '', + color: '', + material: '', + attributeCode: '', + drawingId: '', + installMethod: '', + executeStandard: '', + manufacturer: '', + manufacturerCode: '', + tagMaterial: '', + model3dFileName: '', + model3dDataUrl: '', + } +} + +function buildAutoSkuCode(spu: string, sequence: number) { + const normalizedSpu = spu.trim().toUpperCase() + if (!normalizedSpu) return '' + + const normalizedSequence = Number.isFinite(sequence) && sequence > 0 ? sequence : 1 + return `${normalizedSpu}${String(normalizedSequence).padStart(2, '0')}` +} + +function getPackageSuffixByIndex(index: number) { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + return alphabet[index] ?? '' +} + +function normalizeSetCountValue(value: string) { + const numericValue = Number.parseInt(value.trim(), 10) + if (!Number.isFinite(numericValue) || numericValue <= 1) return 1 + + return Math.min(numericValue, 26) +} + +function getStyleKeyFromModel(model: string) { + const match = model.trim().toUpperCase().match(/K\d{2}$/) + return match?.[0] ?? '' +} + +function toChineseStyleLabel(model: string) { + const match = model.trim().toUpperCase().match(/K(\d{2})$/) + if (!match) return '' + + const styleIndex = Number(match[1]) + if (!Number.isFinite(styleIndex) || styleIndex <= 0) return '' + if (styleIndex <= 10) { + const numerals = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'] + return `款式${numerals[styleIndex - 1]}` + } + if (styleIndex < 20) { + const numerals = ['一', '二', '三', '四', '五', '六', '七', '八', '九'] + return `款式十${numerals[styleIndex - 11]}` + } + if (styleIndex === 20) { + return '款式二十' + } + + return `款式${styleIndex}` +} + +function buildAutoSpecName(item: NonIntegratedSkuItem) { + const brandPrefixMatch = item.model.trim().toUpperCase().match(/^(GB[A-Z]+-)/) + const brandPrefix = brandPrefixMatch?.[1] ?? '' + const styleLabel = toChineseStyleLabel(item.model) + const parts = [styleLabel, item.size.trim(), item.color.trim()].filter(Boolean) + const baseSpecName = [brandPrefix, parts.join(' ')].filter(Boolean).join('') + if (!item.packageSuffix) return baseSpecName + + return [baseSpecName, `${item.packageSuffix}包`].filter(Boolean).join(' ') +} + +function buildAutoDrawingId(item: NonIntegratedSkuItem) { + const model = stripNonIntegratedBrandPrefix(item.model) + const attributeCode = item.attributeCode.trim().toUpperCase() + if (!model) return '' + + return `${model}${attributeCode}V01` +} + +function buildAutoBarcode(skuCode: string, manufacturerCode: string) { + const normalizedSkuCode = skuCode.trim().toUpperCase() + const normalizedManufacturerCode = manufacturerCode.trim().toUpperCase() + if (!normalizedSkuCode) return '' + + return `${normalizedSkuCode}${normalizedManufacturerCode}V01` +} + +function shouldKeepCustomBarcode(currentBarcode: string, skuCode: string, manufacturerCode: string) { + const normalizedBarcode = currentBarcode.trim().toUpperCase() + const normalizedSkuCode = skuCode.trim().toUpperCase() + const normalizedManufacturerCode = manufacturerCode.trim().toUpperCase() + if (!normalizedBarcode || !normalizedSkuCode) return false + + const normalizedBaseSkuCode = stripNonIntegratedBrandPrefix(normalizedSkuCode) + + const autoBarcodeCandidates = new Set([ + buildAutoBarcode(normalizedSkuCode, normalizedManufacturerCode), + buildAutoBarcode(normalizedBaseSkuCode, normalizedManufacturerCode), + buildAutoBarcode(normalizedSkuCode, ''), + buildAutoBarcode(normalizedBaseSkuCode, ''), + ]) + + if (autoBarcodeCandidates.has(normalizedBarcode)) { + return false + } + + return !new RegExp(`^${normalizedSkuCode}[A-Z0-9]*V01$`).test(normalizedBarcode) +} + +function getFileNameWithoutExtension(fileName: string) { + const trimmedName = fileName.trim() + const extensionIndex = trimmedName.lastIndexOf('.') + if (extensionIndex <= 0) return trimmedName.toUpperCase() + + return trimmedName.slice(0, extensionIndex).toUpperCase() +} + +function cloneSkuItemForNextRow(item: NonIntegratedSkuItem): NonIntegratedSkuItem { + return { + ...item, + id: buildNonIntegratedSkuId(), + skuSequence: item.skuSequence + 1, + packageGroupId: '', + packageSuffix: '', + skuCode: '', + specName: '', + barcode: '', + drawingId: '', + installMethod: '', + } +} + +function hydrateSkuSequences(items: NonIntegratedSkuItem[]) { + let nextSequence = 1 + const groupSequenceMap = new Map() + + return items.map((item) => { + if (Number.isFinite(item.skuSequence) && item.skuSequence > 0) { + if (item.packageGroupId) { + groupSequenceMap.set(item.packageGroupId, item.skuSequence) + } + nextSequence = Math.max(nextSequence, item.skuSequence + 1) + return item + } + + if (item.packageGroupId) { + const existingSequence = groupSequenceMap.get(item.packageGroupId) + if (existingSequence) { + return { ...item, skuSequence: existingSequence } + } + } + + const hydratedItem = { ...item, skuSequence: nextSequence } + if (item.packageGroupId) { + groupSequenceMap.set(item.packageGroupId, nextSequence) + } + nextSequence += 1 + return hydratedItem + }) +} + +function createInitialStyleDetails(product: NonIntegratedProductRecord | null) { + const nextStyleDetails: Record = {} + + for (const detail of product?.styleDetails ?? []) { + nextStyleDetails[detail.styleKey] = { + installMethod: detail.installMethod, + installSteps: detail.installSteps, + packageDetail: detail.packageDetail, + craftDetail: detail.craftDetail, + model3dFileName: detail.model3dFileName ?? '', + model3dDataUrl: detail.model3dDataUrl ?? '', + } + } + + for (const item of product?.skuItems ?? []) { + const styleKey = getStyleKeyFromModel(item.model) + if (!styleKey || nextStyleDetails[styleKey]) continue + + nextStyleDetails[styleKey] = { + installMethod: item.installMethod ?? '', + installSteps: product?.installSteps ?? '', + packageDetail: product?.packageDetail ?? '', + craftDetail: product?.craftDetail ?? '', + model3dFileName: '', + model3dDataUrl: '', + } + } + + return nextStyleDetails +} + +function createEmptyBomRow(): NonIntegratedBomRow { + return { + id: buildNonIntegratedBomRowId(), + partCode: '', + partImage: '', + partName: '', + structure: '', + material: '', + process: '', + remark: '', + specSize: '', + quantity: '', + } +} + +function buildAutoBomPartCode(sequence: number) { + const normalizedSequence = Number.isFinite(sequence) && sequence > 0 ? sequence : 1 + return `PJ${String(normalizedSequence).padStart(3, '0')}` +} + +function createInitialBomDetails(product: NonIntegratedProductRecord | null) { + const nextBomDetails: Record = {} + + for (const detail of product?.bomDetails ?? []) { + nextBomDetails[detail.styleKey] = detail.rows.map((row) => ({ ...row })) + } + + for (const item of product?.skuItems ?? []) { + const styleKey = getStyleKeyFromModel(item.model) + if (!styleKey || nextBomDetails[styleKey]) continue + nextBomDetails[styleKey] = [] + } + + return nextBomDetails +} + +function createPackageRowsFromItem(item: NonIntegratedSkuItem, count: number) { + const nextCount = normalizeSetCountValue(String(count)) + const nextGroupId = item.packageGroupId || item.id + + if (nextCount <= 1) { + return [ + { + ...item, + packageGroupId: '', + packageSuffix: '', + setCount: '1', + skuCode: '', + specName: '', + barcode: '', + }, + ] + } + + return Array.from({ length: nextCount }, (_, packageIndex) => ({ + ...item, + id: packageIndex === 0 ? item.id : buildNonIntegratedSkuId(), + skuSequence: item.skuSequence, + packageGroupId: nextGroupId, + packageSuffix: getPackageSuffixByIndex(packageIndex), + setCount: String(nextCount), + skuCode: '', + specName: '', + barcode: '', + })) +} + +function deriveSkuItem( + item: NonIntegratedSkuItem, + index: number, + spu: string, + formValues: FormValues, +): NonIntegratedSkuItem { + const baseSequence = Number.isFinite(item.skuSequence) && item.skuSequence > 0 ? item.skuSequence : index + 1 + const autoSkuCode = buildAutoSkuCode(spu, baseSequence) + const finalSkuCode = item.packageSuffix ? `${autoSkuCode}${item.packageSuffix}` : autoSkuCode + const autoSpecName = buildAutoSpecName(item) + const autoDrawingId = buildAutoDrawingId(item) + const autoBarcode = buildAutoBarcode(finalSkuCode, item.manufacturerCode) + + return { + ...item, + skuCode: finalSkuCode, + specName: autoSpecName, + barcode: shouldKeepCustomBarcode(item.barcode, finalSkuCode, item.manufacturerCode) + ? item.barcode.trim() + : autoBarcode, + drawingId: item.drawingId.trim() || autoDrawingId, + installMethod: item.installMethod.trim(), + executeStandard: item.executeStandard.trim(), + manufacturer: item.manufacturer.trim(), + manufacturerCode: item.manufacturerCode.trim().toUpperCase(), + tagMaterial: formValues.tagMaterial.trim() || item.tagMaterial.trim(), + } +} + +function createFormValuesFromProduct(product: NonIntegratedProductRecord | null): FormValues { + if (!product) { + return { ...EMPTY_FORM_VALUES } + } + + return { + spu: product.spu, + brand: product.brand || '基本盘', + adaptableBrands: product.adaptableBrands?.length ? product.adaptableBrands : [product.brand || '基本盘'], + designer: product.designer, + operator: product.operator, + purchaser: product.purchaser, + previewImage: product.previewImage, + previewImageName: product.previewImageName, + productCode: product.productCode, + size: product.size, + color: product.color, + installMethod: product.installMethod, + installSteps: product.installSteps, + tagMaterial: product.tagMaterial, + packageDetail: product.packageDetail, + craftDetail: product.craftDetail, + } +} + +function FieldLabel({ label, required }: { label: string; required?: boolean }) { + return ( + + ) +} + +function BasicField({ + label, + required, + type = 'text', + placeholder, + options, + value, + readOnly = false, + compact = false, + onChange, +}: { + label: string + required?: boolean + type?: BasicFieldType + placeholder?: string + options?: string[] + value: string + readOnly?: boolean + compact?: boolean + onChange: (value: string) => void +}) { + if (type === 'select') { + return ( +
+ + +
+ ) + } + + if (type === 'textarea') { + return ( +
+ +