diff --git a/api/routes/virtual_fs.py b/api/routes/virtual_fs.py index 2efa1e5..b5ea19f 100644 --- a/api/routes/virtual_fs.py +++ b/api/routes/virtual_fs.py @@ -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}) diff --git a/services/virtual_fs.py b/services/virtual_fs.py index 331a741..6416148 100644 --- a/services/virtual_fs.py +++ b/services/virtual_fs.py @@ -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() diff --git a/web/src/api/vfs.ts b/web/src/api/vfs.ts index 7d4175d..1363488 100644 --- a/web/src/api/vfs.ts +++ b/web/src/api/vfs.ts @@ -50,7 +50,8 @@ export const vfsApi = { request(`/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(/^\/+/, '')); diff --git a/web/src/pages/FileExplorerPage/FileExplorerPage.tsx b/web/src/pages/FileExplorerPage/FileExplorerPage.tsx index d3cc70e..f862c48 100644 --- a/web/src/pages/FileExplorerPage/FileExplorerPage.tsx +++ b/web/src/pages/FileExplorerPage/FileExplorerPage.tsx @@ -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(null); const [sharingEntries, setSharingEntries] = useState([]); const [detailEntry, setDetailEntry] = useState(null); + const [directLinkEntry, setDirectLinkEntry] = useState(null); const [detailData, setDetailData] = useState(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} /> - +
0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}> {loading && entries.length === 0 ? ( @@ -155,6 +159,12 @@ const FileExplorerPage = memo(function FileExplorerPage() { onCancel={() => setSharingEntries([])} /> )} + setDirectLinkEntry(null)} + /> setCreatingDir(true)} onShare={doShare} + onGetDirectLink={doGetDirectLink} /> )} - +
); diff --git a/web/src/pages/FileExplorerPage/components/ContextMenu.tsx b/web/src/pages/FileExplorerPage/components/ContextMenu.tsx index f9b4797..f99b890 100644 --- a/web/src/pages/FileExplorerPage/components/ContextMenu.tsx +++ b/web/src/pages/FileExplorerPage/components/ContextMenu.tsx @@ -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 = (props) => { @@ -86,6 +87,13 @@ export const ContextMenu: React.FC = (props) => { icon: , onClick: () => actions.onShare(targetEntries), }, + { + key: 'directLink', + label: '获取直链', + icon: , + disabled: targetEntries.length !== 1 || targetEntries[0].is_dir, + onClick: () => actions.onGetDirectLink(targetEntries[0]), + }, { key: 'download', label: '下载', diff --git a/web/src/pages/FileExplorerPage/components/Header.tsx b/web/src/pages/FileExplorerPage/components/Header.tsx index bf2c6a4..c2bb0fc 100644 --- a/web/src/pages/FileExplorerPage/components/Header.tsx +++ b/web/src/pages/FileExplorerPage/components/Header.tsx @@ -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 = ({ path, loading, - uploading, viewMode, onGoUp, onNavigate, @@ -101,7 +99,7 @@ export const Header: React.FC = ({ - + 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 ( + + 关闭 + , + ]} + > + + 为 {entry?.name} 生成一个直接访问链接。 + + + 1 小时 + 1 天 + 7 天 + 永久 + + +
+ + +
+
+ ); +}); \ No newline at end of file diff --git a/web/src/pages/FileExplorerPage/components/Modals/UploadModal.tsx b/web/src/pages/FileExplorerPage/components/Modals/UploadModal.tsx new file mode 100644 index 0000000..05bd356 --- /dev/null +++ b/web/src/pages/FileExplorerPage/components/Modals/UploadModal.tsx @@ -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 = ({ 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 ; + case 'success': + return ( + + + 上传成功 + , + ]} + > + ( + { e.currentTarget.style.backgroundColor = '#f0f0f0'; }} + onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }} + > + + + {file.file.name} + +
+ {renderStatus(file)} +
+
+
+ )} + /> + + ); +}; + +export default UploadModal; \ No newline at end of file diff --git a/web/src/pages/FileExplorerPage/hooks/useFileActions.tsx b/web/src/pages/FileExplorerPage/hooks/useFileActions.tsx index c848125..291c8bc 100644 --- a/web/src/pages/FileExplorerPage/hooks/useFileActions.tsx +++ b/web/src/pages/FileExplorerPage/hooks/useFileActions.tsx @@ -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(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) => { - 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, }; } \ No newline at end of file diff --git a/web/src/pages/FileExplorerPage/hooks/useUploader.ts b/web/src/pages/FileExplorerPage/hooks/useUploader.ts new file mode 100644 index 0000000..51515e5 --- /dev/null +++ b/web/src/pages/FileExplorerPage/hooks/useUploader.ts @@ -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([]); + const [isModalVisible, setIsModalVisible] = useState(false); + const fileInputRef = useRef(null); + + const openModal = useCallback(() => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }, []); + + const closeModal = useCallback(() => { + setIsModalVisible(false); + setFiles([]); + }, []); + + const handleFileChange = (event: React.ChangeEvent) => { + 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, + }; +} \ No newline at end of file