WordPreview.tsx 53 KB

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