Initial commit

This commit is contained in:
shiyu
2025-08-24 18:49:00 +08:00
parent 99866befe1
commit 6b0f2bd4fa
129 changed files with 11587 additions and 0 deletions
@@ -0,0 +1,204 @@
import { memo, useEffect, useState } from 'react';
import { useParams } from 'react-router';
import { theme, Pagination } from 'antd';
import { AppWindowsLayer } from '../../apps/AppWindowsLayer';
import { useFileExplorer } from './hooks/useFileExplorer';
import { useFileSelection } from './hooks/useFileSelection';
import { useFileActions } from './hooks/useFileActions.tsx';
import { useAppWindows } from './hooks/useAppWindows.tsx';
import { useContextMenu } from './hooks/useContextMenu';
import { useProcessor } from './hooks/useProcessor';
import { useThumbnails } from './hooks/useThumbnails';
import { Header } from './components/Header';
import { GridView } from './components/GridView';
import { FileListView } from './components/FileListView';
import { EmptyState } from './components/EmptyState';
import { ContextMenu } from './components/ContextMenu';
import { CreateDirModal } from './components/Modals/CreateDirModal';
import { RenameModal } from './components/Modals/RenameModal';
import { ProcessorModal } from './components/Modals/ProcessorModal';
import { ShareModal } from './components/Modals/ShareModal';
import { FileDetailModal } from './components/FileDetailModal';
import type { ViewMode } from './types';
import { vfsApi, type VfsEntry } from '../../api/client';
const FileExplorerPage = memo(function FileExplorerPage() {
const { navKey = 'files', '*': restPath = '' } = useParams();
const { token } = theme.useToken();
const [viewMode, setViewMode] = useState<ViewMode>('grid');
// --- Hooks ---
const { path, entries, loading, pagination, processorTypes, load, navigateTo, goUp, handlePaginationChange, refresh } = useFileExplorer(navKey);
const { selectedEntries, handleSelect, handleSelectRange, clearSelection } = useFileSelection();
const { uploading, fileInputRef, doCreateDir, doDelete, doRename, doDownload, doShare, handleUploadClick, handleFilesSelected } = useFileActions({ path, refresh, clearSelection, onShare: (entries) => setSharingEntries(entries) });
const { appWindows, openFileWithDefaultApp, confirmOpenWithApp, closeWindow, toggleMax, bringToFront, updateWindow } = useAppWindows(path);
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
const processorHook = useProcessor({ path, processorTypes, refresh });
const { thumbs } = useThumbnails(entries, path);
// --- State for Modals ---
const [creatingDir, setCreatingDir] = useState(false);
const [renaming, setRenaming] = useState<VfsEntry | null>(null);
const [sharingEntries, setSharingEntries] = useState<VfsEntry[]>([]);
const [detailEntry, setDetailEntry] = useState<VfsEntry | null>(null);
const [detailData, setDetailData] = useState<any>(null);
const [detailLoading, setDetailLoading] = useState(false);
// --- Effects ---
useEffect(() => {
const routeP = '/' + (restPath || '').replace(/^\/+/, '');
load(routeP, 1, pagination.pageSize);
}, [restPath, navKey, load, pagination.pageSize]);
// --- Handlers ---
const handleOpenEntry = (entry: VfsEntry) => {
if (entry.is_dir) {
const next = (path === '/' ? '' : path) + '/' + entry.name;
navigateTo(next.replace(/\/+/g, '/'));
} else {
openFileWithDefaultApp(entry);
}
};
const openDetail = async (entry: VfsEntry) => {
setDetailEntry(entry);
setDetailLoading(true);
try {
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
const stat = await vfsApi.stat(fullPath);
setDetailData(stat);
} catch (e: any) {
setDetailData({ error: e.message });
} finally {
setDetailLoading(false);
}
};
return (
<div
style={{
background: token.colorBgContainer,
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: token.borderRadius,
height: 'calc(100vh - 88px)',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}
onClick={closeContextMenus}
>
<Header
navKey={navKey}
path={path}
loading={loading}
uploading={uploading}
viewMode={viewMode}
onGoUp={goUp}
onNavigate={navigateTo}
onRefresh={refresh}
onCreateDir={() => setCreatingDir(true)}
onUpload={handleUploadClick}
onSetViewMode={setViewMode}
/>
<input ref={fileInputRef} type="file" style={{ display: 'none' }} multiple onChange={handleFilesSelected} />
<div style={{ flex: 1, overflow: 'auto', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
{loading && entries.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40 }}><EmptyState isRoot={path === '/'} onCreateDir={() => setCreatingDir(true)} onGoUp={goUp} /></div>
) : viewMode === 'grid' ? (
<GridView
entries={entries}
thumbs={thumbs}
selectedEntries={selectedEntries}
loading={loading}
path={path}
onSelect={handleSelect}
onSelectRange={handleSelectRange}
onOpen={handleOpenEntry}
onContextMenu={openContextMenu}
onCreateDir={() => setCreatingDir(true)}
onGoUp={goUp}
/>
) : (
<FileListView
entries={entries}
loading={loading}
selectedEntries={selectedEntries}
onRowClick={(r, e) => handleSelect(r, e.ctrlKey || e.metaKey)}
onOpen={handleOpenEntry}
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, { key: appKey, name: '' } as any)}
onRename={setRenaming}
onDelete={(entry) => doDelete([entry])}
onContextMenu={openContextMenu}
/>
)}
</div>
{pagination.total > 0 && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '12px 16px', background: token.colorBgContainer, borderTop: `1px solid ${token.colorBorderSecondary}`, textAlign: 'center', zIndex: 10 }}>
<Pagination {...pagination} onChange={handlePaginationChange} onShowSizeChange={handlePaginationChange} />
</div>
)}
{/* --- Modals & Context Menus --- */}
<CreateDirModal open={creatingDir} onOk={(name) => { doCreateDir(name); setCreatingDir(false); }} onCancel={() => setCreatingDir(false)} />
<RenameModal entry={renaming} onOk={(entry, newName) => { doRename(entry, newName); setRenaming(null); }} onCancel={() => setRenaming(null)} />
<FileDetailModal entry={detailEntry} loading={detailLoading} data={detailData} onClose={() => setDetailEntry(null)} />
{sharingEntries.length > 0 && (
<ShareModal
path={path}
entries={sharingEntries}
open={sharingEntries.length > 0}
onOk={() => setSharingEntries([])}
onCancel={() => setSharingEntries([])}
/>
)}
<ProcessorModal
entry={processorHook.processorModal.entry}
visible={processorHook.processorModal.visible}
loading={processorHook.processorLoading}
processorTypes={processorTypes}
selectedProcessor={processorHook.selectedProcessor}
config={processorHook.processorConfig}
savingPath={processorHook.processorSavingPath}
overwrite={processorHook.processorOverwrite}
onOk={processorHook.handleProcessorOk}
onCancel={processorHook.handleProcessorCancel}
onSelectedProcessorChange={processorHook.setSelectedProcessor}
onConfigChange={processorHook.setProcessorConfig}
onSavingPathChange={processorHook.setProcessorSavingPath}
onOverwriteChange={processorHook.setProcessorOverwrite}
/>
{(ctxMenu || blankCtxMenu) && (
<ContextMenu
x={ctxMenu?.x || blankCtxMenu!.x}
y={ctxMenu?.y || blankCtxMenu!.y}
entry={ctxMenu?.entry}
entries={entries}
selectedEntries={selectedEntries}
processorTypes={processorTypes}
onClose={closeContextMenus}
onOpen={handleOpenEntry}
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, { key: appKey, name: '' } as any)}
onDownload={doDownload}
onRename={setRenaming}
onDelete={(entriesToDelete) => doDelete(entriesToDelete)}
onDetail={openDetail}
onProcess={(entry, type) => {
processorHook.setSelectedProcessor(type);
processorHook.openProcessorModal(entry);
}}
onUpload={handleUploadClick}
onCreateDir={() => setCreatingDir(true)}
onShare={doShare}
/>
)}
<AppWindowsLayer windows={appWindows} onClose={closeWindow} onToggleMax={toggleMax} onBringToFront={bringToFront} onUpdateWindow={updateWindow} />
</div>
);
});
export default FileExplorerPage;
@@ -0,0 +1,143 @@
import React from 'react';
import { Menu, theme } from 'antd';
import type { VfsEntry } from '../../../api/client';
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
import {
FolderFilled, AppstoreOutlined, AppstoreAddOutlined, DownloadOutlined,
EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined, ShareAltOutlined
} from '@ant-design/icons';
interface ContextMenuProps {
x: number;
y: number;
entry?: VfsEntry;
entries: VfsEntry[];
selectedEntries: string[];
processorTypes: any[];
onClose: () => void;
onOpen: (entry: VfsEntry) => void;
onOpenWith: (entry: VfsEntry, appKey: string) => void;
onDownload: (entry: VfsEntry) => void;
onRename: (entry: VfsEntry) => void;
onDelete: (entries: VfsEntry[]) => void;
onDetail: (entry: VfsEntry) => void;
onProcess: (entry: VfsEntry, processorType: string) => void;
onUpload: () => void;
onCreateDir: () => void;
onShare: (entries: VfsEntry[]) => void;
}
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
const { token } = theme.useToken();
const { x, y, entry, entries, selectedEntries, processorTypes, onClose, ...actions } = props;
const getContextMenuItems = () => {
if (!entry) { // Blank context menu
return [
{ key: 'upload', label: '上传文件', icon: <UploadOutlined />, onClick: actions.onUpload },
{ key: 'mkdir', label: '新建目录', icon: <PlusOutlined />, onClick: actions.onCreateDir },
];
}
// Entry context menu
const apps = getAppsForEntry(entry);
const defaultApp = getDefaultAppForEntry(entry);
const targetNames = selectedEntries.includes(entry.name) ? selectedEntries : [entry.name];
const targetEntries = entries.filter(e => targetNames.includes(e.name));
let processorSubMenu: any[] = [];
if (!entry.is_dir && processorTypes.length > 0) {
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
processorSubMenu = processorTypes
.filter(pt => pt.supported_exts.includes(ext))
.map(pt => ({
key: 'processor-' + pt.type,
label: pt.name,
onClick: () => actions.onProcess(entry, pt.type),
}));
}
return [
(entry.is_dir || apps.length > 0) ? {
key: 'open',
label: defaultApp ? `打开 (${defaultApp.name})` : '打开',
icon: <FolderFilled />,
onClick: () => actions.onOpen(entry),
} : null,
!entry.is_dir && apps.length > 0 ? {
key: 'openWith',
label: '打开方式',
icon: <AppstoreOutlined />,
children: apps.map(a => ({
key: 'openWith-' + a.key,
label: a.name + (a.key === defaultApp?.key ? ' (默认)' : ''),
onClick: () => actions.onOpenWith(entry, a.key),
})),
} : null,
!entry.is_dir && processorSubMenu.length > 0 ? {
key: 'process',
label: '处理器',
icon: <AppstoreAddOutlined />,
children: processorSubMenu,
} : null,
{
key: 'share',
label: '分享',
icon: <ShareAltOutlined />,
onClick: () => actions.onShare(targetEntries),
},
{
key: 'download',
label: '下载',
icon: <DownloadOutlined />,
disabled: targetEntries.some(t => t.is_dir) || targetEntries.length > 1,
onClick: () => actions.onDownload(targetEntries[0]),
},
{
key: 'rename',
label: '重命名',
icon: <EditOutlined />,
disabled: targetEntries.length !== 1 || targetEntries[0].type === 'mount',
onClick: () => actions.onRename(targetEntries[0]),
},
{
key: 'delete',
label: '删除',
icon: <DeleteOutlined />,
danger: true,
disabled: targetEntries.some(t => t.type === 'mount'),
onClick: () => actions.onDelete(targetEntries),
},
{
key: 'detail',
label: '详情',
icon: <InfoCircleOutlined />,
onClick: () => actions.onDetail(entry),
},
].filter(Boolean);
};
const items = getContextMenuItems()
.filter(item => item !== null) // Ensure no null items
.map(item => ({
...item,
onClick: () => {
if (item.onClick) item.onClick();
onClose();
}
}));
return (
<div
style={{ position: 'fixed', top: y, left: x, zIndex: 9999, boxShadow: '0 4px 16px rgba(0,0,0,.15)', borderRadius: token.borderRadius, background: token.colorBgElevated }}
onContextMenu={(e) => e.preventDefault()}
onClick={onClose} // Close on any click inside the menu area
>
<Menu
items={items as any[]}
selectable={false}
style={{ width: 160, borderRadius: token.borderRadius, background: 'transparent' }}
/>
</div>
);
};
@@ -0,0 +1,33 @@
import React from 'react';
import { Button, Space, Typography, theme } from 'antd';
import { PlusOutlined, CloudUploadOutlined, ArrowUpOutlined, FolderOpenOutlined } from '@ant-design/icons';
interface Props {
isRoot: boolean;
onCreateDir: () => void;
onGoUp: () => void;
}
export const EmptyState: React.FC<Props> = ({ isRoot, onCreateDir, onGoUp }) => {
const { token } = theme.useToken();
return (
<div style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', padding:isRoot? '80px 40px':'60px 40px', minHeight: isRoot? '400px':'300px', color: token.colorTextSecondary }}>
<FolderOpenOutlined style={{ fontSize:64, color: token.colorTextQuaternary, marginBottom:16 }} />
<Typography.Title level={4} style={{ color: token.colorTextSecondary, marginBottom:8, fontWeight:400 }}>
{isRoot ? '这里还没有任何文件' : '此目录为空'}
</Typography.Title>
<Typography.Text style={{ color: token.colorTextTertiary, marginBottom:24, textAlign:'center', maxWidth:300, lineHeight:1.5 }}>
{isRoot ? '开始上传文件或创建新目录来组织您的内容' : '您可以在此目录中创建新的文件夹或上传文件'}
</Typography.Text>
<Space size={12}>
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateDir}></Button>
<Button icon={<CloudUploadOutlined />} disabled></Button>
</Space>
{!isRoot && (
<div style={{ marginTop:16 }}>
<Button type="link" size="small" icon={<ArrowUpOutlined />} onClick={onGoUp} style={{ color: token.colorTextTertiary }}></Button>
</div>
)}
</div>
);
};
@@ -0,0 +1,249 @@
import React from 'react';
import { Modal, Typography, Spin, theme, Card, Descriptions, Divider, Badge, Space, message } from 'antd';
import { FileOutlined, FolderOutlined, CameraOutlined, InfoCircleOutlined } from '@ant-design/icons';
import type { VfsEntry } from '../../../api/client';
interface Props {
entry: VfsEntry | null;
loading: boolean;
data: any;
onClose: () => void;
}
const exifFieldMap: Record<string, { label: string; format?: (v: any) => string }> = {
'271': { label: '设备品牌' },
'272': { label: '设备型号' },
'306': { label: '拍摄时间' },
'282': { label: '水平分辨率', format: v => `${v} dpi` },
'283': { label: '垂直分辨率', format: v => `${v} dpi` },
'33434': { label: '曝光时间', format: v => `${v}` },
'33437': { label: '光圈值', format: v => `f/${v}` },
'34855': { label: 'ISO' },
'37377': { label: '焦距', format: v => `${v} mm` },
'40962': { label: '宽度', format: v => `${v} px` },
'40963': { label: '高度', format: v => `${v} px` },
};
function renderExif(exif: Record<string, any>) {
const items = Object.entries(exifFieldMap)
.filter(([key]) => exif[key] !== undefined)
.map(([key, { label, format }]) => ({
key,
label,
value: format ? format(exif[key]) : exif[key]
}));
if (items.length === 0) {
return (
<div style={{ textAlign: 'center', padding: 24, color: '#999' }}>
<InfoCircleOutlined style={{ fontSize: 20, marginBottom: 8 }} />
<div>EXIF信息</div>
</div>
);
}
return (
<Descriptions
size="small"
column={1}
bordered
items={items.map(item => ({
key: item.key,
label: <span style={{ fontWeight: 500, color: '#595959' }}>{item.label}</span>,
children: <span style={{ color: '#262626' }}>{item.value}</span>
}))}
contentStyle={{ padding: '8px 12px' }}
labelStyle={{ padding: '8px 12px', backgroundColor: '#fafafa', width: '30%' }}
/>
);
}
function formatFileSize(size: number | string): string {
if (typeof size !== 'number') return String(size);
const units = ['字节', 'KB', 'MB', 'GB'];
let index = 0;
let fileSize = size;
while (fileSize >= 1024 && index < units.length - 1) {
fileSize /= 1024;
index++;
}
return `${fileSize.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose }) => {
const { token } = theme.useToken();
return (
<Modal
title={
<Space>
<InfoCircleOutlined style={{ color: token.colorPrimary }} />
<span></span>
{entry && (
<Typography.Text type="secondary" style={{ fontSize: 14 }}>
- {entry.name}
</Typography.Text>
)}
</Space>
}
open={!!entry}
onCancel={onClose}
footer={null}
width={800}
styles={{
body: { padding: '20px 0px' }
}}
>
{loading ? (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin size="large" />
<div style={{ marginTop: 16, color: token.colorTextSecondary }}>...</div>
</div>
) : data ? (
data.error ? (
<div style={{ textAlign: 'center', padding: 32 }}>
<Typography.Text type="danger" style={{ fontSize: 16 }}>
{data.error}
</Typography.Text>
</div>
) : (
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
{/* 左侧:基本信息 */}
<div style={{ flex: 1 }}>
<Card
size="small"
title={
<Space>
{data.is_dir ? <FolderOutlined /> : <FileOutlined />}
</Space>
}
style={{ borderRadius: 8, height: 'fit-content' }}
>
<Descriptions
column={1}
size="small"
items={[
{
key: 'name',
label: '名称',
children: <Typography.Text strong>{data.name}</Typography.Text>
},
{
key: 'type',
label: '类型',
children: (
<Badge
status={data.is_dir ? 'processing' : 'default'}
text={data.type || (data.is_dir ? '文件夹' : '文件')}
/>
)
},
{
key: 'size',
label: '大小',
children: formatFileSize(data.size)
},
{
key: 'mtime',
label: '修改时间',
children: data.mtime ? (
typeof data.mtime === 'number'
? new Date(data.mtime * 1000).toLocaleString('zh-CN')
: data.mtime
) : '-'
},
{
key: 'path',
label: '路径',
children: (
<Typography.Text style={{ display: 'block', marginTop: 4 }}>
<a
href="#"
onClick={(e) => {
e.preventDefault();
try {
if (navigator.clipboard) {
navigator.clipboard.writeText(data.path).then(() => {
message.success('路径已复制到剪贴板');
}).catch(() => {
message.error('复制失败');
});
} else {
const textarea = document.createElement('textarea');
textarea.value = data.path;
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
message[ok ? 'success' : 'error'](ok ? '路径已复制到剪贴板' : '复制失败');
}
} catch {
message.error('复制失败');
}
}}
style={{
fontSize: 12,
wordBreak: 'break-all',
backgroundColor: token.colorFillAlter,
padding: '4px 8px',
borderRadius: 4,
display: 'inline-block'
}}
>
{data.path}
</a>
</Typography.Text>
)
}
]}
contentStyle={{
fontSize: 14,
color: token.colorText
}}
labelStyle={{
fontWeight: 500,
color: token.colorTextSecondary,
width: '30%'
}}
/>
{data.mode !== undefined && (
<>
<Divider style={{ margin: '12px 0' }} />
<div>
<span style={{ fontWeight: 500, color: token.colorTextSecondary }}></span>
<Typography.Text code>{data.mode.toString(8)}</Typography.Text>
</div>
</>
)}
</Card>
</div>
{/* 右侧:EXIF 信息 */}
{data.exif && (
<div style={{ flex: 1 }}>
<Card
size="small"
title={
<Space>
<CameraOutlined />
EXIF信息
</Space>
}
style={{ borderRadius: 8, height: 'fit-content' }}
>
{renderExif(data.exif)}
</Card>
</div>
)}
</div>
)
) : null}
</Modal>
);
};
export default FileDetailModal;
@@ -0,0 +1,41 @@
import React from 'react';
import {
FileOutlined,
FileImageOutlined,
VideoCameraOutlined,
AudioOutlined,
FileTextOutlined,
FilePdfOutlined,
FileWordOutlined,
FileExcelOutlined,
FilePptOutlined,
FileZipOutlined,
CodeOutlined,
FileMarkdownOutlined,
SettingOutlined,
DatabaseOutlined,
FontSizeOutlined,
} from '@ant-design/icons';
export const getFileIcon = (fileName: string, size: number = 16) => {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
const iconStyle: React.CSSProperties = { fontSize: size, marginRight: size === 16 ? 6 : 0 };
const make = (node: React.ReactNode, color: string) => React.cloneElement(node as any, { style: { ...iconStyle, color } });
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff'].includes(ext)) return make(<FileImageOutlined />, '#52c41a');
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'm4v', '3gp'].includes(ext)) return make(<VideoCameraOutlined />, '#fa541c');
if (['mp3', 'wav', 'flac', 'aac', 'ogg', 'wma', 'm4a'].includes(ext)) return make(<AudioOutlined />, '#722ed1');
if (['pdf'].includes(ext)) return make(<FilePdfOutlined />, '#f5222d');
if (['doc', 'docx'].includes(ext)) return make(<FileWordOutlined />, '#1890ff');
if (['xls', 'xlsx'].includes(ext)) return make(<FileExcelOutlined />, '#52c41a');
if (['ppt', 'pptx'].includes(ext)) return make(<FilePptOutlined />, '#fa8c16');
if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz'].includes(ext)) return make(<FileZipOutlined />, '#faad14');
if (['js','jsx','ts','tsx','vue','html','css','scss','less','json','xml','yaml','yml','py','java','cpp','c','h','php','rb','go','rs','swift','kt'].includes(ext)) return make(<CodeOutlined />, '#13c2c2');
if (['md', 'markdown'].includes(ext)) return make(<FileMarkdownOutlined />, '#1890ff');
if (['txt', 'log', 'ini', 'cfg', 'conf'].includes(ext)) return make(<FileTextOutlined />, '#8c8c8c');
if (['ttf', 'otf', 'woff', 'woff2', 'eot'].includes(ext)) return make(<FontSizeOutlined />, '#eb2f96');
if (['db', 'sqlite', 'sql'].includes(ext)) return make(<DatabaseOutlined />, '#fa541c');
if (['env', 'config', 'properties', 'toml'].includes(ext)) return make(<SettingOutlined />, '#faad14');
return make(<FileOutlined />, '#8c8c8c');
};
@@ -0,0 +1,107 @@
import React from 'react';
import { Table, Dropdown, Button, Tooltip, theme } from 'antd';
import { FolderFilled, MoreOutlined, EditOutlined, DeleteOutlined, AppstoreOutlined, FolderOpenOutlined } from '@ant-design/icons';
import type { VfsEntry } from '../../../api/client';
import { getFileIcon } from './FileIcons';
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
interface FileListViewProps {
entries: VfsEntry[];
loading: boolean;
selectedEntries: string[];
onRowClick: (entry: VfsEntry, e: React.MouseEvent) => void;
onOpen: (entry: VfsEntry) => void;
onOpenWith: (entry: VfsEntry, appKey: string) => void;
onRename: (entry: VfsEntry) => void;
onDelete: (entry: VfsEntry) => void;
onContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void;
}
export const FileListView: React.FC<FileListViewProps> = ({
entries,
loading,
selectedEntries,
onRowClick,
onOpen,
onOpenWith,
onRename,
onDelete,
onContextMenu,
}) => {
const { token } = theme.useToken();
const columns = [
{
title: '名称',
dataIndex: 'name',
key: 'name',
render: (_: any, r: VfsEntry) => (
<span style={{ cursor: 'pointer', userSelect: 'none' }} onDoubleClick={() => onOpen(r)}>
{r.is_dir ? (
<FolderFilled style={{ color: token.colorPrimary, marginRight: 6 }} />
) : (
getFileIcon(r.name, 16)
)}
{r.name}
{r.type === 'mount' && <Tooltip title="挂载点"><span style={{ marginLeft: 6, fontSize: 10, padding: '0 4px', border: `1px solid ${token.colorBorderSecondary}`, borderRadius: 4 }}>MOUNT</span></Tooltip>}
</span>
)
},
{ title: '大小', dataIndex: 'size', width: 100, render: (v: number, r: VfsEntry) => r.is_dir ? '-' : v },
{ title: '修改时间', dataIndex: 'mtime', width: 160, render: (v: number) => v ? new Date(v * 1000).toLocaleString() : '-' },
{
title: '操作',
key: 'actions',
width: 110,
render: (_: any, r: VfsEntry) => {
const apps = getAppsForEntry(r);
const defaultApp = getDefaultAppForEntry(r);
return (
<Dropdown
menu={{
items: [
(r.is_dir || apps.length > 0) ? { key: 'open', label: defaultApp ? `打开(${defaultApp.name})` : '打开', icon: <FolderOpenOutlined />, onClick: () => onOpen(r) } : null,
!r.is_dir && apps.length > 0 ? {
key: 'openWith',
label: '打开方式',
icon: <AppstoreOutlined />,
children: apps.map(a => ({
key: 'openWith-' + a.key,
label: a.name + (a.key === defaultApp?.key ? ' (默认)' : ''),
onClick: () => onOpenWith(r, a.key)
}))
} : null,
{ key: 'rename', label: '重命名', icon: <EditOutlined />, disabled: r.type === 'mount', onClick: () => onRename(r) },
{ key: 'delete', label: '删除', icon: <DeleteOutlined />, danger: true, disabled: r.type === 'mount', onClick: () => onDelete(r) }
].filter(Boolean) as any[]
}}
>
<Button size="small" icon={<MoreOutlined />} />
</Dropdown>
);
}
}
];
return (
<Table
className="fx-file-table"
rowKey={r => r.name}
dataSource={entries}
columns={columns as any}
loading={loading}
pagination={false}
onRow={(r) => ({
onClick: (e: any) => onRowClick(r, e),
onDoubleClick: () => onOpen(r),
onContextMenu: (e) => onContextMenu(e, r)
})}
rowClassName={(r) => selectedEntries.includes(r.name) ? 'row-selected' : ''}
rowSelection={{
selectedRowKeys: selectedEntries,
onChange: () => {
}
}}
/>
);
};
@@ -0,0 +1,155 @@
import React, { useRef, useState, useEffect } from 'react';
import { Tooltip, Spin, theme } from 'antd';
import { FolderFilled, PictureOutlined } from '@ant-design/icons';
import type { VfsEntry } from '../../../api/client';
import { getFileIcon } from './FileIcons';
import { EmptyState } from './EmptyState';
interface Props {
entries: VfsEntry[];
thumbs: Record<string,string>;
// ...existing code...
// selected was single entry before; now use selectedEntries for multi-select
selectedEntries: string[];
loading: boolean;
path: string;
// onSelect: clicked entry, additive indicates Ctrl/Cmd click to toggle
onSelect: (e: VfsEntry, additive?: boolean) => void;
// onSelectRange: called when marquee/selecting multiple by box
onSelectRange: (names: string[]) => void;
onOpen: (e: VfsEntry) => void;
onContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void;
onCreateDir: () => void;
onGoUp: () => void;
}
const formatSize = (size: number) => {
if (size < 1024) return size + ' B';
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB';
if (size < 1024 * 1024 * 1024) return (size / 1024 / 1024).toFixed(1) + ' MB';
return (size / 1024 / 1024 / 1024).toFixed(1) + ' GB';
};
export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, loading, path, onSelect, onSelectRange, onOpen, onContextMenu, onCreateDir, onGoUp }) => {
const { token } = theme.useToken();
// refs for marquee selection
const containerRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Record<string, HTMLDivElement | null>>({});
const startRef = useRef<{x:number,y:number} | null>(null);
const [rect, setRect] = useState<{left:number,top:number,width:number,height:number} | null>(null);
const [selecting, setSelecting] = useState(false);
useEffect(() => {
const onMove = (ev: MouseEvent) => {
if (!startRef.current) return;
const cx = ev.clientX;
const cy = ev.clientY;
const s = startRef.current;
const left = Math.min(s.x, cx);
const top = Math.min(s.y, cy);
const width = Math.abs(cx - s.x);
const height = Math.abs(cy - s.y);
setRect({ left, top, width, height });
};
const onUp = () => { // 不需要 MouseEvent 参数,避免未使用警告
if (!startRef.current) return;
setSelecting(false);
const r = rect;
if (r) {
// compute intersecting items
const container = containerRef.current;
if (container) {
const sel: string[] = [];
entries.forEach(ent => {
const el = itemRefs.current[ent.name];
if (!el) return;
const br = el.getBoundingClientRect();
const rr = { left: r.left, top: r.top, right: r.left + r.width, bottom: r.top + r.height };
const br2 = { left: br.left, top: br.top, right: br.right, bottom: br.bottom };
const intersect = !(br2.left > rr.right || br2.right < rr.left || br2.top > rr.bottom || br2.bottom < rr.top);
if (intersect) sel.push(ent.name);
});
if (sel.length > 0) onSelectRange(sel);
}
}
startRef.current = null;
setRect(null);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
if (selecting) {
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [selecting, rect, entries, onSelectRange]);
const handleMouseDown = (e: React.MouseEvent) => {
// only left button and not on an item actionable element
if (e.button !== 0) return;
// start marquee if click on empty space inside container
const target = e.target as HTMLElement;
if (target.closest('.fx-grid-item')) {
return; // clicks on item handled separately
}
startRef.current = { x: e.clientX, y: e.clientY };
setSelecting(true);
setRect({ left: e.clientX, top: e.clientY, width: 0, height: 0 });
// prevent text selection
e.preventDefault();
};
return (
<div className="fx-grid" style={{ padding: 16 }} ref={containerRef} onMouseDown={handleMouseDown}>
{entries.map(ent => {
const isImg = thumbs[ent.name];
const ext = ent.name.split('.').pop()?.toLowerCase();
const isPictureType = ['png','jpg','jpeg','gif','webp','svg'].includes(ext || '');
const isSelected = selectedEntries.includes(ent.name);
return (
<div
key={ent.name}
ref={(el) => { itemRefs.current[ent.name] = el; }} // 确保函数不返回值,匹配 Ref 类型
className={['fx-grid-item', isSelected ? 'selected' : '', ent.is_dir? 'dir':'file'].join(' ')}
onClick={(ev) => {
// click selection: support ctrl/cmd to toggle
const additive = ev.ctrlKey || ev.metaKey;
onSelect(ent, additive);
}}
onDoubleClick={() => onOpen(ent)}
onContextMenu={(e)=> onContextMenu(e, ent)}
style={{ userSelect:'none' }}
>
<div className="thumb" style={{ background: ent.is_dir ? 'linear-gradient(#fafafa,#f2f2f2)' : '#fff' }}>
{ent.is_dir && <FolderFilled style={{ fontSize:32, color: token.colorPrimary }} />}
{!ent.is_dir && (isImg ? <img src={isImg} alt={ent.name} style={{ maxWidth:'100%', maxHeight:'100%'}} /> : isPictureType ? <PictureOutlined style={{ fontSize:32, color:'#8c8c8c' }} /> : getFileIcon(ent.name,32))}
{ent.type === 'mount' && <span className="badge">M</span>}
</div>
<Tooltip title={ent.name}><div className="name ellipsis" style={{ userSelect:'none' }}>{ent.name}</div></Tooltip>
<div className="meta ellipsis" style={{ fontSize:11, color: token.colorTextSecondary, userSelect:'none' }}>{ent.is_dir ? '目录' : formatSize(ent.size)}</div>
</div>
)
})}
{rect && (
<div
style={{
position: 'fixed',
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
border: '1px dashed rgba(0,0,0,0.4)',
background: 'rgba(0, 120, 212, 0.08)',
zIndex: 999
}}
/>
)}
{loading && <div style={{ width:'100%', textAlign:'center', padding:40 }}><Spin /></div>}
{!loading && entries.length === 0 && <EmptyState isRoot={path==='/' } onCreateDir={onCreateDir} onGoUp={onGoUp} />}
</div>
);
};
@@ -0,0 +1,118 @@
import React, { useState } from 'react';
import { Flex, Typography, Divider, Button, Space, Tooltip, Segmented, Breadcrumb, Input, theme } from 'antd';
import { ArrowUpOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons';
import type { ViewMode } from '../types';
interface HeaderProps {
navKey: string;
path: string;
loading: boolean;
uploading: boolean;
viewMode: ViewMode;
onGoUp: () => void;
onNavigate: (path: string) => void;
onRefresh: () => void;
onCreateDir: () => void;
onUpload: () => void;
onSetViewMode: (mode: ViewMode) => void;
}
export const Header: React.FC<HeaderProps> = ({
navKey,
path,
loading,
uploading,
viewMode,
onGoUp,
onNavigate,
onRefresh,
onCreateDir,
onUpload,
onSetViewMode,
}) => {
const { token } = theme.useToken();
const [editingPath, setEditingPath] = useState(false);
const [pathInputValue, setPathInputValue] = useState('');
const handlePathEdit = () => {
setEditingPath(true);
setPathInputValue(path);
};
const handlePathSubmit = () => {
const trimmed = pathInputValue.trim();
if (trimmed && trimmed !== path) {
onNavigate(trimmed);
}
setEditingPath(false);
};
const handlePathCancel = () => {
setEditingPath(false);
setPathInputValue('');
};
const renderBreadcrumb = () => {
if (editingPath) {
return (
<Input
size="small"
value={pathInputValue}
onChange={(e) => setPathInputValue(e.target.value)}
onPressEnter={handlePathSubmit}
onBlur={handlePathCancel}
onKeyDown={(e) => e.key === 'Escape' && handlePathCancel()}
autoFocus
style={{ flex: 1 }}
/>
);
}
const breadcrumbItems = [
{ key: 'root', title: <span style={{ cursor: 'pointer' }} onClick={() => onNavigate('/')}>Home</span> },
...path.split('/').filter(Boolean).map((segment, index, arr) => {
const segmentPath = '/' + arr.slice(0, index + 1).join('/');
return {
key: segmentPath,
title: <span style={{ cursor: 'pointer' }} onClick={() => onNavigate(segmentPath)}>{segment}</span>
};
})
];
return (
<div
style={{ cursor: 'pointer', padding: '4px 8px', borderRadius: token.borderRadius, transition: 'background-color 0.2s', flex: 1, overflow: 'hidden' }}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = token.colorFillTertiary; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
onClick={handlePathEdit}
>
<Breadcrumb items={breadcrumbItems} separator="/" style={{ fontSize: 12 }} />
</div>
);
};
return (
<Flex align="center" justify="space-between" style={{ padding: '10px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}`, gap: 12 }}>
<Flex align="center" gap={8} style={{ flexWrap: 'wrap', flex: 1, overflow: 'hidden' }}>
<Button size="small" icon={<ArrowUpOutlined />} onClick={onGoUp} disabled={path === '/'} />
<Typography.Text strong>{navKey}</Typography.Text>
<Divider type="vertical" />
{renderBreadcrumb()}
</Flex>
<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>
<Segmented
size="small"
value={viewMode}
onChange={v => onSetViewMode(v as any)}
options={[
{ label: <Tooltip title="网格"><AppstoreOutlined /></Tooltip>, value: 'grid' },
{ label: <Tooltip title="列表"><UnorderedListOutlined /></Tooltip>, value: 'list' }
]}
/>
</Space>
</Flex>
);
};
@@ -0,0 +1,41 @@
import React, { useState, useEffect } from 'react';
import { Modal, Input } from 'antd';
interface CreateDirModalProps {
open: boolean;
onOk: (name: string) => void;
onCancel: () => void;
}
export const CreateDirModal: React.FC<CreateDirModalProps> = ({ open, onOk, onCancel }) => {
const [name, setName] = useState('');
useEffect(() => {
if (open) {
setName('');
}
}, [open]);
const handleOk = () => {
onOk(name);
};
return (
<Modal
title="新建目录"
open={open}
onOk={handleOk}
onCancel={onCancel}
okButtonProps={{ disabled: !name.trim() }}
destroyOnClose
>
<Input
placeholder="目录名称"
value={name}
onChange={e => setName(e.target.value)}
onPressEnter={handleOk}
autoFocus
/>
</Modal>
);
};
@@ -0,0 +1,95 @@
import React from 'react';
import { Modal, Form, Select, Input, Checkbox } from 'antd';
import type { VfsEntry } from '../../../../api/client';
import type { ProcessorTypeMeta } from '../../../../api/processors';
import { ProcessorConfigForm } from '../../../../components/ProcessorConfigForm';
interface ProcessorModalProps {
entry: VfsEntry | null;
visible: boolean;
loading: boolean;
processorTypes: ProcessorTypeMeta[];
selectedProcessor: string;
config: any;
savingPath: string;
overwrite: boolean;
onOk: () => void;
onCancel: () => void;
onSelectedProcessorChange: (type: string) => void;
onConfigChange: (key: string, value: any) => void;
onSavingPathChange: (path: string) => void;
onOverwriteChange: (overwrite: boolean) => void;
}
export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
const {
entry, visible, loading, processorTypes, selectedProcessor, config,
savingPath, overwrite, onOk, onCancel, onSelectedProcessorChange,
onConfigChange, onSavingPathChange, onOverwriteChange
} = props;
const [form] = Form.useForm();
const selectedProcessorMeta = processorTypes.find(pt => pt.type === selectedProcessor);
// Sync form when modal opens or selected processor changes
React.useEffect(() => {
if (visible) {
form.setFieldsValue({
processor_type: selectedProcessor,
config: config,
});
}
}, [visible, selectedProcessor, config, form]);
const handleFormValuesChange = (changedValues: any) => {
if (changedValues.config) {
for (const key in changedValues.config) {
onConfigChange(key, changedValues.config[key]);
}
}
};
return (
<Modal
title={`使用处理器处理文件${entry ? `: ${entry.name}` : ''}`}
open={visible}
onCancel={onCancel}
onOk={onOk}
confirmLoading={loading}
destroyOnClose
>
<Form form={form} layout="vertical" onValuesChange={handleFormValuesChange}>
<Form.Item name="processor_type" label="处理器" required>
<Select
onChange={onSelectedProcessorChange}
options={processorTypes.map(pt => ({ value: pt.type, label: pt.name }))}
placeholder="请选择处理器"
/>
</Form.Item>
<ProcessorConfigForm
processorMeta={selectedProcessorMeta}
form={form}
configPath={['config']}
/>
{selectedProcessorMeta?.produces_file && (
<>
<Form.Item>
<Checkbox checked={overwrite} onChange={e => onOverwriteChange(e.target.checked)}>
</Checkbox>
</Form.Item>
{!overwrite && (
<Form.Item label="保存为新文件">
<Input
value={savingPath}
onChange={e => onSavingPathChange(e.target.value)}
placeholder="如 /newfile.jpg,不填则仅返回处理结果"
/>
</Form.Item>
)}
</>
)}
</Form>
</Modal>
);
};
@@ -0,0 +1,44 @@
import React, { useState, useEffect } from 'react';
import { Modal, Input } from 'antd';
import type { VfsEntry } from '../../../../api/client';
interface RenameModalProps {
entry: VfsEntry | null;
onOk: (entry: VfsEntry, newName: string) => void;
onCancel: () => void;
}
export const RenameModal: React.FC<RenameModalProps> = ({ entry, onOk, onCancel }) => {
const [name, setName] = useState('');
useEffect(() => {
if (entry) {
setName(entry.name);
}
}, [entry]);
const handleOk = () => {
if (entry) {
onOk(entry, name);
}
};
return (
<Modal
title="重命名"
open={!!entry}
onOk={handleOk}
onCancel={onCancel}
okButtonProps={{ disabled: !name.trim() || name.trim() === entry?.name }}
destroyOnClose
>
<Input
placeholder="新的名称"
value={name}
onChange={e => setName(e.target.value)}
onPressEnter={handleOk}
autoFocus
/>
</Modal>
);
};
@@ -0,0 +1,91 @@
import { memo, useState, useEffect } from 'react';
import { Modal, Form, Input, Radio, InputNumber, message } from 'antd';
import type { VfsEntry } from '../../../../api/client';
import { shareApi } from '../../../../api/share';
interface ShareModalProps {
entries: VfsEntry[];
path: string;
open: boolean;
onOk: () => void;
onCancel: () => void;
}
export const ShareModal = memo(function ShareModal({ entries, path, open, onOk, onCancel }: ShareModalProps) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [accessType, setAccessType] = useState('public');
const defaultName = entries.length > 1
? `分享 ${entries.length} 个项目`
: (entries.length === 1 ? entries[0].name : '');
useEffect(() => {
if (open) {
form.setFieldsValue({
name: defaultName,
accessType: 'public',
expiresInDays: 7,
password: '',
});
setAccessType('public');
}
}, [open, defaultName, form]);
const handleOk = async () => {
try {
const values = await form.validateFields();
setLoading(true);
const fullPaths = entries.map(e => {
const p = path === '/' ? '' : path;
return `${p}/${e.name}`;
});
await shareApi.create({
name: values.name,
paths: fullPaths,
access_type: values.accessType,
password: values.password,
expires_in_days: values.expiresInDays,
});
message.success('分享链接已创建');
onOk();
} catch (e: any) {
message.error(e.message || '创建失败');
} finally {
setLoading(false);
}
};
return (
<Modal
title="创建分享"
open={open}
onOk={handleOk}
onCancel={onCancel}
confirmLoading={loading}
destroyOnClose
>
<Form form={form} layout="vertical" initialValues={{ name: defaultName, accessType: 'public', expiresInDays: 7 }}>
<Form.Item name="name" label="分享名称" rules={[{ required: true }]} >
<Input />
</Form.Item>
<Form.Item name="accessType" label="访问权限">
<Radio.Group onChange={(e) => setAccessType(e.target.value)}>
<Radio value="public"></Radio>
<Radio value="password">访</Radio>
</Radio.Group>
</Form.Item>
{accessType === 'password' && (
<Form.Item name="password" label="访问密码" rules={[{ required: true, message: '请输入密码' }]} >
<Input.Password />
</Form.Item>
)}
<Form.Item name="expiresInDays" label="有效期 (天)" help="设置为 0 或负数表示永久有效">
<InputNumber min={-1} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
);
});
@@ -0,0 +1,90 @@
import { useState, useCallback } from 'react';
import { Modal, Checkbox } from 'antd';
import type { VfsEntry } from '../../../api/client';
import type { AppDescriptor } from '../../../apps/registry';
import type { AppWindow } from '../types';
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
export function useAppWindows(path: string) {
const [appWindows, setAppWindows] = useState<AppWindow[]>([]);
const openWithApp = useCallback((entry: VfsEntry, app: AppDescriptor) => {
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
setAppWindows(ws => {
const idx = ws.length;
const bounds = app.defaultBounds || {};
const baseX = bounds.x ?? (160 + idx * 32);
const baseY = bounds.y ?? (100 + idx * 28);
const baseW = bounds.width ?? 640;
const baseH = bounds.height ?? 480;
const vw = window.innerWidth;
const vh = window.innerHeight;
const finalW = Math.min(baseW, vw - 40);
const finalH = Math.min(baseH, vh - 60);
const finalX = Math.min(Math.max(0, baseX), vw - finalW - 8);
const finalY = Math.min(Math.max(48, baseY), vh - finalH - 8);
return [...ws, {
id: Date.now().toString(36) + Math.random().toString(36).slice(2),
app,
entry,
filePath: fullPath,
maximized: !!app.defaultMaximized,
x: finalX,
y: finalY,
width: finalW,
height: finalH
}];
});
}, [path]);
const openFileWithDefaultApp = useCallback((entry: VfsEntry) => {
const apps = getAppsForEntry(entry);
if (!apps.length) {
Modal.error({ title: '无法打开该文件:没有可用的应用' });
return;
}
const defaultApp = getDefaultAppForEntry(entry) || apps[0];
openWithApp(entry, defaultApp);
}, [openWithApp]);
const confirmOpenWithApp = useCallback((entry: VfsEntry, app: AppDescriptor) => {
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
let setDefault = false;
Modal.confirm({
title: `使用 ${app.name} 打开`,
content: (
<div>
<div style={{ marginBottom: 8 }}>: {entry.name}</div>
<Checkbox onChange={e => setDefault = e.target.checked}>(.{ext})</Checkbox>
</div>
),
onOk: () => {
if (setDefault && ext) {
localStorage.setItem(`app.default.${ext}`, app.key);
}
openWithApp(entry, app);
}
});
}, [openWithApp]);
const closeWindow = (id: string) => setAppWindows(ws => ws.filter(w => w.id !== id));
const toggleMax = (id: string) => setAppWindows(ws => ws.map(w => w.id === id ? { ...w, maximized: !w.maximized } : w));
const bringToFront = (id: string) => setAppWindows(ws => {
const target = ws.find(w => w.id === id);
if (!target) return ws;
return [...ws.filter(w => w.id !== id), target];
});
const updateWindow = (id: string, patch: Partial<Omit<AppWindow, 'id' | 'app' | 'entry' | 'filePath'>>) =>
setAppWindows(ws => ws.map(w => w.id === id ? { ...w, ...patch } : w));
return {
appWindows,
openWithApp,
openFileWithDefaultApp,
confirmOpenWithApp,
closeWindow,
toggleMax,
bringToFront,
updateWindow,
};
}
@@ -0,0 +1,30 @@
import { useState, useCallback } from 'react';
import type { VfsEntry } from '../../../api/client';
export function useContextMenu() {
const [ctxMenu, setCtxMenu] = useState<{ entry: VfsEntry; x: number; y: number } | null>(null);
const [blankCtxMenu, setBlankCtxMenu] = useState<{ x: number; y: number } | null>(null);
const openContextMenu = useCallback((e: React.MouseEvent, entry: VfsEntry) => {
e.preventDefault();
setCtxMenu({ entry, x: e.clientX, y: e.clientY });
}, []);
const openBlankContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setBlankCtxMenu({ x: e.clientX, y: e.clientY });
}, []);
const closeContextMenus = useCallback(() => {
setCtxMenu(null);
setBlankCtxMenu(null);
}, []);
return {
ctxMenu,
blankCtxMenu,
openContextMenu,
openBlankContextMenu,
closeContextMenus,
};
}
@@ -0,0 +1,136 @@
import React, { useState, useCallback, useRef } from 'react';
import { message, Modal } from 'antd';
import { vfsApi, type VfsEntry } from '../../../api/client';
interface FileActionsParams {
path: string;
refresh: () => void;
clearSelection: () => void;
onShare: (entries: VfsEntry[]) => void;
}
export function useFileActions({ path, refresh, clearSelection, onShare }: FileActionsParams) {
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const doCreateDir = useCallback(async (name: string) => {
if (!name.trim()) {
message.warning('请输入名称');
return;
}
try {
await vfsApi.mkdir((path === '/' ? '' : path) + '/' + name.trim());
refresh();
} catch (e: any) {
message.error(e.message);
}
}, [path, refresh]);
const doDelete = useCallback(async (entries: VfsEntry[]) => {
Modal.confirm({
title: `确认删除 ${entries.length > 1 ? `${entries.length}` : entries[0].name} ?`,
content: entries.length > 1 ? <div style={{ maxHeight: 180, overflow: 'auto' }}>{entries.map(it => <div key={it.name}>{it.name}{it.type === 'mount' && ' (挂载点)'}</div>)}</div> : null,
onOk: async () => {
try {
await Promise.all(entries.map(it => vfsApi.deletePath((path === '/' ? '' : path) + '/' + it.name)));
clearSelection();
refresh();
} catch (e: any) {
message.error(e.message);
}
}
});
}, [path, refresh, clearSelection]);
const doRename = useCallback(async (entry: VfsEntry, newName: string) => {
if (!newName.trim() || newName.trim() === entry.name) {
return;
}
try {
await vfsApi.rename(
(path === '/' ? '' : path) + '/' + entry.name,
(path === '/' ? '' : path) + '/' + newName.trim()
);
refresh();
} catch (e: any) {
message.error(e.message);
}
}, [path, refresh]);
const doDownload = useCallback(async (entry: VfsEntry) => {
if (entry.is_dir) {
message.warning('暂不支持下载目录');
return;
}
try {
const buf = await vfsApi.readFile((path === '/' ? '' : path) + '/' + entry.name);
const blob = new Blob([buf]);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = entry.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e: any) {
message.error(e.message || '下载失败');
}
}, [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('请选择要分享的文件或目录');
return;
}
onShare(entries);
}, [onShare]);
return {
uploading,
fileInputRef,
doCreateDir,
doDelete,
doRename,
doDownload,
doShare,
handleUploadClick,
handleFilesSelected,
};
}
@@ -0,0 +1,82 @@
import { useState, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { message } from 'antd';
import { vfsApi, type VfsEntry } from '../../../api/client';
import { processorsApi, type ProcessorTypeMeta } from '../../../api/processors';
export function useFileExplorer(navKey: string) {
const navigate = useNavigate();
const location = useLocation();
const [path, setPath] = useState<string>("/");
const [loading, setLoading] = useState(false);
const [entries, setEntries] = useState<VfsEntry[]>([]);
const [processorTypes, setProcessorTypes] = useState<ProcessorTypeMeta[]>([]);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 50,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number, range: [number, number]) => `${total} 项,第 ${range[0]}-${range[1]}`,
pageSizeOptions: ['20', '50', '100', '200']
});
const load = useCallback(async (p: string, page: number = 1, pageSize: number = 50) => {
const canonical = p === '' ? '/' : (p.startsWith('/') ? p : '/' + p);
setLoading(true);
try {
// Load entries and processor types concurrently
const [res, processors] = await Promise.all([
vfsApi.list(canonical === '/' ? '' : canonical, page, pageSize),
processorsApi.list()
]);
setEntries(res.entries);
setPath(res.path || canonical);
setPagination(prev => ({
...prev,
current: res.pagination!.page,
pageSize: res.pagination!.page_size,
total: res.pagination!.total
}));
setProcessorTypes(processors);
} catch (e: any) {
message.error(e.message || '加载失败');
} finally {
setLoading(false);
}
}, []);
const navigateTo = useCallback((p: string) => {
const canonical = p === '' || p === '/' ? '/' : (p.startsWith('/') ? p : '/' + p);
const target = `/${navKey}${canonical === '/' ? '' : canonical}`;
if (location.pathname !== target) navigate(target);
}, [navKey, navigate, location.pathname]);
const goUp = useCallback(() => {
if (path === '/') return;
const parent = path.replace(/\/$/, '').split('/').slice(0, -1).join('/') || '/';
navigateTo(parent);
}, [path, navigateTo]);
const handlePaginationChange = (page: number, pageSize: number) => {
load(path, page, pageSize);
};
const refresh = () => {
load(path, pagination.current, pagination.pageSize);
}
return {
path,
entries,
loading,
pagination,
processorTypes,
load,
navigateTo,
goUp,
handlePaginationChange,
refresh,
};
}
@@ -0,0 +1,36 @@
import { useState, useCallback } from 'react';
import type { VfsEntry } from '../../../api/client';
export function useFileSelection() {
const [selectedEntries, setSelectedEntries] = useState<string[]>([]);
const handleSelect = useCallback((entry: VfsEntry, additive: boolean = false) => {
const name = entry.name;
if (additive) {
// Toggle selection
setSelectedEntries(prev => {
const exists = prev.includes(name);
return exists ? prev.filter(n => n !== name) : [...prev, name];
});
} else {
// Replace selection
setSelectedEntries([name]);
}
}, []);
const handleSelectRange = useCallback((names: string[]) => {
setSelectedEntries(names);
}, []);
const clearSelection = useCallback(() => {
setSelectedEntries([]);
}, []);
return {
selectedEntries,
setSelectedEntries,
handleSelect,
handleSelectRange,
clearSelection,
};
}
@@ -0,0 +1,103 @@
import { useState, useCallback } from 'react';
import { message } from 'antd';
import { processorsApi, type ProcessorTypeMeta } from '../../../api/processors';
import type { VfsEntry } from '../../../api/client';
interface ProcessorParams {
path: string;
processorTypes: ProcessorTypeMeta[];
refresh: () => void;
}
export function useProcessor({ path, processorTypes, refresh }: ProcessorParams) {
const [modal, setModal] = useState<{ entry: VfsEntry | null; visible: boolean }>({ entry: null, visible: false });
const [selectedProcessor, setSelectedProcessor] = useState<string>('');
const [config, setConfig] = useState<any>({});
const [savingPath, setSavingPath] = useState('');
const [overwrite, setOverwrite] = useState(true);
const [loading, setLoading] = useState(false);
const openModal = useCallback((entry: VfsEntry) => {
const ptMeta = processorTypes.find(p => p.type === selectedProcessor);
setModal({ entry, visible: true });
setSavingPath((path === '/' ? '' : path) + '/' + entry.name);
setOverwrite(!!ptMeta?.produces_file);
}, [path, selectedProcessor, processorTypes]);
const handleOk = useCallback(async () => {
if (!modal.entry || !selectedProcessor) return;
setLoading(true);
try {
const schema = processorTypes.find(pt => pt.type === selectedProcessor)?.config_schema || [];
const finalConfig: any = {};
schema.forEach(field => {
let val = config[field.key];
if ((field.type as any) === 'object' && typeof val === 'string') {
try { val = JSON.parse(val); } catch { /* ignore */ }
}
if (val === undefined) val = field.default;
finalConfig[field.key] = val;
});
const params = {
path: (path === '/' ? '' : path) + '/' + modal.entry.name,
processor_type: selectedProcessor,
config: finalConfig,
save_to: overwrite ? undefined : savingPath || undefined,
overwrite: overwrite ? true : undefined,
};
await processorsApi.process(params);
message.success('处理完成');
setModal({ entry: null, visible: false });
if (overwrite || savingPath) refresh();
} catch (e: any) {
message.error(e.message || '处理失败');
} finally {
setLoading(false);
}
}, [modal.entry, selectedProcessor, processorTypes, config, path, overwrite, savingPath, refresh]);
const handleCancel = useCallback(() => {
setModal({ entry: null, visible: false });
setSelectedProcessor('');
setConfig({});
setSavingPath('');
setOverwrite(false);
}, []);
const handleConfigChange = useCallback((key: string, value: any) => {
setConfig((c: any) => ({ ...c, [key]: value }));
}, []);
const handleProcessorTypeChange = useCallback((type: string) => {
setSelectedProcessor(type);
const meta = processorTypes.find(p => p.type === type);
const newConfig: any = {};
if (meta?.config_schema) {
for (const field of meta.config_schema) {
if (field.default !== undefined) {
newConfig[field.key] = field.default;
}
}
}
setConfig(newConfig);
setOverwrite(!!meta?.produces_file);
}, [processorTypes]);
return {
processorModal: modal,
selectedProcessor,
processorConfig: config,
processorSavingPath: savingPath,
processorOverwrite: overwrite,
processorLoading: loading,
openProcessorModal: openModal,
handleProcessorOk: handleOk,
handleProcessorCancel: handleCancel,
setSelectedProcessor: handleProcessorTypeChange,
setProcessorConfig: handleConfigChange,
setProcessorSavingPath: setSavingPath,
setProcessorOverwrite: setOverwrite,
};
}
@@ -0,0 +1,40 @@
import { useState, useEffect } from 'react';
import type { VfsEntry } from '../../../api/client';
import { API_BASE_URL } from '../../../api/client';
const buildThumbUrl = (filePath: string, w = 256, h = 256, fit = 'cover') => {
const origin = API_BASE_URL.replace(/\/+$/, '');
const cleanPath = filePath.replace(/^\/+/, '');
return `${origin}/fs/thumb/${encodeURI(cleanPath)}?w=${w}&h=${h}&fit=${encodeURIComponent(fit)}`;
};
export function useThumbnails(entries: VfsEntry[], path: string) {
const [thumbs, setThumbs] = useState<Record<string, string>>({});
useEffect(() => {
const newThumbs: Record<string, string> = {};
const targets = entries.filter(e => !e.is_dir && (e as any).is_image && !thumbs[e.name]);
if (targets.length > 0) {
targets.forEach(ent => {
const fullPath = (path === '/' ? '' : path) + '/' + ent.name;
newThumbs[ent.name] = buildThumbUrl(fullPath, 256, 256, 'cover');
});
setThumbs(prev => ({ ...prev, ...newThumbs }));
}
// Clean up old thumbs
const currentEntryNames = new Set(entries.map(e => e.name));
const toRemove = Object.keys(thumbs).filter(key => !currentEntryNames.has(key));
if (toRemove.length > 0) {
setThumbs(prev => {
const next = { ...prev };
toRemove.forEach(key => delete next[key]);
return next;
});
}
}, [entries, path, thumbs]);
return { thumbs };
}
+16
View File
@@ -0,0 +1,16 @@
import type { VfsEntry } from '../../api/client';
import type { AppDescriptor } from '../../apps/registry';
export type ViewMode = 'list' | 'grid';
export interface AppWindow {
id: string;
app: AppDescriptor;
entry: VfsEntry;
filePath: string;
maximized: boolean;
x: number;
y: number;
width: number;
height: number;
}