├── .prettierignore ├── .stylelintignore ├── pnpm-workspace.yaml ├── .env ├── .husky └── commit-msg ├── src ├── vite-env.d.ts ├── assets │ ├── fonts │ │ ├── DIN.otf │ │ ├── MetroDF.ttf │ │ ├── YouSheBiaoTiHei.ttf │ │ └── font.less │ ├── images │ │ ├── avatar.png │ │ └── logo.svg │ └── css │ │ ├── theme-color.less │ │ ├── scrollbar.less │ │ ├── default.less │ │ ├── theme.less │ │ ├── antd.less │ │ ├── public.less │ │ └── reset.less ├── components │ ├── Theme │ │ ├── index.module.less │ │ └── index.tsx │ ├── GlobalSearch │ │ ├── index.module.less │ │ ├── index.tsx │ │ └── components │ │ │ └── SearchFooter.tsx │ ├── Buttons │ │ ├── index.ts │ │ └── components │ │ │ ├── BaseBtn.tsx │ │ │ ├── UpdateBtn.tsx │ │ │ └── DeleteBtn.tsx │ ├── Selects │ │ ├── components │ │ │ └── Loading.tsx │ │ ├── index.ts │ │ ├── types.ts │ │ ├── BaseTreeSelect.tsx │ │ ├── BaseSelect.tsx │ │ ├── ApiSelect.tsx │ │ └── ApiTreeSelect.tsx │ ├── Dates │ │ ├── index.ts │ │ └── components │ │ │ ├── BaseTimePicker.tsx │ │ │ ├── BaseDatePicker.tsx │ │ │ ├── BaseTimeRangePicker.tsx │ │ │ └── BaseRangePicker.tsx │ ├── Table │ │ ├── index.less │ │ ├── utils │ │ │ ├── state.ts │ │ │ ├── reducer.ts │ │ │ └── helper.ts │ │ ├── components │ │ │ ├── ResizableTitle.tsx │ │ │ ├── VirtualWrapper.tsx │ │ │ ├── EllipsisText.tsx │ │ │ └── DragContent.tsx │ │ └── hooks │ │ │ └── useFiler.ts │ ├── Business │ │ ├── index.tsx │ │ └── Selects │ │ │ ├── PartnerSelect.tsx │ │ │ └── GameSelect.tsx │ ├── Upload │ │ └── BaseUpload.tsx │ ├── Card │ │ └── BaseCard.tsx │ ├── Github │ │ └── index.tsx │ ├── Content │ │ └── BaseContent.tsx │ ├── Transfer │ │ └── BaseTransfer.tsx │ ├── PasswordStrength │ │ ├── components │ │ │ └── StrengthBar.tsx │ │ └── index.tsx │ ├── Pagination │ │ ├── BasePagination.tsx │ │ └── index.less │ ├── Fullscreen │ │ └── index.tsx │ ├── Bottom │ │ └── SubmitBottom.tsx │ ├── Form │ │ └── components │ │ │ └── LoadingComponent.tsx │ ├── Modal │ │ └── index.less │ ├── Copy │ │ ├── CopyBtn.tsx │ │ └── CopyInput.tsx │ ├── Count │ │ └── index.tsx │ ├── Ellipsis │ │ └── index.tsx │ ├── I18n │ │ └── index.tsx │ └── WangEditor │ │ └── index.tsx ├── router │ ├── utils │ │ ├── config.ts │ │ └── helper.tsx │ ├── components │ │ ├── Router.tsx │ │ └── Guards.tsx │ └── index.tsx ├── servers │ ├── dashboard │ │ └── index.ts │ ├── platform │ │ ├── game.ts │ │ └── partner.ts │ ├── login │ │ └── index.ts │ ├── content │ │ └── article.ts │ └── system │ │ ├── menu.ts │ │ ├── role.ts │ │ └── user.ts ├── stores │ ├── index.ts │ ├── user.ts │ ├── public.ts │ └── menu.ts ├── utils │ ├── permissions.ts │ ├── request.ts │ ├── is.ts │ ├── config.ts │ └── constants.ts ├── locales │ ├── zh │ │ ├── dashboard.ts │ │ ├── systems │ │ │ └── menu.ts │ │ ├── system.ts │ │ ├── content.ts │ │ ├── login.ts │ │ └── public.ts │ ├── en │ │ ├── dashboard.ts │ │ ├── systems │ │ │ └── menu.ts │ │ ├── content.ts │ │ ├── system.ts │ │ ├── login.ts │ │ └── public.ts │ ├── config.ts │ └── utils │ │ └── helper.ts ├── pages │ ├── content │ │ └── article │ │ │ ├── components │ │ │ └── CustomizeInput.tsx │ │ │ └── model.ts │ ├── login │ │ └── model.ts │ ├── all.module.less │ ├── demo │ │ ├── level1 │ │ │ └── level2 │ │ │ │ └── level3.tsx │ │ ├── editor │ │ │ └── index.tsx │ │ ├── [id] │ │ │ └── dynamic │ │ │ │ └── index.tsx │ │ ├── virtualScroll │ │ │ ├── components │ │ │ │ ├── VirtualList.tsx │ │ │ │ └── VirtualTable.tsx │ │ │ └── index.tsx │ │ ├── copy │ │ │ └── index.tsx │ │ └── watermark │ │ │ └── index.tsx │ ├── index.tsx │ ├── dashboard │ │ ├── model.ts │ │ ├── components │ │ │ ├── Bar.tsx │ │ │ ├── Line.tsx │ │ │ └── Block.tsx │ │ └── index.tsx │ ├── 403.tsx │ ├── 404.tsx │ └── system │ │ ├── menu │ │ └── components │ │ │ ├── IconInput.tsx │ │ │ └── StateSwitch.tsx │ │ ├── role │ │ ├── components │ │ │ └── AuthorizeSelect.tsx │ │ └── model.tsx │ │ └── user │ │ └── components │ │ └── PermissionDrawer.tsx ├── menus │ ├── index.ts │ ├── README.md │ └── demo.ts ├── hooks │ ├── useToken.ts │ ├── useTime.ts │ ├── useLogout.ts │ ├── useFullscreen.ts │ ├── useSearchUrlParams.ts │ ├── useKeyStroke.ts │ ├── useClipboard.ts │ ├── useCommonStore.ts │ ├── useEcharts.ts │ └── useWatermark.ts ├── layouts │ ├── components │ │ ├── TabRefresh.tsx │ │ ├── TabMaximize.tsx │ │ ├── DraggableTabNode.tsx │ │ ├── TabOptions.tsx │ │ ├── Nav.tsx │ │ └── ErrorBoundary.tsx │ ├── index.module.less │ └── utils │ │ └── helper.ts └── main.tsx ├── packages ├── utils │ ├── src │ │ ├── index.ts │ │ ├── crypto.ts │ │ └── local.ts │ ├── package.json │ └── tsconfig.json ├── message │ ├── package.json │ ├── tsconfig.json │ └── src │ │ └── index.ts ├── request │ ├── package.json │ ├── tsconfig.json │ └── src │ │ ├── types.ts │ │ └── index.ts └── stylelintConfig │ ├── README.md │ ├── package.json │ └── index.mjs ├── stylelint.config.mjs ├── .env.production ├── .env.test ├── tsconfig.node.json ├── public ├── upgrade.css ├── logo.svg └── loading.css ├── .vscode ├── extensions.json └── settings.json ├── .env.development ├── .prettierrc ├── .gitignore ├── .commitlintrc.json ├── tsconfig.json ├── index.html ├── vite.config.ts ├── LICENSE └── types └── public.ts /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.md 3 | autoImports.d.ts -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | /dist/* 2 | /public/* 3 | public/* 4 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # 加密密钥 2 | VITE_SECRET_KEY = "__Vite_Admin_Secret__" 3 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no-install commitlint --edit $1 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/utils/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './local'; 2 | export * from './crypto'; 3 | -------------------------------------------------------------------------------- /stylelint.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ['@south/stylelint'], 3 | root: true, 4 | }; 5 | -------------------------------------------------------------------------------- /src/assets/fonts/DIN.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/southliu/react-admin/HEAD/src/assets/fonts/DIN.otf -------------------------------------------------------------------------------- /src/assets/fonts/MetroDF.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/southliu/react-admin/HEAD/src/assets/fonts/MetroDF.ttf -------------------------------------------------------------------------------- /src/assets/images/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/southliu/react-admin/HEAD/src/assets/images/avatar.png -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | VITE_ENV = "production" 2 | 3 | VITE_BASE_URL = "https://mock.mengxuegu.com/mock/63f830b1c5a76a117cab185e/v1" -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | VITE_ENV = "test" 2 | 3 | # 测试接口 4 | VITE_BASE_URL = "https://mock.mengxuegu.com/mock/63f830b1c5a76a117cab185e/v1" -------------------------------------------------------------------------------- /src/assets/fonts/YouSheBiaoTiHei.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/southliu/react-admin/HEAD/src/assets/fonts/YouSheBiaoTiHei.ttf -------------------------------------------------------------------------------- /src/components/Theme/index.module.less: -------------------------------------------------------------------------------- 1 | ::view-transition-new(root), 2 | ::view-transition-old(root) { 3 | /*关闭默认动画 */ 4 | animation: none; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/GlobalSearch/index.module.less: -------------------------------------------------------------------------------- 1 | .icon { 2 | box-shadow: 3 | inset 0 -2px #cdcde6, 4 | inset 0 0 1px 1px #fff, 5 | 0 1px 2px 1px #1e235a66; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/Buttons/index.ts: -------------------------------------------------------------------------------- 1 | export { default as BaseBtn } from './components/BaseBtn'; 2 | export { default as UpdateBtn } from './components/UpdateBtn'; 3 | export { default as DeleteBtn } from './components/DeleteBtn'; 4 | -------------------------------------------------------------------------------- /src/router/utils/config.ts: -------------------------------------------------------------------------------- 1 | // 生成路由排除内容,不带后缀名转换成“/文件名/”格式 2 | export const ROUTER_EXCLUDE = [ 3 | 'login', 4 | 'forget', 5 | 'components', 6 | 'utils', 7 | 'lib', 8 | 'hooks', 9 | 'model.tsx', 10 | '404.tsx', 11 | ]; 12 | -------------------------------------------------------------------------------- /src/servers/dashboard/index.ts: -------------------------------------------------------------------------------- 1 | import { request } from '@/utils/request'; 2 | 3 | /** 4 | * 获取数据总览数据 5 | * @param data - 请求数据 6 | */ 7 | export function getDataTrends(data: object) { 8 | return request.get('/dashboard', { params: data }); 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Bundler", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts", "build/**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /public/upgrade.css: -------------------------------------------------------------------------------- 1 | .upgrade h1 { 2 | font-size: 48px; 3 | margin-bottom: 20px; 4 | } 5 | .upgrade p { 6 | font-size: 24px; 7 | margin-bottom: 40px; 8 | } 9 | .upgrade a { 10 | color: #0077cc; 11 | text-decoration: none; 12 | font-weight: bold; 13 | } 14 | -------------------------------------------------------------------------------- /packages/message/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@south/message", 3 | "version": "0.0.1", 4 | "exports": { 5 | ".": "./src/index.ts" 6 | }, 7 | "typesVersions": { 8 | "*": { 9 | "*": [ 10 | "./src/*" 11 | ] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/request/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@south/request", 3 | "version": "0.0.1", 4 | "exports": { 5 | ".": "./src/index.ts" 6 | }, 7 | "typesVersions": { 8 | "*": { 9 | "*": [ 10 | "./src/*" 11 | ] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/stores/index.ts: -------------------------------------------------------------------------------- 1 | import { useTabsStore } from '@/stores/tabs'; 2 | import { useUserStore } from '@/stores/user'; 3 | import { usePublicStore } from './public'; 4 | import { useMenuStore } from './menu'; 5 | 6 | export { useTabsStore, useUserStore, usePublicStore, useMenuStore }; 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "antfu.iconify", 4 | "voorjaar.windicss-intellisense", 5 | "streetsidesoftware.code-spell-checker", 6 | "lokalise.i18n-ally", 7 | "esbenp.prettier-vscode", 8 | "usernamehw.errorlens" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | VITE_ENV = "development" 2 | 3 | # 端口号 4 | VITE_SERVER_PORT = 7000 5 | 6 | # 跨域 7 | VITE_PROXY = [["/api", "https://mock.mengxuegu.com/mock/63f830b1c5a76a117cab185e/v1"], ["/test", "https://www.baidu.com"]] 8 | 9 | # VITE_PROXY = [["/api", "http://127.0.0.1:8000/"]] 10 | -------------------------------------------------------------------------------- /packages/stylelintConfig/README.md: -------------------------------------------------------------------------------- 1 | ## 💻 安装使用 2 | 3 | - 安装依赖 4 | ```bash 5 | pnpm install @south/stylelint stylelint -w 6 | ``` 7 | 8 | - 配置文件 9 | 根目录创建`stylelint.config.mjs`文件: 10 | ```ts 11 | export default { 12 | extends: ['@south/stylelint'], 13 | root: true, 14 | }; 15 | ``` 16 | -------------------------------------------------------------------------------- /src/assets/fonts/font.less: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: YouSheBiaoTiHei; 3 | src: url('./YouSheBiaoTiHei.ttf'); 4 | } 5 | 6 | @font-face { 7 | font-family: MetroDF; 8 | src: url('./MetroDF.ttf'); 9 | } 10 | 11 | @font-face { 12 | font-family: DIN; 13 | src: url('./DIN.Otf'); 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/permissions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 检测是否有权限 3 | * @param value - 检测值 4 | * @param permissions - 权限 5 | */ 6 | export const checkPermission = (value: string, permissions: string[]): boolean => { 7 | if (!permissions || permissions.length === 0) return false; 8 | return permissions.includes(value); 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/Selects/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { Spin } from 'antd'; 2 | 3 | function Loading() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | 11 | export default Loading; 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "bracketSpacing": true, 5 | "trailingComma": "all", 6 | "printWidth": 100, 7 | "tabWidth": 2, 8 | "useTabs": false, 9 | "endOfLine": "lf", 10 | "jsxBracketSameLine": false, 11 | "jsxSingleAttributePerLine": true, 12 | "arrowParens": "always" 13 | } 14 | -------------------------------------------------------------------------------- /src/locales/zh/dashboard.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | title: '数据展览', 3 | rechargeRankingDay: '当日充值排行', 4 | rechargeAmount: '充值数', 5 | usersNumber: '用户数', 6 | orderNumber: '订单数', 7 | gameNumber: '游戏数', 8 | gameID: '游戏ID', 9 | effectiveRechargeRatio: '有效充值占比', 10 | cooperativeCompany: '合作公司', 11 | fullServerRecharge: '全服充值', 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/Dates/index.ts: -------------------------------------------------------------------------------- 1 | import BaseDatePicker from './components/BaseDatePicker'; 2 | import BaseRangePicker from './components/BaseRangePicker'; 3 | import BaseTimePicker from './components/BaseTimePicker'; 4 | import BaseTimeRangePicker from './components/BaseTimeRangePicker'; 5 | 6 | export * from './utils/helper'; 7 | export { BaseDatePicker, BaseRangePicker, BaseTimePicker, BaseTimeRangePicker }; 8 | -------------------------------------------------------------------------------- /src/locales/zh/systems/menu.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | label: '中文菜单', 3 | labelEn: '英文菜单', 4 | icon: '图标', 5 | router: '路由', 6 | sort: '排序', 7 | rule: '权限标识', 8 | catalog: '目录', 9 | menu: '菜单', 10 | button: '按钮', 11 | parentMenu: '上级菜单', 12 | addChildMenu: '新增下级', 13 | helpIcon: '点击问号可跳转查询icon,查询完将icon name值传入输入框中', 14 | changeState: '切换状态', 15 | changeStateMsg: '是否将{{name}}改为【{{state}}】状态?', 16 | }; 17 | -------------------------------------------------------------------------------- /src/servers/platform/game.ts: -------------------------------------------------------------------------------- 1 | import { request } from '@/utils/request'; 2 | 3 | enum API { 4 | COMMON_URL = '/authority/common', 5 | } 6 | 7 | interface Result { 8 | id: string; 9 | name: string; 10 | children?: Result[]; 11 | } 12 | 13 | /** 14 | * 获取游戏数据 15 | * @param data - 请求数据 16 | */ 17 | export function getGames(data?: unknown) { 18 | return request.get(`${API.COMMON_URL}/games`, { params: data }); 19 | } 20 | -------------------------------------------------------------------------------- /packages/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@south/utils", 3 | "version": "0.0.1", 4 | "exports": { 5 | ".": "./src/index.ts" 6 | }, 7 | "typesVersions": { 8 | "*": { 9 | "*": [ 10 | "./src/*" 11 | ] 12 | } 13 | }, 14 | "dependencies": { 15 | "@south/message": "workspace:^", 16 | "crypto-js": "^4.2.0" 17 | }, 18 | "devDependencies": { 19 | "@types/crypto-js": "^4.2.2" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Selects/index.ts: -------------------------------------------------------------------------------- 1 | import BaseSelect from './BaseSelect'; 2 | import BaseTreeSelect from './BaseTreeSelect'; 3 | import ApiSelect from './ApiSelect'; 4 | import ApiTreeSelect from './ApiTreeSelect'; 5 | import ApiPageSelect from './ApiPageSelect'; 6 | 7 | export const MAX_TAG_COUNT = 'responsive'; // 最多显示多少个标签,responsive:自适应 8 | 9 | export { BaseSelect, BaseTreeSelect, ApiSelect, ApiTreeSelect, ApiPageSelect }; 10 | export type * from './types'; 11 | -------------------------------------------------------------------------------- /src/components/Table/index.less: -------------------------------------------------------------------------------- 1 | .react-resizable { 2 | position: relative; 3 | background-clip: padding-box; 4 | } 5 | 6 | .react-resizable-handle { 7 | position: absolute; 8 | right: 0; 9 | bottom: 0; 10 | z-index: 1; 11 | width: 10px; 12 | height: 100%; 13 | cursor: col-resize; 14 | } 15 | 16 | .ant-table-body, 17 | .ant-table-container { 18 | overflow: auto !important; 19 | scrollbar-color: auto; 20 | scrollbar-width: auto; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Business/index.tsx: -------------------------------------------------------------------------------- 1 | import { addComponent } from '../Form/utils/componentMap'; 2 | 3 | // 自定义组件名 4 | export type BusinessComponents = 'GameSelect' | 'PartnerSelect'; 5 | 6 | /** 组件注入 */ 7 | export function CreateBusiness() { 8 | addComponent( 9 | 'GameSelect', 10 | lazy(() => import('./Selects/GameSelect')), 11 | ); 12 | addComponent( 13 | 'PartnerSelect', 14 | lazy(() => import('./Selects/PartnerSelect')), 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/locales/en/dashboard.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | title: 'Dashboard', 3 | rechargeRankingDay: 'Recharge ranking of the day', 4 | rechargeAmount: 'Recharge amount', 5 | usersNumber: 'Number of users', 6 | orderNumber: 'Number of order', 7 | gameNumber: 'Number of games', 8 | gameID: 'game ID', 9 | effectiveRechargeRatio: 'Effective recharge ratio', 10 | cooperativeCompany: 'Cooperative company', 11 | fullServerRecharge: 'Full server recharge', 12 | }; 13 | -------------------------------------------------------------------------------- /src/assets/css/theme-color.less: -------------------------------------------------------------------------------- 1 | @import url('./default.less'); 2 | @import url('./theme.less'); 3 | 4 | // 默认 5 | .theme-primary { 6 | .changeTheme( 7 | @primary-color, 8 | @primary-bg, 9 | @layout-content-bg, 10 | @content-bg, 11 | @svg-color 12 | ); 13 | } 14 | 15 | // 暗黑主题 16 | .theme-dark { 17 | .changeTheme( 18 | @dark-color, 19 | @dark-bg, 20 | @dark-layout-content-bg, 21 | @dark-content-bg, 22 | @dark-svg-color 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Dates/components/BaseTimePicker.tsx: -------------------------------------------------------------------------------- 1 | import type { TimePickerProps } from 'antd'; 2 | import { TimePicker } from 'antd'; 3 | import { string2Dayjs } from '../utils/helper'; 4 | 5 | function BaseTimePicker(props: TimePickerProps) { 6 | const { value } = props; 7 | const params = { ...props }; 8 | 9 | // 如果值不是dayjs类型则进行转换 10 | if (value) params.value = string2Dayjs(value); 11 | 12 | return ; 13 | } 14 | 15 | export default BaseTimePicker; 16 | -------------------------------------------------------------------------------- /src/components/Upload/BaseUpload.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Upload, type UploadProps } from 'antd'; 2 | 3 | function BaseUpload(props: UploadProps) { 4 | const { t } = useTranslation(); 5 | const [getToken] = useToken(); 6 | const token = getToken(); 7 | 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | 15 | export default BaseUpload; 16 | -------------------------------------------------------------------------------- /src/pages/content/article/components/CustomizeInput.tsx: -------------------------------------------------------------------------------- 1 | import type { InputProps } from 'antd'; 2 | import { Input } from 'antd'; 3 | 4 | /** 5 | * 自定义输入 6 | */ 7 | function CustomizeInput(props: InputProps) { 8 | const { t } = useTranslation(); 9 | 10 | return ( 11 | <> 12 | 13 |
{t('content.sensitiveInfo')}
14 | 15 | ); 16 | } 17 | 18 | export default CustomizeInput; 19 | -------------------------------------------------------------------------------- /src/pages/login/model.ts: -------------------------------------------------------------------------------- 1 | // 接口传入数据 2 | export interface LoginData { 3 | username: string; 4 | password: string; 5 | } 6 | 7 | // 用户数据 8 | interface User { 9 | id: number; 10 | username: string; 11 | phone: string; 12 | email: string; 13 | roles: number[]; 14 | } 15 | 16 | // 用户权限数据 17 | interface Roles { 18 | id: string; 19 | } 20 | 21 | // 接口返回数据 22 | export interface LoginResult { 23 | token: string; 24 | user: User; 25 | permissions: string[]; 26 | roles: Roles[]; 27 | } 28 | -------------------------------------------------------------------------------- /src/locales/zh/system.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | menuTitle: '菜单管理', 3 | userTitle: '用户管理', 4 | permissions: '权限', 5 | authorizationSuccessful: '授权成功', 6 | state: '状态', 7 | module: '模块', 8 | permissionButton: '权限按钮', 9 | age: '年龄', 10 | role: '角色', 11 | phone: '手机', 12 | email: '邮箱', 13 | rightsProfile: '权限配置', 14 | create: '创建', 15 | update: '更新', 16 | delete: '删除', 17 | detail: '详情', 18 | export: '导出', 19 | status: '状态', 20 | description: '描述', 21 | authorize: '授权', 22 | }; 23 | -------------------------------------------------------------------------------- /src/assets/css/scrollbar.less: -------------------------------------------------------------------------------- 1 | /* 修改滚动条样式 */ 2 | ::-webkit-scrollbar { 3 | width: 8px; 4 | height: 8px; 5 | background: hsl(0deg 0% 70% / 10%); 6 | } 7 | 8 | ::-webkit-scrollbar-thumb { 9 | background: transparent; 10 | border-radius: 4px; 11 | } 12 | 13 | :hover::-webkit-scrollbar-thumb { 14 | background: hsl(0deg 0% 53% / 40%); 15 | } 16 | 17 | :hover::-webkit-scrollbar-track { 18 | background: hsl(0deg 0% 53% / 10%); 19 | } 20 | 21 | ::-webkit-scrollbar-corner { 22 | background: rgb(0 0 0 / 0%); 23 | } 24 | -------------------------------------------------------------------------------- /src/assets/css/default.less: -------------------------------------------------------------------------------- 1 | @layout-top: 4.8rem; 2 | 3 | @layout-left: 15rem; 4 | 5 | @layout-left-close: 5rem; 6 | 7 | @bg: #f6f9f8; 8 | 9 | // 默认颜色 10 | @primary-bg: #fff; 11 | @content-bg: #fff; 12 | 13 | @layout-content-bg: #f6f9f8; 14 | 15 | @primary-color: rgba(0, 0, 0, 0.85); 16 | 17 | @svg-color: #00000073; 18 | 19 | // 黑暗主题 20 | @dark-bg: #18181c; 21 | @dark-content-bg: #18181c; 22 | 23 | @dark-layout-content-bg: #000; 24 | 25 | @dark-color: rgb(153, 153, 153); 26 | 27 | @dark-svg-color: rgb(153, 153, 153); 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | package-lock.json 10 | pnpm-lock.yaml 11 | yarn.lock 12 | 13 | node_modules 14 | dist 15 | dist-ssr 16 | *.local 17 | 18 | # Editor directories and files 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | vite.config.ts.*.mjs 28 | __unconfig_vite.config.ts 29 | 30 | # 测试覆盖率 31 | coverage/ 32 | 33 | stats.html 34 | .vite 35 | types/autoImports.d.ts -------------------------------------------------------------------------------- /src/components/Business/Selects/PartnerSelect.tsx: -------------------------------------------------------------------------------- 1 | import type { SelectProps } from 'antd'; 2 | import { getPartner } from '@/servers/platform/partner'; 3 | import { ApiSelect } from '@/components/Selects'; 4 | 5 | /** 6 | * @description: 合作公司下拉组件 7 | */ 8 | function PartnerSelect(props: SelectProps) { 9 | return ( 10 | 16 | ); 17 | } 18 | 19 | export default PartnerSelect; 20 | -------------------------------------------------------------------------------- /src/components/Dates/components/BaseDatePicker.tsx: -------------------------------------------------------------------------------- 1 | import type { Dayjs } from 'dayjs'; 2 | import type { DatePickerProps } from 'antd'; 3 | import { DatePicker } from 'antd'; 4 | import { string2Dayjs } from '../utils/helper'; 5 | 6 | function BaseDatePicker(props: DatePickerProps) { 7 | const { value } = props; 8 | const params = { ...props }; 9 | 10 | // 如果值不是dayjs类型则进行转换 11 | if (value) params.value = string2Dayjs(value as Dayjs); 12 | 13 | return ; 14 | } 15 | 16 | export default BaseDatePicker; 17 | -------------------------------------------------------------------------------- /src/locales/zh/content.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | articleTitle: '文章管理', 3 | contentTitle: '内容管理', 4 | clipboard: '剪切板', 5 | clipboardMessage: '将“admin”传入复制按钮中', 6 | richText: '富文本', 7 | threeTierStructure: '三层结构', 8 | virtualScroll: '虚拟滚动', 9 | virtualScroll1: '虚拟滚动列表(10000条)', 10 | virtualScroll2: '虚拟滚动表格(10000条)', 11 | watermark: '水印', 12 | openWatermark: '打开水印', 13 | hideWatermark: '隐藏水印', 14 | nestedData: '嵌套数据', 15 | sensitiveInfo: '注:标题不能含有敏感信息!', 16 | creator: '创建者', 17 | updater: '更新者', 18 | author: '作者', 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/Business/Selects/GameSelect.tsx: -------------------------------------------------------------------------------- 1 | import type { SelectProps } from 'antd'; 2 | import { getGames } from '@/servers/platform/game'; 3 | import { ApiSelect } from '@/components/Selects'; 4 | 5 | /** 6 | * @description: 游戏下拉组件 7 | */ 8 | function GameSelect(props: SelectProps) { 9 | return ( 10 | <> 11 | 17 | 18 | ); 19 | } 20 | 21 | export default GameSelect; 22 | -------------------------------------------------------------------------------- /src/menus/index.ts: -------------------------------------------------------------------------------- 1 | import type { SideMenu } from '#/public'; 2 | import { demo } from './demo'; 3 | 4 | /** 5 | * 弃用,改为动态菜单获取,如果需要静态菜单将/src/hooks/useCommonStore.ts中的useCommonStore中的menuList改为defaultMenus 6 | * import { defaultMenus } from '@/menus'; 7 | * // 菜单数据 8 | * const menuList = defaultMenus; 9 | */ 10 | export const defaultMenus: SideMenu[] = [ 11 | { 12 | label: '仪表盘', 13 | labelEn: 'Dashboard', 14 | icon: 'la:tachometer-alt', 15 | key: '/dashboard', 16 | rule: '/dashboard', 17 | }, 18 | ...(demo as SideMenu[]), 19 | ]; 20 | -------------------------------------------------------------------------------- /src/components/Dates/components/BaseTimeRangePicker.tsx: -------------------------------------------------------------------------------- 1 | import type { TimeRangePickerProps } from 'antd'; 2 | import { TimePicker } from 'antd'; 3 | import { stringRang2DayjsRang } from '../utils/helper'; 4 | 5 | const { RangePicker } = TimePicker; 6 | 7 | function BaseTimePicker(props: TimeRangePickerProps) { 8 | const { value } = props; 9 | const params = { ...props }; 10 | 11 | // 如果值不是dayjs类型则进行转换 12 | if (value) params.value = stringRang2DayjsRang(value); 13 | 14 | return ; 15 | } 16 | 17 | export default BaseTimePicker; 18 | -------------------------------------------------------------------------------- /src/components/Dates/components/BaseRangePicker.tsx: -------------------------------------------------------------------------------- 1 | import { DatePicker } from 'antd'; 2 | import type { RangePickerProps } from 'antd/es/date-picker'; 3 | import { stringRang2DayjsRang } from '../utils/helper'; 4 | 5 | const { RangePicker } = DatePicker; 6 | 7 | function BaseRangePicker(props: RangePickerProps) { 8 | const { value } = props; 9 | const params = { ...props }; 10 | 11 | // 如果值不是dayjs类型则进行转换 12 | if (value) params.value = stringRang2DayjsRang(value); 13 | 14 | return ; 15 | } 16 | 17 | export default BaseRangePicker; 18 | -------------------------------------------------------------------------------- /src/components/Table/utils/state.ts: -------------------------------------------------------------------------------- 1 | import type { Dispatch } from 'react'; 2 | import { createContext } from 'react'; 3 | import { TableAction } from './reducer'; 4 | 5 | interface ScrollContextProps { 6 | dispatch?: Dispatch; 7 | renderLen: number; 8 | start: number; 9 | offsetStart: number; 10 | rowHeight: number; 11 | totalLen: number; 12 | } 13 | 14 | export const ScrollContext = createContext({ 15 | dispatch: undefined, 16 | renderLen: 1, 17 | start: 0, 18 | offsetStart: 0, 19 | rowHeight: 46, 20 | totalLen: 0, 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/Card/BaseCard.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes } from 'react'; 2 | 3 | function BaseCard(props: HTMLAttributes) { 4 | const { children, className } = props; 5 | 6 | return ( 7 |
22 | {children} 23 |
24 | ); 25 | } 26 | 27 | export default BaseCard; 28 | -------------------------------------------------------------------------------- /src/locales/en/systems/menu.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | label: 'Label', 3 | labelEn: 'LabelEn', 4 | icon: 'Icon', 5 | router: 'Router', 6 | sort: 'Sort', 7 | rule: 'Rule', 8 | catalog: 'Catalog', 9 | menu: 'Menu', 10 | button: 'Button', 11 | parentMenu: 'Parent Menu', 12 | addChildMenu: 'Add a new level', 13 | helpIcon: 14 | 'Click the question mark to jump to the icon query, and after the query, pass the icon name value into the input box', 15 | changeState: 'Switch state', 16 | changeStateMsg: 'Do you want to change {{name}} to [{{state}}] state?', 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/Github/index.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip } from 'antd'; 2 | import { Icon } from '@iconify/react'; 3 | 4 | function Github() { 5 | /** 跳转Github */ 6 | const goGithub = () => { 7 | window.open('https://github.com/southliu/react-admin'); 8 | }; 9 | 10 | return ( 11 | 12 |
13 | 17 |
18 |
19 | ); 20 | } 21 | 22 | export default Github; 23 | -------------------------------------------------------------------------------- /src/servers/platform/partner.ts: -------------------------------------------------------------------------------- 1 | import { request } from '@/utils/request'; 2 | 3 | enum API { 4 | URL = '/platform/partner', 5 | } 6 | 7 | interface Result { 8 | id: string; 9 | name: string; 10 | } 11 | 12 | /** 13 | * 获取公司数据 14 | * @param data - 请求数据 15 | */ 16 | export function getPartner(data?: unknown) { 17 | return request.get(API.URL, { params: data }); 18 | } 19 | 20 | /** 21 | * 获取公司数据-展示用的接口 22 | * @param data - 请求数据 23 | */ 24 | export function getPartnerDemo(url: string, data?: unknown) { 25 | return request.get(url, { params: data }); 26 | } 27 | -------------------------------------------------------------------------------- /packages/utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "jsx": "preserve", 5 | "lib": ["DOM", "ESNext"], 6 | "baseUrl": ".", 7 | "module": "ESNext", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "types": ["node"], 11 | "strict": true, 12 | "strictNullChecks": true, 13 | "noUnusedLocals": true, 14 | "allowSyntheticDefaultImports": true, 15 | "esModuleInterop": true, 16 | "forceConsistentCasingInFileNames": true 17 | }, 18 | "include": ["src/**/*"], 19 | "exclude": ["node_modules", "dist"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/message/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "jsx": "preserve", 5 | "lib": ["DOM", "ESNext"], 6 | "baseUrl": ".", 7 | "module": "ESNext", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "types": ["node"], 11 | "strict": true, 12 | "strictNullChecks": true, 13 | "noUnusedLocals": true, 14 | "allowSyntheticDefaultImports": true, 15 | "esModuleInterop": true, 16 | "forceConsistentCasingInFileNames": true 17 | }, 18 | "include": ["src/**/*"], 19 | "exclude": ["node_modules", "dist"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/request/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "jsx": "preserve", 5 | "lib": ["DOM", "ESNext"], 6 | "baseUrl": ".", 7 | "module": "ESNext", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "types": ["node"], 11 | "strict": true, 12 | "strictNullChecks": true, 13 | "noUnusedLocals": true, 14 | "allowSyntheticDefaultImports": true, 15 | "esModuleInterop": true, 16 | "forceConsistentCasingInFileNames": true 17 | }, 18 | "include": ["src/**/*"], 19 | "exclude": ["node_modules", "dist"] 20 | } 21 | -------------------------------------------------------------------------------- /src/pages/all.module.less: -------------------------------------------------------------------------------- 1 | .animation { 2 | animation: shake 0.6s ease-in-out infinite alternate; 3 | } 4 | 5 | @keyframes shake { 6 | 0% { 7 | transform: translate(-1px); 8 | } 9 | 10 | 10% { 11 | transform: translate(2px, 1px); 12 | } 13 | 14 | 30% { 15 | transform: translate(-3px, 2px); 16 | } 17 | 18 | 35% { 19 | filter: blur(4px); 20 | transform: translate(2px, -3px); 21 | } 22 | 23 | 45% { 24 | filter: blur(0); 25 | transform: translate(2px, 2px) skewY(-8deg) scaleX(0.96); 26 | } 27 | 28 | 50% { 29 | transform: translate(-3px, 1px); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/locales/zh/login.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | login: '登录', 3 | systemLogin: '系统登录', 4 | oldPassword: '旧密码', 5 | newPassword: '新密码', 6 | password: '密码', 7 | username: '用户名', 8 | rememberMe: '记住我', 9 | phoneNumber: '手机号码', 10 | verificationCode: '验证码', 11 | getVerificationCode: '获取验证码', 12 | reacquire: '重新获取({{time}})', 13 | phoneNumberError: '请输入有效的11位手机号码', 14 | resetPassword: '重置密码', 15 | verificationPassed: '验证通过', 16 | forgetPassword: '忘记密码?', 17 | confirmPassword: '确认密码', 18 | confirmPasswordMessage: '密码和确认密码不相同!', 19 | notPermissions: '用户暂无权限登录', 20 | passwordRuleMessage: '密码为6-30位必须包含字母和数字!', 21 | }; 22 | -------------------------------------------------------------------------------- /src/hooks/useToken.ts: -------------------------------------------------------------------------------- 1 | import { setLocalInfo, getLocalInfo, removeLocalInfo } from '@south/utils'; 2 | import { TOKEN } from '@/utils/config'; 3 | 4 | /** 5 | * token存取方法 6 | */ 7 | export function useToken() { 8 | /** 获取token */ 9 | const getToken = () => { 10 | return getLocalInfo(TOKEN) || ''; 11 | }; 12 | 13 | /** 14 | * 设置token 15 | * @param value - token值 16 | */ 17 | const setToken = (value: string) => { 18 | setLocalInfo(TOKEN, value); 19 | }; 20 | 21 | /** 删除token */ 22 | const removeToken = () => { 23 | removeLocalInfo(TOKEN); 24 | }; 25 | 26 | return [getToken, setToken, removeToken] as const; 27 | } 28 | -------------------------------------------------------------------------------- /src/pages/demo/level1/level2/level3.tsx: -------------------------------------------------------------------------------- 1 | import BaseContent from '@/components/Content/BaseContent'; 2 | import { useCommonStore } from '@/hooks/useCommonStore'; 3 | import { checkPermission } from '@/utils/permissions'; 4 | import { useTranslation } from 'react-i18next'; 5 | 6 | function Page() { 7 | const { t } = useTranslation(); 8 | const { permissions } = useCommonStore(); 9 | const isPermission = checkPermission('/demo/level', permissions); 10 | 11 | return ( 12 | 13 |
{t('content.threeTierStructure')}
14 |
15 | ); 16 | } 17 | 18 | export default Page; 19 | -------------------------------------------------------------------------------- /src/servers/login/index.ts: -------------------------------------------------------------------------------- 1 | import type { LoginData, LoginResult } from '@/pages/login/model'; 2 | import { request } from '@/utils/request'; 3 | 4 | /** 5 | * 登录 6 | * @param data - 请求数据 7 | */ 8 | export function login(data: LoginData) { 9 | return request.post('/system/user/login', data); 10 | } 11 | 12 | /** 13 | * 修改密码 14 | * @param data - 请求数据 15 | */ 16 | export function updatePassword(data: object) { 17 | return request.post('/system/user/updatePassword', data); 18 | } 19 | 20 | /** 21 | * 忘记密码 22 | * @param data - 请求数据 23 | */ 24 | export function forgetPassword(data: object) { 25 | return request.post('/system/user/forgetPassword', data); 26 | } 27 | -------------------------------------------------------------------------------- /src/hooks/useTime.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef } from 'react'; 2 | import dayjs from 'dayjs'; 3 | 4 | /** 5 | * @description 获取本地时间 6 | */ 7 | export const useTimes = () => { 8 | const timer = useRef(null); 9 | const [time, setTime] = useState(dayjs().format('YYYY年MM月DD日 HH:mm:ss')); 10 | useEffect(() => { 11 | timer.current = setInterval(() => { 12 | setTime(dayjs().format('YYYY年MM月DD日 HH:mm:ss')); 13 | }, 1000); 14 | return () => { 15 | if (timer.current) { 16 | clearInterval(timer.current as NodeJS.Timeout); 17 | timer.current = null; 18 | } 19 | }; 20 | }, [time]); 21 | 22 | return { 23 | time, 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/Content/BaseContent.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | import Forbidden from '@/pages/403'; 3 | 4 | interface Props { 5 | isPermission?: boolean; 6 | children: ReactNode; 7 | } 8 | 9 | function BaseContent(props: Props) { 10 | const { isPermission, children } = props; 11 | 12 | return ( 13 | <> 14 | {isPermission !== false && ( 15 |
16 | {children} 17 |
18 | )} 19 | {isPermission === false && ( 20 |
21 | 22 |
23 | )} 24 | 25 | ); 26 | } 27 | 28 | export default BaseContent; 29 | -------------------------------------------------------------------------------- /src/utils/request.ts: -------------------------------------------------------------------------------- 1 | import { TOKEN } from '@/utils/config'; 2 | import { creteRequest } from '@south/request'; 3 | 4 | // 生成环境所用的接口 5 | const prefixUrl = import.meta.env.VITE_BASE_URL as string; 6 | const baseURL = process.env.NODE_ENV !== 'development' ? prefixUrl : '/api'; 7 | 8 | // 请求配置 9 | export const request = creteRequest(baseURL, TOKEN); 10 | 11 | // 创建多个请求 12 | // export const newRequest = creteRequest('/test', TOKEN); 13 | 14 | /** 15 | * 取消请求 16 | * @param url - 链接 17 | */ 18 | export const cancelRequest = (url: string | string[]) => { 19 | return request.cancelRequest(url); 20 | }; 21 | 22 | /** 取消全部请求 */ 23 | export const cancelAllRequest = () => { 24 | return request.cancelAllRequest(); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/stylelintConfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@south/stylelint", 3 | "version": "0.0.1", 4 | "exports": { 5 | "import": "./index.mjs", 6 | "default": "./index.mjs" 7 | }, 8 | "devDependencies": { 9 | "@stylistic/stylelint-plugin": "^3.1.0", 10 | "postcss": "^8.4.38", 11 | "postcss-html": "^1.6.0", 12 | "postcss-less": "^6.0.0", 13 | "prettier": "^3.3.3", 14 | "stylelint-config-recess-order": "^5.1.1", 15 | "stylelint-config-recommended": "^14.0.0", 16 | "stylelint-config-recommended-less": "^3.0.1", 17 | "stylelint-config-standard": "^36.0.0", 18 | "stylelint-less": "^3.0.1", 19 | "stylelint-order": "^6.0.4", 20 | "stylelint-prettier": "^5.0.2" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/locales/en/content.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | articleTitle: 'Article Management', 3 | contentTitle: 'Content Management', 4 | clipboard: 'Clipboard', 5 | clipboardMessage: 'Pass "admin" into the copy button', 6 | richText: 'Rich Text', 7 | threeTierStructure: 'Three-tier structure', 8 | virtualScroll: 'Virtual Scroll', 9 | virtualScroll1: 'virtual scrolling list (10000)', 10 | virtualScroll2: 'virtual scrolling table (10000)', 11 | watermark: 'Watermark', 12 | openWatermark: 'Open watermark', 13 | hideWatermark: 'Hide watermark', 14 | nestedData: 'Nested data', 15 | sensitiveInfo: 'Note: The title cannot contain sensitive information!', 16 | creator: 'Creator', 17 | updater: 'Updater', 18 | author: 'Author', 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/Buttons/components/BaseBtn.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | import type { ButtonProps } from 'antd'; 3 | import { Button } from 'antd'; 4 | 5 | interface Props extends ButtonProps { 6 | isLoading?: boolean; 7 | children?: ReactNode; 8 | } 9 | 10 | function BaseBtn(props: Props) { 11 | const { isLoading, loading, children, className } = props; 12 | 13 | // 清除自定义属性 14 | const params: Partial = { ...props }; 15 | delete params.isLoading; 16 | 17 | return ( 18 | 26 | ); 27 | } 28 | 29 | export default BaseBtn; 30 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"], 3 | "rules": { 4 | "body-leading-blank": [2, "always"], 5 | "footer-leading-blank": [1, "always"], 6 | "header-max-length": [2, "always", 108], 7 | "subject-empty": [2, "never"], 8 | "type-empty": [2, "never"], 9 | "subject-case": [0], 10 | "type-enum": [ 11 | 2, 12 | "always", 13 | [ 14 | "feat", 15 | "fix", 16 | "perf", 17 | "style", 18 | "docs", 19 | "test", 20 | "refactor", 21 | "build", 22 | "ci", 23 | "chore", 24 | "revert", 25 | "wip", 26 | "workflow", 27 | "types", 28 | "release" 29 | ] 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/Buttons/components/UpdateBtn.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonProps } from 'antd'; 2 | import { Button } from 'antd'; 3 | import { useTranslation } from 'react-i18next'; 4 | 5 | interface Props extends ButtonProps { 6 | isLoading?: boolean; 7 | } 8 | 9 | function UpdateBtn(props: Props) { 10 | const { isLoading, loading, className } = props; 11 | const { t } = useTranslation(); 12 | 13 | // 清除自定义属性 14 | const params: Partial = { ...props }; 15 | delete params.isLoading; 16 | 17 | return ( 18 | 26 | ); 27 | } 28 | 29 | export default UpdateBtn; 30 | -------------------------------------------------------------------------------- /src/hooks/useLogout.ts: -------------------------------------------------------------------------------- 1 | import { useKeepAliveRef } from 'keepalive-for-react'; 2 | 3 | /** 4 | * 获取常用的状态数据 5 | */ 6 | export const useLogout = () => { 7 | const [, , removeToken] = useToken(); 8 | const { closeAllTab, setActiveKey } = useTabsStore((state) => state); 9 | const clearInfo = useUserStore((state) => state.clearInfo); 10 | const navigate = useNavigate(); 11 | const location = useLocation(); 12 | const aliveRef = useKeepAliveRef(); 13 | /** 退出登录 */ 14 | const handleLogout = () => { 15 | clearInfo(); 16 | closeAllTab(); 17 | setActiveKey(''); 18 | removeToken(); 19 | aliveRef.current?.destroyAll(); // 清除keepalive缓存 20 | navigate(`/login?redirect=${location.pathname}${location.search}`); 21 | }; 22 | 23 | return [handleLogout] as const; 24 | }; 25 | -------------------------------------------------------------------------------- /src/pages/demo/editor/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useCommonStore } from '@/hooks/useCommonStore'; 3 | import { checkPermission } from '@/utils/permissions'; 4 | import WangEditor from '@/components/WangEditor'; 5 | import BaseContent from '@/components/Content/BaseContent'; 6 | 7 | function MyEditor() { 8 | const { permissions } = useCommonStore(); 9 | // 编辑器内容 10 | const [html, setHtml] = useState('

hello

'); 11 | const isPermission = checkPermission('/demo/editor', permissions); 12 | 13 | return ( 14 | 15 |
16 | setHtml(content)} /> 17 |
18 |
19 | ); 20 | } 21 | 22 | export default MyEditor; 23 | -------------------------------------------------------------------------------- /src/hooks/useFullscreen.ts: -------------------------------------------------------------------------------- 1 | import { usePublicStore } from '@/stores/public'; 2 | import { useCommonStore } from './useCommonStore'; 3 | 4 | export function useFullscreen() { 5 | const { isFullscreen } = useCommonStore(); 6 | const setFullscreen = usePublicStore((state) => state.setFullscreen); 7 | 8 | /** 切换全屏 */ 9 | const toggleFullscreen = () => { 10 | // 全屏 11 | if (!isFullscreen && document.documentElement?.requestFullscreen) { 12 | document.documentElement.requestFullscreen(); 13 | setFullscreen(true); 14 | return true; 15 | } 16 | // 退出全屏 17 | if (isFullscreen && document?.exitFullscreen) { 18 | document.exitFullscreen(); 19 | setFullscreen(false); 20 | return true; 21 | } 22 | }; 23 | 24 | return [isFullscreen, toggleFullscreen] as const; 25 | } 26 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useCallback } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { getFirstMenu } from '@/menus/utils/helper'; 4 | import { useCommonStore } from '@/hooks/useCommonStore'; 5 | 6 | function Page() { 7 | const { permissions, menuList } = useCommonStore(); 8 | const navigate = useNavigate(); 9 | 10 | /** 跳转第一个有效菜单路径 */ 11 | const goFirstMenu = useCallback(() => { 12 | const firstMenu = getFirstMenu(menuList, permissions); 13 | navigate(firstMenu); 14 | }, [menuList, navigate, permissions]); 15 | 16 | useEffect(() => { 17 | // 跳转第一个有效菜单路径 18 | goFirstMenu(); 19 | 20 | // eslint-disable-next-line react-hooks/exhaustive-deps 21 | }, [menuList, permissions]); 22 | 23 | return
; 24 | } 25 | 26 | export default Page; 27 | -------------------------------------------------------------------------------- /src/locales/config.ts: -------------------------------------------------------------------------------- 1 | import { initReactI18next } from 'react-i18next'; 2 | import { getZhLang, getEnLang, getZhLangNamespaces, getEnLangNamespaces } from './utils/helper'; 3 | import i18n from 'i18next'; 4 | import Backend from 'i18next-http-backend'; 5 | import LanguageDetector from 'i18next-browser-languagedetector'; 6 | 7 | i18n 8 | .use(Backend) 9 | .use(LanguageDetector) 10 | .use(initReactI18next) 11 | .init({ 12 | debug: true, 13 | fallbackLng: 'zh', 14 | interpolation: { 15 | escapeValue: false, 16 | }, 17 | resources: { 18 | zh: { 19 | translation: getZhLang(), 20 | ...getZhLangNamespaces(), 21 | }, 22 | en: { 23 | translation: getEnLang(), 24 | ...getEnLangNamespaces(), 25 | }, 26 | }, 27 | }); 28 | 29 | export default i18n; 30 | -------------------------------------------------------------------------------- /src/utils/is.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 是否是方法 3 | * @param val - 参数 4 | */ 5 | export function isFunction(val: unknown): boolean { 6 | return typeof val === 'function'; 7 | } 8 | 9 | /** 10 | * 是否是数字 11 | * @param obj - 值 12 | */ 13 | export function isNumber(obj: unknown): boolean { 14 | return typeof obj === 'number' && isFinite(obj); 15 | } 16 | 17 | /** 18 | * 是否是URL 19 | * @param path - 路径 20 | */ 21 | export function isUrl(path: string): boolean { 22 | const reg = 23 | /(((^https?:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)$/; 24 | return reg.test(path); 25 | } 26 | 27 | /** 28 | * 是否是NULL 29 | * @param value - 值 30 | */ 31 | export function isNull(value: unknown): boolean { 32 | return value === null; 33 | } 34 | -------------------------------------------------------------------------------- /src/components/Selects/types.ts: -------------------------------------------------------------------------------- 1 | import type { SelectProps, TreeSelectProps } from 'antd'; 2 | import type { ServerResult } from '@south/request'; 3 | 4 | export type ApiFn = (params?: object | unknown[]) => Promise>; 5 | 6 | // api参数 7 | interface ApiParam { 8 | api?: ApiFn; 9 | params?: object | unknown[]; 10 | apiResultKey?: string; 11 | } 12 | 13 | // 带分页的api参数 14 | interface ApiPageParam extends Omit { 15 | pageKey?: string; 16 | pageSizeKey?: string; 17 | queryKey?: string; 18 | page?: number; 19 | pageSize?: number; 20 | params?: object & { 21 | [key: string]: number; 22 | }; 23 | } 24 | 25 | export type ApiSelectProps = ApiParam & SelectProps; 26 | 27 | export type ApiTreeSelectProps = ApiParam & TreeSelectProps; 28 | 29 | export type ApiPageSelectProps = ApiPageParam & SelectProps; 30 | -------------------------------------------------------------------------------- /src/components/Transfer/BaseTransfer.tsx: -------------------------------------------------------------------------------- 1 | import type { TransferProps } from 'antd'; 2 | import type { TransferItem } from 'antd/es/transfer'; 3 | import { useState } from 'react'; 4 | import { Transfer } from 'antd'; 5 | 6 | interface Props { 7 | value: string[]; 8 | onChange: (value: string[]) => void; 9 | } 10 | 11 | function BaseTransfer(props: Props) { 12 | const { value } = props; 13 | const [targetKeys, setTargetKeys] = useState(value || []); 14 | 15 | /** 16 | * 更改数据 17 | * @param targetKeys - 显示在右侧框数据的key集合 18 | */ 19 | const onChange: TransferProps['onChange'] = (targetKeys) => { 20 | setTargetKeys(targetKeys as string[]); 21 | props?.onChange?.(targetKeys as string[]); 22 | }; 23 | 24 | return ; 25 | } 26 | 27 | export default BaseTransfer; 28 | -------------------------------------------------------------------------------- /src/locales/en/system.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | menuTitle: 'Menu Management', 3 | userTitle: 'User Management', 4 | permissions: 'Permissions', 5 | authorizationSuccessful: 'Authorization successful', 6 | state: 'state', 7 | module: 'module', 8 | controller: 'Controller', 9 | permissionButton: 'Permission Button', 10 | age: 'age', 11 | role: 'role', 12 | email: 'email', 13 | phone: 'phone', 14 | rightsProfile: 'Rights Profile', 15 | authority: 'authority system', 16 | platform: 'operating system', 17 | stat: 'statistical system', 18 | ad: 'delivery system', 19 | cs: 'customer Service System', 20 | log: 'log system', 21 | create: 'create', 22 | update: 'update', 23 | delete: 'delete', 24 | detail: 'details', 25 | export: 'export', 26 | status: 'status', 27 | description: 'description', 28 | authorize: 'authorize', 29 | }; 30 | -------------------------------------------------------------------------------- /src/menus/README.md: -------------------------------------------------------------------------------- 1 | ### 菜单路由说明 2 | * 顶级key使用顶级目录名 3 | * 次级都采用`/顶级key/当前目录/当前页` 4 | * 菜单key为跳转路由地址,需与文件目录结构相符 5 | 6 | ### 菜单路由key: 7 | ``` 8 | ├─ 顶级Key 9 | | └─ /顶级key/当前目录 10 | | └─ /顶级key/当前目录/当前页 11 | ├─ system 12 | | ├─ /system/user 13 | | └─ /system/menu 14 | └─ demo 15 | ├─ /demo/test 16 | └─ /demo/level1 17 | └─ /demo/level1/level2 18 | └─ /demo/level1/level3 19 | ``` 20 | 21 | ### 静态菜单方法: 22 | 如果需要静态菜单将/src/hooks/useCommonStore.ts中的useCommonStore中的menuList改为defaultMenus。 23 | ```js 24 | // src/hooks/useCommonStore.ts 25 | import { defaultMenus } from '@/menus'; 26 | 27 | // const menuList = useMenuStore(state => state.menuList); 28 | // 菜单数据 29 | const menuList = defaultMenus; 30 | ``` 31 | 32 | ### 菜单icon: 33 | 参考 [iconify官方地址](https://icon-sets.iconify.design/) 34 | 35 | ### 外链菜单: 36 | 将key设为一个url地址,前缀为`http`或`https`,则视为外链菜单,点击后直接跳转。 37 | -------------------------------------------------------------------------------- /src/hooks/useSearchUrlParams.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 将搜索参数带入url中 3 | */ 4 | 5 | export const useSearchUrlParams = () => { 6 | const [, setSearchParams] = useSearchParams(); 7 | const { pathname } = useLocation(); 8 | const { setTabs } = useTabsStore((state) => state); 9 | 10 | const handleSetSearchParams = (searchParams: BaseFormData) => { 11 | // 去除 values 中值为 undefined 的属性 12 | const filteredValues = Object.fromEntries( 13 | Object.entries(searchParams).filter(([, value]) => value !== undefined), 14 | ) as Record; 15 | 16 | // 将对象转换为 url 参数字符串 17 | let urlParams = new URLSearchParams(filteredValues).toString(); 18 | if (urlParams?.length) { 19 | urlParams = `?${urlParams}`; 20 | } 21 | 22 | setSearchParams(filteredValues); 23 | setTabs(pathname, urlParams); 24 | }; 25 | 26 | return [handleSetSearchParams]; 27 | }; 28 | -------------------------------------------------------------------------------- /src/pages/demo/[id]/dynamic/index.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from 'react-router-dom'; 2 | import { useCommonStore } from '@/hooks/useCommonStore'; 3 | import { checkPermission } from '@/utils/permissions'; 4 | import BaseCard from '@/components/Card/BaseCard'; 5 | import BaseContent from '@/components/Content/BaseContent'; 6 | 7 | function Dynamic() { 8 | const { id } = useParams(); 9 | const { permissions } = useCommonStore(); 10 | const isPermission = checkPermission('/demo/dynamic', permissions); 11 | 12 | return ( 13 | 14 | 15 |
/demo/123/dynamic中的123为动态参数,可自由修改,文件路径为:/demo/[id]/dynamic。
16 |
17 | id: {id} 18 |
19 |
20 |
21 | ); 22 | } 23 | 24 | export default Dynamic; 25 | -------------------------------------------------------------------------------- /src/layouts/components/TabRefresh.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip } from 'antd'; 2 | import { Icon } from '@iconify/react'; 3 | import { useTranslation } from 'react-i18next'; 4 | 5 | interface Props { 6 | isRefresh: boolean; 7 | onClick: () => void; 8 | } 9 | 10 | function TabRefresh(props: Props) { 11 | const { t } = useTranslation(); 12 | const { isRefresh, onClick } = props; 13 | 14 | return ( 15 | 16 | onClick()} 27 | icon="ant-design:reload-outlined" 28 | /> 29 | 30 | ); 31 | } 32 | 33 | export default TabRefresh; 34 | -------------------------------------------------------------------------------- /src/locales/en/login.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | login: 'Login', 3 | systemLogin: 'System Login', 4 | oldPassword: 'Old Password', 5 | newPassword: 'New Password', 6 | password: 'Password', 7 | username: 'Username', 8 | phoneNumber: 'Phone Number', 9 | verificationCode: 'Verification Code', 10 | getVerificationCode: 'Verification', 11 | reacquire: 'Reacquire({{time}})', 12 | phoneNumberError: 'Please enter a valid 11-digit phone number', 13 | rememberMe: 'Remember Me', 14 | resetPassword: 'Reset Password', 15 | verificationPassed: 'Verification Passed', 16 | forgetPassword: 'Forget Password?', 17 | confirmPassword: 'Confirm Password', 18 | confirmPasswordMessage: 'Password and confirmation password are not the same!', 19 | notPermissions: 'The user has no permission to log in', 20 | passwordRuleMessage: 'The password is 6-30 characters and must contain letters and numbers!', 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/PasswordStrength/components/StrengthBar.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | strength: number; 3 | } 4 | 5 | const arr = new Array(5).fill(0).map((_, index) => index + 1); 6 | 7 | function StrengthBar(props: Props) { 8 | const { strength } = props; 9 | 10 | return ( 11 |
12 | {arr.map((item) => ( 13 |
3 ? '!bg-green-400' : ''} 23 | ${item <= strength && strength === 3 ? '!bg-yellow-400' : ''} 24 | ${item <= strength && strength < 3 ? '!bg-red-400' : ''} 25 | `} 26 | >
27 | ))} 28 |
29 | ); 30 | } 31 | 32 | export default StrengthBar; 33 | -------------------------------------------------------------------------------- /src/components/Pagination/BasePagination.tsx: -------------------------------------------------------------------------------- 1 | import type { PaginationProps } from 'antd'; 2 | import { Pagination } from 'antd'; 3 | import { useTranslation } from 'react-i18next'; 4 | import './index.less'; 5 | 6 | function BasePagination(props: PaginationProps) { 7 | const { t } = useTranslation(); 8 | 9 | /** 10 | * 显示总数 11 | * @param total - 总数 12 | */ 13 | const showTotal = (total?: number): string => { 14 | return t('public.totalNum', { num: total || 0 }); 15 | }; 16 | 17 | return ( 18 | 32 | ); 33 | } 34 | 35 | export default BasePagination; 36 | -------------------------------------------------------------------------------- /src/components/Selects/BaseTreeSelect.tsx: -------------------------------------------------------------------------------- 1 | import { TreeSelect, type TreeSelectProps } from 'antd'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { MAX_TAG_COUNT } from './index'; 4 | 5 | function BaseTreeSelect(props: TreeSelectProps) { 6 | const { treeData } = props; 7 | const { t } = useTranslation(); 8 | 9 | const currentTreeData = 10 | treeData?.map((item) => { 11 | // 如果数组不是对象,则拼接数组 12 | if (typeof item !== 'object') { 13 | return { label: item, value: item }; 14 | } 15 | return item; 16 | }) || []; 17 | 18 | return ( 19 | 28 | ); 29 | } 30 | 31 | export default BaseTreeSelect; 32 | -------------------------------------------------------------------------------- /src/layouts/components/TabMaximize.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@iconify/react'; 2 | import { useCommonStore } from '@/hooks/useCommonStore'; 3 | import { useTabsStore } from '@/stores'; 4 | 5 | function TabMaximize() { 6 | // 是否窗口最大化 7 | const { isMaximize } = useCommonStore(); 8 | const toggleMaximize = useTabsStore((state) => state.toggleMaximize); 9 | 10 | /** 点击最大化/最小化 */ 11 | const onClick = () => { 12 | toggleMaximize(!isMaximize); 13 | }; 14 | 15 | return ( 16 |
17 | 22 | 23 | 28 |
29 | ); 30 | } 31 | 32 | export default TabMaximize; 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Bundler", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noUnusedLocals": true, // 有未使用的变量时,抛出错误 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | "baseUrl": ".", 20 | "types": ["vite/client", "node"], 21 | "paths": { 22 | "@/*": ["src/*"], 23 | "#/*": ["types/*"] 24 | } 25 | }, 26 | "include": ["src", "packages/*", "types/**/*.d.ts"], 27 | "exclude": ["dist", "node_modules", "cypress"], 28 | "references": [{ "path": "./tsconfig.node.json" }] 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Selects/BaseSelect.tsx: -------------------------------------------------------------------------------- 1 | import { Select, type SelectProps } from 'antd'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { MAX_TAG_COUNT } from './index'; 4 | 5 | /** 6 | * @description: 基础下拉组件 7 | */ 8 | function BaseSelect(props: SelectProps) { 9 | const { options } = props; 10 | const { t } = useTranslation(); 11 | 12 | const currentOptions = 13 | options?.map((item) => { 14 | // 如果数组不是对象,则拼接数组 15 | if (typeof item !== 'object') { 16 | return { label: item, value: item }; 17 | } 18 | return item; 19 | }) || []; 20 | 21 | return ( 22 | { 15 | onChange?.(e); 16 | }} 17 | /> 18 | 19 |
30 | 31 |
32 | 33 | 34 |
window.open('https://icon-sets.iconify.design', '_blank')} 37 | > 38 | 39 |
40 |
41 | 42 | ); 43 | } 44 | 45 | export default IconInput; 46 | -------------------------------------------------------------------------------- /src/layouts/components/TabOptions.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Dropdown } from 'antd'; 3 | import { Icon } from '@iconify/react'; 4 | import { useDropdownMenu } from '../hooks/useDropdownMenu'; 5 | 6 | interface Props { 7 | activeKey: string; 8 | handleRefresh: (activeKey: string) => void; 9 | } 10 | 11 | function TabOptions(props: Props) { 12 | const { activeKey, handleRefresh } = props; 13 | const [isOpen, setOpen] = useState(false); 14 | 15 | /** 16 | * 菜单显示变化 17 | * @param open - 显示值 18 | */ 19 | const onOpenChange = (open: boolean) => { 20 | setOpen(open); 21 | }; 22 | 23 | // 下拉菜单 24 | const dropdownMenuParams = { activeKey, onOpenChange, handleRefresh }; 25 | const [items, onClick] = useDropdownMenu(dropdownMenuParams); 26 | 27 | return ( 28 | onClick(e.key), 33 | }} 34 | onOpenChange={onOpenChange} 35 | > 36 | 49 | 50 | ); 51 | } 52 | 53 | export default TabOptions; 54 | -------------------------------------------------------------------------------- /src/router/components/Router.tsx: -------------------------------------------------------------------------------- 1 | import type { RouteObject } from 'react-router-dom'; 2 | import type { DefaultComponent } from '@loadable/component'; 3 | import { useEffect } from 'react'; 4 | import { handleRoutes } from '../utils/helper'; 5 | import { useLocation, useRoutes } from 'react-router-dom'; 6 | import Login from '@/pages/login'; 7 | import Forget from '@/pages/forget'; 8 | import NotFound from '@/pages/404'; 9 | import nprogress from 'nprogress'; 10 | import Guards from './Guards'; 11 | 12 | type PageFiles = Record Promise>>; 13 | const pages = import.meta.glob('../../pages/**/*.tsx') as PageFiles; 14 | const layouts = handleRoutes(pages); 15 | 16 | const newRoutes: RouteObject[] = [ 17 | { 18 | path: 'login', 19 | element: , 20 | }, 21 | { 22 | path: 'forget', 23 | element: , 24 | }, 25 | { 26 | path: '', 27 | element: , 28 | children: layouts, 29 | }, 30 | { 31 | path: '*', 32 | element: , 33 | }, 34 | ]; 35 | 36 | function App() { 37 | const location = useLocation(); 38 | 39 | // 顶部进度条 40 | useEffect(() => { 41 | nprogress.start(); 42 | }, []); 43 | 44 | useEffect(() => { 45 | nprogress.done(); 46 | 47 | return () => { 48 | nprogress.start(); 49 | }; 50 | }, [location]); 51 | 52 | return <>{useRoutes(newRoutes)}; 53 | } 54 | 55 | export default App; 56 | -------------------------------------------------------------------------------- /src/pages/system/role/components/AuthorizeSelect.tsx: -------------------------------------------------------------------------------- 1 | import type { Key } from 'react'; 2 | import { Spin, type SelectProps } from 'antd'; 3 | import { getRolePermission, type PermissionData } from '@/servers/system/role'; 4 | import MenuAuthorize from './MenuAuthorize'; 5 | 6 | interface Props extends SelectProps { 7 | id: string; 8 | } 9 | 10 | function AuthorizeSelect(props: Props) { 11 | const { id, value, onChange } = props; 12 | const [list, setList] = useState([]); 13 | const [isLoading, setLoading] = useState(false); 14 | 15 | useEffect(() => { 16 | getList(); 17 | }, []); 18 | 19 | /** 获取数据 */ 20 | const getList = async () => { 21 | const params = { roleId: id }; 22 | 23 | try { 24 | setLoading(true); 25 | const res = await getRolePermission(params); 26 | const { code, data } = res; 27 | if (Number(code) !== 200) return; 28 | setList(data?.treeData || []); 29 | } finally { 30 | setLoading(false); 31 | } 32 | }; 33 | 34 | /** 点击复选框 */ 35 | const handleCheckedKeysChange = (checkedKeys: Key[]) => { 36 | onChange?.(checkedKeys); 37 | }; 38 | 39 | return ( 40 | 41 | 47 | 48 | ); 49 | } 50 | 51 | export default AuthorizeSelect; 52 | -------------------------------------------------------------------------------- /src/menus/demo.ts: -------------------------------------------------------------------------------- 1 | import type { SideMenu } from '#/public'; 2 | 3 | export const demo: SideMenu[] = [ 4 | { 5 | label: '组件', 6 | labelEn: 'Components', 7 | key: '/demo', 8 | icon: 'fluent:box-20-regular', 9 | children: [ 10 | { 11 | label: '剪切板', 12 | labelEn: 'Copy', 13 | key: '/demo/copy', 14 | rule: '/demo/copy', 15 | }, 16 | { 17 | label: '水印', 18 | labelEn: 'Watermark', 19 | key: '/demo/watermark', 20 | rule: '/demo/watermark', 21 | }, 22 | { 23 | label: '虚拟滚动', 24 | labelEn: 'Virtual Scroll', 25 | key: '/demo/virtualScroll', 26 | rule: '/demo/virtualScroll', 27 | }, 28 | { 29 | label: '富文本', 30 | labelEn: 'Editor', 31 | key: '/demo/editor', 32 | rule: '/demo/editor', 33 | }, 34 | { 35 | label: '层级1', 36 | labelEn: 'Level1', 37 | key: '/demo/level1', 38 | children: [ 39 | { 40 | label: '层级2', 41 | labelEn: 'Level2', 42 | key: '/demo/level1/level2', 43 | children: [ 44 | { 45 | label: '层级3', 46 | labelEn: 'Level3', 47 | key: '/demo/level1/level2/level3', 48 | rule: '/demo/watermark', 49 | }, 50 | ], 51 | }, 52 | ], 53 | }, 54 | ], 55 | }, 56 | ]; 57 | -------------------------------------------------------------------------------- /src/components/Copy/CopyBtn.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonProps } from 'antd'; 2 | import { Button, message } from 'antd'; 3 | import { Icon } from '@iconify/react'; 4 | import { useTranslation } from 'react-i18next'; 5 | import { useClipboard } from '@/hooks/useClipboard'; 6 | 7 | interface Props extends ButtonProps { 8 | text: string; 9 | value: string; 10 | } 11 | 12 | function CopyBtn(props: Props) { 13 | const { text, value } = props; 14 | const { t } = useTranslation(); 15 | const [isCopied, error, copyText] = useClipboard(); 16 | const [messageApi, contextHolder] = message.useMessage(); 17 | 18 | useEffect(() => { 19 | if (isCopied && !error) { 20 | messageApi.success({ content: t('public.copySuccessfully'), key: 'copy' }); 21 | } 22 | 23 | if (error) { 24 | messageApi.warning({ content: error || t('public.copyFailed'), key: 'copy' }); 25 | } 26 | // eslint-disable-next-line react-hooks/exhaustive-deps 27 | }, [isCopied, error]); 28 | 29 | /** 点击处理 */ 30 | const onClick = () => { 31 | try { 32 | copyText(value); 33 | } catch (e) { 34 | console.error(e); 35 | messageApi.warning({ content: t('public.copyFailed'), key: 'copy' }); 36 | } 37 | }; 38 | 39 | return ( 40 | <> 41 | {contextHolder} 42 | 45 | 46 | ); 47 | } 48 | 49 | export default CopyBtn; 50 | -------------------------------------------------------------------------------- /src/pages/dashboard/components/Bar.tsx: -------------------------------------------------------------------------------- 1 | import type { EChartsCoreOption } from 'echarts'; 2 | 3 | const data = [962, 1023, 1112, 1123, 1239, 1382, 1420, 1523, 1622, 1643, 1782, 1928]; 4 | 5 | function Bar() { 6 | const { t } = useTranslation(); 7 | const option: EChartsCoreOption = { 8 | title: { 9 | text: t('dashboard.rechargeRankingDay'), 10 | left: 30, 11 | top: 5, 12 | }, 13 | tooltip: { 14 | trigger: 'axis', 15 | axisPointer: { 16 | type: 'shadow', 17 | }, 18 | }, 19 | grid: { 20 | left: '3%', 21 | right: '4%', 22 | bottom: '3%', 23 | containLabel: true, 24 | }, 25 | xAxis: { 26 | type: 'value', 27 | boundaryGap: [0, 0.01], 28 | }, 29 | yAxis: { 30 | type: 'category', 31 | data: [ 32 | '孤独的霸气', 33 | '凌云齐天', 34 | '夏至未至', 35 | '叶璃溪', 36 | '良辰美景奈何天', 37 | '凹凸曼', 38 | '六月离别', 39 | '离歌', 40 | '终极战犯', 41 | '水洗晴空', 42 | '安城如沫', 43 | '渣渣灰', 44 | ], 45 | }, 46 | series: [ 47 | { 48 | name: t('dashboard.rechargeAmount'), 49 | type: 'bar', 50 | data, 51 | }, 52 | ], 53 | }; 54 | 55 | const [echartsRef] = useEcharts(option, data); 56 | 57 | return ( 58 |
59 |
60 |
61 | ); 62 | } 63 | 64 | export default Bar; 65 | -------------------------------------------------------------------------------- /src/components/Table/utils/reducer.ts: -------------------------------------------------------------------------------- 1 | export interface InitTableState { 2 | rowHeight: number; 3 | curScrollTop: number; 4 | scrollHeight: number; 5 | tableScrollY: number; 6 | total: number; 7 | } 8 | 9 | export interface TableAction extends Partial { 10 | type: 'changeScroll' | 'reset'; 11 | } 12 | 13 | /** 14 | * 状态管理reducer 15 | * @param state - 初始化值 16 | * @param action - 触发值 17 | */ 18 | export function reducer(state: InitTableState, action: TableAction) { 19 | switch (action.type) { 20 | // 监听滚动变化 21 | case 'changeScroll': 22 | let curScrollTop = action.curScrollTop || 0; 23 | let scrollHeight = action.scrollHeight || 0; 24 | const tableScrollY = action.tableScrollY || 0; 25 | 26 | // 处理scrollHeight小于0的情况 27 | if (scrollHeight <= 0) scrollHeight = 0; 28 | 29 | // 更新可滚动区高度 30 | if (scrollHeight !== 0 && tableScrollY === state.tableScrollY) { 31 | scrollHeight = state.scrollHeight; 32 | } 33 | 34 | // 更新当前滚动高度 35 | if (state.scrollHeight && curScrollTop > state.scrollHeight) { 36 | curScrollTop = state.scrollHeight; 37 | } 38 | 39 | return { 40 | ...state, 41 | curScrollTop, 42 | scrollHeight, 43 | tableScrollY, 44 | }; 45 | 46 | // 重置 47 | case 'reset': 48 | return { 49 | ...state, 50 | curScrollTop: 0, 51 | scrollHeight: 0, 52 | }; 53 | 54 | default: 55 | throw new Error('表格:未知错误类型!'); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/Table/components/EllipsisText.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip } from 'antd'; 2 | import { useEffect, useRef, useState, useCallback, type CSSProperties } from 'react'; 3 | 4 | interface EllipsisTextProps { 5 | text: string; 6 | width?: number | string; 7 | color?: string; 8 | className?: string; 9 | style?: CSSProperties; 10 | } 11 | 12 | const EllipsisText = (props: EllipsisTextProps) => { 13 | const { width, text, color, className = '', style } = props; 14 | const textRef = useRef(null); 15 | const [isOverflowed, setIsOverflowed] = useState(false); 16 | 17 | // 计算文本是否溢出 18 | const calculateOverflow = useCallback(() => { 19 | const element = textRef.current; 20 | if (element) { 21 | // 检查文本是否溢出 22 | setIsOverflowed(element.scrollWidth > element.clientWidth); 23 | } 24 | }, [text, width, textRef.current]); 25 | 26 | useEffect(() => { 27 | calculateOverflow(); 28 | }, [calculateOverflow]); 29 | 30 | const textStyle = { 31 | color, 32 | ...style, 33 | }; 34 | 35 | const content = ( 36 | 41 | {text} 42 | 43 | ); 44 | 45 | // 只有在文本溢出时才显示Tooltip 46 | if (isOverflowed) { 47 | return ( 48 | 49 | {content} 50 | 51 | ); 52 | } 53 | 54 | return content; 55 | }; 56 | 57 | export default EllipsisText; 58 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/components/Copy/CopyInput.tsx: -------------------------------------------------------------------------------- 1 | import type { InputProps } from 'antd'; 2 | import { Input, message } from 'antd'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { useClipboard } from '@/hooks/useClipboard'; 5 | 6 | const { Search } = Input; 7 | 8 | function CopyInput(props: InputProps) { 9 | const { t } = useTranslation(); 10 | const [messageApi, contextHolder] = message.useMessage(); 11 | const [isCopied, error, copyText] = useClipboard(); 12 | 13 | useEffect(() => { 14 | if (isCopied && !error) { 15 | messageApi.success({ content: t('public.copySuccessfully'), key: 'copy' }); 16 | } 17 | 18 | if (error) { 19 | messageApi.warning({ content: error || t('public.copyFailed'), key: 'copy' }); 20 | } 21 | // eslint-disable-next-line react-hooks/exhaustive-deps 22 | }, [isCopied, error]); 23 | 24 | /** 25 | * 处理复制 26 | * @param value - 复制内容 27 | */ 28 | const handleCopy = (value: string) => { 29 | if (!value) return messageApi.warning({ content: t('public.inputPleaseEnter'), key: 'copy' }); 30 | try { 31 | copyText(value); 32 | } catch (e) { 33 | console.error(e); 34 | messageApi.warning({ content: t('public.copyFailed'), key: 'copy' }); 35 | } 36 | }; 37 | 38 | return ( 39 | <> 40 | {contextHolder} 41 | 47 | 48 | ); 49 | } 50 | 51 | export default CopyInput; 52 | -------------------------------------------------------------------------------- /src/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/hooks/useClipboard.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | type CopyHandler = (text: string) => Promise; 4 | 5 | export function useClipboard(): [boolean, string, CopyHandler] { 6 | const [isCopied, setIsCopied] = useState(false); 7 | const [error, setError] = useState(''); 8 | 9 | useEffect(() => { 10 | let timer: NodeJS.Timeout; 11 | if (isCopied) { 12 | timer = setTimeout(() => setIsCopied(false), 1000); 13 | } 14 | return () => clearTimeout(timer); 15 | }, [isCopied]); 16 | 17 | const copyText: CopyHandler = async (text) => { 18 | try { 19 | // 现代Clipboard API 20 | if (navigator.clipboard?.writeText) { 21 | await navigator.clipboard.writeText(text); 22 | setIsCopied(true); 23 | return true; 24 | } 25 | 26 | // 兼容旧浏览器的execCommand方法 27 | const textArea = document.createElement('textarea'); 28 | textArea.value = text; 29 | textArea.style.position = 'fixed'; // 避免滚动 30 | document.body.appendChild(textArea); 31 | textArea.select(); 32 | 33 | const success = document.execCommand('copy'); 34 | document.body.removeChild(textArea); 35 | 36 | if (success) { 37 | setIsCopied(true); 38 | return true; 39 | } 40 | 41 | throw new Error('复制失败,请手动复制'); 42 | } catch (err) { 43 | const message = err instanceof Error ? err.message : '复制操作被拒绝'; 44 | setError(message); 45 | setIsCopied(false); 46 | return false; 47 | } 48 | }; 49 | 50 | return [isCopied, error, copyText]; 51 | } 52 | -------------------------------------------------------------------------------- /src/router/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { App } from 'antd'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { HashRouter as Router } from 'react-router-dom'; 5 | import nprogress from 'nprogress'; 6 | import RouterPage from './components/Router'; 7 | import StaticMessage from '@south/message'; 8 | 9 | // antd 10 | import { theme, ConfigProvider } from 'antd'; 11 | import zhCN from 'antd/es/locale/zh_CN'; 12 | import enUS from 'antd/es/locale/en_US'; 13 | 14 | // 禁止进度条添加loading 15 | nprogress.configure({ showSpinner: false }); 16 | 17 | // antd主题 18 | const { defaultAlgorithm, darkAlgorithm } = theme; 19 | 20 | import { useCommonStore } from '@/hooks/useCommonStore'; 21 | 22 | function Page() { 23 | const { i18n } = useTranslation(); 24 | const { theme } = useCommonStore(); 25 | // 获取当前语言 26 | const currentLanguage = i18n.language; 27 | 28 | useEffect(() => { 29 | // 关闭loading 30 | const firstElement = document.getElementById('first'); 31 | if (firstElement && firstElement.style?.display !== 'none') { 32 | firstElement.style.display = 'none'; 33 | } 34 | }, []); 35 | 36 | return ( 37 | 38 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | } 52 | 53 | export default Page; 54 | -------------------------------------------------------------------------------- /src/servers/system/menu.ts: -------------------------------------------------------------------------------- 1 | import { request } from '@/utils/request'; 2 | 3 | enum API { 4 | URL = '/system/menu', 5 | } 6 | 7 | /** 8 | * 获取分页数据 9 | * @param data - 请求数据 10 | */ 11 | export function getMenuPage(data: Partial & PaginationData) { 12 | return request.get>(`${API.URL}/page`, { params: data }); 13 | } 14 | 15 | /** 16 | * 根据ID获取数据 17 | * @param id - ID 18 | */ 19 | export function getMenuById(id: string) { 20 | return request.get(`${API.URL}/detail?id=${id}`); 21 | } 22 | 23 | /** 24 | * 新增数据 25 | * @param data - 请求数据 26 | */ 27 | export function createMenu(data: BaseFormData) { 28 | return request.post(`${API.URL}/create`, data); 29 | } 30 | 31 | /** 32 | * 修改数据 33 | * @param id - 修改id值 34 | * @param data - 请求数据 35 | */ 36 | export function updateMenu(id: string, data: BaseFormData) { 37 | return request.put(`${API.URL}/update/${id}`, data); 38 | } 39 | 40 | /** 41 | * 删除 42 | * @param id - 删除id值 43 | */ 44 | export function deleteMenu(id: string) { 45 | return request.delete(`${API.URL}/${id}`); 46 | } 47 | 48 | /** 49 | * 获取当前菜单数据 50 | * @param data - 请求数据 51 | */ 52 | export function getMenuList() { 53 | return request.get(`${API.URL}/list`); 54 | } 55 | 56 | /** 57 | * 更改菜单状态 58 | * @param data - 请求数据 59 | */ 60 | export function changeMenuState(data: object) { 61 | return request.put(`${API.URL}/changeState`, data); 62 | } 63 | 64 | /** 获取菜单权限列表 */ 65 | export function getMenuPermissionList() { 66 | return request.get(`${API.URL}/permissionList`); 67 | } 68 | -------------------------------------------------------------------------------- /src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import type { TFunction } from 'i18next'; 2 | 3 | /** 4 | * @description: 配置项 5 | */ 6 | export const TITLE_SUFFIX = (t: TFunction) => t('public.currentName'); // 标题后缀 7 | export const WATERMARK_PREFIX = 'admin'; // 水印前缀 8 | export const TOKEN = 'admin_token'; // token名称 9 | export const LANG = 'lang'; // 语言 10 | export const VERSION = 'admin_version'; // 版本 11 | export const EMPTY_VALUE = '-'; // 空值显示 12 | export const THEME_KEY = 'theme_key'; // 主题 13 | 14 | // 初始化分页数据 15 | export const INIT_PAGINATION = { 16 | page: 1, 17 | pageSize: 20, 18 | }; 19 | 20 | // 日期格式化 21 | export const DATE_FORMAT = 'YYYY-MM-DD'; 22 | export const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'; 23 | export const TIME_PICKER_FORMAT = 'HH:mm:ss'; 24 | 25 | // 公共组件默认值 26 | export const FORM_REQUIRED = [{ required: true }]; // 表单必填校验 27 | 28 | // 新增/编辑标题 29 | export const ADD_TITLE = (t: TFunction, title?: string) => 30 | t('public.createTitle', { title: title ?? '' }); 31 | export const EDIT_TITLE = (t: TFunction, name: string, title?: string) => 32 | `${t('public.editTitle', { title: title ?? '' })}${name ? `(${name})` : ''}`; 33 | 34 | // 密码规则 35 | export const PASSWORD_RULE = (t: TFunction) => ({ 36 | pattern: /^(?=.*\d)(?=.*[a-zA-Z])[\da-zA-Z~!@#$%^&*+\.\_\-*]{6,30}$/, 37 | message: t('login.passwordRuleMessage'), 38 | }); 39 | 40 | // 环境判断 41 | const ENV = import.meta.env.VITE_ENV as string; 42 | // 生成环境所用的接口 43 | const URL = import.meta.env.VITE_BASE_URL as string; 44 | // 上传地址 45 | export const FILE_API = `${ENV === 'development' ? '/api' : URL}/authority/file/upload-file`; 46 | -------------------------------------------------------------------------------- /src/components/Count/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | import { amountFormatter } from '@/utils/helper'; 3 | 4 | interface Props extends React.HTMLAttributes { 5 | prefix?: string; 6 | start: number; 7 | end: number; 8 | } 9 | 10 | function Count(props: Props) { 11 | const { prefix, start, end } = props; 12 | const [num, setNum] = useState(start); 13 | const timerRef = useRef(null); 14 | 15 | useEffect(() => { 16 | // 清除之前的定时器 17 | if (timerRef.current) { 18 | clearInterval(timerRef.current); 19 | timerRef.current = null; 20 | } 21 | 22 | // 设置新的定时器 23 | const count = end - start; 24 | const time = 2 * 60; 25 | const add = Math.floor(count / time) || 1; 26 | 27 | timerRef.current = setInterval(() => { 28 | setNum((prevNum) => { 29 | const nextNum = prevNum + add; 30 | // 如果达到或超过目标值,清除定时器并设置为最终值 31 | if (nextNum >= end) { 32 | if (timerRef.current) { 33 | clearInterval(timerRef.current); 34 | timerRef.current = null; 35 | } 36 | return end; 37 | } 38 | return nextNum; 39 | }); 40 | }); 41 | 42 | // 组件卸载时清除定时器 43 | return () => { 44 | if (timerRef.current) { 45 | clearInterval(timerRef.current); 46 | timerRef.current = null; 47 | } 48 | }; 49 | }, [end, start]); 50 | 51 | return ( 52 | 53 | {prefix} 54 | {amountFormatter(num)} 55 | 56 | ); 57 | } 58 | 59 | export default Count; 60 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.wordWrap": "on", 5 | "editor.tabSize": 2, 6 | "eslint.validate": ["typescript"], 7 | "i18n-ally.namespace": true, 8 | "i18n-ally.sourceLanguage": "zh", 9 | "i18n-ally.displayLanguage": "zh", 10 | "i18n-ally.keystyle": "nested", 11 | "i18n-ally.localesPaths": ["src/locales"], 12 | "i18n-ally.enabledParsers": ["yaml", "ts", "js", "json"], 13 | "i18n-ally.pathMatcher": "{locale}/{namespaces}.{ext}", 14 | "i18n-ally.enabledFrameworks": ["react-i18next"], 15 | "cSpell.words": [ 16 | "vite", 17 | "windi", 18 | "windicss", 19 | "antd", 20 | "nprogress", 21 | "vitejs", 22 | "commitlint", 23 | "echarts", 24 | "pnpm", 25 | "antd", 26 | "iconify", 27 | "unocss", 28 | "axios", 29 | "Graphik", 30 | "Merriweather", 31 | "presetAttributify", 32 | "attributify", 33 | "camelcase", 34 | "parens", 35 | "paren", 36 | "Appstore", 37 | "brotliSize", 38 | "wangeditor", 39 | "stylelint", 40 | "liquidfill", 41 | "micromessenger", 42 | "crossorigin", 43 | "gifsicle", 44 | "optipng", 45 | "mozjpeg", 46 | "pngquant", 47 | "middlewares", 48 | "esbuild", 49 | "cssinjs", 50 | "zrender", 51 | "Optpng", 52 | "gridicons", 53 | "tsup", 54 | "YouSheBiaoTiHei", 55 | "zlevel", 56 | "majesticons", 57 | "Unactivate", 58 | "languagedetector", 59 | "locize", 60 | "zustand", 61 | "wechat", 62 | "unplugin", 63 | "geekblue", 64 | "Popconfirm", 65 | "nojekyll", 66 | ] 67 | } -------------------------------------------------------------------------------- /src/assets/css/antd.less: -------------------------------------------------------------------------------- 1 | /* 斑马线:开始 */ 2 | .theme-primary { 3 | .zebra { 4 | .ant-table-row:nth-child(2n), 5 | .ant-table-row:nth-child(2n) > .ant-table-cell { 6 | background-color: #fcfcfc; 7 | } 8 | } 9 | } 10 | 11 | /* 斑马线:结束 */ 12 | 13 | /* 边框:开始 */ 14 | .bordered { 15 | .ant-table-tbody > tr > td { 16 | border-right: 1px solid #f0f0f0; 17 | } 18 | } 19 | 20 | /* 边框:结束 */ 21 | 22 | /* 主题:开始 */ 23 | .left-divide-tab { 24 | border-left: 1px solid #d9d9d9; 25 | } 26 | 27 | .theme-dark { 28 | .border-bottom { 29 | border-bottom: 1px solid #383838 !important; 30 | } 31 | 32 | .bordered { 33 | .ant-table-tbody > tr > td { 34 | border-right: 1px solid #3f3f3f; 35 | } 36 | } 37 | 38 | .left-divide-tab { 39 | border-left: 1px solid #383838 !important; 40 | } 41 | } 42 | 43 | /* 主题:结束 */ 44 | 45 | /* 表单:开始 */ 46 | .ant-form-inline .ant-form-item { 47 | margin-right: 10px !important; 48 | } 49 | 50 | /* 表单:结束 */ 51 | 52 | /* 菜单:开始 */ 53 | #layout-menu .ant-menu-item:hover, 54 | #layout-menu .ant-menu-submenu:hover { 55 | .ant-menu-item-icon { 56 | font-size: 16px; 57 | } 58 | } 59 | 60 | /* 菜单:结束 */ 61 | 62 | /* 搜索:开始 */ 63 | #searches .ant-input-number { 64 | width: 100%; 65 | } 66 | 67 | /* 搜索:结束 */ 68 | 69 | /* 面包屑:开始 */ 70 | .breadcrumb-separator { 71 | color: rgba(0, 0, 0, 0.45); 72 | } 73 | 74 | .theme-dark { 75 | .breadcrumb-separator { 76 | color: rgba(255, 255, 255, 0.45) !important; 77 | } 78 | } 79 | 80 | /* 面包屑:结束 */ 81 | 82 | /* 表格:开始 */ 83 | .ant-table-cell { 84 | overflow: hidden; 85 | } 86 | 87 | .virtualTable { 88 | overflow: auto; 89 | } 90 | 91 | /* 表格:结束 */ 92 | -------------------------------------------------------------------------------- /src/components/PasswordStrength/index.tsx: -------------------------------------------------------------------------------- 1 | import type { InputProps } from 'antd'; 2 | import { useEffect, useState } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { debounce } from 'lodash'; 5 | import { Input } from 'antd'; 6 | import StrengthBar from './components/StrengthBar'; 7 | 8 | /** 9 | * @description: 密码强度组件 10 | */ 11 | function PasswordStrength(props: InputProps) { 12 | const { value } = props; 13 | const { t } = useTranslation(); 14 | const [strength, setStrength] = useState(0); 15 | 16 | /** 17 | * 密码强度判断 18 | * @param value - 值 19 | */ 20 | const handleStrength = debounce((value: string) => { 21 | if (!value) return; 22 | let level = 0; 23 | if (/\d/.test(value)) level++; // 有数字强度加1 24 | if (/[a-z]/.test(value)) level++; // 有小写字母强度加1 25 | if (/[A-Z]/.test(value)) level++; // 有大写字母强度加1 26 | if (value.length > 10) level++; // 长度大于10强度加1 27 | if (/[\.\~\@\#\$\^\&\*]/.test(value)) level++; // 有以下特殊字符强度加1 28 | setStrength(level); 29 | }, 500); 30 | 31 | // 监听传入值变化 32 | useEffect(() => { 33 | handleStrength(value as string); 34 | }, [handleStrength, value]); 35 | 36 | return ( 37 | <> 38 | { 45 | props.onChange?.(e); 46 | handleStrength(e.target.value); 47 | }} 48 | /> 49 | 50 | {!!strength && } 51 | 52 | ); 53 | } 54 | 55 | export default PasswordStrength; 56 | -------------------------------------------------------------------------------- /src/pages/dashboard/components/Line.tsx: -------------------------------------------------------------------------------- 1 | import type { EChartsCoreOption } from 'echarts'; 2 | 3 | function Line() { 4 | const { t } = useTranslation(); 5 | 6 | const option: EChartsCoreOption = { 7 | title: { 8 | text: t('dashboard.effectiveRechargeRatio'), 9 | left: 30, 10 | top: 5, 11 | }, 12 | xAxis: { 13 | type: 'category', 14 | boundaryGap: false, 15 | data: ['07-11', '07-12', '07-13', '07-14', '07-15', '07-16', '07-17'], 16 | }, 17 | yAxis: { 18 | type: 'value', 19 | }, 20 | tooltip: { 21 | trigger: 'axis', 22 | axisPointer: { 23 | type: 'cross', 24 | label: { 25 | backgroundColor: '#6a7985', 26 | }, 27 | }, 28 | }, 29 | series: [ 30 | { 31 | name: t('dashboard.rechargeAmount'), 32 | type: 'line', 33 | areaStyle: { 34 | color: '#1890ff', 35 | opacity: 0.2, 36 | }, 37 | emphasis: { 38 | focus: 'series', 39 | }, 40 | data: [120, 140, 120, 190, 150, 111, 160], 41 | }, 42 | { 43 | name: t('dashboard.usersNumber'), 44 | type: 'line', 45 | areaStyle: { 46 | color: '#1890ff', 47 | opacity: 0.3, 48 | }, 49 | emphasis: { 50 | focus: 'series', 51 | }, 52 | data: [90, 122, 90, 140, 123, 280, 200], 53 | }, 54 | ], 55 | }; 56 | 57 | const [echartsRef] = useEcharts(option); 58 | 59 | return ( 60 |
61 |
62 |
63 | ); 64 | } 65 | 66 | export default Line; 67 | -------------------------------------------------------------------------------- /src/components/Buttons/components/DeleteBtn.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonProps } from 'antd'; 2 | import { Button, Popconfirm } from 'antd'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { DeleteOutlined } from '@ant-design/icons'; 5 | 6 | interface Props extends ButtonProps { 7 | isLoading?: boolean; 8 | btnType?: 'delete' | 'batchDelete'; 9 | name?: string; 10 | customizeTitle?: string; 11 | isIcon?: boolean; 12 | handleDelete: () => void; 13 | } 14 | 15 | function DeleteBtn(props: Props) { 16 | const { 17 | isLoading, 18 | loading, 19 | isIcon, 20 | customizeTitle, 21 | name, 22 | btnType = 'delete', 23 | className, 24 | handleDelete, 25 | } = props; 26 | const { t } = useTranslation(); 27 | 28 | // 清除自定义属性 29 | const params: Partial = { ...props }; 30 | delete params.isIcon; 31 | delete params.isLoading; 32 | delete params.btnType; 33 | delete params.handleDelete; 34 | 35 | return ( 36 | 44 | 54 | 55 | ); 56 | } 57 | 58 | export default DeleteBtn; 59 | -------------------------------------------------------------------------------- /src/hooks/useCommonStore.ts: -------------------------------------------------------------------------------- 1 | import { useMenuStore, usePublicStore, useTabsStore, useUserStore } from '@/stores'; 2 | 3 | /** 4 | * 获取常用的状态数据 5 | */ 6 | export const useCommonStore = () => { 7 | // 权限 8 | const permissions = useUserStore((state) => state.permissions); 9 | // 用户ID 10 | const userId = useUserStore((state) => state.userInfo.id); 11 | // 角色 12 | const roles = useUserStore((state) => state.userInfo.roles); 13 | // 用户名 14 | const username = useUserStore((state) => state.userInfo.username); 15 | // 是否窗口最大化 16 | const isMaximize = useTabsStore((state) => state.isMaximize); 17 | // 导航数据 18 | const nav = useTabsStore((state) => state.nav); 19 | // 菜单是否收缩 20 | const isCollapsed = useMenuStore((state) => state.isCollapsed); 21 | // 是否手机端 22 | const isPhone = useMenuStore((state) => state.isPhone); 23 | // 是否重新加载 24 | const isRefresh = usePublicStore((state) => state.isRefresh); 25 | // 是否全屏 26 | const isFullscreen = usePublicStore((state) => state.isFullscreen); 27 | // 菜单打开的key 28 | const openKeys = useMenuStore((state) => state.openKeys); 29 | // 菜单选中的key 30 | const selectedKeys = useMenuStore((state) => state.selectedKeys); 31 | // 标签栏 32 | const tabs = useTabsStore((state) => state.tabs); 33 | // 主题 34 | const theme = usePublicStore((state) => state.theme); 35 | // 菜单数据 36 | const menuList = useMenuStore((state) => state.menuList); 37 | 38 | return { 39 | isMaximize, 40 | isCollapsed, 41 | isPhone, 42 | isRefresh, 43 | isFullscreen, 44 | nav, 45 | permissions, 46 | userId, 47 | username, 48 | openKeys, 49 | selectedKeys, 50 | tabs, 51 | theme, 52 | menuList, 53 | roles, 54 | } as const; 55 | }; 56 | -------------------------------------------------------------------------------- /src/components/Table/utils/helper.ts: -------------------------------------------------------------------------------- 1 | import type { TableColumn } from '#/public'; 2 | import type { SizeType } from 'antd/es/config-provider/SizeContext'; 3 | import { EMPTY_VALUE } from '@/utils/config'; 4 | import { cloneDeep } from 'lodash'; 5 | 6 | /** 计算表格高度 */ 7 | export function getTableHeight(element: HTMLDivElement | null): number { 8 | // 获取屏幕高度 9 | const clientHeight = document.documentElement.clientHeight || document.body.clientHeight; 10 | 11 | // 获取元素顶部高度 12 | const top = element?.getBoundingClientRect?.()?.top || 0; 13 | 14 | // 分页高度 15 | const paginationElm = document.getElementById('pagination'); 16 | const paginationHeight = paginationElm?.offsetHeight || 0; 17 | 18 | // 表格高度 = 屏幕高度 - 表格距离顶部高度 - 分页高度 19 | const tableHeight = clientHeight - top - paginationHeight; 20 | 21 | return tableHeight > 0 ? tableHeight - 65 : 450; 22 | } 23 | 24 | /** 25 | * 根据大小处理行高度 26 | * @param size - 大小 27 | */ 28 | export function handleRowHeight(size: SizeType): number { 29 | switch (size) { 30 | case 'large': 31 | return 62; 32 | 33 | case 'middle': 34 | return 54; 35 | 36 | default: 37 | return 46; 38 | } 39 | } 40 | 41 | /** 42 | * 表格处理,表头超出隐藏,空值转为‘-’ 43 | * @param columns - 表格数据 44 | */ 45 | export function filterTableColumns(columns: TableColumn[]) { 46 | const newColumns = cloneDeep(columns); 47 | 48 | for (let i = 0; i < newColumns?.length; i++) { 49 | const element = newColumns[i]; 50 | if (element.ellipsis === undefined) { 51 | element.ellipsis = true; 52 | } 53 | if (!element.render) { 54 | element.render = (text: string | number) => { 55 | return text ? text : text === 0 ? text : EMPTY_VALUE; 56 | }; 57 | } 58 | } 59 | 60 | return newColumns; 61 | } 62 | -------------------------------------------------------------------------------- /src/assets/css/public.less: -------------------------------------------------------------------------------- 1 | @import url('./reset.less'); 2 | 3 | body { 4 | margin: 0; 5 | } 6 | 7 | html, 8 | body, 9 | #root { 10 | width: 100%; 11 | height: 100%; 12 | font-size: 16px; 13 | } 14 | 15 | .echarts { 16 | width: 100%; 17 | height: 100%; 18 | } 19 | 20 | .b, 21 | .border { 22 | border-style: solid; 23 | } 24 | 25 | .b-t, 26 | .border-t, 27 | .b-b, 28 | .border-b, 29 | .b-l, 30 | .border-l, 31 | .b-r, 32 | .border-r { 33 | border-style: none; 34 | } 35 | 36 | .b-t, 37 | .border-t, 38 | .b-solid-t, 39 | .border-solid-t { 40 | border-top-style: solid; 41 | } 42 | 43 | .b-b, 44 | .border-b, 45 | .b-solid-b, 46 | .border-solid-b { 47 | border-bottom-style: solid; 48 | } 49 | 50 | .b-l, 51 | .border-l, 52 | .b-solid-l, 53 | .border-solid-l { 54 | border-left-style: solid; 55 | } 56 | 57 | .b-r, 58 | .border-r, 59 | .b-solid-r, 60 | .border-solid-r { 61 | border-right-style: solid; 62 | } 63 | 64 | .border-dashed, 65 | .b-dashed { 66 | border-style: dashed; 67 | } 68 | 69 | .ellipsis { 70 | display: inline-block; 71 | max-width: 100%; 72 | overflow: hidden; 73 | text-overflow: ellipsis; 74 | white-space: nowrap !important; 75 | word-wrap: normal !important; 76 | } 77 | 78 | .small-btn { 79 | padding: 0 10px !important; 80 | height: 29px !important; 81 | line-height: 29px !important; 82 | 83 | span { 84 | font-size: 13px; 85 | } 86 | } 87 | 88 | .content-transition { 89 | transition: 90 | opacity 0.3s ease, 91 | transform 0.3s ease; 92 | } 93 | 94 | .content-hidden { 95 | opacity: 0; 96 | transform: translateY(10px); 97 | } 98 | 99 | .content-visible { 100 | opacity: 1; 101 | transform: translateY(0); 102 | } 103 | -------------------------------------------------------------------------------- /src/assets/css/reset.less: -------------------------------------------------------------------------------- 1 | /* Reset style sheet */ 2 | html, 3 | body, 4 | div, 5 | span, 6 | applet, 7 | object, 8 | iframe, 9 | h1, 10 | h2, 11 | h3, 12 | h4, 13 | h5, 14 | h6, 15 | p, 16 | blockquote, 17 | pre, 18 | a, 19 | abbr, 20 | acronym, 21 | address, 22 | big, 23 | cite, 24 | code, 25 | del, 26 | dfn, 27 | em, 28 | img, 29 | ins, 30 | kbd, 31 | q, 32 | s, 33 | samp, 34 | small, 35 | strike, 36 | strong, 37 | sub, 38 | sup, 39 | tt, 40 | var, 41 | b, 42 | u, 43 | i, 44 | center, 45 | dl, 46 | dt, 47 | dd, 48 | ol, 49 | ul, 50 | li, 51 | fieldset, 52 | form, 53 | label, 54 | legend, 55 | table, 56 | caption, 57 | tbody, 58 | tfoot, 59 | thead, 60 | tr, 61 | th, 62 | td, 63 | article, 64 | aside, 65 | canvas, 66 | details, 67 | embed, 68 | figure, 69 | figcaption, 70 | footer, 71 | header, 72 | hgroup, 73 | menu, 74 | nav, 75 | output, 76 | ruby, 77 | section, 78 | summary, 79 | time, 80 | mark, 81 | audio, 82 | video { 83 | padding: 0; 84 | margin: 0; 85 | border: 0; 86 | } 87 | 88 | /* HTML5 display-role reset for older browsers */ 89 | article, 90 | aside, 91 | details, 92 | figcaption, 93 | figure, 94 | footer, 95 | header, 96 | hgroup, 97 | menu, 98 | nav, 99 | section { 100 | display: block; 101 | } 102 | 103 | body { 104 | padding: 0; 105 | margin: 0; 106 | } 107 | 108 | ol, 109 | ul { 110 | list-style: none; 111 | } 112 | 113 | blockquote, 114 | q { 115 | quotes: none; 116 | } 117 | 118 | blockquote::before, 119 | blockquote::after, 120 | q::before, 121 | q::after { 122 | content: ''; 123 | content: none; 124 | } 125 | 126 | table { 127 | border-spacing: 0; 128 | border-collapse: collapse; 129 | } 130 | 131 | html, 132 | body, 133 | #root { 134 | width: 100%; 135 | height: 100%; 136 | } 137 | -------------------------------------------------------------------------------- /packages/utils/src/local.ts: -------------------------------------------------------------------------------- 1 | import { message } from '@south/message'; 2 | import { encryption, decryption } from './crypto'; 3 | 4 | /** 5 | * @description: localStorage封装 6 | */ 7 | 8 | // 默认缓存期限为2天 9 | const DEFAULT_CACHE_TIME = 60 * 60 * 24 * 2; 10 | 11 | interface StorageData { 12 | value: unknown; 13 | expire: number | null; 14 | } 15 | 16 | /** 17 | * 设置本地缓存 18 | * @param key - 唯一值 19 | * @param value - 缓存值 20 | * @param expire - 缓存期限 21 | */ 22 | export function setLocalInfo( 23 | key: string, 24 | value: unknown, 25 | expire: number | null = DEFAULT_CACHE_TIME, 26 | ) { 27 | // 缓存时间 28 | const time = expire !== null ? new Date().getTime() + expire * 1000 : null; 29 | // 缓存数据 30 | const data: StorageData = { value, expire: time }; 31 | const json = encryption(data); 32 | localStorage.setItem(key, json); 33 | } 34 | 35 | /** 36 | * 获取本地缓存数据 37 | * @param key - 唯一值 38 | */ 39 | export function getLocalInfo(key: string) { 40 | const json = localStorage.getItem(key); 41 | 42 | if (json) { 43 | let data: StorageData | null = null; 44 | try { 45 | data = decryption(json); 46 | } catch { 47 | // 解密失败 48 | message.error({ content: '数据解密失败', key: 'decryption' }); 49 | } 50 | 51 | // 当有数据时 52 | if (data) { 53 | const { value, expire } = data; 54 | // 在有效期内直接返回 55 | if (expire === null || expire >= Date.now()) { 56 | return value as T; 57 | } 58 | } 59 | 60 | // 缓存过期或无数据清空当前本地缓存 61 | removeLocalInfo(key); 62 | return null; 63 | } 64 | return null; 65 | } 66 | 67 | /** 68 | * 移除指定本地缓存 69 | * @param key - 唯一值 70 | */ 71 | export function removeLocalInfo(key: string) { 72 | localStorage.removeItem(key); 73 | } 74 | 75 | /** 清空本地缓存 */ 76 | export function clearLocalInfo() { 77 | localStorage.clear(); 78 | } 79 | -------------------------------------------------------------------------------- /types/public.ts: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | import type { TableProps } from 'antd'; 3 | import type { ColumnType } from 'antd/es/table'; 4 | import type { ItemType } from 'antd/es/menu/interface'; 5 | 6 | // 数组 7 | export type ArrayData = string[] | number[] | boolean[]; 8 | 9 | // 空值 10 | export type EmptyData = null | undefined; 11 | 12 | // 分页接口响应数据 13 | export interface PageServerResult { 14 | items: T; 15 | total: number; 16 | } 17 | 18 | // 分页表格响应数据 19 | export interface PaginationData { 20 | page?: number; 21 | pageSize?: number; 22 | } 23 | 24 | // 侧边菜单 25 | export interface SideMenu extends Omit { 26 | label: string; 27 | labelZh?: string; 28 | labelEn: string; 29 | key: string; 30 | icon?: React.ReactNode | string; 31 | rule?: string; // 路由权限 32 | nav?: string[]; // 面包屑路径 33 | children?: SideMenu[]; 34 | } 35 | 36 | // 页面权限 37 | export interface PagePermission { 38 | page?: boolean; 39 | create?: boolean; 40 | update?: boolean; 41 | delete?: boolean; 42 | [key: string]: boolean | undefined; 43 | } 44 | 45 | export type EnumShowType = 'text' | 'tag'; 46 | 47 | // 表格列表枚举 48 | export interface ColumnsEnum { 49 | label: string; 50 | value: unknown; 51 | color?: string; 52 | type?: EnumShowType; 53 | } 54 | 55 | // 表格列数据 56 | export interface TableColumn extends ColumnType { 57 | enum?: ColumnsEnum[] | Record; 58 | children?: TableColumn[]; 59 | isKeepFixed?: boolean; // 手机端默认关闭fixed,该属性开启fixed 60 | } 61 | 62 | // 表格参数 63 | export interface BaseTableProps extends Omit { 64 | rowKey?: string; 65 | columns: TableColumn[]; 66 | } 67 | 68 | // 表格操作 69 | export type TableOptions = (value: unknown, record: T, index?: number) => ReactNode; 70 | -------------------------------------------------------------------------------- /src/pages/dashboard/components/Block.tsx: -------------------------------------------------------------------------------- 1 | import { Row, Col } from 'antd'; 2 | import { Icon } from '@iconify/react'; 3 | 4 | function Block() { 5 | const { t } = useTranslation(); 6 | const { isPhone } = useCommonStore(); 7 | 8 | const data = [ 9 | { title: t('dashboard.usersNumber'), num: 14966, all: 16236, icon: 'icon-park:peoples' }, 10 | { title: t('dashboard.rechargeAmount'), num: 4286, all: 6142, icon: 'icon-park:paper-money' }, 11 | { 12 | title: t('dashboard.orderNumber'), 13 | num: 5649, 14 | all: 5232, 15 | icon: 'icon-park:transaction-order', 16 | }, 17 | { title: t('dashboard.gameNumber'), num: 619, all: 2132, icon: 'icon-park:game-handle' }, 18 | ]; 19 | 20 | return ( 21 | 22 | {data.map((item) => ( 23 | 30 |
40 |
{item.title}
41 |
42 | 43 | 44 |
45 |
46 | {t('public.total')}: 47 | 48 |
49 |
50 | 51 | ))} 52 |
53 | ); 54 | } 55 | 56 | export default Block; 57 | -------------------------------------------------------------------------------- /src/layouts/index.module.less: -------------------------------------------------------------------------------- 1 | @import url('@/assets/css/default.less'); 2 | 3 | .header { 4 | position: fixed; 5 | top: 0; 6 | right: 0; 7 | left: @layout-left; 8 | z-index: 4; 9 | box-sizing: border-box; 10 | flex-direction: column; 11 | height: @layout-top; 12 | overflow: hidden; 13 | background-color: #fff; 14 | border-bottom: 1px solid #eee; 15 | } 16 | 17 | .header-close-menu { 18 | left: @layout-left-close !important; 19 | } 20 | 21 | .header-driver { 22 | border-bottom: 1px solid #eee; 23 | } 24 | 25 | .menu { 26 | position: fixed; 27 | top: 0; 28 | bottom: 0; 29 | left: 0; 30 | width: @layout-left !important; 31 | background-color: #000; 32 | } 33 | 34 | .menu-close { 35 | width: @layout-left-close !important; 36 | } 37 | 38 | .con { 39 | position: relative; 40 | inset: @layout-top 0 0 @layout-left; 41 | box-sizing: border-box; 42 | width: calc(100% - @layout-left); 43 | min-height: calc(100vh - @layout-top); 44 | } 45 | 46 | .con-close-menu { 47 | left: @layout-left-close; 48 | width: calc(100% - @layout-left-close); 49 | } 50 | 51 | .con-maximize { 52 | top: calc(@layout-top / 2); 53 | left: 0 !important; 54 | width: 100%; 55 | } 56 | 57 | .header-none { 58 | left: 0 !important; 59 | height: calc(@layout-top / 2); 60 | } 61 | 62 | .none { 63 | display: none !important; 64 | } 65 | 66 | .menu-none { 67 | width: 0 !important; 68 | opacity: 0 !important; 69 | } 70 | 71 | .layout-tabs { 72 | :global(.ant-tabs-tab-active) { 73 | background-color: #1d4ed8 !important; 74 | } 75 | 76 | :global(.ant-tabs-tab-active .ant-tabs-tab-btn) { 77 | color: #fff !important; 78 | } 79 | 80 | :global(.ant-tabs-tab-active .ant-tabs-tab-remove) { 81 | color: #fff !important; 82 | } 83 | 84 | :global(.ant-tabs-tab) { 85 | padding: 5px 16px 8px !important; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/pages/system/role/model.tsx: -------------------------------------------------------------------------------- 1 | import type { TFunction } from 'i18next'; 2 | import AuthorizeSelect from './components/AuthorizeSelect'; 3 | 4 | // 搜索数据 5 | export const searchList = (t: TFunction): BaseSearchList[] => [ 6 | { 7 | label: t('public.name'), 8 | name: 'name', 9 | component: 'Input', 10 | }, 11 | ]; 12 | 13 | /** 14 | * 表格数据 15 | * @param optionRender - 渲染操作函数 16 | */ 17 | export const tableColumns = (t: TFunction, optionRender: TableOptions): TableColumn[] => { 18 | return [ 19 | { 20 | title: 'ID', 21 | dataIndex: 'id', 22 | width: 200, 23 | }, 24 | { 25 | title: t('public.name'), 26 | dataIndex: 'name', 27 | width: 200, 28 | }, 29 | { 30 | title: t('system.description'), 31 | dataIndex: 'description', 32 | width: 200, 33 | }, 34 | { 35 | title: t('public.creationTime'), 36 | dataIndex: 'createdAt', 37 | width: 200, 38 | }, 39 | { 40 | title: t('public.updateTime'), 41 | dataIndex: 'updatedAt', 42 | width: 200, 43 | }, 44 | { 45 | title: t('public.operate'), 46 | dataIndex: 'operate', 47 | width: 200, 48 | fixed: 'right', 49 | render: (value: unknown, record: object) => optionRender(value, record), 50 | }, 51 | ]; 52 | }; 53 | 54 | // 新增数据 55 | export const createList = (t: TFunction, id: string): BaseFormList[] => [ 56 | { 57 | label: t('public.name'), 58 | name: 'name', 59 | rules: FORM_REQUIRED, 60 | component: 'Input', 61 | }, 62 | { 63 | label: t('system.description'), 64 | name: 'description', 65 | component: 'TextArea', 66 | }, 67 | { 68 | label: t('system.authorize'), 69 | name: 'authorize', 70 | rules: FORM_REQUIRED, 71 | component: 'customize', 72 | render: AuthorizeSelect as unknown as CustomizeRender, 73 | componentProps: { 74 | id, 75 | }, 76 | }, 77 | ]; 78 | -------------------------------------------------------------------------------- /src/pages/system/menu/components/StateSwitch.tsx: -------------------------------------------------------------------------------- 1 | import { changeMenuState } from '@/servers/system/menu'; 2 | import { message, Popconfirm, Switch } from 'antd'; 3 | import { useState } from 'react'; 4 | 5 | // 当前行数据 6 | interface RowData { 7 | id: string; 8 | label: string; 9 | labelEn: string; 10 | } 11 | 12 | interface Props { 13 | value: number; 14 | record: object; 15 | } 16 | 17 | function StateSwitch(props: Props) { 18 | const { value, record } = props; 19 | const { id, label, labelEn } = record as RowData; 20 | const { t, i18n } = useTranslation(); 21 | const [isLoading, setLoading] = useState(false); 22 | const [localValue, setLocalValue] = useState(value); 23 | const [messageApi, contextHolder] = message.useMessage(); 24 | 25 | const onChange = async () => { 26 | const value = localValue ? 0 : 1; 27 | const params = { id, state: value }; 28 | 29 | try { 30 | setLoading(true); 31 | const { code, message } = await changeMenuState(params); 32 | if (Number(code) === 200) { 33 | messageApi.success({ 34 | content: message || t('public.successfulOperation'), 35 | key: 'success', 36 | }); 37 | setLocalValue(value); 38 | } 39 | } finally { 40 | setLoading(false); 41 | } 42 | }; 43 | 44 | return ( 45 | <> 46 | {contextHolder} 47 | 55 | 61 | 62 | 63 | ); 64 | } 65 | 66 | export default StateSwitch; 67 | -------------------------------------------------------------------------------- /src/servers/system/role.ts: -------------------------------------------------------------------------------- 1 | import type { Key, ReactNode } from 'react'; 2 | import type { DataNode } from 'antd/es/tree'; 3 | import { request } from '@/utils/request'; 4 | 5 | enum API { 6 | URL = '/system/role', 7 | } 8 | 9 | /** 10 | * 获取分页数据 11 | * @param data - 请求数据 12 | */ 13 | export function getRolePage(data: Partial & PaginationData) { 14 | return request.get>(`${API.URL}/page`, { params: data }); 15 | } 16 | 17 | /** 18 | * 根据ID获取数据 19 | * @param id - ID 20 | */ 21 | export function getRoleById(id: string) { 22 | return request.get(`${API.URL}/detail?id=${id}`); 23 | } 24 | 25 | /** 26 | * 新增数据 27 | * @param data - 请求数据 28 | */ 29 | export function createRole(data: BaseFormData) { 30 | return request.post(`${API.URL}/create`, data); 31 | } 32 | 33 | /** 34 | * 修改数据 35 | * @param id - 修改id值 36 | * @param data - 请求数据 37 | */ 38 | export function updateRole(id: string, data: BaseFormData) { 39 | return request.put(`${API.URL}/update/${id}`, data); 40 | } 41 | 42 | /** 43 | * 删除 44 | * @param id - 删除id值 45 | */ 46 | export function deleteRole(id: string) { 47 | return request.delete(`${API.URL}/${id}`); 48 | } 49 | 50 | /** 51 | * 批量删除 52 | * @param data - 请求数据 53 | */ 54 | export function batchDeleteRole(data: BaseFormData) { 55 | return request.post(`${API.URL}/batchDelete`, data); 56 | } 57 | 58 | /** 获取全部角色 */ 59 | export function getRoleList() { 60 | return request.get(`${API.URL}/list`); 61 | } 62 | 63 | /** 64 | * 获取权限列表 65 | * @param data - 搜索数据 66 | */ 67 | export interface PermissionData extends DataNode { 68 | icon: string | ReactNode; 69 | type: number; 70 | children?: PermissionData[]; 71 | } 72 | export interface PermissionResult { 73 | treeData: PermissionData[]; 74 | defaultCheckedKeys: Key[]; 75 | } 76 | export function getRolePermission(data: object) { 77 | return request.get(`${API.URL}/authorize`, { params: data }); 78 | } 79 | -------------------------------------------------------------------------------- /src/servers/system/user.ts: -------------------------------------------------------------------------------- 1 | import type { LoginResult } from '@/pages/login/model'; 2 | import { request } from '@/utils/request'; 3 | import { PermissionResult } from './role'; 4 | 5 | enum API { 6 | URL = '/system/user', 7 | } 8 | 9 | /** 10 | * 获取分页数据 11 | * @param data - 请求数据 12 | */ 13 | export function getUserPage(data: Partial & PaginationData) { 14 | return request.get>(`${API.URL}/page`, { params: data }); 15 | } 16 | 17 | /** 18 | * 根据ID获取数据 19 | * @param id - ID 20 | */ 21 | export function getUserById(id: string) { 22 | return request.get(`${API.URL}/detail?id=${id}`); 23 | } 24 | 25 | /** 26 | * 新增数据 27 | * @param data - 请求数据 28 | */ 29 | export function createUser(data: BaseFormData) { 30 | return request.post(`${API.URL}/create`, data); 31 | } 32 | 33 | /** 34 | * 修改数据 35 | * @param id - 修改id值 36 | * @param data - 请求数据 37 | */ 38 | export function updateUser(id: string, data: BaseFormData) { 39 | return request.put(`${API.URL}/update/${id}`, data); 40 | } 41 | 42 | /** 43 | * 删除 44 | * @param id - 删除id值 45 | */ 46 | export function deleteUser(id: string) { 47 | return request.delete(`${API.URL}/${id}`); 48 | } 49 | 50 | /** 51 | * 批量删除 52 | * @param data - 请求数据 53 | */ 54 | export function batchDeleteUser(data: BaseFormData) { 55 | return request.post(`${API.URL}/batchDelete`, data); 56 | } 57 | 58 | /** 59 | * 获取权限列表 60 | * @param data - 搜索数据 61 | */ 62 | export function getUserPermission(data: object) { 63 | return request.get(`${API.URL}/authorize`, { params: data }); 64 | } 65 | 66 | /** 67 | * 保存用户权限 68 | * @param data - 权限数据 69 | */ 70 | export function saveUserPermission(data: object) { 71 | return request.put(`${API.URL}/authorize/save`, data); 72 | } 73 | 74 | /** 75 | * 获取用户刷新权限 76 | * @param data - 请求数据 77 | */ 78 | export function getUserRefreshPermissions(data: object) { 79 | return request.get(`${API.URL}/refreshPermissions`, { params: data }); 80 | } 81 | -------------------------------------------------------------------------------- /src/hooks/useEcharts.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react'; 2 | import { debounce } from 'lodash'; 3 | import * as echarts from 'echarts'; 4 | /** 5 | * 使用Echarts 6 | * @param options - 绘制echarts的参数 7 | * @param data - 数据 8 | */ 9 | export const useEcharts = (options: echarts.EChartsCoreOption, data?: unknown) => { 10 | const echartsRef = useRef(null); 11 | const htmlDivRef = useRef(null); 12 | const resizeObserverRef = useRef(null); 13 | 14 | /** 销毁echarts */ 15 | const dispose = () => { 16 | if (htmlDivRef.current) { 17 | echartsRef.current?.dispose(); 18 | echartsRef.current = null; 19 | } 20 | if (resizeObserverRef.current) { 21 | resizeObserverRef.current.disconnect(); 22 | resizeObserverRef.current = null; 23 | } 24 | }; 25 | 26 | /** 初始化 */ 27 | const init = useCallback(() => { 28 | if (options && htmlDivRef.current) { 29 | // 摧毁echarts后在初始化 30 | dispose(); 31 | 32 | // 初始化chart 33 | echartsRef.current = echarts.init(htmlDivRef.current); 34 | echartsRef.current.setOption(options); 35 | 36 | // 使用 ResizeObserver 监听容器尺寸变化 37 | resizeObserverRef.current = new ResizeObserver( 38 | debounce(() => { 39 | echartsRef.current?.resize({ 40 | animation: { 41 | duration: 500, 42 | }, 43 | }); 44 | }, 50), 45 | ); 46 | resizeObserverRef.current.observe(htmlDivRef.current); 47 | } 48 | }, [options]); 49 | 50 | useEffect(() => { 51 | if (htmlDivRef.current) { 52 | init(); 53 | 54 | return () => { 55 | dispose(); 56 | }; 57 | } 58 | // eslint-disable-next-line react-hooks/exhaustive-deps 59 | }, []); 60 | 61 | useEffect(() => { 62 | if (data && echartsRef.current) { 63 | echartsRef?.current?.setOption(options); 64 | } 65 | }, [data, options]); 66 | 67 | return [htmlDivRef, echartsRef.current] as const; 68 | }; 69 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | import type { TFunction } from 'i18next'; 2 | import { DefaultOptionType } from 'antd/es/select'; 3 | 4 | /** 5 | * @description: 公用常量 6 | */ 7 | 8 | /** 9 | * 颜色 10 | */ 11 | export enum colors { 12 | success = '#87d068', 13 | primary = '#409EFF', 14 | warning = '#E6A23C', 15 | danger = '#f50', 16 | info = '#909399', 17 | magenta = 'magenta', 18 | red = 'red', 19 | volcano = 'volcano', 20 | orange = 'orange', 21 | gold = 'gold', 22 | lime = 'lime', 23 | green = 'green', 24 | cyan = 'cyan', 25 | blue = 'blue', 26 | geekblue = 'geekblue', 27 | purple = 'purple', 28 | } 29 | 30 | export interface Constant extends Omit { 31 | value: string | number; 32 | label: string; 33 | type?: EnumShowType; 34 | color?: colors; 35 | children?: Constant[]; 36 | } 37 | 38 | /** 39 | * 开启状态 40 | */ 41 | export const OPEN_CLOSE = (t: TFunction): Constant[] => [ 42 | { label: t('public.open'), value: 1, color: colors.green, type: 'tag' }, 43 | { label: t('public.close'), value: 0, color: colors.red, type: 'tag' }, 44 | ]; 45 | 46 | /** 47 | * 菜单状态 48 | */ 49 | export const MENU_STATUS = (t: TFunction): Constant[] => [ 50 | { label: t('public.show'), value: 1, color: colors.green, type: 'tag' }, 51 | { label: t('public.hide'), value: 0, color: colors.red, type: 'tag' }, 52 | ]; 53 | 54 | /** 55 | * 菜单类型 56 | */ 57 | export const MENU_TYPES = (t: TFunction): Constant[] => [ 58 | { label: t('systems:menu.catalog'), value: 1, type: 'tag', color: colors.green }, 59 | { label: t('systems:menu.menu'), value: 2, type: 'tag', color: colors.blue }, 60 | { label: t('systems:menu.button'), value: 3, type: 'tag', color: colors.cyan }, 61 | ]; 62 | 63 | /** 64 | * 菜单作用类型 65 | */ 66 | export const MENU_ACTIONS = (t: TFunction): Constant[] => [ 67 | { value: 'create', label: t('system.create') }, 68 | { value: 'update', label: t('system.update') }, 69 | { value: 'delete', label: t('system.delete') }, 70 | { value: 'detail', label: t('system.detail') }, 71 | { value: 'export', label: t('system.export') }, 72 | { value: 'status', label: t('system.status') }, 73 | ]; 74 | -------------------------------------------------------------------------------- /src/layouts/utils/helper.ts: -------------------------------------------------------------------------------- 1 | import type { TFunction } from 'i18next'; 2 | import type { TabsData } from '@/stores/tabs'; 3 | import type { MessageInstance } from 'antd/es/message/interface'; 4 | import { LANG, VERSION } from '@/utils/config'; 5 | import axios from 'axios'; 6 | 7 | /** 版本监控 */ 8 | export const versionCheck = async (t: TFunction, messageApi: MessageInstance) => { 9 | if (import.meta.env.MODE === 'development') return; 10 | 11 | try { 12 | const versionLocal = localStorage.getItem(VERSION); 13 | const { 14 | data: { version }, 15 | } = await axios.get('version.json', { 16 | // 添加超时和强制刷新参数 17 | timeout: 5000, 18 | params: { t: Date.now() }, 19 | }); 20 | 21 | // 首次进入则缓存本地数据 22 | if (version && !versionLocal) { 23 | return localStorage.setItem(VERSION, String(version)); 24 | } 25 | 26 | if (version && versionLocal !== String(version)) { 27 | localStorage.setItem(VERSION, String(version)); 28 | // 存储定时器防止被垃圾回收 29 | let reloadTimer: ReturnType | null = null; 30 | 31 | messageApi.info({ 32 | content: t('public.reloadPageMsg'), 33 | key: 'reload', 34 | duration: 10, 35 | onClick: () => { 36 | // 用户点击消息时立即刷新 37 | if (reloadTimer) { 38 | clearTimeout(reloadTimer); 39 | } 40 | window.location.reload(); 41 | }, 42 | }); 43 | 44 | // 自动刷新页面 45 | reloadTimer = setTimeout(() => { 46 | window.location.reload(); 47 | }, 3000); 48 | } 49 | } catch (error) { 50 | console.error('版本检查失败:', error); 51 | } 52 | }; 53 | 54 | /** 55 | * 通过路由获取标签名 56 | * @param tabs - 标签 57 | * @param path - 路由路径 58 | */ 59 | export const getTabTitle = (tabs: TabsData[], path: string): string => { 60 | const lang = localStorage.getItem(LANG); 61 | 62 | for (let i = 0; i < tabs?.length; i++) { 63 | const item = tabs[i]; 64 | 65 | if (item.key === path) { 66 | const { label, labelEn, labelZh } = item; 67 | const result = lang === 'en' ? labelEn : labelZh || label; 68 | return result as string; 69 | } 70 | } 71 | 72 | return ''; 73 | }; 74 | -------------------------------------------------------------------------------- /src/layouts/components/Nav.tsx: -------------------------------------------------------------------------------- 1 | import type { BreadcrumbProps } from 'antd'; 2 | import type { NavData } from '@/menus/utils/helper'; 3 | import { useCallback, useEffect, useMemo, useState } from 'react'; 4 | import { useTranslation } from 'react-i18next'; 5 | import { useCommonStore } from '@/hooks/useCommonStore'; 6 | 7 | interface Props { 8 | className?: string; 9 | list: NavData[]; 10 | } 11 | 12 | function Nav(props: Props) { 13 | const { className, list } = props; 14 | const { i18n } = useTranslation(); 15 | // 是否手机端 16 | const { isPhone } = useCommonStore(); 17 | const [nav, setNav] = useState([]); 18 | 19 | // 数据处理 20 | const handleList = useCallback( 21 | (list: NavData[]) => { 22 | const result: BreadcrumbProps['items'] = []; 23 | if (!list?.length) return []; 24 | // 获取当前语言 25 | const currentLanguage = i18n.language; 26 | 27 | for (let i = 0; i < list?.length; i++) { 28 | const item = list?.[i]; 29 | const data = currentLanguage === 'en' ? item.labelEn : item.labelZh; 30 | result.push({ 31 | title: data || '', 32 | }); 33 | } 34 | 35 | return result; 36 | }, 37 | [i18n.language], 38 | ); 39 | 40 | useEffect(() => { 41 | setNav(handleList(list)); 42 | }, [handleList, list]); 43 | 44 | return useMemo( 45 | () => ( 46 | <> 47 | {!isPhone && ( 48 |
49 | {nav?.map((item, index) => ( 50 | 51 | {index !== 0 && ( 52 | / 53 | )} 54 | 57 | {item.title} 58 | 59 | 60 | ))} 61 |
62 | )} 63 | 64 | ), 65 | // eslint-disable-next-line react-hooks/exhaustive-deps 66 | [nav], 67 | ); 68 | } 69 | 70 | export default Nav; 71 | -------------------------------------------------------------------------------- /src/components/Ellipsis/index.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip } from 'antd'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | /** 5 | * 文字超出省略组件 6 | */ 7 | 8 | interface Props { 9 | tooltip?: boolean; // 移动到文本展示完整内容的提示 10 | length?: number; // 在按照长度截取下的文本最大字符数,超过则截取省略 11 | lines?: number; // 在按照行数截取下最大的行数,超过则截取省略 12 | fullWidthRecognition?: boolean; // 是否将全角字符的长度视为 2 来计算字符串长度 13 | children: string; 14 | } 15 | 16 | function Ellipsis(props: Props) { 17 | const { tooltip, length, lines, fullWidthRecognition, children } = props; 18 | const [content, setContent] = useState(''); 19 | 20 | useEffect(() => { 21 | if (children !== content) { 22 | let con = children; 23 | 24 | if (length && length > 0) { 25 | if (fullWidthRecognition) { 26 | con = countFullWidthChars(children, length); 27 | } else { 28 | con = con.substring(0, length) + '...'; 29 | } 30 | } 31 | 32 | setContent(con); 33 | } 34 | }, [children, content, fullWidthRecognition, length]); 35 | 36 | /** 37 | * 计算全角数量 38 | * @param str 39 | * @param len 40 | * @returns 41 | */ 42 | const countFullWidthChars = (str: string, len: number) => { 43 | let count = 0, 44 | result = ''; 45 | for (let i = 0; i < str.length; i++) { 46 | const charCode = str.charCodeAt(i); 47 | // 判断是否为全角字符(这里只判断了基本的中文字符范围,如果需要更精确的判断,可以扩展范围) 48 | if (charCode >= 0x4e00 && charCode <= 0x9fa5) { 49 | count += 2; 50 | } else { 51 | count += 1; 52 | } 53 | 54 | if (count > len) return result + '...'; 55 | result += str[i]; 56 | if (count === len) { 57 | return result + '...'; 58 | } 59 | } 60 | return result; 61 | }; 62 | 63 | const renderContent = ( 64 |
65 | 72 | {content} 73 | 74 |
75 | ); 76 | 77 | return ( 78 | <> 79 | {tooltip && {renderContent}} 80 | {!tooltip && <>{renderContent}} 81 | 82 | ); 83 | } 84 | 85 | export default Ellipsis; 86 | -------------------------------------------------------------------------------- /src/components/I18n/index.tsx: -------------------------------------------------------------------------------- 1 | import type { MenuProps } from 'antd'; 2 | import { Dropdown } from 'antd'; 3 | import { Icon } from '@iconify/react'; 4 | import { LANG } from '@/utils/config'; 5 | import { setTitle } from '@/utils/helper'; 6 | import { getTabTitle } from '@/layouts/utils/helper'; 7 | import { useShallow } from 'zustand/react/shallow'; 8 | 9 | export type Langs = 'zh' | 'en'; 10 | 11 | function I18n() { 12 | const { t, i18n } = useTranslation(); 13 | const { pathname, search } = useLocation(); 14 | const { tabs } = useTabsStore(useShallow((state) => state)); 15 | 16 | useEffect(() => { 17 | const lang = localStorage.getItem(LANG); 18 | // 获取当前语言 19 | const currentLanguage = i18n.language; 20 | 21 | if (!lang) { 22 | localStorage.setItem(LANG, 'zh'); 23 | i18n.changeLanguage('zh'); 24 | } else if (currentLanguage !== lang) { 25 | i18n.changeLanguage(lang); 26 | } 27 | // eslint-disable-next-line react-hooks/exhaustive-deps 28 | }, []); 29 | 30 | // 下拉菜单内容 31 | const items: MenuProps['items'] = [ 32 | { 33 | key: 'zh', 34 | label: 中文, 35 | }, 36 | { 37 | key: 'en', 38 | label: English, 39 | }, 40 | ]; 41 | 42 | /** 43 | * 设置浏览器标签 44 | * @param list - 菜单列表 45 | * @param path - 路径 46 | */ 47 | const handleSetTitle = useCallback(() => { 48 | const path = `${pathname}${search || ''}`; 49 | // 通过路由获取标签名 50 | const title = getTabTitle(tabs, path); 51 | if (title) setTitle(t, title); 52 | // eslint-disable-next-line react-hooks/exhaustive-deps 53 | }, [pathname]); 54 | 55 | /** 点击更换语言 */ 56 | const onClick: MenuProps['onClick'] = (e) => { 57 | i18n.changeLanguage(e.key as Langs); 58 | localStorage.setItem(LANG, e.key); 59 | handleSetTitle(); 60 | }; 61 | 62 | return ( 63 | 64 |
e.preventDefault()} 67 | > 68 | 72 |
73 |
74 | ); 75 | } 76 | 77 | export default I18n; 78 | -------------------------------------------------------------------------------- /src/locales/zh/public.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | currentName: '后台管理系统', 3 | total: '总数', 4 | date: '日期', 5 | search: '搜索', 6 | clear: '清除', 7 | reset: '重置', 8 | create: '新增', 9 | edit: '编辑', 10 | delete: '删除', 11 | batchDelete: '批量删除', 12 | inputPleaseEnter: '请输入', 13 | inputPleaseSelect: '请选择', 14 | createTitle: '新增{{title}}', 15 | editTitle: '编辑{{title}}', 16 | pleaseEnter: '请输入{{name}}', 17 | pleaseSelect: '请选择{{name}}', 18 | confirmMessage: '确定要{{name}}吗?', 19 | deleteConfirmMessage: '确定要删除{{name}}吗?', 20 | batchDeleteConfirmMessage: '确定要批量删除{{name}}吗?', 21 | successfulOperation: '操作成功', 22 | successfullyDeleted: '删除成功', 23 | checkAll: '全选', 24 | checkAllWarning: '表格筛选必须勾选一个', 25 | fullScreen: '全屏', 26 | exitFullscreen: '退出全屏', 27 | themes: '主题模式', 28 | changePassword: '修改密码', 29 | signOut: '退出登录', 30 | signOutMessage: '是否确定退出系统?', 31 | kindTips: '温馨提示', 32 | reload: '重新加载', 33 | closeTab: '关闭标签', 34 | closeOther: '关闭其他', 35 | closeLeft: '关闭左侧', 36 | closeRight: '关闭右侧', 37 | confirm: '确认', 38 | cancel: '取消', 39 | operate: '操作', 40 | submit: '提交', 41 | back: '返回', 42 | show: '显示', 43 | hide: '隐藏', 44 | open: '开启', 45 | close: '关闭', 46 | ok: '确定', 47 | copy: '复制', 48 | copySuccessfully: '复制成功', 49 | copyFailed: '复制失败', 50 | maximize: '最大化', 51 | exitMaximized: '退出最大化', 52 | totalNum: '共{{num}}条数据', 53 | name: '名称', 54 | creationTime: '创建时间', 55 | updateTime: '更新时间', 56 | columnFilter: '列筛选', 57 | refresh: '刷新', 58 | refreshSuccessfully: '刷新成功', 59 | notSearchContent: '暂无搜索内容', 60 | switch: '切换', 61 | content: '内容', 62 | title: '标题', 63 | type: '类型', 64 | refreshPage: '刷新页面', 65 | returnHome: '返回首页', 66 | pageErrorTitle: '页面出现错误', 67 | reloadPageMsg: '发现新内容,自动更新中...', 68 | pagepageErrorSubTitle: '抱歉,页面出现了错误,无法正常显示内容。', 69 | notPermissionMessage: '当前页面无法访问,可能没权限或已删除!', 70 | notFindMessage: '当前页面无法访问,可能没权限或已删除', 71 | requiredForm: '{{label}}为必填项', 72 | validateEmail: '{{label}}不是邮箱格式!', 73 | validateNumber: '{{label}}不是数字格式!', 74 | validateRange: '{{label}}必须大于{{min}}且小于{{max}}', 75 | createMethodWarning: '新增组件缺少对应方法', 76 | getPageWarning: '缺少获取页面方法', 77 | tableSelectWarning: '请勾选表格数据', 78 | menuSearchPlaceholder: '请输入菜单名称', 79 | noMoreData: '没有更多数据', 80 | noLoginVisit: '未登录无法访问', 81 | loading: '加载中', 82 | uploadFile: '上传文件', 83 | }; 84 | -------------------------------------------------------------------------------- /src/pages/dashboard/index.tsx: -------------------------------------------------------------------------------- 1 | import { searchList } from './model'; 2 | import { useEffectOnActive } from 'keepalive-for-react'; 3 | import { getDataTrends } from '@/servers/dashboard'; 4 | import Bar from './components/Bar'; 5 | import Line from './components/Line'; 6 | import Block from './components/Block'; 7 | 8 | // 初始化搜索 9 | const initSearch = { 10 | pay_date: ['2022-10-19', '2022-10-29'], 11 | }; 12 | 13 | function Dashboard() { 14 | const { t } = useTranslation(); 15 | const [isLoading, setLoading] = useState(false); 16 | const { permissions, isPhone } = useCommonStore(); 17 | const isPermission = checkPermission('/dashboard', permissions); 18 | 19 | /** 20 | * 搜索提交 21 | * @param values - 表单返回数据 22 | */ 23 | const handleSearch = useCallback(async (values: BaseFormData) => { 24 | // 数据转换 25 | values.all_pay = values.all_pay ? 1 : undefined; 26 | 27 | const query = { ...values }; 28 | try { 29 | setLoading(true); 30 | await getDataTrends(query); 31 | } finally { 32 | setLoading(false); 33 | } 34 | }, []); 35 | 36 | useEffect(() => { 37 | handleSearch(initSearch); 38 | }, [handleSearch]); 39 | 40 | useEffectOnActive(() => { 41 | console.log('进入和退出时执行'); 42 | 43 | return () => { 44 | console.log('退出时执行'); 45 | }; 46 | }, []); 47 | 48 | useEffectOnActive(() => { 49 | console.log('第二次进入和退出时执行'); 50 | 51 | return () => { 52 | console.log('第二次退出时执行'); 53 | }; 54 | }, []); 55 | 56 | return ( 57 | 58 | 59 | 66 | 67 | 68 | 69 |
70 | 71 |
72 | 73 |
74 |
75 | 76 |
77 |
78 | 79 |
80 |
81 |
82 |
83 | ); 84 | } 85 | 86 | export default Dashboard; 87 | -------------------------------------------------------------------------------- /public/loading.css: -------------------------------------------------------------------------------- 1 | .ma-mskLoading { 2 | width: 100%; 3 | height: 100vh; 4 | background: #ffffff; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | box-sizing: border-box; 9 | position: fixed; 10 | left: 0; 11 | right: 0; 12 | top: 0; 13 | bottom: 0; 14 | z-index: -1; 15 | } 16 | 17 | .ma-line-scale > div { 18 | background-color: #607d8b; 19 | width: 4px; 20 | height: 35px; 21 | border-radius: 2px; 22 | margin: 2px; 23 | -webkit-animation-fill-mode: both; 24 | animation-fill-mode: both; 25 | display: inline-block; 26 | } 27 | 28 | .ma-line-scale > div:first-child { 29 | -webkit-animation: line-scale-data 1s cubic-bezier(0.2, 0.68, 0.18, 1.08) -0.4s infinite; 30 | animation: line-scale-data 1s cubic-bezier(0.2, 0.68, 0.18, 1.08) -0.4s infinite; 31 | } 32 | 33 | .ma-line-scale > div:nth-child(2) { 34 | -webkit-animation: line-scale-data 1s cubic-bezier(0.2, 0.68, 0.18, 1.08) -0.3s infinite; 35 | animation: line-scale-data 1s cubic-bezier(0.2, 0.68, 0.18, 1.08) -0.3s infinite; 36 | } 37 | 38 | .ma-line-scale > div:nth-child(3) { 39 | -webkit-animation: line-scale-data 1s cubic-bezier(0.2, 0.68, 0.18, 1.08) -0.2s infinite; 40 | animation: line-scale-data 1s cubic-bezier(0.2, 0.68, 0.18, 1.08) -0.2s infinite; 41 | } 42 | 43 | .ma-line-scale > div:nth-child(4) { 44 | -webkit-animation: line-scale-data 1s cubic-bezier(0.2, 0.68, 0.18, 1.08) -0.1s infinite; 45 | animation: line-scale-data 1s cubic-bezier(0.2, 0.68, 0.18, 1.08) -0.1s infinite; 46 | } 47 | 48 | .ma-line-scale > div:nth-child(5) { 49 | -webkit-animation: line-scale-data 1s cubic-bezier(0.2, 0.68, 0.18, 1.08) 0s infinite; 50 | animation: line-scale-data 1s cubic-bezier(0.2, 0.68, 0.18, 1.08) 0s infinite; 51 | } 52 | 53 | @-webkit-keyframes line-scale-data { 54 | 0% { 55 | -webkit-transform: scaley(1); 56 | transform: scaley(1); 57 | } 58 | 59 | 50% { 60 | -webkit-transform: scaley(0.4); 61 | transform: scaley(0.4); 62 | } 63 | 64 | to { 65 | -webkit-transform: scaley(1); 66 | transform: scaley(1); 67 | } 68 | } 69 | 70 | @keyframes line-scale-data { 71 | 0% { 72 | -webkit-transform: scaley(1); 73 | transform: scaley(1); 74 | } 75 | 76 | 50% { 77 | -webkit-transform: scaley(0.4); 78 | transform: scaley(0.4); 79 | } 80 | 81 | to { 82 | -webkit-transform: scaley(1); 83 | transform: scaley(1); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/components/Selects/ApiSelect.tsx: -------------------------------------------------------------------------------- 1 | import type { ApiSelectProps } from './types'; 2 | import type { DefaultOptionType } from 'antd/es/select'; 3 | import { Select } from 'antd'; 4 | import { useTranslation } from 'react-i18next'; 5 | import { useState, useEffect, useCallback } from 'react'; 6 | import { MAX_TAG_COUNT } from './index'; 7 | import Loading from './components/Loading'; 8 | 9 | /** 10 | * @description: 根据API获取数据下拉组件 11 | */ 12 | function ApiSelect(props: ApiSelectProps) { 13 | const { t } = useTranslation(); 14 | const [isLoading, setLoading] = useState(false); 15 | const [options, setOptions] = useState([]); 16 | 17 | // 清除自定义属性 18 | const params: Partial = { ...props }; 19 | delete params.api; 20 | delete params.params; 21 | delete params.apiResultKey; 22 | 23 | /** 获取接口数据 */ 24 | const getApiData = useCallback(async () => { 25 | if (!props.api) return; 26 | try { 27 | const { api, params, apiResultKey } = props; 28 | 29 | setLoading(true); 30 | if (api) { 31 | const apiFun = Array.isArray(params) ? api(...params) : api(params); 32 | const { code, data } = await apiFun; 33 | if (Number(code) !== 200) return; 34 | const result = apiResultKey 35 | ? (data as { [apiResultKey: string]: unknown })?.[apiResultKey] 36 | : data; 37 | setOptions(result as DefaultOptionType[]); 38 | } 39 | } finally { 40 | setLoading(false); 41 | } 42 | }, [props]); 43 | 44 | useEffect(() => { 45 | // 当有值且列表为空时,自动获取接口 46 | if (props.value && options.length === 0) { 47 | getApiData(); 48 | } 49 | // eslint-disable-next-line react-hooks/exhaustive-deps 50 | }, [props.value]); 51 | 52 | /** 53 | * 展开下拉回调 54 | * @param open - 是否展开 55 | */ 56 | const onOpenChange = (open: boolean) => { 57 | if (open) getApiData(); 58 | 59 | props.onOpenChange?.(open); 60 | }; 61 | 62 | return ( 63 |