mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-05 15:47:00 +08:00
Initial commit
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user