| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288 |
- import React from 'react';
- import { useQuery } from '@tanstack/react-query';
- import { fileClient } from '@/client/api';
- import type { InferResponseType } from 'hono/client';
- import { Eye, FileText, Image as ImageIcon } from 'lucide-react';
- import { Skeleton } from '@/client/components/ui/skeleton';
- import { Card, CardContent } from '@/client/components/ui/card';
- import { Badge } from '@/client/components/ui/badge';
- import { Button } from '@/client/components/ui/button';
- import { toast } from 'sonner';
- // 定义文件类型
- type FileItem = InferResponseType<typeof fileClient[':id']['$get'], 200>;
- interface FilePreviewItemProps {
- file: FileItem;
- size: 'small' | 'medium' | 'large';
- index?: number;
- total?: number;
- onClick?: (file: FileItem) => void;
- }
- const FilePreviewItem: React.FC<FilePreviewItemProps> = ({
- file,
- size,
- index,
- total,
- onClick
- }) => {
- const getSize = () => {
- switch (size) {
- case 'small':
- return { width: 45, height: 45 };
- case 'medium':
- return { width: 80, height: 80 };
- case 'large':
- return { width: 120, height: 120 };
- default:
- return { width: 80, height: 80 };
- }
- };
- const { width, height } = getSize();
- const isImage = file.type?.startsWith('image/');
- const isVideo = file.type?.startsWith('video/');
- const handlePreview = () => {
- if (onClick) {
- onClick(file);
- } else if (isImage || isVideo) {
- window.open(file.fullUrl, '_blank');
- } else {
- toast.warning('该文件类型不支持预览');
- }
- };
- // 获取文件图标
- const getFileIcon = (type?: string) => {
- if (!type) return <FileText className="h-8 w-8 text-gray-400" />;
-
- if (type.startsWith('image/')) {
- return <ImageIcon className="h-8 w-8 text-blue-500" />;
- } else if (type.startsWith('video/')) {
- return <FileText className="h-8 w-8 text-red-500" />;
- } else if (type.startsWith('audio/')) {
- return <FileText className="h-8 w-8 text-purple-500" />;
- } else if (type.includes('pdf')) {
- return <FileText className="h-8 w-8 text-red-500" />;
- } else if (type.includes('word')) {
- return <FileText className="h-8 w-8 text-blue-600" />;
- } else if (type.includes('excel') || type.includes('sheet')) {
- return <FileText className="h-8 w-8 text-green-500" />;
- } else {
- return <FileText className="h-8 w-8 text-gray-500" />;
- }
- };
- // 获取文件类型标签
- const getFileTypeBadge = (type: string) => {
- if (type.startsWith('image/')) {
- return { text: '图片', color: 'bg-blue-100 text-blue-800' };
- } else if (type.startsWith('video/')) {
- return { text: '视频', color: 'bg-red-100 text-red-800' };
- } else if (type.startsWith('audio/')) {
- return { text: '音频', color: 'bg-purple-100 text-purple-800' };
- } else if (type.includes('pdf')) {
- return { text: 'PDF', color: 'bg-red-100 text-red-800' };
- } else if (type.includes('word')) {
- return { text: '文档', color: 'bg-blue-100 text-blue-800' };
- } else if (type.includes('excel') || type.includes('sheet')) {
- return { text: '表格', color: 'bg-green-100 text-green-800' };
- } else {
- return { text: '文件', color: 'bg-gray-100 text-gray-800' };
- }
- };
- return (
- <div
- className="relative group cursor-pointer"
- style={{ width, height }}
- onClick={handlePreview}
- >
- {/* 文件预览容器 */}
- <div className={`
- relative overflow-hidden rounded-lg border transition-all duration-200
- ${isImage ? 'border-gray-200' : 'border-gray-300 bg-gray-50'}
- group-hover:shadow-md group-hover:border-primary
- `}>
- {isImage ? (
- // 图片预览
- <img
- src={file.fullUrl}
- alt={file.name}
- className="w-full h-full object-cover"
- loading="lazy"
- />
- ) : (
- // 非图片文件预览
- <div className="w-full h-full flex flex-col items-center justify-center">
- {getFileIcon(file.type)}
- <span className="text-xs text-center mt-1 px-1 truncate max-w-full">
- {file.name.length > 8 ? `${file.name.substring(0, 6)}...` : file.name}
- </span>
- </div>
- )}
- {/* 悬停遮罩 */}
- <div className={`
- absolute inset-0 bg-black/60 flex flex-col items-center justify-center
- opacity-0 group-hover:opacity-100 transition-opacity duration-200
- text-white text-xs
- `}>
- <Eye className="h-4 w-4 mb-1" />
- <span>{isImage || isVideo ? '预览' : '查看'}</span>
- </div>
- {/* 序号标记 */}
- {index !== undefined && total !== undefined && total > 1 && (
- <div className={`
- absolute top-1 right-1 bg-black/70 text-white text-xs
- px-1.5 py-0.5 rounded
- `}>
- {index + 1}
- </div>
- )}
- </div>
- {/* 文件类型标签 */}
- <Badge
- className={`
- absolute bottom-1 left-1 text-xs px-1 py-0
- ${getFileTypeBadge(file.type).color}
- `}
- >
- {getFileTypeBadge(file.type).text}
- </Badge>
- </div>
- );
- };
- interface FilePreviewProps {
- fileIds?: number[];
- files?: any[];
- maxCount?: number;
- size?: 'small' | 'medium' | 'large';
- showCount?: boolean;
- onFileClick?: (file: FileItem) => void;
- className?: string;
- }
- const FilePreview: React.FC<FilePreviewProps> = ({
- fileIds = [],
- files = [],
- maxCount = 6,
- size = 'medium',
- showCount = true,
- onFileClick,
- className = '',
- }) => {
- // 合并文件ID和文件对象
- const allFileIds = [...fileIds, ...(files?.map(f => f.id) || [])];
- const uniqueFileIds = [...new Set(allFileIds)].filter(Boolean);
- // 使用 React Query 查询文件详情
- const { data: fileDetails, isLoading, error } = useQuery({
- queryKey: ['files', uniqueFileIds],
- queryFn: async () => {
- if (uniqueFileIds.length === 0) return [];
-
- const promises = uniqueFileIds.map(async (id) => {
- try {
- const response = await fileClient[':id']['$get']({ param: { id: id.toString() } });
- if (response.ok) {
- return response.json();
- }
- return null;
- } catch (error) {
- console.error(`获取文件 ${id} 详情失败:`, error);
- return null;
- }
- });
-
- const results = await Promise.all(promises);
- return results.filter(Boolean) as FileItem[];
- },
- enabled: uniqueFileIds.length > 0,
- staleTime: 5 * 60 * 1000, // 5分钟
- gcTime: 10 * 60 * 1000, // 10分钟
- });
- // 加载状态
- if (isLoading) {
- return (
- <div className={`flex justify-center py-8 ${className}`}>
- <div className="space-y-2">
- <div className="flex gap-2 justify-center">
- {[...Array(Math.min(maxCount, 3))].map((_, i) => (
- <Skeleton key={i} className={`rounded-lg ${size === 'small' ? 'w-12 h-12' : size === 'medium' ? 'w-20 h-20' : 'w-24 h-24'}`} />
- ))}
- </div>
- <p className="text-sm text-gray-500 text-center">加载中...</p>
- </div>
- </div>
- );
- }
- // 错误状态
- if (error) {
- return (
- <div className={`flex flex-col items-center justify-center py-8 ${className}`}>
- <FileText className="h-12 w-12 text-gray-400 mb-2" />
- <p className="text-sm text-gray-600">加载图片失败</p>
- <Button
- variant="outline"
- size="sm"
- className="mt-2"
- onClick={() => {
- // 这里可以添加重试逻辑
- toast.info('请刷新页面重试');
- }}
- >
- 重试
- </Button>
- </div>
- );
- }
- const displayFiles = fileDetails?.slice(0, maxCount) || [];
- const remainingCount = Math.max(0, (fileDetails?.length || 0) - maxCount);
- // 空状态
- if (displayFiles.length === 0) {
- return (
- <div className={`flex flex-col items-center justify-center py-6 ${className}`}>
- <FileText className="h-12 w-12 text-gray-400 mb-2" />
- <p className="text-sm text-gray-600">暂无图片</p>
- </div>
- );
- }
- return (
- <div className={className}>
- <div className="flex flex-wrap gap-2 items-start">
- {displayFiles.map((file, index) => (
- <FilePreviewItem
- key={file.id}
- file={file}
- size={size}
- index={index}
- total={displayFiles.length}
- onClick={onFileClick}
- />
- ))}
- </div>
-
- {/* 剩余数量提示 */}
- {showCount && remainingCount > 0 && (
- <div className="mt-2 text-sm text-gray-500">
- 还有 {remainingCount} 个文件未显示
- </div>
- )}
- </div>
- );
- };
- // 导出组件和类型
- export default FilePreview;
- export type { FilePreviewProps };
|