Przeglądaj źródła

✨ feat(word-preview): 增强图片预览功能

- 添加图片缩略图网格展示,支持文件夹分类查看
- 实现图片点击放大预览功能,支持左右切换和下载
- 优化图片映射数据结构,提升图片加载性能

🔧 chore(deps): 更新文档处理相关依赖

- 降级 docxtemplater 从 ^3.65.2 到 ^3.50.0
- 替换 docxtemplater-image-module-free 为 open-docxtemplater-image-module-2@1.0.2
yourname 4 miesięcy temu
rodzic
commit
91e08798ef
3 zmienionych plików z 190 dodań i 34 usunięć
  1. 2 2
      package.json
  2. 16 22
      pnpm-lock.yaml
  3. 172 10
      src/client/admin-shadcn/pages/WordPreview.tsx

+ 2 - 2
package.json

@@ -54,8 +54,8 @@
     "date-fns": "^4.1.0",
     "dayjs": "^1.11.13",
     "debug": "^4.4.1",
-    "docxtemplater": "^3.65.2",
-    "docxtemplater-image-module-free": "^1.1.1",
+    "docxtemplater": "^3.50.0",
+    "open-docxtemplater-image-module-2": "^1.0.2",
     "dotenv": "^17.2.1",
     "embla-carousel-react": "^8.6.0",
     "formdata-node": "^6.0.3",

+ 16 - 22
pnpm-lock.yaml

@@ -141,11 +141,8 @@ importers:
         specifier: ^4.4.1
         version: 4.4.1
       docxtemplater:
-        specifier: ^3.65.2
-        version: 3.65.2
-      docxtemplater-image-module-free:
-        specifier: ^1.1.1
-        version: 1.1.1
+        specifier: ^3.50.0
+        version: 3.50.0
       dotenv:
         specifier: ^17.2.1
         version: 17.2.1
@@ -185,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)
+      open-docxtemplater-image-module-2:
+        specifier: ^1.0.2
+        version: 1.0.2
       pizzip:
         specifier: ^3.2.0
         version: 3.2.0
@@ -1647,10 +1647,6 @@ 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==}
 
@@ -1955,11 +1951,8 @@ packages:
   dingbat-to-unicode@1.0.1:
     resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==}
 
-  docxtemplater-image-module-free@1.1.1:
-    resolution: {integrity: sha512-aWOzVQN7ggDYjfoy3pTTNrcrZ7/CJrQcI9cT+hmyHE6nRLR67nt5yPFPe9hm9VWbfYIED2fi+3itOnF0TE/RWQ==}
-
-  docxtemplater@3.65.2:
-    resolution: {integrity: sha512-aDE2D0ir+4K1nruSFtIBbLE4vxyvB/qLNVVlXjVjtI6an4z2Qe5BprxusFsDRH8j6FCz3aXe6qxi3O+cGHnq+Q==}
+  docxtemplater@3.50.0:
+    resolution: {integrity: sha512-6EqYbBFUcdNKVwS6G8vQ+pFOURJ7zoSvUNASIi4MPnCpkRdYDvmaOV2e1XcScMrEQV5pFZUAAbKi30Z+JTbLFA==}
     engines: {node: '>=0.10'}
 
   dom-helpers@5.2.1:
@@ -2466,6 +2459,9 @@ packages:
     resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==}
     engines: {node: '>= 0.8'}
 
+  open-docxtemplater-image-module-2@1.0.2:
+    resolution: {integrity: sha512-Qj7ofO0/0sxobwPMPxflq87oVBoUdhKQ9L0+CmZBvsTrmPEhYKoGEUftMEkSWqhMW2fKgL3GaDVjbySzZ5OG3g==}
+
   openapi3-ts@4.5.0:
     resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==}
 
@@ -4560,8 +4556,6 @@ snapshots:
 
   '@xmldom/xmldom@0.8.10': {}
 
-  '@xmldom/xmldom@0.9.8': {}
-
   '@zxing/text-encoding@0.9.0':
     optional: true
 
@@ -4877,13 +4871,9 @@ snapshots:
 
   dingbat-to-unicode@1.0.1: {}
 
-  docxtemplater-image-module-free@1.1.1:
+  docxtemplater@3.50.0:
     dependencies:
-      xmldom: 0.1.31
-
-  docxtemplater@3.65.2:
-    dependencies:
-      '@xmldom/xmldom': 0.9.8
+      '@xmldom/xmldom': 0.8.10
 
   dom-helpers@5.2.1:
     dependencies:
@@ -5383,6 +5373,10 @@ snapshots:
 
   on-headers@1.1.0: {}
 
+  open-docxtemplater-image-module-2@1.0.2:
+    dependencies:
+      xmldom: 0.1.31
+
   openapi3-ts@4.5.0:
     dependencies:
       yaml: 2.8.0

+ 172 - 10
src/client/admin-shadcn/pages/WordPreview.tsx

@@ -2,21 +2,28 @@ import { useState, useRef } from 'react';
 import * as XLSX from 'xlsx';
 import PizZip from 'pizzip';
 import Docxtemplater from 'docxtemplater';
-import ImageModule from 'docxtemplater-image-module-free/build/imagemodule.js';
+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
+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;
@@ -58,6 +65,14 @@ export default function WordPreview() {
   const [excelData, setExcelData] = useState<ExcelRow[]>([]);
   const [processingProgress, setProcessingProgress] = useState(0);
   const [imageMappings, setImageMappings] = useState<ImageMapping>({});
+  const [imagePreviewUrls, setImagePreviewUrls] = useState<Record<string, Record<string, string>>>({});
+  const [selectedImage, setSelectedImage] = useState<{
+    url: string;
+    name: string;
+    folder: string;
+  } | null>(null);
+  const [currentImageIndex, setCurrentImageIndex] = useState(0);
+  const [allImages, setAllImages] = useState<Array<{ url: string; name: string; folder: string }>>([]);
   
   const wordFileInputRef = useRef<HTMLInputElement>(null);
   const excelFileInputRef = useRef<HTMLInputElement>(null);
@@ -175,6 +190,8 @@ export default function WordPreview() {
       const zip = new JSZip();
       const zipContent = await zip.loadAsync(file);
       const newImageMappings: ImageMapping = {};
+      const newImagePreviewUrls: Record<string, Record<string, string>> = {};
+      const allImages: Array<{ url: string; name: string; folder: string }> = [];
 
       // 解析文件夹结构:第一层为序号,第二层为图片
       for (const [path, zipEntry] of Object.entries(zipContent.files)) {
@@ -183,20 +200,34 @@ export default function WordPreview() {
           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('图片压缩文件解析失败');
@@ -253,7 +284,7 @@ export default function WordPreview() {
         paragraphLoop: true,
         linebreaks: true,
         modules: [imageModule]
-      });
+      })
 
       // 处理嵌套数据结构
       const processedData: Record<string, any> = {};
@@ -402,6 +433,9 @@ export default function WordPreview() {
     setExcelData([]);
     setProcessingResult(null);
     setImageMappings({});
+    setImagePreviewUrls({});
+    setSelectedImage(null);
+    setAllImages([]);
     setPreviewFile(null);
     setShowPreview(false);
     
@@ -420,6 +454,35 @@ export default function WordPreview() {
     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 (
     <div className="space-y-6">
       <div>
@@ -629,14 +692,42 @@ export default function WordPreview() {
               图片映射预览
             </CardTitle>
             <CardDescription>
-              文件夹结构:序号文件夹 → 图片文件
+              共 {getTotalImages()} 张图片,点击缩略图查看大图
             </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 className="space-y-4">
+              {Object.entries(imagePreviewUrls).map(([folder, images]) => (
+                <div key={folder} className="border rounded-lg p-4">
+                  <h4 className="font-semibold text-lg mb-3 flex items-center gap-2">
+                    <Package className="h-4 w-4" />
+                    文件夹 {folder} ({Object.keys(images).length} 张图片)
+                  </h4>
+                  <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
+                    {Object.entries(images).map(([imageName, previewUrl]) => (
+                      <div
+                        key={imageName}
+                        className="group relative cursor-pointer"
+                        onClick={() => openImagePreview(previewUrl, imageName, folder)}
+                      >
+                        <div className="aspect-square rounded-lg overflow-hidden border hover:border-blue-500 transition-colors">
+                          <img
+                            src={previewUrl}
+                            alt={imageName}
+                            className="w-full h-full object-cover"
+                          />
+                        </div>
+                        <div className="mt-1">
+                          <p className="text-xs text-center truncate text-gray-600 group-hover:text-blue-600">
+                            {imageName}
+                          </p>
+                        </div>
+                        <div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-opacity rounded-lg flex items-center justify-center">
+                          <ZoomIn className="h-6 w-6 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
+                        </div>
+                      </div>
+                    ))}
+                  </div>
                 </div>
               ))}
             </div>
@@ -814,6 +905,77 @@ export default function WordPreview() {
           </div>
         </CardContent>
       </Card>
+
+      {/* 图片查看器模态框 */}
+      <Dialog open={selectedImage !== null} onOpenChange={closeImagePreview}>
+        <DialogContent className="max-w-4xl max-h-[90vh] p-0">
+          <DialogHeader className="px-6 py-4 border-b">
+            <DialogTitle className="flex items-center justify-between">
+              <span>图片预览</span>
+              <div className="flex items-center gap-2 text-sm text-muted-foreground">
+                <span>{selectedImage?.folder} / {selectedImage?.name}</span>
+                <span>({currentImageIndex + 1} / {allImages.length})</span>
+              </div>
+            </DialogTitle>
+          </DialogHeader>
+          
+          {selectedImage && (
+            <div className="relative">
+              <div className="flex items-center justify-center p-4">
+                <img
+                  src={selectedImage.url}
+                  alt={selectedImage.name}
+                  className="max-w-full max-h-[70vh] object-contain"
+                />
+              </div>
+              
+              {/* 导航按钮 */}
+              {allImages.length > 1 && (
+                <>
+                  <Button
+                    variant="ghost"
+                    size="icon"
+                    className="absolute left-2 top-1/2 -translate-y-1/2"
+                    onClick={() => navigateImage('prev')}
+                  >
+                    <ChevronLeft className="h-5 w-5" />
+                  </Button>
+                  <Button
+                    variant="ghost"
+                    size="icon"
+                    className="absolute right-2 top-1/2 -translate-y-1/2"
+                    onClick={() => navigateImage('next')}
+                  >
+                    <ChevronRight className="h-5 w-5" />
+                  </Button>
+                </>
+              )}
+              
+              {/* 底部信息 */}
+              <div className="px-6 py-3 border-t bg-gray-50">
+                <div className="flex items-center justify-between text-sm">
+                  <div>
+                    <span className="font-medium">文件名:</span> {selectedImage.name}
+                  </div>
+                  <Button
+                    variant="ghost"
+                    size="sm"
+                    onClick={() => {
+                      const a = document.createElement('a');
+                      a.href = selectedImage.url;
+                      a.download = selectedImage.name;
+                      a.click();
+                    }}
+                  >
+                    <Download className="h-4 w-4 mr-2" />
+                    下载图片
+                  </Button>
+                </div>
+              </div>
+            </div>
+          )}
+        </DialogContent>
+      </Dialog>
     </div>
   );
 }