feat: first commit
This commit is contained in:
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
/coverage
|
||||||
|
/.nyc_output
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
2
admin/.env
Normal file
2
admin/.env
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_APP_TITLE=nestjs-admin
|
||||||
|
VITE_APP_BASE_URL=http://127.0.0.1:3000/api/admin
|
||||||
1
admin/.eslintignore
Normal file
1
admin/.eslintignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
34
admin/.eslintrc
Normal file
34
admin/.eslintrc
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"commonjs": true,
|
||||||
|
"es6": true,
|
||||||
|
"node": true,
|
||||||
|
"jest": true
|
||||||
|
},
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"extends": [
|
||||||
|
"prettier",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:react-hooks/recommended"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"experimentalObjectRestSpread": true,
|
||||||
|
"jsx": true
|
||||||
|
},
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"react": {
|
||||||
|
"version": "16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": ["react", "babel", "@typescript-eslint/eslint-plugin"],
|
||||||
|
"rules": {
|
||||||
|
"react/display-name": 0,
|
||||||
|
"react/prop-types": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
17
admin/.gitignore
vendored
Normal file
17
admin/.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
/dist/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
/test/unit/coverage/
|
||||||
|
/test/e2e/reports/
|
||||||
|
selenium-debug.log
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
7
admin/.prettierrc
Normal file
7
admin/.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
4
admin/.stylelintignore
Normal file
4
admin/.stylelintignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
**/*.ts
|
||||||
|
**/*.tsx
|
||||||
|
**/*.jsx
|
||||||
|
**/*.js
|
||||||
17
admin/.stylelintrc
Normal file
17
admin/.stylelintrc
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": ["stylelint-config-standard", "stylelint-config-prettier"],
|
||||||
|
"customSyntax": "postcss-less",
|
||||||
|
"rules": {
|
||||||
|
"selector-class-pattern": null,
|
||||||
|
"no-descending-specificity": null,
|
||||||
|
"no-duplicate-selectors": null,
|
||||||
|
"color-function-notation": null,
|
||||||
|
"font-family-no-missing-generic-family-keyword": null,
|
||||||
|
"selector-pseudo-class-no-unknown": [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
"ignorePseudoClasses": ["global"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
0
admin/README.md
Normal file
0
admin/README.md
Normal file
BIN
admin/favicon.ico
Normal file
BIN
admin/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
18
admin/index.html
Normal file
18
admin/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link
|
||||||
|
rel="shortcut icon"
|
||||||
|
type="image/x-icon"
|
||||||
|
href="favicon.ico"
|
||||||
|
/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||||
|
<title><%- title %></title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
83
admin/package.json
Normal file
83
admin/package.json
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"name": "nestjs-admin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"scripts": {
|
||||||
|
"start": "vite",
|
||||||
|
"dev": "vite",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"build": "vite build",
|
||||||
|
"eslint": "eslint src/ --ext .ts,.tsx,.js,.jsx --fix --cache",
|
||||||
|
"stylelint": "stylelint 'src/**/*.less' 'src/**/*.css' --fix --cache"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@antv/data-set": "^0.11.8",
|
||||||
|
"@arco-design/color": "^0.4.0",
|
||||||
|
"@arco-design/web-react": "^2.32.2",
|
||||||
|
"@arco-plugins/vite-react": "^1.0.5",
|
||||||
|
"@arco-themes/react-arco-pro": "^0.0.7",
|
||||||
|
"@loadable/component": "^5.13.2",
|
||||||
|
"@turf/turf": "^6.5.0",
|
||||||
|
"arco-design-pro": "^2.8.0",
|
||||||
|
"axios": "^0.24.0",
|
||||||
|
"classnames": "^2.3.1",
|
||||||
|
"copy-to-clipboard": "^3.3.1",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"nprogress": "^0.2.0",
|
||||||
|
"query-string": "^6.13.8",
|
||||||
|
"react": "^17.0.2",
|
||||||
|
"react-color": "^2.18.1",
|
||||||
|
"react-dom": "^17.0.2",
|
||||||
|
"react-query": "^3.39.3",
|
||||||
|
"react-router": "^5.2.0",
|
||||||
|
"react-router-dom": "^5.2.0",
|
||||||
|
"zustand": "^4.5.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@arco-design/webpack-plugin": "^1.6.0",
|
||||||
|
"@arco-plugins/vite-plugin-svgr": "^0.7.2",
|
||||||
|
"@svgr/webpack": "^5.5.0",
|
||||||
|
"@types/react": "^17.0.0",
|
||||||
|
"@types/react-dom": "^17.0.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.4.0",
|
||||||
|
"@typescript-eslint/parser": "^5.4.0",
|
||||||
|
"@vitejs/plugin-react": "^1.1.0",
|
||||||
|
"eslint": "^8.10.0",
|
||||||
|
"eslint-config-prettier": "^8.3.0",
|
||||||
|
"eslint-plugin-babel": "^5.3.1",
|
||||||
|
"eslint-plugin-prettier": "^4.0.0",
|
||||||
|
"eslint-plugin-react": "^7.27.1",
|
||||||
|
"eslint-plugin-react-hooks": "^4.3.0",
|
||||||
|
"less": "^4.1.2",
|
||||||
|
"less-loader": "7.3.0",
|
||||||
|
"postcss-less": "4",
|
||||||
|
"prettier": "^2.4.1",
|
||||||
|
"pretty-quick": "^3.1.2",
|
||||||
|
"stylelint": "^14.1.0",
|
||||||
|
"stylelint-config-prettier": "^9.0.3",
|
||||||
|
"stylelint-config-standard": "^24.0.0",
|
||||||
|
"typescript": "^4.5.2",
|
||||||
|
"vite": "^2.6.14",
|
||||||
|
"vite-plugin-html": "^3.2.0"
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{js,jsx,ts,tsx}": [
|
||||||
|
"eslint --fix --cache"
|
||||||
|
],
|
||||||
|
"*.{css, less}": [
|
||||||
|
"stylelint --fix"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
14
admin/src/api/auth/index.ts
Normal file
14
admin/src/api/auth/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
import { LoginData } from '@/api/auth/type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录
|
||||||
|
* */
|
||||||
|
export function login(data: LoginData) {
|
||||||
|
return request.post('/auth/login', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function grantUserRoles(userId: number, data) {
|
||||||
|
return request.post('/auth/userRole/' + userId,data);
|
||||||
|
}
|
||||||
4
admin/src/api/auth/type.ts
Normal file
4
admin/src/api/auth/type.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export type LoginData = {
|
||||||
|
account: string,
|
||||||
|
password: string
|
||||||
|
}
|
||||||
7
admin/src/api/common/index.ts
Normal file
7
admin/src/api/common/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
/**
|
||||||
|
* 文件上传
|
||||||
|
* */
|
||||||
|
export function uploadFile(data) {
|
||||||
|
return request.post('/files', data);
|
||||||
|
}
|
||||||
0
admin/src/api/common/type.ts
Normal file
0
admin/src/api/common/type.ts
Normal file
21
admin/src/api/config/index.ts
Normal file
21
admin/src/api/config/index.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
|
||||||
|
export function createConfig(data) {
|
||||||
|
return request.post('/config', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfig(params) {
|
||||||
|
return request.get('/config', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateConfig(data) {
|
||||||
|
return request.patch('/config/' + data.key, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteConfig(key: string) {
|
||||||
|
return request.delete('/config/' + key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncConfig() {
|
||||||
|
return request.post('/config/sync');
|
||||||
|
}
|
||||||
21
admin/src/api/menu/index.ts
Normal file
21
admin/src/api/menu/index.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
|
||||||
|
export function createMenu(data) {
|
||||||
|
return request.post('/menu', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMenu() {
|
||||||
|
return request.get('/menu');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTreeMenu() {
|
||||||
|
return request.get('/menu/tree');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMenu(data) {
|
||||||
|
return request.patch('/menu/' + data.id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteMenu(id: number) {
|
||||||
|
return request.delete('/menu/' + id);
|
||||||
|
}
|
||||||
9
admin/src/api/permission/index.ts
Normal file
9
admin/src/api/permission/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
|
||||||
|
export function grantPermission(id: number, menuIds: number[]) {
|
||||||
|
return request.patch('/permission/' + id, { menuIds });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRoleMenuIds(id: number) {
|
||||||
|
return request.get('/permission/' + id + '/menu');
|
||||||
|
}
|
||||||
21
admin/src/api/role/index.ts
Normal file
21
admin/src/api/role/index.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
|
||||||
|
export function createRole(data) {
|
||||||
|
return request.post('/role', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRole(params) {
|
||||||
|
return request.get('/role', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllRole() {
|
||||||
|
return request.get('/role/all');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRole(data) {
|
||||||
|
return request.patch('/role/' + data.id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteRole(id: number) {
|
||||||
|
return request.delete('/role/' + id);
|
||||||
|
}
|
||||||
36
admin/src/api/user/index.ts
Normal file
36
admin/src/api/user/index.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户信息
|
||||||
|
* */
|
||||||
|
export function userInfo() {
|
||||||
|
return request.get('/user/info');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新用户
|
||||||
|
* */
|
||||||
|
export function createUser(data) {
|
||||||
|
return request.post('/user', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询
|
||||||
|
* */
|
||||||
|
export function getUsers(params) {
|
||||||
|
return request.get('/user', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改用户信息
|
||||||
|
* */
|
||||||
|
export function updateUser(data) {
|
||||||
|
return request.patch('/user/' + data.id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户
|
||||||
|
* */
|
||||||
|
export function deleteUser(id: number) {
|
||||||
|
return request.delete('/user/' + id);
|
||||||
|
}
|
||||||
1
admin/src/assets/dark.svg
Normal file
1
admin/src/assets/dark.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1599669065723" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="18411" width="32" height="32" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style type="text/css"></style></defs><path d="M390 250c0-52.6 10.6-102.6 29.8-148.2C237.4 146 102 310.2 102 506c0 229.6 186.4 416 416 416 195.8 0 360-135.4 404.2-317.8-45.6 19.2-95.8 29.8-148.2 29.8-212 0-384-172-384-384z" p-id="18412"></path></svg>
|
||||||
|
After Width: | Height: | Size: 579 B |
39
admin/src/assets/light.svg
Normal file
39
admin/src/assets/light.svg
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 22 22" style="enable-background:new 0 0 22 22;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st1{fill:none;}
|
||||||
|
</style>
|
||||||
|
<title>编组 5备份</title>
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<g id="组件页-Web端-_xD83E__xDD1F_">
|
||||||
|
<g id="暗黑模式" transform="translate(2.500000, 2.500000)">
|
||||||
|
<g id="编组-12">
|
||||||
|
<g id="编组-8">
|
||||||
|
<g id="编组-7" transform="translate(7.285714, 0.000000)">
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g id="编组-7备份" transform="translate(8.500000, 8.500000) rotate(-270.000000) translate(-8.500000, -8.500000) translate(7.285714, 0.000000)">
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g id="编组-7备份-2" transform="translate(8.500000, 8.500000) rotate(-225.000000) translate(-8.500000, -8.500000) translate(7.285714, 0.000000)">
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g id="编组-7备份-3" transform="translate(8.500000, 8.500000) rotate(-315.000000) translate(-8.500000, -8.500000) translate(7.285714, 0.000000)">
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path id="椭圆形" class="st0" d="M8.5,11.5c1.7,0,3-1.4,3-3s-1.4-3-3-3s-3,1.4-3,3S6.8,11.5,8.5,11.5z"/>
|
||||||
|
<rect id="矩形" x="7.3" class="st0" width="2.4" height="2.4"/>
|
||||||
|
<rect id="矩形备份-2" x="7.3" y="14.6" class="st0" width="2.4" height="2.4"/>
|
||||||
|
<polygon id="矩形_1_" class="st0" points="17,7.3 17,9.7 14.6,9.7 14.6,7.3 "/>
|
||||||
|
<polygon id="矩形备份-2_1_" class="st0" points="2.4,7.3 2.4,9.7 0,9.7 0,7.3 "/>
|
||||||
|
<polygon id="矩形_2_" class="st0" points="15.4,13.7 13.7,15.4 11.9,13.7 13.7,11.9 "/>
|
||||||
|
<polygon id="矩形备份-2_2_" class="st0" points="5.1,3.3 3.3,5.1 1.6,3.3 3.3,1.6 "/>
|
||||||
|
<polygon id="矩形_3_" class="st0" points="13.7,1.6 15.4,3.3 13.7,5.1 11.9,3.3 "/>
|
||||||
|
<polygon id="矩形备份-2_3_" class="st0" points="3.3,11.9 5.1,13.7 3.3,15.4 1.6,13.7 "/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<rect x="0" class="st1" width="22" height="22"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
BIN
admin/src/assets/logo.png
Normal file
BIN
admin/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
16
admin/src/components/Footer/index.tsx
Normal file
16
admin/src/components/Footer/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Layout } from '@arco-design/web-react';
|
||||||
|
import { FooterProps } from '@arco-design/web-react/es/Layout/interface';
|
||||||
|
import cs from 'classnames';
|
||||||
|
import styles from './style/index.module.less';
|
||||||
|
|
||||||
|
function Footer(props: FooterProps = {}) {
|
||||||
|
const { className, ...restProps } = props;
|
||||||
|
return (
|
||||||
|
<Layout.Footer className={cs(styles.footer, className)} {...restProps}>
|
||||||
|
{/*ultimate*/}
|
||||||
|
</Layout.Footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
8
admin/src/components/Footer/style/index.module.less
Normal file
8
admin/src/components/Footer/style/index.module.less
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 40px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
}
|
||||||
14
admin/src/components/Icon/index.tsx
Normal file
14
admin/src/components/Icon/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import * as icons from "@arco-design/web-react/icon";
|
||||||
|
|
||||||
|
function Icon(props = {name: ''}) {
|
||||||
|
const {name} = props
|
||||||
|
const IconDom = icons[name]
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{IconDom && <IconDom/>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Icon
|
||||||
21
admin/src/components/NavBar/IconButton.tsx
Normal file
21
admin/src/components/NavBar/IconButton.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
import { Button } from '@arco-design/web-react';
|
||||||
|
import styles from './style/icon-button.module.less';
|
||||||
|
import cs from 'classnames';
|
||||||
|
|
||||||
|
function IconButton(props, ref) {
|
||||||
|
const { icon, className, ...rest } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
icon={icon}
|
||||||
|
shape="circle"
|
||||||
|
type="secondary"
|
||||||
|
className={cs(styles['icon-button'], className)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default forwardRef(IconButton);
|
||||||
165
admin/src/components/NavBar/index.tsx
Normal file
165
admin/src/components/NavBar/index.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import React, { useContext } from 'react';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
Avatar,
|
||||||
|
Select,
|
||||||
|
Dropdown,
|
||||||
|
Menu,
|
||||||
|
Divider,
|
||||||
|
Message,
|
||||||
|
Button,
|
||||||
|
} from '@arco-design/web-react';
|
||||||
|
import {
|
||||||
|
IconLanguage,
|
||||||
|
IconSunFill,
|
||||||
|
IconMoonFill,
|
||||||
|
IconUser,
|
||||||
|
IconSettings,
|
||||||
|
IconPoweroff,
|
||||||
|
IconTag,
|
||||||
|
IconLoading,
|
||||||
|
} from '@arco-design/web-react/icon';
|
||||||
|
import { GlobalContext } from '@/context';
|
||||||
|
import useLocale from '@/utils/useLocale';
|
||||||
|
import IconButton from './IconButton';
|
||||||
|
import Settings from '../Settings';
|
||||||
|
import styles from './style/index.module.less';
|
||||||
|
import defaultLocale from '@/locale';
|
||||||
|
import useStorage from '@/utils/useStorage';
|
||||||
|
import logoIcon from '@/assets/logo.png'
|
||||||
|
import {useStore} from '@/store';
|
||||||
|
|
||||||
|
function Navbar({ show }: { show: boolean }) {
|
||||||
|
const t = useLocale();
|
||||||
|
const { userInfo, userLoading } = useStore()
|
||||||
|
const [_, setUserStatus] = useStorage('userStatus');
|
||||||
|
const [role, setRole] = useStorage('userRole', 'admin');
|
||||||
|
|
||||||
|
const { setLang, lang, theme, setTheme } = useContext(GlobalContext);
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
setUserStatus('logout');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMenuItemClick(key) {
|
||||||
|
if (key === 'logout') {
|
||||||
|
logout();
|
||||||
|
} else {
|
||||||
|
Message.info(`You clicked ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!show) {
|
||||||
|
return (
|
||||||
|
<div className={styles['fixed-settings']}>
|
||||||
|
<Settings
|
||||||
|
trigger={
|
||||||
|
<Button icon={<IconSettings />} type="primary" size="large" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChangeRole = () => {
|
||||||
|
const newRole = role === 'admin' ? 'user' : 'admin';
|
||||||
|
setRole(newRole);
|
||||||
|
};
|
||||||
|
|
||||||
|
const droplist = (
|
||||||
|
<Menu onClickMenuItem={onMenuItemClick}>
|
||||||
|
<Menu.SubMenu
|
||||||
|
key="role"
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<IconUser className={styles['dropdown-icon']} />
|
||||||
|
<span className={styles['user-role']}>
|
||||||
|
{role === 'admin'
|
||||||
|
? t['menu.user.role.admin']
|
||||||
|
: t['menu.user.role.user']}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Menu.Item onClick={handleChangeRole} key="switch role">
|
||||||
|
<IconTag className={styles['dropdown-icon']} />
|
||||||
|
{t['menu.user.switchRoles']}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.SubMenu>
|
||||||
|
<Menu.Item key="setting">
|
||||||
|
<IconSettings className={styles['dropdown-icon']} />
|
||||||
|
{t['menu.user.setting']}
|
||||||
|
</Menu.Item>
|
||||||
|
<Divider style={{ margin: '4px 0' }} />
|
||||||
|
<Menu.Item key="logout">
|
||||||
|
<IconPoweroff className={styles['dropdown-icon']} />
|
||||||
|
{t['navbar.logout']}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.navbar}>
|
||||||
|
<div className={styles.left}>
|
||||||
|
<div className={styles.logo}>
|
||||||
|
<img className={styles.logo} src={logoIcon} alt=""/>
|
||||||
|
<div className={styles['logo-name']}>{import.meta.env.VITE_APP_TITLE}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className={styles.right}>
|
||||||
|
<li>
|
||||||
|
<Select
|
||||||
|
triggerElement={<IconButton icon={<IconLanguage />} />}
|
||||||
|
options={[
|
||||||
|
{ label: '中文', value: 'zh-CN' },
|
||||||
|
{ label: 'English', value: 'en-US' },
|
||||||
|
]}
|
||||||
|
value={lang}
|
||||||
|
triggerProps={{
|
||||||
|
autoAlignPopupWidth: false,
|
||||||
|
autoAlignPopupMinWidth: true,
|
||||||
|
position: 'br',
|
||||||
|
}}
|
||||||
|
trigger="hover"
|
||||||
|
onChange={(value) => {
|
||||||
|
setLang(value);
|
||||||
|
const nextLang = defaultLocale[value];
|
||||||
|
Message.info(`${nextLang['message.lang.tips']}${value}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
theme === 'light'
|
||||||
|
? t['settings.navbar.theme.toDark']
|
||||||
|
: t['settings.navbar.theme.toLight']
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon={theme !== 'dark' ? <IconMoonFill /> : <IconSunFill />}
|
||||||
|
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</li>
|
||||||
|
<Settings />
|
||||||
|
{userInfo && (
|
||||||
|
<li>
|
||||||
|
<Dropdown droplist={droplist} position="br" disabled={userLoading}>
|
||||||
|
<Avatar size={32} style={{ cursor: 'pointer' }}>
|
||||||
|
{userLoading ? (
|
||||||
|
<IconLoading />
|
||||||
|
) : (
|
||||||
|
<img alt="avatar" src={userInfo?.avatar?.path} />
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
</Dropdown>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Navbar;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.icon-button {
|
||||||
|
font-size: 16px;
|
||||||
|
border: 1px solid var(--color-border-2);
|
||||||
|
|
||||||
|
> svg {
|
||||||
|
vertical-align: -3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
admin/src/components/NavBar/style/index.module.less
Normal file
83
admin/src/components/NavBar/style/index.module.less
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
.navbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: var(--color-bg-2);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 44px;
|
||||||
|
padding-left: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
svg{
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-name {
|
||||||
|
color: var(--color-text-1);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 20px;
|
||||||
|
margin-left: 10px;
|
||||||
|
font-family: 'PingFang SC';
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
padding-right: 20px;
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 0 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round {
|
||||||
|
:global(.arco-input-inner-wrapper) {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed-settings {
|
||||||
|
position: fixed;
|
||||||
|
top: 280px;
|
||||||
|
right: 0px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
font-size: 18px;
|
||||||
|
vertical-align: -4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
admin/src/components/PermissionWrapper/index.tsx
Normal file
40
admin/src/components/PermissionWrapper/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import authentication, { AuthParams } from '@/utils/authentication';
|
||||||
|
import {useStore} from '@/store';
|
||||||
|
|
||||||
|
type PermissionWrapperProps = AuthParams & {
|
||||||
|
backup?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PermissionWrapper = (
|
||||||
|
props: React.PropsWithChildren<PermissionWrapperProps>
|
||||||
|
) => {
|
||||||
|
const { backup, requiredPermissions, oneOfPerm } = props;
|
||||||
|
const [hasPermission, setHasPermission] = useState(false);
|
||||||
|
const userInfo = useStore().userInfo
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hasPermission = authentication(
|
||||||
|
{ requiredPermissions, oneOfPerm },
|
||||||
|
userInfo.permissions
|
||||||
|
);
|
||||||
|
setHasPermission(hasPermission);
|
||||||
|
}, [requiredPermissions, oneOfPerm, userInfo.permissions]);
|
||||||
|
|
||||||
|
if (hasPermission) {
|
||||||
|
return <>{convertReactElement(props.children)}</>;
|
||||||
|
}
|
||||||
|
if (backup) {
|
||||||
|
return <>{convertReactElement(backup)}</>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function convertReactElement(node: React.ReactNode): React.ReactElement {
|
||||||
|
if (!React.isValidElement(node)) {
|
||||||
|
return <>{node}</>;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PermissionWrapper;
|
||||||
69
admin/src/components/Settings/block.tsx
Normal file
69
admin/src/components/Settings/block.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { Switch, Divider, InputNumber } from '@arco-design/web-react';
|
||||||
|
import { useStore } from '@/store';
|
||||||
|
import useLocale from '../../utils/useLocale';
|
||||||
|
import styles from './style/block.module.less';
|
||||||
|
|
||||||
|
export interface BlockProps {
|
||||||
|
title?: ReactNode;
|
||||||
|
options?: { name: string; value: string; type?: 'switch' | 'number' }[];
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Block(props: BlockProps) {
|
||||||
|
const { title, options, children } = props;
|
||||||
|
const locale = useLocale();
|
||||||
|
const { settings, updateSetting } = useStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.block}>
|
||||||
|
<h5 className={styles.title}>{title}</h5>
|
||||||
|
{options &&
|
||||||
|
options.map((option) => {
|
||||||
|
const type = option.type || 'switch';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles['switch-wrapper']} key={option.value}>
|
||||||
|
<span>{locale[option.name]}</span>
|
||||||
|
{type === 'switch' && (
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={!!settings[option.value]}
|
||||||
|
onChange={(checked) => {
|
||||||
|
const newSetting = {
|
||||||
|
...settings,
|
||||||
|
[option.value]: checked
|
||||||
|
};
|
||||||
|
updateSetting(newSetting);
|
||||||
|
// set color week
|
||||||
|
if (checked && option.value === 'colorWeek') {
|
||||||
|
document.body.style.filter = 'invert(80%)';
|
||||||
|
}
|
||||||
|
if (!checked && option.value === 'colorWeek') {
|
||||||
|
document.body.style.filter = 'none';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{type === 'number' && (
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: 80 }}
|
||||||
|
size="small"
|
||||||
|
value={settings.menuWidth}
|
||||||
|
onChange={(value) => {
|
||||||
|
const newSetting = {
|
||||||
|
...settings,
|
||||||
|
[option.value]: value
|
||||||
|
};
|
||||||
|
updateSetting(newSetting);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{children}
|
||||||
|
<Divider />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
admin/src/components/Settings/color.tsx
Normal file
71
admin/src/components/Settings/color.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Trigger, Typography } from '@arco-design/web-react';
|
||||||
|
import { SketchPicker } from 'react-color';
|
||||||
|
import { generate, getRgbStr } from '@arco-design/color';
|
||||||
|
import { useStore } from '@/store';
|
||||||
|
import useLocale from '@/utils/useLocale';
|
||||||
|
import styles from './style/color-panel.module.less';
|
||||||
|
|
||||||
|
function ColorPanel() {
|
||||||
|
const theme =
|
||||||
|
document.querySelector('body').getAttribute('arco-theme') || 'light';
|
||||||
|
|
||||||
|
const {settings,updateSetting} = useStore()
|
||||||
|
const locale = useLocale();
|
||||||
|
const themeColor = settings.themeColor;
|
||||||
|
const list = generate(themeColor, { list: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Trigger
|
||||||
|
trigger="hover"
|
||||||
|
position="bl"
|
||||||
|
popup={() => (
|
||||||
|
<SketchPicker
|
||||||
|
color={themeColor}
|
||||||
|
onChangeComplete={(color) => {
|
||||||
|
const newColor = color.hex;
|
||||||
|
updateSetting({
|
||||||
|
themeColor: newColor
|
||||||
|
})
|
||||||
|
|
||||||
|
const newList = generate(newColor, {
|
||||||
|
list: true,
|
||||||
|
dark: theme === 'dark',
|
||||||
|
});
|
||||||
|
newList.forEach((l, index) => {
|
||||||
|
const rgbStr = getRgbStr(l);
|
||||||
|
document.body.style.setProperty(
|
||||||
|
`--arcoblue-${index + 1}`,
|
||||||
|
rgbStr
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={styles.input}>
|
||||||
|
<div
|
||||||
|
className={styles.color}
|
||||||
|
style={{ backgroundColor: themeColor }}
|
||||||
|
/>
|
||||||
|
<span>{themeColor}</span>
|
||||||
|
</div>
|
||||||
|
</Trigger>
|
||||||
|
<ul className={styles.ul}>
|
||||||
|
{list.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
className={styles.li}
|
||||||
|
style={{ backgroundColor: item }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Typography.Paragraph style={{ fontSize: 12 }}>
|
||||||
|
{locale['settings.color.tooltip']}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ColorPanel;
|
||||||
71
admin/src/components/Settings/index.tsx
Normal file
71
admin/src/components/Settings/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Drawer, Alert, Message } from '@arco-design/web-react';
|
||||||
|
import { IconSettings } from '@arco-design/web-react/icon';
|
||||||
|
import copy from 'copy-to-clipboard';
|
||||||
|
import Block from './block';
|
||||||
|
import ColorPanel from './color';
|
||||||
|
import IconButton from '../NavBar/IconButton';
|
||||||
|
import useLocale from '@/utils/useLocale';
|
||||||
|
import { useStore } from '@/store';
|
||||||
|
|
||||||
|
interface SettingProps {
|
||||||
|
trigger?: React.ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Setting(props: SettingProps) {
|
||||||
|
const { trigger } = props;
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const locale = useLocale();
|
||||||
|
const settings = useStore().settings
|
||||||
|
|
||||||
|
function onCopySettings() {
|
||||||
|
copy(JSON.stringify(settings, null, 2));
|
||||||
|
Message.success(locale['settings.copySettings.message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{trigger ? (
|
||||||
|
React.cloneElement(trigger as React.ReactElement, {
|
||||||
|
onClick: () => setVisible(true),
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<IconButton icon={<IconSettings />} onClick={() => setVisible(true)} />
|
||||||
|
)}
|
||||||
|
<Drawer
|
||||||
|
width={300}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<IconSettings />
|
||||||
|
{locale['settings.title']}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
visible={visible}
|
||||||
|
okText={locale['settings.copySettings']}
|
||||||
|
cancelText={locale['settings.close']}
|
||||||
|
onOk={onCopySettings}
|
||||||
|
onCancel={() => setVisible(false)}
|
||||||
|
>
|
||||||
|
<Block title={locale['settings.themeColor']}>
|
||||||
|
<ColorPanel />
|
||||||
|
</Block>
|
||||||
|
<Block
|
||||||
|
title={locale['settings.content']}
|
||||||
|
options={[
|
||||||
|
{ name: 'settings.navbar', value: 'navbar' },
|
||||||
|
{ name: 'settings.menu', value: 'menu' },
|
||||||
|
{ name: 'settings.footer', value: 'footer' },
|
||||||
|
{ name: 'settings.menuWidth', value: 'menuWidth', type: 'number' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Block
|
||||||
|
title={locale['settings.otherSettings']}
|
||||||
|
options={[{ name: 'settings.colorWeek', value: 'colorWeek' }]}
|
||||||
|
/>
|
||||||
|
<Alert content={locale['settings.alertContent']} />
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Setting;
|
||||||
16
admin/src/components/Settings/style/block.module.less
Normal file
16
admin/src/components/Settings/style/block.module.less
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.block {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
25
admin/src/components/Settings/style/color-panel.module.less
Normal file
25
admin/src/components/Settings/style/color-panel.module.less
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
.input {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 3px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color {
|
||||||
|
width: 100px;
|
||||||
|
height: 24px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.li {
|
||||||
|
width: 10%;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
33
admin/src/components/Upload/index.tsx
Normal file
33
admin/src/components/Upload/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {Upload, UploadProps} from '@arco-design/web-react';
|
||||||
|
import { uploadFile } from '@/api/common';
|
||||||
|
|
||||||
|
interface Props extends UploadProps {
|
||||||
|
dir?: string,
|
||||||
|
type?: 'image' | 'video'
|
||||||
|
}
|
||||||
|
|
||||||
|
const UploadFile = (props: Props) => {
|
||||||
|
const {dir = '/', type = 'image', ...rest} = props
|
||||||
|
const customRequest = async (option) => {
|
||||||
|
const {onSuccess, file} = option;
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('dir', dir)
|
||||||
|
const {data} = await uploadFile(formData)
|
||||||
|
onSuccess(data.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Upload
|
||||||
|
imagePreview
|
||||||
|
customRequest={customRequest}
|
||||||
|
listType='picture-card'
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UploadFile
|
||||||
8
admin/src/context.tsx
Normal file
8
admin/src/context.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
|
||||||
|
export const GlobalContext = createContext<{
|
||||||
|
lang?: string;
|
||||||
|
setLang?: (value: string) => void;
|
||||||
|
theme?: string;
|
||||||
|
setTheme?: (value: string) => void;
|
||||||
|
}>({});
|
||||||
27
admin/src/declaration.d.ts
vendored
Normal file
27
admin/src/declaration.d.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
declare module '*.svg' {
|
||||||
|
const content: React.FunctionComponent<React.SVGAttributes<SVGElement>>;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.less' {
|
||||||
|
const classes: { [className: string]: string };
|
||||||
|
export default classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*/settings.json' {
|
||||||
|
const value: {
|
||||||
|
colorWeek: boolean;
|
||||||
|
navbar: boolean;
|
||||||
|
menu: boolean;
|
||||||
|
footer: boolean;
|
||||||
|
themeColor: string;
|
||||||
|
menuWidth: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.png' {
|
||||||
|
const value: string;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
7
admin/src/enum/index.ts
Normal file
7
admin/src/enum/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const enum Gender {
|
||||||
|
MALE,
|
||||||
|
FEMALE,
|
||||||
|
UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GenderDict = ['女', '男', '未知'];
|
||||||
267
admin/src/layout.tsx
Normal file
267
admin/src/layout.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
|
import { Switch, Route, Redirect, useHistory } from 'react-router-dom';
|
||||||
|
import { Layout, Menu, Breadcrumb, Spin } from '@arco-design/web-react';
|
||||||
|
import cs from 'classnames';
|
||||||
|
import {
|
||||||
|
IconMenuFold,
|
||||||
|
IconMenuUnfold,
|
||||||
|
IconSettings,
|
||||||
|
IconList, IconDashboard,
|
||||||
|
} from '@arco-design/web-react/icon';
|
||||||
|
import qs from 'query-string';
|
||||||
|
import NProgress from 'nprogress';
|
||||||
|
import Navbar from './components/NavBar';
|
||||||
|
import Footer from './components/Footer';
|
||||||
|
import useRoute, { IRoute } from '@/routes';
|
||||||
|
import { isArray } from './utils/is';
|
||||||
|
import useLocale from './utils/useLocale';
|
||||||
|
import getUrlParams from './utils/getUrlParams';
|
||||||
|
import lazyload from './utils/lazyload';
|
||||||
|
import styles from './style/layout.module.less';
|
||||||
|
import { useStore } from '@/store';
|
||||||
|
|
||||||
|
const MenuItem = Menu.Item;
|
||||||
|
const SubMenu = Menu.SubMenu;
|
||||||
|
|
||||||
|
const Sider = Layout.Sider;
|
||||||
|
const Content = Layout.Content;
|
||||||
|
|
||||||
|
function getFlattenRoutes(routes) {
|
||||||
|
const mod = import.meta.glob('./pages/**/[a-z[]*.tsx');
|
||||||
|
const res = [];
|
||||||
|
function travel(_routes) {
|
||||||
|
_routes.forEach((route) => {
|
||||||
|
if (route.key && !route.children) {
|
||||||
|
route.component = lazyload(mod[`./pages/${route.key}/index.tsx`]);
|
||||||
|
res.push(route);
|
||||||
|
} else if (isArray(route.children) && route.children.length) {
|
||||||
|
travel(route.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
travel(routes);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageLayout() {
|
||||||
|
const urlParams = getUrlParams();
|
||||||
|
const history = useHistory();
|
||||||
|
const pathname = history.location.pathname;
|
||||||
|
const currentComponent = qs.parseUrl(pathname).url.slice(1);
|
||||||
|
const locale = useLocale();
|
||||||
|
const store = useStore()
|
||||||
|
const {settings,userInfo,userLoading} = store
|
||||||
|
const [routes, defaultRoute] = useRoute(userInfo?.menus);
|
||||||
|
|
||||||
|
const defaultSelectedKeys = [currentComponent || defaultRoute];
|
||||||
|
const paths = (currentComponent || defaultRoute).split('/');
|
||||||
|
const defaultOpenKeys = paths.slice(0, paths.length - 1);
|
||||||
|
|
||||||
|
const [breadcrumb, setBreadCrumb] = useState([]);
|
||||||
|
const [collapsed, setCollapsed] = useState<boolean>(false);
|
||||||
|
const [selectedKeys, setSelectedKeys] =
|
||||||
|
useState<string[]>(defaultSelectedKeys);
|
||||||
|
const [openKeys, setOpenKeys] = useState<string[]>(defaultOpenKeys);
|
||||||
|
|
||||||
|
const routeMap = useRef<Map<string, React.ReactNode[]>>(new Map());
|
||||||
|
const menuMap = useRef<
|
||||||
|
Map<string, { menuItem?: boolean; subMenu?: boolean }>
|
||||||
|
>(new Map());
|
||||||
|
|
||||||
|
const navbarHeight = 60;
|
||||||
|
const menuWidth = collapsed ? 48 : settings.menuWidth;
|
||||||
|
|
||||||
|
const showNavbar = settings.navbar && urlParams.navbar !== false;
|
||||||
|
const showMenu = settings.menu && urlParams.menu !== false;
|
||||||
|
const showFooter = settings.footer && urlParams.footer !== false;
|
||||||
|
|
||||||
|
const flattenRoutes = useMemo(() => getFlattenRoutes(routes) || [], [routes]);
|
||||||
|
|
||||||
|
function onClickMenuItem(key) {
|
||||||
|
const currentRoute = flattenRoutes.find((r) => r.key === key);
|
||||||
|
const component = currentRoute.component;
|
||||||
|
const preload = component.preload();
|
||||||
|
NProgress.start();
|
||||||
|
preload.then(() => {
|
||||||
|
history.push(currentRoute.path ? currentRoute.path : `/${key}`);
|
||||||
|
NProgress.done();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCollapse() {
|
||||||
|
setCollapsed((collapsed) => !collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const paddingLeft = showMenu ? { paddingLeft: menuWidth } : {};
|
||||||
|
const paddingTop = showNavbar ? { paddingTop: navbarHeight } : {};
|
||||||
|
const paddingStyle = { ...paddingLeft, ...paddingTop };
|
||||||
|
|
||||||
|
function renderRoutes(locale) {
|
||||||
|
routeMap.current.clear();
|
||||||
|
return function travel(_routes: IRoute[], level, parentNode = []) {
|
||||||
|
return _routes.map((route) => {
|
||||||
|
const { breadcrumb = true, ignore } = route;
|
||||||
|
const iconDom = getIconFromKey(route.key);
|
||||||
|
const titleDom = (
|
||||||
|
<>
|
||||||
|
{iconDom} {locale[route.name] || route.name}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
routeMap.current.set(
|
||||||
|
`/${route.key}`,
|
||||||
|
breadcrumb ? [...parentNode, route.name] : []
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleChildren = (route.children || []).filter((child) => {
|
||||||
|
const { ignore, breadcrumb = true } = child;
|
||||||
|
if (ignore || route.ignore) {
|
||||||
|
routeMap.current.set(
|
||||||
|
`/${child.key}`,
|
||||||
|
breadcrumb ? [...parentNode, route.name, child.name] : []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return !ignore;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ignore) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (visibleChildren.length) {
|
||||||
|
menuMap.current.set(route.key, { subMenu: true });
|
||||||
|
return (
|
||||||
|
<SubMenu key={route.key} title={titleDom}>
|
||||||
|
{travel(visibleChildren, level + 1, [...parentNode, route.name])}
|
||||||
|
</SubMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
menuMap.current.set(route.key, { menuItem: true });
|
||||||
|
return <MenuItem key={route.key}>{titleDom}</MenuItem>;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMenuStatus() {
|
||||||
|
const pathKeys = pathname.split('/');
|
||||||
|
const newSelectedKeys: string[] = [];
|
||||||
|
const newOpenKeys: string[] = [...openKeys];
|
||||||
|
while (pathKeys.length > 0) {
|
||||||
|
const currentRouteKey = pathKeys.join('/');
|
||||||
|
const menuKey = currentRouteKey.replace(/^\//, '');
|
||||||
|
const menuType = menuMap.current.get(menuKey);
|
||||||
|
if (menuType && menuType.menuItem) {
|
||||||
|
newSelectedKeys.push(menuKey);
|
||||||
|
}
|
||||||
|
if (menuType && menuType.subMenu && !openKeys.includes(menuKey)) {
|
||||||
|
newOpenKeys.push(menuKey);
|
||||||
|
}
|
||||||
|
pathKeys.pop();
|
||||||
|
}
|
||||||
|
setSelectedKeys(newSelectedKeys);
|
||||||
|
setOpenKeys(newOpenKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIconFromKey(key) {
|
||||||
|
switch (key) {
|
||||||
|
case 'role':
|
||||||
|
return <IconDashboard className={styles.icon}/>;
|
||||||
|
case 'user':
|
||||||
|
return <IconSettings className={styles.icon}/>;
|
||||||
|
default:
|
||||||
|
return <IconList className={styles.icon}/>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const routeConfig = routeMap.current.get(pathname);
|
||||||
|
const _breadcrumb = routeConfig || []
|
||||||
|
setBreadCrumb([getIconFromKey(paths[0]),..._breadcrumb]);
|
||||||
|
updateMenuStatus();
|
||||||
|
}, [pathname,userLoading]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout className={styles.layout}>
|
||||||
|
<div
|
||||||
|
className={cs(styles['layout-navbar'], {
|
||||||
|
[styles['layout-navbar-hidden']]: !showNavbar,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Navbar show={showNavbar} />
|
||||||
|
</div>
|
||||||
|
{userLoading ? (
|
||||||
|
<Spin className={styles['spin']} />
|
||||||
|
) : (
|
||||||
|
<Layout>
|
||||||
|
{showMenu && (
|
||||||
|
<Sider
|
||||||
|
className={styles['layout-sider']}
|
||||||
|
width={menuWidth}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onCollapse={setCollapsed}
|
||||||
|
trigger={null}
|
||||||
|
collapsible
|
||||||
|
breakpoint="xl"
|
||||||
|
style={paddingTop}
|
||||||
|
>
|
||||||
|
<div className={styles['menu-wrapper']}>
|
||||||
|
<Menu
|
||||||
|
collapse={collapsed}
|
||||||
|
onClickMenuItem={onClickMenuItem}
|
||||||
|
selectedKeys={selectedKeys}
|
||||||
|
openKeys={openKeys}
|
||||||
|
onClickSubMenu={(_, openKeys) => {
|
||||||
|
setOpenKeys(openKeys);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderRoutes(locale)(routes, 1)}
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
<div className={styles['collapse-btn']} onClick={toggleCollapse}>
|
||||||
|
{collapsed ? <IconMenuUnfold /> : <IconMenuFold />}
|
||||||
|
</div>
|
||||||
|
</Sider>
|
||||||
|
)}
|
||||||
|
<Layout className={styles['layout-content']} style={paddingStyle}>
|
||||||
|
<div className={styles['layout-content-wrapper']}>
|
||||||
|
{!!breadcrumb.length && (
|
||||||
|
<div className={styles['layout-breadcrumb']}>
|
||||||
|
<Breadcrumb>
|
||||||
|
{breadcrumb.map((node, index) => (
|
||||||
|
<Breadcrumb.Item key={index}>
|
||||||
|
{typeof node === 'string' ? locale[node] || node : node}
|
||||||
|
</Breadcrumb.Item>
|
||||||
|
))}
|
||||||
|
</Breadcrumb>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Content>
|
||||||
|
<Switch>
|
||||||
|
{flattenRoutes.map((route, index) => {
|
||||||
|
return (
|
||||||
|
<Route
|
||||||
|
key={index}
|
||||||
|
path={`/${route.key}`}
|
||||||
|
component={route.component}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Route exact path="/">
|
||||||
|
<Redirect to={`/${defaultRoute}`} />
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
component={lazyload(() => import('./pages/exception/403'))}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
</Content>
|
||||||
|
</div>
|
||||||
|
{showFooter && <Footer />}
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageLayout;
|
||||||
80
admin/src/locale/index.ts
Normal file
80
admin/src/locale/index.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
const i18n = {
|
||||||
|
'en-US': {
|
||||||
|
'menu.dashboard': 'Dashboard',
|
||||||
|
'menu.dashboard.workplace': 'Workplace',
|
||||||
|
'menu.user.info': 'User Info',
|
||||||
|
'menu.user.setting': 'User Setting',
|
||||||
|
'menu.user.switchRoles': 'Switch Roles',
|
||||||
|
'menu.user.role.admin': 'Admin',
|
||||||
|
'menu.user.role.user': 'General User',
|
||||||
|
'navbar.logout': 'Logout',
|
||||||
|
'settings.title': 'Settings',
|
||||||
|
'settings.themeColor': 'Theme Color',
|
||||||
|
'settings.content': 'Content Setting',
|
||||||
|
'settings.navbar': 'Navbar',
|
||||||
|
'settings.menuWidth': 'Menu Width (px)',
|
||||||
|
'settings.navbar.theme.toLight': 'Click to use light mode',
|
||||||
|
'settings.navbar.theme.toDark': 'Click to use dark mode',
|
||||||
|
'settings.menu': 'Menu',
|
||||||
|
'settings.footer': 'Footer',
|
||||||
|
'settings.otherSettings': 'Other Settings',
|
||||||
|
'settings.colorWeek': 'Color Week',
|
||||||
|
'settings.alertContent':
|
||||||
|
'After the configuration is only temporarily effective, if you want to really affect the project, click the "Copy Settings" button below and replace the configuration in settings.json.',
|
||||||
|
'settings.copySettings': 'Copy Settings',
|
||||||
|
'settings.copySettings.message':
|
||||||
|
'Copy succeeded, please paste to file src/settings.json.',
|
||||||
|
'settings.close': 'Close',
|
||||||
|
'settings.color.tooltip':
|
||||||
|
'10 gradient colors generated according to the theme color',
|
||||||
|
'message.tab.title.message': 'Message',
|
||||||
|
'message.tab.title.notice': 'Notice',
|
||||||
|
'message.tab.title.todo': 'ToDo',
|
||||||
|
'message.allRead': 'All Read',
|
||||||
|
'message.seeMore': 'SeeMore',
|
||||||
|
'message.empty': 'Empty',
|
||||||
|
'message.empty.tips': 'No Content',
|
||||||
|
'message.lang.tips': 'Language switch to ',
|
||||||
|
'navbar.search.placeholder': 'Please search',
|
||||||
|
},
|
||||||
|
'zh-CN': {
|
||||||
|
'menu.dashboard': '仪表盘',
|
||||||
|
'menu.dashboard.workplace': '工作台',
|
||||||
|
'menu.user.info': '用户信息',
|
||||||
|
'menu.user.setting': '用户设置',
|
||||||
|
'menu.user.switchRoles': '切换角色',
|
||||||
|
'menu.user.role.admin': '管理员',
|
||||||
|
'menu.user.role.user': '普通用户',
|
||||||
|
'navbar.logout': '退出登录',
|
||||||
|
'settings.title': '页面配置',
|
||||||
|
'settings.themeColor': '主题色',
|
||||||
|
'settings.content': '内容区域',
|
||||||
|
'settings.navbar': '导航栏',
|
||||||
|
'settings.menuWidth': '菜单宽度 (px)',
|
||||||
|
'settings.navbar.theme.toLight': '点击切换为亮色模式',
|
||||||
|
'settings.navbar.theme.toDark': '点击切换为暗黑模式',
|
||||||
|
'settings.menu': '菜单栏',
|
||||||
|
'settings.footer': '底部',
|
||||||
|
'settings.otherSettings': '其他设置',
|
||||||
|
'settings.colorWeek': '色弱模式',
|
||||||
|
'settings.alertContent':
|
||||||
|
'配置之后仅是临时生效,要想真正作用于项目,点击下方的 "复制配置" 按钮,将配置替换到 settings.json 中即可。',
|
||||||
|
'settings.copySettings': '复制配置',
|
||||||
|
'settings.copySettings.message':
|
||||||
|
'复制成功,请粘贴到 src/settings.json 文件中',
|
||||||
|
'settings.close': '关闭',
|
||||||
|
'settings.color.tooltip':
|
||||||
|
'根据主题颜色生成的 10 个梯度色(将配置复制到项目中,主题色才能对亮色 / 暗黑模式同时生效)',
|
||||||
|
'message.tab.title.message': '消息',
|
||||||
|
'message.tab.title.notice': '通知',
|
||||||
|
'message.tab.title.todo': '待办',
|
||||||
|
'message.allRead': '全部已读',
|
||||||
|
'message.seeMore': '查看更多',
|
||||||
|
'message.empty': '清空',
|
||||||
|
'message.empty.tips': '暂无内容',
|
||||||
|
'message.lang.tips': '语言切换至 ',
|
||||||
|
'navbar.search.placeholder': '输入内容查询',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
101
admin/src/main.tsx
Normal file
101
admin/src/main.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import './style/global.less';
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import { ConfigProvider } from '@arco-design/web-react';
|
||||||
|
import zhCN from '@arco-design/web-react/es/locale/zh-CN';
|
||||||
|
import enUS from '@arco-design/web-react/es/locale/en-US';
|
||||||
|
import { BrowserRouter, Switch, Route } from 'react-router-dom';
|
||||||
|
|
||||||
|
import PageLayout from './layout';
|
||||||
|
import { GlobalContext } from './context';
|
||||||
|
import Login from './pages/login';
|
||||||
|
import checkLogin from './utils/checkLogin';
|
||||||
|
import changeTheme from './utils/changeTheme';
|
||||||
|
import useStorage from './utils/useStorage';
|
||||||
|
import { QueryClientProvider, QueryClient } from 'react-query';
|
||||||
|
import { useStore } from '@/store';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function Index() {
|
||||||
|
const [lang, setLang] = useStorage('arco-lang', 'zh-CN');
|
||||||
|
const [theme, setTheme] = useStorage('arco-theme', 'light');
|
||||||
|
const { fetchUserInfo, init } = useStore();
|
||||||
|
|
||||||
|
function getArcoLocale() {
|
||||||
|
switch (lang) {
|
||||||
|
case 'zh-CN':
|
||||||
|
return zhCN;
|
||||||
|
case 'en-US':
|
||||||
|
return enUS;
|
||||||
|
default:
|
||||||
|
return zhCN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
init();
|
||||||
|
if (checkLogin()) {
|
||||||
|
fetchUserInfo();
|
||||||
|
} else if (window.location.pathname.replace(/\//g, '') !== 'login') {
|
||||||
|
window.location.pathname = '/login';
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
changeTheme(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const contextValue = {
|
||||||
|
lang,
|
||||||
|
setLang,
|
||||||
|
theme,
|
||||||
|
setTheme
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<ConfigProvider
|
||||||
|
locale={getArcoLocale()}
|
||||||
|
componentConfig={{
|
||||||
|
Card: {
|
||||||
|
bordered: false
|
||||||
|
},
|
||||||
|
List: {
|
||||||
|
bordered: false
|
||||||
|
},
|
||||||
|
Table: {
|
||||||
|
border: { headerCell: true, bodyCell: true, wrapper: true }
|
||||||
|
},
|
||||||
|
Form: {
|
||||||
|
labelCol: { span: 5 },
|
||||||
|
wrapperCol: { span: 18 },
|
||||||
|
},
|
||||||
|
Modal: {
|
||||||
|
style: { width: '550px' },
|
||||||
|
autoFocus: false,
|
||||||
|
focusLock: false,
|
||||||
|
escToExit:false
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GlobalContext.Provider value={contextValue}>
|
||||||
|
<Switch>
|
||||||
|
<Route path="/login" component={Login} />
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Route path="/" component={PageLayout} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</Switch>
|
||||||
|
</GlobalContext.Provider>
|
||||||
|
</ConfigProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.render(<Index />, document.getElementById('root'));
|
||||||
71
admin/src/pages/config/form.tsx
Normal file
71
admin/src/pages/config/form.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Modal, Form, Input, Select, Message } from '@arco-design/web-react';
|
||||||
|
import { createConfig, updateConfig } from '@/api/config';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
record: { [key: string]: any } | null,
|
||||||
|
visible: boolean,
|
||||||
|
handleConfirm: () => void,
|
||||||
|
handleCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormComponent(props: Props) {
|
||||||
|
const { visible, record, handleConfirm, handleCancel } = props;
|
||||||
|
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||||
|
|
||||||
|
const onOk = async () => {
|
||||||
|
try {
|
||||||
|
setConfirmLoading(true);
|
||||||
|
const values = await form.validate();
|
||||||
|
if (record?.key) {
|
||||||
|
await updateConfig(values);
|
||||||
|
} else {
|
||||||
|
await createConfig(values);
|
||||||
|
}
|
||||||
|
Message.success(`${record?.key ? '更新' : '新增'}成功!`);
|
||||||
|
handleConfirm();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
} finally {
|
||||||
|
setConfirmLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
record && form.setFieldsValue({
|
||||||
|
...record
|
||||||
|
});
|
||||||
|
}, [record]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
!visible && form.resetFields();
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Modal
|
||||||
|
title={<span>{`${record?.key ? '更新' : '新增'}配置信息`}</span>}
|
||||||
|
visible={visible}
|
||||||
|
onOk={onOk}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
confirmLoading={confirmLoading}
|
||||||
|
>
|
||||||
|
<Form form={form}>
|
||||||
|
<Form.Item disabled={record?.key} label="配置id" field="key" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入配置id" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="配置值" field="value" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入配置值" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="备注" field="remark">
|
||||||
|
<Input placeholder="请输入备注" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormComponent;
|
||||||
169
admin/src/pages/config/index.tsx
Normal file
169
admin/src/pages/config/index.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Table,
|
||||||
|
Typography,
|
||||||
|
TableColumnProps,
|
||||||
|
PaginationProps,
|
||||||
|
Button,
|
||||||
|
Popconfirm,
|
||||||
|
Divider,
|
||||||
|
Avatar, Space, Message
|
||||||
|
} from '@arco-design/web-react';
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
import Form from './form';
|
||||||
|
import { deleteConfig, getConfig, syncConfig } from '@/api/config';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
function ConfigManage() {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [record, setRecord] = useState(null);
|
||||||
|
const columns: TableColumnProps[] = [
|
||||||
|
{
|
||||||
|
title: '配置id',
|
||||||
|
dataIndex: 'key',
|
||||||
|
render: (value) => <Text copyable>{value}</Text>,
|
||||||
|
align: 'center',
|
||||||
|
width:'25%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '配置值',
|
||||||
|
dataIndex: 'value',
|
||||||
|
render: (value) => <Text copyable>{value}</Text>,
|
||||||
|
align: 'center',
|
||||||
|
width:'25%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
dataIndex: 'remark',
|
||||||
|
align: 'center',
|
||||||
|
width:'25%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
dataIndex: 'operations',
|
||||||
|
render: (_, record) => (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => showModal(record)} type="text" status="default">更新</Button>
|
||||||
|
<Divider type="vertical" />
|
||||||
|
<Popconfirm
|
||||||
|
focusLock
|
||||||
|
title="删除数据"
|
||||||
|
content="确认删除该条数据吗?"
|
||||||
|
onOk={() => onDelete(record)}
|
||||||
|
>
|
||||||
|
<Button type="text" status="danger">删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
align: 'center'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const [pagination, setPagination] = useState<PaginationProps>({
|
||||||
|
sizeCanChange: true,
|
||||||
|
showTotal: true,
|
||||||
|
pageSize: 10,
|
||||||
|
current: 1,
|
||||||
|
pageSizeChangeResetCurrent: true,
|
||||||
|
total: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDelete = async ({ key }) => {
|
||||||
|
await deleteConfig(key);
|
||||||
|
Message.success('删除成功!');
|
||||||
|
await refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChangeTable = async ({ current, pageSize }) => {
|
||||||
|
setPagination({
|
||||||
|
...pagination,
|
||||||
|
current,
|
||||||
|
pageSize
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = async ({ queryKey }) => {
|
||||||
|
const [, { page, pageSize }] = queryKey;
|
||||||
|
const { data } = await getConfig({ page, pageSize });
|
||||||
|
setPagination(prevState => {
|
||||||
|
prevState.total = data.count;
|
||||||
|
return prevState;
|
||||||
|
});
|
||||||
|
if (page > 1 && data.list.length === 0) {
|
||||||
|
setPagination({
|
||||||
|
...pagination,
|
||||||
|
current: page - 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
refetch
|
||||||
|
} = useQuery(
|
||||||
|
['config', { page: pagination.current, pageSize: pagination.pageSize }],
|
||||||
|
fetchData,
|
||||||
|
{
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
keepPreviousData: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const showModal = (payload?) => {
|
||||||
|
payload && setRecord(payload);
|
||||||
|
setVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
setRecord(null);
|
||||||
|
setVisible(false);
|
||||||
|
await refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setRecord(null);
|
||||||
|
setVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sync = async () => {
|
||||||
|
await syncConfig();
|
||||||
|
Message.success('配置已同步!');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<Form
|
||||||
|
handleConfirm={handleConfirm}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
visible={visible}
|
||||||
|
record={record}
|
||||||
|
/>
|
||||||
|
<Title heading={6}>系统配置</Title>
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: 12 }}>
|
||||||
|
<Button type="primary" onClick={() => showModal()}>创建配置</Button>
|
||||||
|
<Button type="primary" onClick={() => sync()}>同步配置至redis</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="key"
|
||||||
|
loading={isLoading || isFetching}
|
||||||
|
columns={columns}
|
||||||
|
onChange={onChangeTable}
|
||||||
|
pagination={pagination}
|
||||||
|
data={data?.list}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default ConfigManage;
|
||||||
15
admin/src/pages/config/style/index.module.less
Normal file
15
admin/src/pages/config/style/index.module.less
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
.search-form-wrapper {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--color-border-1);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.right-button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-left: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-left: 1px solid var(--color-border-2);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
admin/src/pages/exception/403/index.tsx
Normal file
28
admin/src/pages/exception/403/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Result, Button } from '@arco-design/web-react';
|
||||||
|
import locale from './locale';
|
||||||
|
import useLocale from '@/utils/useLocale';
|
||||||
|
import styles from './style/index.module.less';
|
||||||
|
|
||||||
|
function Exception403() {
|
||||||
|
const t = useLocale(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<Result
|
||||||
|
className={styles.result}
|
||||||
|
status="403"
|
||||||
|
subTitle={t['exception.result.403.description']}
|
||||||
|
extra={
|
||||||
|
<Button key="back" type="primary">
|
||||||
|
{t['exception.result.403.back']}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Exception403;
|
||||||
17
admin/src/pages/exception/403/locale/index.ts
Normal file
17
admin/src/pages/exception/403/locale/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const i18n = {
|
||||||
|
'en-US': {
|
||||||
|
'menu.exception': 'Exception page',
|
||||||
|
'menu.exception.403': '403',
|
||||||
|
'exception.result.403.description':
|
||||||
|
'Access to this resource on the server is denied.',
|
||||||
|
'exception.result.403.back': 'Back',
|
||||||
|
},
|
||||||
|
'zh-CN': {
|
||||||
|
'menu.exception': '异常页',
|
||||||
|
'menu.exception.403': '403',
|
||||||
|
'exception.result.403.description': '对不起,您没有访问该资源的权限',
|
||||||
|
'exception.result.403.back': '返回',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
11
admin/src/pages/exception/403/style/index.module.less
Normal file
11
admin/src/pages/exception/403/style/index.module.less
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
background-color: var(--color-bg-1);
|
||||||
|
height: calc(100vh - 168px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
46
admin/src/pages/login/banner.tsx
Normal file
46
admin/src/pages/login/banner.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Carousel } from '@arco-design/web-react';
|
||||||
|
import useLocale from '@/utils/useLocale';
|
||||||
|
import locale from './locale';
|
||||||
|
import styles from './style/index.module.less';
|
||||||
|
|
||||||
|
export default function LoginBanner() {
|
||||||
|
const t = useLocale(locale);
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
slogan: t['login.banner.slogan1'],
|
||||||
|
subSlogan: t['login.banner.subSlogan1'],
|
||||||
|
image:
|
||||||
|
'http://p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/6c85f43aed61e320ebec194e6a78d6d3.png~tplv-uwbnlip3yd-png.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slogan: t['login.banner.slogan2'],
|
||||||
|
subSlogan: t['login.banner.subSlogan2'],
|
||||||
|
image:
|
||||||
|
'http://p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/6c85f43aed61e320ebec194e6a78d6d3.png~tplv-uwbnlip3yd-png.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slogan: t['login.banner.slogan3'],
|
||||||
|
subSlogan: t['login.banner.subSlogan3'],
|
||||||
|
image:
|
||||||
|
'http://p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/6c85f43aed61e320ebec194e6a78d6d3.png~tplv-uwbnlip3yd-png.png',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Carousel className={styles.carousel} animation="fade">
|
||||||
|
{data.map((item, index) => (
|
||||||
|
<div key={`${index}`}>
|
||||||
|
<div className={styles['carousel-item']}>
|
||||||
|
<div className={styles['carousel-title']}>{item.slogan}</div>
|
||||||
|
<div className={styles['carousel-sub-title']}>{item.subSlogan}</div>
|
||||||
|
<img
|
||||||
|
alt="banner-image"
|
||||||
|
className={styles['carousel-image']}
|
||||||
|
src={item.image}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Carousel>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
admin/src/pages/login/form.tsx
Normal file
116
admin/src/pages/login/form.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Checkbox,
|
||||||
|
Button,
|
||||||
|
Space
|
||||||
|
} from '@arco-design/web-react';
|
||||||
|
import { FormInstance } from '@arco-design/web-react/es/Form';
|
||||||
|
import { IconLock, IconUser } from '@arco-design/web-react/icon';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import useStorage from '@/utils/useStorage';
|
||||||
|
import useLocale from '@/utils/useLocale';
|
||||||
|
import locale from './locale';
|
||||||
|
import styles from './style/index.module.less';
|
||||||
|
import { useStore } from '@/store';
|
||||||
|
|
||||||
|
export default function LoginForm() {
|
||||||
|
const formRef = useRef<FormInstance>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loginParams, setLoginParams, removeLoginParams] = useStorage('loginParams');
|
||||||
|
const t = useLocale(locale);
|
||||||
|
const [rememberPassword, setRememberPassword] = useState(!!loginParams);
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
function afterLoginSuccess(params) {
|
||||||
|
// 记住密码
|
||||||
|
if (rememberPassword) {
|
||||||
|
setLoginParams(JSON.stringify(params));
|
||||||
|
} else {
|
||||||
|
removeLoginParams();
|
||||||
|
}
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (params) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await store.login(params);
|
||||||
|
afterLoginSuccess(params);
|
||||||
|
} catch (e) {
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function onSubmitClick() {
|
||||||
|
formRef.current.validate().then((values) => {
|
||||||
|
login(values);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 localStorage,设置初始值
|
||||||
|
useEffect(() => {
|
||||||
|
const rememberPassword = !!loginParams;
|
||||||
|
setRememberPassword(rememberPassword);
|
||||||
|
if (formRef.current && rememberPassword) {
|
||||||
|
const parseParams = JSON.parse(loginParams);
|
||||||
|
formRef.current.setFieldsValue(parseParams);
|
||||||
|
}
|
||||||
|
}, [loginParams]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles['login-form-wrapper']}>
|
||||||
|
<div className={styles['login-form-title']}>{t['login.form.title']}</div>
|
||||||
|
<div className={styles['login-form-sub-title']}>
|
||||||
|
{t['login.form.title']}
|
||||||
|
</div>
|
||||||
|
<Form
|
||||||
|
className={styles['login-form']}
|
||||||
|
layout="vertical"
|
||||||
|
ref={formRef}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
field="account"
|
||||||
|
rules={[{ required: true, message: t['login.form.account.errMsg'] }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<IconUser />}
|
||||||
|
placeholder={t['login.form.account.placeholder']}
|
||||||
|
onPressEnter={onSubmitClick}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
field="password"
|
||||||
|
rules={[{ required: true, message: t['login.form.password.errMsg'] }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<IconLock />}
|
||||||
|
placeholder={t['login.form.password.placeholder']}
|
||||||
|
onPressEnter={onSubmitClick}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Space size={16} direction="vertical">
|
||||||
|
<div className={styles['login-form-password-actions']}>
|
||||||
|
<Checkbox checked={rememberPassword} onChange={setRememberPassword}>
|
||||||
|
{t['login.form.rememberPassword']}
|
||||||
|
</Checkbox>
|
||||||
|
{/*<Link>{t['login.form.forgetPassword']}</Link>*/}
|
||||||
|
</div>
|
||||||
|
<Button type="primary" long onClick={onSubmitClick} loading={loading}>
|
||||||
|
{t['login.form.login']}
|
||||||
|
</Button>
|
||||||
|
{/*<Button*/}
|
||||||
|
{/* type="text"*/}
|
||||||
|
{/* long*/}
|
||||||
|
{/* className={styles['login-form-register-btn']}*/}
|
||||||
|
{/*>*/}
|
||||||
|
{/* {t['login.form.register']}*/}
|
||||||
|
{/*</Button>*/}
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
admin/src/pages/login/index.tsx
Normal file
27
admin/src/pages/login/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import Footer from '@/components/Footer';
|
||||||
|
import LoginForm from './form';
|
||||||
|
import styles from './style/index.module.less';
|
||||||
|
|
||||||
|
function Login() {
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.setAttribute('arco-theme', 'light');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles['content-inner']}>
|
||||||
|
<LoginForm />
|
||||||
|
</div>
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Login.displayName = 'LoginPage';
|
||||||
|
|
||||||
|
export default Login;
|
||||||
42
admin/src/pages/login/locale/index.ts
Normal file
42
admin/src/pages/login/locale/index.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const i18n = {
|
||||||
|
'en-US': {
|
||||||
|
'login.form.title': 'Login',
|
||||||
|
'login.form.account.errMsg': 'account cannot be empty',
|
||||||
|
'login.form.password.errMsg': 'Password cannot be empty',
|
||||||
|
'login.form.login.errMsg': 'Login error, please refresh and try again',
|
||||||
|
'login.form.account.placeholder': 'account: admin',
|
||||||
|
'login.form.password.placeholder': 'Password: admin',
|
||||||
|
'login.form.rememberPassword': 'Remember password',
|
||||||
|
'login.form.forgetPassword': 'Forgot password',
|
||||||
|
'login.form.login': 'login',
|
||||||
|
'login.form.register': 'register account',
|
||||||
|
'login.banner.slogan1': 'Out-of-the-box high-quality template',
|
||||||
|
'login.banner.subSlogan1':
|
||||||
|
'Rich page templates, covering most typical business scenarios',
|
||||||
|
'login.banner.slogan2': 'Built-in solutions to common problems',
|
||||||
|
'login.banner.subSlogan2':
|
||||||
|
'Internationalization, routing configuration, state management everything',
|
||||||
|
'login.banner.slogan3': 'Access visualization enhancement tool AUX',
|
||||||
|
'login.banner.subSlogan3': 'Realize flexible block development',
|
||||||
|
},
|
||||||
|
'zh-CN': {
|
||||||
|
'login.form.title': '登录',
|
||||||
|
'login.form.account.errMsg': '用户名不能为空',
|
||||||
|
'login.form.password.errMsg': '密码不能为空',
|
||||||
|
'login.form.login.errMsg': '登录出错,请刷新重试',
|
||||||
|
'login.form.account.placeholder': '用户名:admin',
|
||||||
|
'login.form.password.placeholder': '密码:admin',
|
||||||
|
'login.form.rememberPassword': '记住密码',
|
||||||
|
'login.form.forgetPassword': '忘记密码',
|
||||||
|
'login.form.login': '登录',
|
||||||
|
'login.form.register': '注册账号',
|
||||||
|
'login.banner.slogan1': '开箱即用的高质量模板',
|
||||||
|
'login.banner.subSlogan1': '丰富的的页面模板,覆盖大多数典型业务场景',
|
||||||
|
'login.banner.slogan2': '内置了常见问题的解决方案',
|
||||||
|
'login.banner.subSlogan2': '国际化,路由配置,状态管理应有尽有',
|
||||||
|
'login.banner.slogan3': '接入可视化增强工具AUX',
|
||||||
|
'login.banner.subSlogan3': '实现灵活的区块式开发',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
120
admin/src/pages/login/style/index.module.less
Normal file
120
admin/src/pages/login/style/index.module.less
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
width: 550px;
|
||||||
|
background: linear-gradient(163.85deg, #1d2129 0%, #00308f 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
position: fixed;
|
||||||
|
top: 24px;
|
||||||
|
left: 22px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
&-text {
|
||||||
|
margin-left: 4px;
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--color-fill-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&-inner {
|
||||||
|
height: 100%;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
&-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 28px;
|
||||||
|
color: var(--color-fill-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-sub-title {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-image {
|
||||||
|
margin-top: 30px;
|
||||||
|
width: 320px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
&-wrapper {
|
||||||
|
width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-sub-title {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-error-msg {
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
color: rgb(var(--red-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
&-password-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-register-btn {
|
||||||
|
color: var(--color-text-3) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
admin/src/pages/menu/form.tsx
Normal file
101
admin/src/pages/menu/form.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Modal, Form, Input, Message, Select } from '@arco-design/web-react';
|
||||||
|
import { createMenu, getMenu, updateMenu } from '@/api/menu';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
record: { [key: string]: any } | null,
|
||||||
|
visible: boolean,
|
||||||
|
handleConfirm: () => void,
|
||||||
|
handleCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormComponent(props: Props) {
|
||||||
|
const { visible, record, handleConfirm, handleCancel } = props;
|
||||||
|
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||||
|
|
||||||
|
const onOk = async () => {
|
||||||
|
try {
|
||||||
|
setConfirmLoading(true);
|
||||||
|
const values = await form.validate();
|
||||||
|
if (record?.id) {
|
||||||
|
await updateMenu(values);
|
||||||
|
} else {
|
||||||
|
await createMenu(values);
|
||||||
|
}
|
||||||
|
Message.success(`${record?.id ? '更新' : '新增'}成功!`);
|
||||||
|
handleConfirm();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
} finally {
|
||||||
|
setConfirmLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [menuList, setMenuList] = useState([]);
|
||||||
|
|
||||||
|
const fetchMenuList = async () => {
|
||||||
|
const { data } = await getMenu();
|
||||||
|
const mergeMenu = [
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
name: '无父级'
|
||||||
|
},
|
||||||
|
...data
|
||||||
|
];
|
||||||
|
setMenuList(mergeMenu);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
record && form.setFieldsValue({
|
||||||
|
...record
|
||||||
|
});
|
||||||
|
}, [record]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
fetchMenuList();
|
||||||
|
} else {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Modal
|
||||||
|
title={<span>{`${record?.id ? '更新' : '新增'}菜单信息`}</span>}
|
||||||
|
visible={visible}
|
||||||
|
onOk={onOk}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
confirmLoading={confirmLoading}
|
||||||
|
>
|
||||||
|
<Form form={form}>
|
||||||
|
<Form.Item hidden field="id">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="父级菜单" field="parentId" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
placeholder="请选择父级菜单"
|
||||||
|
allowClear
|
||||||
|
>
|
||||||
|
{menuList.map((option) => (
|
||||||
|
<Select.Option disabled={option.id === record?.id} key={option.id} value={option.id}>
|
||||||
|
{option.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="菜单名称" field="name" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入菜单名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="菜单url" field="url" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入菜单url" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormComponent;
|
||||||
123
admin/src/pages/menu/index.tsx
Normal file
123
admin/src/pages/menu/index.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Table,
|
||||||
|
Typography,
|
||||||
|
TableColumnProps,
|
||||||
|
Button,
|
||||||
|
Popconfirm,
|
||||||
|
Divider,
|
||||||
|
Space,
|
||||||
|
Message
|
||||||
|
} from '@arco-design/web-react';
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
import Form from './form';
|
||||||
|
import { deleteMenu, getTreeMenu } from '@/api/menu';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
function ConfigManage() {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [record, setRecord] = useState(null);
|
||||||
|
const columns: TableColumnProps[] = [
|
||||||
|
{
|
||||||
|
title: '菜单id',
|
||||||
|
dataIndex: 'id',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '菜单名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '菜单url',
|
||||||
|
dataIndex: 'url',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
dataIndex: 'operations',
|
||||||
|
width:'25%',
|
||||||
|
render: (_, record) => (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => showModal(record)} type="text" status="default">更新</Button>
|
||||||
|
<Divider type="vertical" />
|
||||||
|
<Popconfirm
|
||||||
|
focusLock
|
||||||
|
title="删除数据"
|
||||||
|
content="确认删除该条数据吗?"
|
||||||
|
onOk={() => onDelete(record)}
|
||||||
|
>
|
||||||
|
<Button type="text" status="danger">删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
align: 'center'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const onDelete = async ({ id }) => {
|
||||||
|
await deleteMenu(id);
|
||||||
|
Message.success('删除成功!');
|
||||||
|
await refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
const { data } = await getTreeMenu();
|
||||||
|
return data
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
refetch
|
||||||
|
} = useQuery(
|
||||||
|
['menu'], fetchData, { refetchOnWindowFocus: false, keepPreviousData: true });
|
||||||
|
|
||||||
|
const showModal = (payload?) => {
|
||||||
|
payload && setRecord(payload);
|
||||||
|
setVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
setRecord(null);
|
||||||
|
setVisible(false);
|
||||||
|
await refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setRecord(null);
|
||||||
|
setVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<Form
|
||||||
|
handleConfirm={handleConfirm}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
visible={visible}
|
||||||
|
record={record}
|
||||||
|
/>
|
||||||
|
<Title heading={6}>菜单管理</Title>
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: 12 }}>
|
||||||
|
<Button type="primary" onClick={() => showModal()}>新建菜单</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading || isFetching}
|
||||||
|
columns={columns}
|
||||||
|
pagination={false}
|
||||||
|
data={data}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default ConfigManage;
|
||||||
15
admin/src/pages/menu/style/index.module.less
Normal file
15
admin/src/pages/menu/style/index.module.less
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
.search-form-wrapper {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--color-border-1);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.right-button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-left: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-left: 1px solid var(--color-border-2);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
admin/src/pages/role/form.tsx
Normal file
76
admin/src/pages/role/form.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Modal, Form, Input, Message, Select } from '@arco-design/web-react';
|
||||||
|
import { createRole, updateRole } from '@/api/role';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
record: { [key: string]: any } | null,
|
||||||
|
visible: boolean,
|
||||||
|
handleConfirm: () => void,
|
||||||
|
handleCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormComponent(props: Props) {
|
||||||
|
const { visible, record, handleConfirm, handleCancel } = props;
|
||||||
|
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||||
|
|
||||||
|
const onOk = async () => {
|
||||||
|
try {
|
||||||
|
setConfirmLoading(true);
|
||||||
|
const values = await form.validate();
|
||||||
|
if (record?.id) {
|
||||||
|
await updateRole(values);
|
||||||
|
} else {
|
||||||
|
await createRole(values);
|
||||||
|
}
|
||||||
|
Message.success(`${record?.id ? '更新' : '新增'}成功!`);
|
||||||
|
handleConfirm();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
} finally {
|
||||||
|
setConfirmLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
record && form.setFieldsValue({
|
||||||
|
...record
|
||||||
|
});
|
||||||
|
}, [record]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
!visible && form.resetFields();
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Modal
|
||||||
|
title={<span>{`${record?.id ? '更新' : '新增'}角色信息`}</span>}
|
||||||
|
visible={visible}
|
||||||
|
onOk={onOk}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
confirmLoading={confirmLoading}
|
||||||
|
>
|
||||||
|
<Form form={form}>
|
||||||
|
<Form.Item hidden field="id">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="角色名称" field="name" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入角色名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item disabled={record?.id} label="角色编码" field="code" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入角色编码" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="状态" field="status" rules={[{ required: true }]}>
|
||||||
|
<Select placeholder="请选择" options={[{ label: '启用', value: 1 }, { label: '禁用', value: 0 }]} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormComponent;
|
||||||
124
admin/src/pages/role/grantPerm.tsx
Normal file
124
admin/src/pages/role/grantPerm.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Modal, Form, Input, Message, Tree, Checkbox } from '@arco-design/web-react';
|
||||||
|
import { getTreeMenu } from '@/api/menu';
|
||||||
|
import { getRoleMenuIds, grantPermission } from '@/api/permission';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
record: { [key: string]: any } | null,
|
||||||
|
visible: boolean,
|
||||||
|
handleConfirm: () => void,
|
||||||
|
handleCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function GrantPermission(props: Props) {
|
||||||
|
const { visible, record, handleConfirm, handleCancel } = props;
|
||||||
|
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||||
|
const [checkedKeys, setCheckedKeys] = useState([]);
|
||||||
|
const [treeData, setTreeData] = useState([]);
|
||||||
|
|
||||||
|
const onOk = async () => {
|
||||||
|
try {
|
||||||
|
setConfirmLoading(true);
|
||||||
|
const values = await form.validate();
|
||||||
|
await grantPermission(values.id, checkedKeys.map(item=>Number(item)));
|
||||||
|
Message.success(`操作成功!`);
|
||||||
|
handleConfirm();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
} finally {
|
||||||
|
setConfirmLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
record && form.setFieldsValue({
|
||||||
|
...record
|
||||||
|
});
|
||||||
|
}, [record]);
|
||||||
|
|
||||||
|
|
||||||
|
const [handleFinished, setHandleFinished] = useState(false);
|
||||||
|
const handleMenuAndKeys = (menu, menuIds) => {
|
||||||
|
let newMenuIds = menuIds.map((menuId) => menuId.toString());
|
||||||
|
|
||||||
|
function handleMenu(_menu) {
|
||||||
|
_menu.forEach((item) => {
|
||||||
|
// 组件需要的key必须为string
|
||||||
|
item.id = item.id.toString();
|
||||||
|
item.parentId = item.id.toString();
|
||||||
|
|
||||||
|
if (item.children?.length > 0) {
|
||||||
|
if (item.children.some(child => !newMenuIds.includes(child.id))) {
|
||||||
|
newMenuIds = newMenuIds.filter(id => id !== item.id);
|
||||||
|
}
|
||||||
|
handleMenu(item.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMenu(menu);
|
||||||
|
setTreeData(menu);
|
||||||
|
setCheckedKeys(newMenuIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (treeData.length) {
|
||||||
|
setHandleFinished(true);
|
||||||
|
}
|
||||||
|
}, [treeData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
const fetchTreeMenu = async () => {
|
||||||
|
const { data } = await getTreeMenu();
|
||||||
|
const { data: menuIds } = await getRoleMenuIds(record.id);
|
||||||
|
handleMenuAndKeys(data, menuIds);
|
||||||
|
};
|
||||||
|
fetchTreeMenu();
|
||||||
|
} else {
|
||||||
|
form.resetFields();
|
||||||
|
setTreeData([]);
|
||||||
|
setCheckedKeys([]);
|
||||||
|
setHandleFinished(false);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Modal
|
||||||
|
title={<span>分配权限</span>}
|
||||||
|
visible={visible}
|
||||||
|
onOk={onOk}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
confirmLoading={confirmLoading}
|
||||||
|
>
|
||||||
|
<Form form={form}>
|
||||||
|
<Form.Item hidden field="id">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="菜单权限">
|
||||||
|
{
|
||||||
|
handleFinished && <Tree
|
||||||
|
checkable
|
||||||
|
checkedKeys={checkedKeys}
|
||||||
|
autoExpandParent={true}
|
||||||
|
onCheck={(value) => {
|
||||||
|
setCheckedKeys(value);
|
||||||
|
}}
|
||||||
|
treeData={treeData}
|
||||||
|
fieldNames={{
|
||||||
|
key: 'id',
|
||||||
|
title: 'name'
|
||||||
|
}}
|
||||||
|
></Tree>
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GrantPermission;
|
||||||
194
admin/src/pages/role/index.tsx
Normal file
194
admin/src/pages/role/index.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Table,
|
||||||
|
Typography,
|
||||||
|
TableColumnProps,
|
||||||
|
PaginationProps,
|
||||||
|
Button,
|
||||||
|
Popconfirm,
|
||||||
|
Divider,
|
||||||
|
Space,
|
||||||
|
Message, Badge
|
||||||
|
} from '@arco-design/web-react';
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
import Form from './form';
|
||||||
|
import { deleteRole, getRole } from '@/api/role';
|
||||||
|
import GrantPerm from './grantPerm';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
function ConfigManage() {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [record, setRecord] = useState(null);
|
||||||
|
const [visibleGrantPerm, setVisibleGrantPerm] = useState(null);
|
||||||
|
|
||||||
|
const columns: TableColumnProps[] = [
|
||||||
|
{
|
||||||
|
title: '角色id',
|
||||||
|
dataIndex: 'id',
|
||||||
|
render: (value) => <Text copyable>{value}</Text>,
|
||||||
|
align: 'center',
|
||||||
|
width: '20%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '角色名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
align: 'center',
|
||||||
|
width: '20%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '唯一编码',
|
||||||
|
dataIndex: 'code',
|
||||||
|
render: (value) => <Text copyable>{value}</Text>,
|
||||||
|
align: 'center',
|
||||||
|
width: '20%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
align: 'center',
|
||||||
|
width: '20%',
|
||||||
|
render: (x) => {
|
||||||
|
if (x === 0) {
|
||||||
|
return <Badge status="error" text="禁用中"></Badge>;
|
||||||
|
}
|
||||||
|
return <Badge status="success" text="启用中"></Badge>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
dataIndex: 'operations',
|
||||||
|
width: '20%',
|
||||||
|
render: (_, record) => (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => showModal(record)} type="text" status="default">更新</Button>
|
||||||
|
<Divider type="vertical" />
|
||||||
|
<Button onClick={() => showGrantPerm(record)} type="text" status="default">分配权限</Button>
|
||||||
|
<Divider type="vertical" />
|
||||||
|
<Popconfirm
|
||||||
|
focusLock
|
||||||
|
title="删除数据"
|
||||||
|
content="确认删除该条数据吗?"
|
||||||
|
onOk={() => onDelete(record)}
|
||||||
|
>
|
||||||
|
<Button type="text" status="danger">删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
align: 'center'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const [pagination, setPagination] = useState<PaginationProps>({
|
||||||
|
sizeCanChange: true,
|
||||||
|
showTotal: true,
|
||||||
|
pageSize: 10,
|
||||||
|
current: 1,
|
||||||
|
pageSizeChangeResetCurrent: true,
|
||||||
|
total: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDelete = async ({ id }) => {
|
||||||
|
await deleteRole(id);
|
||||||
|
Message.success('删除成功!');
|
||||||
|
await refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChangeTable = async ({ current, pageSize }) => {
|
||||||
|
setPagination({
|
||||||
|
...pagination,
|
||||||
|
current,
|
||||||
|
pageSize
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = async ({ queryKey }) => {
|
||||||
|
const [, { page, pageSize }] = queryKey;
|
||||||
|
const { data } = await getRole({ page, pageSize });
|
||||||
|
setPagination(prevState => {
|
||||||
|
prevState.total = data.count;
|
||||||
|
return prevState;
|
||||||
|
});
|
||||||
|
if (page > 1 && data.list.length === 0) {
|
||||||
|
setPagination({
|
||||||
|
...pagination,
|
||||||
|
current: page - 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
refetch
|
||||||
|
} = useQuery(
|
||||||
|
['role', { page: pagination.current, pageSize: pagination.pageSize }],
|
||||||
|
fetchData,
|
||||||
|
{
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
keepPreviousData: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const showModal = (payload?) => {
|
||||||
|
payload && setRecord(payload);
|
||||||
|
setVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showGrantPerm = (payload) => {
|
||||||
|
setRecord(payload);
|
||||||
|
setVisibleGrantPerm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
handleCancel()
|
||||||
|
await refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setRecord(null);
|
||||||
|
setVisible(false);
|
||||||
|
setVisibleGrantPerm(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<Form
|
||||||
|
handleConfirm={handleConfirm}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
visible={visible}
|
||||||
|
record={record}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GrantPerm
|
||||||
|
handleConfirm={handleConfirm}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
visible={visibleGrantPerm}
|
||||||
|
record={record}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Title heading={6}>角色管理</Title>
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: 12 }}>
|
||||||
|
<Button type="primary" onClick={() => showModal()}>创建角色</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading || isFetching}
|
||||||
|
columns={columns}
|
||||||
|
onChange={onChangeTable}
|
||||||
|
pagination={pagination}
|
||||||
|
data={data?.list}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default ConfigManage;
|
||||||
15
admin/src/pages/role/style/index.module.less
Normal file
15
admin/src/pages/role/style/index.module.less
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
.search-form-wrapper {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--color-border-1);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.right-button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-left: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-left: 1px solid var(--color-border-2);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
admin/src/pages/user/form.tsx
Normal file
94
admin/src/pages/user/form.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Modal, Form, Input, Select, Message } from '@arco-design/web-react';
|
||||||
|
import UploadFile from '@/components/Upload';
|
||||||
|
import { createUser, updateUser } from '@/api/user';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
record: { [key: string]: any } | null,
|
||||||
|
visible: boolean,
|
||||||
|
handleConfirm: () => void,
|
||||||
|
handleCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormComponent(props: Props) {
|
||||||
|
const { visible, record, handleConfirm, handleCancel } = props;
|
||||||
|
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||||
|
|
||||||
|
const onOk = async () => {
|
||||||
|
try {
|
||||||
|
setConfirmLoading(true);
|
||||||
|
const values = await form.validate();
|
||||||
|
const data = {
|
||||||
|
...values,
|
||||||
|
avatarId: values.avatar?.[0]?.response ?? null
|
||||||
|
};
|
||||||
|
if (record?.id) {
|
||||||
|
await updateUser(data);
|
||||||
|
} else {
|
||||||
|
await createUser(data);
|
||||||
|
}
|
||||||
|
Message.success(`${record?.id ? '更新' : '新增'}成功!`);
|
||||||
|
handleConfirm();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
} finally {
|
||||||
|
setConfirmLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
record && form.setFieldsValue({
|
||||||
|
...record,
|
||||||
|
avatar: record.avatar ? [{
|
||||||
|
uid: record.avatar.id,
|
||||||
|
response: record.avatar.id,
|
||||||
|
url: record.avatar.path
|
||||||
|
}] : undefined
|
||||||
|
});
|
||||||
|
}, [record]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
!visible && form.resetFields()
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={<span>{`${record?.id ? '更新' : '新增'}用户信息`}</span>}
|
||||||
|
visible={visible}
|
||||||
|
onOk={onOk}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
confirmLoading={confirmLoading}
|
||||||
|
>
|
||||||
|
<Form form={form}>
|
||||||
|
<Form.Item hidden={true} field="id" rules={[{ required: false }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item triggerPropName="fileList" label="用户头像"
|
||||||
|
field="avatar">
|
||||||
|
<UploadFile limit={1} dir="/user" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="用户昵称" field="name" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入用户昵称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item disabled={record?.id} label="用户账号" field="account" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入用户账号" />
|
||||||
|
</Form.Item>
|
||||||
|
{!record?.id && <Form.Item label="用户密码" field="password" rules={[{ required: true }]}>
|
||||||
|
<Input type="password" placeholder="请输入用户密码" />
|
||||||
|
</Form.Item>}
|
||||||
|
|
||||||
|
<Form.Item label="用户状态" field="status" rules={[{ required: true }]}>
|
||||||
|
<Select placeholder="请选择" options={[{ label: '启用', value: 1 }, { label: '禁用', value: 0 }]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="用户性别" field="gender" rules={[{ required: true }]}>
|
||||||
|
<Select placeholder="请选择"
|
||||||
|
options={[{ label: '女', value: 0 }, { label: '男', value: 1 }, { label: '未知', value: 2 }]} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormComponent;
|
||||||
72
admin/src/pages/user/grantRoles.tsx
Normal file
72
admin/src/pages/user/grantRoles.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Modal, Checkbox, Grid, Message } from '@arco-design/web-react';
|
||||||
|
import { getAllRole } from '@/api/role';
|
||||||
|
import { grantUserRoles } from '@/api/auth';
|
||||||
|
|
||||||
|
const { Col, Row } = Grid;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
record: { [key: string]: any } | null,
|
||||||
|
visible: boolean,
|
||||||
|
handleConfirm: () => void,
|
||||||
|
handleCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssignRoles(props: Props) {
|
||||||
|
const { visible, record, handleConfirm, handleCancel } = props;
|
||||||
|
const [confirmLoading, setConfirmLoading] = useState(true);
|
||||||
|
const [roles, setRoles] = useState([]);
|
||||||
|
const [roleIds, setRoleIds] = useState([]);
|
||||||
|
|
||||||
|
const fetchRoles = async () => {
|
||||||
|
const { data } = await getAllRole();
|
||||||
|
setRoles(data);
|
||||||
|
setConfirmLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setRoleIds(record.userRole.map(item=>item.roleId))
|
||||||
|
|
||||||
|
fetchRoles();
|
||||||
|
} else {
|
||||||
|
setRoles([]);
|
||||||
|
setRoleIds([])
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const onOk = async () => {
|
||||||
|
try {
|
||||||
|
setConfirmLoading(true);
|
||||||
|
await grantUserRoles(record.id,{roleIds})
|
||||||
|
Message.success('操作成功!');
|
||||||
|
handleConfirm();
|
||||||
|
}finally {
|
||||||
|
setConfirmLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={<span>分配角色</span>}
|
||||||
|
visible={visible}
|
||||||
|
onOk={onOk}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
confirmLoading={confirmLoading}
|
||||||
|
>
|
||||||
|
<Checkbox.Group style={{width:'100%'}} value={roleIds} onChange={setRoleIds}>
|
||||||
|
<Row>
|
||||||
|
{
|
||||||
|
roles.map(role => <Col
|
||||||
|
key={role.id}
|
||||||
|
span={8}
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
>
|
||||||
|
<Checkbox value={role.id}>{role.name}</Checkbox>
|
||||||
|
</Col>)
|
||||||
|
}
|
||||||
|
</Row>
|
||||||
|
</Checkbox.Group>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
admin/src/pages/user/index.tsx
Normal file
209
admin/src/pages/user/index.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Table,
|
||||||
|
Typography,
|
||||||
|
TableColumnProps,
|
||||||
|
Badge,
|
||||||
|
PaginationProps,
|
||||||
|
Button,
|
||||||
|
Popconfirm,
|
||||||
|
Divider,
|
||||||
|
Avatar,
|
||||||
|
Message
|
||||||
|
} from '@arco-design/web-react';
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
import Form from './form';
|
||||||
|
import GrantRolesForm from './grantRoles';
|
||||||
|
import { GenderDict } from '@/enum';
|
||||||
|
import { deleteUser, getUsers } from '@/api/user';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
function UserManage() {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [visibleAssignRoles, setVisibleAssignRoles] = useState(false);
|
||||||
|
const [record, setRecord] = useState(null);
|
||||||
|
const columns: TableColumnProps[] = [
|
||||||
|
{
|
||||||
|
title: 'id',
|
||||||
|
dataIndex: 'id',
|
||||||
|
render: (value) => <Text copyable>{value}</Text>,
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '头像',
|
||||||
|
dataIndex: 'avatar',
|
||||||
|
align: 'center',
|
||||||
|
render: (value) => (<Avatar>
|
||||||
|
{value ? <img alt="avatar" src={value?.path} /> : <Avatar>未上传</Avatar>}
|
||||||
|
</Avatar>)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '账号',
|
||||||
|
dataIndex: 'account',
|
||||||
|
align: 'center',
|
||||||
|
render: (value) => <Text copyable>{value}</Text>,
|
||||||
|
ellipsis: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '昵称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '性别',
|
||||||
|
dataIndex: 'gender',
|
||||||
|
align: 'center',
|
||||||
|
render: (value) => <Text>{GenderDict[value]}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
align: 'center',
|
||||||
|
render: (x) => {
|
||||||
|
if (x === 0) {
|
||||||
|
return <Badge status="error" text="禁用中"></Badge>;
|
||||||
|
}
|
||||||
|
return <Badge status="success" text="启用中"></Badge>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
dataIndex: 'operations',
|
||||||
|
width: 300,
|
||||||
|
render: (_, record) => (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => showModal(record)} type="text" status="default">更新</Button>
|
||||||
|
<Divider type="vertical" />
|
||||||
|
<Button onClick={() => showAssignRolesModal(record)} type="text" status="default">分配角色</Button>
|
||||||
|
<Divider type="vertical" />
|
||||||
|
<Popconfirm
|
||||||
|
focusLock
|
||||||
|
title="删除数据"
|
||||||
|
content="确认删除该条数据吗?"
|
||||||
|
onOk={() => onDelete(record)}
|
||||||
|
>
|
||||||
|
<Button type="text" status="danger">删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
align: 'center'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const [pagination, setPagination] = useState<PaginationProps>({
|
||||||
|
sizeCanChange: true,
|
||||||
|
showTotal: true,
|
||||||
|
pageSize: 10,
|
||||||
|
current: 1,
|
||||||
|
pageSizeChangeResetCurrent: true,
|
||||||
|
total: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDelete = async ({ id }) => {
|
||||||
|
await deleteUser(id);
|
||||||
|
Message.success('删除成功!');
|
||||||
|
await refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChangeTable = async ({ current, pageSize }) => {
|
||||||
|
setPagination({
|
||||||
|
...pagination,
|
||||||
|
current,
|
||||||
|
pageSize
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = async ({ queryKey }) => {
|
||||||
|
const [, { page, pageSize }] = queryKey;
|
||||||
|
const { data } = await getUsers({ page, pageSize });
|
||||||
|
setPagination(prevState => {
|
||||||
|
prevState.total = data.count;
|
||||||
|
return prevState;
|
||||||
|
});
|
||||||
|
if (page > 1 && data.list.length === 0) {
|
||||||
|
setPagination({
|
||||||
|
...pagination,
|
||||||
|
current: page - 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
refetch
|
||||||
|
} = useQuery(
|
||||||
|
['user', { page: pagination.current, pageSize: pagination.pageSize }],
|
||||||
|
fetchData,
|
||||||
|
{
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
keepPreviousData: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const showAssignRolesModal = (payload) => {
|
||||||
|
setRecord(payload);
|
||||||
|
setVisibleAssignRoles(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showModal = (payload?) => {
|
||||||
|
payload && setRecord(payload);
|
||||||
|
setVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
setRecord(null);
|
||||||
|
setVisible(false);
|
||||||
|
setVisibleAssignRoles(false);
|
||||||
|
await refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setRecord(null);
|
||||||
|
setVisible(false);
|
||||||
|
setVisibleAssignRoles(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<Form
|
||||||
|
handleConfirm={handleConfirm}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
visible={visible}
|
||||||
|
record={record}
|
||||||
|
/>
|
||||||
|
<GrantRolesForm
|
||||||
|
handleConfirm={handleConfirm}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
visible={visibleAssignRoles}
|
||||||
|
record={record}
|
||||||
|
/>
|
||||||
|
<Title heading={6}>用户管理</Title>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
type="primary"
|
||||||
|
onClick={() => showModal()}
|
||||||
|
>
|
||||||
|
新增用户
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading || isFetching}
|
||||||
|
columns={columns}
|
||||||
|
onChange={onChangeTable}
|
||||||
|
pagination={pagination}
|
||||||
|
data={data?.list}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default UserManage;
|
||||||
15
admin/src/pages/user/style/index.module.less
Normal file
15
admin/src/pages/user/style/index.module.less
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
.search-form-wrapper {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--color-border-1);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.right-button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-left: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-left: 1px solid var(--color-border-2);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
admin/src/routes.ts
Normal file
43
admin/src/routes.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { AuthParams } from '@/utils/authentication';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export type IRoute = AuthParams & {
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
// 当前页是否展示面包屑
|
||||||
|
breadcrumb?: boolean;
|
||||||
|
children?: IRoute[];
|
||||||
|
// 当前路由是否渲染菜单项,为 true 的话不会在菜单中显示,但可通过路由地址访问。
|
||||||
|
ignore?: boolean;
|
||||||
|
icon?: string
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatorMenu = (data, parentId = 0) => {
|
||||||
|
const menu = [];
|
||||||
|
for (const item of data) {
|
||||||
|
item.key = item.url;
|
||||||
|
if (item.parentId === parentId) {
|
||||||
|
const children = generatorMenu(data, item.id);
|
||||||
|
if (children.length) {
|
||||||
|
item.children = children;
|
||||||
|
}
|
||||||
|
menu.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return menu;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useRoute = (menus): [IRoute[], string] => {
|
||||||
|
const routes = generatorMenu(menus);
|
||||||
|
const defaultRoute = useMemo(() => {
|
||||||
|
const first = routes[0];
|
||||||
|
if (first) {
|
||||||
|
return first?.children?.[0]?.key || first.key;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}, [routes]);
|
||||||
|
return [routes, defaultRoute];
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useRoute;
|
||||||
8
admin/src/settings.json
Normal file
8
admin/src/settings.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"colorWeek": false,
|
||||||
|
"navbar": true,
|
||||||
|
"menu": true,
|
||||||
|
"footer": true,
|
||||||
|
"themeColor": "#165DFF",
|
||||||
|
"menuWidth": 220
|
||||||
|
}
|
||||||
22
admin/src/store/index.ts
Normal file
22
admin/src/store/index.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { createUserStore, IUserStore } from '@/store/modules/user';
|
||||||
|
|
||||||
|
|
||||||
|
interface IStore {
|
||||||
|
init: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStore = create<IUserStore & IStore>()((...args) => {
|
||||||
|
const [set] = args;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...createUserStore(...args),
|
||||||
|
|
||||||
|
init: () => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
set({ token });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default useStore
|
||||||
58
admin/src/store/modules/user.ts
Normal file
58
admin/src/store/modules/user.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { StateCreator } from 'zustand';
|
||||||
|
import defaultSettings from '@/settings.json';
|
||||||
|
import { LoginData } from '@/api/auth/type';
|
||||||
|
import { login } from '@/api/auth';
|
||||||
|
import { userInfo } from '@/api/user';
|
||||||
|
import { IRoute } from '@/routes';
|
||||||
|
|
||||||
|
export interface IUserStore {
|
||||||
|
token: string,
|
||||||
|
removeToken: () => void;
|
||||||
|
userLoading: boolean;
|
||||||
|
settings?: typeof defaultSettings;
|
||||||
|
userInfo?: {
|
||||||
|
name?: string;
|
||||||
|
avatar?: { path: string };
|
||||||
|
permissions: any
|
||||||
|
menus:IRoute[]
|
||||||
|
};
|
||||||
|
fetchUserInfo: () => Promise<void>
|
||||||
|
login: (params: LoginData) => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
|
||||||
|
updateSetting: (payload: Partial<typeof defaultSettings>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createUserStore: StateCreator<IUserStore> = (set) => ({
|
||||||
|
token: '',
|
||||||
|
removeToken: () => set({ token: '' }),
|
||||||
|
userLoading: true,
|
||||||
|
settings: defaultSettings,
|
||||||
|
updateSetting: (payload) => set(state => ({ settings: { ...state.settings, ...payload } })),
|
||||||
|
userInfo: {
|
||||||
|
permissions: [],
|
||||||
|
menus:[]
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchUserInfo() {
|
||||||
|
const { data } = await userInfo();
|
||||||
|
set({ userLoading: false, userInfo: data });
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(loginData) {
|
||||||
|
const { data } = await login(loginData);
|
||||||
|
set({ token: data.accessToken });
|
||||||
|
localStorage.setItem('accessToken', data.accessToken);
|
||||||
|
},
|
||||||
|
logout: async () => {
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
window.location.href = '/login';
|
||||||
|
set({
|
||||||
|
userInfo: {
|
||||||
|
permissions: [],
|
||||||
|
menus:[]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
11
admin/src/style/global.less
Normal file
11
admin/src/style/global.less
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
@import '@arco-themes/react-arco-pro/index.less';
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: var(--color-bg-1);
|
||||||
|
}
|
||||||
138
admin/src/style/layout.module.less
Normal file
138
admin/src/style/layout.module.less
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
@nav-size-height: 60px;
|
||||||
|
@layout-max-width: 1100px;
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-navbar {
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
min-width: @layout-max-width;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: @nav-size-height;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
&-hidden {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-sider {
|
||||||
|
position: fixed;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 99;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
border: 4px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
border-radius: 7px;
|
||||||
|
background-color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -1px;
|
||||||
|
width: 1px;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
> :global(.arco-layout-sider-children) {
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn {
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
background-color: var(--color-fill-1);
|
||||||
|
color: var(--color-text-3);
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
// 位置
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
right: 12px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-fill-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-wrapper {
|
||||||
|
overflow: auto;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
:global(.arco-menu-item-inner > a::after),
|
||||||
|
:global(.arco-menu-item > a::after) {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.arco-menu-inline-header) {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 18px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-empty {
|
||||||
|
width: 12px;
|
||||||
|
height: 18px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-content {
|
||||||
|
background-color: var(--color-fill-2);
|
||||||
|
min-width: @layout-max-width;
|
||||||
|
min-height: 100vh;
|
||||||
|
transition: padding-left 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-content-wrapper {
|
||||||
|
padding: 16px 20px 0px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-breadcrumb {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: calc(100vh - @nav-size-height);
|
||||||
|
}
|
||||||
59
admin/src/utils/authentication.ts
Normal file
59
admin/src/utils/authentication.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* { data-analysis: ['read', 'write'] }
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type UserPermission = Record<string, string[]>;
|
||||||
|
|
||||||
|
type Auth = {
|
||||||
|
resource: string | RegExp;
|
||||||
|
actions?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AuthParams {
|
||||||
|
requiredPermissions?: Array<Auth>;
|
||||||
|
oneOfPerm?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const judge = (actions: string[], perm: string[]) => {
|
||||||
|
if (!perm || !perm.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (perm.join('') === '*') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions.every((action) => perm.includes(action));
|
||||||
|
};
|
||||||
|
|
||||||
|
const auth = (params: Auth, userPermission: UserPermission) => {
|
||||||
|
const { resource, actions = [] } = params;
|
||||||
|
if (resource instanceof RegExp) {
|
||||||
|
const permKeys = Object.keys(userPermission);
|
||||||
|
const matchPermissions = permKeys.filter((item) => item.match(resource));
|
||||||
|
if (!matchPermissions.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return matchPermissions.every((key) => {
|
||||||
|
const perm = userPermission[key];
|
||||||
|
return judge(actions, perm);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const perm = userPermission[resource];
|
||||||
|
return judge(actions, perm);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (params: AuthParams, userPermission: UserPermission) => {
|
||||||
|
const { requiredPermissions, oneOfPerm } = params;
|
||||||
|
if (Array.isArray(requiredPermissions) && requiredPermissions.length) {
|
||||||
|
let count = 0;
|
||||||
|
for (const rp of requiredPermissions) {
|
||||||
|
if (auth(rp, userPermission)) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return oneOfPerm ? count > 0 : count === requiredPermissions.length;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
9
admin/src/utils/changeTheme.ts
Normal file
9
admin/src/utils/changeTheme.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
function changeTheme(theme) {
|
||||||
|
if (theme === 'dark') {
|
||||||
|
document.body.setAttribute('arco-theme', 'dark');
|
||||||
|
} else {
|
||||||
|
document.body.removeAttribute('arco-theme');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default changeTheme;
|
||||||
5
admin/src/utils/checkLogin.tsx
Normal file
5
admin/src/utils/checkLogin.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import store from '@/store';
|
||||||
|
|
||||||
|
export default function checkLogin() {
|
||||||
|
return !!store.getState().token
|
||||||
|
}
|
||||||
40
admin/src/utils/clipboard.ts
Normal file
40
admin/src/utils/clipboard.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// https://github.com/feross/clipboard-copy/blob/master/index.js
|
||||||
|
|
||||||
|
export default function clipboard(text) {
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
return navigator.clipboard.writeText(text).catch(function (err) {
|
||||||
|
throw err !== undefined
|
||||||
|
? err
|
||||||
|
: new DOMException('The request is not allowed', 'NotAllowedError');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = text;
|
||||||
|
|
||||||
|
span.style.whiteSpace = 'pre';
|
||||||
|
|
||||||
|
document.body.appendChild(span);
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const range = window.document.createRange();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
range.selectNode(span);
|
||||||
|
selection.addRange(range);
|
||||||
|
|
||||||
|
let success = false;
|
||||||
|
try {
|
||||||
|
success = window.document.execCommand('copy');
|
||||||
|
} catch (err) {
|
||||||
|
console.log('error', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
selection.removeAllRanges();
|
||||||
|
window.document.body.removeChild(span);
|
||||||
|
|
||||||
|
return success
|
||||||
|
? Promise.resolve()
|
||||||
|
: Promise.reject(
|
||||||
|
new DOMException('The request is not allowed', 'NotAllowedError')
|
||||||
|
);
|
||||||
|
}
|
||||||
19
admin/src/utils/getUrlParams.ts
Normal file
19
admin/src/utils/getUrlParams.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// 仅用于线上预览,实际使用中可以将此逻辑删除
|
||||||
|
import qs from 'query-string';
|
||||||
|
import { isSSR } from './is';
|
||||||
|
|
||||||
|
export type ParamsType = Record<string, any>;
|
||||||
|
|
||||||
|
export default function getUrlParams(): ParamsType {
|
||||||
|
const params = qs.parseUrl(!isSSR ? window.location.href : '').query;
|
||||||
|
const returnParams: ParamsType = {};
|
||||||
|
Object.keys(params).forEach((p) => {
|
||||||
|
if (params[p] === 'true') {
|
||||||
|
returnParams[p] = true;
|
||||||
|
}
|
||||||
|
if (params[p] === 'false') {
|
||||||
|
returnParams[p] = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return returnParams;
|
||||||
|
}
|
||||||
17
admin/src/utils/is.ts
Normal file
17
admin/src/utils/is.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export function isArray(val): boolean {
|
||||||
|
return Object.prototype.toString.call(val) === '[object Array]';
|
||||||
|
}
|
||||||
|
export function isObject(val): boolean {
|
||||||
|
return Object.prototype.toString.call(val) === '[object Object]';
|
||||||
|
}
|
||||||
|
export function isString(val): boolean {
|
||||||
|
return Object.prototype.toString.call(val) === '[object String]';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isSSR = (function () {
|
||||||
|
try {
|
||||||
|
return !(typeof window !== 'undefined' && document !== undefined);
|
||||||
|
} catch (e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
})();
|
||||||
37
admin/src/utils/lazyload.tsx
Normal file
37
admin/src/utils/lazyload.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import loadable from '@loadable/component';
|
||||||
|
import { Spin } from '@arco-design/web-react';
|
||||||
|
import styles from '../style/layout.module.less';
|
||||||
|
|
||||||
|
// https://github.com/gregberge/loadable-components/pull/226
|
||||||
|
function load(fn, options) {
|
||||||
|
const Component = loadable(fn, options);
|
||||||
|
|
||||||
|
Component.preload = fn.requireAsync || fn;
|
||||||
|
|
||||||
|
return Component;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingComponent(props: {
|
||||||
|
error: boolean;
|
||||||
|
timedOut: boolean;
|
||||||
|
pastDelay: boolean;
|
||||||
|
}) {
|
||||||
|
if (props.error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.spin}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (loader) =>
|
||||||
|
load(loader, {
|
||||||
|
fallback: LoadingComponent({
|
||||||
|
pastDelay: true,
|
||||||
|
error: false,
|
||||||
|
timedOut: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
62
admin/src/utils/request.ts
Normal file
62
admin/src/utils/request.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import axios, { AxiosRequestConfig, AxiosInstance } from 'axios';
|
||||||
|
import { Message } from '@arco-design/web-react';
|
||||||
|
import store from '@/store';
|
||||||
|
|
||||||
|
class Request {
|
||||||
|
private instance: AxiosInstance;
|
||||||
|
|
||||||
|
constructor(config: AxiosRequestConfig) {
|
||||||
|
this.instance = axios.create(config);
|
||||||
|
this.instance.interceptors.request.use(
|
||||||
|
config => {
|
||||||
|
const token = store.getState().token;
|
||||||
|
if (token) config.headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 响应拦截器
|
||||||
|
this.instance.interceptors.response.use(
|
||||||
|
result => result.data,
|
||||||
|
error => {
|
||||||
|
if (error.response) {
|
||||||
|
const message = error.response?.data?.message;
|
||||||
|
Message.error({
|
||||||
|
content: Array.isArray(message) ? message.join(',') : message,
|
||||||
|
duration: error.response.status === 401 ? 1000 : 2000,
|
||||||
|
onClose: () => {
|
||||||
|
if (error.response.status === 401) {
|
||||||
|
store.getState().logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Message.error(error.message);
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(url: string, params?: object) {
|
||||||
|
return this.instance.get(url, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
post(url: string, data?: object) {
|
||||||
|
return this.instance.post(url, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
patch(url: string, data?: object) {
|
||||||
|
return this.instance.patch(url, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(url: string) {
|
||||||
|
return this.instance.delete(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: AxiosRequestConfig = {
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 10 * 1000
|
||||||
|
};
|
||||||
|
export default new Request(config);
|
||||||
11
admin/src/utils/useLocale.ts
Normal file
11
admin/src/utils/useLocale.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { GlobalContext } from '../context';
|
||||||
|
import defaultLocale from '../locale';
|
||||||
|
|
||||||
|
function useLocale(locale = null) {
|
||||||
|
const { lang } = useContext(GlobalContext);
|
||||||
|
|
||||||
|
return (locale || defaultLocale)[lang] || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useLocale;
|
||||||
44
admin/src/utils/useStorage.ts
Normal file
44
admin/src/utils/useStorage.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// https://stackoverflow.com/questions/68424114/next-js-how-to-fetch-localstorage-data-before-client-side-rendering
|
||||||
|
// 解决 nextJS 无法获取初始localstorage问题
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { isSSR } from '@/utils/is';
|
||||||
|
|
||||||
|
const getDefaultStorage = (key) => {
|
||||||
|
if (!isSSR) {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function useStorage(key: string, defaultValue?: string): [string, (string) => void, () => void] {
|
||||||
|
const [storedValue, setStoredValue] = useState(getDefaultStorage(key) || defaultValue
|
||||||
|
);
|
||||||
|
|
||||||
|
const setStorageValue = (value: string) => {
|
||||||
|
if (!isSSR) {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
if (value !== storedValue) {
|
||||||
|
setStoredValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeStorage = () => {
|
||||||
|
if (!isSSR) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storageValue = localStorage.getItem(key);
|
||||||
|
if (storageValue) {
|
||||||
|
setStoredValue(storageValue);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [storedValue, setStorageValue, removeStorage];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useStorage;
|
||||||
1
admin/src/vite-env.d.ts
vendored
Normal file
1
admin/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
30
admin/tsconfig.json
Normal file
30
admin/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2017",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": false,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
52
admin/vite.config.ts
Normal file
52
admin/vite.config.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { defineConfig,loadEnv } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import svgrPlugin from '@arco-plugins/vite-plugin-svgr';
|
||||||
|
import vitePluginForArco from '@arco-plugins/vite-react';
|
||||||
|
import setting from './src/settings.json';
|
||||||
|
import { createHtmlPlugin } from "vite-plugin-html";
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig(({mode})=>{
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
return {
|
||||||
|
server:{
|
||||||
|
port:9000,
|
||||||
|
proxy:{
|
||||||
|
'^/api': {
|
||||||
|
target: env.VITE_APP_BASE_URL,
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: [{ find: '@', replacement: '/src' }],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
svgrPlugin({
|
||||||
|
svgrOptions: {},
|
||||||
|
}),
|
||||||
|
vitePluginForArco({
|
||||||
|
theme: '@arco-themes/react-arco-pro',
|
||||||
|
modifyVars: {
|
||||||
|
'arcoblue-6': setting.themeColor,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createHtmlPlugin({
|
||||||
|
inject: {
|
||||||
|
data: {
|
||||||
|
title: env.VITE_APP_TITLE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
],
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
less: {
|
||||||
|
javascriptEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
6930
admin/yarn.lock
Normal file
6930
admin/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
1
server/.env
Normal file
1
server/.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DATABASE_URL="postgresql://postgres:ultimate@81.70.149.52:8000/promise?schema=public"
|
||||||
32
server/.eslintrc.js
Normal file
32
server/.eslintrc.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
module.exports = {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
project: 'tsconfig.json',
|
||||||
|
tsconfigRootDir : __dirname,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||||
|
extends: [
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:prettier/recommended',
|
||||||
|
],
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
jest: true,
|
||||||
|
},
|
||||||
|
ignorePatterns: ['.eslintrc.js'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/interface-name-prefix': 'off',
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-var-requires': 'off',
|
||||||
|
"prettier/prettier": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"endOfLine": "auto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
};
|
||||||
35
server/.gitignore
vendored
Normal file
35
server/.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
/coverage
|
||||||
|
/.nyc_output
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
4
server/.prettierrc
Normal file
4
server/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
4
server/README.md
Normal file
4
server/README.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
```shell
|
||||||
|
# 种子文件
|
||||||
|
npx prisma db seed
|
||||||
|
```
|
||||||
9
server/nest-cli.json
Normal file
9
server/nest-cli.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"assets": ["**/*.yaml"],
|
||||||
|
"watchAssets": true
|
||||||
|
}
|
||||||
|
}
|
||||||
102
server/package.json
Normal file
102
server/package.json
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"prebuild": "rimraf dist",
|
||||||
|
"build": "cross-env NODE_ENV=production nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"dev": "cross-env NODE_ENV=development nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/cache-manager": "^2.2.2",
|
||||||
|
"@nestjs/common": "^10.2.7",
|
||||||
|
"@nestjs/config": "^3.1.1",
|
||||||
|
"@nestjs/core": "^10.2.7",
|
||||||
|
"@nestjs/jwt": "^10.1.1",
|
||||||
|
"@nestjs/mapped-types": "*",
|
||||||
|
"@nestjs/platform-express": "^10.2.7",
|
||||||
|
"@prisma/client": "5.5.2",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cache-manager": "^5.5.3",
|
||||||
|
"cache-manager-redis-store": "^3.0.1",
|
||||||
|
"cache-manager-redis-yet": "^5.0.1",
|
||||||
|
"chalk": "^4.1.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"mime-types": "^2.1.35",
|
||||||
|
"minio": "^8.0.1",
|
||||||
|
"nanoid": "^3.3.6",
|
||||||
|
"nest-winston": "^1.9.2",
|
||||||
|
"reflect-metadata": "^0.1.13",
|
||||||
|
"rimraf": "^3.0.2",
|
||||||
|
"rxjs": "^7.2.0",
|
||||||
|
"winston": "^3.9.0",
|
||||||
|
"winston-daily-rotate-file": "^4.7.1"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "ts-node prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.2.0",
|
||||||
|
"@nestjs/schematics": "^10.0.2",
|
||||||
|
"@nestjs/testing": "^10.2.7",
|
||||||
|
"@types/cache-manager-redis-store": "^2.0.4",
|
||||||
|
"@types/express": "^4.17.13",
|
||||||
|
"@types/jest": "27.5.0",
|
||||||
|
"@types/js-yaml": "^4.0.5",
|
||||||
|
"@types/mime-types": "^2.1.3",
|
||||||
|
"@types/multer": "^1.4.9",
|
||||||
|
"@types/node": "^16.18.101",
|
||||||
|
"@types/supertest": "^2.0.11",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||||
|
"@typescript-eslint/parser": "^5.0.0",
|
||||||
|
"eslint": "^8.0.1",
|
||||||
|
"eslint-config-prettier": "^8.3.0",
|
||||||
|
"eslint-plugin-prettier": "^4.0.0",
|
||||||
|
"jest": "28.0.3",
|
||||||
|
"prettier": "^2.3.2",
|
||||||
|
"prisma": "5.5.2",
|
||||||
|
"source-map-support": "^0.5.20",
|
||||||
|
"supertest": "^6.1.3",
|
||||||
|
"ts-jest": "28.0.1",
|
||||||
|
"ts-loader": "^9.2.3",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "4.0.0",
|
||||||
|
"typescript": "^4.9.5"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
},
|
||||||
|
"volta": {
|
||||||
|
"node": "18.20.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
117
server/prisma/migrations/20241026034430_/migration.sql
Normal file
117
server/prisma/migrations/20241026034430_/migration.sql
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sys_user" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"account" TEXT NOT NULL,
|
||||||
|
"password" TEXT NOT NULL,
|
||||||
|
"password_salt" TEXT NOT NULL,
|
||||||
|
"status" SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
"avatar_id" INTEGER,
|
||||||
|
"gender" SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "sys_user_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sys_role" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"status" SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "sys_role_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sys_user_role" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"user_id" INTEGER NOT NULL,
|
||||||
|
"role_id" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "sys_user_role_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "files" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"path" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "files_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sys_permission" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"type" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "sys_permission_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sys_menu" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"parent_id" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "sys_menu_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sys_menu_permission" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"menu_id" INTEGER NOT NULL,
|
||||||
|
"permission_id" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "sys_menu_permission_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sys_role_permission" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"role_id" INTEGER NOT NULL,
|
||||||
|
"permission_id" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "sys_role_permission_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sys_config" (
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"remark" TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sys_user_account_key" ON "sys_user"("account");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sys_user_avatar_id_idx" ON "sys_user"("avatar_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sys_role_code_key" ON "sys_role"("code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sys_user_role_user_id_idx" ON "sys_user_role"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sys_user_role_role_id_idx" ON "sys_user_role"("role_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sys_menu_permission_menu_id_idx" ON "sys_menu_permission"("menu_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sys_menu_permission_permission_id_idx" ON "sys_menu_permission"("permission_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sys_role_permission_role_id_idx" ON "sys_role_permission"("role_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sys_role_permission_permission_id_idx" ON "sys_role_permission"("permission_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sys_config_key_key" ON "sys_config"("key");
|
||||||
3
server/prisma/migrations/migration_lock.toml
Normal file
3
server/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
||||||
123
server/prisma/schema.prisma
Normal file
123
server/prisma/schema.prisma
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
relationMode = "prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统用户表
|
||||||
|
model SysUser {
|
||||||
|
id Int @id @default(autoincrement()) /// 主键id
|
||||||
|
name String? /// 用户姓名
|
||||||
|
account String @unique /// 账户
|
||||||
|
password String /// 密码
|
||||||
|
passwordSalt String @map("password_salt") /// 密码盐
|
||||||
|
status Int @default(0) @db.SmallInt /// 用户状态
|
||||||
|
avatarId Int? @map("avatar_id") /// 用户头像id
|
||||||
|
avatar Files? @relation(fields: [avatarId], references: [id])
|
||||||
|
gender Int @default(0) @db.SmallInt /// 用户性别
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") /// 创建时间
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") /// 修改时间
|
||||||
|
userRole SysUserRole[]
|
||||||
|
|
||||||
|
@@index([avatarId])
|
||||||
|
@@map("sys_user")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统角色表
|
||||||
|
model SysRole {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
status Int @default(0) @db.SmallInt /// 角色状态
|
||||||
|
sysRolePermission SysRolePermission[]
|
||||||
|
sysUserRole SysUserRole[]
|
||||||
|
|
||||||
|
@@map("sys_role")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户角色关联表
|
||||||
|
model SysUserRole {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int @map("user_id")
|
||||||
|
roleId Int @map("role_id")
|
||||||
|
user SysUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
role SysRole @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([roleId])
|
||||||
|
@@map("sys_user_role")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件表
|
||||||
|
model Files {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String /// 文件名称
|
||||||
|
path String
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") /// 创建时间
|
||||||
|
sysUser SysUser[]
|
||||||
|
|
||||||
|
@@map("files")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限表
|
||||||
|
model SysPermission {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
type Int
|
||||||
|
sysRolePermission SysRolePermission[]
|
||||||
|
sysMenuPermission SysMenuPermission[]
|
||||||
|
|
||||||
|
@@map("sys_permission")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统菜单表
|
||||||
|
model SysMenu {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
url String
|
||||||
|
parentId Int @default(0) @map("parent_id")
|
||||||
|
sysMenuPermission SysMenuPermission[]
|
||||||
|
|
||||||
|
@@map("sys_menu")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 菜单权限关联表
|
||||||
|
model SysMenuPermission {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
menuId Int @map("menu_id")
|
||||||
|
permissionId Int @map("permission_id")
|
||||||
|
menu SysMenu @relation(fields: [menuId], references: [id], onDelete: Cascade)
|
||||||
|
permission SysPermission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([menuId])
|
||||||
|
@@index([permissionId])
|
||||||
|
@@map("sys_menu_permission")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 角色权限关联表
|
||||||
|
model SysRolePermission {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
roleId Int @map("role_id")
|
||||||
|
permissionId Int @map("permission_id")
|
||||||
|
role SysRole @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||||
|
permission SysPermission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([roleId])
|
||||||
|
@@index([permissionId])
|
||||||
|
@@map("sys_role_permission")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统配置
|
||||||
|
model SysConfig {
|
||||||
|
key String @unique
|
||||||
|
value String
|
||||||
|
remark String?
|
||||||
|
|
||||||
|
@@map("sys_config")
|
||||||
|
}
|
||||||
51
server/prisma/seed.ts
Normal file
51
server/prisma/seed.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
enum PermissionType {
|
||||||
|
Menu,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await prisma.sysUser.upsert({
|
||||||
|
where: { account: 'admin' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: '管理员',
|
||||||
|
account: 'admin',
|
||||||
|
password: '$2a$10$rWb52cHK1hrWuQQg3LiOJ.crDfIqjoOIoglGM96a.Ao1DL/dQwKCu',
|
||||||
|
passwordSalt: '$2a$10$rWb52cHK1hrWuQQg3LiOJ.',
|
||||||
|
status: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const menus = [
|
||||||
|
{ name: '用户管理', url: 'user', parentId: 0 },
|
||||||
|
{ name: '角色管理', url: 'role', parentId: 0 },
|
||||||
|
{ name: '菜单管理', url: 'menu', parentId: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const menu of menus) {
|
||||||
|
// 创建菜单
|
||||||
|
await prisma.$transaction(async (_prisma) => {
|
||||||
|
const { id: menuId } = await _prisma.sysMenu.create({ data: menu });
|
||||||
|
// 创建菜单权限
|
||||||
|
const { id: permissionId } = await _prisma.sysPermission.create({
|
||||||
|
data: { type: PermissionType.Menu },
|
||||||
|
});
|
||||||
|
// 关联权限和菜单
|
||||||
|
await _prisma.sysMenuPermission.create({
|
||||||
|
data: { menuId, permissionId },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(async () => {
|
||||||
|
console.log('seed done!');
|
||||||
|
await prisma.$disconnect();
|
||||||
|
})
|
||||||
|
.catch(async (e) => {
|
||||||
|
console.error(e);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
45
server/src/app.module.ts
Normal file
45
server/src/app.module.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import configuration from './config/configuration';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from './common/guard/auth.guard';
|
||||||
|
import { PermissionGuard } from '@/common/guard/permission.guard';
|
||||||
|
import { SystemModule } from './system/system.module';
|
||||||
|
import { PrismaModule } from '@/prisma/prisma.module';
|
||||||
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
|
import { redisStore } from 'cache-manager-redis-yet';
|
||||||
|
import { MinioService } from './minio/minio.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
load: [configuration],
|
||||||
|
}),
|
||||||
|
PrismaModule,
|
||||||
|
// 系统模块
|
||||||
|
SystemModule,
|
||||||
|
// https://github.com/dabroek/node-cache-manager-redis-store/issues/40
|
||||||
|
CacheModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
isGlobal: true,
|
||||||
|
useFactory: async (config: ConfigService) => ({
|
||||||
|
store: await redisStore({
|
||||||
|
ttl: config.get('redis.ttl'),
|
||||||
|
socket: {
|
||||||
|
host: config.get('redis.host'),
|
||||||
|
port: config.get('redis.port'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [],
|
||||||
|
providers: [
|
||||||
|
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||||
|
{ provide: APP_GUARD, useClass: PermissionGuard },
|
||||||
|
MinioService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
5
server/src/common/decorators/roles.decorator.ts
Normal file
5
server/src/common/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { Role } from '../enum';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user