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