feat: add uploads app

This commit is contained in:
熊潇 2025-01-04 14:04:09 +08:00
parent 5b1b73fae5
commit 80d2ec49a3
20 changed files with 1377 additions and 91 deletions

5
.npmrc Normal file
View File

@ -0,0 +1,5 @@
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
@abearxiong:registry=https://npm.pkg.github.com
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
@build:registry=https://npm.xiongxiao.me
@kevisual:registry=https://npm.xiongxiao.me

View File

@ -2,3 +2,19 @@
`/system/lib/app.js` 包函的模块是 `QueryRouterServer``Page``useConfigKey` `/system/lib/app.js` 包函的模块是 `QueryRouterServer``Page``useConfigKey`
## deploy app的过程
- `envision pack -p`
- `curl` 请求
```sh
curl https://envision.xiongxiao.me/api/router?path=micro-app&key=deploy -d '{
"data": {
"id": "17d105b8-9b6b-4cfc-9447-c815f24fe3d7",
"key": "mark"
}
}'
```
- 启动 `https://kevisual.xiongxiao.me/api/router?path=local-apps&key=updateStatus&appKey=mark&status=start`

View File

@ -1,5 +1,5 @@
{ {
"name": "app-template", "name": "app-show",
"version": "0.0.1", "version": "0.0.1",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
@ -7,8 +7,8 @@
"dev": "cross-env WEB_DEV=true vite", "dev": "cross-env WEB_DEV=true vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"prepub": "envision switchOrg user", "prepub": "envision switchOrg system",
"pub": "envision deploy ./dist -k app-template -v 0.0.1" "pub": "envision deploy ./dist -k app-show -v 0.0.1"
}, },
"keywords": [], "keywords": [],
"author": "abearxiong <xiongxiao@xiongxiao.me>", "author": "abearxiong <xiongxiao@xiongxiao.me>",
@ -19,23 +19,33 @@
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@kevisual/query": "0.0.7-alpha.3", "@kevisual/query": "0.0.7-alpha.3",
"@kevisual/system-ui": "^0.0.3", "@kevisual/system-ui": "^0.0.3",
"@mui/icons-material": "^6.3.0",
"@mui/material": "^6.3.0", "@mui/material": "^6.3.0",
"autoprefixer": "^10.4.20",
"clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router": "^7.1.1",
"react-router-dom": "^7.1.1",
"tailwindcss": "^3.4.17",
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
"@build/tailwind": "1.0.2-alpha-2",
"@emotion/css": "^11.13.5", "@emotion/css": "^11.13.5",
"@kevisual/router": "0.0.6-alpha-4", "@kevisual/router": "0.0.6-alpha-4",
"@kevisual/store": "0.0.1-alpha.9", "@kevisual/store": "0.0.1-alpha.9",
"@kevisual/types": "^0.0.5", "@kevisual/types": "^0.0.5",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/typography": "^0.5.15",
"@types/react": "^19.0.2", "@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2", "@types/react-dom": "^19.0.2",
"@vitejs/plugin-basic-ssl": "^1.2.0", "@vitejs/plugin-basic-ssl": "^1.2.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"react": "^19.0.0", "react": "^19.0.0",
"tailwindcss-animate": "^1.0.7",
"vite": "^6.0.6" "vite": "^6.0.6"
} }
} }

870
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,15 @@
import { createRoot } from 'react-dom/client'; import { BrowserRouter, Route, Routes } from 'react-router';
export const App = () => { import { List } from './pages/List';
import { LayoutMain } from './layouts';
export const ReactApp = () => {
return ( return (
<div className='flex justify-center items-center h-screen bg-gray-200'> <BrowserRouter basename='/system/app-show'>
<h1 className='text-4xl font-bold text-gray-800'>Hello Vite + React!</h1> <Routes>
</div> <Route element={<LayoutMain />}>
<Route path='/show-home' element={<List />} />
</Route>
</Routes>
</BrowserRouter>
); );
}; };
createRoot(document.getElementById('root')!).render(<App />);

9
src/app.ts Normal file
View File

@ -0,0 +1,9 @@
import { useContextKey } from '@kevisual/store/config';
import { Page } from "@kevisual/store/page";
export const page = useContextKey('page', () => {
return new Page({
basename: '/system/app-show',
});
});

View File

@ -1,44 +0,0 @@
import { useStore } from '@/store/app';
import { useEffect } from 'react';
import { css } from '@emotion/css';
const containerStyle = css`
padding: 20px;
display: flex;
`;
const itemStyle = css`
display: flex;
min-width: 240px;
flex-direction: column;
padding: 20px;
margin-bottom: 20px;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
background-color: #fff;
`;
export const List = () => {
const store = useStore();
useEffect(() => {
store.init();
}, []);
return (
<div className={containerStyle}>
<div>
{store.list.map((item) => {
return (
<div className={itemStyle} key={item.key}>
<span>{item.key}</span>
<span>{item.description}</span>
<span>{item.status}</span>
<span>{item.type}</span>
<span>{item.version}</span>
</div>
);
})}
</div>
</div>
);
};

View File

@ -0,0 +1,13 @@
import { Outlet } from 'react-router-dom';
import { LayoutMenu } from './LayoutMenu';
export const LayoutMain = () => {
return (
<div className='bg-slate-200' style={{ display: 'flex', height: '100%' }}>
<LayoutMenu />
<main style={{ flexGrow: 1, height: '100%', overflow: 'auto' }}>
<Outlet />
</main>
</div>
);
};

View File

@ -0,0 +1,25 @@
import React from 'react';
import { Menu, MenuItem, ListItemIcon, ListItemText } from '@mui/material';
import InboxIcon from '@mui/icons-material/Inbox';
import MailIcon from '@mui/icons-material/Mail';
// ...existing code...
const menuItems = [
{ text: 'Inbox', icon: <InboxIcon /> },
{ text: 'Mail', icon: <MailIcon /> },
// 可以根据需要添加更多菜单项
];
export const LayoutMenu = () => {
return (
<Menu open={false} anchorOrigin={{ vertical: 'top', horizontal: 'left' }}>
{menuItems.map((item, index) => (
<MenuItem key={index}>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</MenuItem>
))}
</Menu>
);
};

1
src/layouts/index.ts Normal file
View File

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

View File

@ -1,29 +1,41 @@
import { useContextKey } from '@kevisual/store/config'; import { useContextKey } from '@kevisual/store/config';
import { Page } from '@kevisual/store/page';
import { QueryRouterServer } from '@kevisual/router'; import { QueryRouterServer } from '@kevisual/router';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { List } from './app/List'; import { List } from './pages/List';
import { List as UploadAppList } from './pages/upload-apps/List';
import { page } from './app';
import '@build/tailwind/main.css';
// import './tailwind.css';
export const initRoot = (renderRoot?: any) => {
return useContextKey('root', () => {
if (!renderRoot) {
console.error('renderRoot is required');
}
return createRoot(renderRoot);
});
};
export const render = ({ renderRoot }) => { export const render = ({ renderRoot }) => {
// renderRoot.innerHTML = ` const root = initRoot(renderRoot);
// <h1>Hello, World!</h1>
// `;
const root = createRoot(renderRoot);
// @ts-ignore
root.render(<List />); root.render(<List />);
}; };
const page = useContextKey('page', () => {
return new Page({
basename: '',
});
});
if (page) { if (page) {
page.addPage('/app-template', 'home'); initRoot(document.getElementById('ai-root'));
page.subscribe('home', () => { page.addPage('/', 'home');
render({ page.addPage('/local-apps', 'local-apps');
renderRoot: document.getElementById('ai-root'), page.subscribe('local-apps', () => {
const root = initRoot();
root.render(<List />);
}); });
page.addPage('/upload-apps', 'upload-apps');
page.subscribe('upload-apps', () => {
const root = initRoot();
root.render(<UploadAppList />);
});
page.subscribe('home', () => {
page.navigate('/local-apps');
}); });
} }
@ -34,7 +46,7 @@ const app = useContextKey('app', () => {
if (app) { if (app) {
app app
.route({ .route({
path: 'app-template', path: 'show-home',
key: 'render', key: 'render',
}) })
.define(async (ctx) => { .define(async (ctx) => {

105
src/pages/List.tsx Normal file
View File

@ -0,0 +1,105 @@
import { useStore } from '@/store/app';
import { useEffect } from 'react';
import { css } from '@emotion/css';
import clsx from 'clsx';
import { Page } from '@kevisual/store/page';
import { page } from '@/app';
import { useOperateStore } from '@/store/operate';
import { message } from '@/modules/message';
const containerStyle = css`
padding: 20px;
`;
const itemStyle = css`
display: flex;
min-width: 240px;
flex-direction: column;
padding: 20px;
margin-bottom: 20px;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
background-color: #fff;
`;
export const List = () => {
const store = useStore();
const operateStore = useOperateStore();
useEffect(() => {
store.init();
}, []);
return (
<div className={clsx(containerStyle, 'h-full w-full ')}>
<div
onClick={() => {
page.navigate('/upload-apps');
}}>
To Upload Apps
</div>
<h1 className='text-2xl font-bold'>Local Apps</h1>
<div>
<div className='local-apps-headers flex flex-row-reverse'>
<button
className='px-4 py-2 mt-2 text-white bg-blue-500 rounded hover:bg-blue-700'
onClick={async () => {
const res = await operateStore.detect();
if (res.code === 200) {
store.init();
}
}}>
Detect
</button>
</div>
<div className='flex flex-row flex-wrap gap-4'>
{store.list.map((item) => {
return (
<div className={itemStyle} key={item.key}>
<span>{item.key}</span>
<span>{item.description}</span>
<span>{item.status}</span>
<span>{item.type}</span>
<span>{item.version}</span>
<div className='flex flex-row gap-2'>
<button
className='px-4 py-2 mt-2 text-white bg-blue-500 rounded hover:bg-blue-700'
onClick={async () => {
if (item.status !== 'stop') {
const res = await operateStore.updateStatus({ status: 'stop', appKey: item.key });
if (res.code === 200) {
store.init();
}
}
}}>
Stop
</button>
<button
className='px-4 py-2 mt-2 text-white bg-blue-500 rounded hover:bg-blue-700'
onClick={async () => {
if (item.status !== 'running') {
const res = await operateStore.updateStatus({ status: 'start', appKey: item.key });
if (res.code === 200) {
store.init();
}
}
}}>
Start
</button>
<button
className='px-4 py-2 mt-2 text-white bg-blue-500 rounded hover:bg-blue-700'
onClick={async () => {
const res = await store.deleteData(item.key, { refresh: true });
//
}}>
Delete
</button>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,54 @@
import { useAppUploadStore } from '@/store/app-upload';
import { useEffect } from 'react';
import { KeyEditModal } from './modal/Edit';
import { page } from '@/app';
import dayjs from 'dayjs';
export const List = () => {
const store = useAppUploadStore();
useEffect(() => {
store.init();
}, []);
const onDeploy = (item: { id: string; [key: string]: any }) => {
console.log(item);
store.setFormData({
id: item.id,
key: '',
});
store.setShowModal(true);
};
return (
<div className='p-4'>
<div
onClick={() => {
page.navigate('/local-apps');
}}>
To Local Apps
</div>
<h1 className='text-2xl font-bold'>Upload Apps</h1>
<div className='flex flex-wrap gap-4'>
{store.list.map((item) => {
return (
<div className='p-4 mb-4 border rounded shadow bg-white w-[300px] flex flex-col' key={item.id}>
<span>{item.title}</span>
<span>{item.description}</span>
<span>{item.tags.join(', ')}</span>
<span>{item.type}</span>
<span>{dayjs(item.updatedTime).format('YYYY-MM-DD HH:mm:ss')}</span>
<div>
<button
className='px-4 py-2 mt-2 text-white bg-blue-500 rounded hover:bg-blue-700'
onClick={() => {
onDeploy(item);
}}>
Deploy
</button>
</div>
</div>
);
})}
</div>
<KeyEditModal />
</div>
);
};

View File

@ -0,0 +1,40 @@
import { useAppUploadStore } from '@/store/app-upload';
import { Modal } from '@mui/material';
export const KeyEditModal = () => {
const store = useAppUploadStore();
const onSave = () => {
console.log('save');
store.setShowModal(false);
const fromData = store.formData;
store.pubApp(fromData);
console.log(fromData);
};
return (
<Modal
className='fixed flex items-center justify-center'
open={store.showModal}
onClose={() => store.setShowModal(false)}
>
<div className='p-4 bg-white w-[400px]'>
<h1 className='text-2xl font-bold'>Edit Key</h1>
<div className='mt-4'>
<label className='block'>Key</label>
<input
type='text'
className='w-full p-2 border rounded'
onChange={(e) => {
const value = e.target.value;
store.setFormData({ ...store.formData, key: value });
}}
/>
</div>
<div className='mt-4'>
<button className='px-4 py-2 text-white bg-blue-500 rounded hover:bg-blue-700' onClick={onSave}>
Deploy
</button>
</div>
</div>
</Modal>
);
};

107
src/store/app-upload.ts Normal file
View File

@ -0,0 +1,107 @@
import { create } from 'zustand';
import { query } from '@/modules/query';
import { message } from '@/modules/message';
type Store = {
list: any[];
setList: (list: any[]) => void;
data: any;
setData: (data: any) => void;
loading: boolean;
setLoading: (loading: boolean) => void;
formData: any;
setFormData: (data: any) => void;
getList: () => Promise<any>;
init: () => Promise<void>;
getData: (id: number) => Promise<any>;
updateData: (data: any, opts?: { refresh?: boolean }) => Promise<any>;
deleteData: (id: number, opts?: { refresh?: boolean }) => Promise<any>;
showModal: boolean;
setShowModal: (showModal: boolean) => void;
pubApp: (data: any) => Promise<any>;
};
export const useAppUploadStore = create<Store>((set, get) => ({
list: [],
setList: (list) => set({ list }),
data: null,
setData: (data) => set({ data }),
loading: false,
setLoading: (loading) => set({ loading }),
formData: null,
setFormData: (formData) => set({ formData }),
getList: async () => {
set({ loading: true });
const res = await query.post({ path: 'micro-app-upload', key: 'list' });
set({ loading: false });
if (res.code === 200) {
set({ list: res.data });
}
return res;
},
init: async () => {
await get().getList();
},
getData: async (id) => {
set({ loading: true });
const res = await query.post({
path: 'micro-app-upload',
key: 'get',
id,
});
set({ loading: false });
if (res.code === 200) {
const data = res.data;
set({ data });
}
return res;
},
updateData: async (data, opts = { refresh: true }) => {
set({ loading: true });
const res = await query.post({
path: 'micro-app-upload',
key: 'update',
data,
});
set({ loading: false });
if (res.code === 200) {
set({ data: res.data });
}
if (opts.refresh) {
await get().getList();
}
return res;
},
deleteData: async (id, opts = { refresh: true }) => {
set({ loading: true });
const res = await query.post({
path: 'micro-app-upload',
key: 'delete',
id,
});
set({ loading: false });
if (res.code === 200) {
set({ data: null });
}
if (opts.refresh) {
await get().getList();
}
return res;
},
showModal: false,
setShowModal: (showModal) => set({ showModal }),
pubApp: async (data) => {
set({ loading: true });
const res = await query.post({
path: 'micro-app',
key: 'deploy',
data,
});
set({ loading: false });
if (res.code === 200) {
message.success('发布成功');
set({ data: null });
} else {
message.error(res.message || '发布失败');
}
},
}));

View File

@ -72,7 +72,7 @@ export const useStore = create<Store>((set, get) => ({
const res = await query.post({ const res = await query.post({
path: 'local-apps', path: 'local-apps',
key: 'delete', key: 'delete',
id, appKey: id,
}); });
set({ loading: false }); set({ loading: false });
if (res.code === 200) { if (res.code === 200) {

View File

@ -2,53 +2,66 @@ import { create } from 'zustand';
import { query } from '@/modules/query'; import { query } from '@/modules/query';
import { message } from '@/modules/message'; import { message } from '@/modules/message';
interface OperateStore { interface OperateStore {
updateStatus: () => Promise<void>; updateStatus: (data: { status: 'stop' | 'start'; appKey: string }) => Promise<any>;
detect: () => Promise<void>; detect: () => Promise<any>;
download: () => Promise<void>; download: () => Promise<any>;
/** /**
* *
*/ */
upload: () => Promise<void>; upload: () => Promise<any>;
unload: () => Promise<void>; unload: () => Promise<any>;
deploy: () => Promise<void>; deploy: () => Promise<any>;
} }
export const useOperateStore = create<OperateStore>((set) => ({ export const useOperateStore = create<OperateStore>((set) => ({
updateStatus: async () => { updateStatus: async (data) => {
const res = await query.post({ const res = await query.post({
path: 'local-apps', path: 'local-apps',
key: 'updateStatus', key: 'updateStatus',
status: data.status,
appKey: data.appKey,
}); });
if (res.code === 200) {
message.success('操作成功');
} else {
message.error(res.message || '操作失败');
}
return res;
}, },
detect: async () => { detect: async () => {
const res = await query.post({ const res = await query.post({
path: 'local-apps', path: 'local-apps',
key: 'detect', key: 'detect',
}); });
return res;
}, },
download: async () => { download: async () => {
const res = await query.post({ const res = await query.post({
path: 'local-apps', path: 'local-apps',
key: 'download', key: 'download',
}); });
return res;
}, },
upload: async () => { upload: async () => {
const res = await query.post({ const res = await query.post({
path: 'micro-apps', path: 'micro-apps',
key: 'upload', key: 'upload',
}); });
return res;
}, },
unload: async () => { unload: async () => {
const res = await query.post({ const res = await query.post({
path: 'micro-apps', path: 'micro-apps',
key: 'unload', key: 'unload',
}); });
return res;
}, },
deploy: async () => { deploy: async () => {
const res = await query.post({ const res = await query.post({
path: 'micro-apps', path: 'micro-apps',
key: 'deploy', key: 'deploy',
}); });
return res;
}, },
})); }));

4
src/tailwind.css Normal file
View File

@ -0,0 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

47
tailwind.config.js Normal file
View File

@ -0,0 +1,47 @@
import path from 'path';
const root = path.resolve(process.cwd());
const contents = ['./src/**/*.{ts,tsx,html}', './src/**/*.css']
const content = contents.map((item) => path.join(root, item));
/** @type {import('tailwindcss').Config} */
export default {
// darkMode: ['class'],
content: content,
plugins: [
require('@tailwindcss/aspect-ratio'), //
require('@tailwindcss/typography'),
require('tailwindcss-animate'),
require('@build/tailwind'),
],
theme: {
extend: {
fontFamily: {
mon: ['Montserrat', 'sans-serif'], // 定义自定义字体族
rob: ['Roboto', 'sans-serif'],
int: ['Inter', 'sans-serif'],
orb: ['Orbitron', 'sans-serif'],
din: ['DIN', 'sans-serif'],
},
},
screen: {
sm: '640px',
// => @media (min-width: 640px) { ... }
md: '768px',
// => @media (min-width: 768px) { ... }
lg: '1024px',
// => @media (min-width: 1024px) { ... }
xl: '1280px',
// => @media (min-width: 1280px) { ... }
'2xl': '1536px',
// => @media (min-width: 1536px) { ... }
'3xl': '1920px',
// => @media (min-width: 1920) { ... }
'4xl': '2560px',
// => @media (min-width: 2560) { ... }
},
},
};

View File

@ -3,6 +3,9 @@ import react from '@vitejs/plugin-react'
// import basicSsl from '@vitejs/plugin-basic-ssl'; // import basicSsl from '@vitejs/plugin-basic-ssl';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import path from 'path'; import path from 'path';
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';
import nesting from 'tailwindcss/nesting';
const isDev = process.env.NODE_ENV === 'development'; const isDev = process.env.NODE_ENV === 'development';
const BUILD_TIME = dayjs().format('YYYY-MM-DD HH:mm:ss'); const BUILD_TIME = dayjs().format('YYYY-MM-DD HH:mm:ss');
@ -10,6 +13,13 @@ console.log('process', isDev, process.env.WEB_DEV)
export default defineConfig({ export default defineConfig({
// plugins: [basicSsl()], // plugins: [basicSsl()],
plugins: [react()], plugins: [react()],
css: {
postcss: {
// @ts-ignore
plugins: [nesting, tailwindcss, autoprefixer],
},
},
base: isDev ? '/' : './',
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
@ -32,13 +42,13 @@ export default defineConfig({
proxy: { proxy: {
'/api': { '/api': {
target: 'https://kevisual.xiongxiao.me', target: 'https://kevisual.xiongxiao.me',
// target: 'http://localhost:9787',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api'), rewrite: (path) => path.replace(/^\/api/, '/api'),
}, },
'/system': { '/system/lib': {
target: 'https://kevisual.xiongxiao.me', target: 'https://kevisual.xiongxiao.me',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/system/, '/system'),
}, },
}, },
}, },