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,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>
);
});