feat: add drawer and add upload feat

This commit is contained in:
xion 2025-03-16 03:39:16 +08:00
parent fd30741151
commit cc76842582
15 changed files with 417 additions and 98 deletions

View File

@ -1,7 +1,42 @@
import { createTheme, ThemeOptions } from '@mui/material/styles';
import { createTheme, Shadows, ThemeOptions } from '@mui/material/styles';
import { useTheme as useMuiTheme, Theme } from '@mui/material/styles';
import { amber } from '@mui/material/colors';
const generateShadows = (color: string): Shadows => {
return [
'none',
`0px 2px 1px -1px ${color}`,
`0px 1px 1px 0px ${color}`,
`0px 1px 3px 0px ${color}`,
`0px 2px 4px -1px ${color}`,
`0px 3px 5px -1px ${color}`,
`0px 3px 5px -1px ${color}`,
`0px 4px 5px -2px ${color}`,
`0px 5px 5px -3px ${color}`,
`0px 5px 6px -3px ${color}`,
`0px 6px 6px -3px ${color}`,
`0px 6px 7px -4px ${color}`,
`0px 7px 8px -4px ${color}`,
`0px 7px 8px -4px ${color}`,
`0px 8px 9px -5px ${color}`,
`0px 8px 9px -5px ${color}`,
`0px 9px 10px -5px ${color}`,
`0px 9px 11px -6px ${color}`,
`0px 10px 12px -6px ${color}`,
`0px 10px 13px -6px ${color}`,
`0px 11px 13px -7px ${color}`,
`0px 11px 14px -7px ${color}`,
`0px 12px 15px -7px ${color}`,
`0px 12px 16px -8px ${color}`,
`0px 13px 17px -8px ${color}`,
];
};
export const themeOptions: ThemeOptions = {
// @ts-ignore
// cssVariables: true,
palette: {
primary: {
main: '#ffc107', // amber[300]
@ -22,6 +57,7 @@ export const themeOptions: ThemeOptions = {
// paper: '#f5f5f5', // 设置纸张背景颜色
},
},
shadows: generateShadows('rgba(255, 193, 7, 0.2)'),
typography: {
// fontFamily: 'Roboto, sans-serif',
},
@ -30,6 +66,22 @@ export const themeOptions: ThemeOptions = {
defaultProps: {
disableRipple: true,
},
styleOverrides: {
root: {
'&:hover': {
backgroundColor: amber[100],
},
},
},
},
MuiButtonGroup: {
styleOverrides: {
root: {
'& .MuiButton-root': {
borderColor: amber[600],
},
},
},
},
MuiTextField: {
styleOverrides: {
@ -51,6 +103,13 @@ export const themeOptions: ThemeOptions = {
},
},
},
MuiCard: {
styleOverrides: {
root: {
// border: `1px solid ${amber[300]}`,
},
},
},
},
};

View File

@ -5,6 +5,9 @@
@apply w-20 h-20 bg-gray-300 rounded-full animate-spin;
}
}
:root {
--scrollbar-color: #ffbf00;
}
#root {
width: 100%;
height: 100%;
@ -18,3 +21,8 @@
z-index: 9999;
pointer-events: none;
}
.scrollbar {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-color) #fff;
}

View File

@ -0,0 +1,16 @@
import { useResourceStore } from '@/pages/store/resource';
import { useResourceFileStore } from '@/pages/store/resource-file';
import { Drawer } from '@mui/material';
export const FileDrawer = () => {
const { prefix } = useResourceStore();
const { resource, openDrawer, setOpenDrawer } = useResourceFileStore();
return (
<Drawer open={openDrawer} onClose={() => setOpenDrawer(false)} anchor='right' {...(!openDrawer && { inert: true })}>
<div className='p-4 w-[600px]'>
<h2 className='text-2xl font-bold'>{resource?.name ? resource.name.replace(prefix, '') : resource?.prefix?.replace(prefix, '')}</h2>
<pre className='flex flex-col gap-2'>{JSON.stringify(resource, null, 2)}</pre>
</div>
</Drawer>
);
};

View File

@ -1,50 +1,93 @@
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { useResourceStore } from '../store/resource';
import { useSettingsStore } from '../store/settings';
import { Box, Button, Card, CardContent, Typography, ButtonGroup, useTheme } from '@mui/material';
import { FileText, Image, File, Table, Grid } from 'lucide-react';
import { getIcon } from './FileIcon';
import { Box, Button, Typography, ButtonGroup } from '@mui/material';
import { FileText, Table, Grid } from 'lucide-react';
import { FileTable } from './list/FileTable';
import { FileCard } from './list/FileCard';
import { PrefixRedirect } from './modules/PrefixRedirect';
import { UploadButton } from '../upload';
import { FileDrawer } from './draw/FileDrawer';
import { useResourceFileStore } from '../store/resource-file';
export const FileApp = () => {
const { list, getList, prefix, setListType, listType } = useResourceStore();
const { settings } = useSettingsStore();
const { getList, prefix, setListType, listType } = useResourceStore();
const { getStatFile, prefix: statPrefix, openDrawer } = useResourceFileStore();
useEffect(() => {
getList();
}, []);
const theme = useTheme();
const directory = useMemo(() => {
const _prefix = prefix.split('/');
let dir = _prefix.slice(2).join('/');
if (dir.endsWith('/')) {
dir = dir.slice(0, -1);
}
return dir;
}, [prefix]);
useEffect(() => {
if (statPrefix && openDrawer) {
getStatFile();
}
}, [statPrefix, openDrawer]);
const handleUpload = (res: any) => {
getList();
};
return (
<Box sx={{ padding: 2, backgroundColor: 'white', borderRadius: 2, marginTop: 4, boxShadow: '0 0 10px 0 rgba(0, 0, 0, 0.1)' }}>
<div className='flex items-center gap-3 mb-8'>
<FileText className='w-8 h-8 text-amber-600' />
<Typography
variant='h1'
className='text-amber-900'
<div className='h-full py-4 px-2 w-full'>
<Box
sx={{
borderRadius: 2,
display: 'flex',
marginBottom: 4,
height: '100%',
flexDirection: 'column',
}}>
<div className='flex items-center gap-3 mb-8'>
<FileText className='w-8 h-8 text-amber-600' />
<Typography
variant='h1'
className='text-amber-900'
sx={{
fontSize: { xs: '1.3rem', sm: '2rem' },
fontWeight: 'bold',
}}>
Resources
</Typography>
</div>
<Box className='flex items-center gap-2 mb-4'>
<PrefixRedirect />
<UploadButton prefix={directory} onUpload={handleUpload} />
<ButtonGroup className='ml-auto' variant='contained' color='primary' sx={{ color: 'white' }}>
<Button
variant={listType === 'table' ? 'contained' : 'outlined'}
sx={{
'& > svg': {
color: listType === 'table' ? 'white' : 'inherit',
},
}}
onClick={() => setListType('table')}>
<Table />
</Button>
<Button
variant={listType === 'card' ? 'contained' : 'outlined'}
sx={{
'& > svg': {
color: listType === 'card' ? 'white' : 'inherit',
},
}}
onClick={() => setListType('card')}>
<Grid />
</Button>
</ButtonGroup>
</Box>
<Box
className='scrollbar'
sx={{
fontSize: { xs: '1.3rem', sm: '2rem' },
fontWeight: 'bold',
height: 'calc(100% - 80px)',
overflow: 'auto',
}}>
Resources
</Typography>
</div>
<Box className='flex items-center gap-2 mb-4'>
<Typography variant='h5' sx={{ fontWeight: 'bold', color: theme.palette.primary.main }}>
<span className='mr-2' style={{ color: theme.palette.secondary.main }}>
Prefix:
</span>
{prefix}
</Typography>
<ButtonGroup className='ml-auto' variant='contained' color='primary' sx={{ color: 'white' }}>
<Button variant={listType === 'table' ? 'contained' : 'outlined'} onClick={() => setListType('table')}>
<Table />
</Button>
<Button variant={listType === 'card' ? 'contained' : 'outlined'} onClick={() => setListType('card')}>
<Grid />
</Button>
</ButtonGroup>
{listType === 'card' ? <FileCard /> : <FileTable />}
</Box>
</Box>
<div>{listType === 'card' ? <FileCard /> : <FileTable />}</div>
</Box>
<FileDrawer />
</div>
);
};

View File

@ -1,21 +1,40 @@
import { useResourceStore } from '@/pages/store/resource';
import { Card, CardContent, Typography } from '@mui/material';
import { getIcon } from '../FileIcon';
import { amber } from '@mui/material/colors';
import clsx from 'clsx';
import { useResourceFileStore } from '@/pages/store/resource-file';
export const FileCard = () => {
const { list, prefix } = useResourceStore();
const { list, prefix, onOpenPrefix } = useResourceStore();
const { setPrefix, setOpenDrawer } = useResourceFileStore();
return (
<>
{list.map((resource) => (
<Card key={resource.etag} style={{ margin: '10px' }}>
<Card
key={resource.etag || resource.name || resource.prefix}
sx={{
boxShadow: `0px 2px 1px -1px rgba(255, 193, 7, 0.2), 0px 1px 1px 0px rgba(255, 193, 7, 0.14), 0px 1px 3px 0px rgba(255, 193, 7, 0.12)`,
borderRadius: '8px',
}}
style={{ margin: '10px' }}>
<CardContent>
<Typography
variant='h5'
component='div'
// className='flex items-center gap-2'
>
{getIcon(resource.name)}
{resource.name ? resource.name.replace(prefix, '') : resource.prefix?.replace(prefix, '')}
className={clsx('flex items-center gap-2', {
'cursor-pointer': true,
})}
onClick={(e) => {
if (!resource.name) {
onOpenPrefix(resource.prefix || '');
} else {
setPrefix(resource.name || '');
setOpenDrawer(true);
}
e.stopPropagation();
}}>
<div className='shrink-0'>{getIcon(resource.name)}</div>
<div className='flex-1 truncate'>{resource.name ? resource.name.replace(prefix, '') : resource.prefix?.replace(prefix, '')}</div>
</Typography>
{resource.lastModified && <Typography color='text.secondary'>Last Modified: {resource.lastModified}</Typography>}
{resource.size > 0 && <Typography color='text.secondary'>Size: {resource.size} bytes</Typography>}

View File

@ -3,48 +3,87 @@ import { Button, Paper, Table, TableBody, TableCell, TableContainer, TableHead,
import prettyBytes from 'pretty-bytes';
import dayjs from 'dayjs';
import { getIcon } from '../FileIcon';
import { Download, Trash } from 'lucide-react';
import clsx from 'clsx';
import { useResourceFileStore } from '@/pages/store/resource-file';
export const FileTable = () => {
const { list, prefix, download } = useResourceStore();
const { list, prefix, download, onOpenPrefix, deleteFile } = useResourceStore();
const { setOpenDrawer, setPrefix } = useResourceFileStore();
return (
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }} aria-label='simple table'>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell sx={{ minWidth: 100 }}>Size</TableCell>
<TableCell sx={{ minWidth: 100 }}>Last Modified</TableCell>
<TableCell sx={{ minWidth: 100 }}>Actions</TableCell>
<TableCell sx={{ maxWidth: 100 }}>Size</TableCell>
<TableCell sx={{ maxWidth: 100 }}>Last Modified</TableCell>
<TableCell sx={{ maxWidth: 100 }}>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{list.map((row) => (
<TableRow key={row.name}>
<TableCell>
<div className='flex items-center gap-2'>
{getIcon(row.name)}
{row.name ? row.name.replace(prefix, '') : row.prefix?.replace?.(prefix, '')}
</div>
</TableCell>
<TableCell>{row.size ? prettyBytes(row.size) : ''}</TableCell>
<TableCell>{row.lastModified ? dayjs(row.lastModified).format('YYYY-MM-DD HH:mm:ss') : ''}</TableCell>
<TableCell>
{!row.prefix ? (
<Button
variant='contained'
color='primary'
onClick={() => download(row)}
sx={{
color: 'white',
{list.map((row) => {
const isFile = !!row.name;
return (
<TableRow key={row.etag || row.name || row.prefix}>
<TableCell>
<div
className={clsx('flex items-center gap-2 max-w-[300px] line-clamp-2 text-ellipsis', {
'cursor-pointer': true,
})}
onClick={(e) => {
if (!row.name) {
onOpenPrefix(row.prefix || '');
} else {
setPrefix(row.name || '');
setOpenDrawer(true);
}
e.stopPropagation();
}}>
Download
</Button>
) : (
''
)}
</TableCell>
</TableRow>
))}
<div className='shrink-0'>{getIcon(row.name)}</div>
{row.name ? row.name.replace(prefix, '') : row.prefix?.replace?.(prefix, '')}
</div>
</TableCell>
<TableCell>{row.size ? prettyBytes(row.size) : ''}</TableCell>
<TableCell>{row.lastModified ? dayjs(row.lastModified).format('YYYY-MM-DD HH:mm:ss') : ''}</TableCell>
<TableCell>
{isFile && (
<Button
variant='contained'
color='primary'
onClick={(e) => {
e.stopPropagation();
download(row);
}}
sx={{
color: 'white',
minWidth: '32px',
padding: '4px',
}}>
<Download />
</Button>
)}
{isFile && (
<Button
variant='contained'
color='error'
className='ml-2!'
onClick={(e) => {
e.stopPropagation();
deleteFile(row);
}}
sx={{
color: 'white',
minWidth: '32px',
padding: '4px',
}}>
<Trash />
</Button>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>

View File

@ -0,0 +1,35 @@
import { useResourceStore } from '@/pages/store/resource';
import { Breadcrumbs, Typography } from '@mui/material';
import clsx from 'clsx';
import { useMemo } from 'react';
export const PrefixRedirect = () => {
const { prefix, onOpenPrefix } = useResourceStore();
const prefixCom = useMemo(() => {
const _prefix = prefix.split('/').filter(Boolean);
return _prefix.map((item, index) => {
const path = _prefix.slice(0, index + 1).join('/');
const onClick = () => {
console.log('path', path);
const openPath = path + '/';
if (openPath !== prefix) {
onOpenPrefix(openPath);
}
};
return {
name: item,
path,
onClick: index > 0 ? onClick : undefined,
};
});
}, [prefix]);
return (
<Breadcrumbs>
{prefixCom.map((item) => (
<Typography variant='h5' key={item.name} className={clsx({ 'cursor-pointer': item.onClick })} onClick={item.onClick}>
{item.name}
</Typography>
))}
</Breadcrumbs>
);
};

View File

@ -106,7 +106,9 @@ export const Left = ({ children }: LeftProps) => {
</Box>
</Box>
</Box>
<Container sx={{ flexGrow: 1 }}>{children}</Container>
<Box sx={{ flexGrow: 1, height: '100vh', overflow: 'hidden' }}>
<Container sx={{ width: '100%', height: '100%' }}>{children}</Container>
</Box>
</Box>
);
};

View File

@ -18,5 +18,5 @@ export const Main = () => {
if (activeMenu === ActiveMenu.Statistic) {
return <Statistic />;
}
return <div>{activeMenu}</div>;
return <div className='h-full'>{activeMenu}</div>;
};

View File

@ -80,11 +80,11 @@ export const Settings = () => {
<FormText label='Key' value={config.key} onChange={handleChange} focused={true} />
</Box>
<Box mb={2}>
<FormText label='Version' value={config.version} onChange={handleChange} disabled={true} />
<FormText label='Version' value={config.version} onChange={handleChange} disabled={false} />
</Box>
<Box mb={2}>
{/* <Box mb={2}>
<FormText label='Username' value={config.username} onChange={handleChange} disabled={true} />
</Box>
</Box> */}
<Box mb={2}>
<FormText label='Prefix' value={config.prefix} onChange={handleChange} disabled={true} />
</Box>

View File

@ -0,0 +1,35 @@
import { create } from 'zustand';
import { Resource } from './resource';
import { query } from '@/modules/query';
interface ResourceFileStore {
resource: Resource | null;
setResource: (resource: Resource) => void;
openDrawer: boolean;
setOpenDrawer: (openDrawer: boolean) => void;
prefix: string;
setPrefix: (prefix: string, replace?: string) => void;
getStatFile: () => Promise<any>;
}
export const useResourceFileStore = create<ResourceFileStore>((set, get) => ({
resource: null,
setResource: (resource) => set({ resource }),
openDrawer: false,
setOpenDrawer: (openDrawer) => set({ openDrawer }),
prefix: '',
setPrefix: (prefix, replace) => set({ prefix: replace ? prefix.replace(replace, '') : prefix }),
getStatFile: async () => {
const { prefix } = get();
const res = await query.post({
path: 'file',
key: 'stat',
data: {
prefix,
},
});
if (res.code === 200) {
set({ resource: { ...res.data, name: prefix } });
}
},
}));

View File

@ -19,11 +19,17 @@ interface ResourceStore {
setList: (list: Resource[]) => void;
prefix: string;
setPrefix: (prefix: string) => void;
getList: () => Promise<void>;
download: (resource: Resource) => void;
listType: 'table' | 'card';
setListType: (listType: 'table' | 'card') => void;
init: () => void;
/**
*
* @param prefix
*/
onOpenPrefix: (prefix: string) => void;
getList: () => Promise<void>;
deleteFile: (resource: Resource) => Promise<void>;
}
export const useResourceStore = create<ResourceStore>((set, get) => ({
@ -76,4 +82,29 @@ export const useResourceStore = create<ResourceStore>((set, get) => ({
set({ listType: listType as 'table' | 'card' });
}
},
onOpenPrefix: (prefix: string) => {
set({ prefix });
get().getList();
},
deleteFile: async (resource: Resource) => {
console.log('deleteFile', resource);
const name = resource.name;
if (!name) {
toast.error('Resource is not a file');
return;
}
const res = await query.post({
path: 'file',
key: 'delete',
data: {
prefix: name,
},
});
if (res.code === 200) {
get().getList();
toast.success('Delete file success');
} else {
toast.error(res.message || 'Request failed');
}
},
}));

View File

@ -1,14 +1,39 @@
import { Box, useTheme, Container, Typography } from '@mui/material';
import { Box, useTheme, Container, Typography, Button } from '@mui/material';
import { useDropzone } from 'react-dropzone';
import { uploadFiles } from './utils/upload';
import { FileText, CloudUpload as UploadIcon } from 'lucide-react';
import { uploadFileChunked } from './utils/upload-chunk';
export const UploadButton = (props: { prefix?: string; onUpload?: (res: any) => void }) => {
const onDrop = async (acceptedFiles) => {
console.log(acceptedFiles);
if (acceptedFiles.length > 1) {
const res = await uploadFiles(acceptedFiles, { directory: props.prefix });
console.log('uploadFiles res', res);
props.onUpload?.(res);
} else if (acceptedFiles.length === 1) {
const res = await uploadFileChunked(acceptedFiles[0], { directory: props.prefix });
console.log('uploadFiles res', res);
props.onUpload?.(res);
}
};
const { getRootProps, getInputProps } = useDropzone({ onDrop });
return (
<Box {...getRootProps()}>
<Button
color='primary'
sx={{
minWidth: 'unset',
padding: '2px',
}}>
<UploadIcon />
</Button>
<input type='file' style={{ display: 'none' }} {...getInputProps()} />
</Box>
);
};
export const Upload = () => {
const onDrop = async (acceptedFiles) => {
console.log(acceptedFiles);
// Handle the files here
// const res = await uploadFiles(acceptedFiles, {});
if (acceptedFiles.length > 1) {
const res = await uploadFiles(acceptedFiles, {});
console.log('uploadFiles res', res);

View File

@ -8,9 +8,11 @@ type ConvertOpts = {
appKey?: string;
version?: string;
username?: string;
directory?: string;
};
export const uploadFileChunked = async (file: File, opts: ConvertOpts) => {
const { directory } = opts;
return new Promise(async (resolve, reject) => {
const token = localStorage.getItem('token');
if (!token) {
@ -22,7 +24,8 @@ export const uploadFileChunked = async (file: File, opts: ConvertOpts) => {
const filename = file.name;
const load = toast.loading(`${filename} 上传中...`);
NProgress.start();
const eventSource = new EventSource('http://49.232.155.236:11015/api/s1/events?taskId=' + taskId);
// const eventSource = new EventSource('http://49.232.155.236:11015/api/s1/events?taskId=' + taskId);
const eventSource = new EventSource('/api/s1/events?taskId=' + taskId);
// 监听服务器推送的进度更新
eventSource.onmessage = function (event) {
console.log('Progress update:', event.data);
@ -60,7 +63,9 @@ export const uploadFileChunked = async (file: File, opts: ConvertOpts) => {
formData.append('file', chunk, file.name);
formData.append('chunkIndex', currentChunk.toString());
formData.append('totalChunks', totalChunks.toString());
if (directory) {
formData.append('directory', directory);
}
try {
const res = await fetch('/api/s1/resources/upload/chunk?taskId=' + taskId, {
method: 'POST',
@ -70,19 +75,17 @@ export const uploadFileChunked = async (file: File, opts: ConvertOpts) => {
Authorization: `Bearer ${token}`,
},
}).then((response) => response.json());
console.log(`Chunk ${currentChunk + 1}/${totalChunks} uploaded`, res);
fetch('/api/s1/events/close?taskId=' + taskId);
eventSource.close();
NProgress.done();
toast.dismiss(load);
resolve(res);
// console.log(`Chunk ${currentChunk + 1}/${totalChunks} uploaded`, res);
} catch (error) {
console.log('Error uploading chunk', error);
reject(error);
return;
}
}
fetch('/api/s1/events/close?taskId=' + taskId);
eventSource.close();
NProgress.done();
toast.dismiss(load);
resolve({ message: 'All chunks uploaded successfully' });
});
};

View File

@ -8,13 +8,18 @@ type ConvertOpts = {
appKey?: string;
version?: string;
username?: string;
directory?: string;
};
export const uploadFiles = async (files: File[], opts: ConvertOpts) => {
const { directory } = opts;
return new Promise((resolve, reject) => {
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append('file', files[i], files[i].name);
}
if (directory) {
formData.append('directory', directory);
}
const token = localStorage.getItem('token');
if (!token) {
toastLogin();
@ -23,8 +28,8 @@ export const uploadFiles = async (files: File[], opts: ConvertOpts) => {
const taskId = nanoid();
// 49.232.155.236:11015
// const eventSource = new EventSource('https://kevisual.silkyai.cn/api/s1/events?taskId=' + taskId);
// const eventSource = new EventSource('/api/s1/events?taskId=' + taskId);
const eventSource = new EventSource('http://49.232.155.236:11015/api/s1/events?taskId=' + taskId);
const eventSource = new EventSource('/api/s1/events?taskId=' + taskId);
// const eventSource = new EventSource('http://49.232.155.236:11015/api/s1/events?taskId=' + taskId);
const load = toast.loading('上传中...');
NProgress.start();
eventSource.onopen = async function (event) {
@ -63,7 +68,6 @@ export const uploadFiles = async (files: File[], opts: ConvertOpts) => {
if (progress) {
NProgress.set(progress);
}
};
eventSource.onerror = function (event) {
console.log('eventSource.onerror', event);