mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-06 18:22:44 +08:00
feat: Add the ability to obtain direct file links
This commit is contained in:
@@ -145,11 +145,12 @@ async def stream_endpoint(
|
||||
@router.get("/temp-link/{full_path:path}")
|
||||
async def get_temp_link(
|
||||
full_path: str,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
expires_in: int = Query(3600, description="有效时间(秒), 0或负数表示永久")
|
||||
):
|
||||
"""获取文件的临时公开访问令牌"""
|
||||
full_path = '/' + full_path if not full_path.startswith('/') else full_path
|
||||
token = await generate_temp_link_token(full_path)
|
||||
token = await generate_temp_link_token(full_path, expires_in=expires_in)
|
||||
return success({"token": token, "path": full_path})
|
||||
|
||||
|
||||
|
||||
@@ -481,8 +481,12 @@ async def get_temp_link_secret_key() -> bytes:
|
||||
|
||||
|
||||
async def generate_temp_link_token(path: str, expires_in: int = 3600) -> str:
|
||||
"""为文件路径生成一个有时效的令牌"""
|
||||
expiration_time = int(time.time() + expires_in)
|
||||
"""为文件路径生成一个有时效的令牌。expires_in <= 0 表示永久"""
|
||||
if expires_in <= 0:
|
||||
expiration_time = "0"
|
||||
else:
|
||||
expiration_time = str(int(time.time() + expires_in))
|
||||
|
||||
message = f"{path}:{expiration_time}".encode('utf-8')
|
||||
secret_key = await get_temp_link_secret_key()
|
||||
signature = hmac.new(secret_key, message, hashlib.sha256).digest()
|
||||
@@ -496,15 +500,16 @@ async def verify_temp_link_token(token: str) -> str:
|
||||
try:
|
||||
decoded_token = base64.urlsafe_b64decode(token).decode('utf-8')
|
||||
path, expiration_time_str, signature_b64 = decoded_token.rsplit(':', 2)
|
||||
expiration_time = int(expiration_time_str)
|
||||
signature = base64.urlsafe_b64decode(signature_b64)
|
||||
except (ValueError, TypeError, base64.binascii.Error):
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
|
||||
if time.time() > expiration_time:
|
||||
raise HTTPException(status_code=410, detail="Link has expired")
|
||||
if expiration_time_str != "0":
|
||||
expiration_time = int(expiration_time_str)
|
||||
if time.time() > expiration_time:
|
||||
raise HTTPException(status_code=410, detail="Link has expired")
|
||||
|
||||
message = f"{path}:{expiration_time}".encode('utf-8')
|
||||
message = f"{path}:{expiration_time_str}".encode('utf-8')
|
||||
secret_key = await get_temp_link_secret_key()
|
||||
expected_signature = hmac.new(secret_key, message, hashlib.sha256).digest()
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@ export const vfsApi = {
|
||||
request<ArrayBuffer>(`/fs/thumb/${encodeURI(path.replace(/^\/+/, ''))}?w=${w}&h=${h}&fit=${fit}`),
|
||||
streamUrl: (path: string) => `${API_BASE_URL}/fs/stream/${encodeURI(path.replace(/^\/+/, ''))}`,
|
||||
stat: (path: string) => request(`/fs/stat/${encodeURI(path.replace(/^\/+/, ''))}`),
|
||||
getTempLinkToken: (path: string) => request<{token: string}>(`/fs/temp-link/${encodeURI(path.replace(/^\/+/, ''))}`),
|
||||
getTempLinkToken: (path: string, expiresIn: number = 3600) =>
|
||||
request<{token: string}>(`/fs/temp-link/${encodeURI(path.replace(/^\/+/, ''))}?expires_in=${expiresIn}`),
|
||||
getTempPublicUrl: (token: string) => `${API_BASE_URL}/fs/public/${token}`,
|
||||
uploadStream: (fullPath: string, file: File, overwrite: boolean = true, onProgress?: (loaded: number, total: number) => void) => {
|
||||
const enc = encodeURI(fullPath.replace(/^\/+/, ''));
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAppWindows } from './hooks/useAppWindows.tsx';
|
||||
import { useContextMenu } from './hooks/useContextMenu';
|
||||
import { useProcessor } from './hooks/useProcessor';
|
||||
import { useThumbnails } from './hooks/useThumbnails';
|
||||
import { useUploader } from './hooks/useUploader';
|
||||
import { Header } from './components/Header';
|
||||
import { GridView } from './components/GridView';
|
||||
import { FileListView } from './components/FileListView';
|
||||
@@ -17,7 +18,9 @@ import { ContextMenu } from './components/ContextMenu';
|
||||
import { CreateDirModal } from './components/Modals/CreateDirModal';
|
||||
import { RenameModal } from './components/Modals/RenameModal';
|
||||
import { ProcessorModal } from './components/Modals/ProcessorModal';
|
||||
import UploadModal from './components/Modals/UploadModal';
|
||||
import { ShareModal } from './components/Modals/ShareModal';
|
||||
import { DirectLinkModal } from './components/Modals/DirectLinkModal';
|
||||
import { FileDetailModal } from './components/FileDetailModal';
|
||||
import type { ViewMode } from './types';
|
||||
import { vfsApi, type VfsEntry } from '../../api/client';
|
||||
@@ -30,9 +33,10 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
// --- Hooks ---
|
||||
const { path, entries, loading, pagination, processorTypes, load, navigateTo, goUp, handlePaginationChange, refresh } = useFileExplorer(navKey);
|
||||
const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection();
|
||||
const { uploading, fileInputRef, doCreateDir, doDelete, doRename, doDownload, doShare, handleUploadClick, handleFilesSelected } = useFileActions({ path, refresh, clearSelection, onShare: (entries) => setSharingEntries(entries) });
|
||||
const { doCreateDir, doDelete, doRename, doDownload, doShare, doGetDirectLink } = useFileActions({ path, refresh, clearSelection, onShare: (entries) => setSharingEntries(entries), onGetDirectLink: (entry) => setDirectLinkEntry(entry) });
|
||||
const { appWindows, openFileWithDefaultApp, confirmOpenWithApp, closeWindow, toggleMax, bringToFront, updateWindow } = useAppWindows(path);
|
||||
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
||||
const uploader = useUploader(path, refresh);
|
||||
const processorHook = useProcessor({ path, processorTypes, refresh });
|
||||
const { thumbs } = useThumbnails(entries, path);
|
||||
|
||||
@@ -41,6 +45,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
const [renaming, setRenaming] = useState<VfsEntry | null>(null);
|
||||
const [sharingEntries, setSharingEntries] = useState<VfsEntry[]>([]);
|
||||
const [detailEntry, setDetailEntry] = useState<VfsEntry | null>(null);
|
||||
const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null);
|
||||
const [detailData, setDetailData] = useState<any>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
@@ -91,17 +96,16 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
navKey={navKey}
|
||||
path={path}
|
||||
loading={loading}
|
||||
uploading={uploading}
|
||||
viewMode={viewMode}
|
||||
onGoUp={goUp}
|
||||
onNavigate={navigateTo}
|
||||
onRefresh={refresh}
|
||||
onCreateDir={() => setCreatingDir(true)}
|
||||
onUpload={handleUploadClick}
|
||||
onUpload={uploader.openModal}
|
||||
onSetViewMode={setViewMode}
|
||||
/>
|
||||
|
||||
<input ref={fileInputRef} type="file" style={{ display: 'none' }} multiple onChange={handleFilesSelected} />
|
||||
<input ref={uploader.fileInputRef} type="file" style={{ display: 'none' }} multiple onChange={uploader.handleFileChange} />
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
|
||||
{loading && entries.length === 0 ? (
|
||||
@@ -155,6 +159,12 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
onCancel={() => setSharingEntries([])}
|
||||
/>
|
||||
)}
|
||||
<DirectLinkModal
|
||||
entry={directLinkEntry}
|
||||
path={path}
|
||||
open={!!directLinkEntry}
|
||||
onCancel={() => setDirectLinkEntry(null)}
|
||||
/>
|
||||
<ProcessorModal
|
||||
entry={processorHook.processorModal.entry}
|
||||
visible={processorHook.processorModal.visible}
|
||||
@@ -191,12 +201,18 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
processorHook.setSelectedProcessor(type);
|
||||
processorHook.openProcessorModal(entry);
|
||||
}}
|
||||
onUpload={handleUploadClick}
|
||||
onUpload={uploader.openModal}
|
||||
onCreateDir={() => setCreatingDir(true)}
|
||||
onShare={doShare}
|
||||
onGetDirectLink={doGetDirectLink}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UploadModal
|
||||
visible={uploader.isModalVisible}
|
||||
files={uploader.files}
|
||||
onClose={uploader.closeModal}
|
||||
onStartUpload={uploader.startUpload}
|
||||
/>
|
||||
<AppWindowsLayer windows={appWindows} onClose={closeWindow} onToggleMax={toggleMax} onBringToFront={bringToFront} onUpdateWindow={updateWindow} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { VfsEntry } from '../../../api/client';
|
||||
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
|
||||
import {
|
||||
FolderFilled, AppstoreOutlined, AppstoreAddOutlined, DownloadOutlined,
|
||||
EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined, ShareAltOutlined
|
||||
EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined, ShareAltOutlined, LinkOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface ContextMenuProps {
|
||||
@@ -25,6 +25,7 @@ interface ContextMenuProps {
|
||||
onUpload: () => void;
|
||||
onCreateDir: () => void;
|
||||
onShare: (entries: VfsEntry[]) => void;
|
||||
onGetDirectLink: (entry: VfsEntry) => void;
|
||||
}
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
@@ -86,6 +87,13 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
icon: <ShareAltOutlined />,
|
||||
onClick: () => actions.onShare(targetEntries),
|
||||
},
|
||||
{
|
||||
key: 'directLink',
|
||||
label: '获取直链',
|
||||
icon: <LinkOutlined />,
|
||||
disabled: targetEntries.length !== 1 || targetEntries[0].is_dir,
|
||||
onClick: () => actions.onGetDirectLink(targetEntries[0]),
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: '下载',
|
||||
|
||||
@@ -7,7 +7,6 @@ interface HeaderProps {
|
||||
navKey: string;
|
||||
path: string;
|
||||
loading: boolean;
|
||||
uploading: boolean;
|
||||
viewMode: ViewMode;
|
||||
onGoUp: () => void;
|
||||
onNavigate: (path: string) => void;
|
||||
@@ -20,7 +19,6 @@ interface HeaderProps {
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
path,
|
||||
loading,
|
||||
uploading,
|
||||
viewMode,
|
||||
onGoUp,
|
||||
onNavigate,
|
||||
@@ -101,7 +99,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
<Space size={8} wrap>
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading}>刷新</Button>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir}>新建目录</Button>
|
||||
<Button size="small" icon={<UploadOutlined />} loading={uploading} onClick={onUpload}>上传</Button>
|
||||
<Button size="small" icon={<UploadOutlined />} onClick={onUpload}>上传</Button>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={viewMode}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { memo, useState, useEffect } from 'react';
|
||||
import { Modal, Radio, message, Button, Typography, Input } from 'antd';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import type { VfsEntry } from '../../../../api/client';
|
||||
import { vfsApi } from '../../../../api/client';
|
||||
|
||||
interface DirectLinkModalProps {
|
||||
entry: VfsEntry | null;
|
||||
path: string;
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open, onCancel }: DirectLinkModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expiresIn, setExpiresIn] = useState(3600);
|
||||
const [link, setLink] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open && entry) {
|
||||
setLink('');
|
||||
generateLink();
|
||||
}
|
||||
}, [open, entry, expiresIn]);
|
||||
|
||||
const generateLink = async () => {
|
||||
if (!entry) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
|
||||
const res = await vfsApi.getTempLinkToken(fullPath, expiresIn);
|
||||
const tempLink = `${window.location.origin}/api/fs/public/${res.token}`;
|
||||
setLink(tempLink);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '生成链接失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
message.success('已复制到剪贴板');
|
||||
};
|
||||
|
||||
const handleExpiresChange = (e: any) => {
|
||||
setExpiresIn(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="获取直链"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={[
|
||||
<Button key="back" onClick={onCancel}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Typography.Paragraph>
|
||||
为 <strong>{entry?.name}</strong> 生成一个直接访问链接。
|
||||
</Typography.Paragraph>
|
||||
<Radio.Group value={expiresIn} onChange={handleExpiresChange} style={{ marginBottom: 16 }}>
|
||||
<Radio.Button value={3600}>1 小时</Radio.Button>
|
||||
<Radio.Button value={86400}>1 天</Radio.Button>
|
||||
<Radio.Button value={604800}>7 天</Radio.Button>
|
||||
<Radio.Button value={0}>永久</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Input readOnly value={link} disabled={loading} placeholder={loading ? "正在生成链接..." : "链接将显示在这里"} />
|
||||
<Button icon={<CopyOutlined />} onClick={() => handleCopy(link)} disabled={!link || loading}>
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Modal, Button, List, Progress, Typography, message, Flex } from 'antd';
|
||||
import { CopyOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
||||
import type { UploadFile } from '../../hooks/useUploader';
|
||||
|
||||
interface UploadModalProps {
|
||||
visible: boolean;
|
||||
files: UploadFile[];
|
||||
onClose: () => void;
|
||||
onStartUpload: () => void;
|
||||
}
|
||||
|
||||
const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onStartUpload }) => {
|
||||
|
||||
const allSuccess = files.every(f => f.status === 'success');
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && files.length > 0 && files.every(f => f.status === 'pending')) {
|
||||
onStartUpload();
|
||||
}
|
||||
}, [visible, files, onStartUpload]);
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
message.success('链接已复制到剪贴板');
|
||||
};
|
||||
|
||||
const renderStatus = (file: UploadFile) => {
|
||||
switch (file.status) {
|
||||
case 'uploading':
|
||||
return <Progress percent={Math.round(file.progress)} size="small" />;
|
||||
case 'success':
|
||||
return (
|
||||
<Flex align="center" gap={8}>
|
||||
<CheckCircleFilled style={{ color: '#52c41a' }} />
|
||||
<Typography.Text type="secondary" style={{ verticalAlign: 'middle' }}>上传成功</Typography.Text>
|
||||
<Button icon={<CopyOutlined />} size="small" onClick={() => handleCopy(file.permanentLink!)} type="text" />
|
||||
</Flex>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<Flex align="center" gap={8}>
|
||||
<CloseCircleFilled style={{ color: '#ff4d4f' }} />
|
||||
<Typography.Text type="danger" title={file.error}>上传失败</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
default:
|
||||
return <Typography.Text type="secondary">等待上传</Typography.Text>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
title="上传文件"
|
||||
width={600}
|
||||
onCancel={onClose}
|
||||
footer={[
|
||||
<Button key="close" onClick={onClose} disabled={!allSuccess && files.some(f => f.status === 'uploading')}>
|
||||
{allSuccess ? '关闭' : '完成'}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<List
|
||||
dataSource={files}
|
||||
itemLayout="horizontal"
|
||||
renderItem={file => (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '12px 8px',
|
||||
borderRadius: 8,
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
<Flex justify="space-between" align="center" style={{ width: '100%' }}>
|
||||
<Typography.Text ellipsis={{ tooltip: file.file.name }} style={{ maxWidth: '60%' }}>
|
||||
{file.file.name}
|
||||
</Typography.Text>
|
||||
<div style={{ minWidth: 180, textAlign: 'right', flexShrink: 0 }}>
|
||||
{renderStatus(file)}
|
||||
</div>
|
||||
</Flex>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadModal;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { message, Modal } from 'antd';
|
||||
import { vfsApi, type VfsEntry } from '../../../api/client';
|
||||
|
||||
@@ -7,12 +7,10 @@ interface FileActionsParams {
|
||||
refresh: () => void;
|
||||
clearSelection: () => void;
|
||||
onShare: (entries: VfsEntry[]) => void;
|
||||
onGetDirectLink: (entry: VfsEntry) => void;
|
||||
}
|
||||
|
||||
export function useFileActions({ path, refresh, clearSelection, onShare }: FileActionsParams) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
export function useFileActions({ path, refresh, clearSelection, onShare, onGetDirectLink }: FileActionsParams) {
|
||||
const doCreateDir = useCallback(async (name: string) => {
|
||||
if (!name.trim()) {
|
||||
message.warning('请输入名称');
|
||||
@@ -78,42 +76,6 @@ export function useFileActions({ path, refresh, clearSelection, onShare }: FileA
|
||||
}
|
||||
}, [path]);
|
||||
|
||||
const handleUploadClick = useCallback(() => {
|
||||
if (uploading) return;
|
||||
fileInputRef.current?.click();
|
||||
}, [uploading]);
|
||||
|
||||
const handleFilesSelected = useCallback(async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = ev.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
const dir = path === '/' ? '' : path;
|
||||
setUploading(true);
|
||||
const uploadedNames: string[] = [];
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const dest = (dir + '/' + file.name).replace(/\/+/g, '/');
|
||||
const key = 'upload-' + file.name;
|
||||
await vfsApi.uploadStream(dest, file, true, (loaded, total) => {
|
||||
const pct = total ? (loaded / total * 100) : 0;
|
||||
message.open({
|
||||
key,
|
||||
type: 'loading',
|
||||
content: `上传 ${file.name} ${pct.toFixed(1)}%`
|
||||
});
|
||||
});
|
||||
message.open({ key, type: 'success', content: `上传完成: ${file.name}`, duration: 2 });
|
||||
uploadedNames.push(file.name);
|
||||
}
|
||||
refresh();
|
||||
// You might want to select the new files after upload, this can be handled in the main component
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
}, [path, refresh]);
|
||||
|
||||
const doShare = useCallback((entries: VfsEntry[]) => {
|
||||
if (entries.length === 0) {
|
||||
message.warning('请选择要分享的文件或目录');
|
||||
@@ -122,15 +84,20 @@ export function useFileActions({ path, refresh, clearSelection, onShare }: FileA
|
||||
onShare(entries);
|
||||
}, [onShare]);
|
||||
|
||||
const doGetDirectLink = useCallback((entry: VfsEntry) => {
|
||||
if (entry.is_dir) {
|
||||
message.warning('不支持获取目录的直链');
|
||||
return;
|
||||
}
|
||||
onGetDirectLink(entry);
|
||||
}, [onGetDirectLink]);
|
||||
|
||||
return {
|
||||
uploading,
|
||||
fileInputRef,
|
||||
doCreateDir,
|
||||
doDelete,
|
||||
doRename,
|
||||
doDownload,
|
||||
doShare,
|
||||
handleUploadClick,
|
||||
handleFilesSelected,
|
||||
doGetDirectLink,
|
||||
};
|
||||
}
|
||||
91
web/src/pages/FileExplorerPage/hooks/useUploader.ts
Normal file
91
web/src/pages/FileExplorerPage/hooks/useUploader.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { vfsApi } from '../../../api/client';
|
||||
import { message }
|
||||
from 'antd';
|
||||
|
||||
export interface UploadFile {
|
||||
id: string;
|
||||
file: File;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||
progress: number;
|
||||
error?: string;
|
||||
permanentLink?: string;
|
||||
}
|
||||
|
||||
export function useUploader(path: string, onUploadComplete: () => void) {
|
||||
const [files, setFiles] = useState<UploadFile[]>([]);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const openModal = useCallback(() => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setIsModalVisible(false);
|
||||
setFiles([]);
|
||||
}, []);
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = event.target.files;
|
||||
if (selectedFiles && selectedFiles.length > 0) {
|
||||
const newFiles: UploadFile[] = Array.from(selectedFiles).map(file => ({
|
||||
id: `${file.name}-${Date.now()}`,
|
||||
file,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
}));
|
||||
setFiles(newFiles);
|
||||
setIsModalVisible(true);
|
||||
// reset file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startUpload = useCallback(async () => {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = path === '/' ? '' : path;
|
||||
|
||||
for (const uploadFile of files) {
|
||||
if (uploadFile.status !== 'pending') continue;
|
||||
|
||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'uploading' } : f));
|
||||
|
||||
const dest = (dir + '/' + uploadFile.file.name).replace(/\/+/g, '/');
|
||||
|
||||
try {
|
||||
await vfsApi.uploadStream(dest, uploadFile.file, true, (loaded, total) => {
|
||||
const progress = total > 0 ? (loaded / total) * 100 : 0;
|
||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, progress } : f));
|
||||
});
|
||||
|
||||
const link = await vfsApi.getTempLinkToken(dest, 60 * 60 * 24 * 365 * 10); // 10 years
|
||||
const permanentLink = vfsApi.getTempPublicUrl(link.token);
|
||||
|
||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'success', progress: 100, permanentLink } : f));
|
||||
} catch (e: any) {
|
||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'error', error: e.message } : f));
|
||||
message.error(`上传失败: ${uploadFile.file.name} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
onUploadComplete();
|
||||
}, [files, path, onUploadComplete]);
|
||||
|
||||
return {
|
||||
files,
|
||||
isModalVisible,
|
||||
fileInputRef,
|
||||
openModal,
|
||||
closeModal,
|
||||
handleFileChange,
|
||||
startUpload,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user