update resources

This commit is contained in:
2025-03-27 19:41:28 +08:00
parent d649666379
commit 43d612fff3
35 changed files with 1862 additions and 764 deletions

View File

@@ -1,31 +0,0 @@
{
"name": "@kevisual/components",
"version": "0.0.1",
"description": "center components",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"files": [
"src"
],
"keywords": [],
"author": "abearxiong <xiongxiao@xiongxiao.me>",
"license": "MIT",
"type": "module",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/material": "^6.4.7",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-hook-form": "^7.54.2"
},
"exports": {
".": "./src/index.tsx",
"./*": "./src/*"
},
"devDependencies": {
"clsx": "^2.1.1",
"tailwind-merge": "^3.0.2"
}
}

View File

@@ -1,25 +0,0 @@
import MuiButton, { ButtonProps } from '@mui/material/Button';
export const Button = (props: ButtonProps) => {
return <MuiButton {...props} />;
};
export const IconButton = (props: ButtonProps) => {
const { variant = 'contained', color = 'primary', sx, children, ...rest } = props;
return (
<MuiButton variant={variant} color={color} {...rest} sx={{ color: 'white', minWidth: '32px', padding: '8px', ...sx }}>
{children}
</MuiButton>
);
};
export const IconButtonItem = (props: ButtonProps) => {
const { variant = 'contained', size = 'small', color = 'primary', sx, children, ...rest } = props;
return (
<MuiButton {...props} >
{/* <MuiButton variant={'contained'} size={size} color={color} {...rest} sx={{ color: 'white', ...sx }}> */}
{children}
</MuiButton>
);
};

View File

@@ -1,17 +0,0 @@
import clsx from 'clsx';
import twMerge from 'tailwind-merge';
type CardBlankProps = {
number?: number;
className?: string;
};
export const CardBlank = (props: CardBlankProps) => {
const { number = 4, className } = props;
return (
<>
{new Array(number).fill(0).map((_, index) => {
return <div key={index} className={clsx('w-[300px] shark-0', className)}></div>;
})}
</>
);
};

View File

@@ -1,7 +0,0 @@
import clsx, { ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export const clsxMerge = (...args: ClassValue[]) => {
return twMerge(clsx(...args));
};
export { clsx };

View File

@@ -1,6 +0,0 @@
export * from './theme';
/**
* 输入组件, 使用theme的defaultProps
*/
export * from './input/TextField';

View File

@@ -1,23 +0,0 @@
import { TextField as MuiTextField, TextFieldProps, Tooltip } from '@mui/material';
import { useTheme } from '../theme';
import { HelpCircle } from 'lucide-react';
export const TextField = (props: TextFieldProps) => {
const theme = useTheme();
const defaultProps = theme.components?.MuiTextField?.defaultProps;
return <MuiTextField {...defaultProps} {...props} />;
};
export const TextFieldLabel = ({ children, tips, label }: { children?: React.ReactNode; tips?: string; label?: any }) => {
return (
<div className='flex items-center gap-1'>
{label}
{children}
{tips && (
<Tooltip title={tips}>
<HelpCircle size={16} />
</Tooltip>
)}
</div>
);
};

View File

@@ -1,47 +0,0 @@
import { FormControlLabel, TextField } from '@mui/material';
import { useForm, Controller } from 'react-hook-form';
export const InputControl = ({ name, value, onChange }: { name: string; value: string; onChange?: (value: string) => void }) => {
return (
<TextField
variant='outlined'
size='small'
name={name}
value={value || ''}
onChange={(e) => onChange?.(e.target.value)}
sx={{
width: '100%',
marginBottom: '16px',
}}
/>
);
};
type FormProps = {
onSubmit?: (data: any) => void;
children?: React.ReactNode;
};
export const FormDemo = (props: FormProps) => {
const { control, handleSubmit } = useForm();
const { onSubmit = () => {}, children } = props;
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name='name'
control={control}
defaultValue=''
rules={{ required: 'Name is required' }}
render={({ field, fieldState: { error } }) => (
<TextField
{...field}
label='Name'
variant='outlined'
margin='normal'
fullWidth //
error={!!error}
helperText={<>{error?.message}</>}
/>
)}
/>
</form>
);
};

View File

@@ -1,99 +0,0 @@
import { Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Button } from '@mui/material';
import { useRef, useState } from 'react';
import type { ModalFuncProps } from 'antd';
export const Confirm = ({
open,
onClose,
title,
content,
onConfirm,
okText = '确认',
cancelText = '取消',
}: {
open: boolean;
onClose: () => void;
title: string;
content: string;
onConfirm?: () => void;
okText?: string;
cancelText?: string;
}) => {
return (
<Dialog
open={open}
onClose={onClose}
aria-labelledby='alert-dialog-title'
aria-describedby='alert-dialog-description'>
<DialogTitle id='alert-dialog-title' className='text-secondary min-w-[300px]'>
{title}
</DialogTitle>
<DialogContent>
<DialogContentText id='alert-dialog-description'>{content}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color='primary'>
{cancelText || '取消'}
</Button>
<Button onClick={onConfirm} variant='contained' color='primary' autoFocus>
{okText || '确认'}
</Button>
</DialogActions>
</Dialog>
);
};
type Fn = () => void;
export const useModal = () => {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const fns = useRef<{
onConfirm: Fn;
onCancel: Fn;
okText: string;
cancelText: string;
}>({
onConfirm: () => {},
onCancel: () => {},
okText: '确认',
cancelText: '取消',
});
const modal = {
confirm: (props: ModalFuncProps) => {
setOpen(true);
setTitle(props.title as string);
setContent(props.content as string);
fns.current.onConfirm = async () => {
const isClose = await props.onOk?.();
if (!isClose) {
setOpen(false);
}
};
fns.current.onCancel = async () => {
await props.onCancel?.();
setOpen(false);
};
fns.current.okText = props.okText as string;
fns.current.cancelText = props.cancelText as string;
},
cancel: () => {
setOpen(false);
fns.current.onCancel();
},
};
const contextHolder = (
<Confirm
open={open}
okText={fns.current.okText}
cancelText={fns.current.cancelText}
onClose={() => {
setOpen(false);
fns.current.onCancel();
}}
title={title}
content={content}
onConfirm={fns.current.onConfirm}
/>
);
return [modal, contextHolder] as [typeof modal, React.ReactNode];
};

View File

@@ -1,58 +0,0 @@
import { Fragment, useEffect, useState } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import { TextField, Chip } from '@mui/material';
type TagsInputProps = {
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
label?: any;
showLabel?: boolean;
};
export const TagsInput = ({ value, onChange, placeholder = '', label = '', showLabel = false }: TagsInputProps) => {
const [tags, setTags] = useState<string[]>(value);
useEffect(() => {
setTags(value);
}, [value]);
const randomid = () => {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
};
return (
<Autocomplete
multiple
freeSolo
options={[]}
value={tags}
onChange={(event, newValue) => {
// setTags(newValue as string[]);
onChange(newValue as string[]);
}}
sx={{
width: '100%',
}}
renderTags={(value: string[], getTagProps) => {
const id = randomid();
const com = value.map((option: string, index: number) => (
<Chip
variant='outlined'
sx={{
borderColor: 'primary.main',
borderRadius: '4px',
'&:hover': {
borderColor: 'primary.main',
},
'& .MuiChip-deleteIcon': {
color: 'secondary.main',
},
}}
label={option}
{...getTagProps({ index })}
key={`${id}-${index}`}
/>
));
return <Fragment key={id}>{com}</Fragment>;
}}
renderInput={(params) => <TextField {...params} label={showLabel ? label : ''} placeholder={placeholder} />}
/>
);
};

View File

@@ -1,20 +0,0 @@
import { MenuItem, Select as MuiSelect, SelectProps as MuiSelectProps } from '@mui/material';
import React from 'react';
type SelectProps = {
options?: { label: string; value: string }[];
} & MuiSelectProps;
export const Select = React.forwardRef((props: SelectProps, ref) => {
const { options, ...rest } = props;
console.log(props, 'props');
return (
<MuiSelect {...rest} ref={ref}>
{options?.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</MuiSelect>
);
});

View File

@@ -1,226 +0,0 @@
import { createTheme, Shadows, ThemeOptions } from '@mui/material/styles';
import { useTheme as useMuiTheme, Theme } from '@mui/material/styles';
import { amber, red } from '@mui/material/colors';
import { ThemeProvider } from '@mui/material/styles';
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}`,
];
};
const primaryMain = amber[300]; // #ffc107
const secondaryMain = amber[500]; // #ffa000
export const themeOptions: ThemeOptions = {
// @ts-ignore
// cssVariables: true,
palette: {
primary: {
main: primaryMain, // amber[300]
},
secondary: {
main: secondaryMain, // amber[500]
},
divider: amber[200],
common: {
white: secondaryMain,
},
text: {
primary: amber[600],
secondary: amber[600],
},
background: {
default: '#ffffff', // 设置默认背景颜色
// paper: '#f5f5f5', // 设置纸张背景颜色
},
error: {
main: red[500], // 设置错误颜色 "#f44336"
},
},
shadows: generateShadows('rgba(255, 193, 7, 0.2)'),
typography: {
// fontFamily: 'Roboto, sans-serif',
},
components: {
MuiButtonBase: {
defaultProps: {
disableRipple: true,
},
styleOverrides: {
root: {
'&:hover': {
backgroundColor: amber[100],
},
'&.MuiButton-contained': {
color: '#ffffff',
':hover': {
color: secondaryMain,
},
},
},
},
},
MuiButtonGroup: {
styleOverrides: {
root: {
'& .MuiButton-root': {
borderColor: amber[600],
},
},
},
},
MuiTextField: {
defaultProps: {
fullWidth: true,
size: 'small',
slotProps: {
inputLabel: {
shrink: true,
},
},
},
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderColor: amber[300],
},
'&:hover fieldset': {
borderColor: amber[500],
},
'& .MuiInputBase-input': {
color: amber[600],
},
},
'& .MuiInputLabel-root': {
color: amber[600],
},
},
},
},
MuiAutocomplete: {
defaultProps: {
size: 'small',
},
},
MuiSelect: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-notchedOutline': {
borderColor: amber[300],
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: amber[500],
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: amber[500],
},
'& .MuiSelect-icon': {
color: amber[500], // Set arrow icon color to primary
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
// border: `1px solid ${amber[300]}`,
},
},
},
MuiIconButton: {
styleOverrides: {
root: {
color: '#ffffff', // Set default font color to white
},
},
},
MuiFormControlLabel: {
defaultProps: {
labelPlacement: 'top',
},
styleOverrides: {
root: {
color: amber[600],
alignItems: 'flex-start',
'& .MuiFormControlLabel-label': {
textAlign: 'left',
width: '100%',
},
'& .MuiFormControlLabel-root': {
width: '100%',
},
'& .MuiInputBase-root': {
width: '100%',
},
},
},
},
MuiMenuItem: {
styleOverrides: {
root: {
'&.Mui-selected': {
backgroundColor: amber[500],
color: '#ffffff',
'&:hover': {
backgroundColor: amber[600],
color: '#ffffff',
},
},
},
},
},
},
};
/**
* https://bareynol.github.io/mui-theme-creator/
*/
export const theme = createTheme(themeOptions);
/**
*
* @returns
*/
export const useTheme = () => {
return useMuiTheme<Theme>();
};
/**
* 自定义主题设置。
* @param param0
* @returns
*/
export const CustomThemeProvider = ({ children, themeOptions: customThemeOptions }: { children: React.ReactNode; themeOptions?: ThemeOptions }) => {
const theme = createTheme(customThemeOptions || themeOptions);
return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
};
// TODO: think
export const getComponentProps = () => {};

View File

@@ -1,76 +0,0 @@
@import 'tailwindcss';
@theme {
--color-primary: #ffc107;
--color-secondary: #ffa000;
--color-success: #28a745;
--color-scrollbar-thumb: #999999;
--color-scrollbar-track: rgba(0, 0, 0, 0.1);
--color-scrollbar-thumb-hover: #666666;
--scrollbar-color: #ffc107; /* 滚动条颜色 */
}
html,
body {
width: 100%;
height: 100%;
font-size: 16px;
font-family: 'Montserrat', sans-serif;
}
/* font-family */
@utility font-family-mon {
font-family: 'Montserrat', sans-serif;
}
@utility font-family-rob {
font-family: 'Roboto', sans-serif;
}
@utility font-family-int {
font-family: 'Inter', sans-serif;
}
@utility font-family-orb {
font-family: 'Orbitron', sans-serif;
}
@utility font-family-din {
font-family: 'DIN', sans-serif;
}
@utility flex-row-center {
@apply flex flex-row items-center justify-center;
}
@utility flex-col-center {
@apply flex flex-col items-center justify-center;
}
@utility scrollbar {
overflow: auto;
/* 整个滚动条 */
&::-webkit-scrollbar {
width: 3px;
height: 3px;
}
&::-webkit-scrollbar-track {
background-color: var(--color-scrollbar-track);
}
/* 滚动条有滑块的轨道部分 */
&::-webkit-scrollbar-track-piece {
background-color: transparent;
border-radius: 1px;
}
/* 滚动条滑块(竖向:vertical 横向:horizontal) */
&::-webkit-scrollbar-thumb {
cursor: pointer;
background-color: var(--color-scrollbar-thumb);
border-radius: 5px;
}
/* 滚动条滑块hover */
&::-webkit-scrollbar-thumb:hover {
background-color: var(--color-scrollbar-thumb-hover);
}
/* 同时有垂直和水平滚动条时交汇的部分 */
&::-webkit-scrollbar-corner {
display: block; /* 修复交汇时出现的白块 */
}
}

View File

@@ -1,40 +0,0 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"baseUrl": "./",
"typeRoots": [
"node_modules/@types",
"node_modules/@kevisual/types",
],
"paths": {
"@kevisual/components/*": [
"src/*"
]
},
/* Linting */
"strict": true,
"noImplicitAny": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": [
"src",
"typings.d.ts",
]
}

View File

@@ -2,4 +2,6 @@ export { KeyParse, keysTips } from './pages/file/modules/key-parse';
export { PermissionManager } from './pages/file/modules/PermissionManager.tsx';
export { PermissionModal, usePermissionModal } from './pages/file/modules/PermissionModal.tsx';
export { iText } from './i-text/index.ts';
export * from './pages/upload/app';
export { uploadFiles, uploadFileChunked, getDirectoryAndName, toFile, createDirectory } from './pages/upload/app';
export { DialogDirectory, DialogDeleteDirectory } from './pages/upload/DialogDirectory';

View File

@@ -1,34 +1,39 @@
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useResourceStore } from '../store/resource';
import { Box, Button, Typography, ButtonGroup } from '@mui/material';
import { FileText, Table, Grid } from 'lucide-react';
import { Box, Button, Typography, ButtonGroup, Tooltip } from '@mui/material';
import { FileText, Table, Grid, Trash, Upload, FolderPlus, RefreshCw } 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';
import { IconButtonItem } from '@kevisual/components/button/index.tsx';
import { IconButtonItem, IconButton } from '@kevisual/components/button/index.tsx';
import { useTranslation } from 'react-i18next';
import { DialogDeleteDirectory, DialogDirectory } from '../upload/DialogDirectory';
export const FileApp = () => {
const { getList, prefix, setListType, listType } = useResourceStore();
const { getList, prefix, setListType, listType, onOpenPrefix } = useResourceStore();
const { getStatFile, prefix: statPrefix, openDrawer } = useResourceFileStore();
useEffect(() => {
getList();
}, []);
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) => {
const paths = ['root', ...prefix.split('/').filter((item) => item)];
const [_mockUsername, appKey, version, ...directory] = paths;
const directoryPath = directory.join('/');
const [dialogDeleteDirectory, setDialogDeleteDirectory] = useState(false);
const [dialogDirectory, setDialogDirectory] = useState(false);
const { t } = useTranslation();
const onDirectoryClick = (prefix: string) => {
onOpenPrefix(prefix);
};
const onUloadFinish = (res: any) => {
getList();
};
return (
@@ -55,7 +60,75 @@ export const FileApp = () => {
</div>
<Box className='flex items-center gap-2 mb-4'>
<PrefixRedirect />
<UploadButton prefix={directory} onUpload={handleUpload} />
<Tooltip title={t('refresh')} placement='bottom'>
<IconButton
color='primary'
onClick={() => {
onDirectoryClick(prefix);
}}>
<RefreshCw />
</IconButton>
</Tooltip>
{true && (
<>
<Tooltip title={t('create_directory')} placement='bottom'>
<IconButton color='primary' onClick={() => setDialogDirectory(true)}>
<FolderPlus />
</IconButton>
</Tooltip>
<DialogDirectory
open={dialogDirectory}
onClose={() => setDialogDirectory(false)}
onSuccess={(newPrefix) => {
const currentPath = [appKey, version, newPrefix].filter(Boolean).join('/');
onDirectoryClick(currentPath + '/');
setDialogDirectory(false);
}}
prefix={directoryPath}
opts={{
appKey,
version,
}}
/>
</>
)}
{true && (
<>
<Tooltip title={t('uploadDirectory')} placement='bottom'>
<IconButton color='primary'>
<UploadButton onlyIcon uploadDirectory icon={<Upload />} directory={directoryPath} onUpload={onUloadFinish} />
</IconButton>
</Tooltip>
<Tooltip title={t('upload')} placement='bottom'>
<IconButton color='primary'>
<UploadButton onlyIcon directory={directoryPath} onUpload={onUloadFinish} />
</IconButton>
</Tooltip>
</>
)}
{directoryPath !== '' && (
<>
<Tooltip title={t('deleteDirectory')} placement='bottom'>
<IconButton
color='primary'
onClick={() => {
setDialogDeleteDirectory(true);
}}>
<Trash />
</IconButton>
</Tooltip>
<DialogDeleteDirectory
open={dialogDeleteDirectory}
onClose={() => setDialogDeleteDirectory(false)}
onSuccess={(prefix) => {
const newPrefix = prefix.split('/').slice(0, -1).join('/');
setDialogDeleteDirectory(false);
onDirectoryClick(newPrefix + '/');
}}
prefix={appKey + '/' + version + '/' + directoryPath}
/>
</>
)}
<ButtonGroup className='ml-auto' variant='contained' color='primary' sx={{ color: 'white' }}>
<Button
variant={listType === 'table' ? 'contained' : 'outlined'}

View File

@@ -16,7 +16,20 @@ interface ResourceFileStore {
*/
getStatFile: () => Promise<any>;
updateMeta: (metadata: any) => Promise<any>;
/**
* 删除文件
* @param resource 文件
* @param opts 选项
* @returns
*/
deleteFile: (resource: Resource, opts?: { onSuccess?: (res: any) => void }) => Promise<void>;
/**
* 删除目录
* @param prefix 目录
* @param opts 选项
* @returns
*/
deleteDirectory: (prefix: string, opts?: { onSuccess?: (res: any) => void }) => Promise<void>;
once: ((data: any) => any) | null;
setOnce: (data: any) => void;
}
@@ -81,6 +94,30 @@ export const useResourceFileStore = create<ResourceFileStore>((set, get) => ({
toast.error(res.message || 'Request failed');
}
},
deleteDirectory: async (prefix: string, opts?: { onSuccess?: (res: any) => void }) => {
if (!prefix) {
toast.error('Directory name is required');
return;
}
if (prefix.endsWith('/')) {
toast.error('Directory name cannot end with a slash');
return;
}
const res = await query.post({
path: 'file',
key: 'delete-all',
data: {
directory: prefix,
},
});
if (res.code === 200) {
toast.success('Delete directory success');
opts?.onSuccess?.(res);
} else {
toast.error(res.message || 'Request failed');
}
},
once: null,
setOnce: (data: any) => set({ once: data }),
}));

View File

@@ -0,0 +1,113 @@
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material';
import { useTheme } from '@kevisual/components/theme/index.js';
import { useTranslation } from 'react-i18next';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { createDirectory } from './utils/create-directory';
import { ConvertOpts } from './utils/upload-chunk';
import { useResourceFileStore } from '../store/resource-file';
type DialogDirectoryProps = {
open: boolean;
onClose: () => void;
onSuccess: (directory: string) => void;
prefix?: string;
opts?: ConvertOpts;
};
/**
* 创建目录
* @param props
* @returns
*/
export const DialogDirectory = (props: DialogDirectoryProps) => {
const { open, onClose, onSuccess, prefix, opts } = props;
const theme = useTheme();
const [directory, setDirectory] = useState('');
const defaultProps = theme.components?.MuiTextField?.defaultProps as any;
const { t } = useTranslation();
useEffect(() => {
setDirectory('');
}, [open]);
const onClick = async () => {
if (!directory) {
toast.error(t('directory_name_required'));
return;
}
if (directory.startsWith('.') || directory.endsWith('.')) {
toast.error(t('directory_name_invalid'));
return;
}
if (directory.startsWith('/') || directory.endsWith('/')) {
toast.error(t('directory_name_invalid'));
return;
}
if (directory.includes('//')) {
toast.error(t('directory_name_invalid'));
return;
}
const res = await createDirectory(prefix ? `${prefix}/${directory}` : directory, opts);
if (res?.code === 200) {
if (onSuccess) {
onSuccess(prefix ? `${prefix}/${directory}` : directory);
} else {
toast.success(t('create_directory_success'));
onClose();
}
} else {
toast.error(res.message);
}
};
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>{t('create_directory')}</DialogTitle>
<DialogContent>
<div className='min-w-[400px] py-4'>
<TextField {...defaultProps} value={directory} onChange={(e) => setDirectory(e.target.value)} label='目录名' />
</div>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('Cancel')}</Button>
<Button onClick={onClick}>{t('Submit')}</Button>
</DialogActions>
</Dialog>
);
};
type DialogDeleteDirectoryProps = {
open: boolean;
onClose: () => void;
onSuccess: (directory: string) => void;
prefix?: string;
};
export const DialogDeleteDirectory = (props: DialogDeleteDirectoryProps) => {
const { open, onClose, onSuccess, prefix } = props;
const { t } = useTranslation();
const { deleteDirectory } = useResourceFileStore();
const onClick = async () => {
if (!prefix) {
toast.error(t('directory_name_required'));
return;
}
await deleteDirectory(prefix!, {
onSuccess: () => {
if (onSuccess) {
onSuccess(prefix!);
} else {
onClose();
}
},
});
};
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>{t('delete_directory')}</DialogTitle>
<DialogContent>
<div className='min-w-[400px]'>: {`${prefix}/`}</div>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('Cancel')}</Button>
<Button onClick={onClick}>{t('Submit')}</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -3,3 +3,5 @@ export * from './tools/to-file';
export * from './utils/upload';
export * from './utils/upload-chunk';
export * from './utils/create-directory';

View File

@@ -4,7 +4,7 @@ import { uploadFiles } from './utils/upload';
import { FileText, CloudUpload as UploadIcon } from 'lucide-react';
import { uploadFileChunked } from './utils/upload-chunk';
import { filterFiles } from './utils/filter-files';
import { IconButton } from '@kevisual/components/button/index.tsx';
type UploadButtonProps = {
/**
* 前缀
@@ -77,18 +77,7 @@ export const UploadButton = (props: UploadButtonProps) => {
if (onlyIcon) {
return uploadCom;
}
return (
<Box>
<Button
color='primary'
sx={{
minWidth: 'unset',
padding: '2px',
}}>
{uploadCom}
</Button>
</Box>
);
return <IconButton color='primary'>{uploadCom}</IconButton>;
};
export const Upload = ({ uploadDirectory = false }: { uploadDirectory?: boolean }) => {
const onDrop = async (acceptedFiles) => {

View File

@@ -32,6 +32,11 @@ const getFileType = (extension: string) => {
const checkIsBase64 = (content: string) => {
return content.startsWith('data:');
};
/**
* 获取文件的目录和文件名
* @param filename 文件名
* @returns 目录和文件名
*/
export const getDirectoryAndName = (filename: string) => {
if (!filename) {
return null;
@@ -87,3 +92,14 @@ export const toFile = (content: string, filename: string) => {
return new File([blob], filename, { type });
}
};
/**
* 把字符串转为文本文件
* @param content 字符串
* @param filename 文件名
* @returns 文件流
*/
export const toTextFile = (content: string = 'keep directory exist', filename: string = 'keep.txt') => {
const file = toFile(content, filename);
return file;
};

View File

@@ -0,0 +1,50 @@
import { toTextFile } from '../app';
import { ConvertOpts, uploadFileChunked } from './upload-chunk';
/**
* 对创建的directory的路径进行解析
* 如果是 nameA/nameB/nameC 则创建 nameA/nameB/nameC
*
* 不能以.开头,不能以.结尾,不能以/开头,不能以/结尾。
* @param directory
* @param opts
* @returns
*/
export const createDirectory = async (directory: string = '', opts?: ConvertOpts) => {
const directoryPath = directory || opts?.directory;
const error = '目录名不能以.开头,不能以.结尾,不能以/开头,不能以/结尾,不能包含//';
const errorMsg = () => {
return {
code: 400,
message: error,
success: false,
};
};
if (directoryPath) {
if (directoryPath.startsWith('.')) {
return errorMsg();
}
if (directoryPath.endsWith('.')) {
return errorMsg();
}
if (directoryPath.startsWith('/')) {
return errorMsg();
}
if (directoryPath.endsWith('/')) {
return errorMsg();
}
if (directoryPath.includes('//')) {
return errorMsg();
}
}
const res = await uploadFileChunked(toTextFile('keep directory exist', 'keep.txt'), {
directory,
...opts,
});
return res as {
code: number;
message?: string;
success?: boolean;
data?: any;
[key: string]: any;
};
};

View File

@@ -4,7 +4,7 @@ import { toast } from 'react-toastify';
import { nanoid } from 'nanoid';
import { toastLogin } from '@kevisual/resources/pages/message/ToastLogin';
type ConvertOpts = {
export type ConvertOpts = {
appKey?: string;
version?: string;
username?: string;

View File

@@ -11,6 +11,19 @@ type ConvertOpts = {
};
export const uploadFiles = async (files: File[], opts: ConvertOpts) => {
const { directory, appKey, version, username } = opts;
const length = files.length;
const maxSize = 10 * 1024 * 1024; // 10MB
const totalSize = files.reduce((acc, file) => acc + file.size, 0);
if (totalSize > maxSize) {
toast.error('有文件大小不能超过10MB');
return;
}
const maxCount = 10;
if (length > maxCount) {
toast.error(`最多只能上传${maxCount}个文件`);
return;
}
toast.info(`上传中,共${length}个文件`);
return new Promise((resolve, reject) => {
const formData = new FormData();
const webkitRelativePath = files[0]?.webkitRelativePath;
@@ -43,10 +56,7 @@ export const uploadFiles = async (files: File[], opts: ConvertOpts) => {
return;
}
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 load = toast.loading('上传中...');
NProgress.start();
eventSource.onopen = async function (event) {