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