feat: first commit

This commit is contained in:
2024-10-26 12:10:13 +08:00
parent b244826aa1
commit b064c4cb87
158 changed files with 18888 additions and 1 deletions

2
admin/.env Normal file
View 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
View File

@@ -0,0 +1 @@
node_modules

34
admin/.eslintrc Normal file
View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"jsxSingleQuote": false,
"useTabs": false,
"tabWidth": 2
}

4
admin/.stylelintignore Normal file
View File

@@ -0,0 +1,4 @@
**/*.ts
**/*.tsx
**/*.jsx
**/*.js

17
admin/.stylelintrc Normal file
View 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
View File

BIN
admin/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

18
admin/index.html Normal file
View 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
View 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"
]
}
}

View 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);
}

View File

@@ -0,0 +1,4 @@
export type LoginData = {
account: string,
password: string
}

View File

@@ -0,0 +1,7 @@
import request from '@/utils/request';
/**
* 文件上传
* */
export function uploadFile(data) {
return request.post('/files', data);
}

View File

View 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');
}

View 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);
}

View 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');
}

View 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);
}

View 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);
}

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View 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;

View File

@@ -0,0 +1,8 @@
.footer {
display: flex;
align-items: center;
justify-content: center;
height: 40px;
text-align: center;
color: var(--color-text-2);
}

View 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

View 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);

View 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;

View File

@@ -0,0 +1,8 @@
.icon-button {
font-size: 16px;
border: 1px solid var(--color-border-2);
> svg {
vertical-align: -3px;
}
}

View 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;
}
}

View 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;

View 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>
);
}

View 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;

View 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;

View 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;
}

View 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;
}

View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
export const enum Gender {
MALE,
FEMALE,
UNKNOWN
}
export const GenderDict = ['女', '男', '未知'];

267
admin/src/layout.tsx Normal file
View 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
View 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
View 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'));

View 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;

View 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;

View 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;
}
}

View 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;

View 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;

View 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%);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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;

View 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;
}
}

View 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;

View 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;

View 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;
}
}

View 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;

View 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;

View 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;

View 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;
}
}

View 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;

View 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>
);
}

View 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;

View 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
View 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
View 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
View 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

View 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:[]
}
});
}
});

View 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);
}

View 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);
}

View 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;
};

View 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;

View File

@@ -0,0 +1,5 @@
import store from '@/store';
export default function checkLogin() {
return !!store.getState().token
}

View 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')
);
}

View 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
View 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;
}
})();

View 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,
}),
});

View 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);

View 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;

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

30
admin/tsconfig.json Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff