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

8
.gitmodules vendored
View File

@@ -3,4 +3,10 @@
url = git@git.xiongxiao.me:kevisual/kevsiual-query-login.git
[submodule "submodules/query-config"]
path = submodules/query-config
url = git@git.xiongxiao.me:kevisual/kevsiual-query-config.git
url = git@git.xiongxiao.me:kevisual/kevsiual-query-config.git
[submodule "submodules/wallnote"]
path = submodules/wallnote
url = git@git.xiongxiao.me:tailored/wallnote.git
[submodule "packages/kevisual-official"]
path = packages/kevisual-official
url = git@git.xiongxiao.me:kevisual/official-website.git

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) {

1399
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -79,5 +79,10 @@
"Workspace Config Tips": "Set the default workspace, when there is no specific space to open, the default workspace will be opened. The collection information module, the position of the added resource, will be placed here by default. For example, the WeChat public account's essay.",
"Add Config Tips": "Add configuration, when the key exists, the data update of the key will be queried first, if the key does not exist, the new configuration will be added.",
"VIP Config": "VIP Config",
"VIP Config Tips": "VIP Config, only the admin can configure."
"VIP Config Tips": "VIP Config, only the admin can configure.",
"directory_name_required": "Directory name is required",
"delete_directory": "Delete Directory",
"delete_directory_success": "Delete directory success",
"create_directory": "Create Directory",
"create_directory_success": "Create directory success"
}

View File

@@ -79,5 +79,10 @@
"Workspace Config Tips": "设置默认的工作空间,当没有打开具体的空间的时候,默认打开的工作空间。收集信息的模块,添加的资源的位置,默认放到这里。比如,微信公众号的随笔。",
"Add Config Tips": "添加配置当key存在的时候会优先查询key数据的更新如果key不存在则新增配置。",
"VIP Config": "VIP配置",
"VIP Config Tips": "VIP配置只有管理员才能配置。"
"VIP Config Tips": "VIP配置只有管理员才能配置。",
"directory_name_required": "目录名称是必须的",
"delete_directory": "删除目录",
"delete_directory_success": "删除目录成功",
"create_directory": "创建目录",
"create_directory_success": "创建目录成功"
}

View File

@@ -31,28 +31,6 @@ export const LayoutUser = () => {
}, []);
const navigate = useNewNavigate();
const { t } = useTranslation();
const meun = [
{
title: t('Your profile'),
icon: <SquareUser size={16} />,
link: '/user/profile',
},
{
title: t('Your orgs'),
icon: <SwitcherOutlined />,
link: '/org/edit/list',
},
{
title: t('Site Map'),
icon: <Map size={16} />,
link: '/map',
},
{
title: t('Domain'),
icon: <ArrowDownLeftFromSquareIcon size={16} />,
link: '/domain/edit/list',
},
];
const items = useMemo(() => {
const orgs = store.me?.orgs || [];
return orgs.map((item) => {
@@ -63,7 +41,43 @@ export const LayoutUser = () => {
};
});
}, [store.me]);
const menu = useMemo(() => {
const orgs = store.me?.orgs || [];
const hasOrg = orgs.length > 0;
const items = [
{
title: t('Your profile'),
icon: <SquareUser size={16} />,
link: '/user/profile',
},
{
title: t('Your orgs'),
icon: <SwitcherOutlined />,
link: '/org/edit/list',
isOrg: true,
},
{
title: t('Site Map'),
icon: <Map size={16} />,
link: '/map',
},
{
title: t('Domain'),
icon: <ArrowDownLeftFromSquareIcon size={16} />,
link: '/domain/edit/list',
isAdmin: true,
},
];
return items.filter((item) => {
if (item.isOrg) {
return hasOrg;
}
if (item.isAdmin) {
return isAdmin;
}
return true;
});
}, [store.me]);
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
@@ -101,7 +115,7 @@ export const LayoutUser = () => {
</div>
</div>
<div className='mt-3 font-medium'>
{meun.map((item, index) => {
{menu.map((item, index) => {
return (
<div
key={index}

View File

@@ -88,11 +88,11 @@ const FormModal = () => {
<DialogContent>
<form className='flex flex-col gap-4 pt-2' onSubmit={handleSubmit(onFinish)}>
<Controller name='title' control={control} render={({ field }) => <TextField {...defaultProps} {...field} label='title' />} />
<Controller
{/* <Controller
name='domain'
control={control}
render={({ field }) => <TextField {...defaultProps} {...field} label='domain' variant='outlined' helperText='域名自定义绑定' />}
/>
/> */}
<Controller name='key' control={control} render={({ field }) => <TextField {...defaultProps} {...field} label='key' fullWidth />} />
<Controller
name='description'
@@ -277,6 +277,17 @@ export const List = () => {
<CodeOutlined />
</IconButton>
</Tooltip>
<Tooltip title='域名自定义绑定'>
<IconButton
sx={{
padding: '8px',
}}
onClick={() => {
message.info('联系管理员');
}}>
<LinkOutlined />
</IconButton>
</Tooltip>
</div>
<div className='grow'>
<div className='w-full h-full p-4'>
@@ -307,9 +318,9 @@ export const List = () => {
{item.id}
</div>
</Tooltip>
{item.domain && (
{item?.data?.domain && (
<div className='text-xs'>
{t('app.domain')}: {item.domain}
{t('app.domain')}: {item?.data?.domain}
</div>
)}
<div className='text-xs'>

View File

@@ -12,9 +12,10 @@ import UploadOutlined from '@ant-design/icons/lib/icons/UploadOutlined';
import { Tooltip } from '@mui/material';
import { useResourceFileStore } from '@kevisual/resources/pages/store/resource-file.ts';
import { FileDrawerApp } from '@kevisual/resources/pages/file/draw/FileDrawer.tsx';
import { RefreshCw, Upload } from 'lucide-react';
import { Delete, FolderPlus, RefreshCw, Trash, Upload } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { UploadButton } from '@kevisual/resources/pages/upload/index.tsx';
import { DialogDirectory, DialogDeleteDirectory } from '@kevisual/resources/pages/upload/DialogDirectory.tsx';
export const CardPath = ({ children }: any) => {
const userAppStore = useFileStore(
useShallow((state) => {
@@ -26,6 +27,8 @@ export const CardPath = ({ children }: any) => {
};
}),
);
const [dialogDirectory, setDialogDirectory] = useState(false);
const [dialogDeleteDirectory, setDialogDeleteDirectory] = useState(false);
const paths = ['root', ...userAppStore.path.split('/').filter((item) => item)];
const onDirectoryClick = (prefix: string) => {
if (prefix === 'root') {
@@ -38,13 +41,14 @@ export const CardPath = ({ children }: any) => {
};
const { t } = useTranslation();
const [usrname, appKey, version] = paths;
const [_username, appKey, version, ...directory] = paths;
const onUloadFinish = (res: any) => {
console.log(res);
userAppStore.getList();
};
const directoryPath = directory.join('/');
return (
<div className='border border-gray-200 rounded'>
<div className='border border-gray-200 rounded h-full overflow-hidden'>
<div className='p-2'>
<div className='flex flex-col'>
<div className='flex justify-between'>
@@ -78,25 +82,81 @@ export const CardPath = ({ children }: any) => {
<RefreshCw />
</IconButton>
</Tooltip>
{version && (
<>
<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;
onDirectoryClick(currentPath);
setDialogDirectory(false);
}}
prefix={directoryPath}
opts={{
appKey,
version,
}}
/>
</>
)}
{version && (
<>
<Tooltip title={t('uploadDirectory')} placement='bottom'>
<IconButton color='primary'>
<UploadButton onlyIcon uploadDirectory icon={<Upload />} appKey={appKey} version={version} username={usrname} onUpload={onUloadFinish} />
<UploadButton
onlyIcon
uploadDirectory
icon={<Upload />}
directory={directoryPath}
appKey={appKey}
version={version}
onUpload={onUloadFinish}
/>
</IconButton>
</Tooltip>
<Tooltip title={t('upload')} placement='bottom'>
<IconButton color='primary'>
<UploadButton onlyIcon appKey={appKey} version={version} username={usrname} onUpload={onUloadFinish} />
<UploadButton onlyIcon appKey={appKey} version={version} directory={directoryPath} onUpload={onUloadFinish} />
</IconButton>
</Tooltip>
</>
)}
{version && 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}
/>
</>
)}
</div>
</div>
</div>
</div>
<div className=''>{children}</div>
<div className='scrollbar' style={{ height: 'calc(100% - 20px)' }}>
{children}
</div>
</div>
);
};

1
submodules/query-mark Submodule

Submodule submodules/query-mark added at a617a68e2f

1
submodules/wallnote Submodule

Submodule submodules/wallnote added at 4d0e945a92

View File

@@ -3,22 +3,8 @@ import react from '@vitejs/plugin-react';
import path from 'path';
import tailwindcss from '@tailwindcss/vite';
import basicSsl from '@vitejs/plugin-basic-ssl';
const isDev = process.env.NODE_ENV === 'development';
const unamiPlugin = {
name: 'html-transform',
transformIndexHtml(html: string) {
return html.replace(
'</head>',
`<script defer src="https://umami.xiongxiao.me/script.js" data-website-id="79e7aa98-9e6e-4eef-bc8b-9cbd0ecb11c3"></script></head>`,
);
},
};
const plugins: any[] = [basicSsl()];
if (!isDev) {
plugins.push(unamiPlugin);
}
plugins.push(tailwindcss());
const devBackend = 'https://kevisual.silkyai.cn';
// const meBackend = 'https://kevisual.xiongxiao.me';