mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
fix: 预览
This commit is contained in:
@@ -70,6 +70,7 @@
|
|||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-markdown": "^9.0.3",
|
"react-markdown": "^9.0.3",
|
||||||
|
"react-photo-view": "^1.2.7",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-responsive": "^10.0.0",
|
"react-responsive": "^10.0.0",
|
||||||
"react-router-dom": "^7.1.4",
|
"react-router-dom": "^7.1.4",
|
||||||
|
@@ -18,11 +18,10 @@ interface FilePreviewModalProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp']
|
export const videoExts = ['.mp4', '.webm']
|
||||||
const videoExts = ['.mp4', '.webm']
|
export const audioExts = ['.mp3', '.wav']
|
||||||
const audioExts = ['.mp3', '.wav']
|
|
||||||
|
|
||||||
const supportedPreviewExts = [...imageExts, ...videoExts, ...audioExts]
|
export const supportedPreviewExts = [...videoExts, ...audioExts]
|
||||||
|
|
||||||
export default function FilePreviewModal({
|
export default function FilePreviewModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -31,7 +30,7 @@ export default function FilePreviewModal({
|
|||||||
}: FilePreviewModalProps) {
|
}: FilePreviewModalProps) {
|
||||||
const ext = path.extname(filePath).toLowerCase()
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
const { data, loading, error, run } = useRequest(
|
const { data, loading, error, run } = useRequest(
|
||||||
async (path: string) => FileManager.downloadToURL(path),
|
async () => FileManager.downloadToURL(filePath),
|
||||||
{
|
{
|
||||||
refreshDeps: [filePath],
|
refreshDeps: [filePath],
|
||||||
refreshDepsAction: () => {
|
refreshDepsAction: () => {
|
||||||
@@ -39,7 +38,7 @@ export default function FilePreviewModal({
|
|||||||
if (!filePath || !supportedPreviewExts.includes(ext)) {
|
if (!filePath || !supportedPreviewExts.includes(ext)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
run(filePath)
|
run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -55,20 +54,20 @@ export default function FilePreviewModal({
|
|||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
} else if (imageExts.includes(ext)) {
|
|
||||||
contentElement = (
|
|
||||||
<img src={data} alt="预览" className="max-w-full max-h-96" />
|
|
||||||
)
|
|
||||||
} else if (videoExts.includes(ext)) {
|
} else if (videoExts.includes(ext)) {
|
||||||
contentElement = (
|
contentElement = <video src={data} controls className="max-w-full" />
|
||||||
<video src={data} controls className="max-w-full max-h-96" />
|
|
||||||
)
|
|
||||||
} else if (audioExts.includes(ext)) {
|
} else if (audioExts.includes(ext)) {
|
||||||
contentElement = <audio src={data} controls className="w-full" />
|
contentElement = <audio src={data} controls className="w-full" />
|
||||||
|
} else {
|
||||||
|
contentElement = (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior="inside">
|
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior="inside" size="3xl">
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader>文件预览</ModalHeader>
|
<ModalHeader>文件预览</ModalHeader>
|
||||||
<ModalBody className="flex justify-center items-center">
|
<ModalBody className="flex justify-center items-center">
|
||||||
|
@@ -12,15 +12,19 @@ import {
|
|||||||
TableRow
|
TableRow
|
||||||
} from '@heroui/table'
|
} from '@heroui/table'
|
||||||
import path from 'path-browserify'
|
import path from 'path-browserify'
|
||||||
import { useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { BiRename } from 'react-icons/bi'
|
import { BiRename } from 'react-icons/bi'
|
||||||
import { FiCopy, FiDownload, FiMove, FiTrash2 } from 'react-icons/fi'
|
import { FiCopy, FiDownload, FiMove, FiTrash2 } from 'react-icons/fi'
|
||||||
|
import { PhotoSlider } from 'react-photo-view'
|
||||||
|
|
||||||
import FileIcon from '@/components/file_icon'
|
import FileIcon from '@/components/file_icon'
|
||||||
|
|
||||||
import type { FileInfo } from '@/controllers/file_manager'
|
import type { FileInfo } from '@/controllers/file_manager'
|
||||||
|
|
||||||
interface FileTableProps {
|
import { supportedPreviewExts } from './file_preview_modal'
|
||||||
|
import ImageNameButton, { PreviewImage, imageExts } from './image_name_button'
|
||||||
|
|
||||||
|
export interface FileTableProps {
|
||||||
files: FileInfo[]
|
files: FileInfo[]
|
||||||
currentPath: string
|
currentPath: string
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -62,143 +66,180 @@ export default function FileTable({
|
|||||||
const start = (page - 1) * PAGE_SIZE
|
const start = (page - 1) * PAGE_SIZE
|
||||||
const end = start + PAGE_SIZE
|
const end = start + PAGE_SIZE
|
||||||
const displayFiles = files.slice(start, end)
|
const displayFiles = files.slice(start, end)
|
||||||
|
const [showImage, setShowImage] = useState(false)
|
||||||
|
const [previewIndex, setPreviewIndex] = useState(0)
|
||||||
|
const [previewImages, setPreviewImages] = useState<PreviewImage[]>([])
|
||||||
|
|
||||||
|
const addPreviewImage = useCallback((image: PreviewImage) => {
|
||||||
|
setPreviewImages((prev) => {
|
||||||
|
const exists = prev.some((p) => p.key === image.key)
|
||||||
|
if (exists) return prev
|
||||||
|
return [...prev, image]
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPreviewImages([])
|
||||||
|
setPreviewIndex(0)
|
||||||
|
setShowImage(false)
|
||||||
|
}, [files])
|
||||||
|
|
||||||
|
const onPreviewImage = (name: string, images: PreviewImage[]) => {
|
||||||
|
const index = images.findIndex((image) => image.key === name)
|
||||||
|
if (index === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPreviewIndex(index)
|
||||||
|
setShowImage(true)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table
|
<>
|
||||||
aria-label="文件列表"
|
<PhotoSlider
|
||||||
sortDescriptor={sortDescriptor}
|
images={previewImages}
|
||||||
onSortChange={onSortChange}
|
visible={showImage}
|
||||||
onSelectionChange={onSelectionChange}
|
onClose={() => setShowImage(false)}
|
||||||
defaultSelectedKeys={[]}
|
index={previewIndex}
|
||||||
selectedKeys={selectedFiles}
|
onIndexChange={setPreviewIndex}
|
||||||
selectionMode="multiple"
|
/>
|
||||||
bottomContent={
|
<Table
|
||||||
<div className="flex w-full justify-center">
|
aria-label="文件列表"
|
||||||
<Pagination
|
sortDescriptor={sortDescriptor}
|
||||||
isCompact
|
onSortChange={onSortChange}
|
||||||
showControls
|
onSelectionChange={onSelectionChange}
|
||||||
showShadow
|
defaultSelectedKeys={[]}
|
||||||
color="danger"
|
selectedKeys={selectedFiles}
|
||||||
page={page}
|
selectionMode="multiple"
|
||||||
total={pages}
|
bottomContent={
|
||||||
onChange={(page) => setPage(page)}
|
<div className="flex w-full justify-center">
|
||||||
/>
|
<Pagination
|
||||||
</div>
|
isCompact
|
||||||
}
|
showControls
|
||||||
>
|
showShadow
|
||||||
<TableHeader>
|
color="danger"
|
||||||
<TableColumn key="name" allowsSorting>
|
page={page}
|
||||||
名称
|
total={pages}
|
||||||
</TableColumn>
|
onChange={(page) => setPage(page)}
|
||||||
<TableColumn key="type" allowsSorting>
|
/>
|
||||||
类型
|
|
||||||
</TableColumn>
|
|
||||||
<TableColumn key="size" allowsSorting>
|
|
||||||
大小
|
|
||||||
</TableColumn>
|
|
||||||
<TableColumn key="mtime" allowsSorting>
|
|
||||||
修改时间
|
|
||||||
</TableColumn>
|
|
||||||
<TableColumn key="actions">操作</TableColumn>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody
|
|
||||||
isLoading={loading}
|
|
||||||
loadingContent={
|
|
||||||
<div className="flex justify-center items-center h-full">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
items={displayFiles}
|
|
||||||
>
|
>
|
||||||
{(file: FileInfo) => {
|
<TableHeader>
|
||||||
const filePath = path.join(currentPath, file.name)
|
<TableColumn key="name" allowsSorting>
|
||||||
// 判断预览类型
|
名称
|
||||||
const ext = path.extname(file.name).toLowerCase()
|
</TableColumn>
|
||||||
const previewable = [
|
<TableColumn key="type" allowsSorting>
|
||||||
'.png',
|
类型
|
||||||
'.jpg',
|
</TableColumn>
|
||||||
'.jpeg',
|
<TableColumn key="size" allowsSorting>
|
||||||
'.gif',
|
大小
|
||||||
'.bmp',
|
</TableColumn>
|
||||||
'.mp4',
|
<TableColumn key="mtime" allowsSorting>
|
||||||
'.webm',
|
修改时间
|
||||||
'.mp3',
|
</TableColumn>
|
||||||
'.wav'
|
<TableColumn key="actions">操作</TableColumn>
|
||||||
].includes(ext)
|
</TableHeader>
|
||||||
return (
|
<TableBody
|
||||||
<TableRow key={file.name}>
|
isLoading={loading}
|
||||||
<TableCell>
|
loadingContent={
|
||||||
<Button
|
<div className="flex justify-center items-center h-full">
|
||||||
variant="light"
|
<Spinner />
|
||||||
onPress={() =>
|
</div>
|
||||||
file.isDirectory
|
}
|
||||||
? onDirectoryClick(file.name)
|
>
|
||||||
: previewable
|
{displayFiles.map((file: FileInfo) => {
|
||||||
? onPreview(filePath)
|
const filePath = path.join(currentPath, file.name)
|
||||||
: onEdit(filePath)
|
const ext = path.extname(file.name).toLowerCase()
|
||||||
}
|
const previewable = supportedPreviewExts.includes(ext)
|
||||||
className="text-left justify-start"
|
const images = previewImages
|
||||||
startContent={
|
return (
|
||||||
<FileIcon name={file.name} isDirectory={file.isDirectory} />
|
<TableRow key={file.name}>
|
||||||
}
|
<TableCell>
|
||||||
>
|
{imageExts.includes(ext) ? (
|
||||||
{file.name}
|
<ImageNameButton
|
||||||
</Button>
|
name={file.name}
|
||||||
</TableCell>
|
filePath={filePath}
|
||||||
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
|
onPreview={() => onPreviewImage(file.name, images)}
|
||||||
<TableCell>
|
onAddPreview={addPreviewImage}
|
||||||
{isNaN(file.size) || file.isDirectory
|
/>
|
||||||
? '-'
|
) : (
|
||||||
: `${file.size} 字节`}
|
<Button
|
||||||
</TableCell>
|
variant="light"
|
||||||
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
|
onPress={() =>
|
||||||
<TableCell>
|
file.isDirectory
|
||||||
<ButtonGroup size="sm">
|
? onDirectoryClick(file.name)
|
||||||
<Button
|
: previewable
|
||||||
isIconOnly
|
? onPreview(filePath)
|
||||||
color="danger"
|
: onEdit(filePath)
|
||||||
variant="flat"
|
}
|
||||||
onPress={() => onRenameRequest(file.name)}
|
className="text-left justify-start"
|
||||||
>
|
startContent={
|
||||||
<BiRename />
|
<FileIcon
|
||||||
</Button>
|
name={file.name}
|
||||||
<Button
|
isDirectory={file.isDirectory}
|
||||||
isIconOnly
|
/>
|
||||||
color="danger"
|
}
|
||||||
variant="flat"
|
>
|
||||||
onPress={() => onMoveRequest(file.name)}
|
{file.name}
|
||||||
>
|
</Button>
|
||||||
<FiMove />
|
)}
|
||||||
</Button>
|
</TableCell>
|
||||||
<Button
|
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
|
||||||
isIconOnly
|
<TableCell>
|
||||||
color="danger"
|
{isNaN(file.size) || file.isDirectory
|
||||||
variant="flat"
|
? '-'
|
||||||
onPress={() => onCopyPath(file.name)}
|
: `${file.size} 字节`}
|
||||||
>
|
</TableCell>
|
||||||
<FiCopy />
|
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
|
||||||
</Button>
|
<TableCell>
|
||||||
<Button
|
<ButtonGroup size="sm">
|
||||||
isIconOnly
|
<Button
|
||||||
color="danger"
|
isIconOnly
|
||||||
variant="flat"
|
color="danger"
|
||||||
onPress={() => onDownload(filePath)}
|
variant="flat"
|
||||||
>
|
onPress={() => onRenameRequest(file.name)}
|
||||||
<FiDownload />
|
>
|
||||||
</Button>
|
<BiRename />
|
||||||
<Button
|
</Button>
|
||||||
isIconOnly
|
<Button
|
||||||
color="danger"
|
isIconOnly
|
||||||
variant="flat"
|
color="danger"
|
||||||
onPress={() => onDelete(filePath)}
|
variant="flat"
|
||||||
>
|
onPress={() => onMoveRequest(file.name)}
|
||||||
<FiTrash2 />
|
>
|
||||||
</Button>
|
<FiMove />
|
||||||
</ButtonGroup>
|
</Button>
|
||||||
</TableCell>
|
<Button
|
||||||
</TableRow>
|
isIconOnly
|
||||||
)
|
color="danger"
|
||||||
}}
|
variant="flat"
|
||||||
</TableBody>
|
onPress={() => onCopyPath(file.name)}
|
||||||
</Table>
|
>
|
||||||
|
<FiCopy />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
color="danger"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => onDownload(filePath)}
|
||||||
|
>
|
||||||
|
<FiDownload />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
color="danger"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => onDelete(filePath)}
|
||||||
|
>
|
||||||
|
<FiTrash2 />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,73 @@
|
|||||||
|
import { Button } from '@heroui/button'
|
||||||
|
import { Image } from '@heroui/image'
|
||||||
|
import { Spinner } from '@heroui/spinner'
|
||||||
|
import { useRequest } from 'ahooks'
|
||||||
|
import path from 'path-browserify'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
import FileManager from '@/controllers/file_manager'
|
||||||
|
|
||||||
|
import FileIcon from '../file_icon'
|
||||||
|
|
||||||
|
export interface PreviewImage {
|
||||||
|
key: string
|
||||||
|
src: string
|
||||||
|
alt: string
|
||||||
|
}
|
||||||
|
export const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp']
|
||||||
|
|
||||||
|
export interface ImageNameButtonProps {
|
||||||
|
name: string
|
||||||
|
filePath: string
|
||||||
|
onPreview: () => void
|
||||||
|
onAddPreview: (image: PreviewImage) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImageNameButton({
|
||||||
|
name,
|
||||||
|
filePath,
|
||||||
|
onPreview,
|
||||||
|
onAddPreview
|
||||||
|
}: ImageNameButtonProps) {
|
||||||
|
const { data, loading, error, run } = useRequest(
|
||||||
|
async () => FileManager.downloadToURL(filePath),
|
||||||
|
{
|
||||||
|
refreshDeps: [filePath],
|
||||||
|
refreshDepsAction: () => {
|
||||||
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
|
if (!filePath || !imageExts.includes(ext)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
onAddPreview({
|
||||||
|
key: name,
|
||||||
|
src: data,
|
||||||
|
alt: name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [data, name, onAddPreview])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
className="text-left justify-start"
|
||||||
|
onPress={onPreview}
|
||||||
|
startContent={
|
||||||
|
error ? (
|
||||||
|
<FileIcon name={name} isDirectory={false} />
|
||||||
|
) : loading || !data ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
<Image src={data} alt={name} className="w-8 h-8" radius="sm" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
@@ -1,4 +1,5 @@
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import 'react-photo-view/dist/react-photo-view.css'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
|
||||||
import App from '@/App.tsx'
|
import App from '@/App.tsx'
|
||||||
|
Reference in New Issue
Block a user