Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑

This commit is contained in:
2025-11-13 15:24:54 +08:00
commit 42be39b343
249 changed files with 38843 additions and 0 deletions

View File

@@ -0,0 +1,968 @@
'use client';
import { useCallback, useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import useEmblaCarousel from 'embla-carousel-react';
import { useGesture } from '@use-gesture/react';
import { motion, useMotionValue, useSpring, animate, useMotionValueEvent } from 'framer-motion';
import {
X,
FileText,
FileArchive,
FileSpreadsheet,
Image as ImageIcon,
Video,
Music,
File,
Download,
ZoomIn,
ZoomOut,
RotateCw,
ChevronLeft,
ChevronRight,
Maximize2,
HelpCircle,
ArrowLeft,
ArrowRight,
Plus,
Minus,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Kbd } from '@/components/ui/kbd';
import { cn, downloadFromFile } from '@/lib/utils';
import { formatBytes } from '@/lib/format';
// ==================== 工具函数 ====================
/**
* 根据文件 MIME 类型返回对应的图标
*/
export const getFileIcon = (type: string | undefined) => {
if (!!type) {
if (type.startsWith('image/')) return <ImageIcon className="size-6" />;
if (type.startsWith('video/')) return <Video className="size-6" />;
if (type.startsWith('audio/')) return <Music className="size-6" />;
if (type.includes('pdf')) return <FileText className="size-6" />;
if (type.includes('word') || type.includes('doc')) return <FileText className="size-6" />;
if (type.includes('excel') || type.includes('sheet')) return <FileSpreadsheet className="size-6" />;
if (type.includes('zip') || type.includes('rar') || type.includes('7z')) return <FileArchive className="size-6" />;
}
return <File className="size-6" />;
};
// ==================== 类型定义 ====================
/**
* 文件预览项的基础接口
*/
export interface FilePreviewItem {
/** 文件唯一标识 */
id: string;
/** 文件名称 */
name: string;
/** 文件大小(字节) */
size: number;
/** 文件 MIME 类型 */
type?: string;
/** 预览 URL用于图片预览 */
preview?: string;
/** 上传进度0-100undefined 表示未上传或已完成 */
progress?: number;
/** 浏览器 File 对象(可选,用于下载功能) */
file?: File;
}
/**
* 文件卡片预览组件的属性
*/
export interface FileCardPreviewProps {
/** 文件列表 */
files: FilePreviewItem[];
/** 外层容器的自定义类名 */
className?: string;
/** 网格容器的自定义类名 */
gridClassName?: string;
/** 是否禁用操作按钮 */
disabled?: boolean;
/** 是否显示下载按钮 */
showDownload?: boolean;
/** 是否显示删除按钮 */
showRemove?: boolean;
/** 是否显示文件信息(文件名和大小) */
showFileInfo?: boolean;
/** 删除文件的回调函数 */
onRemove?: (id: string, file: FilePreviewItem) => void;
/** 下载文件的回调函数(如果不提供,将使用默认的 downloadFromFile */
onDownload?: (id: string, file: FilePreviewItem) => void;
/** 点击文件卡片的回调函数 */
onClick?: (id: string, file: FilePreviewItem) => void;
}
// ==================== 文件卡片预览组件 ====================
/**
* 文件卡片预览组件
*
* 用于以卡片网格形式展示文件列表,支持图片预览、文件信息显示、下载和删除操作。
*
* 特性:
* - 响应式网格布局(移动端 1 列,平板 2 列,桌面 3 列)
* - 图片文件显示预览图,其他文件显示对应图标
* - 支持上传进度显示(圆形进度条)
* - PC 端悬停显示操作按钮和文件信息
* - 移动端点击激活显示操作按钮和文件信息
* - 可自定义是否显示下载、删除按钮和文件信息
*
* @example
* ```tsx
* <FileCardPreview
* files={files}
* showDownload
* showRemove
* onRemove={(id) => console.log('Remove', id)}
* />
* ```
*/
export function FileCardPreview({
files,
className,
gridClassName,
disabled = false,
showDownload = true,
showRemove = true,
showFileInfo = true,
onRemove,
onDownload,
onClick,
}: FileCardPreviewProps) {
const [activeFileId, setActiveFileId] = useState<string | null>(null);
const [carouselOpen, setCarouselOpen] = useState(false);
const [carouselIndex, setCarouselIndex] = useState(0);
const handleRemove = useCallback(
(id: string, e: React.MouseEvent) => {
e.stopPropagation();
const fileItem = files.find((f) => f.id === id);
if (fileItem && onRemove) {
onRemove(id, fileItem);
}
setActiveFileId(null);
},
[files, onRemove]
);
const handleDownload = useCallback(
(id: string, e: React.MouseEvent) => {
e.stopPropagation();
const fileItem = files.find((f) => f.id === id);
if (fileItem) {
if (onDownload) {
onDownload(id, fileItem);
} else if (fileItem.file) {
downloadFromFile(fileItem.file);
}
}
},
[files, onDownload]
);
const handlePreview = useCallback(
(id: string, e: React.MouseEvent) => {
e.stopPropagation();
const index = files.findIndex((f) => f.id === id);
if (index !== -1) {
setCarouselIndex(index);
setCarouselOpen(true);
}
},
[files]
);
const handleFileClick = useCallback(
(id: string) => {
const fileItem = files.find((f) => f.id === id);
if (fileItem && onClick) {
onClick(id, fileItem);
}
setActiveFileId((prev) => (prev === id ? null : id));
},
[files, onClick]
);
if (files.length === 0) {
return null;
}
return (
<div className={cn('w-full', className)}>
<div
className={cn(
'grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3',
gridClassName
)}
>
{files.map((fileItem) => (
<div
key={fileItem.id}
className="group relative aspect-square cursor-pointer p-1"
onClick={() => handleFileClick(fileItem.id)}
>
<div
className={cn(
'relative h-full overflow-hidden rounded-lg border bg-card transition-all',
activeFileId === fileItem.id
? 'border-primary ring-2 ring-primary ring-offset-2 shadow-lg'
: 'md:group-hover:border-primary/50 md:group-hover:shadow-md'
)}
>
{/* 文件预览区域 */}
{fileItem.type?.startsWith('image/') && fileItem.preview ? (
<img
src={fileItem.preview}
alt={fileItem.name}
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
) : (
<div className="flex h-full items-center justify-center bg-muted text-muted-foreground">
{getFileIcon(fileItem.type)}
</div>
)}
{/* 上传进度指示器 */}
{fileItem.progress !== undefined && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="relative size-16">
<svg className="size-full -rotate-90" viewBox="0 0 100 100">
{/* 背景圆环 */}
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke="currentColor"
strokeWidth="8"
className="text-white/20"
/>
{/* 进度圆环 */}
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke="currentColor"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 45}`}
strokeDashoffset={`${2 * Math.PI * 45 * (1 - fileItem.progress / 100)}`}
className="text-primary transition-all duration-300"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-semibold text-white">
{Math.round(fileItem.progress)}%
</span>
</div>
</div>
</div>
)}
{/* PC端悬停或移动端点击后显示的操作按钮 */}
{(showDownload || showRemove || fileItem.type?.startsWith('image/')) && (
<div
className={cn(
'absolute inset-0 flex items-center justify-center gap-2 rounded-lg bg-black/50 transition-opacity',
activeFileId === fileItem.id ? 'opacity-100' : 'opacity-0 md:group-hover:opacity-100 pointer-events-none md:group-hover:pointer-events-auto'
)}
>
{fileItem.type?.startsWith('image/') && (
<Button
onClick={(e) => handlePreview(fileItem.id, e)}
type="button"
variant="secondary"
size="icon"
disabled={disabled}
className={cn(
'size-7',
activeFileId === fileItem.id && 'pointer-events-auto'
)}
title="预览"
>
<ZoomIn className="size-4" />
</Button>
)}
{showDownload && (fileItem.file || onDownload) && (
<Button
onClick={(e) => handleDownload(fileItem.id, e)}
type="button"
variant="secondary"
size="icon"
disabled={disabled}
className={cn(
'size-7',
activeFileId === fileItem.id && 'pointer-events-auto'
)}
title="下载"
>
<Download className="size-4" />
</Button>
)}
{showRemove && onRemove && (
<Button
onClick={(e) => handleRemove(fileItem.id, e)}
type="button"
variant="secondary"
size="icon"
disabled={disabled}
className={cn(
'size-7',
activeFileId === fileItem.id && 'pointer-events-auto'
)}
title="删除"
>
<X className="size-4" />
</Button>
)}
</div>
)}
{/* PC端悬停或移动端点击后显示的文件信息 */}
{showFileInfo && (
<div
className={cn(
'absolute bottom-0 left-0 right-0 rounded-b-lg bg-gradient-to-t from-black/80 via-black/60 to-transparent p-2 pt-8 text-white transition-opacity',
activeFileId === fileItem.id ? 'opacity-100' : 'opacity-0 md:group-hover:opacity-100'
)}
>
<p className="truncate text-xs font-medium" title={fileItem.name}>
{fileItem.name}
</p>
<p className="text-xs text-gray-300">
{formatBytes(fileItem.size)}
</p>
</div>
)}
</div>
</div>
))}
</div>
{/* 轮播预览 */}
<FileCarouselPreview
files={files}
initialIndex={carouselIndex}
open={carouselOpen}
onClose={() => setCarouselOpen(false)}
onDownload={onDownload}
/>
</div>
);
}
// ==================== 轮播预览组件 ====================
/**
* 文件轮播预览组件的属性
*/
export interface FileCarouselPreviewProps {
/** 文件列表 */
files: FilePreviewItem[];
/** 初始显示的文件索引 */
initialIndex?: number;
/** 是否打开预览 */
open: boolean;
/** 关闭预览的回调 */
onClose: () => void;
/** 下载文件的回调函数 */
onDownload?: (id: string, file: FilePreviewItem) => void;
}
/**
* 文件轮播预览组件
*
* 全屏图片查看器,支持缩放、旋转、拖拽、键盘导航等功能。
*
* 特性:
* - Portal 渲染(避免 z-index 问题)
* - ESC 键关闭,缩放、旋转、拖拽均有快捷键支持
* - 缩放(按钮 + 滚轮)、旋转、拖拽移动(放大后)
* - 下载图片
* - 平滑动画
* - 响应式设计
* - 触摸友好
* - ARIA 属性
* - 键盘导航
*
* @example
* ```tsx
* const [open, setOpen] = useState(false);
* const [index, setIndex] = useState(0);
*
* <FileCarouselPreview
* files={files}
* initialIndex={index}
* open={open}
* onClose={() => setOpen(false)}
* />
* ```
*/
export function FileCarouselPreview({
files,
initialIndex = 0,
open,
onClose,
onDownload,
}: FileCarouselPreviewProps) {
const [mounted, setMounted] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({
startIndex: initialIndex,
loop: false,
duration: 20, // 设置切换动画时长(毫秒)
});
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [rotation, setRotation] = useState(0);
const [showHelp, setShowHelp] = useState(false);
const [currentScale, setCurrentScale] = useState(1);
// 使用 framer-motion 的 motion values 和 spring 动画
const scaleMotion = useMotionValue(1);
const xMotion = useMotionValue(0);
const yMotion = useMotionValue(0);
// 使用 useSpring 包装 motion values添加弹性物理效果
const scale = useSpring(scaleMotion, { stiffness: 300, damping: 30 });
const x = useSpring(xMotion, { stiffness: 300, damping: 30 });
const y = useSpring(yMotion, { stiffness: 300, damping: 30 });
// 监听 scale 变化,更新 state 以触发按钮状态更新
useMotionValueEvent(scale, "change", (latest) => {
setCurrentScale(latest);
});
const imageRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const thumbnailRef = useRef<HTMLDivElement>(null);
// 确保组件在客户端挂载
useEffect(() => {
setMounted(true);
}, []);
// 监听 embla 选中事件
useEffect(() => {
if (!emblaApi) return;
const onSelect = () => {
setCurrentIndex(emblaApi.selectedScrollSnap());
// 切换图片时重置变换
animate(scaleMotion, 1, { duration: 0.2 });
animate(xMotion, 0, { duration: 0.2 });
animate(yMotion, 0, { duration: 0.2 });
setRotation(0);
};
emblaApi.on('select', onSelect);
onSelect();
return () => {
emblaApi.off('select', onSelect);
};
}, [emblaApi, scaleMotion, xMotion, yMotion]);
// 禁用背景滚动
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}
}, [open]);
// 缩放功能
const handleZoomIn = useCallback(() => {
const current = scaleMotion.get();
animate(scaleMotion, Math.min(current + 0.25, 5), { duration: 0.2 });
}, [scaleMotion]);
const handleZoomOut = useCallback(() => {
const current = scaleMotion.get();
animate(scaleMotion, Math.max(current - 0.25, 0.5), { duration: 0.2 });
}, [scaleMotion]);
const handleRotate = useCallback(() => {
setRotation((prev) => (prev + 90) % 360);
}, []);
const handleReset = useCallback(() => {
animate(scaleMotion, 1, { duration: 0.2 });
animate(xMotion, 0, { duration: 0.2 });
animate(yMotion, 0, { duration: 0.2 });
setRotation(0);
}, [scaleMotion, xMotion, yMotion]);
// 键盘事件处理
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
// 阻止事件冒泡,避免关闭父对话框
switch (e.key) {
case 'Escape':
e.preventDefault();
e.stopPropagation();
onClose();
break;
case 'ArrowLeft':
e.preventDefault();
e.stopPropagation();
emblaApi?.scrollPrev();
break;
case 'ArrowRight':
e.preventDefault();
e.stopPropagation();
emblaApi?.scrollNext();
break;
case '+':
case '=':
e.preventDefault();
e.stopPropagation();
handleZoomIn();
break;
case '-':
e.preventDefault();
e.stopPropagation();
handleZoomOut();
break;
case 'r':
case 'R':
e.preventDefault();
e.stopPropagation();
handleRotate();
break;
case '0':
e.preventDefault();
e.stopPropagation();
handleReset();
break;
case '?':
e.preventDefault();
e.stopPropagation();
setShowHelp((prev) => !prev);
break;
}
};
// 使用 capture 阶段捕获事件,优先级更高
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [open, emblaApi, onClose, handleZoomIn, handleZoomOut, handleRotate, handleReset]);
// 使用 use-gesture 处理所有手势
// 注意:必须使用 target 选项才能正确使用 preventDefault
useGesture(
{
// 拖拽手势 - 用于移动图片或切换图片
onDrag: ({ offset: [ox, oy], active }) => {
const currentScale = scale.get();
// 如果图片放大了,拖拽用于移动图片
if (currentScale > 1) {
x.set(ox);
y.set(oy);
}
else if (!active) {
// 重置位置切换的逻辑embla-carousel-react处理这里不用管
animate(x, 0, { duration: 0.3 });
animate(y, 0, { duration: 0.3 });
}
},
// 滚轮手势 - 用于缩放
onWheel: ({ event, delta: [, dy], last }) => {
// 避免在最后一个事件中访问 eventdebounced
if (!last && event) {
event.preventDefault();
}
const currentScale = scaleMotion.get();
const scaleDelta = dy > 0 ? -0.1 : 0.1;
const newScale = Math.max(0.5, Math.min(5, currentScale + scaleDelta));
if (!last) {
scaleMotion.set(newScale);
}
},
// 双指缩放手势 - 移动端
onPinch: ({ offset: [s], origin: [ox, oy], first, memo, last }) => {
if (first) {
const currentScale = scaleMotion.get();
const currentX = xMotion.get();
const currentY = yMotion.get();
return [currentScale, currentX, currentY, ox, oy];
}
const [initialScale, initialX, initialY, initialOx, initialOy] = memo;
const newScale = Math.max(0.5, Math.min(5, s));
// 计算缩放中心偏移
if (imageRef.current) {
const rect = imageRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// 相对于图片中心的偏移
const offsetX = ox - centerX;
const offsetY = oy - centerY;
// 根据缩放调整位置,使缩放中心保持在手指位置
const scaleRatio = newScale / initialScale;
xMotion.set(initialX + offsetX * (1 - scaleRatio));
yMotion.set(initialY + offsetY * (1 - scaleRatio));
}
if (!last) {
scaleMotion.set(newScale);
}
return memo;
},
},
{
target: imageRef,
drag: {
from: () => [x.get(), y.get()],
bounds: (state) => {
const currentScale = scale.get();
if (currentScale <= 1) {
// 未放大时,允许左右拖拽切换
return { left: -200, right: 200, top: 0, bottom: 0 };
}
// 放大时不限制边界
return { left: -Infinity, right: Infinity, top: -Infinity, bottom: Infinity };
},
},
pinch: {
scaleBounds: { min: 0.5, max: 5 },
eventOptions: { passive: false },
},
wheel: {
eventOptions: { passive: false },
},
}
);
// 缩略图容器的拖动手势
useGesture(
{
onDrag: ({ movement: [mx], memo = thumbnailRef.current?.scrollLeft ?? 0 }) => {
if (thumbnailRef.current) {
thumbnailRef.current.scrollLeft = memo - mx;
}
return memo;
},
},
{
target: thumbnailRef,
drag: {
axis: 'x',
filterTaps: true,
},
}
);
// 下载当前文件
const handleDownloadCurrent = useCallback(() => {
const currentFile = files[currentIndex];
if (currentFile) {
if (onDownload) {
onDownload(currentFile.id, currentFile);
} else if (currentFile.file) {
downloadFromFile(currentFile.file);
}
}
}, [files, currentIndex, onDownload]);
if (!mounted || !open) return null;
const currentFile = files[currentIndex];
const canScrollPrev = emblaApi?.canScrollPrev() ?? false;
const canScrollNext = emblaApi?.canScrollNext() ?? false;
return createPortal(
<div
ref={containerRef}
className="fixed inset-0 z-70 flex items-center justify-center bg-black/95 pointer-events-auto"
onKeyDown={(e) => {
// 阻止键盘事件冒泡到父组件
e.stopPropagation();
}}
role="dialog"
aria-modal="true"
aria-label="文件预览"
>
{/* 顶部工具栏 */}
<div
className="absolute top-0 left-0 right-0 z-10 flex items-start gap-2 p-4 bg-gradient-to-b from-black/50 to-transparent pointer-events-none"
>
<span className="text-sm font-medium text-white shrink-0">
{currentIndex + 1} / {files.length}
</span>
{currentFile && (
<span className="text-sm text-gray-300 break-words flex-1 min-w-0">
{currentFile.name}
</span>
)}
<div className="flex items-center gap-2 shrink-0 pointer-events-auto">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
setShowHelp((prev) => !prev);
}}
className="text-white hover:bg-white/20"
title="快捷键帮助 (?)"
>
<HelpCircle className='size-5'/>
</Button>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
onClose();
}}
className="text-white hover:bg-white/20"
title="关闭 (ESC)"
>
<X className="size-5" />
</Button>
</div>
</div>
{/* 快捷键帮助面板 */}
{showHelp && (
<div
className="absolute top-20 right-4 z-20 bg-black/90 text-white p-4 rounded-lg text-sm space-y-3 max-w-xs pointer-events-none"
>
<h3 className="font-semibold mb-2"></h3>
<div className="space-y-2">
<div className="flex justify-between items-center gap-4">
<Kbd className="bg-white/10 text-white border border-white/20">ESC</Kbd>
<span className="text-gray-300"></span>
</div>
<div className="flex justify-between items-center gap-4">
<div className="flex gap-1">
<Kbd className="bg-white/10 text-white border border-white/20">
<ArrowLeft className="size-3" />
</Kbd>
<Kbd className="bg-white/10 text-white border border-white/20">
<ArrowRight className="size-3" />
</Kbd>
</div>
<span className="text-gray-300"></span>
</div>
<div className="flex justify-between items-center gap-4">
<div className="flex gap-1">
<Kbd className="bg-white/10 text-white border border-white/20">
<Plus className="size-3" />
</Kbd>
<Kbd className="bg-white/10 text-white border border-white/20">
<Minus className="size-3" />
</Kbd>
</div>
<span className="text-gray-300"></span>
</div>
<div className="flex justify-between items-center gap-4">
<Kbd className="bg-white/10 text-white border border-white/20">R</Kbd>
<span className="text-gray-300"></span>
</div>
<div className="flex justify-between items-center gap-4">
<Kbd className="bg-white/10 text-white border border-white/20">0</Kbd>
<span className="text-gray-300"></span>
</div>
<div className="flex justify-between items-center gap-4">
<Kbd className="bg-white/10 text-white border border-white/20">?</Kbd>
<span className="text-gray-300">/</span>
</div>
</div>
</div>
)}
{/* 左侧工具栏 */}
<div className="absolute left-4 top-1/2 -translate-y-1/2 z-10 flex flex-col gap-2">
<Button
variant="secondary"
size="icon"
onClick={(e) => {
handleZoomIn();
}}
disabled={currentScale >= 5}
title="放大 (+)"
className="bg-black/50 hover:bg-black/70 text-white"
>
<ZoomIn className="size-5" />
</Button>
<Button
variant="secondary"
size="icon"
onClick={(e) => {
handleZoomOut();
}}
disabled={currentScale <= 0.5}
title="缩小 (-)"
className="bg-black/50 hover:bg-black/70 text-white"
>
<ZoomOut className="size-5" />
</Button>
<Button
variant="secondary"
size="icon"
onClick={(e) => {
handleRotate();
}}
title="旋转 (R)"
className="bg-black/50 hover:bg-black/70 text-white"
>
<RotateCw className="size-5" />
</Button>
<Button
variant="secondary"
size="icon"
onClick={(e) => {
handleReset();
}}
title="重置 (0)"
className="bg-black/50 hover:bg-black/70 text-white"
>
<Maximize2 className="size-5" />
</Button>
{(currentFile?.file || onDownload) && (
<Button
variant="secondary"
size="icon"
onClick={(e) => {
handleDownloadCurrent();
}}
title="下载"
className="bg-black/50 hover:bg-black/70 text-white"
>
<Download className="size-5" />
</Button>
)}
</div>
{/* 轮播容器 */}
<div className="relative w-full h-full flex items-center justify-center">
<div ref={emblaRef} className="overflow-hidden w-full h-full">
<div className="flex h-full gap-8">
{files.map((file, index) => (
<div
key={file.id}
className="flex-[0_0_100%] min-w-0 flex items-center justify-center"
>
<div
ref={index === currentIndex ? imageRef : null}
className="relative flex items-center justify-center w-full h-full touch-none"
style={{
cursor: index === currentIndex && scale.get() > 1 ? 'grab' : 'default',
}}
>
{file.type?.startsWith('image/') && file.preview ? (
<motion.img
src={file.preview}
alt={file.name}
className="max-w-full max-h-full object-contain select-none"
style={
index === currentIndex
? {
scale,
x,
y,
rotate: rotation,
}
: {}
}
draggable={false}
/>
) : (
<div className="flex flex-col items-center justify-center gap-4 text-white">
{getFileIcon(file.type)}
<div className="text-center">
<p className="text-lg font-medium">{file.name}</p>
<p className="text-sm text-gray-400">{formatBytes(file.size)}</p>
</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
{/* 底部导航区域:左右切换按钮 + 缩略图 */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2">
{/* 左切换按钮 */}
<Button
variant="secondary"
size="icon"
onClick={() => emblaApi?.scrollPrev()}
disabled={!canScrollPrev}
className="size-10 rounded-full bg-black/50 hover:bg-black/70 text-white flex-shrink-0 disabled:opacity-30 disabled:cursor-not-allowed"
title="上一张 (←)"
>
<ChevronLeft className="size-5" />
</Button>
{/* 缩略图导航 */}
<div
ref={thumbnailRef}
className="flex gap-2 max-w-[70vw] overflow-x-auto scrollbar-muted p-2 bg-black/50 rounded-lg cursor-grab active:cursor-grabbing touch-pan-x"
>
{files.map((file, index) => (
<button
key={file.id}
onClick={() => emblaApi?.scrollTo(index)}
className={cn(
'relative size-16 flex-shrink-0 rounded overflow-hidden border-2 transition-all cursor-pointer select-none',
index === currentIndex
? 'border-primary ring-2 ring-primary'
: 'border-transparent hover:border-white/50'
)}
title={file.name}
>
{file.type?.startsWith('image/') && file.preview ? (
<img
src={file.preview}
alt={file.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-muted text-muted-foreground">
{getFileIcon(file.type)}
</div>
)}
</button>
))}
</div>
{/* 右切换按钮 */}
<Button
variant="secondary"
size="icon"
onClick={() => emblaApi?.scrollNext()}
disabled={!canScrollNext}
className="size-10 rounded-full bg-black/50 hover:bg-black/70 text-white flex-shrink-0 disabled:opacity-30 disabled:cursor-not-allowed"
title="下一张 (→)"
>
<ChevronRight className="size-5" />
</Button>
</div>
</div>,
document.body
);
}