From b129acdc4865ea6931cd43da1bb89f645dbef9ad Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 3 Sep 2026 12:06:29 +0800 Subject: [PATCH] feat(finished-product-progress): add filtered export with embedded images Add an export action that collects all rows matching the current filters (or everything when unfiltered), fetches each row's image from object storage, and builds an ExcelJS workbook with the images embedded in the image column. The workbook headers match the import template so the file round-trips through the existing import flow. Export progress, image fetch failures and completion counts are surfaced in a status panel. --- .../FinishedProductProgressClient.tsx | 15 ++- .../FinishedProductExportProgress.tsx | 24 ++++ .../useFinishedProductExport.ts | 106 ++++++++++++++++ .../finished-product-progress-export.test.ts | 71 +++++++++++ .../lib/finished-product-progress-export.ts | 114 ++++++++++++++++++ 5 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/components/FinishedProductExportProgress.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/useFinishedProductExport.ts create mode 100644 furniture-3d-system/src/lib/finished-product-progress-export.test.ts create mode 100644 furniture-3d-system/src/lib/finished-product-progress-export.ts diff --git a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/FinishedProductProgressClient.tsx b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/FinishedProductProgressClient.tsx index c28dcd38..64f4a190 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/FinishedProductProgressClient.tsx +++ b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/FinishedProductProgressClient.tsx @@ -12,6 +12,7 @@ import { } from '@/lib/finished-product-progress' import { FinishedProductColumnSettings } from './components/FinishedProductColumnSettings' import { FinishedProductEditorDrawer } from './components/FinishedProductEditorDrawer' +import { FinishedProductExportProgress } from './components/FinishedProductExportProgress' import { FinishedProductHistoryDialog } from './components/FinishedProductHistoryDialog' import { FinishedProductContractGroupEditor } from './components/FinishedProductContractGroupEditor' import { FinishedProductImportProgress } from './components/FinishedProductImportProgress' @@ -19,6 +20,7 @@ import { FinishedProductMultiSelect } from './components/FinishedProductMultiSel import { FinishedProductPagination } from './components/FinishedProductPagination' import { FinishedProductTable } from './components/FinishedProductTable' import { finishedProductProgressStyles } from './finishedProductProgressStyles' +import { useFinishedProductExport } from './useFinishedProductExport' import { useFinishedProductProgressColumns } from './useFinishedProductProgressColumns' import { useFinishedProductImport } from './useFinishedProductImport' @@ -41,6 +43,8 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca const [pageInput, setPageInput] = useState(String(initialData.pageInfo.page)) const fileRef = useRef(null) const columns = useFinishedProductProgressColumns(currentUserName) + const exportTask = useFinishedProductExport() + const appliedFiltersRef = useRef({ ...EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS }) const pageSelectionKeys = data.rows.flatMap((group) => group.details.map((row) => row.id)) const allSelected = canEdit && pageSelectionKeys.length > 0 && pageSelectionKeys.every((key) => selectedIds.has(key)) @@ -62,10 +66,11 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca if (Array.isArray(value)) value.forEach((entry) => params.append(key, entry)) else if (value) params.set(key, value) }) - const next = await readResponse(await fetch(`/api/supply-chain/finished-product-progress?${params}`, { cache: 'no-store' })) as FinishedProductProgressPage - setData(next) - setFilters(nextFilters) - setSelectedIds(new Set()) + const next = await readResponse(await fetch(`/api/supply-chain/finished-product-progress?${params}`, { cache: 'no-store' })) as FinishedProductProgressPage + setData(next) + setFilters(nextFilters) + appliedFiltersRef.current = nextFilters + setSelectedIds(new Set()) setPageInput(String(next.pageInfo.page)) } catch (error) { window.alert(error instanceof Error ? error.message : '加载失败') } finally { setLoading(false) } }, []) @@ -145,6 +150,7 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca {canEdit && } {canEdit && } {canDownload && } + {canDownload && } {canEdit && <> { const file = event.target.files?.[0]; if (file) void importTask.importFile(file); event.currentTarget.value = '' }} />} @@ -175,6 +181,7 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca {viewer && setViewer(null)} onRowUpdated={updateRow} onSaved={() => setViewer(null)} />} {logs && setLogs(null)} />} + {preview?.imageUrl &&
{ if (event.target === event.currentTarget) setPreview(null) }}>
{preview.productName
} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/components/FinishedProductExportProgress.tsx b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/components/FinishedProductExportProgress.tsx new file mode 100644 index 00000000..e1226fdd --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/components/FinishedProductExportProgress.tsx @@ -0,0 +1,24 @@ +'use client' + +import { CircleAlert, CircleCheck, LoaderCircle, X } from 'lucide-react' +import type { FinishedProductExportState } from '../useFinishedProductExport' + +export function FinishedProductExportProgress({ state, onDismiss }: { state: FinishedProductExportState; onDismiss: () => void }) { + if (state.status === 'idle') return null + const failed = state.status === 'error' + const done = state.status === 'done' + const running = state.status === 'collecting' || state.status === 'images' || state.status === 'building' + + return +} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/useFinishedProductExport.ts b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/useFinishedProductExport.ts new file mode 100644 index 00000000..cc32d769 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/useFinishedProductExport.ts @@ -0,0 +1,106 @@ +'use client' + +import { useCallback, useRef, useState } from 'react' +import type { FinishedProductProgressDetailRow, FinishedProductProgressFilters, FinishedProductProgressPage } from '@/lib/finished-product-progress' + +const PAGE_SIZE = 200 + +export type FinishedProductExportStatus = 'idle' | 'collecting' | 'images' | 'building' | 'done' | 'error' + +export type FinishedProductExportState = { + status: FinishedProductExportStatus + processedRows: number + totalRows: number + processedImages: number + totalImages: number + message: string + failedImages: string[] +} + +const IDLE: FinishedProductExportState = { + status: 'idle', + processedRows: 0, + totalRows: 0, + processedImages: 0, + totalImages: 0, + message: '', + failedImages: [], +} + +export function useFinishedProductExport() { + const [state, setState] = useState(IDLE) + const runningRef = useRef(false) + + const run = useCallback(async (filters: FinishedProductProgressFilters) => { + if (runningRef.current) return + runningRef.current = true + setState({ ...IDLE, status: 'collecting' }) + try { + // 1) 拉取当前筛选下的全部行(跨页) + const rows: FinishedProductProgressDetailRow[] = [] + let page = 1 + let total = Number.POSITIVE_INFINITY + do { + const params = new URLSearchParams({ page: String(page), pageSize: String(PAGE_SIZE) }) + Object.entries(filters).forEach(([key, value]) => { + if (Array.isArray(value)) value.forEach((entry) => params.append(key, entry)) + else if (value) params.set(key, value) + }) + const response = await fetch(`/api/supply-chain/finished-product-progress?${params.toString()}`, { cache: 'no-store' }) + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '导出数据拉取失败') + const pageData = payload.data as FinishedProductProgressPage + pageData.rows.forEach((group) => rows.push(...group.details)) + total = pageData.pageInfo.total + page += 1 + setState((current) => ({ ...current, processedRows: rows.length, totalRows: total })) + } while (rows.length < total) + + // 2) 拉取图片(浏览器会话可直接访问存储签名 URL;单张失败跳过并记录) + const imageRows = rows.filter((row) => row.imageUrl) + setState((current) => ({ ...current, status: 'images', totalImages: imageRows.length })) + const images: Array<{ rowId: string; bytes: Uint8Array; extension: 'png' | 'jpeg' | 'gif' }> = [] + const failedImages: string[] = [] + for (const row of imageRows) { + const label = [row.itemNumber, row.barcode].filter(Boolean).join('/') || row.id + try { + const response = await fetch(row.imageUrl, { cache: 'no-store' }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + const type = (response.headers.get('Content-Type') || '').toLowerCase() + const extension = type.includes('gif') ? 'gif' as const : type.includes('jpeg') ? 'jpeg' as const : 'png' as const + images.push({ rowId: row.id, bytes: new Uint8Array(await response.arrayBuffer()), extension }) + } catch { + failedImages.push(`${label}(图片拉取失败)`) + } + setState((current) => ({ ...current, status: 'images', processedImages: images.length + failedImages.length })) + } + + // 3) 生成工作簿并下载(ExcelJS 按需加载) + setState((current) => ({ ...current, status: 'building' })) + const { createFinishedProductExportWorkbookBuffer } = await import('@/lib/finished-product-progress-export') + const { buffer, rowCount, imageCount } = await createFinishedProductExportWorkbookBuffer({ rows, images }) + const blob = new Blob([buffer as BlobPart], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = `成品进度明细-${new Date().toISOString().slice(0, 10)}.xlsx` + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + URL.revokeObjectURL(url) + setState((current) => ({ + ...current, + status: 'done', + message: `导出完成:${rowCount} 行,嵌入图片 ${imageCount} 张${failedImages.length ? `;图片拉取失败 ${failedImages.length} 行(${failedImages.join('、')})` : ''}`, + })) + } catch (error) { + setState((current) => ({ ...current, status: 'error', message: error instanceof Error ? error.message : '导出失败' })) + } finally { + runningRef.current = false + } + }, []) + + const dismiss = useCallback(() => setState(IDLE), []) + + return { state, run, dismiss } +} diff --git a/furniture-3d-system/src/lib/finished-product-progress-export.test.ts b/furniture-3d-system/src/lib/finished-product-progress-export.test.ts new file mode 100644 index 00000000..e316c76d --- /dev/null +++ b/furniture-3d-system/src/lib/finished-product-progress-export.test.ts @@ -0,0 +1,71 @@ +import { parseFinishedProductProgressImport } from '@/lib/finished-product-progress-import' +import { createFinishedProductExportWorkbookBuffer } from './finished-product-progress-export' +import type { FinishedProductProgressDetailRow } from './finished-product-progress' + +function detailRow(overrides: Partial): FinishedProductProgressDetailRow { + return { + id: overrides.id ?? 'row-1', + orderSequenceNo: '', + orderPlacedDate: '', + completedAt: null, + contractNumber: 'HT-001', + followUpOwner: '', + imageUrl: '', + itemNumber: 'SKU-001', + barcode: '6900001', + productName: '测试产品', + supplier: '测试供应商', + brand: '测试品牌', + orderedQuantity: 100, + inTransitQuantity: 40, + firstDueDate: '2026-09-05', + orderDueDate: '2026-09-10', + realtimeDate: '2026-09-01', + deliveryQuantity: 60, + remarks: '', + currentNode: '送货中', + orderDueDateChangeReason: '', + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-01T00:00:00.000Z', + updatedByName: '测试用户', + deliveryRecords: [], + ...overrides, + } as FinishedProductProgressDetailRow +} + +const PNG_BYTES = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]) + +describe('finished product progress export workbook', () => { + it('produces a workbook whose rows and embedded images round-trip through the import parser', async () => { + const rows = [ + detailRow({ id: 'row-1', orderSequenceNo: 'SDRM001', orderPlacedDate: '2026-08-25', itemNumber: 'SKU-001', barcode: '6900001' }), + detailRow({ id: 'row-2', orderSequenceNo: 'SDRM002', orderPlacedDate: '2026-08-27', itemNumber: 'SKU-002', barcode: '6900002', orderedQuantity: 110, inTransitQuantity: 110, deliveryQuantity: 0 }), + ] + const images = [ + { rowId: 'row-1', bytes: PNG_BYTES, extension: 'png' as const }, + ] + + const { buffer, rowCount, imageCount } = await createFinishedProductExportWorkbookBuffer({ rows, images }) + expect(rowCount).toBe(2) + expect(imageCount).toBe(1) + + const parsed = await parseFinishedProductProgressImport(Buffer.from(buffer)) + expect(parsed.rows).toHaveLength(2) + expect(parsed.embeddedImages).toHaveLength(1) + expect(parsed.embeddedImages[0].contentType).toBe('image/png') + expect(parsed.embeddedImages[0].buffer.byteLength).toBe(PNG_BYTES.byteLength) + expect(parsed.embeddedImages[0].rowNumber).toBe(2) + }) + + it('keeps rows without images importable and omits embedded images', async () => { + const rows = [detailRow({ id: 'row-1' })] + const { imageCount } = await createFinishedProductExportWorkbookBuffer({ rows, images: [] }) + expect(imageCount).toBe(0) + + const { buffer } = await createFinishedProductExportWorkbookBuffer({ rows, images: [] }) + const parsed = await parseFinishedProductProgressImport(Buffer.from(buffer)) + expect(parsed.rows).toHaveLength(1) + expect(parsed.embeddedImages).toHaveLength(0) + expect(parsed.rows[0]?.values.contractNumber).toBe('HT-001') + }) +}) diff --git a/furniture-3d-system/src/lib/finished-product-progress-export.ts b/furniture-3d-system/src/lib/finished-product-progress-export.ts new file mode 100644 index 00000000..b57fa0fe --- /dev/null +++ b/furniture-3d-system/src/lib/finished-product-progress-export.ts @@ -0,0 +1,114 @@ +import ExcelJS from 'exceljs' +import type { FinishedProductProgressDetailRow } from './finished-product-progress' + +export type FinishedProductProgressExportImage = { + rowId: string + bytes: Uint8Array + extension: 'png' | 'jpeg' | 'gif' +} + +export type FinishedProductProgressExportInput = { + rows: FinishedProductProgressDetailRow[] + images: FinishedProductProgressExportImage[] +} + +export type FinishedProductProgressExportResult = { + buffer: Uint8Array + rowCount: number + imageCount: number + imageColumnName: string +} + +const EXPORT_COLUMNS = [ + { header: '订单序号', key: 'orderSequenceNo', width: 14 }, + { header: '下单日期', key: 'orderPlacedDate', width: 12 }, + { header: '合同号', key: 'contractNumber', width: 22 }, + { header: '图片', key: 'imageUrl', width: 14 }, + { header: '货号', key: 'itemNumber', width: 16 }, + { header: '条码', key: 'barcode', width: 20 }, + { header: '产品名称', key: 'productName', width: 28 }, + { header: '供应商', key: 'supplier', width: 18 }, + { header: '品牌', key: 'brand', width: 14 }, + { header: '下单数量', key: 'orderedQuantity', width: 10 }, + { header: '在途数量', key: 'inTransitQuantity', width: 10 }, + { header: '首次交期', key: 'firstDueDate', width: 12 }, + { header: '订单交期(周更)', key: 'orderDueDate', width: 14 }, + { header: '备注', key: 'remarks', width: 24 }, + { header: '跟进人', key: 'followUpOwner', width: 12 }, + { header: '当前节点', key: 'currentNode', width: 14 }, + { header: '完成时间', key: 'completedAt', width: 18 }, +] as const + +const IMAGE_COLUMN_KEY = 'imageUrl' + +function toExcelExtension(extension: FinishedProductProgressExportImage['extension']) { + return extension +} + +export function buildFinishedProductExportWorkbook({ rows, images }: FinishedProductProgressExportInput) { + const imageByRowId = new Map(images.map((image) => [image.rowId, image])) + const workbook = new ExcelJS.Workbook() + workbook.creator = '成品进度明细' + const sheet = workbook.addWorksheet('成品进度明细') + sheet.columns = EXPORT_COLUMNS.map((column) => ({ ...column })) + sheet.views = [{ state: 'frozen', ySplit: 1 }] + sheet.getRow(1).font = { bold: true } + ;['orderSequenceNo', 'contractNumber', 'itemNumber', 'barcode'].forEach((key) => { + sheet.getColumn(key).numFmt = '@' + }) + + const imageColumnIndex = EXPORT_COLUMNS.findIndex((column) => column.key === IMAGE_COLUMN_KEY) + let rowIndex = 1 + let imageCount = 0 + rows.forEach((row) => { + rowIndex += 1 + const image = imageByRowId.get(row.id) + const added = sheet.addRow({ + orderSequenceNo: row.orderSequenceNo ?? '', + orderPlacedDate: row.orderPlacedDate ?? '', + contractNumber: row.contractNumber, + imageUrl: '', + itemNumber: row.itemNumber, + barcode: row.barcode ?? '', + productName: row.productName, + supplier: row.supplier, + brand: row.brand ?? '', + orderedQuantity: row.orderedQuantity, + inTransitQuantity: row.inTransitQuantity, + firstDueDate: row.firstDueDate ?? '', + orderDueDate: row.orderDueDate ?? '', + remarks: row.remarks ?? '', + followUpOwner: row.followUpOwner ?? '', + currentNode: row.currentNode ?? '', + completedAt: row.completedAt ?? '', + }) + if (image) { + added.height = 72 + const bytes = image.bytes instanceof Uint8Array ? image.bytes : new Uint8Array(image.bytes) + const imageId = workbook.addImage({ + buffer: bytes as unknown as ExcelJS.Buffer, + extension: toExcelExtension(image.extension), + }) + // 图片锚定在「图片」列(0 基),导入按列索引读取嵌入图片 + sheet.addImage(imageId, { + tl: { col: imageColumnIndex, row: rowIndex - 1 }, + ext: { width: 88, height: 88 }, + editAs: 'oneCell', + }) + imageCount += 1 + } + }) + + return workbook +} + +export async function createFinishedProductExportWorkbookBuffer(input: FinishedProductProgressExportInput) { + const workbook = buildFinishedProductExportWorkbook(input) + const buffer = (await workbook.xlsx.writeBuffer()) as unknown as Uint8Array + return { + buffer, + rowCount: input.rows.length, + imageCount: input.images.length, + imageColumnName: '图片', + } +}