WordPreview.tsx 61 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691
  1. import { useState, useRef } from 'react';
  2. import * as XLSX from 'xlsx';
  3. import PizZip from 'pizzip';
  4. import Docxtemplater from 'docxtemplater';
  5. import ImageModule from 'open-docxtemplater-image-module-2';
  6. import JSZip from 'jszip';
  7. import { Button } from '@/client/components/ui/button';
  8. import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
  9. import { Input } from '@/client/components/ui/input';
  10. import { Label } from '@/client/components/ui/label';
  11. import { Alert, AlertDescription } from '@/client/components/ui/alert';
  12. import {
  13. FileText, Upload, Download, Eye, FileWarning, FileSpreadsheet,
  14. RefreshCw, CheckCircle, AlertCircle, DownloadCloud, Image, Package,
  15. X, ZoomIn, ZoomOut, ChevronLeft, ChevronRight, Settings,
  16. CheckSquare, Square, Archive, CheckCheck, User, MessageCircle
  17. } from 'lucide-react';
  18. import { toast } from 'sonner';
  19. import WordViewer from '@/client/home/components/WordViewer';
  20. import { Badge } from '@/client/components/ui/badge';
  21. import { Progress } from '@/client/components/ui/progress';
  22. import {
  23. Dialog,
  24. DialogContent,
  25. DialogHeader,
  26. DialogTitle,
  27. } from '@/client/components/ui/dialog';
  28. import { useAuth } from '@/client/home/hooks/AuthProvider';
  29. import { useNavigate } from 'react-router-dom';
  30. interface WordFile {
  31. id: string;
  32. name: string;
  33. size: number;
  34. url: string;
  35. previewUrl?: string;
  36. }
  37. interface ExcelRow {
  38. [key: string]: string | number;
  39. }
  40. interface ImageMapping {
  41. [key: string]: {
  42. [imageName: string]: File;
  43. };
  44. }
  45. interface ImageSizeSettings {
  46. width: number;
  47. height: number;
  48. }
  49. interface ProcessingResult {
  50. originalFile: File;
  51. generatedFiles: Array<{
  52. name: string;
  53. content: Blob;
  54. fields: Record<string, string>;
  55. }>;
  56. total: number;
  57. }
  58. export default function WordPreview() {
  59. const { isAuthenticated, isLoading: authLoading } = useAuth();
  60. const navigate = useNavigate();
  61. const [selectedWordFile, setSelectedWordFile] = useState<File | null>(null);
  62. const [selectedExcelFile, setSelectedExcelFile] = useState<File | null>(null);
  63. const [imageZipFile, setImageZipFile] = useState<File | null>(null);
  64. const [previewFile, setPreviewFile] = useState<WordFile | null>(null);
  65. const [isLoading, setIsLoading] = useState(false);
  66. const [previewLoading, setPreviewLoading] = useState(false);
  67. const [showPreview, setShowPreview] = useState(false);
  68. const [processingResult, setProcessingResult] = useState<ProcessingResult | null>(null);
  69. const [excelData, setExcelData] = useState<ExcelRow[]>([]);
  70. const [processingProgress, setProcessingProgress] = useState(0);
  71. const [imageMappings, setImageMappings] = useState<ImageMapping>({});
  72. const [imagePreviewUrls, setImagePreviewUrls] = useState<Record<string, Record<string, string>>>({});
  73. const [selectedImage, setSelectedImage] = useState<{
  74. url: string;
  75. name: string;
  76. folder: string;
  77. } | null>(null);
  78. const [currentImageIndex, setCurrentImageIndex] = useState(0);
  79. const [allImages, setAllImages] = useState<Array<{ url: string; name: string; folder: string }>>([]);
  80. const [imageSizeSettings, setImageSizeSettings] = useState<ImageSizeSettings>({ width: 200, height: 150 });
  81. const [showSizeSettings, setShowSizeSettings] = useState(false);
  82. // 新增状态:自动重命名图片功能
  83. const [autoRenameImages, setAutoRenameImages] = useState(true);
  84. // 新增状态:选择下载功能
  85. const [selectedFiles, setSelectedFiles] = useState<Set<number>>(new Set());
  86. const [isDownloading, setIsDownloading] = useState(false);
  87. const [downloadProgress, setDownloadProgress] = useState(0);
  88. const [mergeDownloading, setMergeDownloading] = useState(false);
  89. const [wordMergeDownloading, setWordMergeDownloading] = useState(false);
  90. const wordFileInputRef = useRef<HTMLInputElement>(null);
  91. const excelFileInputRef = useRef<HTMLInputElement>(null);
  92. const imageZipInputRef = useRef<HTMLInputElement>(null);
  93. // 文件选择处理
  94. const handleWordFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
  95. const file = event.target.files?.[0];
  96. if (file) {
  97. const validTypes = [
  98. 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
  99. ];
  100. const maxSize = 10 * 1024 * 1024;
  101. if (!validTypes.includes(file.type)) {
  102. toast.error('请选择有效的Word文件(.docx格式)');
  103. return;
  104. }
  105. if (file.size > maxSize) {
  106. toast.error('文件大小超过10MB限制');
  107. return;
  108. }
  109. setSelectedWordFile(file);
  110. setShowPreview(false);
  111. toast.success('Word模板已选择');
  112. }
  113. };
  114. const handleExcelFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
  115. const file = event.target.files?.[0];
  116. if (file) {
  117. const validTypes = [
  118. 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  119. 'application/vnd.ms-excel'
  120. ];
  121. const maxSize = 10 * 1024 * 1024;
  122. if (!validTypes.includes(file.type)) {
  123. toast.error('请选择有效的Excel文件(.xlsx/.xls格式)');
  124. return;
  125. }
  126. if (file.size > maxSize) {
  127. toast.error('文件大小超过10MB限制');
  128. return;
  129. }
  130. setSelectedExcelFile(file);
  131. parseExcelFile(file);
  132. toast.success('Excel数据文件已选择');
  133. }
  134. };
  135. const handleImageZipSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
  136. const file = event.target.files?.[0];
  137. if (file) {
  138. const maxSize = 500 * 1024 * 1024;
  139. // 检查文件扩展名 - 目前只支持ZIP格式
  140. const fileExtension = file.name.toLowerCase().split('.').pop();
  141. const isZipFile = fileExtension === 'zip';
  142. if (!isZipFile) {
  143. toast.error('目前仅支持 .zip 格式压缩包,请转换为ZIP格式后重试');
  144. return;
  145. }
  146. if (file.size > maxSize) {
  147. toast.error('压缩文件大小超过500MB限制');
  148. return;
  149. }
  150. setImageZipFile(file);
  151. await parseImageZip(file);
  152. toast.success('图片压缩文件已选择');
  153. }
  154. };
  155. // 解析Excel文件
  156. const parseExcelFile = async (file: File) => {
  157. try {
  158. const data = await file.arrayBuffer();
  159. const workbook = XLSX.read(data, { type: 'array' });
  160. const firstSheetName = workbook.SheetNames[0];
  161. const worksheet = workbook.Sheets[firstSheetName];
  162. const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][];
  163. if (jsonData.length < 2) {
  164. toast.error('Excel文件格式不正确,需要包含表头和至少一行数据');
  165. return;
  166. }
  167. const headers = jsonData[0] as string[];
  168. const rows: ExcelRow[] = [];
  169. for (let i = 1; i < jsonData.length; i++) {
  170. const row = jsonData[i];
  171. const rowData: ExcelRow = {};
  172. headers.forEach((header, index) => {
  173. rowData[header] = row[index] || '';
  174. });
  175. rows.push(rowData);
  176. }
  177. setExcelData(rows);
  178. toast.success(`成功解析 ${rows.length} 条数据记录`);
  179. } catch (error) {
  180. toast.error('Excel文件解析失败');
  181. console.error('Excel parsing error:', error);
  182. }
  183. };
  184. // 解析图片压缩文件
  185. const parseImageZip = async (file: File) => {
  186. try {
  187. const zip = new JSZip();
  188. const zipContent = await zip.loadAsync(file);
  189. const newImageMappings: ImageMapping = {};
  190. const newImagePreviewUrls: Record<string, Record<string, string>> = {};
  191. const allImages: Array<{ url: string; name: string; folder: string }> = [];
  192. if (autoRenameImages) {
  193. // 自动重命名模式:按文件夹分组图片并按顺序重命名为A1,A2,A3...
  194. const folderImages: Record<string, Array<{ path: string; zipEntry: JSZip.JSZipObject }>> = {};
  195. // 首先收集所有图片并按文件夹分组
  196. for (const [path, zipEntry] of Object.entries(zipContent.files)) {
  197. if (!zipEntry.dir && isImageFile(path)) {
  198. const pathParts = path.split('/');
  199. if (pathParts.length >= 2) {
  200. const folderIndex = pathParts[0];
  201. if (!folderImages[folderIndex]) {
  202. folderImages[folderIndex] = [];
  203. }
  204. folderImages[folderIndex].push({ path, zipEntry });
  205. }
  206. }
  207. }
  208. // 对每个文件夹的图片按文件名排序,然后重命名为A1,A2,A3...
  209. for (const [folderIndex, images] of Object.entries(folderImages)) {
  210. // 按文件名排序以确保顺序一致
  211. images.sort((a, b) => a.path.localeCompare(b.path));
  212. if (!newImageMappings[folderIndex]) {
  213. newImageMappings[folderIndex] = {};
  214. newImagePreviewUrls[folderIndex] = {};
  215. }
  216. // 为每个图片分配A1,A2,A3...的命名
  217. for (let i = 0; i < images.length; i++) {
  218. const { zipEntry } = images[i];
  219. const newImageName = `A${i + 1}`; // A1, A2, A3...
  220. const imageFile = await zipEntry.async('blob');
  221. newImageMappings[folderIndex][newImageName] = new File([imageFile], newImageName, {
  222. type: getImageMimeType(images[i].path)
  223. });
  224. // 创建预览URL
  225. const previewUrl = URL.createObjectURL(imageFile);
  226. newImagePreviewUrls[folderIndex][newImageName] = previewUrl;
  227. allImages.push({
  228. url: previewUrl,
  229. name: newImageName,
  230. folder: folderIndex
  231. });
  232. }
  233. }
  234. } else {
  235. // 原有模式:保持图片原始文件名
  236. for (const [path, zipEntry] of Object.entries(zipContent.files)) {
  237. if (!zipEntry.dir && isImageFile(path)) {
  238. const pathParts = path.split('/');
  239. if (pathParts.length >= 2) {
  240. const folderIndex = pathParts[0]; // 序号文件夹
  241. const imageName = pathParts[pathParts.length - 1].split('.')[0]; // 去掉扩展名的文件名
  242. const fullImageName = pathParts[pathParts.length - 1]; // 完整文件名
  243. if (!newImageMappings[folderIndex]) {
  244. newImageMappings[folderIndex] = {};
  245. newImagePreviewUrls[folderIndex] = {};
  246. }
  247. const imageFile = await zipEntry.async('blob');
  248. newImageMappings[folderIndex][imageName] = new File([imageFile], imageName, {
  249. type: getImageMimeType(path)
  250. });
  251. // 创建预览URL
  252. const previewUrl = URL.createObjectURL(imageFile);
  253. newImagePreviewUrls[folderIndex][imageName] = previewUrl;
  254. allImages.push({
  255. url: previewUrl,
  256. name: fullImageName,
  257. folder: folderIndex
  258. });
  259. }
  260. }
  261. }
  262. }
  263. setImageMappings(newImageMappings);
  264. setImagePreviewUrls(newImagePreviewUrls);
  265. setAllImages(allImages);
  266. if (autoRenameImages) {
  267. toast.success(`成功解析 ${Object.keys(newImageMappings).length} 个文件夹的图片,已自动重命名为A1,A2,A3...`);
  268. } else {
  269. toast.success(`成功解析 ${Object.keys(newImageMappings).length} 个文件夹的图片`);
  270. }
  271. } catch (error) {
  272. toast.error('图片压缩文件解析失败');
  273. console.error('Image zip parsing error:', error);
  274. }
  275. };
  276. // 工具函数
  277. const isImageFile = (filename: string): boolean => {
  278. const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'];
  279. return imageExtensions.some(ext => filename.toLowerCase().endsWith(ext));
  280. };
  281. const getImageMimeType = (filename: string): string => {
  282. const extension = filename.toLowerCase().split('.').pop();
  283. const mimeTypes: Record<string, string> = {
  284. 'jpg': 'image/jpeg',
  285. 'jpeg': 'image/jpeg',
  286. 'png': 'image/png',
  287. 'gif': 'image/gif',
  288. 'bmp': 'image/bmp',
  289. 'webp': 'image/webp'
  290. };
  291. return mimeTypes[extension || ''] || 'image/jpeg';
  292. };
  293. // 不再需要从模板解析图片尺寸,使用用户设置
  294. // 计算保持比例的图片尺寸
  295. const calculateProportionalSize = (
  296. originalWidth: number,
  297. originalHeight: number,
  298. maxWidth: number,
  299. maxHeight: number
  300. ): [number, number] => {
  301. const widthRatio = maxWidth / originalWidth;
  302. const heightRatio = maxHeight / originalHeight;
  303. // 使用较小的比例以保持原始长宽比
  304. const ratio = Math.min(widthRatio, heightRatio, 1);
  305. const newWidth = Math.round(originalWidth * ratio);
  306. const newHeight = Math.round(originalHeight * ratio);
  307. return [newWidth, newHeight];
  308. };
  309. // 替换Word字段并插入图片
  310. const replaceFieldsInWord = async (wordFile: File, excelRow: ExcelRow, rowIndex: number): Promise<Blob> => {
  311. try {
  312. const arrayBuffer = await wordFile.arrayBuffer();
  313. const zip = new PizZip(arrayBuffer);
  314. const folderIndex = (rowIndex + 1).toString();
  315. const imageDataMap: Record<string, ArrayBuffer> = {};
  316. // 预加载当前文件夹的所有图片 - 使用更健壮的方式
  317. if (imageMappings[folderIndex]) {
  318. for (const [imageName, imageFile] of Object.entries(imageMappings[folderIndex])) {
  319. try {
  320. // 创建文件的副本,避免原始文件引用丢失
  321. const fileCopy = new File([imageFile], imageFile.name, { type: imageFile.type });
  322. imageDataMap[imageName] = await fileCopy.arrayBuffer();
  323. } catch (error) {
  324. console.warn(`Failed to load image ${imageName}:`, error);
  325. // 如果加载失败,尝试重新从原始映射获取
  326. try {
  327. const originalFile = imageMappings[folderIndex][imageName];
  328. if (originalFile) {
  329. imageDataMap[imageName] = await originalFile.arrayBuffer();
  330. }
  331. } catch (retryError) {
  332. console.warn(`Retry failed for image ${imageName}:`, retryError);
  333. }
  334. }
  335. }
  336. }
  337. // 使用用户设置的图片尺寸限制
  338. const defaultLimit = imageSizeSettings;
  339. // 配置图片模块 - 使用实际图片尺寸并应用限制
  340. const imageSizeCache = new Map<string, [number, number]>();
  341. const imageOpts = {
  342. centered: false,
  343. getImage: (tagValue: string): ArrayBuffer | null => {
  344. if (tagValue && typeof tagValue === 'string') {
  345. return imageDataMap[tagValue] || null;
  346. }
  347. return null;
  348. },
  349. getSize: (img: ArrayBuffer, tagValue: string, tagName: string) => {
  350. try {
  351. const cacheKey = `${tagValue}_${img.byteLength}`;
  352. if (imageSizeCache.has(cacheKey)) {
  353. return imageSizeCache.get(cacheKey)!;
  354. }
  355. // 获取图片原始尺寸
  356. let originalWidth = 200;
  357. let originalHeight = 150;
  358. const view = new DataView(img);
  359. // PNG格式检测
  360. if (view.getUint32(0) === 0x89504E47 && view.getUint32(4) === 0x0D0A1A0A) {
  361. originalWidth = view.getUint32(16);
  362. originalHeight = view.getUint32(20);
  363. }
  364. // JPEG格式检测
  365. else if (view.getUint16(0) === 0xFFD8) {
  366. let offset = 2;
  367. while (offset < img.byteLength - 10) {
  368. if (view.getUint8(offset) === 0xFF) {
  369. const marker = view.getUint8(offset + 1);
  370. if (marker >= 0xC0 && marker <= 0xC3) {
  371. originalHeight = view.getUint16(offset + 5);
  372. originalWidth = view.getUint16(offset + 7);
  373. break;
  374. }
  375. const length = view.getUint16(offset + 2);
  376. offset += length + 2;
  377. continue;
  378. }
  379. offset++;
  380. }
  381. }
  382. // 计算符合尺寸限制的最终尺寸
  383. const [finalWidth, finalHeight] = calculateProportionalSize(
  384. originalWidth,
  385. originalHeight,
  386. defaultLimit.width,
  387. defaultLimit.height
  388. );
  389. const finalSize: [number, number] = [finalWidth, finalHeight];
  390. imageSizeCache.set(cacheKey, finalSize);
  391. return finalSize;
  392. } catch (error) {
  393. console.warn('Failed to get image size, using default:', error);
  394. return [defaultLimit.width, defaultLimit.height];
  395. }
  396. }
  397. };
  398. const imageModule = new ImageModule(imageOpts);
  399. // 创建Docxtemplater实例
  400. const doc = new Docxtemplater(zip, {
  401. modules: [imageModule],
  402. paragraphLoop: true,
  403. linebreaks: true,
  404. });
  405. // 处理嵌套数据结构
  406. const processedData: Record<string, any> = {};
  407. // 处理普通字段
  408. Object.entries(excelRow).forEach(([key, value]) => {
  409. if (key.includes('.')) {
  410. const parts = key.split('.');
  411. let current = processedData;
  412. for (let i = 0; i < parts.length - 1; i++) {
  413. if (!current[parts[i]]) {
  414. current[parts[i]] = {};
  415. }
  416. current = current[parts[i]];
  417. }
  418. current[parts[parts.length - 1]] = value;
  419. } else {
  420. processedData[key] = value;
  421. }
  422. });
  423. // 处理图片字段 - 确保图片名称正确传递给模板
  424. if (imageMappings[folderIndex]) {
  425. for (const [imageName] of Object.entries(imageMappings[folderIndex])) {
  426. // 确保图片名称作为标签值传递给模板
  427. processedData[imageName] = imageName;
  428. }
  429. }
  430. doc.render(processedData);
  431. const generatedDoc = doc.getZip().generate({
  432. type: 'blob',
  433. mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  434. });
  435. return generatedDoc;
  436. } catch (error) {
  437. console.error('Word处理错误:', error);
  438. throw new Error(`Word文档处理失败,请检查模板格式: ${error instanceof Error ? error.message : String(error)}`);
  439. }
  440. };
  441. // 处理文件(带重试机制)
  442. const processFiles = async () => {
  443. if (!selectedWordFile || !selectedExcelFile || excelData.length === 0) {
  444. toast.error('请先选择Word模板和Excel数据文件');
  445. return;
  446. }
  447. setIsLoading(true);
  448. setProcessingProgress(0);
  449. try {
  450. const generatedFiles: ProcessingResult['generatedFiles'] = [];
  451. const maxRetries = 3;
  452. for (let i = 0; i < excelData.length; i++) {
  453. const row = excelData[i];
  454. let processedBlob: Blob | null = null;
  455. let retryCount = 0;
  456. let lastError: Error | null = null;
  457. // 重试机制
  458. while (retryCount < maxRetries && !processedBlob) {
  459. try {
  460. processedBlob = await replaceFieldsInWord(selectedWordFile, row, i);
  461. } catch (error) {
  462. lastError = error as Error;
  463. retryCount++;
  464. console.warn(`处理第 ${i + 1} 行数据失败,第 ${retryCount} 次重试:`, error);
  465. if (retryCount < maxRetries) {
  466. // 等待一段时间后重试
  467. await new Promise(resolve => setTimeout(resolve, 500 * retryCount));
  468. }
  469. }
  470. }
  471. if (!processedBlob) {
  472. throw new Error(`无法处理第 ${i + 1} 行数据: ${lastError?.message || '未知错误'}`);
  473. }
  474. const fileName = `processed_${i + 1}_${selectedWordFile.name}`;
  475. generatedFiles.push({
  476. name: fileName,
  477. content: processedBlob,
  478. fields: row
  479. });
  480. setProcessingProgress(((i + 1) / excelData.length) * 100);
  481. }
  482. const result: ProcessingResult = {
  483. originalFile: selectedWordFile,
  484. generatedFiles,
  485. total: generatedFiles.length
  486. };
  487. setProcessingResult(result);
  488. toast.success(`成功生成 ${generatedFiles.length} 个文档`);
  489. } catch (error) {
  490. console.error('Processing error:', error);
  491. const errorMessage = error instanceof Error ? error.message : '文档处理失败';
  492. if (errorMessage.includes('权限') || errorMessage.includes('permission')) {
  493. toast.error('文件权限错误,请重新选择文件后重试');
  494. } else if (errorMessage.includes('模板格式')) {
  495. toast.error('Word模板格式错误,请检查模板中的占位符格式');
  496. } else {
  497. toast.error(`文档处理失败: ${errorMessage}`);
  498. }
  499. } finally {
  500. setIsLoading(false);
  501. setProcessingProgress(0);
  502. }
  503. };
  504. // 预览功能
  505. const handlePreview = async () => {
  506. if (!selectedWordFile) {
  507. toast.error('请先选择Word文件');
  508. return;
  509. }
  510. setPreviewLoading(true);
  511. setShowPreview(true);
  512. try {
  513. const fileUrl = URL.createObjectURL(selectedWordFile);
  514. const wordFile: WordFile = {
  515. id: Date.now().toString(),
  516. name: selectedWordFile.name,
  517. size: selectedWordFile.size,
  518. url: fileUrl,
  519. previewUrl: fileUrl
  520. };
  521. setPreviewFile(wordFile);
  522. toast.success('正在预览Word模板...');
  523. } catch (error) {
  524. toast.error('文件预览失败');
  525. console.error('Preview error:', error);
  526. setShowPreview(false);
  527. } finally {
  528. setPreviewLoading(false);
  529. }
  530. };
  531. // 下载功能
  532. const downloadProcessedFile = (file: ProcessingResult['generatedFiles'][0]) => {
  533. const url = URL.createObjectURL(file.content);
  534. const a = document.createElement('a');
  535. a.href = url;
  536. a.download = file.name;
  537. document.body.appendChild(a);
  538. a.click();
  539. document.body.removeChild(a);
  540. URL.revokeObjectURL(url);
  541. };
  542. const downloadAllFiles = () => {
  543. if (!processingResult) return;
  544. processingResult.generatedFiles.forEach((file, index) => {
  545. setTimeout(() => {
  546. downloadProcessedFile(file);
  547. }, index * 500);
  548. });
  549. };
  550. // 新增:选择下载功能
  551. const toggleFileSelection = (index: number) => {
  552. const newSelectedFiles = new Set(selectedFiles);
  553. if (newSelectedFiles.has(index)) {
  554. newSelectedFiles.delete(index);
  555. } else {
  556. newSelectedFiles.add(index);
  557. }
  558. setSelectedFiles(newSelectedFiles);
  559. };
  560. const selectAllFiles = () => {
  561. if (!processingResult) return;
  562. const allIndices = new Set(Array.from({ length: processingResult.generatedFiles.length }, (_, i) => i));
  563. setSelectedFiles(allIndices);
  564. };
  565. const clearSelection = () => {
  566. setSelectedFiles(new Set());
  567. };
  568. const downloadSelectedFiles = async () => {
  569. if (!processingResult || selectedFiles.size === 0) return;
  570. setIsDownloading(true);
  571. setDownloadProgress(0);
  572. const filesToDownload = Array.from(selectedFiles)
  573. .sort((a, b) => a - b)
  574. .map(index => processingResult.generatedFiles[index]);
  575. for (let i = 0; i < filesToDownload.length; i++) {
  576. downloadProcessedFile(filesToDownload[i]);
  577. setDownloadProgress(((i + 1) / filesToDownload.length) * 100);
  578. await new Promise(resolve => setTimeout(resolve, 300)); // 添加延迟避免浏览器阻塞
  579. }
  580. setIsDownloading(false);
  581. setDownloadProgress(0);
  582. toast.success(`已下载 ${filesToDownload.length} 个文档`);
  583. };
  584. // 新增:Word文档合并下载功能
  585. const mergeAndDownloadFiles = async () => {
  586. if (!processingResult || selectedFiles.size === 0) return;
  587. setMergeDownloading(true);
  588. try {
  589. const filesToMerge = Array.from(selectedFiles)
  590. .sort((a, b) => a - b)
  591. .map(index => processingResult.generatedFiles[index]);
  592. // 创建一个新的JSZip实例来合并文件
  593. const JSZip = (await import('jszip')).default;
  594. const zip = new JSZip();
  595. // 将所有选中的文件添加到zip中
  596. filesToMerge.forEach((file, index) => {
  597. zip.file(file.name, file.content);
  598. });
  599. // 生成zip文件
  600. const zipContent = await zip.generateAsync({
  601. type: 'blob',
  602. compression: 'DEFLATE',
  603. compressionOptions: {
  604. level: 6
  605. }
  606. });
  607. // 下载合并的zip文件
  608. const url = URL.createObjectURL(zipContent);
  609. const a = document.createElement('a');
  610. a.href = url;
  611. a.download = `合并文档_${new Date().toISOString().slice(0, 10)}.zip`;
  612. document.body.appendChild(a);
  613. a.click();
  614. document.body.removeChild(a);
  615. URL.revokeObjectURL(url);
  616. toast.success(`已合并并下载 ${filesToMerge.length} 个文档`);
  617. } catch (error) {
  618. console.error('文档合并失败:', error);
  619. toast.error('文档合并失败,请重试');
  620. } finally {
  621. setMergeDownloading(false);
  622. }
  623. };
  624. // 新增:下载所有文件为zip
  625. const downloadAllAsZip = async () => {
  626. if (!processingResult) return;
  627. setMergeDownloading(true);
  628. try {
  629. const JSZip = (await import('jszip')).default;
  630. const zip = new JSZip();
  631. processingResult.generatedFiles.forEach((file, index) => {
  632. zip.file(file.name, file.content);
  633. });
  634. const zipContent = await zip.generateAsync({
  635. type: 'blob',
  636. compression: 'DEFLATE',
  637. compressionOptions: {
  638. level: 6
  639. }
  640. });
  641. const url = URL.createObjectURL(zipContent);
  642. const a = document.createElement('a');
  643. a.href = url;
  644. a.download = `全部文档_${new Date().toISOString().slice(0, 10)}.zip`;
  645. document.body.appendChild(a);
  646. a.click();
  647. document.body.removeChild(a);
  648. URL.revokeObjectURL(url);
  649. toast.success(`已下载全部 ${processingResult.generatedFiles.length} 个文档为压缩包`);
  650. } catch (error) {
  651. console.error('压缩包创建失败:', error);
  652. toast.error('压缩包创建失败,请重试');
  653. } finally {
  654. setMergeDownloading(false);
  655. }
  656. };
  657. // 新增:Word文档内容合并功能(将多个Word文档合并成一个Word文档)
  658. const mergeWordDocuments = async () => {
  659. if (!processingResult || selectedFiles.size === 0) return;
  660. setWordMergeDownloading(true);
  661. try {
  662. const filesToMerge = Array.from(selectedFiles)
  663. .sort((a, b) => a - b)
  664. .map(index => processingResult.generatedFiles[index]);
  665. // 加载docxtemplater和pizzip
  666. const PizZip = (await import('pizzip')).default;
  667. const Docxtemplater = (await import('docxtemplater')).default;
  668. // 创建一个新的空Word文档作为基础
  669. const baseArrayBuffer = await selectedWordFile!.arrayBuffer();
  670. const baseZip = new PizZip(baseArrayBuffer);
  671. const baseDoc = new Docxtemplater(baseZip, {
  672. paragraphLoop: true,
  673. linebreaks: true,
  674. });
  675. // 获取基础文档的内容
  676. baseDoc.render({});
  677. const baseContent = baseDoc.getZip().generate({
  678. type: 'blob',
  679. mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  680. });
  681. // 创建一个新的JSZip实例来合并内容
  682. const JSZip = (await import('jszip')).default;
  683. const mergedZip = new JSZip();
  684. // 加载基础文档
  685. const baseDocx = await mergedZip.loadAsync(await baseContent.arrayBuffer());
  686. // 获取基础文档的word/document.xml内容
  687. let mergedContent = await baseDocx.file('word/document.xml').async('text');
  688. // 移除基础文档的结束标签,以便添加其他文档内容
  689. mergedContent = mergedContent.replace(/<\/w:body><\/w:document>$/, '');
  690. // 逐个添加其他文档的内容
  691. for (const file of filesToMerge) {
  692. const fileArrayBuffer = await file.content.arrayBuffer();
  693. const fileZip = await mergedZip.loadAsync(fileArrayBuffer);
  694. let fileContent = await fileZip.file('word/document.xml').async('text');
  695. // 提取文档主体内容(去掉xml声明和文档标签)
  696. const bodyMatch = fileContent.match(/<w:body[^>]*>([\s\S]*?)<\/w:body>/);
  697. if (bodyMatch && bodyMatch[1]) {
  698. mergedContent += bodyMatch[1];
  699. }
  700. }
  701. // 添加结束标签
  702. mergedContent += '</w:body></w:document>';
  703. // 更新合并后的内容
  704. baseDocx.file('word/document.xml', mergedContent);
  705. // 生成合并后的Word文档
  706. const mergedDoc = await baseDocx.generateAsync({
  707. type: 'blob',
  708. mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  709. compression: 'DEFLATE',
  710. compressionOptions: { level: 6 }
  711. });
  712. // 下载合并后的Word文档
  713. const url = URL.createObjectURL(mergedDoc);
  714. const a = document.createElement('a');
  715. a.href = url;
  716. a.download = `合并Word文档_${new Date().toISOString().slice(0, 10)}.docx`;
  717. document.body.appendChild(a);
  718. a.click();
  719. document.body.removeChild(a);
  720. URL.revokeObjectURL(url);
  721. toast.success(`已成功合并 ${filesToMerge.length} 个Word文档为一个文件`);
  722. } catch (error) {
  723. console.error('Word文档合并失败:', error);
  724. toast.error('Word文档合并失败,请重试');
  725. } finally {
  726. setWordMergeDownloading(false);
  727. }
  728. };
  729. const clearAllFiles = () => {
  730. setSelectedWordFile(null);
  731. setSelectedExcelFile(null);
  732. setImageZipFile(null);
  733. setExcelData([]);
  734. setProcessingResult(null);
  735. setImageMappings({});
  736. setImagePreviewUrls({});
  737. setSelectedImage(null);
  738. setAllImages([]);
  739. setPreviewFile(null);
  740. setShowPreview(false);
  741. if (wordFileInputRef.current) wordFileInputRef.current.value = '';
  742. if (excelFileInputRef.current) excelFileInputRef.current.value = '';
  743. if (imageZipInputRef.current) imageZipInputRef.current.value = '';
  744. toast.success('已清除所有文件');
  745. };
  746. const formatFileSize = (bytes: number) => {
  747. if (bytes === 0) return '0 Bytes';
  748. const k = 1024;
  749. const sizes = ['Bytes', 'KB', 'MB', 'GB'];
  750. const i = Math.floor(Math.log(bytes) / Math.log(k));
  751. return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  752. };
  753. // 图片预览功能
  754. const openImagePreview = (url: string, name: string, folder: string) => {
  755. const imageIndex = allImages.findIndex(img => img.url === url);
  756. setCurrentImageIndex(imageIndex !== -1 ? imageIndex : 0);
  757. setSelectedImage({ url, name, folder });
  758. };
  759. const closeImagePreview = () => {
  760. setSelectedImage(null);
  761. };
  762. const navigateImage = (direction: 'prev' | 'next') => {
  763. if (allImages.length === 0) return;
  764. let newIndex = currentImageIndex;
  765. if (direction === 'prev') {
  766. newIndex = currentImageIndex > 0 ? currentImageIndex - 1 : allImages.length - 1;
  767. } else {
  768. newIndex = currentImageIndex < allImages.length - 1 ? currentImageIndex + 1 : 0;
  769. }
  770. setCurrentImageIndex(newIndex);
  771. setSelectedImage(allImages[newIndex]);
  772. };
  773. const getTotalImages = () => {
  774. return Object.values(imagePreviewUrls).reduce((total, folder) => total + Object.keys(folder).length, 0);
  775. };
  776. // 如果未登录,显示提示信息
  777. if (!isAuthenticated && !authLoading) {
  778. return (
  779. <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 px-4 py-12">
  780. <Card className="w-full max-w-md border-0 shadow-xl">
  781. <CardHeader className="space-y-1 text-center">
  782. <div className="mx-auto bg-red-100 p-3 rounded-full">
  783. <User className="h-8 w-8 text-red-600" />
  784. </div>
  785. <CardTitle className="text-2xl font-bold">需要登录</CardTitle>
  786. <CardDescription>
  787. 只有登录用户才能访问文档处理功能
  788. </CardDescription>
  789. </CardHeader>
  790. <CardContent className="space-y-4">
  791. <Alert variant="destructive">
  792. <AlertCircle className="h-4 w-4" />
  793. <AlertDescription>
  794. 请跟管理员联系获取账号密码
  795. </AlertDescription>
  796. </Alert>
  797. <div className="bg-blue-50 p-4 rounded-lg">
  798. <div className="flex items-center justify-center space-x-2">
  799. <MessageCircle className="h-5 w-5 text-blue-600" />
  800. <span className="font-medium text-blue-800">管理员微信:</span>
  801. <span className="text-lg font-bold text-blue-900">andree123654</span>
  802. </div>
  803. <p className="text-sm text-blue-700 mt-2 text-center">
  804. 添加管理员微信获取账号密码
  805. </p>
  806. </div>
  807. <Button
  808. className="w-full"
  809. onClick={() => navigate('/login')}
  810. >
  811. 前往登录页面
  812. </Button>
  813. </CardContent>
  814. </Card>
  815. </div>
  816. );
  817. }
  818. // 如果正在检查认证状态,显示加载中
  819. if (authLoading) {
  820. return (
  821. <div className="flex justify-center items-center min-h-screen">
  822. <div className="text-center">
  823. <RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4" />
  824. <p>正在检查登录状态...</p>
  825. </div>
  826. </div>
  827. );
  828. }
  829. return (
  830. <div className="space-y-6">
  831. <div>
  832. <h1 className="text-3xl font-bold tracking-tight">元亨Word批量处理增强版</h1>
  833. <p className="text-muted-foreground">支持图片压缩文件,自动生成替换字段和图片的文档</p>
  834. </div>
  835. {/* 文件上传区域 */}
  836. <div className="grid gap-6 md:grid-cols-3">
  837. {/* Word模板上传 */}
  838. <Card>
  839. <CardHeader>
  840. <CardTitle className="flex items-center gap-2">
  841. <FileText className="h-5 w-5" />
  842. 选择Word模板
  843. </CardTitle>
  844. <CardDescription>
  845. 支持 .docx 格式的Word文档,最大10MB
  846. </CardDescription>
  847. </CardHeader>
  848. <CardContent className="space-y-4">
  849. <div className="grid w-full items-center gap-1.5">
  850. <Label htmlFor="word-file">Word模板文件</Label>
  851. <Input
  852. ref={wordFileInputRef}
  853. id="word-file"
  854. type="file"
  855. accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
  856. onChange={handleWordFileSelect}
  857. />
  858. </div>
  859. {selectedWordFile && (
  860. <Alert>
  861. <FileText className="h-4 w-4" />
  862. <AlertDescription>
  863. <div className="space-y-1">
  864. <p><strong>文件名:</strong> {selectedWordFile.name}</p>
  865. <p><strong>大小:</strong> {formatFileSize(selectedWordFile.size)}</p>
  866. </div>
  867. </AlertDescription>
  868. </Alert>
  869. )}
  870. </CardContent>
  871. </Card>
  872. {/* Excel数据上传 */}
  873. <Card>
  874. <CardHeader>
  875. <CardTitle className="flex items-center gap-2">
  876. <FileSpreadsheet className="h-5 w-5" />
  877. 选择Excel数据
  878. </CardTitle>
  879. <CardDescription>
  880. 支持 .xlsx/.xls 格式的Excel文档,最大10MB
  881. </CardDescription>
  882. </CardHeader>
  883. <CardContent className="space-y-4">
  884. <div className="grid w-full items-center gap-1.5">
  885. <Label htmlFor="excel-file">Excel数据文件</Label>
  886. <Input
  887. ref={excelFileInputRef}
  888. id="excel-file"
  889. type="file"
  890. accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
  891. onChange={handleExcelFileSelect}
  892. />
  893. </div>
  894. {selectedExcelFile && (
  895. <Alert>
  896. <FileSpreadsheet className="h-4 w-4" />
  897. <AlertDescription>
  898. <div className="space-y-1">
  899. <p><strong>文件名:</strong> {selectedExcelFile.name}</p>
  900. <p><strong>大小:</strong> {formatFileSize(selectedExcelFile.size)}</p>
  901. {excelData.length > 0 && (
  902. <p><strong>数据行数:</strong> {excelData.length}</p>
  903. )}
  904. </div>
  905. </AlertDescription>
  906. </Alert>
  907. )}
  908. </CardContent>
  909. </Card>
  910. {/* 图片压缩文件上传 */}
  911. <Card>
  912. <CardHeader>
  913. <CardTitle className="flex items-center gap-2">
  914. <Package className="h-5 w-5" />
  915. 选择图片压缩包
  916. </CardTitle>
  917. <CardDescription>
  918. 支持 .zip 格式压缩包,最大500MB
  919. </CardDescription>
  920. </CardHeader>
  921. <CardContent className="space-y-4">
  922. {/* 自动重命名开关 */}
  923. <div className="flex items-center space-x-2">
  924. <input
  925. type="checkbox"
  926. id="auto-rename"
  927. checked={autoRenameImages}
  928. onChange={(e) => setAutoRenameImages(e.target.checked)}
  929. className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
  930. />
  931. <Label htmlFor="auto-rename" className="text-sm font-medium">
  932. 自动重命名图片为A1,A2,A3...
  933. </Label>
  934. </div>
  935. <div className="grid w-full items-center gap-1.5">
  936. <Label htmlFor="image-zip">图片压缩文件</Label>
  937. <Input
  938. ref={imageZipInputRef}
  939. id="image-zip"
  940. type="file"
  941. accept=".zip,.rar,.7z,application/zip,application/x-zip-compressed,application/x-rar-compressed,application/x-7z-compressed"
  942. onChange={handleImageZipSelect}
  943. />
  944. </div>
  945. {imageZipFile && (
  946. <Alert>
  947. <Image className="h-4 w-4" />
  948. <AlertDescription>
  949. <div className="space-y-1">
  950. <p><strong>文件名:</strong> {imageZipFile.name}</p>
  951. <p><strong>大小:</strong> {formatFileSize(imageZipFile.size)}</p>
  952. {Object.keys(imageMappings).length > 0 && (
  953. <p><strong>文件夹数:</strong> {Object.keys(imageMappings).length}</p>
  954. )}
  955. </div>
  956. </AlertDescription>
  957. </Alert>
  958. )}
  959. </CardContent>
  960. </Card>
  961. </div>
  962. {/* 操作按钮 */}
  963. <Card>
  964. <CardHeader>
  965. <CardTitle>操作区域</CardTitle>
  966. <CardDescription>选择文件后执行相应操作</CardDescription>
  967. </CardHeader>
  968. <CardContent className="space-y-4">
  969. <div className="flex gap-2 flex-wrap">
  970. <Button
  971. onClick={handlePreview}
  972. disabled={!selectedWordFile || previewLoading}
  973. variant="outline"
  974. >
  975. <Eye className="h-4 w-4 mr-2" />
  976. 预览模板
  977. </Button>
  978. <Button
  979. onClick={processFiles}
  980. disabled={!selectedWordFile || !selectedExcelFile || excelData.length === 0 || isLoading}
  981. className="bg-blue-600 hover:bg-blue-700"
  982. >
  983. {isLoading ? (
  984. <>
  985. <RefreshCw className="h-4 w-4 mr-2 animate-spin" />
  986. 处理中...
  987. </>
  988. ) : (
  989. <>
  990. <Upload className="h-4 w-4 mr-2" />
  991. 开始处理
  992. </>
  993. )}
  994. </Button>
  995. <Button
  996. onClick={clearAllFiles}
  997. variant="outline"
  998. className="text-red-600 hover:text-red-700"
  999. >
  1000. 清除所有
  1001. </Button>
  1002. </div>
  1003. {/* 图片尺寸设置 */}
  1004. <div className="border-t pt-4 mt-4">
  1005. <div className="flex items-center justify-between mb-3">
  1006. <h4 className="font-medium">图片尺寸设置</h4>
  1007. <Button
  1008. variant="outline"
  1009. size="sm"
  1010. onClick={() => setShowSizeSettings(!showSizeSettings)}
  1011. >
  1012. <Settings className="h-4 w-4 mr-2" />
  1013. {showSizeSettings ? '隐藏设置' : '设置尺寸'}
  1014. </Button>
  1015. </div>
  1016. {showSizeSettings && (
  1017. <div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
  1018. <div>
  1019. <Label htmlFor="image-width">图片宽度 (像素)</Label>
  1020. <Input
  1021. id="image-width"
  1022. type="number"
  1023. min="0"
  1024. max="1000"
  1025. value={imageSizeSettings.width}
  1026. onChange={(e) => setImageSizeSettings(prev => ({
  1027. ...prev,
  1028. width: Math.max(0, Math.min(1000, parseInt(e.target.value) || 0))
  1029. }))}
  1030. className="mt-1"
  1031. />
  1032. </div>
  1033. <div>
  1034. <Label htmlFor="image-height">图片高度 (像素)</Label>
  1035. <Input
  1036. id="image-height"
  1037. type="number"
  1038. min="0"
  1039. max="1000"
  1040. value={imageSizeSettings.height}
  1041. onChange={(e) => setImageSizeSettings(prev => ({
  1042. ...prev,
  1043. height: Math.max(0, Math.min(1000, parseInt(e.target.value) || 0))
  1044. }))}
  1045. className="mt-1"
  1046. />
  1047. </div>
  1048. <div className="col-span-2">
  1049. <div className="text-sm text-muted-foreground">
  1050. <p>• 当前设置:宽度 {imageSizeSettings.width}px,高度 {imageSizeSettings.height}px</p>
  1051. <p>• 系统将自动保持图片长宽比例</p>
  1052. <p>• 支持范围:0-1000 像素,可输入任意数值</p>
  1053. <p>• 建议尺寸:单列540×800像素,双列300×500像素(具体根据实际文档需要显示大小测试,可以先导入部分数据测试显示大小)</p>
  1054. </div>
  1055. </div>
  1056. </div>
  1057. )}
  1058. </div>
  1059. {isLoading && (
  1060. <div className="space-y-2">
  1061. <Progress value={processingProgress} className="w-full" />
  1062. <p className="text-sm text-muted-foreground text-center">
  1063. 正在处理文档... {Math.round(processingProgress)}%
  1064. </p>
  1065. </div>
  1066. )}
  1067. </CardContent>
  1068. </Card>
  1069. {/* 预览区域 */}
  1070. {showPreview && selectedWordFile && (
  1071. <Card>
  1072. <CardHeader>
  1073. <CardTitle className="flex items-center gap-2">
  1074. <FileText className="h-5 w-5" />
  1075. 文档预览
  1076. </CardTitle>
  1077. <CardDescription>
  1078. {selectedWordFile.name}
  1079. </CardDescription>
  1080. </CardHeader>
  1081. <CardContent>
  1082. <WordViewer file={selectedWordFile} />
  1083. </CardContent>
  1084. </Card>
  1085. )}
  1086. {/* 处理结果 */}
  1087. {processingResult && (
  1088. <Card>
  1089. <CardHeader>
  1090. <CardTitle className="flex items-center gap-2">
  1091. <CheckCircle className="h-5 w-5 text-green-500" />
  1092. 处理完成
  1093. </CardTitle>
  1094. <CardDescription>
  1095. 共生成 {processingResult.total} 个文档
  1096. </CardDescription>
  1097. </CardHeader>
  1098. <CardContent>
  1099. <div className="space-y-4">
  1100. {/* 批量操作工具栏 */}
  1101. <div className="flex flex-wrap gap-2 items-center justify-between p-3 bg-blue-50 rounded-lg">
  1102. <div className="flex items-center gap-2">
  1103. <span className="text-sm font-medium">
  1104. 已选择 {selectedFiles.size} 个文档
  1105. </span>
  1106. {selectedFiles.size > 0 && (
  1107. <Button
  1108. variant="ghost"
  1109. size="sm"
  1110. onClick={clearSelection}
  1111. className="h-7 px-2 text-xs"
  1112. >
  1113. 取消选择
  1114. </Button>
  1115. )}
  1116. </div>
  1117. <div className="flex gap-2">
  1118. <Button
  1119. variant="outline"
  1120. size="sm"
  1121. onClick={selectAllFiles}
  1122. className="h-7 px-2 text-xs"
  1123. >
  1124. <CheckSquare className="h-3 w-3 mr-1" />
  1125. 全选
  1126. </Button>
  1127. {selectedFiles.size > 0 && (
  1128. <>
  1129. <Button
  1130. onClick={downloadSelectedFiles}
  1131. disabled={isDownloading || selectedFiles.size === 0}
  1132. size="sm"
  1133. className="h-7 px-2 text-xs bg-green-600 hover:bg-green-700"
  1134. >
  1135. {isDownloading ? (
  1136. <RefreshCw className="h-3 w-3 mr-1 animate-spin" />
  1137. ) : (
  1138. <Download className="h-3 w-3 mr-1" />
  1139. )}
  1140. 下载选中 ({selectedFiles.size})
  1141. </Button>
  1142. <Button
  1143. onClick={mergeAndDownloadFiles}
  1144. disabled={mergeDownloading || selectedFiles.size === 0}
  1145. size="sm"
  1146. variant="outline"
  1147. className="h-7 px-2 text-xs"
  1148. >
  1149. {mergeDownloading ? (
  1150. <RefreshCw className="h-3 w-3 mr-1 animate-spin" />
  1151. ) : (
  1152. <Archive className="h-3 w-3 mr-1" />
  1153. )}
  1154. 打包为ZIP
  1155. </Button>
  1156. <Button
  1157. onClick={mergeWordDocuments}
  1158. disabled={wordMergeDownloading || selectedFiles.size === 0}
  1159. size="sm"
  1160. variant="outline"
  1161. className="h-7 px-2 text-xs bg-green-600 hover:bg-green-700 text-white"
  1162. >
  1163. {wordMergeDownloading ? (
  1164. <RefreshCw className="h-3 w-3 mr-1 animate-spin" />
  1165. ) : (
  1166. <FileText className="h-3 w-3 mr-1" />
  1167. )}
  1168. 合并为Word
  1169. </Button>
  1170. </>
  1171. )}
  1172. </div>
  1173. </div>
  1174. {/* 下载全部选项 */}
  1175. <div className="grid grid-cols-2 gap-2">
  1176. <Button
  1177. onClick={downloadAllFiles}
  1178. variant="outline"
  1179. className="w-full"
  1180. >
  1181. <DownloadCloud className="h-4 w-4 mr-2" />
  1182. 逐个下载全部
  1183. </Button>
  1184. <Button
  1185. onClick={downloadAllAsZip}
  1186. disabled={mergeDownloading}
  1187. className="w-full bg-blue-600 hover:bg-blue-700"
  1188. >
  1189. {mergeDownloading ? (
  1190. <RefreshCw className="h-4 w-4 mr-2 animate-spin" />
  1191. ) : (
  1192. <Archive className="h-4 w-4 mr-2" />
  1193. )}
  1194. 打包下载全部
  1195. </Button>
  1196. </div>
  1197. {/* 下载进度显示 */}
  1198. {(isDownloading || mergeDownloading) && (
  1199. <div className="space-y-2">
  1200. <Progress
  1201. value={isDownloading ? downloadProgress : 100}
  1202. className="w-full"
  1203. />
  1204. <p className="text-sm text-muted-foreground text-center">
  1205. {isDownloading ? `正在下载文档... ${Math.round(downloadProgress)}%` : '正在打包文档...'}
  1206. </p>
  1207. </div>
  1208. )}
  1209. {/* 文档列表 */}
  1210. <div className="space-y-2 max-h-64 overflow-y-auto">
  1211. {processingResult.generatedFiles.map((file, index) => (
  1212. <div
  1213. key={index}
  1214. className={`flex items-center justify-between p-3 rounded-lg border ${
  1215. selectedFiles.has(index)
  1216. ? 'bg-blue-50 border-blue-200'
  1217. : 'bg-gray-50 border-gray-200'
  1218. }`}
  1219. >
  1220. <div className="flex items-center gap-3 flex-1 min-w-0">
  1221. <Button
  1222. variant="ghost"
  1223. size="icon"
  1224. className="h-6 w-6 shrink-0"
  1225. onClick={() => toggleFileSelection(index)}
  1226. >
  1227. {selectedFiles.has(index) ? (
  1228. <CheckSquare className="h-4 w-4 text-blue-600" />
  1229. ) : (
  1230. <Square className="h-4 w-4 text-gray-400" />
  1231. )}
  1232. </Button>
  1233. <div className="min-w-0 flex-1">
  1234. <p className="font-medium truncate">{file.name}</p>
  1235. <p className="text-sm text-muted-foreground">
  1236. 包含 {Object.keys(file.fields).length} 个字段
  1237. </p>
  1238. </div>
  1239. </div>
  1240. <div className="flex items-center gap-1 shrink-0">
  1241. <Button
  1242. variant="ghost"
  1243. size="sm"
  1244. onClick={() => downloadProcessedFile(file)}
  1245. className="h-7 w-7 p-0"
  1246. title="单独下载"
  1247. >
  1248. <Download className="h-3 w-3" />
  1249. </Button>
  1250. </div>
  1251. </div>
  1252. ))}
  1253. </div>
  1254. </div>
  1255. </CardContent>
  1256. </Card>
  1257. )}
  1258. {/* 图片映射预览 */}
  1259. {Object.keys(imageMappings).length > 0 && (
  1260. <Card>
  1261. <CardHeader>
  1262. <CardTitle className="flex items-center gap-2">
  1263. <Image className="h-5 w-5" />
  1264. 图片映射预览
  1265. </CardTitle>
  1266. <CardDescription>
  1267. 共 {getTotalImages()} 张图片,点击缩略图查看大图
  1268. </CardDescription>
  1269. </CardHeader>
  1270. <CardContent>
  1271. <div className="space-y-4">
  1272. {Object.entries(imagePreviewUrls).map(([folder, images]) => (
  1273. <div key={folder} className="border rounded-lg p-4">
  1274. <h4 className="font-semibold text-lg mb-3 flex items-center gap-2">
  1275. <Package className="h-4 w-4" />
  1276. 文件夹 {folder} ({Object.keys(images).length} 张图片)
  1277. </h4>
  1278. <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
  1279. {Object.entries(images).map(([imageName, previewUrl]) => (
  1280. <div
  1281. key={imageName}
  1282. className="group relative cursor-pointer"
  1283. onClick={() => openImagePreview(previewUrl, imageName, folder)}
  1284. >
  1285. <div className="aspect-square rounded-lg overflow-hidden border hover:border-blue-500 transition-colors">
  1286. <img
  1287. src={previewUrl}
  1288. alt={imageName}
  1289. className="w-full h-full object-cover"
  1290. />
  1291. </div>
  1292. <div className="mt-1">
  1293. <p className="text-xs text-center truncate text-gray-600 group-hover:text-blue-600">
  1294. {imageName}
  1295. </p>
  1296. </div>
  1297. <div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-opacity rounded-lg flex items-center justify-center">
  1298. <ZoomIn className="h-6 w-6 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
  1299. </div>
  1300. </div>
  1301. ))}
  1302. </div>
  1303. </div>
  1304. ))}
  1305. </div>
  1306. </CardContent>
  1307. </Card>
  1308. )}
  1309. {/* 数据预览 */}
  1310. {excelData.length > 0 && (
  1311. <Card>
  1312. <CardHeader>
  1313. <CardTitle className="flex items-center gap-2">
  1314. <FileSpreadsheet className="h-5 w-5" />
  1315. Excel数据预览
  1316. </CardTitle>
  1317. <CardDescription>
  1318. 显示前5行数据,共 {excelData.length} 行
  1319. </CardDescription>
  1320. </CardHeader>
  1321. <CardContent>
  1322. <div className="overflow-x-auto">
  1323. <table className="w-full text-sm">
  1324. <thead>
  1325. <tr className="border-b">
  1326. {Object.keys(excelData[0]).map(header => (
  1327. <th key={header} className="text-left p-2 font-medium">
  1328. {header}
  1329. </th>
  1330. ))}
  1331. </tr>
  1332. </thead>
  1333. <tbody>
  1334. {excelData.slice(0, 5).map((row, index) => (
  1335. <tr key={index} className="border-b">
  1336. {Object.values(row).map((value, valueIndex) => (
  1337. <td key={valueIndex} className="p-2">
  1338. {String(value)}
  1339. </td>
  1340. ))}
  1341. </tr>
  1342. ))}
  1343. </tbody>
  1344. </table>
  1345. {excelData.length > 5 && (
  1346. <p className="text-sm text-muted-foreground mt-2 text-center">
  1347. 还有 {excelData.length - 5} 行数据...
  1348. </p>
  1349. )}
  1350. </div>
  1351. </CardContent>
  1352. </Card>
  1353. )}
  1354. {/* 处理结果预览 */}
  1355. {processingResult && processingResult.generatedFiles.length > 0 && (
  1356. <Card>
  1357. <CardHeader>
  1358. <CardTitle className="flex items-center gap-2">
  1359. <FileText className="h-5 w-5" />
  1360. 处理结果预览
  1361. </CardTitle>
  1362. <CardDescription>
  1363. 预览第一个生成的文档
  1364. </CardDescription>
  1365. </CardHeader>
  1366. <CardContent>
  1367. <WordViewer file={processingResult.generatedFiles[0].content} />
  1368. </CardContent>
  1369. </Card>
  1370. )}
  1371. {/* 使用说明 */}
  1372. <Card>
  1373. <CardHeader>
  1374. <CardTitle>使用说明(增强版)</CardTitle>
  1375. </CardHeader>
  1376. <CardContent>
  1377. <div className="space-y-3 text-sm">
  1378. <div>
  1379. <h4 className="font-medium mb-1">1. 准备Word模板</h4>
  1380. <p className="text-muted-foreground">
  1381. 使用 {'{字段名}'} 格式作为文本占位符,使用 {'{%图片名}'} 格式作为图片占位符
  1382. </p>
  1383. </div>
  1384. <div>
  1385. <h4 className="font-medium mb-1">2. 设置图片尺寸(新增)</h4>
  1386. <p className="text-muted-foreground">
  1387. 使用"图片尺寸设置"按钮设置图片最大尺寸,系统将自动限制所有图片尺寸并保留长宽比例
  1388. </p>
  1389. </div>
  1390. <div>
  1391. <h4 className="font-medium mb-1">3. 准备Excel数据</h4>
  1392. <p className="text-muted-foreground">
  1393. Excel文件第一行为表头,列名应与Word模板中的字段名对应
  1394. </p>
  1395. </div>
  1396. <div>
  1397. <h4 className="font-medium mb-1">4. 准备图片压缩包</h4>
  1398. <p className="text-muted-foreground">
  1399. 压缩包结构:第一层为序号文件夹(1,2,3...对应Excel行),第二层为图片文件
  1400. </p>
  1401. </div>
  1402. <div>
  1403. <h4 className="font-medium mb-1">5. 图片命名规则</h4>
  1404. <p className="text-muted-foreground">
  1405. <strong>自动重命名模式:</strong>启用"自动重命名图片为A1,A2,A3..."选项,系统会自动按图片顺序重命名,在Word模板中使用 {'{%A1}'}, {'{%A2}'}, {'{%A3}'} 等占位符
  1406. </p>
  1407. <p className="text-muted-foreground mt-2">
  1408. <strong>手动命名模式:</strong>禁用自动重命名,图片文件名必须与模板中的图片占位符匹配(不含扩展名),例如:模板中使用 {'{%logo}'},则图片文件应命名为 logo.jpg/png等
  1409. </p>
  1410. </div>
  1411. </div>
  1412. </CardContent>
  1413. </Card>
  1414. {/* 注意事项 */}
  1415. <Card>
  1416. <CardHeader>
  1417. <CardTitle className="flex items-center gap-2">
  1418. <AlertCircle className="h-5 w-5" />
  1419. 注意事项
  1420. </CardTitle>
  1421. </CardHeader>
  1422. <CardContent>
  1423. <div className="space-y-2 text-sm">
  1424. <p>• Word模板中的字段名必须与Excel表头完全匹配</p>
  1425. <p>• 启用自动重命名时,图片将按顺序命名为A1,A2,A3...,模板中使用 {'{%A1}'}, {'{%A2}'}, {'{%A3}'} 等占位符</p>
  1426. <p>• 禁用自动重命名时,图片文件名必须与模板中的图片占位符匹配(不含扩展名)</p>
  1427. <p>• 文件夹序号必须与Excel行号对应(第1行对应文件夹"1")</p>
  1428. <p>• 如果图片不存在,对应位置将留空</p>
  1429. <p>• 支持jpg、jpeg、png、gif、bmp、webp格式图片</p>
  1430. <p>• 图片占位符使用 {'{%图片名}'} 格式,如 {'{%logo}'} 或 {'{%A1}'}</p>
  1431. </div>
  1432. </CardContent>
  1433. </Card>
  1434. {/* 图片查看器模态框 */}
  1435. <Dialog open={selectedImage !== null} onOpenChange={closeImagePreview}>
  1436. <DialogContent className="max-w-4xl max-h-[90vh] p-0">
  1437. <DialogHeader className="px-6 py-4 border-b">
  1438. <DialogTitle className="flex items-center justify-between">
  1439. <span>图片预览</span>
  1440. <div className="flex items-center gap-2 text-sm text-muted-foreground">
  1441. <span>{selectedImage?.folder} / {selectedImage?.name}</span>
  1442. <span>({currentImageIndex + 1} / {allImages.length})</span>
  1443. </div>
  1444. </DialogTitle>
  1445. </DialogHeader>
  1446. {selectedImage && (
  1447. <div className="relative">
  1448. <div className="flex items-center justify-center p-4">
  1449. <img
  1450. src={selectedImage.url}
  1451. alt={selectedImage.name}
  1452. className="max-w-full max-h-[70vh] object-contain"
  1453. />
  1454. </div>
  1455. {/* 导航按钮 */}
  1456. {allImages.length > 1 && (
  1457. <>
  1458. <Button
  1459. variant="ghost"
  1460. size="icon"
  1461. className="absolute left-2 top-1/2 -translate-y-1/2"
  1462. onClick={() => navigateImage('prev')}
  1463. >
  1464. <ChevronLeft className="h-5 w-5" />
  1465. </Button>
  1466. <Button
  1467. variant="ghost"
  1468. size="icon"
  1469. className="absolute right-2 top-1/2 -translate-y-1/2"
  1470. onClick={() => navigateImage('next')}
  1471. >
  1472. <ChevronRight className="h-5 w-5" />
  1473. </Button>
  1474. </>
  1475. )}
  1476. {/* 底部信息 */}
  1477. <div className="px-6 py-3 border-t bg-gray-50">
  1478. <div className="flex items-center justify-between text-sm">
  1479. <div>
  1480. <span className="font-medium">文件名:</span> {selectedImage.name}
  1481. </div>
  1482. <Button
  1483. variant="ghost"
  1484. size="sm"
  1485. onClick={() => {
  1486. const a = document.createElement('a');
  1487. a.href = selectedImage.url;
  1488. a.download = selectedImage.name;
  1489. a.click();
  1490. }}
  1491. >
  1492. <Download className="h-4 w-4 mr-2" />
  1493. 下载图片
  1494. </Button>
  1495. </div>
  1496. </div>
  1497. </div>
  1498. )}
  1499. </DialogContent>
  1500. </Dialog>
  1501. </div>
  1502. );
  1503. }