import { useState, useRef } from 'react'; import * as XLSX from 'xlsx'; import PizZip from 'pizzip'; import Docxtemplater from 'docxtemplater'; import ImageModule from 'open-docxtemplater-image-module-2'; import JSZip from 'jszip'; import { Button } from '@/client/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card'; import { Input } from '@/client/components/ui/input'; import { Label } from '@/client/components/ui/label'; import { Alert, AlertDescription } from '@/client/components/ui/alert'; import { FileText, Upload, Download, Eye, FileWarning, FileSpreadsheet, RefreshCw, CheckCircle, AlertCircle, DownloadCloud, Image, Package, X, ZoomIn, ZoomOut, ChevronLeft, ChevronRight } from 'lucide-react'; import { toast } from 'sonner'; import WordViewer from '@/client/admin-shadcn/components/WordViewer'; import { Badge } from '@/client/components/ui/badge'; import { Progress } from '@/client/components/ui/progress'; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/client/components/ui/dialog'; interface WordFile { id: string; name: string; size: number; url: string; previewUrl?: string; } interface ExcelRow { [key: string]: string | number; } interface ImageMapping { [key: string]: { [imageName: string]: File; }; } interface ProcessingResult { originalFile: File; generatedFiles: Array<{ name: string; content: Blob; fields: Record; }>; total: number; } export default function WordPreview() { const [selectedWordFile, setSelectedWordFile] = useState(null); const [selectedExcelFile, setSelectedExcelFile] = useState(null); const [imageZipFile, setImageZipFile] = useState(null); const [previewFile, setPreviewFile] = useState(null); const [isLoading, setIsLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false); const [showPreview, setShowPreview] = useState(false); const [processingResult, setProcessingResult] = useState(null); const [excelData, setExcelData] = useState([]); const [processingProgress, setProcessingProgress] = useState(0); const [imageMappings, setImageMappings] = useState({}); const [imagePreviewUrls, setImagePreviewUrls] = useState>>({}); const [selectedImage, setSelectedImage] = useState<{ url: string; name: string; folder: string; } | null>(null); const [currentImageIndex, setCurrentImageIndex] = useState(0); const [allImages, setAllImages] = useState>([]); const wordFileInputRef = useRef(null); const excelFileInputRef = useRef(null); const imageZipInputRef = useRef(null); // 文件选择处理 const handleWordFileSelect = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) { const validTypes = [ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ]; const maxSize = 10 * 1024 * 1024; if (!validTypes.includes(file.type)) { toast.error('请选择有效的Word文件(.docx格式)'); return; } if (file.size > maxSize) { toast.error('文件大小超过10MB限制'); return; } setSelectedWordFile(file); setShowPreview(false); toast.success('Word模板已选择'); } }; const handleExcelFileSelect = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) { const validTypes = [ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-excel' ]; const maxSize = 10 * 1024 * 1024; if (!validTypes.includes(file.type)) { toast.error('请选择有效的Excel文件(.xlsx/.xls格式)'); return; } if (file.size > maxSize) { toast.error('文件大小超过10MB限制'); return; } setSelectedExcelFile(file); parseExcelFile(file); toast.success('Excel数据文件已选择'); } }; const handleImageZipSelect = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) { const validTypes = ['application/zip', 'application/x-zip-compressed']; const maxSize = 50 * 1024 * 1024; if (!validTypes.includes(file.type)) { toast.error('请选择有效的ZIP压缩文件'); return; } if (file.size > maxSize) { toast.error('压缩文件大小超过50MB限制'); return; } setImageZipFile(file); await parseImageZip(file); toast.success('图片压缩文件已选择'); } }; // 解析Excel文件 const parseExcelFile = async (file: File) => { try { const data = await file.arrayBuffer(); const workbook = XLSX.read(data, { type: 'array' }); const firstSheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[firstSheetName]; const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][]; if (jsonData.length < 2) { toast.error('Excel文件格式不正确,需要包含表头和至少一行数据'); return; } const headers = jsonData[0] as string[]; const rows: ExcelRow[] = []; for (let i = 1; i < jsonData.length; i++) { const row = jsonData[i]; const rowData: ExcelRow = {}; headers.forEach((header, index) => { rowData[header] = row[index] || ''; }); rows.push(rowData); } setExcelData(rows); toast.success(`成功解析 ${rows.length} 条数据记录`); } catch (error) { toast.error('Excel文件解析失败'); console.error('Excel parsing error:', error); } }; // 解析图片压缩文件 const parseImageZip = async (file: File) => { try { const zip = new JSZip(); const zipContent = await zip.loadAsync(file); const newImageMappings: ImageMapping = {}; const newImagePreviewUrls: Record> = {}; const allImages: Array<{ url: string; name: string; folder: string }> = []; // 解析文件夹结构:第一层为序号,第二层为图片 for (const [path, zipEntry] of Object.entries(zipContent.files)) { if (!zipEntry.dir && isImageFile(path)) { const pathParts = path.split('/'); if (pathParts.length >= 2) { const folderIndex = pathParts[0]; // 序号文件夹 const imageName = pathParts[pathParts.length - 1].split('.')[0]; // 去掉扩展名的文件名 const fullImageName = pathParts[pathParts.length - 1]; // 完整文件名 if (!newImageMappings[folderIndex]) { newImageMappings[folderIndex] = {}; newImagePreviewUrls[folderIndex] = {}; } const imageFile = await zipEntry.async('blob'); newImageMappings[folderIndex][imageName] = new File([imageFile], imageName, { type: getImageMimeType(path) }); // 创建预览URL const previewUrl = URL.createObjectURL(imageFile); newImagePreviewUrls[folderIndex][imageName] = previewUrl; allImages.push({ url: previewUrl, name: fullImageName, folder: folderIndex }); } } } setImageMappings(newImageMappings); setImagePreviewUrls(newImagePreviewUrls); setAllImages(allImages); toast.success(`成功解析 ${Object.keys(newImageMappings).length} 个文件夹的图片`); } catch (error) { toast.error('图片压缩文件解析失败'); console.error('Image zip parsing error:', error); } }; // 工具函数 const isImageFile = (filename: string): boolean => { const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']; return imageExtensions.some(ext => filename.toLowerCase().endsWith(ext)); }; const getImageMimeType = (filename: string): string => { const extension = filename.toLowerCase().split('.').pop(); const mimeTypes: Record = { 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'gif': 'image/gif', 'bmp': 'image/bmp', 'webp': 'image/webp' }; return mimeTypes[extension || ''] || 'image/jpeg'; }; // 替换Word字段并插入图片 const replaceFieldsInWord = async (wordFile: File, excelRow: ExcelRow, rowIndex: number): Promise => { try { const arrayBuffer = await wordFile.arrayBuffer(); const zip = new PizZip(arrayBuffer); // 配置图片模块 const imageOpts = { centered: false, getImage: (tagValue: string) => { console.log('tagValue', tagValue); if (tagValue && typeof tagValue === 'string') { // 从imageMappings中获取对应的图片 const folderIndex = (rowIndex + 1).toString(); const imageFile = imageMappings[folderIndex]?.[tagValue]; if (imageFile) { return imageFile.arrayBuffer(); } } return null; }, getSize: () => [200, 150] // 固定尺寸 }; const imageModule = new ImageModule(imageOpts); const doc = new Docxtemplater(zip, { paragraphLoop: true, linebreaks: true, modules: [imageModule] }) // 处理嵌套数据结构 const processedData: Record = {}; // 处理普通字段 Object.entries(excelRow).forEach(([key, value]) => { if (key.includes('.')) { const parts = key.split('.'); let current = processedData; for (let i = 0; i < parts.length - 1; i++) { if (!current[parts[i]]) { current[parts[i]] = {}; } current = current[parts[i]]; } current[parts[parts.length - 1]] = value; } else { processedData[key] = value; } }); // 处理图片字段 - 直接传递图片名称 const folderIndex = (rowIndex + 1).toString(); if (imageMappings[folderIndex]) { for (const [imageName] of Object.entries(imageMappings[folderIndex])) { processedData[imageName] = imageName; } } doc.render(processedData); const generatedDoc = doc.getZip().generate({ type: 'blob', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', }); return generatedDoc; } catch (error) { console.error('Word处理错误:', error); throw new Error('Word文档处理失败,请检查模板格式'); } }; // 处理文件 const processFiles = async () => { if (!selectedWordFile || !selectedExcelFile || excelData.length === 0) { toast.error('请先选择Word模板和Excel数据文件'); return; } setIsLoading(true); setProcessingProgress(0); try { const generatedFiles: ProcessingResult['generatedFiles'] = []; for (let i = 0; i < excelData.length; i++) { const row = excelData[i]; const processedBlob = await replaceFieldsInWord(selectedWordFile, row, i); const fileName = `processed_${i + 1}_${selectedWordFile.name}`; generatedFiles.push({ name: fileName, content: processedBlob, fields: row }); setProcessingProgress(((i + 1) / excelData.length) * 100); } const result: ProcessingResult = { originalFile: selectedWordFile, generatedFiles, total: generatedFiles.length }; setProcessingResult(result); toast.success(`成功生成 ${generatedFiles.length} 个文档`); } catch (error) { toast.error('文档处理失败'); console.error('Processing error:', error); } finally { setIsLoading(false); setProcessingProgress(0); } }; // 预览功能 const handlePreview = async () => { if (!selectedWordFile) { toast.error('请先选择Word文件'); return; } setPreviewLoading(true); setShowPreview(true); try { const fileUrl = URL.createObjectURL(selectedWordFile); const wordFile: WordFile = { id: Date.now().toString(), name: selectedWordFile.name, size: selectedWordFile.size, url: fileUrl, previewUrl: fileUrl }; setPreviewFile(wordFile); toast.success('正在预览Word模板...'); } catch (error) { toast.error('文件预览失败'); console.error('Preview error:', error); setShowPreview(false); } finally { setPreviewLoading(false); } }; // 下载功能 const downloadProcessedFile = (file: ProcessingResult['generatedFiles'][0]) => { const url = URL.createObjectURL(file.content); const a = document.createElement('a'); a.href = url; a.download = file.name; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; const downloadAllFiles = () => { if (!processingResult) return; processingResult.generatedFiles.forEach((file, index) => { setTimeout(() => { downloadProcessedFile(file); }, index * 500); }); }; const clearAllFiles = () => { setSelectedWordFile(null); setSelectedExcelFile(null); setImageZipFile(null); setExcelData([]); setProcessingResult(null); setImageMappings({}); setImagePreviewUrls({}); setSelectedImage(null); setAllImages([]); setPreviewFile(null); setShowPreview(false); if (wordFileInputRef.current) wordFileInputRef.current.value = ''; if (excelFileInputRef.current) excelFileInputRef.current.value = ''; if (imageZipInputRef.current) imageZipInputRef.current.value = ''; toast.success('已清除所有文件'); }; const formatFileSize = (bytes: number) => { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; // 图片预览功能 const openImagePreview = (url: string, name: string, folder: string) => { const imageIndex = allImages.findIndex(img => img.url === url); setCurrentImageIndex(imageIndex !== -1 ? imageIndex : 0); setSelectedImage({ url, name, folder }); }; const closeImagePreview = () => { setSelectedImage(null); }; const navigateImage = (direction: 'prev' | 'next') => { if (allImages.length === 0) return; let newIndex = currentImageIndex; if (direction === 'prev') { newIndex = currentImageIndex > 0 ? currentImageIndex - 1 : allImages.length - 1; } else { newIndex = currentImageIndex < allImages.length - 1 ? currentImageIndex + 1 : 0; } setCurrentImageIndex(newIndex); setSelectedImage(allImages[newIndex]); }; const getTotalImages = () => { return Object.values(imagePreviewUrls).reduce((total, folder) => total + Object.keys(folder).length, 0); }; return (

Word批量处理工具(增强版)

支持图片压缩文件,自动生成替换字段和图片的文档

{/* 文件上传区域 */}
{/* Word模板上传 */} 选择Word模板 支持 .docx 格式的Word文档,最大10MB
{selectedWordFile && (

文件名: {selectedWordFile.name}

大小: {formatFileSize(selectedWordFile.size)}

)}
{/* Excel数据上传 */} 选择Excel数据 支持 .xlsx/.xls 格式的Excel文档,最大10MB
{selectedExcelFile && (

文件名: {selectedExcelFile.name}

大小: {formatFileSize(selectedExcelFile.size)}

{excelData.length > 0 && (

数据行数: {excelData.length}

)}
)}
{/* 图片压缩文件上传 */} 选择图片压缩包 支持 .zip 格式压缩包,最大50MB
{imageZipFile && (

文件名: {imageZipFile.name}

大小: {formatFileSize(imageZipFile.size)}

{Object.keys(imageMappings).length > 0 && (

文件夹数: {Object.keys(imageMappings).length}

)}
)}
{/* 操作按钮 */} 操作区域 选择文件后执行相应操作
{isLoading && (

正在处理文档... {Math.round(processingProgress)}%

)}
{/* 预览区域 */} {showPreview && selectedWordFile && ( 文档预览 {selectedWordFile.name} )} {/* 图片映射预览 */} {Object.keys(imageMappings).length > 0 && ( 图片映射预览 共 {getTotalImages()} 张图片,点击缩略图查看大图
{Object.entries(imagePreviewUrls).map(([folder, images]) => (

文件夹 {folder} ({Object.keys(images).length} 张图片)

{Object.entries(images).map(([imageName, previewUrl]) => (
openImagePreview(previewUrl, imageName, folder)} >
{imageName}

{imageName}

))}
))}
)} {/* 数据预览 */} {excelData.length > 0 && ( Excel数据预览 显示前5行数据,共 {excelData.length} 行
{Object.keys(excelData[0]).map(header => ( ))} {excelData.slice(0, 5).map((row, index) => ( {Object.values(row).map((value, valueIndex) => ( ))} ))}
{header}
{String(value)}
{excelData.length > 5 && (

还有 {excelData.length - 5} 行数据...

)}
)} {/* 处理结果预览 */} {processingResult && processingResult.generatedFiles.length > 0 && ( 处理结果预览 预览第一个生成的文档 )} {/* 处理结果 */} {processingResult && ( 处理完成 共生成 {processingResult.total} 个文档
{processingResult.generatedFiles.map((file, index) => (

{file.name}

包含 {Object.keys(file.fields).length} 个字段

))}
)} {/* 使用说明 */} 使用说明(增强版)

1. 准备Word模板

使用 {'{字段名}'} 格式作为文本占位符,使用 {'{%image:图片名%}'} 格式作为图片占位符

2. 准备Excel数据

Excel文件第一行为表头,列名应与Word模板中的字段名对应

3. 准备图片压缩包

压缩包结构:第一层为序号文件夹(1,2,3...对应Excel行),第二层为图片文件(文件名对应模板中的图片名)

4. 图片命名规则

例如:模板中使用 {'{%logo%}'},则图片文件应命名为 logo.jpg/png等

{/* 注意事项 */} 注意事项

• Word模板中的字段名必须与Excel表头完全匹配

• 图片文件名必须与模板中的图片占位符匹配(不含扩展名)

• 文件夹序号必须与Excel行号对应(第1行对应文件夹"1")

• 如果图片不存在,对应位置将留空

• 支持jpg、jpeg、png、gif、bmp、webp格式图片

• 图片占位符使用 {'{%图片名%}'} 格式,如 {'{%logo%}'}

{/* 图片查看器模态框 */} 图片预览
{selectedImage?.folder} / {selectedImage?.name} ({currentImageIndex + 1} / {allImages.length})
{selectedImage && (
{selectedImage.name}
{/* 导航按钮 */} {allImages.length > 1 && ( <> )} {/* 底部信息 */}
文件名: {selectedImage.name}
)}
); }