Selaa lähdekoodia

🗑️ chore(admin): 移除废弃的Word预览相关文件

- 删除 WordViewer 组件文件
- 删除 EnhancedWordPreview 页面文件
- 删除 WordPreview 页面文件
- 将首页路由从 HomePage 替换为 WordPreview
yourname 4 kuukautta sitten
vanhempi
sitoutus
cfb6e39c35

+ 0 - 297
src/client/admin-shadcn/components/WordViewer.tsx

@@ -1,297 +0,0 @@
-import { useEffect, useState } from 'react';
-import { Card, CardContent } from '@/client/components/ui/card';
-import { Alert, AlertDescription } from '@/client/components/ui/alert';
-import { Button } from '@/client/components/ui/button';
-import { Loader2, AlertCircle, FileText } from 'lucide-react';
-import mammoth from 'mammoth';
-
-interface WordViewerProps {
-  file: File | null;
-  fileUrl?: string;
-}
-
-export default function WordViewer({ file, fileUrl }: WordViewerProps) {
-  const [isLoading, setIsLoading] = useState(false);
-  const [error, setError] = useState<string | null>(null);
-  const [previewHtml, setPreviewHtml] = useState<string>('');
-  const [wordContent, setWordContent] = useState<string>('');
-  const [documentInfo, setDocumentInfo] = useState<any>(null);
-
-  const readWordFile = async (file: File) => {
-    setIsLoading(true);
-    setError(null);
-    
-    try {
-      const arrayBuffer = await file.arrayBuffer();
-      
-      // 使用 mammoth.js 解析 DOCX 文件
-      const result = await mammoth.convertToHtml(
-        { arrayBuffer },
-        {
-          styleMap: [
-            "p[style-name='Heading 1'] => h1:fresh",
-            "p[style-name='Heading 2'] => h2:fresh",
-            "p[style-name='Heading 3'] => h3:fresh",
-            "p[style-name='Heading 4'] => h4:fresh",
-            "p[style-name='Heading 5'] => h5:fresh",
-            "p[style-name='Heading 6'] => h6:fresh",
-            "p => p:fresh"
-          ],
-          includeDefaultStyleMap: true
-        }
-      );
-      
-      // 获取文档信息
-      const info = await mammoth.extractRawText({ arrayBuffer });
-      
-      setPreviewHtml(result.value);
-      setWordContent(info.value);
-      
-      // 提取文档元数据
-      setDocumentInfo({
-        name: file.name,
-        size: file.size,
-        type: file.type,
-        lastModified: file.lastModifiedDate,
-        wordCount: info.value.split(/\s+/).filter(word => word.length > 0).length,
-        characterCount: info.value.length
-      });
-      
-    } catch (err) {
-      console.error('Word parsing error:', err);
-      
-      if (file.type === 'application/msword') {
-        setError('不支持旧的 .doc 格式,请使用 .docx 格式的Word文档');
-      } else if (file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
-        setError('不支持的文件格式,请选择 .docx 格式的Word文档');
-      } else {
-        setError('文件解析失败,请检查文件是否损坏');
-      }
-    } finally {
-      setIsLoading(false);
-    }
-  };
-
-  const loadFromUrl = async (url: string) => {
-    setIsLoading(true);
-    setError(null);
-    
-    try {
-      const response = await fetch(url);
-      if (!response.ok) throw new Error('无法加载文件');
-      
-      const arrayBuffer = await response.arrayBuffer();
-      
-      const result = await mammoth.convertToHtml(
-        { arrayBuffer },
-        {
-          styleMap: [
-            "p[style-name='Heading 1'] => h1:fresh",
-            "p[style-name='Heading 2'] => h2:fresh",
-            "p[style-name='Heading 3'] => h3:fresh",
-            "p[style-name='Heading 4'] => h4:fresh",
-            "p[style-name='Heading 5'] => h5:fresh",
-            "p[style-name='Heading 6'] => h6:fresh",
-            "p => p:fresh"
-          ],
-          includeDefaultStyleMap: true
-        }
-      );
-      
-      const info = await mammoth.extractRawText({ arrayBuffer });
-      
-      setPreviewHtml(result.value);
-      setWordContent(info.value);
-      
-    } catch (err) {
-      setError('文件加载失败,请检查网络连接和文件权限');
-      console.error('URL loading error:', err);
-    } finally {
-      setIsLoading(false);
-    }
-  };
-
-  const formatFileSize = (bytes: number) => {
-    if (bytes === 0) return '0 Bytes';
-    const k = 1024;
-    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
-    const i = Math.floor(Math.log(bytes) / Math.log(k));
-    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
-  };
-
-  useEffect(() => {
-    if (file) {
-      readWordFile(file);
-    } else if (fileUrl) {
-      loadFromUrl(fileUrl);
-    }
-  }, [file, fileUrl]);
-
-  if (!file && !fileUrl) {
-    return (
-      <Card>
-        <CardContent className="pt-6">
-          <div className="text-center py-12">
-            <FileText className="h-16 w-16 mx-auto mb-4 text-muted-foreground" />
-            <p className="text-muted-foreground">请选择Word文件进行预览</p>
-          </div>
-        </CardContent>
-      </Card>
-    );
-  }
-
-  if (isLoading) {
-    return (
-      <Card>
-        <CardContent className="pt-6">
-          <div className="flex items-center justify-center py-12">
-            <Loader2 className="h-8 w-8 animate-spin text-primary" />
-            <span className="ml-2">正在解析文档...</span>
-          </div>
-        </CardContent>
-      </Card>
-    );
-  }
-
-  if (error) {
-    return (
-      <Card>
-        <CardContent className="pt-6">
-          <Alert variant="destructive">
-            <AlertCircle className="h-4 w-4" />
-            <AlertDescription>{error}</AlertDescription>
-          </Alert>
-          <div className="mt-4">
-            <Button variant="outline" onClick={() => setError(null)}>
-              重试
-            </Button>
-          </div>
-        </CardContent>
-      </Card>
-    );
-  }
-
-  return (
-    <Card>
-      <CardContent className="pt-6">
-        <div className="word-viewer-container">
-          <style jsx>{`
-            .word-viewer-container {
-              max-width: 100%;
-              overflow-x: auto;
-            }
-            .word-content {
-              font-family: 'Times New Roman', serif;
-              line-height: 1.6;
-              color: #1f2937;
-            }
-            .word-content h1 {
-              font-size: 2rem;
-              font-weight: bold;
-              margin: 1.5rem 0 1rem 0;
-              color: #111827;
-            }
-            .word-content h2 {
-              font-size: 1.75rem;
-              font-weight: bold;
-              margin: 1.5rem 0 0.75rem 0;
-              color: #111827;
-            }
-            .word-content h3 {
-              font-size: 1.5rem;
-              font-weight: bold;
-              margin: 1.25rem 0 0.75rem 0;
-              color: #111827;
-            }
-            .word-content h4 {
-              font-size: 1.25rem;
-              font-weight: bold;
-              margin: 1rem 0 0.5rem 0;
-              color: #111827;
-            }
-            .word-content h5 {
-              font-size: 1.125rem;
-              font-weight: bold;
-              margin: 1rem 0 0.5rem 0;
-              color: #111827;
-            }
-            .word-content h6 {
-              font-size: 1rem;
-              font-weight: bold;
-              margin: 0.75rem 0 0.5rem 0;
-              color: #111827;
-            }
-            .word-content p {
-              margin-bottom: 0.75rem;
-            }
-            .word-content ul, .word-content ol {
-              margin: 0.5rem 0 0.5rem 2rem;
-            }
-            .word-content li {
-              margin-bottom: 0.25rem;
-            }
-            .word-content table {
-              width: 100%;
-              border-collapse: collapse;
-              margin: 1rem 0;
-            }
-            .word-content th, .word-content td {
-              border: 1px solid #d1d5db;
-              padding: 0.5rem;
-              text-align: left;
-            }
-            .word-content th {
-              background-color: #f9fafb;
-              font-weight: bold;
-            }
-            .document-info {
-              background-color: #f9fafb;
-              border: 1px solid #e5e7eb;
-              border-radius: 0.5rem;
-              padding: 1rem;
-              margin-bottom: 1rem;
-            }
-            .document-info h4 {
-              font-weight: bold;
-              margin-bottom: 0.5rem;
-            }
-            .document-info p {
-              margin: 0.25rem 0;
-              font-size: 0.875rem;
-            }
-          `}</style>
-          
-          {documentInfo && (
-            <div className="document-info">
-              <h4>文档信息</h4>
-              <p><strong>文件名:</strong> {documentInfo.name}</p>
-              <p><strong>大小:</strong> {formatFileSize(documentInfo.size)}</p>
-              <p><strong>类型:</strong> {documentInfo.type}</p>
-              <p><strong>字数:</strong> {documentInfo.wordCount}</p>
-              <p><strong>字符数:</strong> {documentInfo.characterCount}</p>
-              {documentInfo.lastModified && (
-                <p><strong>修改时间:</strong> {documentInfo.lastModified.toLocaleString()}</p>
-              )}
-            </div>
-          )}
-          
-          <div 
-            className="word-content"
-            dangerouslySetInnerHTML={{ __html: previewHtml }} 
-          />
-          
-          {wordContent && (
-            <details className="mt-4">
-              <summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
-                查看纯文本内容
-              </summary>
-              <div className="mt-2 p-4 bg-muted rounded-lg">
-                <pre className="text-sm whitespace-pre-wrap font-mono">{wordContent}</pre>
-              </div>
-            </details>
-          )}
-        </div>
-      </CardContent>
-    </Card>
-  );
-}

+ 0 - 783
src/client/admin-shadcn/pages/EnhancedWordPreview.tsx

@@ -1,783 +0,0 @@
-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, FileSpreadsheet, 
-  RefreshCw, CheckCircle, AlertCircle, DownloadCloud, Image, Package
-} from 'lucide-react';
-import { toast } from 'sonner';
-import WordViewer from '@/client/admin-shadcn/components/WordViewer';
-import { Badge } from '@/client/components/ui/badge';
-import { Progress } from '@/client/components/ui/progress';
-
-interface WordFile {
-  id: string;
-  name: string;
-  size: number;
-  url: string;
-  previewUrl?: string;
-}
-
-interface ExcelRow {
-  [key: string]: string | number;
-}
-
-interface ImageMapping {
-  [key: string]: {
-    [imageName: string]: File;
-  };
-}
-
-interface ProcessingResult {
-  originalFile: File;
-  generatedFiles: Array<{
-    name: string;
-    content: Blob;
-    fields: Record<string, string>;
-  }>;
-  total: number;
-}
-
-export default function EnhancedWordPreview() {
-  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 handleWordFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
-    const file = event.target.files?.[0];
-    if (file) {
-      const validTypes = [
-        'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
-      ];
-      const maxSize = 10 * 1024 * 1024;
-
-      if (!validTypes.includes(file.type)) {
-        toast.error('请选择有效的Word文件(.docx格式)');
-        return;
-      }
-
-      if (file.size > maxSize) {
-        toast.error('文件大小超过10MB限制');
-        return;
-      }
-
-      setSelectedWordFile(file);
-      setShowPreview(false);
-      toast.success('Word模板已选择');
-    }
-  };
-
-  const handleExcelFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
-    const file = event.target.files?.[0];
-    if (file) {
-      const validTypes = [
-        'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
-        'application/vnd.ms-excel'
-      ];
-      const maxSize = 10 * 1024 * 1024;
-
-      if (!validTypes.includes(file.type)) {
-        toast.error('请选择有效的Excel文件(.xlsx/.xls格式)');
-        return;
-      }
-
-      if (file.size > maxSize) {
-        toast.error('文件大小超过10MB限制');
-        return;
-      }
-
-      setSelectedExcelFile(file);
-      parseExcelFile(file);
-      toast.success('Excel数据文件已选择');
-    }
-  };
-
-  const handleImageZipSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
-    const file = event.target.files?.[0];
-    if (file) {
-      const 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 (!selectedWordFile) {
-      toast.error('请先选择Word文件');
-      return;
-    }
-
-    setPreviewLoading(true);
-    setShowPreview(true);
-    
-    try {
-      const fileUrl = URL.createObjectURL(selectedWordFile);
-      const wordFile: WordFile = {
-        id: Date.now().toString(),
-        name: selectedWordFile.name,
-        size: selectedWordFile.size,
-        url: fileUrl,
-        previewUrl: fileUrl
-      };
-      
-      setPreviewFile(wordFile);
-      toast.success('正在预览Word模板...');
-    } catch (error) {
-      toast.error('文件预览失败');
-      console.error('Preview error:', error);
-      setShowPreview(false);
-    } finally {
-      setPreviewLoading(false);
-    }
-  };
-
-  // 下载功能
-  const downloadProcessedFile = (file: ProcessingResult['generatedFiles'][0]) => {
-    const url = URL.createObjectURL(file.content);
-    const a = document.createElement('a');
-    a.href = url;
-    a.download = file.name;
-    document.body.appendChild(a);
-    a.click();
-    document.body.removeChild(a);
-    URL.revokeObjectURL(url);
-  };
-
-  const downloadAllFiles = () => {
-    if (!processingResult) return;
-
-    processingResult.generatedFiles.forEach((file, index) => {
-      setTimeout(() => {
-        downloadProcessedFile(file);
-      }, index * 500);
-    });
-  };
-
-  const clearAllFiles = () => {
-    setSelectedWordFile(null);
-    setSelectedExcelFile(null);
-    setImageZipFile(null);
-    setExcelData([]);
-    setProcessingResult(null);
-    setImageMappings({});
-    setPreviewFile(null);
-    setShowPreview(false);
-    
-    if (wordFileInputRef.current) wordFileInputRef.current.value = '';
-    if (excelFileInputRef.current) excelFileInputRef.current.value = '';
-    if (imageZipInputRef.current) imageZipInputRef.current.value = '';
-    
-    toast.success('已清除所有文件');
-  };
-
-  const formatFileSize = (bytes: number) => {
-    if (bytes === 0) return '0 Bytes';
-    const k = 1024;
-    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
-    const i = Math.floor(Math.log(bytes) / Math.log(k));
-    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
-  };
-
-  return (
-    <div className="space-y-6">
-      <div>
-        <h1 className="text-3xl font-bold tracking-tight">Word批量处理工具(增强版)</h1>
-        <p className="text-muted-foreground">支持图片压缩文件,自动生成替换字段和图片的文档</p>
-      </div>
-
-      {/* 文件上传区域 */}
-      <div className="grid gap-6 md:grid-cols-3">
-        {/* Word模板上传 */}
-        <Card>
-          <CardHeader>
-            <CardTitle className="flex items-center gap-2">
-              <FileText className="h-5 w-5" />
-              选择Word模板
-            </CardTitle>
-            <CardDescription>
-              支持 .docx 格式的Word文档,最大10MB
-            </CardDescription>
-          </CardHeader>
-          <CardContent className="space-y-4">
-            <div className="grid w-full items-center gap-1.5">
-              <Label htmlFor="word-file">Word模板文件</Label>
-              <Input
-                ref={wordFileInputRef}
-                id="word-file"
-                type="file"
-                accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
-                onChange={handleWordFileSelect}
-              />
-            </div>
-
-            {selectedWordFile && (
-              <Alert>
-                <FileText className="h-4 w-4" />
-                <AlertDescription>
-                  <div className="space-y-1">
-                    <p><strong>文件名:</strong> {selectedWordFile.name}</p>
-                    <p><strong>大小:</strong> {formatFileSize(selectedWordFile.size)}</p>
-                  </div>
-                </AlertDescription>
-              </Alert>
-            )}
-          </CardContent>
-        </Card>
-
-        {/* Excel数据上传 */}
-        <Card>
-          <CardHeader>
-            <CardTitle className="flex items-center gap-2">
-              <FileSpreadsheet className="h-5 w-5" />
-              选择Excel数据
-            </CardTitle>
-            <CardDescription>
-              支持 .xlsx/.xls 格式的Excel文档,最大10MB
-            </CardDescription>
-          </CardHeader>
-          <CardContent className="space-y-4">
-            <div className="grid w-full items-center gap-1.5">
-              <Label htmlFor="excel-file">Excel数据文件</Label>
-              <Input
-                ref={excelFileInputRef}
-                id="excel-file"
-                type="file"
-                accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
-                onChange={handleExcelFileSelect}
-              />
-            </div>
-
-            {selectedExcelFile && (
-              <Alert>
-                <FileSpreadsheet className="h-4 w-4" />
-                <AlertDescription>
-                  <div className="space-y-1">
-                    <p><strong>文件名:</strong> {selectedExcelFile.name}</p>
-                    <p><strong>大小:</strong> {formatFileSize(selectedExcelFile.size)}</p>
-                    {excelData.length > 0 && (
-                      <p><strong>数据行数:</strong> {excelData.length}</p>
-                    )}
-                  </div>
-                </AlertDescription>
-              </Alert>
-            )}
-          </CardContent>
-        </Card>
-
-        {/* 图片压缩文件上传 */}
-        <Card>
-          <CardHeader>
-            <CardTitle className="flex items-center gap-2">
-              <Package className="h-5 w-5" />
-              选择图片压缩包
-            </CardTitle>
-            <CardDescription>
-              支持 .zip 格式压缩包,最大50MB
-            </CardDescription>
-          </CardHeader>
-          <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>
-
-      {/* 操作按钮 */}
-      <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="flex items-center gap-2">
-              <Image className="h-5 w-5" />
-              图片映射预览
-            </CardTitle>
-            <CardDescription>
-              文件夹结构:序号文件夹 → 图片文件
-            </CardDescription>
-          </CardHeader>
-          <CardContent>
-            <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="flex items-center gap-2">
-              <CheckCircle className="h-5 w-5 text-green-500" />
-              处理完成
-            </CardTitle>
-            <CardDescription>
-              共生成 {processingResult.total} 个文档
-            </CardDescription>
-          </CardHeader>
-          <CardContent>
-            <div className="space-y-4">
-              <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="flex items-center gap-2">
-              <FileText className="h-5 w-5" />
-              文档预览
-            </CardTitle>
-            <CardDescription>
-              {selectedWordFile.name}
-            </CardDescription>
-          </CardHeader>
-          <CardContent>
-            <WordViewer file={selectedWordFile} />
-          </CardContent>
-        </Card>
-      )}
-
-      {/* 数据预览 */}
-      {excelData.length > 0 && (
-        <Card>
-          <CardHeader>
-            <CardTitle className="flex items-center gap-2">
-              <FileSpreadsheet className="h-5 w-5" />
-              Excel数据预览
-            </CardTitle>
-            <CardDescription>
-              显示前5行数据,共 {excelData.length} 行
-            </CardDescription>
-          </CardHeader>
-          <CardContent>
-            <div className="overflow-x-auto">
-              <table className="w-full text-sm">
-                <thead>
-                  <tr className="border-b">
-                    {Object.keys(excelData[0]).map(header => (
-                      <th key={header} className="text-left p-2 font-medium">
-                        {header}
-                      </th>
-                    ))}
-                  </tr>
-                </thead>
-                <tbody>
-                  {excelData.slice(0, 5).map((row, index) => (
-                    <tr key={index} className="border-b">
-                      {Object.values(row).map((value, valueIndex) => (
-                        <td key={valueIndex} className="p-2">
-                          {String(value)}
-                        </td>
-                      ))}
-                    </tr>
-                  ))}
-                </tbody>
-              </table>
-              {excelData.length > 5 && (
-                <p className="text-sm text-muted-foreground mt-2 text-center">
-                  还有 {excelData.length - 5} 行数据...
-                </p>
-              )}
-            </div>
-          </CardContent>
-        </Card>
-      )}
-
-      {/* 使用说明 */}
-      <Card>
-        <CardHeader>
-          <CardTitle>使用说明(增强版)</CardTitle>
-        </CardHeader>
-        <CardContent>
-          <div className="space-y-3 text-sm">
-            <div>
-              <h4 className="font-medium mb-1">1. 准备Word模板</h4>
-              <p className="text-muted-foreground">
-                使用 {'{{'}字段名{'}'} 格式作为文本占位符,使用 {'{{'}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>
-
-      {/* 注意事项 */}
-      <Card>
-        <CardHeader>
-          <CardTitle className="flex items-center gap-2">
-            <AlertCircle className="h-5 w-5" />
-            注意事项
-          </CardTitle>
-        </CardHeader>
-        <CardContent>
-          <div className="space-y-2 text-sm">
-            <p>• Word模板中的字段名必须与Excel表头完全匹配</p>
-            <p>• 图片文件名必须与模板中的图片占位符匹配(不含扩展名)</p>
-            <p>• 文件夹序号必须与Excel行号对应(第1行对应文件夹"1")</p>
-            <p>• 如果图片不存在,对应位置将留空</p>
-            <p>• 支持jpg、jpeg、png、gif、bmp、webp格式图片</p>
-          </div>
-        </CardContent>
-      </Card>
-    </div>
-  );
-}

+ 0 - 1054
src/client/admin-shadcn/pages/WordPreview.tsx

@@ -1,1054 +0,0 @@
-import { useState, useRef } from 'react';
-import * as XLSX from 'xlsx';
-import PizZip from 'pizzip';
-import Docxtemplater from 'docxtemplater';
-import ImageModule from 'open-docxtemplater-image-module-2';
-import JSZip from 'jszip';
-import { Button } from '@/client/components/ui/button';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
-import { Input } from '@/client/components/ui/input';
-import { Label } from '@/client/components/ui/label';
-import { Alert, AlertDescription } from '@/client/components/ui/alert';
-import {
-  FileText, Upload, Download, Eye, FileWarning, FileSpreadsheet,
-  RefreshCw, CheckCircle, AlertCircle, DownloadCloud, Image, Package,
-  X, ZoomIn, ZoomOut, ChevronLeft, ChevronRight
-} from 'lucide-react';
-import { toast } from 'sonner';
-import WordViewer from '@/client/admin-shadcn/components/WordViewer';
-import { Badge } from '@/client/components/ui/badge';
-import { Progress } from '@/client/components/ui/progress';
-import {
-  Dialog,
-  DialogContent,
-  DialogHeader,
-  DialogTitle,
-} from '@/client/components/ui/dialog';
-
-interface WordFile {
-  id: string;
-  name: string;
-  size: number;
-  url: string;
-  previewUrl?: string;
-}
-
-interface ExcelRow {
-  [key: string]: string | number;
-}
-
-interface ImageMapping {
-  [key: string]: {
-    [imageName: string]: File;
-  };
-}
-
-interface ProcessingResult {
-  originalFile: File;
-  generatedFiles: Array<{
-    name: string;
-    content: Blob;
-    fields: Record<string, string>;
-  }>;
-  total: number;
-}
-
-export default function WordPreview() {
-  const [selectedWordFile, setSelectedWordFile] = useState<File | null>(null);
-  const [selectedExcelFile, setSelectedExcelFile] = useState<File | null>(null);
-  const [imageZipFile, setImageZipFile] = useState<File | null>(null);
-  const [previewFile, setPreviewFile] = useState<WordFile | null>(null);
-  const [isLoading, setIsLoading] = useState(false);
-  const [previewLoading, setPreviewLoading] = useState(false);
-  const [showPreview, setShowPreview] = useState(false);
-  const [processingResult, setProcessingResult] = useState<ProcessingResult | null>(null);
-  const [excelData, setExcelData] = useState<ExcelRow[]>([]);
-  const [processingProgress, setProcessingProgress] = useState(0);
-  const [imageMappings, setImageMappings] = useState<ImageMapping>({});
-  const [imagePreviewUrls, setImagePreviewUrls] = useState<Record<string, Record<string, string>>>({});
-  const [selectedImage, setSelectedImage] = useState<{
-    url: string;
-    name: string;
-    folder: string;
-  } | null>(null);
-  const [currentImageIndex, setCurrentImageIndex] = useState(0);
-  const [allImages, setAllImages] = useState<Array<{ url: string; name: string; folder: string }>>([]);
-  
-  const wordFileInputRef = useRef<HTMLInputElement>(null);
-  const excelFileInputRef = useRef<HTMLInputElement>(null);
-  const imageZipInputRef = useRef<HTMLInputElement>(null);
-
-  // 文件选择处理
-  const handleWordFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
-    const file = event.target.files?.[0];
-    if (file) {
-      const validTypes = [
-        'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
-      ];
-      const maxSize = 10 * 1024 * 1024;
-
-      if (!validTypes.includes(file.type)) {
-        toast.error('请选择有效的Word文件(.docx格式)');
-        return;
-      }
-
-      if (file.size > maxSize) {
-        toast.error('文件大小超过10MB限制');
-        return;
-      }
-
-      setSelectedWordFile(file);
-      setShowPreview(false);
-      toast.success('Word模板已选择');
-    }
-  };
-
-  const handleExcelFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
-    const file = event.target.files?.[0];
-    if (file) {
-      const validTypes = [
-        'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
-        'application/vnd.ms-excel'
-      ];
-      const maxSize = 10 * 1024 * 1024;
-
-      if (!validTypes.includes(file.type)) {
-        toast.error('请选择有效的Excel文件(.xlsx/.xls格式)');
-        return;
-      }
-
-      if (file.size > maxSize) {
-        toast.error('文件大小超过10MB限制');
-        return;
-      }
-
-      setSelectedExcelFile(file);
-      parseExcelFile(file);
-      toast.success('Excel数据文件已选择');
-    }
-  };
-
-  const handleImageZipSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
-    const file = event.target.files?.[0];
-    if (file) {
-      const 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 = {};
-      const newImagePreviewUrls: Record<string, Record<string, string>> = {};
-      const allImages: Array<{ url: string; name: string; folder: string }> = [];
-
-      // 解析文件夹结构:第一层为序号,第二层为图片
-      for (const [path, zipEntry] of Object.entries(zipContent.files)) {
-        if (!zipEntry.dir && isImageFile(path)) {
-          const pathParts = path.split('/');
-          if (pathParts.length >= 2) {
-            const folderIndex = pathParts[0]; // 序号文件夹
-            const imageName = pathParts[pathParts.length - 1].split('.')[0]; // 去掉扩展名的文件名
-            const fullImageName = pathParts[pathParts.length - 1]; // 完整文件名
-            
-            if (!newImageMappings[folderIndex]) {
-              newImageMappings[folderIndex] = {};
-              newImagePreviewUrls[folderIndex] = {};
-            }
-            
-            const imageFile = await zipEntry.async('blob');
-            newImageMappings[folderIndex][imageName] = new File([imageFile], imageName, {
-              type: getImageMimeType(path)
-            });
-            
-            // 创建预览URL
-            const previewUrl = URL.createObjectURL(imageFile);
-            newImagePreviewUrls[folderIndex][imageName] = previewUrl;
-            
-            allImages.push({
-              url: previewUrl,
-              name: fullImageName,
-              folder: folderIndex
-            });
-          }
-        }
-      }
-
-      setImageMappings(newImageMappings);
-      setImagePreviewUrls(newImagePreviewUrls);
-      setAllImages(allImages);
-      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);
-      
-      // 预加载所有图片数据,避免Promise问题
-      const folderIndex = (rowIndex + 1).toString();
-      const imageDataMap: Record<string, ArrayBuffer> = {};
-      
-      // 预加载当前文件夹的所有图片
-      if (imageMappings[folderIndex]) {
-        for (const [imageName, imageFile] of Object.entries(imageMappings[folderIndex])) {
-          try {
-            imageDataMap[imageName] = await imageFile.arrayBuffer();
-          } catch (error) {
-            console.warn(`Failed to load image ${imageName}:`, error);
-          }
-        }
-      }
-      
-      // 配置图片模块 - 使用实际图片尺寸
-      const imageSizeCache = new Map<string, [number, number]>();
-      
-      const imageOpts = {
-        centered: false,
-        getImage: (tagValue: string): ArrayBuffer | null => {
-          if (tagValue && typeof tagValue === 'string') {
-            return imageDataMap[tagValue] || null;
-          }
-          return null;
-        },
-        getSize: (img: ArrayBuffer, tagValue: string, tagName: string) => {
-          console.log('tagName', tagName)
-          console.log('tagValue', tagValue)
-          // 从图片数据中获取实际尺寸
-          try {
-            // 为每个图片创建唯一的缓存键
-            const cacheKey = `${tagValue}_${img.byteLength}`;
-            
-            // 如果已经缓存了尺寸,直接返回
-            if (imageSizeCache.has(cacheKey)) {
-              return imageSizeCache.get(cacheKey)!;
-            }
-            
-            // 简化的图片尺寸检测(基于图片数据特征)
-            // 这里我们使用一个合理的方法来从图片数据推断尺寸
-            const view = new DataView(img);
-            
-            // PNG格式检测
-            if (view.getUint32(0) === 0x89504E47 && view.getUint32(4) === 0x0D0A1A0A) {
-              // PNG IHDR chunk: width at offset 16, height at offset 20
-              const width = view.getUint32(16);
-              const height = view.getUint32(20);
-              const size: [number, number] = [width, height];
-              console.log('size', size)
-              imageSizeCache.set(cacheKey, size);
-              return size;
-            }
-            
-            // JPEG格式检测
-            if (view.getUint16(0) === 0xFFD8) {
-              // 简化的JPEG尺寸检测
-              let offset = 2;
-              while (offset < img.byteLength - 10) {
-                if (view.getUint8(offset) === 0xFF) {
-                  const marker = view.getUint8(offset + 1);
-                  if (marker >= 0xC0 && marker <= 0xC3) {
-                    // SOF marker found
-                    const height = view.getUint16(offset + 5);
-                    const width = view.getUint16(offset + 7);
-                    const size: [number, number] = [width, height];
-                    console.log('size', size)
-                    imageSizeCache.set(cacheKey, size);
-                    return size;
-                  }
-                  const length = view.getUint16(offset + 2);
-                  offset += length + 2;
-                  continue;
-                }
-                offset++;
-              }
-            }
-            
-            // 如果无法检测尺寸,使用默认尺寸
-            const defaultSize: [number, number] = [200, 150];
-            imageSizeCache.set(cacheKey, defaultSize);
-            console.log('defaultSize', defaultSize)
-            return defaultSize;
-            
-          } catch (error) {
-            console.warn('Failed to get image size, using default:', error);
-            return [200, 150];
-          }
-        }
-      };
-      
-      const imageModule = new ImageModule(imageOpts);
-
-      const doc = new Docxtemplater(zip, {
-        paragraphLoop: true,
-        linebreaks: true,
-        modules: [imageModule]
-      })
-
-      // 处理嵌套数据结构
-      const processedData: Record<string, any> = {};
-      
-      // 处理普通字段
-      Object.entries(excelRow).forEach(([key, value]) => {
-        if (key.includes('.')) {
-          const parts = key.split('.');
-          let current = processedData;
-          for (let i = 0; i < parts.length - 1; i++) {
-            if (!current[parts[i]]) {
-              current[parts[i]] = {};
-            }
-            current = current[parts[i]];
-          }
-          current[parts[parts.length - 1]] = value;
-        } else {
-          processedData[key] = value;
-        }
-      });
-
-      // 处理图片字段 - 确保图片名称正确传递给模板
-      if (imageMappings[folderIndex]) {
-        for (const [imageName] of Object.entries(imageMappings[folderIndex])) {
-          // 确保图片名称作为标签值传递给模板
-          processedData[imageName] = imageName;
-        }
-      }
-
-      doc.render(processedData);
-      
-      const generatedDoc = doc.getZip().generate({
-        type: 'blob',
-        mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
-      });
-
-      return generatedDoc;
-    } catch (error) {
-      console.error('Word处理错误:', error);
-      throw new Error('Word文档处理失败,请检查模板格式');
-    }
-  };
-
-  // 处理文件
-  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 (!selectedWordFile) {
-      toast.error('请先选择Word文件');
-      return;
-    }
-
-    setPreviewLoading(true);
-    setShowPreview(true);
-    
-    try {
-      const fileUrl = URL.createObjectURL(selectedWordFile);
-      const wordFile: WordFile = {
-        id: Date.now().toString(),
-        name: selectedWordFile.name,
-        size: selectedWordFile.size,
-        url: fileUrl,
-        previewUrl: fileUrl
-      };
-      
-      setPreviewFile(wordFile);
-      toast.success('正在预览Word模板...');
-    } catch (error) {
-      toast.error('文件预览失败');
-      console.error('Preview error:', error);
-      setShowPreview(false);
-    } finally {
-      setPreviewLoading(false);
-    }
-  };
-
-  // 下载功能
-  const downloadProcessedFile = (file: ProcessingResult['generatedFiles'][0]) => {
-    const url = URL.createObjectURL(file.content);
-    const a = document.createElement('a');
-    a.href = url;
-    a.download = file.name;
-    document.body.appendChild(a);
-    a.click();
-    document.body.removeChild(a);
-    URL.revokeObjectURL(url);
-  };
-
-  const downloadAllFiles = () => {
-    if (!processingResult) return;
-
-    processingResult.generatedFiles.forEach((file, index) => {
-      setTimeout(() => {
-        downloadProcessedFile(file);
-      }, index * 500);
-    });
-  };
-
-  const clearAllFiles = () => {
-    setSelectedWordFile(null);
-    setSelectedExcelFile(null);
-    setImageZipFile(null);
-    setExcelData([]);
-    setProcessingResult(null);
-    setImageMappings({});
-    setImagePreviewUrls({});
-    setSelectedImage(null);
-    setAllImages([]);
-    setPreviewFile(null);
-    setShowPreview(false);
-    
-    if (wordFileInputRef.current) wordFileInputRef.current.value = '';
-    if (excelFileInputRef.current) excelFileInputRef.current.value = '';
-    if (imageZipInputRef.current) imageZipInputRef.current.value = '';
-    
-    toast.success('已清除所有文件');
-  };
-
-  const formatFileSize = (bytes: number) => {
-    if (bytes === 0) return '0 Bytes';
-    const k = 1024;
-    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
-    const i = Math.floor(Math.log(bytes) / Math.log(k));
-    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
-  };
-
-  // 图片预览功能
-  const openImagePreview = (url: string, name: string, folder: string) => {
-    const imageIndex = allImages.findIndex(img => img.url === url);
-    setCurrentImageIndex(imageIndex !== -1 ? imageIndex : 0);
-    setSelectedImage({ url, name, folder });
-  };
-
-  const closeImagePreview = () => {
-    setSelectedImage(null);
-  };
-
-  const navigateImage = (direction: 'prev' | 'next') => {
-    if (allImages.length === 0) return;
-    
-    let newIndex = currentImageIndex;
-    if (direction === 'prev') {
-      newIndex = currentImageIndex > 0 ? currentImageIndex - 1 : allImages.length - 1;
-    } else {
-      newIndex = currentImageIndex < allImages.length - 1 ? currentImageIndex + 1 : 0;
-    }
-    
-    setCurrentImageIndex(newIndex);
-    setSelectedImage(allImages[newIndex]);
-  };
-
-  const getTotalImages = () => {
-    return Object.values(imagePreviewUrls).reduce((total, folder) => total + Object.keys(folder).length, 0);
-  };
-
-  return (
-    <div className="space-y-6">
-      <div>
-        <h1 className="text-3xl font-bold tracking-tight">Word批量处理工具(增强版)</h1>
-        <p className="text-muted-foreground">支持图片压缩文件,自动生成替换字段和图片的文档</p>
-      </div>
-
-      {/* 文件上传区域 */}
-      <div className="grid gap-6 md:grid-cols-3">
-        {/* Word模板上传 */}
-        <Card>
-          <CardHeader>
-            <CardTitle className="flex items-center gap-2">
-              <FileText className="h-5 w-5" />
-              选择Word模板
-            </CardTitle>
-            <CardDescription>
-              支持 .docx 格式的Word文档,最大10MB
-            </CardDescription>
-          </CardHeader>
-          <CardContent className="space-y-4">
-            <div className="grid w-full items-center gap-1.5">
-              <Label htmlFor="word-file">Word模板文件</Label>
-              <Input
-                ref={wordFileInputRef}
-                id="word-file"
-                type="file"
-                accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
-                onChange={handleWordFileSelect}
-              />
-            </div>
-
-            {selectedWordFile && (
-              <Alert>
-                <FileText className="h-4 w-4" />
-                <AlertDescription>
-                  <div className="space-y-1">
-                    <p><strong>文件名:</strong> {selectedWordFile.name}</p>
-                    <p><strong>大小:</strong> {formatFileSize(selectedWordFile.size)}</p>
-                  </div>
-                </AlertDescription>
-              </Alert>
-            )}
-          </CardContent>
-        </Card>
-
-        {/* Excel数据上传 */}
-        <Card>
-          <CardHeader>
-            <CardTitle className="flex items-center gap-2">
-              <FileSpreadsheet className="h-5 w-5" />
-              选择Excel数据
-            </CardTitle>
-            <CardDescription>
-              支持 .xlsx/.xls 格式的Excel文档,最大10MB
-            </CardDescription>
-          </CardHeader>
-          <CardContent className="space-y-4">
-            <div className="grid w-full items-center gap-1.5">
-              <Label htmlFor="excel-file">Excel数据文件</Label>
-              <Input
-                ref={excelFileInputRef}
-                id="excel-file"
-                type="file"
-                accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
-                onChange={handleExcelFileSelect}
-              />
-            </div>
-
-            {selectedExcelFile && (
-              <Alert>
-                <FileSpreadsheet className="h-4 w-4" />
-                <AlertDescription>
-                  <div className="space-y-1">
-                    <p><strong>文件名:</strong> {selectedExcelFile.name}</p>
-                    <p><strong>大小:</strong> {formatFileSize(selectedExcelFile.size)}</p>
-                    {excelData.length > 0 && (
-                      <p><strong>数据行数:</strong> {excelData.length}</p>
-                    )}
-                  </div>
-                </AlertDescription>
-              </Alert>
-            )}
-          </CardContent>
-        </Card>
-
-        {/* 图片压缩文件上传 */}
-        <Card>
-          <CardHeader>
-            <CardTitle className="flex items-center gap-2">
-              <Package className="h-5 w-5" />
-              选择图片压缩包
-            </CardTitle>
-            <CardDescription>
-              支持 .zip 格式压缩包,最大50MB
-            </CardDescription>
-          </CardHeader>
-          <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>
-
-      {/* 操作按钮 */}
-      <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>
-
-      {/* 预览区域 */}
-      {showPreview && selectedWordFile && (
-        <Card>
-          <CardHeader>
-            <CardTitle className="flex items-center gap-2">
-              <FileText className="h-5 w-5" />
-              文档预览
-            </CardTitle>
-            <CardDescription>
-              {selectedWordFile.name}
-            </CardDescription>
-          </CardHeader>
-          <CardContent>
-            <WordViewer file={selectedWordFile} />
-          </CardContent>
-        </Card>
-      )}
-
-      {/* 图片映射预览 */}
-      {Object.keys(imageMappings).length > 0 && (
-        <Card>
-          <CardHeader>
-            <CardTitle className="flex items-center gap-2">
-              <Image className="h-5 w-5" />
-              图片映射预览
-            </CardTitle>
-            <CardDescription>
-              共 {getTotalImages()} 张图片,点击缩略图查看大图
-            </CardDescription>
-          </CardHeader>
-          <CardContent>
-            <div className="space-y-4">
-              {Object.entries(imagePreviewUrls).map(([folder, images]) => (
-                <div key={folder} className="border rounded-lg p-4">
-                  <h4 className="font-semibold text-lg mb-3 flex items-center gap-2">
-                    <Package className="h-4 w-4" />
-                    文件夹 {folder} ({Object.keys(images).length} 张图片)
-                  </h4>
-                  <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
-                    {Object.entries(images).map(([imageName, previewUrl]) => (
-                      <div
-                        key={imageName}
-                        className="group relative cursor-pointer"
-                        onClick={() => openImagePreview(previewUrl, imageName, folder)}
-                      >
-                        <div className="aspect-square rounded-lg overflow-hidden border hover:border-blue-500 transition-colors">
-                          <img
-                            src={previewUrl}
-                            alt={imageName}
-                            className="w-full h-full object-cover"
-                          />
-                        </div>
-                        <div className="mt-1">
-                          <p className="text-xs text-center truncate text-gray-600 group-hover:text-blue-600">
-                            {imageName}
-                          </p>
-                        </div>
-                        <div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-opacity rounded-lg flex items-center justify-center">
-                          <ZoomIn className="h-6 w-6 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
-                        </div>
-                      </div>
-                    ))}
-                  </div>
-                </div>
-              ))}
-            </div>
-          </CardContent>
-        </Card>
-      )}
-
-      {/* 数据预览 */}
-      {excelData.length > 0 && (
-        <Card>
-          <CardHeader>
-            <CardTitle className="flex items-center gap-2">
-              <FileSpreadsheet className="h-5 w-5" />
-              Excel数据预览
-            </CardTitle>
-            <CardDescription>
-              显示前5行数据,共 {excelData.length} 行
-            </CardDescription>
-          </CardHeader>
-          <CardContent>
-            <div className="overflow-x-auto">
-              <table className="w-full text-sm">
-                <thead>
-                  <tr className="border-b">
-                    {Object.keys(excelData[0]).map(header => (
-                      <th key={header} className="text-left p-2 font-medium">
-                        {header}
-                      </th>
-                    ))}
-                  </tr>
-                </thead>
-                <tbody>
-                  {excelData.slice(0, 5).map((row, index) => (
-                    <tr key={index} className="border-b">
-                      {Object.values(row).map((value, valueIndex) => (
-                        <td key={valueIndex} className="p-2">
-                          {String(value)}
-                        </td>
-                      ))}
-                    </tr>
-                  ))}
-                </tbody>
-              </table>
-              {excelData.length > 5 && (
-                <p className="text-sm text-muted-foreground mt-2 text-center">
-                  还有 {excelData.length - 5} 行数据...
-                </p>
-              )}
-            </div>
-          </CardContent>
-        </Card>
-      )}
-
-      {/* 处理结果预览 */}
-      {processingResult && processingResult.generatedFiles.length > 0 && (
-        <Card>
-          <CardHeader>
-            <CardTitle className="flex items-center gap-2">
-              <FileText className="h-5 w-5" />
-              处理结果预览
-            </CardTitle>
-            <CardDescription>
-              预览第一个生成的文档
-            </CardDescription>
-          </CardHeader>
-          <CardContent>
-            <WordViewer file={processingResult.generatedFiles[0].content} />
-          </CardContent>
-        </Card>
-      )}
-
-      {/* 处理结果 */}
-      {processingResult && (
-        <Card>
-          <CardHeader>
-            <CardTitle className="flex items-center gap-2">
-              <CheckCircle className="h-5 w-5 text-green-500" />
-              处理完成
-            </CardTitle>
-            <CardDescription>
-              共生成 {processingResult.total} 个文档
-            </CardDescription>
-          </CardHeader>
-          <CardContent>
-            <div className="space-y-4">
-              <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>
-      )}
-
-      {/* 使用说明 */}
-      <Card>
-        <CardHeader>
-          <CardTitle>使用说明(增强版)</CardTitle>
-        </CardHeader>
-        <CardContent>
-          <div className="space-y-3 text-sm">
-            <div>
-              <h4 className="font-medium mb-1">1. 准备Word模板</h4>
-              <p className="text-muted-foreground">
-                使用 {'{字段名}'} 格式作为文本占位符,使用 {'{%图片名}'} 格式作为图片占位符
-              </p>
-            </div>
-            
-            <div>
-              <h4 className="font-medium mb-1">2. 准备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">
-                例如:模板中使用 {'{%logo}'},则图片文件应命名为 logo.jpg/png等
-              </p>
-            </div>
-          </div>
-        </CardContent>
-      </Card>
-
-      {/* 注意事项 */}
-      <Card>
-        <CardHeader>
-          <CardTitle className="flex items-center gap-2">
-            <AlertCircle className="h-5 w-5" />
-            注意事项
-          </CardTitle>
-        </CardHeader>
-        <CardContent>
-          <div className="space-y-2 text-sm">
-            <p>• Word模板中的字段名必须与Excel表头完全匹配</p>
-            <p>• 图片文件名必须与模板中的图片占位符匹配(不含扩展名)</p>
-            <p>• 文件夹序号必须与Excel行号对应(第1行对应文件夹"1")</p>
-            <p>• 如果图片不存在,对应位置将留空</p>
-            <p>• 支持jpg、jpeg、png、gif、bmp、webp格式图片</p>
-            <p>• 图片占位符使用 {'{%图片名%}'} 格式,如 {'{%logo%}'}</p>
-          </div>
-        </CardContent>
-      </Card>
-
-      {/* 图片查看器模态框 */}
-      <Dialog open={selectedImage !== null} onOpenChange={closeImagePreview}>
-        <DialogContent className="max-w-4xl max-h-[90vh] p-0">
-          <DialogHeader className="px-6 py-4 border-b">
-            <DialogTitle className="flex items-center justify-between">
-              <span>图片预览</span>
-              <div className="flex items-center gap-2 text-sm text-muted-foreground">
-                <span>{selectedImage?.folder} / {selectedImage?.name}</span>
-                <span>({currentImageIndex + 1} / {allImages.length})</span>
-              </div>
-            </DialogTitle>
-          </DialogHeader>
-          
-          {selectedImage && (
-            <div className="relative">
-              <div className="flex items-center justify-center p-4">
-                <img
-                  src={selectedImage.url}
-                  alt={selectedImage.name}
-                  className="max-w-full max-h-[70vh] object-contain"
-                />
-              </div>
-              
-              {/* 导航按钮 */}
-              {allImages.length > 1 && (
-                <>
-                  <Button
-                    variant="ghost"
-                    size="icon"
-                    className="absolute left-2 top-1/2 -translate-y-1/2"
-                    onClick={() => navigateImage('prev')}
-                  >
-                    <ChevronLeft className="h-5 w-5" />
-                  </Button>
-                  <Button
-                    variant="ghost"
-                    size="icon"
-                    className="absolute right-2 top-1/2 -translate-y-1/2"
-                    onClick={() => navigateImage('next')}
-                  >
-                    <ChevronRight className="h-5 w-5" />
-                  </Button>
-                </>
-              )}
-              
-              {/* 底部信息 */}
-              <div className="px-6 py-3 border-t bg-gray-50">
-                <div className="flex items-center justify-between text-sm">
-                  <div>
-                    <span className="font-medium">文件名:</span> {selectedImage.name}
-                  </div>
-                  <Button
-                    variant="ghost"
-                    size="sm"
-                    onClick={() => {
-                      const a = document.createElement('a');
-                      a.href = selectedImage.url;
-                      a.download = selectedImage.name;
-                      a.click();
-                    }}
-                  >
-                    <Download className="h-4 w-4 mr-2" />
-                    下载图片
-                  </Button>
-                </div>
-              </div>
-            </div>
-          )}
-        </DialogContent>
-      </Dialog>
-    </div>
-  );
-}

+ 2 - 1
src/client/home-shadcn/routes.tsx

@@ -8,11 +8,12 @@ import { MainLayout } from './layouts/MainLayout';
 import LoginPage from './pages/LoginPage';
 import RegisterPage from './pages/RegisterPage';
 import MemberPage from './pages/MemberPage';
+import WordPreview from './pages/WordPreview';
 
 export const router = createBrowserRouter([
   {
     path: '/',
-    element: <HomePage />
+    element: <WordPreview />
   },
   {
     path: '/login',