|
|
@@ -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>
|
|
|
);
|
|
|
}
|