temp: add resources

This commit is contained in:
熊潇 2025-03-15 23:54:53 +08:00
parent a5bde33678
commit fd30741151
40 changed files with 2174 additions and 32 deletions

2
.npmrc
View File

@ -1,2 +1,4 @@
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
ignore-workspace-root-check=true

View File

@ -14,6 +14,8 @@
},
"dependencies": {
"@ant-design/icons": "^5.6.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@icon-park/react": "^1.4.2",
"@kevisual/codemirror": "^0.0.2",
"@kevisual/container": "1.0.0",
@ -21,6 +23,7 @@
"@kevisual/system-ui": "^0.0.3",
"@kevisual/ui": "^0.0.2",
"@monaco-editor/react": "^4.7.0",
"@mui/material": "^6.4.7",
"@tailwindcss/vite": "^4.0.12",
"@uiw/react-textarea-code-editor": "^3.1.0",
"@xyflow/react": "^12.4.4",

View File

@ -0,0 +1,31 @@
{
"name": "@kevisual/center-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"
},
"exports": {
".": "./src/index.tsx",
"./theme": "./src/theme/index.tsx",
"./button": "./src/button/index.tsx"
},
"devDependencies": {
"clsx": "^2.1.1",
"tailwind-merge": "^3.0.2"
}
}

View File

@ -0,0 +1,5 @@
import MuiButton, { ButtonProps } from '@mui/material/Button';
export const Button = (props: ButtonProps) => {
return <MuiButton {...props} />;
};

View File

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

View File

@ -0,0 +1 @@
export * from './theme';

View File

@ -0,0 +1,64 @@
import { createTheme, ThemeOptions } from '@mui/material/styles';
import { useTheme as useMuiTheme, Theme } from '@mui/material/styles';
import { amber } from '@mui/material/colors';
export const themeOptions: ThemeOptions = {
palette: {
primary: {
main: '#ffc107', // amber[300]
},
secondary: {
main: '#ffa000', // amber[500]
},
divider: amber[200],
common: {
white: '#ffa000',
},
text: {
primary: amber[600],
secondary: amber[600],
},
background: {
default: '#ffffff', // 设置默认背景颜色
// paper: '#f5f5f5', // 设置纸张背景颜色
},
},
typography: {
// fontFamily: 'Roboto, sans-serif',
},
components: {
MuiButtonBase: {
defaultProps: {
disableRipple: true,
},
},
MuiTextField: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderColor: amber[300],
},
'&:hover fieldset': {
borderColor: amber[500],
},
'& .MuiInputBase-input': {
color: amber[600],
},
},
'& .MuiInputLabel-root': {
color: amber[600],
},
},
},
},
},
};
/**
* https://bareynol.github.io/mui-theme-creator/
*/
export const theme = createTheme(themeOptions);
export const useTheme = () => {
return useMuiTheme<Theme>();
};

View File

@ -0,0 +1,36 @@
{
"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": {},
/* Linting */
"strict": true,
"noImplicitAny": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": [
"src",
"typings.d.ts",
]
}

27
packages/resources/.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
tsconfig.app.tsbuildinfo
tsconfig.node.tsbuildinfo

View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Apps</title>
<link rel="stylesheet" href="./src/assets/index.css">
<style>
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
font-size: 16px;
}
#ai-root {
width: 100%;
height: 100%;
}
</style>
<script src="/system/lib/app.js"></script>
</head>
<body>
<div id="ai-root"></div>
<!-- <div id="ai-bot-root"></div> -->
</body>
<script src="./src/main.ts" type="module"></script>
</html>

View File

@ -0,0 +1,40 @@
{
"name": "resources",
"version": "0.0.1",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"keywords": [],
"author": "abearxiong <xiongxiao@xiongxiao.me>",
"license": "MIT",
"type": "module",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@kevisual/center-components": "workspace:*",
"@kevisual/router": "^0.0.9",
"@kevisual/store": "^0.0.2",
"@mui/material": "^6.4.7",
"@types/lodash-es": "^4.17.12",
"@types/nprogress": "^0.2.3",
"@vitejs/plugin-basic-ssl": "^2.0.0",
"dayjs": "^1.11.13",
"immer": "^10.1.1",
"lodash-es": "^4.17.21",
"lucide-react": "^0.482.0",
"nanoid": "^5.1.3",
"nprogress": "^0.2.0",
"pretty-bytes": "^6.1.1",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-dropzone": "^14.3.8",
"react-toastify": "^11.0.5",
"zustand": "^5.0.3"
},
"devDependencies": {
"@kevisual/types": "^0.0.6"
}
}

View File

@ -0,0 +1,12 @@
import type { Page } from '@kevisual/store/page';
import type { QueryRouterServer } from '@kevisual/router';
import { basename } from './modules/basename';
export const page = useContextKey('page', () => {
return new window.Page({
basename,
}) as unknown as Page;
});
export const app = useContextKey('app', () => {
console.error('app not found');
return null as unknown as QueryRouterServer;
});

View File

@ -0,0 +1,20 @@
@import 'tailwindcss';
@layer components {
.test-loading {
@apply w-20 h-20 bg-gray-300 rounded-full animate-spin;
}
}
#root {
width: 100%;
height: 100%;
}
#ai-bot-root {
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: -100px;
z-index: 9999;
pointer-events: none;
}

View File

@ -0,0 +1,50 @@
import { page, app } from './app.ts';
import { basename } from './modules/basename.ts';
import './pages/main.tsx';
export const render = ({ renderRoot }) => {
renderRoot.innerHTML = `
<h1>Hello, World!</h1>
`;
};
console.log('basename', basename, page, app);
if (page) {
page.addPage('/', 'home');
page.subscribe('home', () => {
render({
renderRoot: document.getElementById('ai-root'),
});
});
page.addPage('', 'index');
page.subscribe('index', () => {
const root = document.getElementById('ai-root') as HTMLElement;
root.innerHTML = `
<h1>Hello, World!</h1>
`;
});
}
if (app) {
app
.route({
path: 'app-template',
key: 'render',
})
.define(async (ctx) => {
let { renderRoot } = ctx.query;
if (!renderRoot) {
ctx.throw(404, 'renderRoot is required');
}
if (typeof renderRoot === 'string') {
renderRoot = document.querySelector(renderRoot);
}
if (!renderRoot) {
ctx.throw(404, 'renderRoot not found');
}
render({
renderRoot,
});
})
.addTo(app);
}

View File

@ -0,0 +1,2 @@
// @ts-ignore
export const basename = DEV_SERVER ? '/' : BASE_NAME;

View File

@ -0,0 +1,3 @@
import { QueryClient } from '@kevisual/query';
export const query = new QueryClient();

View File

@ -0,0 +1,39 @@
import { useEffect } from 'react';
import { theme } from '@kevisual/center-components/theme';
import { ThemeProvider } from '@mui/material/styles';
import { Left } from './layout/Left';
import { Main } from './main/index';
import { ToastContainer } from 'react-toastify';
import { useSettingsStore } from './store/settings';
import { CircularProgress } from '@mui/material';
import { useResourceStore } from './store/resource';
export const App = () => {
const { init, mounted, settings } = useSettingsStore();
const { setPrefix, init: initResource } = useResourceStore();
useEffect(() => {
init();
initResource();
}, []);
useEffect(() => {
if (settings.prefix && mounted) {
setPrefix(settings.prefix);
}
}, [mounted, settings.prefix]);
if (!mounted) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<CircularProgress />
</div>
);
}
return (
<ThemeProvider theme={theme}>
<Left>
<Main />
</Left>
<ToastContainer />
</ThemeProvider>
);
};

View File

@ -0,0 +1,92 @@
import { FileText, Image, File, Video, Sheet, FileArchive, FolderClosedIcon } from 'lucide-react';
import { SVGProps } from 'react';
export const PDFIcon = (props: SVGProps<SVGSVGElement>) => {
return (
<svg viewBox='0 0 1024 1024' version='1.1' xmlns='http://www.w3.org/2000/svg' p-id='1656' id='mx_n_1742047110347' width='32' height='32' {...props}>
<path
d='M645.071238 97.52381L828.952381 281.258667V414.47619h48.761905v390.095239h-48.761905v48.761904a73.142857 73.142857 0 0 1-73.142857 73.142857H268.190476a73.142857 73.142857 0 0 1-73.142857-73.142857v-48.761904H146.285714V414.47619h48.761905V170.666667a73.142857 73.142857 0 0 1 73.142857-73.142857h376.880762zM755.809524 804.571429H268.190476v48.761904h487.619048v-48.761904zM368.274286 520.94781H299.154286v174.445714h43.885714v-53.101714h10.971429a177.493333 177.493333 0 0 0 31.597714-2.852572 103.619048 103.619048 0 0 0 16.237714-4.827428c4.534857-2.048 8.777143-4.461714 12.726857-7.241143 7.168-5.412571 12.507429-12.141714 16.018286-20.187429 3.364571-8.484571 5.046857-17.334857 5.046857-26.550857a72.338286 72.338286 0 0 0-4.388571-23.698286 56.441905 56.441905 0 0 0-13.604572-19.968 62.025143 62.025143 0 0 0-23.917714-12.726857 116.784762 116.784762 0 0 0-25.453714-3.291428z m140.434285 0h-61.44v174.445714h61.44c6.875429-0.146286 13.750857-0.731429 20.626286-1.755429 6.144-1.024 12.141714-2.56 17.993143-4.608 5.266286-2.194286 10.313143-4.754286 15.140571-7.68 4.388571-3.218286 8.411429-6.875429 12.068572-10.971428 3.510857-4.388571 6.582857-9.069714 9.216-14.043429a110.689524 110.689524 0 0 0 6.144-16.896c2.194286-10.24 3.364571-20.626286 3.510857-31.158857a176.37181 176.37181 0 0 0-1.755429-21.284571 113.249524 113.249524 0 0 0-4.608-18.432 90.599619 90.599619 0 0 0-7.68-15.579429 78.214095 78.214095 0 0 0-10.532571-12.507429 77.994667 77.994667 0 0 0-13.604571-9.435428 93.42781 93.42781 0 0 0-16.457143-6.363429 139.702857 139.702857 0 0 0-30.061715-3.730285z m213.504 0h-116.736v174.445714h43.885715v-65.828572h63.414857v-34.011428h-63.414857v-39.497143h72.850285v-35.108571z m-206.482285 35.108571c4.973714 0.146286 9.654857 1.536 14.043428 4.169143 4.534857 3.072 8.265143 7.021714 11.190857 11.849143 3.218286 5.851429 5.485714 11.995429 6.802286 18.432 1.024 5.997714 1.609143 11.995429 1.755429 17.993143-0.146286 6.144-0.731429 12.214857-1.755429 18.212571a62.22019 62.22019 0 0 1-6.802286 18.212571c-2.925714 4.681143-6.656 8.557714-11.190857 11.629715a28.038095 28.038095 0 0 1-14.043428 3.730285h-24.576v-104.228571h24.576z m-151.625143 0c3.949714 0.146286 7.826286 0.877714 11.629714 2.194286 3.364571 1.316571 6.363429 3.218286 8.996572 5.705143 4.681143 5.12 7.021714 11.117714 7.021714 17.993142 0 7.314286-2.706286 13.385143-8.118857 18.212572a28.525714 28.525714 0 0 1-9.874286 5.485714 47.88419 47.88419 0 0 1-12.068571 1.536h-18.651429v-51.126857h21.065143z m217.307428-385.414095L268.190476 170.666667v243.809523h487.619048v-49.956571h-174.372572L581.412571 170.666667z m73.142858 39.740952v80.993524h81.042285l-81.042285-80.993524z'
fill='currentColor'></path>
</svg>
);
};
export const FilePowerpoint = (props: SVGProps<SVGSVGElement>) => {
return (
<svg viewBox='0 0 1024 1024' version='1.1' xmlns='http://www.w3.org/2000/svg' p-id='3922' width='32' height='32' {...props}>
<path
d='M424 476c-4.4 0-8 3.6-8 8v276c0 4.4 3.6 8 8 8h32.5c4.4 0 8-3.6 8-8v-95.5h63.3c59.4 0 96.2-38.9 96.2-94.1 0-54.5-36.3-94.3-96-94.3H424z m150.6 94.3c0 43.4-26.5 54.3-71.2 54.3h-38.9V516.2h56.2c33.8 0 53.9 19.7 53.9 54.1z'
fill='currentColor'></path>
<path
d='M854.6 288.6L639.4 73.4c-6-6-14.1-9.4-22.6-9.4H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V311.3c0-8.5-3.4-16.7-9.4-22.7zM790.2 326H602V137.8L790.2 326z m1.8 562H232V136h302v216c0 23.2 18.8 42 42 42h216v494z'
fill='currentColor'></path>
</svg>
);
};
export const getExtension = (name: string) => {
return name.split('.').pop();
};
export const getIcon = (name: string) => {
if (!name) {
return <FolderClosedIcon />;
}
const extension = getExtension(name);
switch (extension) {
case 'pdf':
return <PDFIcon className='w-6 h-6' />;
case 'jpg':
case 'jpeg':
case 'gif':
case 'png':
return <Image />;
case 'mp3':
case 'wav':
case 'ogg':
case 'm4a':
case 'aac':
case 'flac':
case 'wma':
case 'mp4':
return <Video />;
case 'doc':
case 'docx':
return <FileText />;
case 'ppt':
case 'pptx':
return <FilePowerpoint className='w-6 h-6' />;
case 'xls':
case 'xlsx':
return <Sheet />;
case 'zip':
case 'rar':
case '7z':
case 'tar':
case 'gz':
case 'bz2':
return <FileArchive />;
case 'txt':
case 'md':
case 'csv':
case 'json':
case 'xml':
case 'yaml':
case 'yml':
case 'toml':
case 'ini':
case 'conf':
case 'cfg':
case 'config':
case 'props':
case 'properties':
case 'log':
case 'sh':
case 'bash':
case 'zsh':
case 'fish':
case 'bat':
case 'cmd':
return <FileText />;
default:
return <File />;
}
};

View File

@ -0,0 +1,50 @@
import { useEffect } 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 { FileTable } from './list/FileTable';
import { FileCard } from './list/FileCard';
export const FileApp = () => {
const { list, getList, prefix, setListType, listType } = useResourceStore();
const { settings } = useSettingsStore();
useEffect(() => {
getList();
}, []);
const theme = useTheme();
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'
sx={{
fontSize: { xs: '1.3rem', sm: '2rem' },
fontWeight: 'bold',
}}>
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>
</Box>
<div>{listType === 'card' ? <FileCard /> : <FileTable />}</div>
</Box>
);
};

View File

@ -0,0 +1,27 @@
import { useResourceStore } from '@/pages/store/resource';
import { Card, CardContent, Typography } from '@mui/material';
import { getIcon } from '../FileIcon';
export const FileCard = () => {
const { list, prefix } = useResourceStore();
return (
<>
{list.map((resource) => (
<Card key={resource.etag} 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, '')}
</Typography>
{resource.lastModified && <Typography color='text.secondary'>Last Modified: {resource.lastModified}</Typography>}
{resource.size > 0 && <Typography color='text.secondary'>Size: {resource.size} bytes</Typography>}
</CardContent>
</Card>
))}
</>
);
};

View File

@ -0,0 +1,52 @@
import { useResourceStore } from '@/pages/store/resource';
import { Button, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material';
import prettyBytes from 'pretty-bytes';
import dayjs from 'dayjs';
import { getIcon } from '../FileIcon';
export const FileTable = () => {
const { list, prefix, download } = useResourceStore();
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>
</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',
}}>
Download
</Button>
) : (
''
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
};

View File

@ -0,0 +1,112 @@
import React, { Fragment, useEffect, useMemo } from 'react';
import {
AppBar,
Box,
CssBaseline,
Divider,
List,
ListItem,
ListItemButton,
ListItemText,
Toolbar,
Typography,
Button,
useTheme,
ListItemIcon,
Tooltip,
Container,
} from '@mui/material';
import { ActiveMenu, useLayoutStore } from '../store/layout';
import { activeMenuList } from '../modules/MenuList';
import { amber } from '@mui/material/colors';
import { lighten, rgbToHex } from '@mui/material/styles';
import { SquareMenu } from 'lucide-react';
const drawerWidth = 240;
export type LeftProps = {
children?: React.ReactNode;
};
export const Left = ({ children }: LeftProps) => {
const { setActiveMenu, init, showLabel, setShowLabel } = useLayoutStore();
const theme = useTheme();
// console.log(theme.palette.primary.main, rgbToHex(lighten(theme.palette.primary.main, 0.4)));
// console.log(theme.palette.divider);
useEffect(() => {
init();
}, []);
let list = useMemo(() => {
const _activeMenuList = [
{
label: '资源管理器',
value: 'menu',
icon: <SquareMenu />,
hasDivider: true,
},
...activeMenuList,
];
return _activeMenuList;
}, [activeMenuList]);
return (
<Box sx={{ display: 'flex', backgroundColor: amber[50] }}>
<Box component='nav' sx={{ flexShrink: { sm: 0 } }}>
<Box
sx={{
flexShrink: 0,
'& .MuiDrawer-paper': { boxSizing: 'border-box' },
}}>
<Box
sx={{
textAlign: 'center',
height: '100vh',
borderRight: 1,
borderColor: theme.palette.divider,
display: 'flex',
flexDirection: 'column',
}}>
<List sx={{ flexGrow: 1 }} key={list.length}>
{list.map((item) => (
<Fragment key={item.value}>
<ListItem disablePadding>
<ListItemButton
sx={{
'&:hover': {
backgroundColor: theme.palette.primary.main,
color: 'white',
},
color: theme.palette.primary.main,
width: showLabel ? '240px' : '56px',
minHeight: '48px',
}}
onClick={() => {
if (item.value === 'menu') {
// 打开资源管理器
setShowLabel(!showLabel);
} else {
setActiveMenu(item.value as ActiveMenu);
}
}}>
<ListItemIcon sx={{ color: 'inherit' }}>
{!showLabel ? (
<Tooltip placement='right' title={item.label}>
{item.icon}
</Tooltip>
) : (
item.icon
)}
</ListItemIcon>
{showLabel && <ListItemText primary={item.label} />}
</ListItemButton>
</ListItem>
{item.hasDivider && <Divider />}
</Fragment>
))}
</List>
</Box>
</Box>
</Box>
<Container sx={{ flexGrow: 1 }}>{children}</Container>
</Box>
);
};

View File

@ -0,0 +1,4 @@
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
createRoot(document.getElementById('ai-root')!).render(<App />);

View File

@ -0,0 +1,22 @@
import { ActiveMenu, useLayoutStore } from '../store/layout';
import { Upload } from '../upload';
import { Settings } from '../settings';
import { FileApp } from '../file';
import { Statistic } from '../statistic';
export const Main = () => {
const { activeMenu } = useLayoutStore();
if (activeMenu === ActiveMenu.Upload) {
return <Upload />;
}
if (activeMenu === ActiveMenu.Setting) {
return <Settings />;
}
if (activeMenu === ActiveMenu.Resource) {
return <FileApp />;
}
if (activeMenu === ActiveMenu.Statistic) {
return <Statistic />;
}
return <div>{activeMenu}</div>;
};

View File

@ -0,0 +1,21 @@
import { toast } from 'react-toastify';
// Custom message component
const LoginMessage = () => {
const handleClick = () => {
const currentUrl = window.location.href;
const redirect = encodeURIComponent(currentUrl);
window.location.href = '/user/login?redirect=' + redirect;
};
return (
<div className='msg-container' onClick={handleClick} style={{ cursor: 'pointer' }}>
<p className='msg-title'>Please login</p>
<p className='msg-description'>Click here to go to the login page.</p>
</div>
);
};
export const toastLogin = () => {
toast.info(<LoginMessage />);
};

View File

@ -0,0 +1,5 @@
import { toast } from 'react-toastify';
export const toastify = (message: string, type: 'success' | 'error' | 'warning' | 'info') => {
toast(message, { type });
};

View File

@ -0,0 +1,30 @@
import { ActiveMenu } from '../store/layout';
import { UploadIcon, FileText, Settings, BarChart } from 'lucide-react';
export const activeMenuList: {
label: string;
value: ActiveMenu;
icon: any;
hasDivider?: boolean;
}[] = [
{
label: '上传',
value: ActiveMenu.Upload,
icon: <UploadIcon />,
},
{
label: '资源管理',
value: ActiveMenu.Resource,
icon: <FileText />,
},
{
label: '设置',
value: ActiveMenu.Setting,
icon: <Settings />,
},
{
label: '统计',
value: ActiveMenu.Statistic,
icon: <BarChart />,
},
];

View File

@ -0,0 +1,104 @@
import { useSettingsStore, Settings as SettingsType } from '@/pages/store/settings';
import { Box, Typography, TextField, useTheme, Button } from '@mui/material';
import { useEffect, useState } from 'react';
import { Settings as SettingsIcon } from 'lucide-react';
export const FormText = ({
label,
name,
value,
onChange,
disabled,
focused,
}: {
label?: string;
name?: string;
value?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
focused?: boolean;
}) => {
const theme = useTheme();
return (
<TextField
label={label}
variant='outlined'
id={label}
name={name || label}
value={value || ''}
onChange={onChange}
fullWidth
disabled={disabled}
focused={focused}
/>
);
};
export const Settings = () => {
const { settings, updateSettings } = useSettingsStore();
const [config, setConfig] = useState<SettingsType>({});
useEffect(() => {
setConfig(settings);
}, [settings]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let { name, value } = e.target;
console.log(name, value, e.target);
name = name.toLowerCase();
setConfig({ ...config, [name]: value });
};
const theme = useTheme();
const handleSave = async () => {
updateSettings(config);
};
return (
<Box
sx={{
padding: 2,
backgroundColor: 'white',
borderRadius: 2,
boxShadow: '0 0 10px 0 rgba(0, 0, 0, 0.1)',
width: '80%',
// maxWidth: '600px',
margin: '0 auto',
marginTop: 4,
[theme.breakpoints.down('sm')]: {
width: '90%',
},
}}>
<div className='flex items-center gap-3 mb-8'>
<SettingsIcon className='w-8 h-8 text-amber-600' />
<Typography
variant='h1'
className='text-amber-900'
sx={{
fontSize: { xs: '1.3rem', sm: '2rem' },
fontWeight: 'bold',
}}>
Upload Settings
</Typography>
</div>
<form>
<Box mb={2}>
<FormText label='Key' value={config.key} onChange={handleChange} focused={true} />
</Box>
<Box mb={2}>
<FormText label='Version' value={config.version} onChange={handleChange} disabled={true} />
</Box>
<Box mb={2}>
<FormText label='Username' value={config.username} onChange={handleChange} disabled={true} />
</Box>
<Box mb={2}>
<FormText label='Prefix' value={config.prefix} onChange={handleChange} disabled={true} />
</Box>
<Box mb={2}>
<Button
variant='contained'
sx={{
color: 'white',
}}
onClick={handleSave}>
</Button>
</Box>
</form>
</Box>
);
};

View File

@ -0,0 +1,32 @@
import { useEffect } from 'react';
import { useStatisticStore } from '../store/statistic';
import { Typography, Box, Card, CardContent } from '@mui/material';
import { BarChart } from 'lucide-react';
export const Statistic = () => {
const { statistic, getStatistic } = useStatisticStore();
useEffect(() => {
getStatistic();
}, []);
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'>
<BarChart className='w-8 h-8 text-amber-600' />
<Typography
variant='h1'
className='text-amber-900'
sx={{
fontSize: { xs: '1.3rem', sm: '2rem' },
fontWeight: 'bold',
}}>
Statistic
</Typography>
</div>
<Card>
<CardContent>
<div>allFileSize: {parseFloat(statistic.sizeMb + '').toFixed(2)} MB</div>
</CardContent>
</Card>
</Box>
);
};

View File

@ -0,0 +1,61 @@
import { create } from 'zustand';
export enum ActiveMenu {
Upload = 'upload',
Resource = 'resource',
Setting = 'setting',
Statistic = 'statistic',
}
export const activeMenuList = [
{
label: '上传',
value: ActiveMenu.Upload,
},
{
label: '资源管理',
value: ActiveMenu.Resource,
},
{
label: '设置',
value: ActiveMenu.Setting,
},
{
label: '统计',
value: ActiveMenu.Statistic,
},
];
interface LayoutStore {
layout: 'left' | 'right';
setLayout: (layout: 'left' | 'right') => void;
/**
* upload
*/
activeMenu: ActiveMenu;
setActiveMenu: (activeMenu: ActiveMenu) => void;
open: boolean;
setOpen: (open: boolean) => void;
showLabel: boolean;
setShowLabel: (showLabel: boolean) => void;
init: () => void;
}
export const useLayoutStore = create<LayoutStore>((set) => ({
layout: 'left',
setLayout: (layout) => set({ layout }),
activeMenu: ActiveMenu.Upload,
setActiveMenu: (activeMenu) => set({ activeMenu }),
open: false,
setOpen: (open) => set({ open }),
showLabel: true,
setShowLabel: (showLabel) => {
localStorage.setItem('showLabel', showLabel.toString());
set({ showLabel });
},
init: () => {
const showLabel = localStorage.getItem('showLabel');
if (showLabel) {
set({ showLabel: showLabel === 'true' });
}
},
}));

View File

@ -0,0 +1,79 @@
import { create } from 'zustand';
import { query } from '@/modules/query';
import { sortBy } from 'lodash-es';
import { toast } from 'react-toastify';
export type Resource = {
name: string;
lastModified: string;
etag: string;
metaData: Record<string, string>;
size: number;
prefix?: string;
};
interface ResourceStore {
resources: Resource[];
loading: boolean;
setLoading: (loading: boolean) => void;
list: Resource[];
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;
}
export const useResourceStore = create<ResourceStore>((set, get) => ({
resources: [],
loading: false,
setLoading: (loading: boolean) => set({ loading }),
list: [],
setList: (list: any[]) => set({ list }),
prefix: '',
setPrefix: (prefix: string) => set({ prefix }),
getList: async () => {
set({ loading: true });
const { prefix, getList } = get();
if (!prefix) {
setTimeout(() => {
getList();
}, 2000);
return;
}
const res = await query.post({
path: 'file',
key: 'list',
data: {
prefix,
},
});
set({ loading: false });
console.log(res);
if (res.code === 200) {
const list = res.data;
const sortedList = sortBy(list, [(item) => !item.prefix]);
set({ list: sortedList });
} else {
toast.error(res.message || 'Request failed');
}
},
download: (resource: Resource) => {
const { prefix } = get();
const url = `${prefix}/${resource.name}`;
window.open(url, '_blank');
},
listType: 'table',
setListType: (listType: 'table' | 'card') => {
localStorage.setItem('listType', listType);
set({ listType });
},
init: () => {
const listType = localStorage.getItem('listType');
if (listType) {
set({ listType: listType as 'table' | 'card' });
}
},
}));

View File

@ -0,0 +1,65 @@
import { create } from 'zustand';
import { query } from '@/modules/query';
import { toast } from 'react-toastify';
export type Settings = {
key?: string;
version?: string;
username?: string;
prefix?: string;
};
interface SettingsStore {
settings: Settings;
setSettings: (settings: Settings) => void;
querySettings: () => Promise<void>;
mounted: boolean;
setMounted: (mounted: boolean) => void;
init: () => Promise<void>;
updateSettings: (settings: Settings) => void;
}
export const useSettingsStore = create<SettingsStore>((set, get) => ({
settings: {},
setSettings: (settings) => set({ settings }),
mounted: false,
setMounted: (mounted) => set({ mounted }),
querySettings: async () => {
const settings = get().settings;
const res = await query.post({
path: 'config',
key: 'getUploadConfig',
});
console.log(res);
if (res.code === 200) {
const config = res.data;
localStorage.setItem('upload-config', JSON.stringify(config));
set({ settings: config });
if (JSON.stringify(settings) !== JSON.stringify(config)) {
set({ mounted: false });
set({ mounted: true });
}
} else {
toast.error(res.message || '获取配置失败');
}
},
updateSettings: async (settings) => {
console.log('setinng', settings);
// const res = await query.post({
// path: 'config',
// key: 'updateUploadConfig',
// data: settings,
// });
},
init: async () => {
const cacheConfig = localStorage.getItem('upload-config');
if (cacheConfig) {
try {
set({ settings: JSON.parse(cacheConfig), mounted: true });
} catch (error) {
toast.error('配置文件损坏');
localStorage.removeItem('upload-config');
}
}
get().querySettings();
},
}));

View File

@ -0,0 +1,31 @@
import { create } from 'zustand';
import { query } from '@/modules/query';
type Statistic = {
list: any[];
total: number;
size: number;
sizeMb: number;
};
export interface StatisticStore {
statistic: Statistic;
setStatistic: (statistic: Statistic) => void;
getStatistic: () => Promise<void>;
}
export const useStatisticStore = create<StatisticStore>((set) => ({
statistic: {
list: [],
total: 0,
size: 0,
sizeMb: 0,
},
setStatistic: (statistic: Statistic) => set({ statistic }),
getStatistic: async () => {
const res = await query.post({
path: 'file',
key: 'me-all-file-stat',
});
set({ statistic: res.data });
},
}));

View File

@ -0,0 +1,71 @@
import { Box, useTheme, Container, Typography } 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 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);
} else if (acceptedFiles.length === 1) {
const res = await uploadFileChunked(acceptedFiles[0], {});
console.log('uploadFiles res', res);
}
};
const { getRootProps, getInputProps } = useDropzone({ onDrop });
const theme = useTheme();
return (
<Box
sx={{
backgroundColor: theme.palette.background.paper,
padding: { xs: '16px', sm: '32px' },
borderRadius: '8px',
margin: { xs: '16px', sm: '32px' },
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'
sx={{
fontSize: { xs: '1.3rem', sm: '2rem' },
fontWeight: 'bold',
}}>
Resources Upload
</Typography>
</div>
<Box
{...getRootProps()}
sx={{
border: '2px dashed',
borderColor: theme.palette.secondary.main,
borderRadius: '8px',
padding: { xs: '16px', sm: '32px' },
margin: { xs: '16px', sm: '32px' },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '16px',
'&:hover': {
borderColor: theme.palette.primary.main,
},
transition: 'border-color 200ms',
textAlign: 'center',
}}>
<UploadIcon style={{ width: '48px', height: '48px', color: theme.palette.secondary.main }} />
<label style={{ cursor: 'pointer' }}>
<input type='file' style={{ display: 'none' }} {...getInputProps()} />
<Box sx={{ color: theme.palette.secondary.main, '&:hover': { color: theme.palette.primary.main } }}>Click to upload or drag and drop</Box>
</label>
</Box>
</Box>
);
};

View File

@ -0,0 +1,88 @@
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import { toast } from 'react-toastify';
import { nanoid } from 'nanoid';
import { toastLogin } from '@/pages/message/ToastLogin';
type ConvertOpts = {
appKey?: string;
version?: string;
username?: string;
};
export const uploadFileChunked = async (file: File, opts: ConvertOpts) => {
return new Promise(async (resolve, reject) => {
const token = localStorage.getItem('token');
if (!token) {
toastLogin();
return;
}
const taskId = nanoid();
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);
// 监听服务器推送的进度更新
eventSource.onmessage = function (event) {
console.log('Progress update:', event.data);
const parseIfJson = (data: string) => {
try {
return JSON.parse(data);
} catch (e) {
return data;
}
};
const receivedData = parseIfJson(event.data);
if (typeof receivedData === 'string') return;
const progress = receivedData.progress;
const progressFixed = progress.toFixed(2);
console.log('progress', progress, progressFixed);
toast.update(load, { render: `${filename} \n上传中...${progressFixed}%`, isLoading: true, autoClose: false });
if (progress) {
NProgress.set(progress);
}
};
eventSource.onerror = function (event) {
console.log('eventSource.onerror', event);
reject(event);
};
const chunkSize = 1 * 1024 * 1024; // 1MB
const totalChunks = Math.ceil(file.size / chunkSize);
for (let currentChunk = 0; currentChunk < totalChunks; currentChunk++) {
const start = currentChunk * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk, file.name);
formData.append('chunkIndex', currentChunk.toString());
formData.append('totalChunks', totalChunks.toString());
try {
const res = await fetch('/api/s1/resources/upload/chunk?taskId=' + taskId, {
method: 'POST',
body: formData,
headers: {
'task-id': taskId,
Authorization: `Bearer ${token}`,
},
}).then((response) => response.json());
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

@ -0,0 +1,73 @@
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import { toast } from 'react-toastify';
import { nanoid } from 'nanoid';
import { toastLogin } from '@/pages/message/ToastLogin';
type ConvertOpts = {
appKey?: string;
version?: string;
username?: string;
};
export const uploadFiles = async (files: File[], opts: ConvertOpts) => {
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);
}
const token = localStorage.getItem('token');
if (!token) {
toastLogin();
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) {
console.log('eventSource.onopen', event);
const res = await fetch('/api/s1/resources/upload?taskId=' + taskId, {
method: 'POST',
body: formData,
headers: {
'task-id': taskId,
Authorization: `Bearer ${token}`,
},
}).then((response) => response.json());
console.log('upload success', res);
fetch('/api/s1/events/close?taskId=' + taskId);
eventSource.close();
NProgress.done();
toast.dismiss(load);
resolve(res);
};
// 监听服务器推送的进度更新
eventSource.onmessage = function (event) {
console.log('Progress update:', event.data);
const parseIfJson = (data: string) => {
try {
return JSON.parse(data);
} catch (e) {
return data;
}
};
const receivedData = parseIfJson(event.data);
if (typeof receivedData === 'string') return;
const progress = receivedData.progress;
console.log('progress', progress);
toast.update(load, { render: `上传中...${progress}%`, isLoading: true, autoClose: false });
if (progress) {
NProgress.set(progress);
}
};
eventSource.onerror = function (event) {
console.log('eventSource.onerror', event);
reject(event);
};
});
};

View File

@ -0,0 +1,39 @@
{
"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": {
"@/*": [
"src/*"
]
},
/* Linting */
"strict": true,
"noImplicitAny": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": [
"src",
]
}

View File

@ -0,0 +1,80 @@
import { defineConfig } from 'vite';
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 plugins = [basicSsl()];
// const plugins = [];
plugins.push(tailwindcss());
let proxy = {};
if (true) {
proxy = {
'/api': {
target: 'https://kevisual.silkyai.cn',
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(/^\/api/, '/api'),
},
'/api/router': {
target: 'wss://kevisual.silkyai.cn',
changeOrigin: true,
ws: true,
rewriteWsOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api'),
},
'/user/login': {
target: 'https://kevisual.silkyai.cn',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/user/, '/user'),
},
};
}
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), ...plugins],
css: {
postcss: {},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
define: {
DEV_SERVER: JSON.stringify(process.env.NODE_ENV === 'development'),
},
base: isDev ? '/' : '/root/resources/',
server: {
port: 6022,
host: '0.0.0.0',
proxy: {
'/system/lib': {
target: 'https://kevisual.xiongxiao.me',
changeOrigin: true,
},
'/api': {
target: 'http://localhost:4005',
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(/^\/api/, '/api'),
},
'/api/router': {
target: 'ws://localhost:4005',
changeOrigin: true,
ws: true,
rewriteWsOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api'),
},
'/api/s1/events': {
target: 'https://kevisual.silkyai.cn',
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(/^\/api/, '/api'),
},
...proxy,
},
},
});

690
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,2 @@
packages:
- 'packages/*'