Переглянути джерело

✨ feat(word): 新增增强版Word批量处理工具

- 集成docxtemplater实现Word模板批量填充
- 支持Excel数据源批量生成文档
- 新增图片压缩包解析和动态插入功能
- 支持文件夹序号与Excel行号自动对应
- 提供实时进度显示和处理状态反馈
- 增强预览功能支持模板检查
- 优化文件选择和结果展示界面

✨ feat(word): 重构WordPreview为完整批量处理功能

- 将简单预览功能升级为完整批量处理工具
- 集成docxtemplater、xlsx、jszip等核心库
- 实现Excel数据解析和字段映射
- 支持图片动态插入和文件夹结构映射
- 增加批量下载和进度显示功能
yourname 3 місяців тому
батько
коміт
0ad90fca40

+ 4 - 0
package.json

@@ -54,6 +54,7 @@
     "date-fns": "^4.1.0",
     "dayjs": "^1.11.13",
     "debug": "^4.4.1",
+    "docxtemplater": "^3.65.2",
     "dotenv": "^17.2.1",
     "embla-carousel-react": "^8.6.0",
     "formdata-node": "^6.0.3",
@@ -61,11 +62,13 @@
     "input-otp": "^1.4.2",
     "ioredis": "^5.6.1",
     "jsonwebtoken": "^9.0.2",
+    "jszip": "^3.10.1",
     "lucide-react": "^0.536.0",
     "mammoth": "^1.10.0",
     "minio": "^8.0.5",
     "mysql2": "^3.14.2",
     "next-themes": "^0.4.6",
+    "pizzip": "^3.2.0",
     "rc-upload": "^4.9.2",
     "react": "^19.1.0",
     "react-day-picker": "^9.8.1",
@@ -84,6 +87,7 @@
     "typeorm": "^0.3.25",
     "uuid": "^11.1.0",
     "vaul": "^1.1.2",
+    "xlsx": "^0.18.5",
     "zod": "^4.0.15"
   },
   "devDependencies": {

+ 107 - 0
pnpm-lock.yaml

@@ -140,6 +140,9 @@ importers:
       debug:
         specifier: ^4.4.1
         version: 4.4.1
+      docxtemplater:
+        specifier: ^3.65.2
+        version: 3.65.2
       dotenv:
         specifier: ^17.2.1
         version: 17.2.1
@@ -161,6 +164,9 @@ importers:
       jsonwebtoken:
         specifier: ^9.0.2
         version: 9.0.2
+      jszip:
+        specifier: ^3.10.1
+        version: 3.10.1
       lucide-react:
         specifier: ^0.536.0
         version: 0.536.0(react@19.1.0)
@@ -176,6 +182,9 @@ importers:
       next-themes:
         specifier: ^0.4.6
         version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      pizzip:
+        specifier: ^3.2.0
+        version: 3.2.0
       rc-upload:
         specifier: ^4.9.2
         version: 4.9.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -230,6 +239,9 @@ importers:
       vaul:
         specifier: ^1.1.2
         version: 1.1.2(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      xlsx:
+        specifier: ^0.18.5
+        version: 0.18.5
       zod:
         specifier: ^4.0.15
         version: 4.0.15
@@ -1632,9 +1644,17 @@ packages:
     resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==}
     engines: {node: '>=10.0.0'}
 
+  '@xmldom/xmldom@0.9.8':
+    resolution: {integrity: sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==}
+    engines: {node: '>=14.6'}
+
   '@zxing/text-encoding@0.9.0':
     resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
 
+  adler-32@1.3.1:
+    resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
+    engines: {node: '>=0.8'}
+
   ansi-regex@5.0.1:
     resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
     engines: {node: '>=8'}
@@ -1737,6 +1757,10 @@ packages:
     resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
     engines: {node: '>= 0.4'}
 
+  cfb@1.2.2:
+    resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
+    engines: {node: '>=0.8'}
+
   chownr@3.0.0:
     resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
     engines: {node: '>=18'}
@@ -1765,6 +1789,10 @@ packages:
       react: ^18 || ^19 || ^19.0.0-rc
       react-dom: ^18 || ^19 || ^19.0.0-rc
 
+  codepage@1.15.0:
+    resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
+    engines: {node: '>=0.8'}
+
   color-convert@2.0.1:
     resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
     engines: {node: '>=7.0.0'}
@@ -1797,6 +1825,11 @@ packages:
   core-util-is@1.0.3:
     resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
 
+  crc-32@1.2.2:
+    resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
+    engines: {node: '>=0.8'}
+    hasBin: true
+
   cross-env@7.0.3:
     resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
     engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
@@ -1919,6 +1952,10 @@ packages:
   dingbat-to-unicode@1.0.1:
     resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==}
 
+  docxtemplater@3.65.2:
+    resolution: {integrity: sha512-aDE2D0ir+4K1nruSFtIBbLE4vxyvB/qLNVVlXjVjtI6an4z2Qe5BprxusFsDRH8j6FCz3aXe6qxi3O+cGHnq+Q==}
+    engines: {node: '>=0.10'}
+
   dom-helpers@5.2.1:
     resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
 
@@ -2042,6 +2079,10 @@ packages:
     resolution: {integrity: sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==}
     engines: {node: '>= 18'}
 
+  frac@1.1.2:
+    resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
+    engines: {node: '>=0.8'}
+
   fsevents@2.3.3:
     resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
     engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2431,6 +2472,9 @@ packages:
   pako@1.0.11:
     resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
 
+  pako@2.1.0:
+    resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
+
   path-is-absolute@1.0.1:
     resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
     engines: {node: '>=0.10.0'}
@@ -2450,6 +2494,9 @@ packages:
     resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
     engines: {node: '>=12'}
 
+  pizzip@3.2.0:
+    resolution: {integrity: sha512-X4NPNICxCfIK8VYhF6wbksn81vTiziyLbvKuORVAmolvnUzl1A1xmz9DAWKxPRq9lZg84pJOOAMq3OE61bD8IQ==}
+
   possible-typed-array-names@1.1.0:
     resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
     engines: {node: '>= 0.4'}
@@ -2926,6 +2973,10 @@ packages:
     resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
     engines: {node: '>= 0.6'}
 
+  ssf@0.11.2:
+    resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
+    engines: {node: '>=0.8'}
+
   standard-as-callback@2.1.0:
     resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
 
@@ -3204,6 +3255,14 @@ packages:
     engines: {node: '>= 8'}
     hasBin: true
 
+  wmf@1.0.2:
+    resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
+    engines: {node: '>=0.8'}
+
+  word@0.3.0:
+    resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
+    engines: {node: '>=0.8'}
+
   wrap-ansi@7.0.0:
     resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
     engines: {node: '>=10'}
@@ -3212,6 +3271,11 @@ packages:
     resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
     engines: {node: '>=12'}
 
+  xlsx@0.18.5:
+    resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
+    engines: {node: '>=0.8'}
+    hasBin: true
+
   xml2js@0.6.2:
     resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
     engines: {node: '>=4.0.0'}
@@ -4485,9 +4549,13 @@ snapshots:
 
   '@xmldom/xmldom@0.8.10': {}
 
+  '@xmldom/xmldom@0.9.8': {}
+
   '@zxing/text-encoding@0.9.0':
     optional: true
 
+  adler-32@1.3.1: {}
+
   ansi-regex@5.0.1: {}
 
   ansi-regex@6.1.0: {}
@@ -4635,6 +4703,11 @@ snapshots:
       call-bind-apply-helpers: 1.0.2
       get-intrinsic: 1.3.0
 
+  cfb@1.2.2:
+    dependencies:
+      adler-32: 1.3.1
+      crc-32: 1.2.2
+
   chownr@3.0.0: {}
 
   class-variance-authority@0.7.1:
@@ -4665,6 +4738,8 @@ snapshots:
       - '@types/react'
       - '@types/react-dom'
 
+  codepage@1.15.0: {}
+
   color-convert@2.0.1:
     dependencies:
       color-name: 1.1.4
@@ -4701,6 +4776,8 @@ snapshots:
 
   core-util-is@1.0.3: {}
 
+  crc-32@1.2.2: {}
+
   cross-env@7.0.3:
     dependencies:
       cross-spawn: 7.0.6
@@ -4789,6 +4866,10 @@ snapshots:
 
   dingbat-to-unicode@1.0.1: {}
 
+  docxtemplater@3.65.2:
+    dependencies:
+      '@xmldom/xmldom': 0.9.8
+
   dom-helpers@5.2.1:
     dependencies:
       '@babel/runtime': 7.28.2
@@ -4920,6 +5001,8 @@ snapshots:
 
   formdata-node@6.0.3: {}
 
+  frac@1.1.2: {}
+
   fsevents@2.3.3:
     optional: true
 
@@ -5295,6 +5378,8 @@ snapshots:
 
   pako@1.0.11: {}
 
+  pako@2.1.0: {}
+
   path-is-absolute@1.0.1: {}
 
   path-key@3.1.1: {}
@@ -5308,6 +5393,10 @@ snapshots:
 
   picomatch@4.0.3: {}
 
+  pizzip@3.2.0:
+    dependencies:
+      pako: 2.1.0
+
   possible-typed-array-names@1.1.0: {}
 
   postcss@8.5.6:
@@ -5889,6 +5978,10 @@ snapshots:
 
   sqlstring@2.3.3: {}
 
+  ssf@0.11.2:
+    dependencies:
+      frac: 1.1.2
+
   standard-as-callback@2.1.0: {}
 
   stream-chain@2.2.5: {}
@@ -6122,6 +6215,10 @@ snapshots:
     dependencies:
       isexe: 2.0.0
 
+  wmf@1.0.2: {}
+
+  word@0.3.0: {}
+
   wrap-ansi@7.0.0:
     dependencies:
       ansi-styles: 4.3.0
@@ -6134,6 +6231,16 @@ snapshots:
       string-width: 5.1.2
       strip-ansi: 7.1.0
 
+  xlsx@0.18.5:
+    dependencies:
+      adler-32: 1.3.1
+      cfb: 1.2.2
+      codepage: 1.15.0
+      crc-32: 1.2.2
+      ssf: 0.11.2
+      wmf: 1.0.2
+      word: 0.3.0
+
   xml2js@0.6.2:
     dependencies:
       sax: 1.4.1

+ 783 - 0
src/client/admin-shadcn/pages/EnhancedWordPreview.tsx

@@ -0,0 +1,783 @@
+import { useState, useRef } from 'react';
+import * as XLSX from 'xlsx';
+import PizZip from 'pizzip';
+import Docxtemplater from 'docxtemplater';
+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
+} 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';
+
+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<string, string>;
+  }>;
+  total: number;
+}
+
+export default function EnhancedWordPreview() {
+  const [selectedWordFile, setSelectedWordFile] = useState<File | null>(null);
+  const [selectedExcelFile, setSelectedExcelFile] = useState<File | null>(null);
+  const [imageZipFile, setImageZipFile] = useState<File | null>(null);
+  const [previewFile, setPreviewFile] = useState<WordFile | null>(null);
+  const [isLoading, setIsLoading] = useState(false);
+  const [previewLoading, setPreviewLoading] = useState(false);
+  const [showPreview, setShowPreview] = useState(false);
+  const [processingResult, setProcessingResult] = useState<ProcessingResult | null>(null);
+  const [excelData, setExcelData] = useState<ExcelRow[]>([]);
+  const [processingProgress, setProcessingProgress] = useState(0);
+  const [imageMappings, setImageMappings] = useState<ImageMapping>({});
+  
+  const wordFileInputRef = useRef<HTMLInputElement>(null);
+  const excelFileInputRef = useRef<HTMLInputElement>(null);
+  const imageZipInputRef = useRef<HTMLInputElement>(null);
+
+  // 文件选择处理
+  const handleWordFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
+    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<HTMLInputElement>) => {
+    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<HTMLInputElement>) => {
+    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 = {};
+
+      // 解析文件夹结构:第一层为序号,第二层为图片
+      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]; // 去掉扩展名的文件名
+            
+            if (!newImageMappings[folderIndex]) {
+              newImageMappings[folderIndex] = {};
+            }
+            
+            const imageFile = await zipEntry.async('blob');
+            newImageMappings[folderIndex][imageName] = new File([imageFile], imageName, {
+              type: getImageMimeType(path)
+            });
+          }
+        }
+      }
+
+      setImageMappings(newImageMappings);
+      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<string, string> = {
+      '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<Blob> => {
+    try {
+      const arrayBuffer = await wordFile.arrayBuffer();
+      const zip = new PizZip(arrayBuffer);
+      
+      const doc = new Docxtemplater(zip, {
+        paragraphLoop: true,
+        linebreaks: true,
+      });
+
+      // 处理嵌套数据结构
+      const processedData: Record<string, any> = {};
+      
+      // 处理普通字段
+      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]) {
+        Object.entries(imageMappings[folderIndex]).forEach(([imageName, imageFile]) => {
+          processedData[`image:${imageName}`] = {
+            data: imageFile,
+            width: 200,
+            height: 150
+          };
+        });
+      }
+
+      doc.setData(processedData);
+      doc.render();
+      
+      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({});
+    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];
+  };
+
+  return (
+    <div className="space-y-6">
+      <div>
+        <h1 className="text-3xl font-bold tracking-tight">Word批量处理工具(增强版)</h1>
+        <p className="text-muted-foreground">支持图片压缩文件,自动生成替换字段和图片的文档</p>
+      </div>
+
+      {/* 文件上传区域 */}
+      <div className="grid gap-6 md:grid-cols-3">
+        {/* Word模板上传 */}
+        <Card>
+          <CardHeader>
+            <CardTitle className="flex items-center gap-2">
+              <FileText className="h-5 w-5" />
+              选择Word模板
+            </CardTitle>
+            <CardDescription>
+              支持 .docx 格式的Word文档,最大10MB
+            </CardDescription>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            <div className="grid w-full items-center gap-1.5">
+              <Label htmlFor="word-file">Word模板文件</Label>
+              <Input
+                ref={wordFileInputRef}
+                id="word-file"
+                type="file"
+                accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+                onChange={handleWordFileSelect}
+              />
+            </div>
+
+            {selectedWordFile && (
+              <Alert>
+                <FileText className="h-4 w-4" />
+                <AlertDescription>
+                  <div className="space-y-1">
+                    <p><strong>文件名:</strong> {selectedWordFile.name}</p>
+                    <p><strong>大小:</strong> {formatFileSize(selectedWordFile.size)}</p>
+                  </div>
+                </AlertDescription>
+              </Alert>
+            )}
+          </CardContent>
+        </Card>
+
+        {/* Excel数据上传 */}
+        <Card>
+          <CardHeader>
+            <CardTitle className="flex items-center gap-2">
+              <FileSpreadsheet className="h-5 w-5" />
+              选择Excel数据
+            </CardTitle>
+            <CardDescription>
+              支持 .xlsx/.xls 格式的Excel文档,最大10MB
+            </CardDescription>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            <div className="grid w-full items-center gap-1.5">
+              <Label htmlFor="excel-file">Excel数据文件</Label>
+              <Input
+                ref={excelFileInputRef}
+                id="excel-file"
+                type="file"
+                accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
+                onChange={handleExcelFileSelect}
+              />
+            </div>
+
+            {selectedExcelFile && (
+              <Alert>
+                <FileSpreadsheet className="h-4 w-4" />
+                <AlertDescription>
+                  <div className="space-y-1">
+                    <p><strong>文件名:</strong> {selectedExcelFile.name}</p>
+                    <p><strong>大小:</strong> {formatFileSize(selectedExcelFile.size)}</p>
+                    {excelData.length > 0 && (
+                      <p><strong>数据行数:</strong> {excelData.length}</p>
+                    )}
+                  </div>
+                </AlertDescription>
+              </Alert>
+            )}
+          </CardContent>
+        </Card>
+
+        {/* 图片压缩文件上传 */}
+        <Card>
+          <CardHeader>
+            <CardTitle className="flex items-center gap-2">
+              <Package className="h-5 w-5" />
+              选择图片压缩包
+            </CardTitle>
+            <CardDescription>
+              支持 .zip 格式压缩包,最大50MB
+            </CardDescription>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            <div className="grid w-full items-center gap-1.5">
+              <Label htmlFor="image-zip">图片压缩文件</Label>
+              <Input
+                ref={imageZipInputRef}
+                id="image-zip"
+                type="file"
+                accept=".zip,application/zip,application/x-zip-compressed"
+                onChange={handleImageZipSelect}
+              />
+            </div>
+
+            {imageZipFile && (
+              <Alert>
+                <Image className="h-4 w-4" />
+                <AlertDescription>
+                  <div className="space-y-1">
+                    <p><strong>文件名:</strong> {imageZipFile.name}</p>
+                    <p><strong>大小:</strong> {formatFileSize(imageZipFile.size)}</p>
+                    {Object.keys(imageMappings).length > 0 && (
+                      <p><strong>文件夹数:</strong> {Object.keys(imageMappings).length}</p>
+                    )}
+                  </div>
+                </AlertDescription>
+              </Alert>
+            )}
+          </CardContent>
+        </Card>
+      </div>
+
+      {/* 操作按钮 */}
+      <Card>
+        <CardHeader>
+          <CardTitle>操作区域</CardTitle>
+          <CardDescription>选择文件后执行相应操作</CardDescription>
+        </CardHeader>
+        <CardContent className="space-y-4">
+          <div className="flex gap-2 flex-wrap">
+            <Button
+              onClick={handlePreview}
+              disabled={!selectedWordFile || previewLoading}
+              variant="outline"
+            >
+              <Eye className="h-4 w-4 mr-2" />
+              预览模板
+            </Button>
+            
+            <Button
+              onClick={processFiles}
+              disabled={!selectedWordFile || !selectedExcelFile || excelData.length === 0 || isLoading}
+              className="bg-blue-600 hover:bg-blue-700"
+            >
+              {isLoading ? (
+                <>
+                  <RefreshCw className="h-4 w-4 mr-2 animate-spin" />
+                  处理中...
+                </>
+              ) : (
+                <>
+                  <Upload className="h-4 w-4 mr-2" />
+                  开始处理
+                </>
+              )}
+            </Button>
+
+            <Button
+              onClick={clearAllFiles}
+              variant="outline"
+              className="text-red-600 hover:text-red-700"
+            >
+              清除所有
+            </Button>
+          </div>
+
+          {isLoading && (
+            <div className="space-y-2">
+              <Progress value={processingProgress} className="w-full" />
+              <p className="text-sm text-muted-foreground text-center">
+                正在处理文档... {Math.round(processingProgress)}%
+              </p>
+            </div>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* 图片映射预览 */}
+      {Object.keys(imageMappings).length > 0 && (
+        <Card>
+          <CardHeader>
+            <CardTitle className="flex items-center gap-2">
+              <Image className="h-5 w-5" />
+              图片映射预览
+            </CardTitle>
+            <CardDescription>
+              文件夹结构:序号文件夹 → 图片文件
+            </CardDescription>
+          </CardHeader>
+          <CardContent>
+            <div className="space-y-2 max-h-40 overflow-y-auto">
+              {Object.entries(imageMappings).map(([folder, images]) => (
+                <div key={folder} className="p-2 bg-gray-50 rounded">
+                  <strong>文件夹 {folder}:</strong> {Object.keys(images).join(', ')}
+                </div>
+              ))}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {/* 处理结果 */}
+      {processingResult && (
+        <Card>
+          <CardHeader>
+            <CardTitle className="flex items-center gap-2">
+              <CheckCircle className="h-5 w-5 text-green-500" />
+              处理完成
+            </CardTitle>
+            <CardDescription>
+              共生成 {processingResult.total} 个文档
+            </CardDescription>
+          </CardHeader>
+          <CardContent>
+            <div className="space-y-4">
+              <Button
+                onClick={downloadAllFiles}
+                className="w-full"
+              >
+                <DownloadCloud className="h-4 w-4 mr-2" />
+                下载全部文档
+              </Button>
+              
+              <div className="space-y-2 max-h-64 overflow-y-auto">
+                {processingResult.generatedFiles.map((file, index) => (
+                  <div
+                    key={index}
+                    className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
+                  >
+                    <div>
+                      <p className="font-medium">{file.name}</p>
+                      <p className="text-sm text-muted-foreground">
+                        包含 {Object.keys(file.fields).length} 个字段
+                      </p>
+                    </div>
+                    <Button
+                      variant="ghost"
+                      size="sm"
+                      onClick={() => downloadProcessedFile(file)}
+                    >
+                      <Download className="h-4 w-4" />
+                    </Button>
+                  </div>
+                ))}
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {/* 预览区域 */}
+      {showPreview && selectedWordFile && (
+        <Card>
+          <CardHeader>
+            <CardTitle className="flex items-center gap-2">
+              <FileText className="h-5 w-5" />
+              文档预览
+            </CardTitle>
+            <CardDescription>
+              {selectedWordFile.name}
+            </CardDescription>
+          </CardHeader>
+          <CardContent>
+            <WordViewer file={selectedWordFile} />
+          </CardContent>
+        </Card>
+      )}
+
+      {/* 数据预览 */}
+      {excelData.length > 0 && (
+        <Card>
+          <CardHeader>
+            <CardTitle className="flex items-center gap-2">
+              <FileSpreadsheet className="h-5 w-5" />
+              Excel数据预览
+            </CardTitle>
+            <CardDescription>
+              显示前5行数据,共 {excelData.length} 行
+            </CardDescription>
+          </CardHeader>
+          <CardContent>
+            <div className="overflow-x-auto">
+              <table className="w-full text-sm">
+                <thead>
+                  <tr className="border-b">
+                    {Object.keys(excelData[0]).map(header => (
+                      <th key={header} className="text-left p-2 font-medium">
+                        {header}
+                      </th>
+                    ))}
+                  </tr>
+                </thead>
+                <tbody>
+                  {excelData.slice(0, 5).map((row, index) => (
+                    <tr key={index} className="border-b">
+                      {Object.values(row).map((value, valueIndex) => (
+                        <td key={valueIndex} className="p-2">
+                          {String(value)}
+                        </td>
+                      ))}
+                    </tr>
+                  ))}
+                </tbody>
+              </table>
+              {excelData.length > 5 && (
+                <p className="text-sm text-muted-foreground mt-2 text-center">
+                  还有 {excelData.length - 5} 行数据...
+                </p>
+              )}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {/* 使用说明 */}
+      <Card>
+        <CardHeader>
+          <CardTitle>使用说明(增强版)</CardTitle>
+        </CardHeader>
+        <CardContent>
+          <div className="space-y-3 text-sm">
+            <div>
+              <h4 className="font-medium mb-1">1. 准备Word模板</h4>
+              <p className="text-muted-foreground">
+                使用 {'{{'}字段名{'}'} 格式作为文本占位符,使用 {'{{'}image:图片名{'}'} 格式作为图片占位符
+              </p>
+            </div>
+            
+            <div>
+              <h4 className="font-medium mb-1">2. 准备Excel数据</h4>
+              <p className="text-muted-foreground">
+                Excel文件第一行为表头,列名应与Word模板中的字段名对应
+              </p>
+            </div>
+            
+            <div>
+              <h4 className="font-medium mb-1">3. 准备图片压缩包</h4>
+              <p className="text-muted-foreground">
+                压缩包结构:第一层为序号文件夹(1,2,3...对应Excel行),第二层为图片文件(文件名对应模板中的图片名)
+              </p>
+            </div>
+            
+            <div>
+              <h4 className="font-medium mb-1">4. 图片命名规则</h4>
+              <p className="text-muted-foreground">
+                例如:模板中使用 {'{{'}image:logo{'}'},则图片文件应命名为 logo.jpg/png等
+              </p>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+
+      {/* 注意事项 */}
+      <Card>
+        <CardHeader>
+          <CardTitle className="flex items-center gap-2">
+            <AlertCircle className="h-5 w-5" />
+            注意事项
+          </CardTitle>
+        </CardHeader>
+        <CardContent>
+          <div className="space-y-2 text-sm">
+            <p>• Word模板中的字段名必须与Excel表头完全匹配</p>
+            <p>• 图片文件名必须与模板中的图片占位符匹配(不含扩展名)</p>
+            <p>• 文件夹序号必须与Excel行号对应(第1行对应文件夹"1")</p>
+            <p>• 如果图片不存在,对应位置将留空</p>
+            <p>• 支持jpg、jpeg、png、gif、bmp、webp格式图片</p>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 618 - 109
src/client/admin-shadcn/pages/WordPreview.tsx

@@ -1,13 +1,21 @@
-import { useState } from 'react';
+import { useState, useRef } from 'react';
+import * as XLSX from 'xlsx';
+import PizZip from 'pizzip';
+import Docxtemplater from 'docxtemplater';
+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 } from 'lucide-react';
+import { 
+  FileText, Upload, Download, Eye, FileWarning, FileSpreadsheet, 
+  RefreshCw, CheckCircle, AlertCircle, DownloadCloud, Image, Package
+} from 'lucide-react';
 import { toast } from 'sonner';
 import WordViewer from '@/client/admin-shadcn/components/WordViewer';
-import { AlertCircle } from 'lucide-react';
+import { Badge } from '@/client/components/ui/badge';
+import { Progress } from '@/client/components/ui/progress';
 
 interface WordFile {
   id: string;
@@ -17,44 +25,309 @@ interface WordFile {
   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<string, string>;
+  }>;
+  total: number;
+}
+
 export default function WordPreview() {
-  const [selectedFile, setSelectedFile] = useState<File | null>(null);
+  const [selectedWordFile, setSelectedWordFile] = useState<File | null>(null);
+  const [selectedExcelFile, setSelectedExcelFile] = useState<File | null>(null);
+  const [imageZipFile, setImageZipFile] = useState<File | null>(null);
   const [previewFile, setPreviewFile] = useState<WordFile | null>(null);
   const [isLoading, setIsLoading] = useState(false);
   const [previewLoading, setPreviewLoading] = useState(false);
   const [showPreview, setShowPreview] = useState(false);
+  const [processingResult, setProcessingResult] = useState<ProcessingResult | null>(null);
+  const [excelData, setExcelData] = useState<ExcelRow[]>([]);
+  const [processingProgress, setProcessingProgress] = useState(0);
+  const [imageMappings, setImageMappings] = useState<ImageMapping>({});
+  
+  const wordFileInputRef = useRef<HTMLInputElement>(null);
+  const excelFileInputRef = useRef<HTMLInputElement>(null);
+  const imageZipInputRef = useRef<HTMLInputElement>(null);
 
-  const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
+  // 文件选择处理
+  const handleWordFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
     const file = event.target.files?.[0];
     if (file) {
-      // 检查文件类型和大小
       const validTypes = [
         'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
       ];
-      const maxSize = 10 * 1024 * 1024; // 10MB
-      
+      const maxSize = 10 * 1024 * 1024;
+
       if (!validTypes.includes(file.type)) {
-        if (file.type === 'application/msword') {
-          toast.error('不支持旧的 .doc 格式,请使用 .docx 格式的Word文档');
-        } else {
-          toast.error('请选择有效的Word文件(.docx格式)');
-        }
+        toast.error('请选择有效的Word文件(.docx格式)');
         return;
       }
-      
+
       if (file.size > maxSize) {
         toast.error('文件大小超过10MB限制');
         return;
       }
 
-      setSelectedFile(file);
+      setSelectedWordFile(file);
       setShowPreview(false);
-      toast.success('文件已选择,可以开始预览');
+      toast.success('Word模板已选择');
     }
   };
 
+  const handleExcelFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
+    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<HTMLInputElement>) => {
+    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 = {};
+
+      // 解析文件夹结构:第一层为序号,第二层为图片
+      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]; // 去掉扩展名的文件名
+            
+            if (!newImageMappings[folderIndex]) {
+              newImageMappings[folderIndex] = {};
+            }
+            
+            const imageFile = await zipEntry.async('blob');
+            newImageMappings[folderIndex][imageName] = new File([imageFile], imageName, {
+              type: getImageMimeType(path)
+            });
+          }
+        }
+      }
+
+      setImageMappings(newImageMappings);
+      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<string, string> = {
+      '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<Blob> => {
+    try {
+      const arrayBuffer = await wordFile.arrayBuffer();
+      const zip = new PizZip(arrayBuffer);
+      
+      const doc = new Docxtemplater(zip, {
+        paragraphLoop: true,
+        linebreaks: true,
+      });
+
+      // 处理嵌套数据结构
+      const processedData: Record<string, any> = {};
+      
+      // 处理普通字段
+      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]) {
+        Object.entries(imageMappings[folderIndex]).forEach(([imageName, imageFile]) => {
+          processedData[`image:${imageName}`] = {
+            data: imageFile,
+            width: 200,
+            height: 150
+          };
+        });
+      }
+
+      doc.setData(processedData);
+      doc.render();
+      
+      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 (!selectedFile) {
+    if (!selectedWordFile) {
       toast.error('请先选择Word文件');
       return;
     }
@@ -63,19 +336,19 @@ export default function WordPreview() {
     setShowPreview(true);
     
     try {
-      const fileUrl = URL.createObjectURL(selectedFile);
+      const fileUrl = URL.createObjectURL(selectedWordFile);
       const wordFile: WordFile = {
         id: Date.now().toString(),
-        name: selectedFile.name,
-        size: selectedFile.size,
+        name: selectedWordFile.name,
+        size: selectedWordFile.size,
         url: fileUrl,
         previewUrl: fileUrl
       };
       
       setPreviewFile(wordFile);
-      toast.success('文档解析中...');
+      toast.success('正在预览Word模板...');
     } catch (error) {
-      toast.error('文件预览失败,请重试');
+      toast.error('文件预览失败');
       console.error('Preview error:', error);
       setShowPreview(false);
     } finally {
@@ -83,12 +356,43 @@ export default function WordPreview() {
     }
   };
 
-  const handleClearFile = () => {
-    setSelectedFile(null);
+  // 下载功能
+  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({});
     setPreviewFile(null);
     setShowPreview(false);
-    const input = document.getElementById('word-file') as HTMLInputElement;
-    if (input) input.value = '';
+    
+    if (wordFileInputRef.current) wordFileInputRef.current.value = '';
+    if (excelFileInputRef.current) excelFileInputRef.current.value = '';
+    if (imageZipInputRef.current) imageZipInputRef.current.value = '';
+    
+    toast.success('已清除所有文件');
   };
 
   const formatFileSize = (bytes: number) => {
@@ -102,152 +406,356 @@ export default function WordPreview() {
   return (
     <div className="space-y-6">
       <div>
-        <h1 className="text-3xl font-bold tracking-tight">Word文档在线预览</h1>
-        <p className="text-muted-foreground">上传并预览Word文档内容,支持 .docx 格式</p>
+        <h1 className="text-3xl font-bold tracking-tight">Word批量处理工具(增强版)</h1>
+        <p className="text-muted-foreground">支持图片压缩文件,自动生成替换字段和图片的文档</p>
       </div>
 
-      <div className="grid gap-6 md:grid-cols-2">
-        {/* 文件选择区域 */}
+      {/* 文件上传区域 */}
+      <div className="grid gap-6 md:grid-cols-3">
+        {/* Word模板上传 */}
         <Card>
           <CardHeader>
             <CardTitle className="flex items-center gap-2">
-              <Upload className="h-5 w-5" />
-              选择Word文件
+              <FileText className="h-5 w-5" />
+              选择Word模板
             </CardTitle>
             <CardDescription>
               支持 .docx 格式的Word文档,最大10MB
             </CardDescription>
           </CardHeader>
           <CardContent className="space-y-4">
-            <div className="grid w-full max-w-sm items-center gap-1.5">
-              <Label htmlFor="word-file">Word文档</Label>
+            <div className="grid w-full items-center gap-1.5">
+              <Label htmlFor="word-file">Word模板文件</Label>
               <Input
+                ref={wordFileInputRef}
                 id="word-file"
                 type="file"
                 accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
-                onChange={handleFileSelect}
+                onChange={handleWordFileSelect}
               />
             </div>
 
-            {selectedFile && (
+            {selectedWordFile && (
               <Alert>
                 <FileText className="h-4 w-4" />
                 <AlertDescription>
                   <div className="space-y-1">
-                    <p><strong>文件名:</strong> {selectedFile.name}</p>
-                    <p><strong>大小:</strong> {formatFileSize(selectedFile.size)}</p>
-                    <p><strong>类型:</strong> {selectedFile.type}</p>
+                    <p><strong>文件名:</strong> {selectedWordFile.name}</p>
+                    <p><strong>大小:</strong> {formatFileSize(selectedWordFile.size)}</p>
                   </div>
                 </AlertDescription>
               </Alert>
             )}
+          </CardContent>
+        </Card>
 
-            <div className="flex gap-2">
-              <Button
-                onClick={handlePreview}
-                disabled={!selectedFile || previewLoading}
-                className="flex-1"
-              >
-                {previewLoading ? (
-                  <>
-                    <Loader2 className="h-4 w-4 mr-2 animate-spin" />
-                    解析中...
-                  </>
-                ) : (
-                  <>
-                    <Eye className="h-4 w-4 mr-2" />
-                    开始预览
-                  </>
-                )}
-              </Button>
-              
-              {selectedFile && (
-                <Button
-                  variant="outline"
-                  onClick={handleClearFile}
-                  className="flex-1"
-                >
-                  清除文件
-                </Button>
-              )}
+        {/* Excel数据上传 */}
+        <Card>
+          <CardHeader>
+            <CardTitle className="flex items-center gap-2">
+              <FileSpreadsheet className="h-5 w-5" />
+              选择Excel数据
+            </CardTitle>
+            <CardDescription>
+              支持 .xlsx/.xls 格式的Excel文档,最大10MB
+            </CardDescription>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            <div className="grid w-full items-center gap-1.5">
+              <Label htmlFor="excel-file">Excel数据文件</Label>
+              <Input
+                ref={excelFileInputRef}
+                id="excel-file"
+                type="file"
+                accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
+                onChange={handleExcelFileSelect}
+              />
             </div>
+
+            {selectedExcelFile && (
+              <Alert>
+                <FileSpreadsheet className="h-4 w-4" />
+                <AlertDescription>
+                  <div className="space-y-1">
+                    <p><strong>文件名:</strong> {selectedExcelFile.name}</p>
+                    <p><strong>大小:</strong> {formatFileSize(selectedExcelFile.size)}</p>
+                    {excelData.length > 0 && (
+                      <p><strong>数据行数:</strong> {excelData.length}</p>
+                    )}
+                  </div>
+                </AlertDescription>
+              </Alert>
+            )}
           </CardContent>
         </Card>
 
-        {/* 预览区域 */}
+        {/* 图片压缩文件上传 */}
         <Card>
           <CardHeader>
             <CardTitle className="flex items-center gap-2">
-              <FileText className="h-5 w-5" />
-              文档预览
+              <Package className="h-5 w-5" />
+              选择图片压缩包
             </CardTitle>
             <CardDescription>
-              {selectedFile ? selectedFile.name : '请先选择并预览文档'}
+              支持 .zip 格式压缩包,最大50MB
             </CardDescription>
           </CardHeader>
-          <CardContent>
-            {!showPreview ? (
-              <div className="text-center py-12">
-                <FileWarning className="h-16 w-16 mx-auto mb-4 text-muted-foreground" />
-                <p className="text-muted-foreground">
-                  {selectedFile ? '点击"开始预览"按钮查看文档内容' : '请先选择Word文档'}
-                </p>
-              </div>
-            ) : (
-              <WordViewer file={selectedFile} />
+          <CardContent className="space-y-4">
+            <div className="grid w-full items-center gap-1.5">
+              <Label htmlFor="image-zip">图片压缩文件</Label>
+              <Input
+                ref={imageZipInputRef}
+                id="image-zip"
+                type="file"
+                accept=".zip,application/zip,application/x-zip-compressed"
+                onChange={handleImageZipSelect}
+              />
+            </div>
+
+            {imageZipFile && (
+              <Alert>
+                <Image className="h-4 w-4" />
+                <AlertDescription>
+                  <div className="space-y-1">
+                    <p><strong>文件名:</strong> {imageZipFile.name}</p>
+                    <p><strong>大小:</strong> {formatFileSize(imageZipFile.size)}</p>
+                    {Object.keys(imageMappings).length > 0 && (
+                      <p><strong>文件夹数:</strong> {Object.keys(imageMappings).length}</p>
+                    )}
+                  </div>
+                </AlertDescription>
+              </Alert>
             )}
           </CardContent>
         </Card>
       </div>
 
-      {/* 功能特性 */}
-      <div className="grid gap-4 md:grid-cols-3">
+      {/* 操作按钮 */}
+      <Card>
+        <CardHeader>
+          <CardTitle>操作区域</CardTitle>
+          <CardDescription>选择文件后执行相应操作</CardDescription>
+        </CardHeader>
+        <CardContent className="space-y-4">
+          <div className="flex gap-2 flex-wrap">
+            <Button
+              onClick={handlePreview}
+              disabled={!selectedWordFile || previewLoading}
+              variant="outline"
+            >
+              <Eye className="h-4 w-4 mr-2" />
+              预览模板
+            </Button>
+            
+            <Button
+              onClick={processFiles}
+              disabled={!selectedWordFile || !selectedExcelFile || excelData.length === 0 || isLoading}
+              className="bg-blue-600 hover:bg-blue-700"
+            >
+              {isLoading ? (
+                <>
+                  <RefreshCw className="h-4 w-4 mr-2 animate-spin" />
+                  处理中...
+                </>
+              ) : (
+                <>
+                  <Upload className="h-4 w-4 mr-2" />
+                  开始处理
+                </>
+              )}
+            </Button>
+
+            <Button
+              onClick={clearAllFiles}
+              variant="outline"
+              className="text-red-600 hover:text-red-700"
+            >
+              清除所有
+            </Button>
+          </div>
+
+          {isLoading && (
+            <div className="space-y-2">
+              <Progress value={processingProgress} className="w-full" />
+              <p className="text-sm text-muted-foreground text-center">
+                正在处理文档... {Math.round(processingProgress)}%
+              </p>
+            </div>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* 图片映射预览 */}
+      {Object.keys(imageMappings).length > 0 && (
         <Card>
           <CardHeader>
-            <CardTitle className="text-lg">格式支持</CardTitle>
+            <CardTitle className="flex items-center gap-2">
+              <Image className="h-5 w-5" />
+              图片映射预览
+            </CardTitle>
+            <CardDescription>
+              文件夹结构:序号文件夹 → 图片文件
+            </CardDescription>
           </CardHeader>
           <CardContent>
-            <p className="text-sm text-muted-foreground">
-              完美支持 .docx 格式,保留原始文档格式和布局
-            </p>
+            <div className="space-y-2 max-h-40 overflow-y-auto">
+              {Object.entries(imageMappings).map(([folder, images]) => (
+                <div key={folder} className="p-2 bg-gray-50 rounded">
+                  <strong>文件夹 {folder}:</strong> {Object.keys(images).join(', ')}
+                </div>
+              ))}
+            </div>
           </CardContent>
         </Card>
-        
+      )}
+
+      {/* 处理结果 */}
+      {processingResult && (
         <Card>
           <CardHeader>
-            <CardTitle className="text-lg">本地处理</CardTitle>
+            <CardTitle className="flex items-center gap-2">
+              <CheckCircle className="h-5 w-5 text-green-500" />
+              处理完成
+            </CardTitle>
+            <CardDescription>
+              共生成 {processingResult.total} 个文档
+            </CardDescription>
           </CardHeader>
           <CardContent>
-            <p className="text-sm text-muted-foreground">
-              所有文件处理都在本地浏览器完成,保护您的隐私安全
-            </p>
+            <div className="space-y-4">
+              <Button
+                onClick={downloadAllFiles}
+                className="w-full"
+              >
+                <DownloadCloud className="h-4 w-4 mr-2" />
+                下载全部文档
+              </Button>
+              
+              <div className="space-y-2 max-h-64 overflow-y-auto">
+                {processingResult.generatedFiles.map((file, index) => (
+                  <div
+                    key={index}
+                    className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
+                  >
+                    <div>
+                      <p className="font-medium">{file.name}</p>
+                      <p className="text-sm text-muted-foreground">
+                        包含 {Object.keys(file.fields).length} 个字段
+                      </p>
+                    </div>
+                    <Button
+                      variant="ghost"
+                      size="sm"
+                      onClick={() => downloadProcessedFile(file)}
+                    >
+                      <Download className="h-4 w-4" />
+                    </Button>
+                  </div>
+                ))}
+              </div>
+            </div>
           </CardContent>
         </Card>
-        
+      )}
+
+      {/* 预览区域 */}
+      {showPreview && selectedWordFile && (
         <Card>
           <CardHeader>
-            <CardTitle className="text-lg">实时预览</CardTitle>
+            <CardTitle className="flex items-center gap-2">
+              <FileText className="h-5 w-5" />
+              文档预览
+            </CardTitle>
+            <CardDescription>
+              {selectedWordFile.name}
+            </CardDescription>
           </CardHeader>
           <CardContent>
-            <p className="text-sm text-muted-foreground">
-              即时解析文档内容,快速查看Word文档的完整内容
-            </p>
+            <WordViewer file={selectedWordFile} />
           </CardContent>
         </Card>
-      </div>
+      )}
+
+      {/* 数据预览 */}
+      {excelData.length > 0 && (
+        <Card>
+          <CardHeader>
+            <CardTitle className="flex items-center gap-2">
+              <FileSpreadsheet className="h-5 w-5" />
+              Excel数据预览
+            </CardTitle>
+            <CardDescription>
+              显示前5行数据,共 {excelData.length} 行
+            </CardDescription>
+          </CardHeader>
+          <CardContent>
+            <div className="overflow-x-auto">
+              <table className="w-full text-sm">
+                <thead>
+                  <tr className="border-b">
+                    {Object.keys(excelData[0]).map(header => (
+                      <th key={header} className="text-left p-2 font-medium">
+                        {header}
+                      </th>
+                    ))}
+                  </tr>
+                </thead>
+                <tbody>
+                  {excelData.slice(0, 5).map((row, index) => (
+                    <tr key={index} className="border-b">
+                      {Object.values(row).map((value, valueIndex) => (
+                        <td key={valueIndex} className="p-2">
+                          {String(value)}
+                        </td>
+                      ))}
+                    </tr>
+                  ))}
+                </tbody>
+              </table>
+              {excelData.length > 5 && (
+                <p className="text-sm text-muted-foreground mt-2 text-center">
+                  还有 {excelData.length - 5} 行数据...
+                </p>
+              )}
+            </div>
+          </CardContent>
+        </Card>
+      )}
 
       {/* 使用说明 */}
       <Card>
         <CardHeader>
-          <CardTitle>使用说明</CardTitle>
+          <CardTitle>使用说明(增强版)</CardTitle>
         </CardHeader>
         <CardContent>
-          <div className="space-y-2 text-sm">
-            <p>• <strong>支持的文件格式:</strong> .docx(Word 2007及以上版本)</p>
-            <p>• <strong>文件大小限制:</strong> 最大10MB,超过此限制的文件可能无法正确解析</p>
-            <p>• <strong>隐私保护:</strong> 所有文件处理都在本地浏览器完成,不会上传到任何服务器</p>
-            <p>• <strong>格式兼容性:</strong> 支持大部分Word格式,包括标题、列表、表格、图片等</p>
-            <p>• <strong>不支持格式:</strong> 旧的 .doc 格式(Word 97-2003)暂不支持</p>
+          <div className="space-y-3 text-sm">
+            <div>
+              <h4 className="font-medium mb-1">1. 准备Word模板</h4>
+              <p className="text-muted-foreground">
+                使用 {'{{'}字段名{'}'} 格式作为文本占位符,使用 {'{{'}image:图片名{'}'} 格式作为图片占位符
+              </p>
+            </div>
+            
+            <div>
+              <h4 className="font-medium mb-1">2. 准备Excel数据</h4>
+              <p className="text-muted-foreground">
+                Excel文件第一行为表头,列名应与Word模板中的字段名对应
+              </p>
+            </div>
+            
+            <div>
+              <h4 className="font-medium mb-1">3. 准备图片压缩包</h4>
+              <p className="text-muted-foreground">
+                压缩包结构:第一层为序号文件夹(1,2,3...对应Excel行),第二层为图片文件(文件名对应模板中的图片名)
+              </p>
+            </div>
+            
+            <div>
+              <h4 className="font-medium mb-1">4. 图片命名规则</h4>
+              <p className="text-muted-foreground">
+                例如:模板中使用 {'{{'}image:logo{'}'},则图片文件应命名为 logo.jpg/png等
+              </p>
+            </div>
           </div>
         </CardContent>
       </Card>
@@ -262,10 +770,11 @@ export default function WordPreview() {
         </CardHeader>
         <CardContent>
           <div className="space-y-2 text-sm">
-            <p>• 复杂格式的文档可能需要更长的解析时间</p>
-            <p>• 某些高级格式(如宏、表单控件)可能无法完美显示</p>
-            <p>• 建议在现代浏览器中使用以获得最佳体验</p>
-            <p>• 如果文档解析失败,请检查文件是否损坏</p>
+            <p>• Word模板中的字段名必须与Excel表头完全匹配</p>
+            <p>• 图片文件名必须与模板中的图片占位符匹配(不含扩展名)</p>
+            <p>• 文件夹序号必须与Excel行号对应(第1行对应文件夹"1")</p>
+            <p>• 如果图片不存在,对应位置将留空</p>
+            <p>• 支持jpg、jpeg、png、gif、bmp、webp格式图片</p>
           </div>
         </CardContent>
       </Card>