|
|
@@ -1,1054 +0,0 @@
|
|
|
-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<string, string>;
|
|
|
- }>;
|
|
|
- total: number;
|
|
|
-}
|
|
|
-
|
|
|
-export default function WordPreview() {
|
|
|
- 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 [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);
|
|
|
- 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 = {};
|
|
|
- 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)) {
|
|
|
- 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<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);
|
|
|
-
|
|
|
- // 预加载所有图片数据,避免Promise问题
|
|
|
- const folderIndex = (rowIndex + 1).toString();
|
|
|
- const imageDataMap: Record<string, ArrayBuffer> = {};
|
|
|
-
|
|
|
- // 预加载当前文件夹的所有图片
|
|
|
- if (imageMappings[folderIndex]) {
|
|
|
- for (const [imageName, imageFile] of Object.entries(imageMappings[folderIndex])) {
|
|
|
- try {
|
|
|
- imageDataMap[imageName] = await imageFile.arrayBuffer();
|
|
|
- } catch (error) {
|
|
|
- console.warn(`Failed to load image ${imageName}:`, error);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 配置图片模块 - 使用实际图片尺寸
|
|
|
- const imageSizeCache = new Map<string, [number, number]>();
|
|
|
-
|
|
|
- const imageOpts = {
|
|
|
- centered: false,
|
|
|
- getImage: (tagValue: string): ArrayBuffer | null => {
|
|
|
- if (tagValue && typeof tagValue === 'string') {
|
|
|
- return imageDataMap[tagValue] || null;
|
|
|
- }
|
|
|
- return null;
|
|
|
- },
|
|
|
- getSize: (img: ArrayBuffer, tagValue: string, tagName: string) => {
|
|
|
- console.log('tagName', tagName)
|
|
|
- console.log('tagValue', tagValue)
|
|
|
- // 从图片数据中获取实际尺寸
|
|
|
- try {
|
|
|
- // 为每个图片创建唯一的缓存键
|
|
|
- const cacheKey = `${tagValue}_${img.byteLength}`;
|
|
|
-
|
|
|
- // 如果已经缓存了尺寸,直接返回
|
|
|
- if (imageSizeCache.has(cacheKey)) {
|
|
|
- return imageSizeCache.get(cacheKey)!;
|
|
|
- }
|
|
|
-
|
|
|
- // 简化的图片尺寸检测(基于图片数据特征)
|
|
|
- // 这里我们使用一个合理的方法来从图片数据推断尺寸
|
|
|
- const view = new DataView(img);
|
|
|
-
|
|
|
- // PNG格式检测
|
|
|
- if (view.getUint32(0) === 0x89504E47 && view.getUint32(4) === 0x0D0A1A0A) {
|
|
|
- // PNG IHDR chunk: width at offset 16, height at offset 20
|
|
|
- const width = view.getUint32(16);
|
|
|
- const height = view.getUint32(20);
|
|
|
- const size: [number, number] = [width, height];
|
|
|
- console.log('size', size)
|
|
|
- imageSizeCache.set(cacheKey, size);
|
|
|
- return size;
|
|
|
- }
|
|
|
-
|
|
|
- // JPEG格式检测
|
|
|
- if (view.getUint16(0) === 0xFFD8) {
|
|
|
- // 简化的JPEG尺寸检测
|
|
|
- let offset = 2;
|
|
|
- while (offset < img.byteLength - 10) {
|
|
|
- if (view.getUint8(offset) === 0xFF) {
|
|
|
- const marker = view.getUint8(offset + 1);
|
|
|
- if (marker >= 0xC0 && marker <= 0xC3) {
|
|
|
- // SOF marker found
|
|
|
- const height = view.getUint16(offset + 5);
|
|
|
- const width = view.getUint16(offset + 7);
|
|
|
- const size: [number, number] = [width, height];
|
|
|
- console.log('size', size)
|
|
|
- imageSizeCache.set(cacheKey, size);
|
|
|
- return size;
|
|
|
- }
|
|
|
- const length = view.getUint16(offset + 2);
|
|
|
- offset += length + 2;
|
|
|
- continue;
|
|
|
- }
|
|
|
- offset++;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 如果无法检测尺寸,使用默认尺寸
|
|
|
- const defaultSize: [number, number] = [200, 150];
|
|
|
- imageSizeCache.set(cacheKey, defaultSize);
|
|
|
- console.log('defaultSize', defaultSize)
|
|
|
- return defaultSize;
|
|
|
-
|
|
|
- } catch (error) {
|
|
|
- console.warn('Failed to get image size, using default:', error);
|
|
|
- return [200, 150];
|
|
|
- }
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- const imageModule = new ImageModule(imageOpts);
|
|
|
-
|
|
|
- const doc = new Docxtemplater(zip, {
|
|
|
- paragraphLoop: true,
|
|
|
- linebreaks: true,
|
|
|
- modules: [imageModule]
|
|
|
- })
|
|
|
-
|
|
|
- // 处理嵌套数据结构
|
|
|
- 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;
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- // 处理图片字段 - 确保图片名称正确传递给模板
|
|
|
- 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 (
|
|
|
- <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>
|
|
|
-
|
|
|
- {/* 预览区域 */}
|
|
|
- {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>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* 图片映射预览 */}
|
|
|
- {Object.keys(imageMappings).length > 0 && (
|
|
|
- <Card>
|
|
|
- <CardHeader>
|
|
|
- <CardTitle className="flex items-center gap-2">
|
|
|
- <Image className="h-5 w-5" />
|
|
|
- 图片映射预览
|
|
|
- </CardTitle>
|
|
|
- <CardDescription>
|
|
|
- 共 {getTotalImages()} 张图片,点击缩略图查看大图
|
|
|
- </CardDescription>
|
|
|
- </CardHeader>
|
|
|
- <CardContent>
|
|
|
- <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/0 group-hover:bg-black/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>
|
|
|
- </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>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* 处理结果预览 */}
|
|
|
- {processingResult && processingResult.generatedFiles.length > 0 && (
|
|
|
- <Card>
|
|
|
- <CardHeader>
|
|
|
- <CardTitle className="flex items-center gap-2">
|
|
|
- <FileText className="h-5 w-5" />
|
|
|
- 处理结果预览
|
|
|
- </CardTitle>
|
|
|
- <CardDescription>
|
|
|
- 预览第一个生成的文档
|
|
|
- </CardDescription>
|
|
|
- </CardHeader>
|
|
|
- <CardContent>
|
|
|
- <WordViewer file={processingResult.generatedFiles[0].content} />
|
|
|
- </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>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* 使用说明 */}
|
|
|
- <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">
|
|
|
- 使用 {'{字段名}'} 格式作为文本占位符,使用 {'{%图片名}'} 格式作为图片占位符
|
|
|
- </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">
|
|
|
- 例如:模板中使用 {'{%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>
|
|
|
- <p>• 图片占位符使用 {'{%图片名%}'} 格式,如 {'{%logo%}'}</p>
|
|
|
- </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>
|
|
|
- );
|
|
|
-}
|