feat: first commit
This commit is contained in:
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" />
|
||||
Reference in New Issue
Block a user