import JSZip from 'jszip' import FileSaver from 'file-saver' import type { QcWorkOrder } from '@/api/qc-work-order' import type { QcInspectionBatch } from '@/utils/qc-inspection-batch' import { ensureUniqueZipEntryName, fetchAttachmentBlob, resolveAttachmentFileName, sanitizeZipPathSegment } from '@/utils/attachment-zip' export type AttachmentZipExportPhase = | 'preparing' | 'downloading' | 'zipping' | 'done' | 'cancelled' export type AttachmentZipExportProgress = { phase: AttachmentZipExportPhase percent: number currentFile?: string currentIndex?: number totalFiles?: number contextHint?: string } export type AttachmentZipExportEntry = { zipPath: string fileUrl: string fileName: string contextHint?: string } export type AttachmentZipExportResult = { added: number failed: number cancelled: boolean } const PREPARING_MAX = 5 const DOWNLOADING_MAX = 85 function isInboundQcWorkOrder(row: QcWorkOrder) { return (row.workOrderType || 'inbound') === 'inbound' } function workOrderFolderName(row: QcWorkOrder, used: Set) { if (isInboundQcWorkOrder(row)) { const labCode = sanitizeZipPathSegment(row.labStorageCode || '', '未命名') const workOrderNo = sanitizeZipPathSegment(row.workOrderNo || row.id, '工单') const base = `入库${labCode}_${workOrderNo}` return ensureUniqueZipEntryName(base, used) } const parts = [sanitizeZipPathSegment(row.workOrderNo || row.id, '工单')] if (row.sampleCode) { parts.push(`样本${sanitizeZipPathSegment(row.sampleCode)}`) } return ensureUniqueZipEntryName(parts.join('_'), used) } function applyInboundLabStorageCodePrefix(code: string, workOrder: QcWorkOrder) { if (!isInboundQcWorkOrder(workOrder)) return code const raw = String(code || '').trim() const workOrderNo = String(workOrder.workOrderNo || '').trim() if (!raw || !workOrderNo || !raw.startsWith(workOrderNo)) return raw const labCode = sanitizeZipPathSegment(workOrder.labStorageCode || '', '未命名') return sanitizeZipPathSegment(`${labCode}${raw.slice(workOrderNo.length)}`, '') } function batchFolderName(batch: QcInspectionBatch, workOrder: QcWorkOrder, used: Set) { const no = sanitizeZipPathSegment( applyInboundLabStorageCodePrefix(batch.batchNo || '', workOrder), '' ) const name = sanitizeZipPathSegment(batch.batchName || '未命名批次') const base = no ? `${no}_${name}` : name || `批次${batch.orderNum ?? 1}` return ensureUniqueZipEntryName(base, used) } function taskFolderName( task: { taskNo?: string; itemName?: string }, workOrder: QcWorkOrder, used: Set ) { const no = sanitizeZipPathSegment( applyInboundLabStorageCodePrefix(task.taskNo || '', workOrder), '' ) const name = sanitizeZipPathSegment(task.itemName || '未命名任务') const base = no ? `${no}_${name}` : name return ensureUniqueZipEntryName(base, used) } export function buildQcWorkOrderZipEntries( workOrders: QcWorkOrder[], batchMap: Record ): AttachmentZipExportEntry[] { const entries: AttachmentZipExportEntry[] = [] const usedWorkOrderFolders = new Set() for (const wo of workOrders) { const woFolder = workOrderFolderName(wo, usedWorkOrderFolders) const batches = batchMap[wo.id] || [] const usedBatchFolders = new Set() for (const batch of batches) { const batchFolder = batchFolderName(batch, wo, usedBatchFolders) const tasks = batch.tasks || [] const usedTaskFolders = new Set() for (const task of tasks) { const files = task.attachmentFiles?.filter((f) => String(f.fileUrl || '').trim()) || [] if (!files.length) continue const taskFolder = taskFolderName(task, wo, usedTaskFolders) const usedFileNames = new Set() const contextHint = [wo.workOrderNo, batch.batchName, task.itemName] .filter(Boolean) .join(' · ') files.forEach((file, fileIndex) => { const fileName = resolveAttachmentFileName(file, fileIndex) const uniqueName = ensureUniqueZipEntryName(fileName, usedFileNames) entries.push({ zipPath: `${woFolder}/${batchFolder}/${taskFolder}/${uniqueName}`, fileUrl: file.fileUrl, fileName, contextHint }) }) } } } return entries } function calcDownloadingPercent(currentIndex: number, totalFiles: number) { if (totalFiles <= 0) return PREPARING_MAX const ratio = Math.min(1, (currentIndex + 1) / totalFiles) return PREPARING_MAX + ratio * (DOWNLOADING_MAX - PREPARING_MAX) } function calcZippingPercent(zipPercent: number) { return DOWNLOADING_MAX + (zipPercent / 100) * (100 - DOWNLOADING_MAX) } export async function runAttachmentZipExport(options: { entries: AttachmentZipExportEntry[] zipFileName: string signal?: AbortSignal onProgress?: (progress: AttachmentZipExportProgress) => void }): Promise { const { entries, zipFileName, signal, onProgress } = options const zip = new JSZip() let added = 0 let failed = 0 const report = (partial: AttachmentZipExportProgress) => { onProgress?.(partial) } const totalFiles = entries.length for (let i = 0; i < entries.length; i += 1) { if (signal?.aborted) { report({ phase: 'cancelled', percent: calcDownloadingPercent(i, totalFiles) }) return { added, failed, cancelled: true } } const entry = entries[i] report({ phase: 'downloading', percent: Math.round(calcDownloadingPercent(i, totalFiles)), currentFile: entry.fileName, currentIndex: i + 1, totalFiles, contextHint: entry.contextHint }) try { const blob = await fetchAttachmentBlob(entry.fileUrl, entry.fileName, { signal }) if (signal?.aborted) { report({ phase: 'cancelled', percent: calcDownloadingPercent(i, totalFiles) }) return { added, failed, cancelled: true } } if (blob) { zip.file(entry.zipPath, blob) added += 1 } else { failed += 1 } } catch { if (signal?.aborted) { report({ phase: 'cancelled', percent: calcDownloadingPercent(i, totalFiles) }) return { added, failed, cancelled: true } } failed += 1 } } if (added === 0) { report({ phase: 'done', percent: 100 }) return { added, failed, cancelled: false } } if (signal?.aborted) { return { added, failed, cancelled: true } } report({ phase: 'zipping', percent: DOWNLOADING_MAX, totalFiles, currentIndex: totalFiles }) const content = await zip.generateAsync({ type: 'blob' }, (metadata) => { if (signal?.aborted) return report({ phase: 'zipping', percent: Math.round(calcZippingPercent(metadata.percent)), totalFiles, currentIndex: totalFiles }) }) if (signal?.aborted) { return { added, failed, cancelled: true } } FileSaver.saveAs(content, zipFileName) report({ phase: 'done', percent: 100, totalFiles, currentIndex: totalFiles }) return { added, failed, cancelled: false } } export function reportPreparingProgress( onProgress?: (progress: AttachmentZipExportProgress) => void ) { onProgress?.({ phase: 'preparing', percent: 2 }) }