瀏覽代碼

✨ feat(templates): 添加文件预览功能

- 新增FilePreviewDialog组件,支持图片文件在线预览和其他类型文件提示
- 模板列表页添加预览按钮,实现文件预览功能
- 为Template实体添加preview_url字段,支持自定义预览链接
- 优化文件URL获取逻辑,增加错误处理和重试机制
- 数据库迁移添加templates表preview_url字段

🐛 fix(file): 修复文件URL获取失败问题

- 修改download.ts中获取文件URL的方式,增加try/catch错误处理
- 修复preview.ts中文件预览URL获取逻辑,确保URL正确生成
- 添加错误日志记录,便于排查文件服务异常问题
yourname 3 月之前
父節點
當前提交
fc99a6521c

+ 110 - 0
src/client/admin/components/FilePreviewDialog.tsx

@@ -0,0 +1,110 @@
+import React from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
+import { Button } from '@/client/components/ui/button';
+import { Download, X, File as FileIcon, Image as ImageIcon } from 'lucide-react';
+import { cn } from '@/client/lib/utils';
+
+export interface FilePreviewDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  file?: {
+    id: number;
+    name: string;
+    type: string;
+    fullUrl: string;
+    size?: number;
+  };
+}
+
+export const FilePreviewDialog: React.FC<FilePreviewDialogProps> = ({
+  open,
+  onOpenChange,
+  file,
+}) => {
+  if (!file) return null;
+
+  const getFileIcon = (fileType: string) => {
+    if (fileType.startsWith('image/')) {
+      return <ImageIcon className="h-16 w-16 text-gray-400" />;
+    }
+    if (fileType.startsWith('video/')) {
+      return <FileIcon className="h-16 w-16 text-blue-500" />;
+    }
+    if (fileType.startsWith('audio/')) {
+      return <FileIcon className="h-16 w-16 text-green-500" />;
+    }
+    if (fileType.includes('pdf')) {
+      return <FileIcon className="h-16 w-16 text-red-500" />;
+    }
+    if (fileType.includes('text') || fileType.includes('word') || fileType.includes('document')) {
+      return <FileIcon className="h-16 w-16 text-blue-600" />;
+    }
+    return <FileIcon className="h-16 w-16 text-gray-400" />;
+  };
+
+  const formatFileSize = (bytes?: number) => {
+    if (!bytes) return '未知大小';
+    if (bytes < 1024) return `${bytes} B`;
+    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+  };
+
+  const handleDownload = () => {
+    window.open(file.fullUrl, '_blank');
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
+        <DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
+          <DialogTitle className="text-lg">文件预览 - {file.name}</DialogTitle>
+          <Button
+            variant="ghost"
+            size="icon"
+            onClick={() => onOpenChange(false)}
+            className="h-8 w-8"
+          >
+            <X className="h-4 w-4" />
+          </Button>
+        </DialogHeader>
+
+        <div className="flex-1 overflow-auto">
+          <div className="flex flex-col items-center space-y-4">
+            {file.type.startsWith('image/') ? (
+              <div className="w-full flex justify-center">
+                <img
+                  src={file.fullUrl}
+                  alt={file.name}
+                  className="max-w-full max-h-[60vh] object-contain rounded-lg border"
+                />
+              </div>
+            ) : (
+              <div className="flex flex-col items-center justify-center p-8 border rounded-lg bg-gray-50 min-h-[300px] w-full">
+                {getFileIcon(file.type)}
+                <p className="text-lg font-medium mt-4 text-gray-700">{file.name}</p>
+                <p className="text-sm text-gray-500 mt-2">
+                  {file.type} • {formatFileSize(file.size)}
+                </p>
+                <p className="text-sm text-gray-400 mt-4 text-center">
+                  此文件类型不支持在线预览,请下载后查看
+                </p>
+              </div>
+            )}
+          </div>
+        </div>
+
+        <div className="flex justify-between items-center pt-4 border-t">
+          <div className="text-sm text-gray-500">
+            {file.type} • {formatFileSize(file.size)}
+          </div>
+          <Button onClick={handleDownload} className="gap-2">
+            <Download className="h-4 w-4" />
+            下载文件
+          </Button>
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+};
+
+export default FilePreviewDialog;

+ 30 - 0
src/client/admin/pages/Templates.tsx

@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
 import { format } from 'date-fns';
 import { Plus, Search, Edit, Trash2, Download, Eye } from 'lucide-react';
 import { templateClient } from '@/client/api';
+import FilePreviewDialog from '@/client/admin/components/FilePreviewDialog';
 import type { InferRequestType, InferResponseType } from 'hono/client';
 import { Button } from '@/client/components/ui/button';
 import { Input } from '@/client/components/ui/input';
@@ -45,6 +46,8 @@ export const TemplatesPage = () => {
   const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
   const [templateToDelete, setTemplateToDelete] = useState<number | null>(null);
   const [isCreateForm, setIsCreateForm] = useState(true);
+  const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
+  const [previewFile, setPreviewFile] = useState<any>(null);
   
   const createForm = useForm<CreateTemplateFormData>({
     resolver: zodResolver(createTemplateFormSchema),
@@ -195,6 +198,16 @@ export const TemplatesPage = () => {
     }
   };
 
+  // 处理预览模板文件
+  const handlePreviewTemplate = (template: TemplateResponse) => {
+    if (!template.file) {
+      toast.warning('该模板没有关联文件');
+      return;
+    }
+    setPreviewFile(template.file);
+    setPreviewDialogOpen(true);
+  };
+
   // 渲染加载骨架
   if (isLoading) {
     return (
@@ -308,10 +321,19 @@ export const TemplatesPage = () => {
                     </TableCell>
                     <TableCell className="text-right">
                       <div className="flex justify-end gap-2">
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => handlePreviewTemplate(template)}
+                          title="预览文件"
+                        >
+                          <Eye className="h-4 w-4" />
+                        </Button>
                         <Button
                           variant="ghost"
                           size="icon"
                           onClick={() => handleEditTemplate(template)}
+                          title="编辑模板"
                         >
                           <Edit className="h-4 w-4" />
                         </Button>
@@ -319,6 +341,7 @@ export const TemplatesPage = () => {
                           variant="ghost"
                           size="icon"
                           onClick={() => handleDeleteTemplate(template.id)}
+                          title="删除模板"
                         >
                           <Trash2 className="h-4 w-4" />
                         </Button>
@@ -617,6 +640,13 @@ export const TemplatesPage = () => {
           </DialogFooter>
         </DialogContent>
       </Dialog>
+      
+      {/* 文件预览对话框 */}
+      <FilePreviewDialog
+        open={previewDialogOpen}
+        onOpenChange={setPreviewDialogOpen}
+        file={previewFile}
+      />
     </div>
   );
 };

+ 1 - 0
src/client/home/pages/TemplateSquare.tsx

@@ -19,6 +19,7 @@ interface Template {
   category: string;
   isFree: number;
   downloadCount: number;
+  previewUrl?: string;
   file: {
     id: number;
     name: string;

+ 7 - 1
src/server/api/public/templates/[id]/download.ts

@@ -87,7 +87,13 @@ const app = new OpenAPIHono<AuthContext>().openapi(routeDef, async (c) => {
     await service.incrementDownloadCount(templateId);
 
     // 获取下载URL
-    const downloadUrl = template.file.fullUrl;
+    let downloadUrl: string;
+    try {
+      downloadUrl = await template.file.fullUrl;
+    } catch (error) {
+      console.error('获取文件下载URL失败:', error);
+      return c.json({ code: 500, message: '获取文件下载URL失败' }, 500);
+    }
     const fileName = template.file.name;
 
     return c.json({

+ 12 - 1
src/server/api/public/templates/[id]/preview.ts

@@ -71,7 +71,18 @@ const app = new OpenAPIHono().openapi(routeDef, async (c) => {
     }
 
     // 构建预览URL(这里可以集成Office Online或其他预览服务)
-    const previewUrl = template.previewUrl || template.file.fullUrl;
+    let previewUrl = template.previewUrl;
+    
+    // 如果没有配置预览URL,使用文件URL
+    if (!previewUrl) {
+      try {
+        previewUrl = await template.file.fullUrl;
+      } catch (error) {
+        console.error('获取文件URL失败:', error);
+        // 如果获取文件URL失败,返回错误信息
+        return c.json({ code: 500, message: '获取文件预览URL失败' }, 500);
+      }
+    }
 
     return c.json({
       previewUrl,

+ 9 - 0
src/server/migrations/002-add-preview-url-to-templates.sql

@@ -0,0 +1,9 @@
+-- 为templates表添加preview_url字段
+ALTER TABLE templates 
+ADD COLUMN preview_url VARCHAR(500) NULL COMMENT '预览URL' AFTER is_deleted;
+
+-- 更新现有记录的preview_url字段(可选)
+-- UPDATE templates SET preview_url = CONCAT('https://view.officeapps.live.com/op/embed.aspx?src=', 
+--   (SELECT CONCAT('https://minio.example.com/d8dai/', f.path) 
+--    FROM file f WHERE f.id = templates.file_id))
+-- WHERE preview_url IS NULL;

+ 3 - 0
src/server/modules/templates/template.entity.ts

@@ -34,6 +34,9 @@ export class Template {
   @Column({ name: 'is_deleted', type: 'tinyint', default: 0, comment: '是否删除:0-未删除,1-已删除' })
   isDeleted!: number;
 
+  @Column({ name: 'preview_url', type: 'varchar', length: 500, nullable: true, comment: '预览URL' })
+  previewUrl!: string | null;
+
   @CreateDateColumn({ name: 'created_at', comment: '创建时间' })
   createdAt!: Date;
 

+ 4 - 0
src/server/modules/templates/template.schema.ts

@@ -43,6 +43,10 @@ export const TemplateSchema = z.object({
     description: '是否删除:0-未删除,1-已删除',
     example: DeleteStatus.NOT_DELETED
   }),
+  previewUrl: z.string().url().nullable().openapi({
+    description: '预览URL',
+    example: 'https://example.com/preview/template.docx'
+  }),
   createdAt: z.coerce.date<Date>('创建时间格式不正确').openapi({
     description: '创建时间',
     example: '2024-01-15T10:30:00Z'