|
|
@@ -1,13 +1,21 @@
|
|
|
-import { useState } from 'react';
|
|
|
+import { useState, useRef } from 'react';
|
|
|
+import * as XLSX from 'xlsx';
|
|
|
+import PizZip from 'pizzip';
|
|
|
+import Docxtemplater from 'docxtemplater';
|
|
|
+import JSZip from 'jszip';
|
|
|
import { Button } from '@/client/components/ui/button';
|
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
|
|
|
import { Input } from '@/client/components/ui/input';
|
|
|
import { Label } from '@/client/components/ui/label';
|
|
|
import { Alert, AlertDescription } from '@/client/components/ui/alert';
|
|
|
-import { FileText, Upload, Download, Eye, FileWarning } from 'lucide-react';
|
|
|
+import {
|
|
|
+ FileText, Upload, Download, Eye, FileWarning, FileSpreadsheet,
|
|
|
+ RefreshCw, CheckCircle, AlertCircle, DownloadCloud, Image, Package
|
|
|
+} from 'lucide-react';
|
|
|
import { toast } from 'sonner';
|
|
|
import WordViewer from '@/client/admin-shadcn/components/WordViewer';
|
|
|
-import { AlertCircle } from 'lucide-react';
|
|
|
+import { Badge } from '@/client/components/ui/badge';
|
|
|
+import { Progress } from '@/client/components/ui/progress';
|
|
|
|
|
|
interface WordFile {
|
|
|
id: string;
|
|
|
@@ -17,44 +25,309 @@ interface WordFile {
|
|
|
previewUrl?: string;
|
|
|
}
|
|
|
|
|
|
+interface ExcelRow {
|
|
|
+ [key: string]: string | number;
|
|
|
+}
|
|
|
+
|
|
|
+interface ImageMapping {
|
|
|
+ [key: string]: {
|
|
|
+ [imageName: string]: File;
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+interface ProcessingResult {
|
|
|
+ originalFile: File;
|
|
|
+ generatedFiles: Array<{
|
|
|
+ name: string;
|
|
|
+ content: Blob;
|
|
|
+ fields: Record<string, string>;
|
|
|
+ }>;
|
|
|
+ total: number;
|
|
|
+}
|
|
|
+
|
|
|
export default function WordPreview() {
|
|
|
- const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
|
+ const [selectedWordFile, setSelectedWordFile] = useState<File | null>(null);
|
|
|
+ const [selectedExcelFile, setSelectedExcelFile] = useState<File | null>(null);
|
|
|
+ const [imageZipFile, setImageZipFile] = useState<File | null>(null);
|
|
|
const [previewFile, setPreviewFile] = useState<WordFile | null>(null);
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
const [previewLoading, setPreviewLoading] = useState(false);
|
|
|
const [showPreview, setShowPreview] = useState(false);
|
|
|
+ const [processingResult, setProcessingResult] = useState<ProcessingResult | null>(null);
|
|
|
+ const [excelData, setExcelData] = useState<ExcelRow[]>([]);
|
|
|
+ const [processingProgress, setProcessingProgress] = useState(0);
|
|
|
+ const [imageMappings, setImageMappings] = useState<ImageMapping>({});
|
|
|
+
|
|
|
+ const wordFileInputRef = useRef<HTMLInputElement>(null);
|
|
|
+ const excelFileInputRef = useRef<HTMLInputElement>(null);
|
|
|
+ const imageZipInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
- const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
+ // 文件选择处理
|
|
|
+ const handleWordFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
const file = event.target.files?.[0];
|
|
|
if (file) {
|
|
|
- // 检查文件类型和大小
|
|
|
const validTypes = [
|
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
|
];
|
|
|
- const maxSize = 10 * 1024 * 1024; // 10MB
|
|
|
-
|
|
|
+ const maxSize = 10 * 1024 * 1024;
|
|
|
+
|
|
|
if (!validTypes.includes(file.type)) {
|
|
|
- if (file.type === 'application/msword') {
|
|
|
- toast.error('不支持旧的 .doc 格式,请使用 .docx 格式的Word文档');
|
|
|
- } else {
|
|
|
- toast.error('请选择有效的Word文件(.docx格式)');
|
|
|
- }
|
|
|
+ toast.error('请选择有效的Word文件(.docx格式)');
|
|
|
return;
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
if (file.size > maxSize) {
|
|
|
toast.error('文件大小超过10MB限制');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- setSelectedFile(file);
|
|
|
+ setSelectedWordFile(file);
|
|
|
setShowPreview(false);
|
|
|
- toast.success('文件已选择,可以开始预览');
|
|
|
+ toast.success('Word模板已选择');
|
|
|
}
|
|
|
};
|
|
|
|
|
|
+ const handleExcelFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
+ const file = event.target.files?.[0];
|
|
|
+ if (file) {
|
|
|
+ const validTypes = [
|
|
|
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
|
+ 'application/vnd.ms-excel'
|
|
|
+ ];
|
|
|
+ const maxSize = 10 * 1024 * 1024;
|
|
|
+
|
|
|
+ if (!validTypes.includes(file.type)) {
|
|
|
+ toast.error('请选择有效的Excel文件(.xlsx/.xls格式)');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (file.size > maxSize) {
|
|
|
+ toast.error('文件大小超过10MB限制');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ setSelectedExcelFile(file);
|
|
|
+ parseExcelFile(file);
|
|
|
+ toast.success('Excel数据文件已选择');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleImageZipSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
+ const file = event.target.files?.[0];
|
|
|
+ if (file) {
|
|
|
+ const validTypes = ['application/zip', 'application/x-zip-compressed'];
|
|
|
+ const maxSize = 50 * 1024 * 1024;
|
|
|
+
|
|
|
+ if (!validTypes.includes(file.type)) {
|
|
|
+ toast.error('请选择有效的ZIP压缩文件');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (file.size > maxSize) {
|
|
|
+ toast.error('压缩文件大小超过50MB限制');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ setImageZipFile(file);
|
|
|
+ await parseImageZip(file);
|
|
|
+ toast.success('图片压缩文件已选择');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 解析Excel文件
|
|
|
+ const parseExcelFile = async (file: File) => {
|
|
|
+ try {
|
|
|
+ const data = await file.arrayBuffer();
|
|
|
+ const workbook = XLSX.read(data, { type: 'array' });
|
|
|
+ const firstSheetName = workbook.SheetNames[0];
|
|
|
+ const worksheet = workbook.Sheets[firstSheetName];
|
|
|
+ const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][];
|
|
|
+
|
|
|
+ if (jsonData.length < 2) {
|
|
|
+ toast.error('Excel文件格式不正确,需要包含表头和至少一行数据');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const headers = jsonData[0] as string[];
|
|
|
+ const rows: ExcelRow[] = [];
|
|
|
+
|
|
|
+ for (let i = 1; i < jsonData.length; i++) {
|
|
|
+ const row = jsonData[i];
|
|
|
+ const rowData: ExcelRow = {};
|
|
|
+ headers.forEach((header, index) => {
|
|
|
+ rowData[header] = row[index] || '';
|
|
|
+ });
|
|
|
+ rows.push(rowData);
|
|
|
+ }
|
|
|
+
|
|
|
+ setExcelData(rows);
|
|
|
+ toast.success(`成功解析 ${rows.length} 条数据记录`);
|
|
|
+ } catch (error) {
|
|
|
+ toast.error('Excel文件解析失败');
|
|
|
+ console.error('Excel parsing error:', error);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 解析图片压缩文件
|
|
|
+ const parseImageZip = async (file: File) => {
|
|
|
+ try {
|
|
|
+ const zip = new JSZip();
|
|
|
+ const zipContent = await zip.loadAsync(file);
|
|
|
+ const newImageMappings: ImageMapping = {};
|
|
|
+
|
|
|
+ // 解析文件夹结构:第一层为序号,第二层为图片
|
|
|
+ for (const [path, zipEntry] of Object.entries(zipContent.files)) {
|
|
|
+ if (!zipEntry.dir && isImageFile(path)) {
|
|
|
+ const pathParts = path.split('/');
|
|
|
+ if (pathParts.length >= 2) {
|
|
|
+ const folderIndex = pathParts[0]; // 序号文件夹
|
|
|
+ const imageName = pathParts[pathParts.length - 1].split('.')[0]; // 去掉扩展名的文件名
|
|
|
+
|
|
|
+ if (!newImageMappings[folderIndex]) {
|
|
|
+ newImageMappings[folderIndex] = {};
|
|
|
+ }
|
|
|
+
|
|
|
+ const imageFile = await zipEntry.async('blob');
|
|
|
+ newImageMappings[folderIndex][imageName] = new File([imageFile], imageName, {
|
|
|
+ type: getImageMimeType(path)
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ setImageMappings(newImageMappings);
|
|
|
+ toast.success(`成功解析 ${Object.keys(newImageMappings).length} 个文件夹的图片`);
|
|
|
+ } catch (error) {
|
|
|
+ toast.error('图片压缩文件解析失败');
|
|
|
+ console.error('Image zip parsing error:', error);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 工具函数
|
|
|
+ const isImageFile = (filename: string): boolean => {
|
|
|
+ const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'];
|
|
|
+ return imageExtensions.some(ext => filename.toLowerCase().endsWith(ext));
|
|
|
+ };
|
|
|
+
|
|
|
+ const getImageMimeType = (filename: string): string => {
|
|
|
+ const extension = filename.toLowerCase().split('.').pop();
|
|
|
+ const mimeTypes: Record<string, string> = {
|
|
|
+ 'jpg': 'image/jpeg',
|
|
|
+ 'jpeg': 'image/jpeg',
|
|
|
+ 'png': 'image/png',
|
|
|
+ 'gif': 'image/gif',
|
|
|
+ 'bmp': 'image/bmp',
|
|
|
+ 'webp': 'image/webp'
|
|
|
+ };
|
|
|
+ return mimeTypes[extension || ''] || 'image/jpeg';
|
|
|
+ };
|
|
|
+
|
|
|
+ // 替换Word字段并插入图片
|
|
|
+ const replaceFieldsInWord = async (wordFile: File, excelRow: ExcelRow, rowIndex: number): Promise<Blob> => {
|
|
|
+ try {
|
|
|
+ const arrayBuffer = await wordFile.arrayBuffer();
|
|
|
+ const zip = new PizZip(arrayBuffer);
|
|
|
+
|
|
|
+ const doc = new Docxtemplater(zip, {
|
|
|
+ paragraphLoop: true,
|
|
|
+ linebreaks: true,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 处理嵌套数据结构
|
|
|
+ const processedData: Record<string, any> = {};
|
|
|
+
|
|
|
+ // 处理普通字段
|
|
|
+ Object.entries(excelRow).forEach(([key, value]) => {
|
|
|
+ if (key.includes('.')) {
|
|
|
+ const parts = key.split('.');
|
|
|
+ let current = processedData;
|
|
|
+ for (let i = 0; i < parts.length - 1; i++) {
|
|
|
+ if (!current[parts[i]]) {
|
|
|
+ current[parts[i]] = {};
|
|
|
+ }
|
|
|
+ current = current[parts[i]];
|
|
|
+ }
|
|
|
+ current[parts[parts.length - 1]] = value;
|
|
|
+ } else {
|
|
|
+ processedData[key] = value;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 处理图片字段
|
|
|
+ const folderIndex = (rowIndex + 1).toString();
|
|
|
+ if (imageMappings[folderIndex]) {
|
|
|
+ Object.entries(imageMappings[folderIndex]).forEach(([imageName, imageFile]) => {
|
|
|
+ processedData[`image:${imageName}`] = {
|
|
|
+ data: imageFile,
|
|
|
+ width: 200,
|
|
|
+ height: 150
|
|
|
+ };
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ doc.setData(processedData);
|
|
|
+ doc.render();
|
|
|
+
|
|
|
+ const generatedDoc = doc.getZip().generate({
|
|
|
+ type: 'blob',
|
|
|
+ mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
|
+ });
|
|
|
+
|
|
|
+ return generatedDoc;
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Word处理错误:', error);
|
|
|
+ throw new Error('Word文档处理失败,请检查模板格式');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 处理文件
|
|
|
+ const processFiles = async () => {
|
|
|
+ if (!selectedWordFile || !selectedExcelFile || excelData.length === 0) {
|
|
|
+ toast.error('请先选择Word模板和Excel数据文件');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ setIsLoading(true);
|
|
|
+ setProcessingProgress(0);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const generatedFiles: ProcessingResult['generatedFiles'] = [];
|
|
|
+
|
|
|
+ for (let i = 0; i < excelData.length; i++) {
|
|
|
+ const row = excelData[i];
|
|
|
+ const processedBlob = await replaceFieldsInWord(selectedWordFile, row, i);
|
|
|
+
|
|
|
+ const fileName = `processed_${i + 1}_${selectedWordFile.name}`;
|
|
|
+ generatedFiles.push({
|
|
|
+ name: fileName,
|
|
|
+ content: processedBlob,
|
|
|
+ fields: row
|
|
|
+ });
|
|
|
+
|
|
|
+ setProcessingProgress(((i + 1) / excelData.length) * 100);
|
|
|
+ }
|
|
|
+
|
|
|
+ const result: ProcessingResult = {
|
|
|
+ originalFile: selectedWordFile,
|
|
|
+ generatedFiles,
|
|
|
+ total: generatedFiles.length
|
|
|
+ };
|
|
|
+
|
|
|
+ setProcessingResult(result);
|
|
|
+ toast.success(`成功生成 ${generatedFiles.length} 个文档`);
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ toast.error('文档处理失败');
|
|
|
+ console.error('Processing error:', error);
|
|
|
+ } finally {
|
|
|
+ setIsLoading(false);
|
|
|
+ setProcessingProgress(0);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 预览功能
|
|
|
const handlePreview = async () => {
|
|
|
- if (!selectedFile) {
|
|
|
+ if (!selectedWordFile) {
|
|
|
toast.error('请先选择Word文件');
|
|
|
return;
|
|
|
}
|
|
|
@@ -63,19 +336,19 @@ export default function WordPreview() {
|
|
|
setShowPreview(true);
|
|
|
|
|
|
try {
|
|
|
- const fileUrl = URL.createObjectURL(selectedFile);
|
|
|
+ const fileUrl = URL.createObjectURL(selectedWordFile);
|
|
|
const wordFile: WordFile = {
|
|
|
id: Date.now().toString(),
|
|
|
- name: selectedFile.name,
|
|
|
- size: selectedFile.size,
|
|
|
+ name: selectedWordFile.name,
|
|
|
+ size: selectedWordFile.size,
|
|
|
url: fileUrl,
|
|
|
previewUrl: fileUrl
|
|
|
};
|
|
|
|
|
|
setPreviewFile(wordFile);
|
|
|
- toast.success('文档解析中...');
|
|
|
+ toast.success('正在预览Word模板...');
|
|
|
} catch (error) {
|
|
|
- toast.error('文件预览失败,请重试');
|
|
|
+ toast.error('文件预览失败');
|
|
|
console.error('Preview error:', error);
|
|
|
setShowPreview(false);
|
|
|
} finally {
|
|
|
@@ -83,12 +356,43 @@ export default function WordPreview() {
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- const handleClearFile = () => {
|
|
|
- setSelectedFile(null);
|
|
|
+ // 下载功能
|
|
|
+ const downloadProcessedFile = (file: ProcessingResult['generatedFiles'][0]) => {
|
|
|
+ const url = URL.createObjectURL(file.content);
|
|
|
+ const a = document.createElement('a');
|
|
|
+ a.href = url;
|
|
|
+ a.download = file.name;
|
|
|
+ document.body.appendChild(a);
|
|
|
+ a.click();
|
|
|
+ document.body.removeChild(a);
|
|
|
+ URL.revokeObjectURL(url);
|
|
|
+ };
|
|
|
+
|
|
|
+ const downloadAllFiles = () => {
|
|
|
+ if (!processingResult) return;
|
|
|
+
|
|
|
+ processingResult.generatedFiles.forEach((file, index) => {
|
|
|
+ setTimeout(() => {
|
|
|
+ downloadProcessedFile(file);
|
|
|
+ }, index * 500);
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const clearAllFiles = () => {
|
|
|
+ setSelectedWordFile(null);
|
|
|
+ setSelectedExcelFile(null);
|
|
|
+ setImageZipFile(null);
|
|
|
+ setExcelData([]);
|
|
|
+ setProcessingResult(null);
|
|
|
+ setImageMappings({});
|
|
|
setPreviewFile(null);
|
|
|
setShowPreview(false);
|
|
|
- const input = document.getElementById('word-file') as HTMLInputElement;
|
|
|
- if (input) input.value = '';
|
|
|
+
|
|
|
+ if (wordFileInputRef.current) wordFileInputRef.current.value = '';
|
|
|
+ if (excelFileInputRef.current) excelFileInputRef.current.value = '';
|
|
|
+ if (imageZipInputRef.current) imageZipInputRef.current.value = '';
|
|
|
+
|
|
|
+ toast.success('已清除所有文件');
|
|
|
};
|
|
|
|
|
|
const formatFileSize = (bytes: number) => {
|
|
|
@@ -102,152 +406,356 @@ export default function WordPreview() {
|
|
|
return (
|
|
|
<div className="space-y-6">
|
|
|
<div>
|
|
|
- <h1 className="text-3xl font-bold tracking-tight">Word文档在线预览</h1>
|
|
|
- <p className="text-muted-foreground">上传并预览Word文档内容,支持 .docx 格式</p>
|
|
|
+ <h1 className="text-3xl font-bold tracking-tight">Word批量处理工具(增强版)</h1>
|
|
|
+ <p className="text-muted-foreground">支持图片压缩文件,自动生成替换字段和图片的文档</p>
|
|
|
</div>
|
|
|
|
|
|
- <div className="grid gap-6 md:grid-cols-2">
|
|
|
- {/* 文件选择区域 */}
|
|
|
+ {/* 文件上传区域 */}
|
|
|
+ <div className="grid gap-6 md:grid-cols-3">
|
|
|
+ {/* Word模板上传 */}
|
|
|
<Card>
|
|
|
<CardHeader>
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
- <Upload className="h-5 w-5" />
|
|
|
- 选择Word文件
|
|
|
+ <FileText className="h-5 w-5" />
|
|
|
+ 选择Word模板
|
|
|
</CardTitle>
|
|
|
<CardDescription>
|
|
|
支持 .docx 格式的Word文档,最大10MB
|
|
|
</CardDescription>
|
|
|
</CardHeader>
|
|
|
<CardContent className="space-y-4">
|
|
|
- <div className="grid w-full max-w-sm items-center gap-1.5">
|
|
|
- <Label htmlFor="word-file">Word文档</Label>
|
|
|
+ <div className="grid w-full items-center gap-1.5">
|
|
|
+ <Label htmlFor="word-file">Word模板文件</Label>
|
|
|
<Input
|
|
|
+ ref={wordFileInputRef}
|
|
|
id="word-file"
|
|
|
type="file"
|
|
|
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
|
- onChange={handleFileSelect}
|
|
|
+ onChange={handleWordFileSelect}
|
|
|
/>
|
|
|
</div>
|
|
|
|
|
|
- {selectedFile && (
|
|
|
+ {selectedWordFile && (
|
|
|
<Alert>
|
|
|
<FileText className="h-4 w-4" />
|
|
|
<AlertDescription>
|
|
|
<div className="space-y-1">
|
|
|
- <p><strong>文件名:</strong> {selectedFile.name}</p>
|
|
|
- <p><strong>大小:</strong> {formatFileSize(selectedFile.size)}</p>
|
|
|
- <p><strong>类型:</strong> {selectedFile.type}</p>
|
|
|
+ <p><strong>文件名:</strong> {selectedWordFile.name}</p>
|
|
|
+ <p><strong>大小:</strong> {formatFileSize(selectedWordFile.size)}</p>
|
|
|
</div>
|
|
|
</AlertDescription>
|
|
|
</Alert>
|
|
|
)}
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
|
|
|
- <div className="flex gap-2">
|
|
|
- <Button
|
|
|
- onClick={handlePreview}
|
|
|
- disabled={!selectedFile || previewLoading}
|
|
|
- className="flex-1"
|
|
|
- >
|
|
|
- {previewLoading ? (
|
|
|
- <>
|
|
|
- <Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
|
- 解析中...
|
|
|
- </>
|
|
|
- ) : (
|
|
|
- <>
|
|
|
- <Eye className="h-4 w-4 mr-2" />
|
|
|
- 开始预览
|
|
|
- </>
|
|
|
- )}
|
|
|
- </Button>
|
|
|
-
|
|
|
- {selectedFile && (
|
|
|
- <Button
|
|
|
- variant="outline"
|
|
|
- onClick={handleClearFile}
|
|
|
- className="flex-1"
|
|
|
- >
|
|
|
- 清除文件
|
|
|
- </Button>
|
|
|
- )}
|
|
|
+ {/* Excel数据上传 */}
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle className="flex items-center gap-2">
|
|
|
+ <FileSpreadsheet className="h-5 w-5" />
|
|
|
+ 选择Excel数据
|
|
|
+ </CardTitle>
|
|
|
+ <CardDescription>
|
|
|
+ 支持 .xlsx/.xls 格式的Excel文档,最大10MB
|
|
|
+ </CardDescription>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent className="space-y-4">
|
|
|
+ <div className="grid w-full items-center gap-1.5">
|
|
|
+ <Label htmlFor="excel-file">Excel数据文件</Label>
|
|
|
+ <Input
|
|
|
+ ref={excelFileInputRef}
|
|
|
+ id="excel-file"
|
|
|
+ type="file"
|
|
|
+ accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
|
|
|
+ onChange={handleExcelFileSelect}
|
|
|
+ />
|
|
|
</div>
|
|
|
+
|
|
|
+ {selectedExcelFile && (
|
|
|
+ <Alert>
|
|
|
+ <FileSpreadsheet className="h-4 w-4" />
|
|
|
+ <AlertDescription>
|
|
|
+ <div className="space-y-1">
|
|
|
+ <p><strong>文件名:</strong> {selectedExcelFile.name}</p>
|
|
|
+ <p><strong>大小:</strong> {formatFileSize(selectedExcelFile.size)}</p>
|
|
|
+ {excelData.length > 0 && (
|
|
|
+ <p><strong>数据行数:</strong> {excelData.length}</p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </AlertDescription>
|
|
|
+ </Alert>
|
|
|
+ )}
|
|
|
</CardContent>
|
|
|
</Card>
|
|
|
|
|
|
- {/* 预览区域 */}
|
|
|
+ {/* 图片压缩文件上传 */}
|
|
|
<Card>
|
|
|
<CardHeader>
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
- <FileText className="h-5 w-5" />
|
|
|
- 文档预览
|
|
|
+ <Package className="h-5 w-5" />
|
|
|
+ 选择图片压缩包
|
|
|
</CardTitle>
|
|
|
<CardDescription>
|
|
|
- {selectedFile ? selectedFile.name : '请先选择并预览文档'}
|
|
|
+ 支持 .zip 格式压缩包,最大50MB
|
|
|
</CardDescription>
|
|
|
</CardHeader>
|
|
|
- <CardContent>
|
|
|
- {!showPreview ? (
|
|
|
- <div className="text-center py-12">
|
|
|
- <FileWarning className="h-16 w-16 mx-auto mb-4 text-muted-foreground" />
|
|
|
- <p className="text-muted-foreground">
|
|
|
- {selectedFile ? '点击"开始预览"按钮查看文档内容' : '请先选择Word文档'}
|
|
|
- </p>
|
|
|
- </div>
|
|
|
- ) : (
|
|
|
- <WordViewer file={selectedFile} />
|
|
|
+ <CardContent className="space-y-4">
|
|
|
+ <div className="grid w-full items-center gap-1.5">
|
|
|
+ <Label htmlFor="image-zip">图片压缩文件</Label>
|
|
|
+ <Input
|
|
|
+ ref={imageZipInputRef}
|
|
|
+ id="image-zip"
|
|
|
+ type="file"
|
|
|
+ accept=".zip,application/zip,application/x-zip-compressed"
|
|
|
+ onChange={handleImageZipSelect}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {imageZipFile && (
|
|
|
+ <Alert>
|
|
|
+ <Image className="h-4 w-4" />
|
|
|
+ <AlertDescription>
|
|
|
+ <div className="space-y-1">
|
|
|
+ <p><strong>文件名:</strong> {imageZipFile.name}</p>
|
|
|
+ <p><strong>大小:</strong> {formatFileSize(imageZipFile.size)}</p>
|
|
|
+ {Object.keys(imageMappings).length > 0 && (
|
|
|
+ <p><strong>文件夹数:</strong> {Object.keys(imageMappings).length}</p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </AlertDescription>
|
|
|
+ </Alert>
|
|
|
)}
|
|
|
</CardContent>
|
|
|
</Card>
|
|
|
</div>
|
|
|
|
|
|
- {/* 功能特性 */}
|
|
|
- <div className="grid gap-4 md:grid-cols-3">
|
|
|
+ {/* 操作按钮 */}
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle>操作区域</CardTitle>
|
|
|
+ <CardDescription>选择文件后执行相应操作</CardDescription>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent className="space-y-4">
|
|
|
+ <div className="flex gap-2 flex-wrap">
|
|
|
+ <Button
|
|
|
+ onClick={handlePreview}
|
|
|
+ disabled={!selectedWordFile || previewLoading}
|
|
|
+ variant="outline"
|
|
|
+ >
|
|
|
+ <Eye className="h-4 w-4 mr-2" />
|
|
|
+ 预览模板
|
|
|
+ </Button>
|
|
|
+
|
|
|
+ <Button
|
|
|
+ onClick={processFiles}
|
|
|
+ disabled={!selectedWordFile || !selectedExcelFile || excelData.length === 0 || isLoading}
|
|
|
+ className="bg-blue-600 hover:bg-blue-700"
|
|
|
+ >
|
|
|
+ {isLoading ? (
|
|
|
+ <>
|
|
|
+ <RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
|
|
+ 处理中...
|
|
|
+ </>
|
|
|
+ ) : (
|
|
|
+ <>
|
|
|
+ <Upload className="h-4 w-4 mr-2" />
|
|
|
+ 开始处理
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </Button>
|
|
|
+
|
|
|
+ <Button
|
|
|
+ onClick={clearAllFiles}
|
|
|
+ variant="outline"
|
|
|
+ className="text-red-600 hover:text-red-700"
|
|
|
+ >
|
|
|
+ 清除所有
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {isLoading && (
|
|
|
+ <div className="space-y-2">
|
|
|
+ <Progress value={processingProgress} className="w-full" />
|
|
|
+ <p className="text-sm text-muted-foreground text-center">
|
|
|
+ 正在处理文档... {Math.round(processingProgress)}%
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ {/* 图片映射预览 */}
|
|
|
+ {Object.keys(imageMappings).length > 0 && (
|
|
|
<Card>
|
|
|
<CardHeader>
|
|
|
- <CardTitle className="text-lg">格式支持</CardTitle>
|
|
|
+ <CardTitle className="flex items-center gap-2">
|
|
|
+ <Image className="h-5 w-5" />
|
|
|
+ 图片映射预览
|
|
|
+ </CardTitle>
|
|
|
+ <CardDescription>
|
|
|
+ 文件夹结构:序号文件夹 → 图片文件
|
|
|
+ </CardDescription>
|
|
|
</CardHeader>
|
|
|
<CardContent>
|
|
|
- <p className="text-sm text-muted-foreground">
|
|
|
- 完美支持 .docx 格式,保留原始文档格式和布局
|
|
|
- </p>
|
|
|
+ <div className="space-y-2 max-h-40 overflow-y-auto">
|
|
|
+ {Object.entries(imageMappings).map(([folder, images]) => (
|
|
|
+ <div key={folder} className="p-2 bg-gray-50 rounded">
|
|
|
+ <strong>文件夹 {folder}:</strong> {Object.keys(images).join(', ')}
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
</CardContent>
|
|
|
</Card>
|
|
|
-
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 处理结果 */}
|
|
|
+ {processingResult && (
|
|
|
<Card>
|
|
|
<CardHeader>
|
|
|
- <CardTitle className="text-lg">本地处理</CardTitle>
|
|
|
+ <CardTitle className="flex items-center gap-2">
|
|
|
+ <CheckCircle className="h-5 w-5 text-green-500" />
|
|
|
+ 处理完成
|
|
|
+ </CardTitle>
|
|
|
+ <CardDescription>
|
|
|
+ 共生成 {processingResult.total} 个文档
|
|
|
+ </CardDescription>
|
|
|
</CardHeader>
|
|
|
<CardContent>
|
|
|
- <p className="text-sm text-muted-foreground">
|
|
|
- 所有文件处理都在本地浏览器完成,保护您的隐私安全
|
|
|
- </p>
|
|
|
+ <div className="space-y-4">
|
|
|
+ <Button
|
|
|
+ onClick={downloadAllFiles}
|
|
|
+ className="w-full"
|
|
|
+ >
|
|
|
+ <DownloadCloud className="h-4 w-4 mr-2" />
|
|
|
+ 下载全部文档
|
|
|
+ </Button>
|
|
|
+
|
|
|
+ <div className="space-y-2 max-h-64 overflow-y-auto">
|
|
|
+ {processingResult.generatedFiles.map((file, index) => (
|
|
|
+ <div
|
|
|
+ key={index}
|
|
|
+ className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
|
|
+ >
|
|
|
+ <div>
|
|
|
+ <p className="font-medium">{file.name}</p>
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
+ 包含 {Object.keys(file.fields).length} 个字段
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <Button
|
|
|
+ variant="ghost"
|
|
|
+ size="sm"
|
|
|
+ onClick={() => downloadProcessedFile(file)}
|
|
|
+ >
|
|
|
+ <Download className="h-4 w-4" />
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
</CardContent>
|
|
|
</Card>
|
|
|
-
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 预览区域 */}
|
|
|
+ {showPreview && selectedWordFile && (
|
|
|
<Card>
|
|
|
<CardHeader>
|
|
|
- <CardTitle className="text-lg">实时预览</CardTitle>
|
|
|
+ <CardTitle className="flex items-center gap-2">
|
|
|
+ <FileText className="h-5 w-5" />
|
|
|
+ 文档预览
|
|
|
+ </CardTitle>
|
|
|
+ <CardDescription>
|
|
|
+ {selectedWordFile.name}
|
|
|
+ </CardDescription>
|
|
|
</CardHeader>
|
|
|
<CardContent>
|
|
|
- <p className="text-sm text-muted-foreground">
|
|
|
- 即时解析文档内容,快速查看Word文档的完整内容
|
|
|
- </p>
|
|
|
+ <WordViewer file={selectedWordFile} />
|
|
|
</CardContent>
|
|
|
</Card>
|
|
|
- </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 数据预览 */}
|
|
|
+ {excelData.length > 0 && (
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle className="flex items-center gap-2">
|
|
|
+ <FileSpreadsheet className="h-5 w-5" />
|
|
|
+ Excel数据预览
|
|
|
+ </CardTitle>
|
|
|
+ <CardDescription>
|
|
|
+ 显示前5行数据,共 {excelData.length} 行
|
|
|
+ </CardDescription>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <div className="overflow-x-auto">
|
|
|
+ <table className="w-full text-sm">
|
|
|
+ <thead>
|
|
|
+ <tr className="border-b">
|
|
|
+ {Object.keys(excelData[0]).map(header => (
|
|
|
+ <th key={header} className="text-left p-2 font-medium">
|
|
|
+ {header}
|
|
|
+ </th>
|
|
|
+ ))}
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {excelData.slice(0, 5).map((row, index) => (
|
|
|
+ <tr key={index} className="border-b">
|
|
|
+ {Object.values(row).map((value, valueIndex) => (
|
|
|
+ <td key={valueIndex} className="p-2">
|
|
|
+ {String(value)}
|
|
|
+ </td>
|
|
|
+ ))}
|
|
|
+ </tr>
|
|
|
+ ))}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ {excelData.length > 5 && (
|
|
|
+ <p className="text-sm text-muted-foreground mt-2 text-center">
|
|
|
+ 还有 {excelData.length - 5} 行数据...
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ )}
|
|
|
|
|
|
{/* 使用说明 */}
|
|
|
<Card>
|
|
|
<CardHeader>
|
|
|
- <CardTitle>使用说明</CardTitle>
|
|
|
+ <CardTitle>使用说明(增强版)</CardTitle>
|
|
|
</CardHeader>
|
|
|
<CardContent>
|
|
|
- <div className="space-y-2 text-sm">
|
|
|
- <p>• <strong>支持的文件格式:</strong> .docx(Word 2007及以上版本)</p>
|
|
|
- <p>• <strong>文件大小限制:</strong> 最大10MB,超过此限制的文件可能无法正确解析</p>
|
|
|
- <p>• <strong>隐私保护:</strong> 所有文件处理都在本地浏览器完成,不会上传到任何服务器</p>
|
|
|
- <p>• <strong>格式兼容性:</strong> 支持大部分Word格式,包括标题、列表、表格、图片等</p>
|
|
|
- <p>• <strong>不支持格式:</strong> 旧的 .doc 格式(Word 97-2003)暂不支持</p>
|
|
|
+ <div className="space-y-3 text-sm">
|
|
|
+ <div>
|
|
|
+ <h4 className="font-medium mb-1">1. 准备Word模板</h4>
|
|
|
+ <p className="text-muted-foreground">
|
|
|
+ 使用 {'{{'}字段名{'}'} 格式作为文本占位符,使用 {'{{'}image:图片名{'}'} 格式作为图片占位符
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <h4 className="font-medium mb-1">2. 准备Excel数据</h4>
|
|
|
+ <p className="text-muted-foreground">
|
|
|
+ Excel文件第一行为表头,列名应与Word模板中的字段名对应
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <h4 className="font-medium mb-1">3. 准备图片压缩包</h4>
|
|
|
+ <p className="text-muted-foreground">
|
|
|
+ 压缩包结构:第一层为序号文件夹(1,2,3...对应Excel行),第二层为图片文件(文件名对应模板中的图片名)
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <h4 className="font-medium mb-1">4. 图片命名规则</h4>
|
|
|
+ <p className="text-muted-foreground">
|
|
|
+ 例如:模板中使用 {'{{'}image:logo{'}'},则图片文件应命名为 logo.jpg/png等
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</CardContent>
|
|
|
</Card>
|
|
|
@@ -262,10 +770,11 @@ export default function WordPreview() {
|
|
|
</CardHeader>
|
|
|
<CardContent>
|
|
|
<div className="space-y-2 text-sm">
|
|
|
- <p>• 复杂格式的文档可能需要更长的解析时间</p>
|
|
|
- <p>• 某些高级格式(如宏、表单控件)可能无法完美显示</p>
|
|
|
- <p>• 建议在现代浏览器中使用以获得最佳体验</p>
|
|
|
- <p>• 如果文档解析失败,请检查文件是否损坏</p>
|
|
|
+ <p>• Word模板中的字段名必须与Excel表头完全匹配</p>
|
|
|
+ <p>• 图片文件名必须与模板中的图片占位符匹配(不含扩展名)</p>
|
|
|
+ <p>• 文件夹序号必须与Excel行号对应(第1行对应文件夹"1")</p>
|
|
|
+ <p>• 如果图片不存在,对应位置将留空</p>
|
|
|
+ <p>• 支持jpg、jpeg、png、gif、bmp、webp格式图片</p>
|
|
|
</div>
|
|
|
</CardContent>
|
|
|
</Card>
|