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: '图片', + } +}