| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691 |
- 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, Settings,
- CheckSquare, Square, Archive, CheckCheck, User, MessageCircle
- } from 'lucide-react';
- import { toast } from 'sonner';
- import WordViewer from '@/client/home/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';
- import { useAuth } from '@/client/home/hooks/AuthProvider';
- import { useNavigate } from 'react-router-dom';
- 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 ImageSizeSettings {
- width: number;
- height: number;
- }
- interface ProcessingResult {
- originalFile: File;
- generatedFiles: Array<{
- name: string;
- content: Blob;
- fields: Record<string, string>;
- }>;
- total: number;
- }
- export default function WordPreview() {
- const { isAuthenticated, isLoading: authLoading } = useAuth();
- const navigate = useNavigate();
- 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 [imageSizeSettings, setImageSizeSettings] = useState<ImageSizeSettings>({ width: 200, height: 150 });
- const [showSizeSettings, setShowSizeSettings] = useState(false);
-
- // 新增状态:自动重命名图片功能
- const [autoRenameImages, setAutoRenameImages] = useState(true);
-
- // 新增状态:选择下载功能
- const [selectedFiles, setSelectedFiles] = useState<Set<number>>(new Set());
- const [isDownloading, setIsDownloading] = useState(false);
- const [downloadProgress, setDownloadProgress] = useState(0);
- const [mergeDownloading, setMergeDownloading] = useState(false);
- const [wordMergeDownloading, setWordMergeDownloading] = useState(false);
-
- 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 maxSize = 500 * 1024 * 1024;
-
- // 检查文件扩展名 - 目前只支持ZIP格式
- const fileExtension = file.name.toLowerCase().split('.').pop();
- const isZipFile = fileExtension === 'zip';
-
- if (!isZipFile) {
- toast.error('目前仅支持 .zip 格式压缩包,请转换为ZIP格式后重试');
- return;
- }
- if (file.size > maxSize) {
- toast.error('压缩文件大小超过500MB限制');
- 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 }> = [];
- if (autoRenameImages) {
- // 自动重命名模式:按文件夹分组图片并按顺序重命名为A1,A2,A3...
- const folderImages: Record<string, Array<{ path: string; zipEntry: JSZip.JSZipObject }>> = {};
- // 首先收集所有图片并按文件夹分组
- 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];
- if (!folderImages[folderIndex]) {
- folderImages[folderIndex] = [];
- }
- folderImages[folderIndex].push({ path, zipEntry });
- }
- }
- }
- // 对每个文件夹的图片按文件名排序,然后重命名为A1,A2,A3...
- for (const [folderIndex, images] of Object.entries(folderImages)) {
- // 按文件名排序以确保顺序一致
- images.sort((a, b) => a.path.localeCompare(b.path));
-
- if (!newImageMappings[folderIndex]) {
- newImageMappings[folderIndex] = {};
- newImagePreviewUrls[folderIndex] = {};
- }
- // 为每个图片分配A1,A2,A3...的命名
- for (let i = 0; i < images.length; i++) {
- const { zipEntry } = images[i];
- const newImageName = `A${i + 1}`; // A1, A2, A3...
-
- const imageFile = await zipEntry.async('blob');
- newImageMappings[folderIndex][newImageName] = new File([imageFile], newImageName, {
- type: getImageMimeType(images[i].path)
- });
- // 创建预览URL
- const previewUrl = URL.createObjectURL(imageFile);
- newImagePreviewUrls[folderIndex][newImageName] = previewUrl;
- allImages.push({
- url: previewUrl,
- name: newImageName,
- folder: folderIndex
- });
- }
- }
- } else {
- // 原有模式:保持图片原始文件名
- 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);
-
- if (autoRenameImages) {
- toast.success(`成功解析 ${Object.keys(newImageMappings).length} 个文件夹的图片,已自动重命名为A1,A2,A3...`);
- } else {
- 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';
- };
- // 不再需要从模板解析图片尺寸,使用用户设置
- // 计算保持比例的图片尺寸
- const calculateProportionalSize = (
- originalWidth: number,
- originalHeight: number,
- maxWidth: number,
- maxHeight: number
- ): [number, number] => {
- const widthRatio = maxWidth / originalWidth;
- const heightRatio = maxHeight / originalHeight;
-
- // 使用较小的比例以保持原始长宽比
- const ratio = Math.min(widthRatio, heightRatio, 1);
-
- const newWidth = Math.round(originalWidth * ratio);
- const newHeight = Math.round(originalHeight * ratio);
-
- return [newWidth, newHeight];
- };
- // 替换Word字段并插入图片
- const replaceFieldsInWord = async (wordFile: File, excelRow: ExcelRow, rowIndex: number): Promise<Blob> => {
- try {
- const arrayBuffer = await wordFile.arrayBuffer();
- const zip = new PizZip(arrayBuffer);
-
- const folderIndex = (rowIndex + 1).toString();
- const imageDataMap: Record<string, ArrayBuffer> = {};
-
- // 预加载当前文件夹的所有图片 - 使用更健壮的方式
- if (imageMappings[folderIndex]) {
- for (const [imageName, imageFile] of Object.entries(imageMappings[folderIndex])) {
- try {
- // 创建文件的副本,避免原始文件引用丢失
- const fileCopy = new File([imageFile], imageFile.name, { type: imageFile.type });
- imageDataMap[imageName] = await fileCopy.arrayBuffer();
- } catch (error) {
- console.warn(`Failed to load image ${imageName}:`, error);
- // 如果加载失败,尝试重新从原始映射获取
- try {
- const originalFile = imageMappings[folderIndex][imageName];
- if (originalFile) {
- imageDataMap[imageName] = await originalFile.arrayBuffer();
- }
- } catch (retryError) {
- console.warn(`Retry failed for image ${imageName}:`, retryError);
- }
- }
- }
- }
-
- // 使用用户设置的图片尺寸限制
- const defaultLimit = imageSizeSettings;
-
- // 配置图片模块 - 使用实际图片尺寸并应用限制
- 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) => {
- try {
- const cacheKey = `${tagValue}_${img.byteLength}`;
-
- if (imageSizeCache.has(cacheKey)) {
- return imageSizeCache.get(cacheKey)!;
- }
-
- // 获取图片原始尺寸
- let originalWidth = 200;
- let originalHeight = 150;
-
- const view = new DataView(img);
-
- // PNG格式检测
- if (view.getUint32(0) === 0x89504E47 && view.getUint32(4) === 0x0D0A1A0A) {
- originalWidth = view.getUint32(16);
- originalHeight = view.getUint32(20);
- }
- // JPEG格式检测
- else if (view.getUint16(0) === 0xFFD8) {
- let offset = 2;
- while (offset < img.byteLength - 10) {
- if (view.getUint8(offset) === 0xFF) {
- const marker = view.getUint8(offset + 1);
- if (marker >= 0xC0 && marker <= 0xC3) {
- originalHeight = view.getUint16(offset + 5);
- originalWidth = view.getUint16(offset + 7);
- break;
- }
- const length = view.getUint16(offset + 2);
- offset += length + 2;
- continue;
- }
- offset++;
- }
- }
-
- // 计算符合尺寸限制的最终尺寸
- const [finalWidth, finalHeight] = calculateProportionalSize(
- originalWidth,
- originalHeight,
- defaultLimit.width,
- defaultLimit.height
- );
-
- const finalSize: [number, number] = [finalWidth, finalHeight];
- imageSizeCache.set(cacheKey, finalSize);
-
- return finalSize;
-
- } catch (error) {
- console.warn('Failed to get image size, using default:', error);
- return [defaultLimit.width, defaultLimit.height];
- }
- }
- };
-
- const imageModule = new ImageModule(imageOpts);
- // 创建Docxtemplater实例
- const doc = new Docxtemplater(zip, {
- modules: [imageModule],
- 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;
- }
- });
- // 处理图片字段 - 确保图片名称正确传递给模板
- 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文档处理失败,请检查模板格式: ${error instanceof Error ? error.message : String(error)}`);
- }
- };
- // 处理文件(带重试机制)
- const processFiles = async () => {
- if (!selectedWordFile || !selectedExcelFile || excelData.length === 0) {
- toast.error('请先选择Word模板和Excel数据文件');
- return;
- }
- setIsLoading(true);
- setProcessingProgress(0);
-
- try {
- const generatedFiles: ProcessingResult['generatedFiles'] = [];
- const maxRetries = 3;
-
- for (let i = 0; i < excelData.length; i++) {
- const row = excelData[i];
- let processedBlob: Blob | null = null;
- let retryCount = 0;
- let lastError: Error | null = null;
- // 重试机制
- while (retryCount < maxRetries && !processedBlob) {
- try {
- processedBlob = await replaceFieldsInWord(selectedWordFile, row, i);
- } catch (error) {
- lastError = error as Error;
- retryCount++;
- console.warn(`处理第 ${i + 1} 行数据失败,第 ${retryCount} 次重试:`, error);
-
- if (retryCount < maxRetries) {
- // 等待一段时间后重试
- await new Promise(resolve => setTimeout(resolve, 500 * retryCount));
- }
- }
- }
- if (!processedBlob) {
- throw new Error(`无法处理第 ${i + 1} 行数据: ${lastError?.message || '未知错误'}`);
- }
- 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) {
- console.error('Processing error:', error);
- const errorMessage = error instanceof Error ? error.message : '文档处理失败';
-
- if (errorMessage.includes('权限') || errorMessage.includes('permission')) {
- toast.error('文件权限错误,请重新选择文件后重试');
- } else if (errorMessage.includes('模板格式')) {
- toast.error('Word模板格式错误,请检查模板中的占位符格式');
- } else {
- toast.error(`文档处理失败: ${errorMessage}`);
- }
- } 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 toggleFileSelection = (index: number) => {
- const newSelectedFiles = new Set(selectedFiles);
- if (newSelectedFiles.has(index)) {
- newSelectedFiles.delete(index);
- } else {
- newSelectedFiles.add(index);
- }
- setSelectedFiles(newSelectedFiles);
- };
- const selectAllFiles = () => {
- if (!processingResult) return;
- const allIndices = new Set(Array.from({ length: processingResult.generatedFiles.length }, (_, i) => i));
- setSelectedFiles(allIndices);
- };
- const clearSelection = () => {
- setSelectedFiles(new Set());
- };
- const downloadSelectedFiles = async () => {
- if (!processingResult || selectedFiles.size === 0) return;
-
- setIsDownloading(true);
- setDownloadProgress(0);
-
- const filesToDownload = Array.from(selectedFiles)
- .sort((a, b) => a - b)
- .map(index => processingResult.generatedFiles[index]);
-
- for (let i = 0; i < filesToDownload.length; i++) {
- downloadProcessedFile(filesToDownload[i]);
- setDownloadProgress(((i + 1) / filesToDownload.length) * 100);
- await new Promise(resolve => setTimeout(resolve, 300)); // 添加延迟避免浏览器阻塞
- }
-
- setIsDownloading(false);
- setDownloadProgress(0);
- toast.success(`已下载 ${filesToDownload.length} 个文档`);
- };
- // 新增:Word文档合并下载功能
- const mergeAndDownloadFiles = async () => {
- if (!processingResult || selectedFiles.size === 0) return;
-
- setMergeDownloading(true);
-
- try {
- const filesToMerge = Array.from(selectedFiles)
- .sort((a, b) => a - b)
- .map(index => processingResult.generatedFiles[index]);
-
- // 创建一个新的JSZip实例来合并文件
- const JSZip = (await import('jszip')).default;
- const zip = new JSZip();
-
- // 将所有选中的文件添加到zip中
- filesToMerge.forEach((file, index) => {
- zip.file(file.name, file.content);
- });
-
- // 生成zip文件
- const zipContent = await zip.generateAsync({
- type: 'blob',
- compression: 'DEFLATE',
- compressionOptions: {
- level: 6
- }
- });
-
- // 下载合并的zip文件
- const url = URL.createObjectURL(zipContent);
- const a = document.createElement('a');
- a.href = url;
- a.download = `合并文档_${new Date().toISOString().slice(0, 10)}.zip`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
-
- toast.success(`已合并并下载 ${filesToMerge.length} 个文档`);
-
- } catch (error) {
- console.error('文档合并失败:', error);
- toast.error('文档合并失败,请重试');
- } finally {
- setMergeDownloading(false);
- }
- };
- // 新增:下载所有文件为zip
- const downloadAllAsZip = async () => {
- if (!processingResult) return;
-
- setMergeDownloading(true);
-
- try {
- const JSZip = (await import('jszip')).default;
- const zip = new JSZip();
-
- processingResult.generatedFiles.forEach((file, index) => {
- zip.file(file.name, file.content);
- });
-
- const zipContent = await zip.generateAsync({
- type: 'blob',
- compression: 'DEFLATE',
- compressionOptions: {
- level: 6
- }
- });
-
- const url = URL.createObjectURL(zipContent);
- const a = document.createElement('a');
- a.href = url;
- a.download = `全部文档_${new Date().toISOString().slice(0, 10)}.zip`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
-
- toast.success(`已下载全部 ${processingResult.generatedFiles.length} 个文档为压缩包`);
-
- } catch (error) {
- console.error('压缩包创建失败:', error);
- toast.error('压缩包创建失败,请重试');
- } finally {
- setMergeDownloading(false);
- }
- };
- // 新增:Word文档内容合并功能(将多个Word文档合并成一个Word文档)
- const mergeWordDocuments = async () => {
- if (!processingResult || selectedFiles.size === 0) return;
-
- setWordMergeDownloading(true);
-
- try {
- const filesToMerge = Array.from(selectedFiles)
- .sort((a, b) => a - b)
- .map(index => processingResult.generatedFiles[index]);
- // 加载docxtemplater和pizzip
- const PizZip = (await import('pizzip')).default;
- const Docxtemplater = (await import('docxtemplater')).default;
- // 创建一个新的空Word文档作为基础
- const baseArrayBuffer = await selectedWordFile!.arrayBuffer();
- const baseZip = new PizZip(baseArrayBuffer);
- const baseDoc = new Docxtemplater(baseZip, {
- paragraphLoop: true,
- linebreaks: true,
- });
- // 获取基础文档的内容
- baseDoc.render({});
- const baseContent = baseDoc.getZip().generate({
- type: 'blob',
- mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
- });
- // 创建一个新的JSZip实例来合并内容
- const JSZip = (await import('jszip')).default;
- const mergedZip = new JSZip();
-
- // 加载基础文档
- const baseDocx = await mergedZip.loadAsync(await baseContent.arrayBuffer());
-
- // 获取基础文档的word/document.xml内容
- let mergedContent = await baseDocx.file('word/document.xml').async('text');
-
- // 移除基础文档的结束标签,以便添加其他文档内容
- mergedContent = mergedContent.replace(/<\/w:body><\/w:document>$/, '');
-
- // 逐个添加其他文档的内容
- for (const file of filesToMerge) {
- const fileArrayBuffer = await file.content.arrayBuffer();
- const fileZip = await mergedZip.loadAsync(fileArrayBuffer);
- let fileContent = await fileZip.file('word/document.xml').async('text');
-
- // 提取文档主体内容(去掉xml声明和文档标签)
- const bodyMatch = fileContent.match(/<w:body[^>]*>([\s\S]*?)<\/w:body>/);
- if (bodyMatch && bodyMatch[1]) {
- mergedContent += bodyMatch[1];
- }
- }
-
- // 添加结束标签
- mergedContent += '</w:body></w:document>';
-
- // 更新合并后的内容
- baseDocx.file('word/document.xml', mergedContent);
-
- // 生成合并后的Word文档
- const mergedDoc = await baseDocx.generateAsync({
- type: 'blob',
- mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
- compression: 'DEFLATE',
- compressionOptions: { level: 6 }
- });
-
- // 下载合并后的Word文档
- const url = URL.createObjectURL(mergedDoc);
- const a = document.createElement('a');
- a.href = url;
- a.download = `合并Word文档_${new Date().toISOString().slice(0, 10)}.docx`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
-
- toast.success(`已成功合并 ${filesToMerge.length} 个Word文档为一个文件`);
-
- } catch (error) {
- console.error('Word文档合并失败:', error);
- toast.error('Word文档合并失败,请重试');
- } finally {
- setWordMergeDownloading(false);
- }
- };
- 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);
- };
- // 如果未登录,显示提示信息
- if (!isAuthenticated && !authLoading) {
- return (
- <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 px-4 py-12">
- <Card className="w-full max-w-md border-0 shadow-xl">
- <CardHeader className="space-y-1 text-center">
- <div className="mx-auto bg-red-100 p-3 rounded-full">
- <User className="h-8 w-8 text-red-600" />
- </div>
- <CardTitle className="text-2xl font-bold">需要登录</CardTitle>
- <CardDescription>
- 只有登录用户才能访问文档处理功能
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-4">
- <Alert variant="destructive">
- <AlertCircle className="h-4 w-4" />
- <AlertDescription>
- 请跟管理员联系获取账号密码
- </AlertDescription>
- </Alert>
-
- <div className="bg-blue-50 p-4 rounded-lg">
- <div className="flex items-center justify-center space-x-2">
- <MessageCircle className="h-5 w-5 text-blue-600" />
- <span className="font-medium text-blue-800">管理员微信:</span>
- <span className="text-lg font-bold text-blue-900">andree123654</span>
- </div>
- <p className="text-sm text-blue-700 mt-2 text-center">
- 添加管理员微信获取账号密码
- </p>
- </div>
-
- <Button
- className="w-full"
- onClick={() => navigate('/login')}
- >
- 前往登录页面
- </Button>
- </CardContent>
- </Card>
- </div>
- );
- }
- // 如果正在检查认证状态,显示加载中
- if (authLoading) {
- return (
- <div className="flex justify-center items-center min-h-screen">
- <div className="text-center">
- <RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4" />
- <p>正在检查登录状态...</p>
- </div>
- </div>
- );
- }
- 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 格式压缩包,最大500MB
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-4">
- {/* 自动重命名开关 */}
- <div className="flex items-center space-x-2">
- <input
- type="checkbox"
- id="auto-rename"
- checked={autoRenameImages}
- onChange={(e) => setAutoRenameImages(e.target.checked)}
- className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
- />
- <Label htmlFor="auto-rename" className="text-sm font-medium">
- 自动重命名图片为A1,A2,A3...
- </Label>
- </div>
-
- <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,.rar,.7z,application/zip,application/x-zip-compressed,application/x-rar-compressed,application/x-7z-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>
-
- {/* 图片尺寸设置 */}
- <div className="border-t pt-4 mt-4">
- <div className="flex items-center justify-between mb-3">
- <h4 className="font-medium">图片尺寸设置</h4>
- <Button
- variant="outline"
- size="sm"
- onClick={() => setShowSizeSettings(!showSizeSettings)}
- >
- <Settings className="h-4 w-4 mr-2" />
- {showSizeSettings ? '隐藏设置' : '设置尺寸'}
- </Button>
- </div>
-
- {showSizeSettings && (
- <div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
- <div>
- <Label htmlFor="image-width">图片宽度 (像素)</Label>
- <Input
- id="image-width"
- type="number"
- min="0"
- max="1000"
- value={imageSizeSettings.width}
- onChange={(e) => setImageSizeSettings(prev => ({
- ...prev,
- width: Math.max(0, Math.min(1000, parseInt(e.target.value) || 0))
- }))}
- className="mt-1"
- />
- </div>
- <div>
- <Label htmlFor="image-height">图片高度 (像素)</Label>
- <Input
- id="image-height"
- type="number"
- min="0"
- max="1000"
- value={imageSizeSettings.height}
- onChange={(e) => setImageSizeSettings(prev => ({
- ...prev,
- height: Math.max(0, Math.min(1000, parseInt(e.target.value) || 0))
- }))}
- className="mt-1"
- />
- </div>
- <div className="col-span-2">
- <div className="text-sm text-muted-foreground">
- <p>• 当前设置:宽度 {imageSizeSettings.width}px,高度 {imageSizeSettings.height}px</p>
- <p>• 系统将自动保持图片长宽比例</p>
- <p>• 支持范围:0-1000 像素,可输入任意数值</p>
- <p>• 建议尺寸:单列540×800像素,双列300×500像素(具体根据实际文档需要显示大小测试,可以先导入部分数据测试显示大小)</p>
- </div>
- </div>
- </div>
- )}
- </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>
- )}
- {/* 处理结果 */}
- {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">
- {/* 批量操作工具栏 */}
- <div className="flex flex-wrap gap-2 items-center justify-between p-3 bg-blue-50 rounded-lg">
- <div className="flex items-center gap-2">
- <span className="text-sm font-medium">
- 已选择 {selectedFiles.size} 个文档
- </span>
- {selectedFiles.size > 0 && (
- <Button
- variant="ghost"
- size="sm"
- onClick={clearSelection}
- className="h-7 px-2 text-xs"
- >
- 取消选择
- </Button>
- )}
- </div>
-
- <div className="flex gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={selectAllFiles}
- className="h-7 px-2 text-xs"
- >
- <CheckSquare className="h-3 w-3 mr-1" />
- 全选
- </Button>
-
- {selectedFiles.size > 0 && (
- <>
- <Button
- onClick={downloadSelectedFiles}
- disabled={isDownloading || selectedFiles.size === 0}
- size="sm"
- className="h-7 px-2 text-xs bg-green-600 hover:bg-green-700"
- >
- {isDownloading ? (
- <RefreshCw className="h-3 w-3 mr-1 animate-spin" />
- ) : (
- <Download className="h-3 w-3 mr-1" />
- )}
- 下载选中 ({selectedFiles.size})
- </Button>
-
- <Button
- onClick={mergeAndDownloadFiles}
- disabled={mergeDownloading || selectedFiles.size === 0}
- size="sm"
- variant="outline"
- className="h-7 px-2 text-xs"
- >
- {mergeDownloading ? (
- <RefreshCw className="h-3 w-3 mr-1 animate-spin" />
- ) : (
- <Archive className="h-3 w-3 mr-1" />
- )}
- 打包为ZIP
- </Button>
-
- <Button
- onClick={mergeWordDocuments}
- disabled={wordMergeDownloading || selectedFiles.size === 0}
- size="sm"
- variant="outline"
- className="h-7 px-2 text-xs bg-green-600 hover:bg-green-700 text-white"
- >
- {wordMergeDownloading ? (
- <RefreshCw className="h-3 w-3 mr-1 animate-spin" />
- ) : (
- <FileText className="h-3 w-3 mr-1" />
- )}
- 合并为Word
- </Button>
- </>
- )}
- </div>
- </div>
- {/* 下载全部选项 */}
- <div className="grid grid-cols-2 gap-2">
- <Button
- onClick={downloadAllFiles}
- variant="outline"
- className="w-full"
- >
- <DownloadCloud className="h-4 w-4 mr-2" />
- 逐个下载全部
- </Button>
-
- <Button
- onClick={downloadAllAsZip}
- disabled={mergeDownloading}
- className="w-full bg-blue-600 hover:bg-blue-700"
- >
- {mergeDownloading ? (
- <RefreshCw className="h-4 w-4 mr-2 animate-spin" />
- ) : (
- <Archive className="h-4 w-4 mr-2" />
- )}
- 打包下载全部
- </Button>
- </div>
- {/* 下载进度显示 */}
- {(isDownloading || mergeDownloading) && (
- <div className="space-y-2">
- <Progress
- value={isDownloading ? downloadProgress : 100}
- className="w-full"
- />
- <p className="text-sm text-muted-foreground text-center">
- {isDownloading ? `正在下载文档... ${Math.round(downloadProgress)}%` : '正在打包文档...'}
- </p>
- </div>
- )}
- {/* 文档列表 */}
- <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 rounded-lg border ${
- selectedFiles.has(index)
- ? 'bg-blue-50 border-blue-200'
- : 'bg-gray-50 border-gray-200'
- }`}
- >
- <div className="flex items-center gap-3 flex-1 min-w-0">
- <Button
- variant="ghost"
- size="icon"
- className="h-6 w-6 shrink-0"
- onClick={() => toggleFileSelection(index)}
- >
- {selectedFiles.has(index) ? (
- <CheckSquare className="h-4 w-4 text-blue-600" />
- ) : (
- <Square className="h-4 w-4 text-gray-400" />
- )}
- </Button>
-
- <div className="min-w-0 flex-1">
- <p className="font-medium truncate">{file.name}</p>
- <p className="text-sm text-muted-foreground">
- 包含 {Object.keys(file.fields).length} 个字段
- </p>
- </div>
- </div>
-
- <div className="flex items-center gap-1 shrink-0">
- <Button
- variant="ghost"
- size="sm"
- onClick={() => downloadProcessedFile(file)}
- className="h-7 w-7 p-0"
- title="单独下载"
- >
- <Download className="h-3 w-3" />
- </Button>
- </div>
- </div>
- ))}
- </div>
- </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>
- 共 {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>
- )}
- {/* 使用说明 */}
- <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. 设置图片尺寸(新增)</h4>
- <p className="text-muted-foreground">
- 使用"图片尺寸设置"按钮设置图片最大尺寸,系统将自动限制所有图片尺寸并保留长宽比例
- </p>
- </div>
-
- <div>
- <h4 className="font-medium mb-1">3. 准备Excel数据</h4>
- <p className="text-muted-foreground">
- Excel文件第一行为表头,列名应与Word模板中的字段名对应
- </p>
- </div>
-
- <div>
- <h4 className="font-medium mb-1">4. 准备图片压缩包</h4>
- <p className="text-muted-foreground">
- 压缩包结构:第一层为序号文件夹(1,2,3...对应Excel行),第二层为图片文件
- </p>
- </div>
-
- <div>
- <h4 className="font-medium mb-1">5. 图片命名规则</h4>
- <p className="text-muted-foreground">
- <strong>自动重命名模式:</strong>启用"自动重命名图片为A1,A2,A3..."选项,系统会自动按图片顺序重命名,在Word模板中使用 {'{%A1}'}, {'{%A2}'}, {'{%A3}'} 等占位符
- </p>
- <p className="text-muted-foreground mt-2">
- <strong>手动命名模式:</strong>禁用自动重命名,图片文件名必须与模板中的图片占位符匹配(不含扩展名),例如:模板中使用 {'{%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>• 启用自动重命名时,图片将按顺序命名为A1,A2,A3...,模板中使用 {'{%A1}'}, {'{%A2}'}, {'{%A3}'} 等占位符</p>
- <p>• 禁用自动重命名时,图片文件名必须与模板中的图片占位符匹配(不含扩展名)</p>
- <p>• 文件夹序号必须与Excel行号对应(第1行对应文件夹"1")</p>
- <p>• 如果图片不存在,对应位置将留空</p>
- <p>• 支持jpg、jpeg、png、gif、bmp、webp格式图片</p>
- <p>• 图片占位符使用 {'{%图片名}'} 格式,如 {'{%logo}'} 或 {'{%A1}'}</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>
- );
- }
|