WordPreview.tsx 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981
  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
  16. } from 'lucide-react';
  17. import { toast } from 'sonner';
  18. import WordViewer from '@/client/admin-shadcn/components/WordViewer';
  19. import { Badge } from '@/client/components/ui/badge';
  20. import { Progress } from '@/client/components/ui/progress';
  21. import {
  22. Dialog,
  23. DialogContent,
  24. DialogHeader,
  25. DialogTitle,
  26. } from '@/client/components/ui/dialog';
  27. interface WordFile {
  28. id: string;
  29. name: string;
  30. size: number;
  31. url: string;
  32. previewUrl?: string;
  33. }
  34. interface ExcelRow {
  35. [key: string]: string | number;
  36. }
  37. interface ImageMapping {
  38. [key: string]: {
  39. [imageName: string]: File;
  40. };
  41. }
  42. interface ProcessingResult {
  43. originalFile: File;
  44. generatedFiles: Array<{
  45. name: string;
  46. content: Blob;
  47. fields: Record<string, string>;
  48. }>;
  49. total: number;
  50. }
  51. export default function WordPreview() {
  52. const [selectedWordFile, setSelectedWordFile] = useState<File | null>(null);
  53. const [selectedExcelFile, setSelectedExcelFile] = useState<File | null>(null);
  54. const [imageZipFile, setImageZipFile] = useState<File | null>(null);
  55. const [previewFile, setPreviewFile] = useState<WordFile | null>(null);
  56. const [isLoading, setIsLoading] = useState(false);
  57. const [previewLoading, setPreviewLoading] = useState(false);
  58. const [showPreview, setShowPreview] = useState(false);
  59. const [processingResult, setProcessingResult] = useState<ProcessingResult | null>(null);
  60. const [excelData, setExcelData] = useState<ExcelRow[]>([]);
  61. const [processingProgress, setProcessingProgress] = useState(0);
  62. const [imageMappings, setImageMappings] = useState<ImageMapping>({});
  63. const [imagePreviewUrls, setImagePreviewUrls] = useState<Record<string, Record<string, string>>>({});
  64. const [selectedImage, setSelectedImage] = useState<{
  65. url: string;
  66. name: string;
  67. folder: string;
  68. } | null>(null);
  69. const [currentImageIndex, setCurrentImageIndex] = useState(0);
  70. const [allImages, setAllImages] = useState<Array<{ url: string; name: string; folder: string }>>([]);
  71. const wordFileInputRef = useRef<HTMLInputElement>(null);
  72. const excelFileInputRef = useRef<HTMLInputElement>(null);
  73. const imageZipInputRef = useRef<HTMLInputElement>(null);
  74. // 文件选择处理
  75. const handleWordFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
  76. const file = event.target.files?.[0];
  77. if (file) {
  78. const validTypes = [
  79. 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
  80. ];
  81. const maxSize = 10 * 1024 * 1024;
  82. if (!validTypes.includes(file.type)) {
  83. toast.error('请选择有效的Word文件(.docx格式)');
  84. return;
  85. }
  86. if (file.size > maxSize) {
  87. toast.error('文件大小超过10MB限制');
  88. return;
  89. }
  90. setSelectedWordFile(file);
  91. setShowPreview(false);
  92. toast.success('Word模板已选择');
  93. }
  94. };
  95. const handleExcelFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
  96. const file = event.target.files?.[0];
  97. if (file) {
  98. const validTypes = [
  99. 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  100. 'application/vnd.ms-excel'
  101. ];
  102. const maxSize = 10 * 1024 * 1024;
  103. if (!validTypes.includes(file.type)) {
  104. toast.error('请选择有效的Excel文件(.xlsx/.xls格式)');
  105. return;
  106. }
  107. if (file.size > maxSize) {
  108. toast.error('文件大小超过10MB限制');
  109. return;
  110. }
  111. setSelectedExcelFile(file);
  112. parseExcelFile(file);
  113. toast.success('Excel数据文件已选择');
  114. }
  115. };
  116. const handleImageZipSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
  117. const file = event.target.files?.[0];
  118. if (file) {
  119. const validTypes = ['application/zip', 'application/x-zip-compressed'];
  120. const maxSize = 50 * 1024 * 1024;
  121. if (!validTypes.includes(file.type)) {
  122. toast.error('请选择有效的ZIP压缩文件');
  123. return;
  124. }
  125. if (file.size > maxSize) {
  126. toast.error('压缩文件大小超过50MB限制');
  127. return;
  128. }
  129. setImageZipFile(file);
  130. await parseImageZip(file);
  131. toast.success('图片压缩文件已选择');
  132. }
  133. };
  134. // 解析Excel文件
  135. const parseExcelFile = async (file: File) => {
  136. try {
  137. const data = await file.arrayBuffer();
  138. const workbook = XLSX.read(data, { type: 'array' });
  139. const firstSheetName = workbook.SheetNames[0];
  140. const worksheet = workbook.Sheets[firstSheetName];
  141. const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][];
  142. if (jsonData.length < 2) {
  143. toast.error('Excel文件格式不正确,需要包含表头和至少一行数据');
  144. return;
  145. }
  146. const headers = jsonData[0] as string[];
  147. const rows: ExcelRow[] = [];
  148. for (let i = 1; i < jsonData.length; i++) {
  149. const row = jsonData[i];
  150. const rowData: ExcelRow = {};
  151. headers.forEach((header, index) => {
  152. rowData[header] = row[index] || '';
  153. });
  154. rows.push(rowData);
  155. }
  156. setExcelData(rows);
  157. toast.success(`成功解析 ${rows.length} 条数据记录`);
  158. } catch (error) {
  159. toast.error('Excel文件解析失败');
  160. console.error('Excel parsing error:', error);
  161. }
  162. };
  163. // 解析图片压缩文件
  164. const parseImageZip = async (file: File) => {
  165. try {
  166. const zip = new JSZip();
  167. const zipContent = await zip.loadAsync(file);
  168. const newImageMappings: ImageMapping = {};
  169. const newImagePreviewUrls: Record<string, Record<string, string>> = {};
  170. const allImages: Array<{ url: string; name: string; folder: string }> = [];
  171. // 解析文件夹结构:第一层为序号,第二层为图片
  172. for (const [path, zipEntry] of Object.entries(zipContent.files)) {
  173. if (!zipEntry.dir && isImageFile(path)) {
  174. const pathParts = path.split('/');
  175. if (pathParts.length >= 2) {
  176. const folderIndex = pathParts[0]; // 序号文件夹
  177. const imageName = pathParts[pathParts.length - 1].split('.')[0]; // 去掉扩展名的文件名
  178. const fullImageName = pathParts[pathParts.length - 1]; // 完整文件名
  179. if (!newImageMappings[folderIndex]) {
  180. newImageMappings[folderIndex] = {};
  181. newImagePreviewUrls[folderIndex] = {};
  182. }
  183. const imageFile = await zipEntry.async('blob');
  184. newImageMappings[folderIndex][imageName] = new File([imageFile], imageName, {
  185. type: getImageMimeType(path)
  186. });
  187. // 创建预览URL
  188. const previewUrl = URL.createObjectURL(imageFile);
  189. newImagePreviewUrls[folderIndex][imageName] = previewUrl;
  190. allImages.push({
  191. url: previewUrl,
  192. name: fullImageName,
  193. folder: folderIndex
  194. });
  195. }
  196. }
  197. }
  198. setImageMappings(newImageMappings);
  199. setImagePreviewUrls(newImagePreviewUrls);
  200. setAllImages(allImages);
  201. toast.success(`成功解析 ${Object.keys(newImageMappings).length} 个文件夹的图片`);
  202. } catch (error) {
  203. toast.error('图片压缩文件解析失败');
  204. console.error('Image zip parsing error:', error);
  205. }
  206. };
  207. // 工具函数
  208. const isImageFile = (filename: string): boolean => {
  209. const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'];
  210. return imageExtensions.some(ext => filename.toLowerCase().endsWith(ext));
  211. };
  212. const getImageMimeType = (filename: string): string => {
  213. const extension = filename.toLowerCase().split('.').pop();
  214. const mimeTypes: Record<string, string> = {
  215. 'jpg': 'image/jpeg',
  216. 'jpeg': 'image/jpeg',
  217. 'png': 'image/png',
  218. 'gif': 'image/gif',
  219. 'bmp': 'image/bmp',
  220. 'webp': 'image/webp'
  221. };
  222. return mimeTypes[extension || ''] || 'image/jpeg';
  223. };
  224. // 替换Word字段并插入图片
  225. const replaceFieldsInWord = async (wordFile: File, excelRow: ExcelRow, rowIndex: number): Promise<Blob> => {
  226. try {
  227. const arrayBuffer = await wordFile.arrayBuffer();
  228. const zip = new PizZip(arrayBuffer);
  229. // 配置图片模块
  230. const imageOpts = {
  231. centered: false,
  232. getImage: (tagValue: string) => {
  233. console.log('tagValue', tagValue);
  234. if (tagValue && typeof tagValue === 'string') {
  235. // 从imageMappings中获取对应的图片
  236. const folderIndex = (rowIndex + 1).toString();
  237. const imageFile = imageMappings[folderIndex]?.[tagValue];
  238. if (imageFile) {
  239. return imageFile.arrayBuffer();
  240. }
  241. }
  242. return null;
  243. },
  244. getSize: () => [200, 150] // 固定尺寸
  245. };
  246. const imageModule = new ImageModule(imageOpts);
  247. const doc = new Docxtemplater(zip, {
  248. paragraphLoop: true,
  249. linebreaks: true,
  250. modules: [imageModule]
  251. })
  252. // 处理嵌套数据结构
  253. const processedData: Record<string, any> = {};
  254. // 处理普通字段
  255. Object.entries(excelRow).forEach(([key, value]) => {
  256. if (key.includes('.')) {
  257. const parts = key.split('.');
  258. let current = processedData;
  259. for (let i = 0; i < parts.length - 1; i++) {
  260. if (!current[parts[i]]) {
  261. current[parts[i]] = {};
  262. }
  263. current = current[parts[i]];
  264. }
  265. current[parts[parts.length - 1]] = value;
  266. } else {
  267. processedData[key] = value;
  268. }
  269. });
  270. // 处理图片字段 - 直接传递图片名称
  271. const folderIndex = (rowIndex + 1).toString();
  272. if (imageMappings[folderIndex]) {
  273. for (const [imageName] of Object.entries(imageMappings[folderIndex])) {
  274. processedData[imageName] = imageName;
  275. }
  276. }
  277. doc.render(processedData);
  278. const generatedDoc = doc.getZip().generate({
  279. type: 'blob',
  280. mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  281. });
  282. return generatedDoc;
  283. } catch (error) {
  284. console.error('Word处理错误:', error);
  285. throw new Error('Word文档处理失败,请检查模板格式');
  286. }
  287. };
  288. // 处理文件
  289. const processFiles = async () => {
  290. if (!selectedWordFile || !selectedExcelFile || excelData.length === 0) {
  291. toast.error('请先选择Word模板和Excel数据文件');
  292. return;
  293. }
  294. setIsLoading(true);
  295. setProcessingProgress(0);
  296. try {
  297. const generatedFiles: ProcessingResult['generatedFiles'] = [];
  298. for (let i = 0; i < excelData.length; i++) {
  299. const row = excelData[i];
  300. const processedBlob = await replaceFieldsInWord(selectedWordFile, row, i);
  301. const fileName = `processed_${i + 1}_${selectedWordFile.name}`;
  302. generatedFiles.push({
  303. name: fileName,
  304. content: processedBlob,
  305. fields: row
  306. });
  307. setProcessingProgress(((i + 1) / excelData.length) * 100);
  308. }
  309. const result: ProcessingResult = {
  310. originalFile: selectedWordFile,
  311. generatedFiles,
  312. total: generatedFiles.length
  313. };
  314. setProcessingResult(result);
  315. toast.success(`成功生成 ${generatedFiles.length} 个文档`);
  316. } catch (error) {
  317. toast.error('文档处理失败');
  318. console.error('Processing error:', error);
  319. } finally {
  320. setIsLoading(false);
  321. setProcessingProgress(0);
  322. }
  323. };
  324. // 预览功能
  325. const handlePreview = async () => {
  326. if (!selectedWordFile) {
  327. toast.error('请先选择Word文件');
  328. return;
  329. }
  330. setPreviewLoading(true);
  331. setShowPreview(true);
  332. try {
  333. const fileUrl = URL.createObjectURL(selectedWordFile);
  334. const wordFile: WordFile = {
  335. id: Date.now().toString(),
  336. name: selectedWordFile.name,
  337. size: selectedWordFile.size,
  338. url: fileUrl,
  339. previewUrl: fileUrl
  340. };
  341. setPreviewFile(wordFile);
  342. toast.success('正在预览Word模板...');
  343. } catch (error) {
  344. toast.error('文件预览失败');
  345. console.error('Preview error:', error);
  346. setShowPreview(false);
  347. } finally {
  348. setPreviewLoading(false);
  349. }
  350. };
  351. // 下载功能
  352. const downloadProcessedFile = (file: ProcessingResult['generatedFiles'][0]) => {
  353. const url = URL.createObjectURL(file.content);
  354. const a = document.createElement('a');
  355. a.href = url;
  356. a.download = file.name;
  357. document.body.appendChild(a);
  358. a.click();
  359. document.body.removeChild(a);
  360. URL.revokeObjectURL(url);
  361. };
  362. const downloadAllFiles = () => {
  363. if (!processingResult) return;
  364. processingResult.generatedFiles.forEach((file, index) => {
  365. setTimeout(() => {
  366. downloadProcessedFile(file);
  367. }, index * 500);
  368. });
  369. };
  370. const clearAllFiles = () => {
  371. setSelectedWordFile(null);
  372. setSelectedExcelFile(null);
  373. setImageZipFile(null);
  374. setExcelData([]);
  375. setProcessingResult(null);
  376. setImageMappings({});
  377. setImagePreviewUrls({});
  378. setSelectedImage(null);
  379. setAllImages([]);
  380. setPreviewFile(null);
  381. setShowPreview(false);
  382. if (wordFileInputRef.current) wordFileInputRef.current.value = '';
  383. if (excelFileInputRef.current) excelFileInputRef.current.value = '';
  384. if (imageZipInputRef.current) imageZipInputRef.current.value = '';
  385. toast.success('已清除所有文件');
  386. };
  387. const formatFileSize = (bytes: number) => {
  388. if (bytes === 0) return '0 Bytes';
  389. const k = 1024;
  390. const sizes = ['Bytes', 'KB', 'MB', 'GB'];
  391. const i = Math.floor(Math.log(bytes) / Math.log(k));
  392. return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  393. };
  394. // 图片预览功能
  395. const openImagePreview = (url: string, name: string, folder: string) => {
  396. const imageIndex = allImages.findIndex(img => img.url === url);
  397. setCurrentImageIndex(imageIndex !== -1 ? imageIndex : 0);
  398. setSelectedImage({ url, name, folder });
  399. };
  400. const closeImagePreview = () => {
  401. setSelectedImage(null);
  402. };
  403. const navigateImage = (direction: 'prev' | 'next') => {
  404. if (allImages.length === 0) return;
  405. let newIndex = currentImageIndex;
  406. if (direction === 'prev') {
  407. newIndex = currentImageIndex > 0 ? currentImageIndex - 1 : allImages.length - 1;
  408. } else {
  409. newIndex = currentImageIndex < allImages.length - 1 ? currentImageIndex + 1 : 0;
  410. }
  411. setCurrentImageIndex(newIndex);
  412. setSelectedImage(allImages[newIndex]);
  413. };
  414. const getTotalImages = () => {
  415. return Object.values(imagePreviewUrls).reduce((total, folder) => total + Object.keys(folder).length, 0);
  416. };
  417. return (
  418. <div className="space-y-6">
  419. <div>
  420. <h1 className="text-3xl font-bold tracking-tight">Word批量处理工具(增强版)</h1>
  421. <p className="text-muted-foreground">支持图片压缩文件,自动生成替换字段和图片的文档</p>
  422. </div>
  423. {/* 文件上传区域 */}
  424. <div className="grid gap-6 md:grid-cols-3">
  425. {/* Word模板上传 */}
  426. <Card>
  427. <CardHeader>
  428. <CardTitle className="flex items-center gap-2">
  429. <FileText className="h-5 w-5" />
  430. 选择Word模板
  431. </CardTitle>
  432. <CardDescription>
  433. 支持 .docx 格式的Word文档,最大10MB
  434. </CardDescription>
  435. </CardHeader>
  436. <CardContent className="space-y-4">
  437. <div className="grid w-full items-center gap-1.5">
  438. <Label htmlFor="word-file">Word模板文件</Label>
  439. <Input
  440. ref={wordFileInputRef}
  441. id="word-file"
  442. type="file"
  443. accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
  444. onChange={handleWordFileSelect}
  445. />
  446. </div>
  447. {selectedWordFile && (
  448. <Alert>
  449. <FileText className="h-4 w-4" />
  450. <AlertDescription>
  451. <div className="space-y-1">
  452. <p><strong>文件名:</strong> {selectedWordFile.name}</p>
  453. <p><strong>大小:</strong> {formatFileSize(selectedWordFile.size)}</p>
  454. </div>
  455. </AlertDescription>
  456. </Alert>
  457. )}
  458. </CardContent>
  459. </Card>
  460. {/* Excel数据上传 */}
  461. <Card>
  462. <CardHeader>
  463. <CardTitle className="flex items-center gap-2">
  464. <FileSpreadsheet className="h-5 w-5" />
  465. 选择Excel数据
  466. </CardTitle>
  467. <CardDescription>
  468. 支持 .xlsx/.xls 格式的Excel文档,最大10MB
  469. </CardDescription>
  470. </CardHeader>
  471. <CardContent className="space-y-4">
  472. <div className="grid w-full items-center gap-1.5">
  473. <Label htmlFor="excel-file">Excel数据文件</Label>
  474. <Input
  475. ref={excelFileInputRef}
  476. id="excel-file"
  477. type="file"
  478. accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
  479. onChange={handleExcelFileSelect}
  480. />
  481. </div>
  482. {selectedExcelFile && (
  483. <Alert>
  484. <FileSpreadsheet className="h-4 w-4" />
  485. <AlertDescription>
  486. <div className="space-y-1">
  487. <p><strong>文件名:</strong> {selectedExcelFile.name}</p>
  488. <p><strong>大小:</strong> {formatFileSize(selectedExcelFile.size)}</p>
  489. {excelData.length > 0 && (
  490. <p><strong>数据行数:</strong> {excelData.length}</p>
  491. )}
  492. </div>
  493. </AlertDescription>
  494. </Alert>
  495. )}
  496. </CardContent>
  497. </Card>
  498. {/* 图片压缩文件上传 */}
  499. <Card>
  500. <CardHeader>
  501. <CardTitle className="flex items-center gap-2">
  502. <Package className="h-5 w-5" />
  503. 选择图片压缩包
  504. </CardTitle>
  505. <CardDescription>
  506. 支持 .zip 格式压缩包,最大50MB
  507. </CardDescription>
  508. </CardHeader>
  509. <CardContent className="space-y-4">
  510. <div className="grid w-full items-center gap-1.5">
  511. <Label htmlFor="image-zip">图片压缩文件</Label>
  512. <Input
  513. ref={imageZipInputRef}
  514. id="image-zip"
  515. type="file"
  516. accept=".zip,application/zip,application/x-zip-compressed"
  517. onChange={handleImageZipSelect}
  518. />
  519. </div>
  520. {imageZipFile && (
  521. <Alert>
  522. <Image className="h-4 w-4" />
  523. <AlertDescription>
  524. <div className="space-y-1">
  525. <p><strong>文件名:</strong> {imageZipFile.name}</p>
  526. <p><strong>大小:</strong> {formatFileSize(imageZipFile.size)}</p>
  527. {Object.keys(imageMappings).length > 0 && (
  528. <p><strong>文件夹数:</strong> {Object.keys(imageMappings).length}</p>
  529. )}
  530. </div>
  531. </AlertDescription>
  532. </Alert>
  533. )}
  534. </CardContent>
  535. </Card>
  536. </div>
  537. {/* 操作按钮 */}
  538. <Card>
  539. <CardHeader>
  540. <CardTitle>操作区域</CardTitle>
  541. <CardDescription>选择文件后执行相应操作</CardDescription>
  542. </CardHeader>
  543. <CardContent className="space-y-4">
  544. <div className="flex gap-2 flex-wrap">
  545. <Button
  546. onClick={handlePreview}
  547. disabled={!selectedWordFile || previewLoading}
  548. variant="outline"
  549. >
  550. <Eye className="h-4 w-4 mr-2" />
  551. 预览模板
  552. </Button>
  553. <Button
  554. onClick={processFiles}
  555. disabled={!selectedWordFile || !selectedExcelFile || excelData.length === 0 || isLoading}
  556. className="bg-blue-600 hover:bg-blue-700"
  557. >
  558. {isLoading ? (
  559. <>
  560. <RefreshCw className="h-4 w-4 mr-2 animate-spin" />
  561. 处理中...
  562. </>
  563. ) : (
  564. <>
  565. <Upload className="h-4 w-4 mr-2" />
  566. 开始处理
  567. </>
  568. )}
  569. </Button>
  570. <Button
  571. onClick={clearAllFiles}
  572. variant="outline"
  573. className="text-red-600 hover:text-red-700"
  574. >
  575. 清除所有
  576. </Button>
  577. </div>
  578. {isLoading && (
  579. <div className="space-y-2">
  580. <Progress value={processingProgress} className="w-full" />
  581. <p className="text-sm text-muted-foreground text-center">
  582. 正在处理文档... {Math.round(processingProgress)}%
  583. </p>
  584. </div>
  585. )}
  586. </CardContent>
  587. </Card>
  588. {/* 预览区域 */}
  589. {showPreview && selectedWordFile && (
  590. <Card>
  591. <CardHeader>
  592. <CardTitle className="flex items-center gap-2">
  593. <FileText className="h-5 w-5" />
  594. 文档预览
  595. </CardTitle>
  596. <CardDescription>
  597. {selectedWordFile.name}
  598. </CardDescription>
  599. </CardHeader>
  600. <CardContent>
  601. <WordViewer file={selectedWordFile} />
  602. </CardContent>
  603. </Card>
  604. )}
  605. {/* 图片映射预览 */}
  606. {Object.keys(imageMappings).length > 0 && (
  607. <Card>
  608. <CardHeader>
  609. <CardTitle className="flex items-center gap-2">
  610. <Image className="h-5 w-5" />
  611. 图片映射预览
  612. </CardTitle>
  613. <CardDescription>
  614. 共 {getTotalImages()} 张图片,点击缩略图查看大图
  615. </CardDescription>
  616. </CardHeader>
  617. <CardContent>
  618. <div className="space-y-4">
  619. {Object.entries(imagePreviewUrls).map(([folder, images]) => (
  620. <div key={folder} className="border rounded-lg p-4">
  621. <h4 className="font-semibold text-lg mb-3 flex items-center gap-2">
  622. <Package className="h-4 w-4" />
  623. 文件夹 {folder} ({Object.keys(images).length} 张图片)
  624. </h4>
  625. <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
  626. {Object.entries(images).map(([imageName, previewUrl]) => (
  627. <div
  628. key={imageName}
  629. className="group relative cursor-pointer"
  630. onClick={() => openImagePreview(previewUrl, imageName, folder)}
  631. >
  632. <div className="aspect-square rounded-lg overflow-hidden border hover:border-blue-500 transition-colors">
  633. <img
  634. src={previewUrl}
  635. alt={imageName}
  636. className="w-full h-full object-cover"
  637. />
  638. </div>
  639. <div className="mt-1">
  640. <p className="text-xs text-center truncate text-gray-600 group-hover:text-blue-600">
  641. {imageName}
  642. </p>
  643. </div>
  644. <div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-opacity rounded-lg flex items-center justify-center">
  645. <ZoomIn className="h-6 w-6 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
  646. </div>
  647. </div>
  648. ))}
  649. </div>
  650. </div>
  651. ))}
  652. </div>
  653. </CardContent>
  654. </Card>
  655. )}
  656. {/* 数据预览 */}
  657. {excelData.length > 0 && (
  658. <Card>
  659. <CardHeader>
  660. <CardTitle className="flex items-center gap-2">
  661. <FileSpreadsheet className="h-5 w-5" />
  662. Excel数据预览
  663. </CardTitle>
  664. <CardDescription>
  665. 显示前5行数据,共 {excelData.length} 行
  666. </CardDescription>
  667. </CardHeader>
  668. <CardContent>
  669. <div className="overflow-x-auto">
  670. <table className="w-full text-sm">
  671. <thead>
  672. <tr className="border-b">
  673. {Object.keys(excelData[0]).map(header => (
  674. <th key={header} className="text-left p-2 font-medium">
  675. {header}
  676. </th>
  677. ))}
  678. </tr>
  679. </thead>
  680. <tbody>
  681. {excelData.slice(0, 5).map((row, index) => (
  682. <tr key={index} className="border-b">
  683. {Object.values(row).map((value, valueIndex) => (
  684. <td key={valueIndex} className="p-2">
  685. {String(value)}
  686. </td>
  687. ))}
  688. </tr>
  689. ))}
  690. </tbody>
  691. </table>
  692. {excelData.length > 5 && (
  693. <p className="text-sm text-muted-foreground mt-2 text-center">
  694. 还有 {excelData.length - 5} 行数据...
  695. </p>
  696. )}
  697. </div>
  698. </CardContent>
  699. </Card>
  700. )}
  701. {/* 处理结果预览 */}
  702. {processingResult && processingResult.generatedFiles.length > 0 && (
  703. <Card>
  704. <CardHeader>
  705. <CardTitle className="flex items-center gap-2">
  706. <FileText className="h-5 w-5" />
  707. 处理结果预览
  708. </CardTitle>
  709. <CardDescription>
  710. 预览第一个生成的文档
  711. </CardDescription>
  712. </CardHeader>
  713. <CardContent>
  714. <WordViewer file={processingResult.generatedFiles[0].content} />
  715. </CardContent>
  716. </Card>
  717. )}
  718. {/* 处理结果 */}
  719. {processingResult && (
  720. <Card>
  721. <CardHeader>
  722. <CardTitle className="flex items-center gap-2">
  723. <CheckCircle className="h-5 w-5 text-green-500" />
  724. 处理完成
  725. </CardTitle>
  726. <CardDescription>
  727. 共生成 {processingResult.total} 个文档
  728. </CardDescription>
  729. </CardHeader>
  730. <CardContent>
  731. <div className="space-y-4">
  732. <Button
  733. onClick={downloadAllFiles}
  734. className="w-full"
  735. >
  736. <DownloadCloud className="h-4 w-4 mr-2" />
  737. 下载全部文档
  738. </Button>
  739. <div className="space-y-2 max-h-64 overflow-y-auto">
  740. {processingResult.generatedFiles.map((file, index) => (
  741. <div
  742. key={index}
  743. className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
  744. >
  745. <div>
  746. <p className="font-medium">{file.name}</p>
  747. <p className="text-sm text-muted-foreground">
  748. 包含 {Object.keys(file.fields).length} 个字段
  749. </p>
  750. </div>
  751. <Button
  752. variant="ghost"
  753. size="sm"
  754. onClick={() => downloadProcessedFile(file)}
  755. >
  756. <Download className="h-4 w-4" />
  757. </Button>
  758. </div>
  759. ))}
  760. </div>
  761. </div>
  762. </CardContent>
  763. </Card>
  764. )}
  765. {/* 使用说明 */}
  766. <Card>
  767. <CardHeader>
  768. <CardTitle>使用说明(增强版)</CardTitle>
  769. </CardHeader>
  770. <CardContent>
  771. <div className="space-y-3 text-sm">
  772. <div>
  773. <h4 className="font-medium mb-1">1. 准备Word模板</h4>
  774. <p className="text-muted-foreground">
  775. 使用 {'{字段名}'} 格式作为文本占位符,使用 {'{%image:图片名%}'} 格式作为图片占位符
  776. </p>
  777. </div>
  778. <div>
  779. <h4 className="font-medium mb-1">2. 准备Excel数据</h4>
  780. <p className="text-muted-foreground">
  781. Excel文件第一行为表头,列名应与Word模板中的字段名对应
  782. </p>
  783. </div>
  784. <div>
  785. <h4 className="font-medium mb-1">3. 准备图片压缩包</h4>
  786. <p className="text-muted-foreground">
  787. 压缩包结构:第一层为序号文件夹(1,2,3...对应Excel行),第二层为图片文件(文件名对应模板中的图片名)
  788. </p>
  789. </div>
  790. <div>
  791. <h4 className="font-medium mb-1">4. 图片命名规则</h4>
  792. <p className="text-muted-foreground">
  793. 例如:模板中使用 {'{%logo%}'},则图片文件应命名为 logo.jpg/png等
  794. </p>
  795. </div>
  796. </div>
  797. </CardContent>
  798. </Card>
  799. {/* 注意事项 */}
  800. <Card>
  801. <CardHeader>
  802. <CardTitle className="flex items-center gap-2">
  803. <AlertCircle className="h-5 w-5" />
  804. 注意事项
  805. </CardTitle>
  806. </CardHeader>
  807. <CardContent>
  808. <div className="space-y-2 text-sm">
  809. <p>• Word模板中的字段名必须与Excel表头完全匹配</p>
  810. <p>• 图片文件名必须与模板中的图片占位符匹配(不含扩展名)</p>
  811. <p>• 文件夹序号必须与Excel行号对应(第1行对应文件夹"1")</p>
  812. <p>• 如果图片不存在,对应位置将留空</p>
  813. <p>• 支持jpg、jpeg、png、gif、bmp、webp格式图片</p>
  814. <p>• 图片占位符使用 {'{%图片名%}'} 格式,如 {'{%logo%}'}</p>
  815. </div>
  816. </CardContent>
  817. </Card>
  818. {/* 图片查看器模态框 */}
  819. <Dialog open={selectedImage !== null} onOpenChange={closeImagePreview}>
  820. <DialogContent className="max-w-4xl max-h-[90vh] p-0">
  821. <DialogHeader className="px-6 py-4 border-b">
  822. <DialogTitle className="flex items-center justify-between">
  823. <span>图片预览</span>
  824. <div className="flex items-center gap-2 text-sm text-muted-foreground">
  825. <span>{selectedImage?.folder} / {selectedImage?.name}</span>
  826. <span>({currentImageIndex + 1} / {allImages.length})</span>
  827. </div>
  828. </DialogTitle>
  829. </DialogHeader>
  830. {selectedImage && (
  831. <div className="relative">
  832. <div className="flex items-center justify-center p-4">
  833. <img
  834. src={selectedImage.url}
  835. alt={selectedImage.name}
  836. className="max-w-full max-h-[70vh] object-contain"
  837. />
  838. </div>
  839. {/* 导航按钮 */}
  840. {allImages.length > 1 && (
  841. <>
  842. <Button
  843. variant="ghost"
  844. size="icon"
  845. className="absolute left-2 top-1/2 -translate-y-1/2"
  846. onClick={() => navigateImage('prev')}
  847. >
  848. <ChevronLeft className="h-5 w-5" />
  849. </Button>
  850. <Button
  851. variant="ghost"
  852. size="icon"
  853. className="absolute right-2 top-1/2 -translate-y-1/2"
  854. onClick={() => navigateImage('next')}
  855. >
  856. <ChevronRight className="h-5 w-5" />
  857. </Button>
  858. </>
  859. )}
  860. {/* 底部信息 */}
  861. <div className="px-6 py-3 border-t bg-gray-50">
  862. <div className="flex items-center justify-between text-sm">
  863. <div>
  864. <span className="font-medium">文件名:</span> {selectedImage.name}
  865. </div>
  866. <Button
  867. variant="ghost"
  868. size="sm"
  869. onClick={() => {
  870. const a = document.createElement('a');
  871. a.href = selectedImage.url;
  872. a.download = selectedImage.name;
  873. a.click();
  874. }}
  875. >
  876. <Download className="h-4 w-4 mr-2" />
  877. 下载图片
  878. </Button>
  879. </div>
  880. </div>
  881. </div>
  882. )}
  883. </DialogContent>
  884. </Dialog>
  885. </div>
  886. );
  887. }