├── .commitlintrc.json ├── .env ├── .env.development ├── .env.production ├── .env.test ├── .gitignore ├── .husky └── commit-msg ├── .prettierignore ├── .prettierrc ├── .stylelintignore ├── .vscode ├── extensions.json ├── settings.json └── south.code-snippets ├── LICENSE ├── README.md ├── build ├── plugins │ ├── autoImport.ts │ ├── index.ts │ ├── nojekyll.ts │ ├── time.ts │ └── version.ts ├── utils │ └── helper.ts └── vite │ ├── build.ts │ └── proxy.ts ├── eslint.config.js ├── index.html ├── package.json ├── packages ├── message │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── request │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── request.ts │ │ └── types.ts │ └── tsconfig.json ├── stylelintConfig │ ├── README.md │ ├── index.mjs │ └── package.json └── utils │ ├── package.json │ ├── src │ ├── crypto.ts │ ├── index.ts │ └── local.ts │ └── tsconfig.json ├── pnpm-workspace.yaml ├── public ├── loading.css ├── logo.svg └── upgrade.css ├── src ├── assets │ ├── css │ │ ├── antd.less │ │ ├── default.less │ │ ├── public.less │ │ ├── reset.less │ │ ├── scrollbar.less │ │ ├── theme-color.less │ │ └── theme.less │ ├── fonts │ │ ├── DIN.otf │ │ ├── MetroDF.ttf │ │ ├── YouSheBiaoTiHei.ttf │ │ └── font.less │ └── images │ │ ├── avatar.png │ │ └── logo.svg ├── components │ ├── Bottom │ │ └── SubmitBottom.tsx │ ├── Business │ │ ├── Selects │ │ │ ├── GameSelect.tsx │ │ │ └── PartnerSelect.tsx │ │ └── index.tsx │ ├── Buttons │ │ ├── components │ │ │ ├── BaseBtn.tsx │ │ │ ├── DeleteBtn.tsx │ │ │ └── UpdateBtn.tsx │ │ └── index.ts │ ├── Card │ │ └── BaseCard.tsx │ ├── Content │ │ └── BaseContent.tsx │ ├── Copy │ │ ├── CopyBtn.tsx │ │ └── CopyInput.tsx │ ├── Count │ │ └── index.tsx │ ├── Dates │ │ ├── components │ │ │ ├── BaseDatePicker.tsx │ │ │ ├── BaseRangePicker.tsx │ │ │ ├── BaseTimePicker.tsx │ │ │ └── BaseTimeRangePicker.tsx │ │ ├── index.ts │ │ └── utils │ │ │ └── helper.ts │ ├── Ellipsis │ │ └── index.tsx │ ├── Form │ │ ├── BaseForm.tsx │ │ ├── components │ │ │ └── LoadingComponent.tsx │ │ └── utils │ │ │ ├── componentMap.tsx │ │ │ └── helper.tsx │ ├── Fullscreen │ │ └── index.tsx │ ├── Github │ │ └── index.tsx │ ├── GlobalSearch │ │ ├── components │ │ │ ├── SearchFooter.tsx │ │ │ ├── SearchModal.tsx │ │ │ └── SearchResult.tsx │ │ ├── index.module.less │ │ └── index.tsx │ ├── I18n │ │ └── index.tsx │ ├── Modal │ │ ├── BaseModal.tsx │ │ └── index.less │ ├── Pagination │ │ ├── BasePagination.tsx │ │ └── index.less │ ├── PasswordStrength │ │ ├── components │ │ │ └── StrengthBar.tsx │ │ └── index.tsx │ ├── Search │ │ └── BaseSearch.tsx │ ├── Selects │ │ ├── ApiPageSelect.tsx │ │ ├── ApiSelect.tsx │ │ ├── ApiTreeSelect.tsx │ │ ├── BaseSelect.tsx │ │ ├── BaseTreeSelect.tsx │ │ ├── components │ │ │ └── Loading.tsx │ │ ├── index.ts │ │ └── types.ts │ ├── Table │ │ ├── BaseTable.tsx │ │ ├── components │ │ │ ├── DragContent.tsx │ │ │ ├── EllipsisText.tsx │ │ │ ├── ResizableTitle.tsx │ │ │ ├── TableFilter.tsx │ │ │ └── VirtualWrapper.tsx │ │ ├── hooks │ │ │ ├── useFiler.ts │ │ │ └── useVirtual.tsx │ │ ├── index.less │ │ └── utils │ │ │ ├── helper.ts │ │ │ ├── reducer.ts │ │ │ └── state.ts │ ├── Theme │ │ ├── index.module.less │ │ └── index.tsx │ ├── Transfer │ │ └── BaseTransfer.tsx │ ├── Upload │ │ └── BaseUpload.tsx │ └── WangEditor │ │ └── index.tsx ├── hooks │ ├── useClipboard.ts │ ├── useCommonStore.ts │ ├── useEcharts.ts │ ├── useFullscreen.ts │ ├── useKeyStroke.ts │ ├── useLogout.ts │ ├── useSearchUrlParams.ts │ ├── useSingleTab.ts │ ├── useTime.ts │ ├── useToken.ts │ └── useWatermark.ts ├── layouts │ ├── components │ │ ├── DraggableTabNode.tsx │ │ ├── ErrorBoundary.tsx │ │ ├── Header.tsx │ │ ├── Menu.tsx │ │ ├── Nav.tsx │ │ ├── TabMaximize.tsx │ │ ├── TabOptions.tsx │ │ ├── TabRefresh.tsx │ │ ├── Tabs.tsx │ │ └── UpdatePassword.tsx │ ├── hooks │ │ └── useDropdownMenu.tsx │ ├── index.module.less │ ├── index.tsx │ └── utils │ │ └── helper.ts ├── locales │ ├── config.ts │ ├── en │ │ ├── content.ts │ │ ├── dashboard.ts │ │ ├── login.ts │ │ ├── public.ts │ │ ├── system.ts │ │ └── systems │ │ │ └── menu.ts │ ├── utils │ │ └── helper.ts │ └── zh │ │ ├── content.ts │ │ ├── dashboard.ts │ │ ├── login.ts │ │ ├── public.ts │ │ ├── system.ts │ │ └── systems │ │ └── menu.ts ├── main.tsx ├── menus │ ├── README.md │ ├── demo.ts │ ├── index.ts │ └── utils │ │ └── helper.ts ├── pages │ ├── 403.tsx │ ├── 404.tsx │ ├── all.module.less │ ├── content │ │ └── article │ │ │ ├── [option].tsx │ │ │ ├── components │ │ │ └── CustomizeInput.tsx │ │ │ ├── index.tsx │ │ │ └── model.ts │ ├── dashboard │ │ ├── components │ │ │ ├── Bar.tsx │ │ │ ├── Block.tsx │ │ │ └── Line.tsx │ │ ├── index.tsx │ │ └── model.ts │ ├── demo │ │ ├── [id] │ │ │ └── dynamic │ │ │ │ └── index.tsx │ │ ├── copy │ │ │ └── index.tsx │ │ ├── editor │ │ │ └── index.tsx │ │ ├── level1 │ │ │ └── level2 │ │ │ │ └── level3.tsx │ │ ├── virtualScroll │ │ │ ├── components │ │ │ │ ├── VirtualList.tsx │ │ │ │ └── VirtualTable.tsx │ │ │ └── index.tsx │ │ └── watermark │ │ │ └── index.tsx │ ├── forget │ │ └── index.tsx │ ├── index.tsx │ ├── login │ │ ├── index.tsx │ │ └── model.ts │ └── system │ │ ├── menu │ │ ├── components │ │ │ ├── IconInput.tsx │ │ │ └── StateSwitch.tsx │ │ ├── index.tsx │ │ └── model.tsx │ │ ├── role │ │ ├── components │ │ │ ├── AuthorizeSelect.tsx │ │ │ └── MenuAuthorize.tsx │ │ ├── index.tsx │ │ └── model.tsx │ │ └── user │ │ ├── components │ │ └── PermissionDrawer.tsx │ │ ├── index.tsx │ │ └── model.tsx ├── router │ ├── components │ │ ├── Guards.tsx │ │ └── Router.tsx │ ├── index.tsx │ └── utils │ │ ├── config.ts │ │ └── helper.tsx ├── servers │ ├── content │ │ └── article.ts │ ├── dashboard │ │ └── index.ts │ ├── login │ │ └── index.ts │ ├── platform │ │ ├── game.ts │ │ └── partner.ts │ └── system │ │ ├── menu.ts │ │ ├── role.ts │ │ └── user.ts ├── stores │ ├── index.ts │ ├── menu.ts │ ├── public.ts │ ├── tabs.ts │ └── user.ts ├── utils │ ├── config.ts │ ├── constants.ts │ ├── helper.ts │ ├── is.ts │ ├── permissions.ts │ └── request.ts └── vite-env.d.ts ├── stylelint.config.mjs ├── tsconfig.json ├── tsconfig.node.json ├── types ├── form.ts └── public.ts └── vite.config.ts /.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 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # 加密密钥 2 | VITE_SECRET_KEY = "__Vite_Admin_Secret__" 3 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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" -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no-install commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.md 3 | autoImports.d.ts -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | /dist/* 2 | /public/* 3 | public/* 4 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 southliu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /build/plugins/autoImport.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from 'vite'; 2 | import path from 'path'; 3 | import AutoImport from 'unplugin-auto-import/vite'; 4 | 5 | /** 6 | * 自动导入处理 7 | */ 8 | export const autoImportPlugin = (): PluginOption => { 9 | return AutoImport({ 10 | dirs: [ 11 | 'src/hooks/**', 12 | 'src/components/**', 13 | 'src/stores/**', 14 | 'types/**', 15 | 'src/utils/permissions.ts', 16 | 'src/utils/config.ts', 17 | ], 18 | imports: [ 19 | 'react', 20 | 'react-router', 21 | 'react-router-dom', 22 | 'react-i18next', 23 | { from: 'react', imports: ['FC'], type: true }, 24 | ], 25 | dts: 'types/autoImports.d.ts', 26 | include: [/\.[tj]sx?$/], 27 | resolvers: [ 28 | (name) => { 29 | // 处理 @/ 开头的路径别名 30 | if (name.startsWith('@/')) { 31 | return { 32 | from: name.replace('@/', path.resolve(__dirname, 'src/') + '/'), 33 | }; 34 | } 35 | }, 36 | ], 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /build/plugins/index.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from 'vite'; 2 | import { visualizer } from 'rollup-plugin-visualizer'; 3 | import { timePlugin } from './time'; 4 | import { autoImportPlugin } from './autoImport'; 5 | import { versionUpdatePlugin } from './version'; 6 | import react from '@vitejs/plugin-react-swc'; 7 | import unocss from 'unocss/vite'; 8 | import viteCompression from 'vite-plugin-compression'; 9 | import { nojekyllPlugin } from './nojekyll'; 10 | 11 | export function createVitePlugins() { 12 | // 插件参数 13 | const vitePlugins: PluginOption[] = [ 14 | react(), 15 | unocss(), 16 | // 版本控制 17 | versionUpdatePlugin(), 18 | // 自动导入 19 | autoImportPlugin(), 20 | // 包分析 21 | visualizer({ 22 | gzipSize: true, 23 | brotliSize: true, 24 | }), 25 | // 打包时间 26 | timePlugin(), 27 | // 压缩包 28 | viteCompression(), 29 | // 生成 .nojekyll 空文件 30 | nojekyllPlugin(), 31 | ]; 32 | 33 | return vitePlugins; 34 | } 35 | -------------------------------------------------------------------------------- /build/plugins/nojekyll.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from 'vite'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | /** 6 | * 打包完后生成 .nojekyll 空文件 7 | * Github Pages 默认基于 Jekyll 构建,会忽略下划线开头的文件 8 | * https://github.com/southliu/south-admin-react/issues/276 9 | */ 10 | export const nojekyllPlugin = (): PluginOption => { 11 | return { 12 | name: 'vite-create-nojekyll', 13 | // 在服务器关闭时被调用 14 | closeBundle: () => { 15 | console.timeEnd('打包时间'); 16 | 17 | // 构建完成后在 dist 目录创建 .nojekyll 文件 18 | const distPath = path.resolve(process.cwd(), 'dist'); 19 | const noJekyllPath = path.join(distPath, '.nojekyll'); 20 | 21 | try { 22 | fs.writeFileSync(noJekyllPath, ''); 23 | console.log('生成.nojekyll成功'); 24 | } catch (error) { 25 | console.error('生成.nojekyll失败:', error); 26 | } 27 | }, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /build/plugins/time.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from 'vite'; 2 | 3 | /** 4 | * 显示打包时间插件 5 | */ 6 | export const timePlugin = (): PluginOption => { 7 | return { 8 | name: 'vite-build-time', 9 | enforce: 'pre', 10 | apply: 'build', 11 | buildStart: () => { 12 | console.time('打包时间'); 13 | }, 14 | buildEnd: () => { 15 | // console.timeEnd('\n模块转义完成时间') 16 | }, 17 | // 在服务器关闭时被调用 18 | closeBundle: () => { 19 | console.timeEnd('打包时间'); 20 | }, 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /build/plugins/version.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from 'vite'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | export const versionUpdatePlugin = (): PluginOption => { 6 | let outDir = ''; 7 | 8 | return { 9 | name: 'version-update', 10 | configResolved(resolvedConfig) { 11 | // 存储最终解析的配置 12 | outDir = resolvedConfig?.build?.outDir || 'dist'; 13 | }, 14 | closeBundle() { 15 | // 这里使用编译时间作为版本信息 16 | const content = JSON.stringify({ version: new Date().getTime() }); 17 | // 定义 version.json 文件路径 18 | const filePath = path.join(outDir, 'version.json'); 19 | 20 | // 如果outDir目录不存在则创建 21 | if (outDir && !fs.existsSync(outDir)) { 22 | fs.mkdirSync(outDir, { recursive: true }); 23 | } 24 | 25 | // 将 JSON 数据写入文件 26 | fs.writeFileSync(filePath, content, 'utf-8'); 27 | }, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /build/utils/helper.ts: -------------------------------------------------------------------------------- 1 | type EnvConfigs = Record; 2 | 3 | // env数据 4 | interface ViteEnv { 5 | VITE_SERVER_PORT: number; 6 | VITE_PROXY: [string, string][]; 7 | } 8 | 9 | /** 10 | * 处理转化env 11 | * @param envConfigs 12 | */ 13 | export function handleEnv(envConfigs: EnvConfigs): ViteEnv { 14 | const { VITE_SERVER_PORT, VITE_PROXY } = envConfigs; 15 | 16 | const proxy: [string, string][] = VITE_PROXY ? JSON.parse(VITE_PROXY.replace(/'/g, '"')) : []; 17 | 18 | const res: ViteEnv = { 19 | VITE_SERVER_PORT: Number(VITE_SERVER_PORT) || 8080, 20 | VITE_PROXY: proxy, 21 | }; 22 | 23 | return res; 24 | } 25 | 26 | /** 27 | * JS模块分包 28 | * @param id - 标识符 29 | */ 30 | export function splitJSModules(id: string) { 31 | // pnpm兼容 32 | const pnpmName = id.includes('.pnpm') ? '.pnpm/' : ''; 33 | const fileName = `node_modules/${pnpmName}`; 34 | 35 | const result = id.split(fileName)[1].split('/')[0].toString(); 36 | 37 | return result; 38 | } 39 | -------------------------------------------------------------------------------- /build/vite/build.ts: -------------------------------------------------------------------------------- 1 | import type { BuildOptions } from 'vite'; 2 | import { splitJSModules } from '../utils/helper'; 3 | 4 | /** 5 | * @description 分包配置 6 | */ 7 | export function buildOptions(): BuildOptions { 8 | return { 9 | target: ['es2020'], 10 | chunkSizeWarningLimit: 1000, // 大于1000k才警告 11 | sourcemap: process.env.NODE_ENV !== 'production', // 非生产环境开启 12 | minify: 'terser', 13 | terserOptions: { 14 | compress: { 15 | // 生产环境时移除console和debugger 16 | drop_console: true, 17 | drop_debugger: true, 18 | }, 19 | }, 20 | rollupOptions: { 21 | output: { 22 | chunkFileNames: 'assets/js/[name].[hash].js', 23 | entryFileNames: 'assets/js/[name].[hash].js', 24 | assetFileNames: 'assets/[ext]/[name].[hash].[ext]', 25 | manualChunks(id) { 26 | // JS模块 27 | if (id.includes('node_modules')) { 28 | return splitJSModules(id); 29 | } 30 | }, 31 | }, 32 | }, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /build/vite/proxy.ts: -------------------------------------------------------------------------------- 1 | import type { ProxyOptions } from 'vite'; 2 | 3 | type ProxyList = [string, string][]; 4 | 5 | type ProxyTargetList = Record; 6 | 7 | /** 8 | * 创建跨域 9 | * @param list - 二维数组参数 10 | */ 11 | export function createProxy(list: ProxyList = []) { 12 | const res: ProxyTargetList = {}; 13 | 14 | for (const [prefix, target] of list) { 15 | res[`^${prefix}`] = { 16 | target, 17 | changeOrigin: true, 18 | rewrite: (path) => path.replace(new RegExp(`^${prefix}`), ''), 19 | }; 20 | } 21 | 22 | return res; 23 | } 24 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 后台管理系统 10 | 11 | 12 | 19 | 20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | 30 |
31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /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/message/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { MessageInstance } from 'antd/es/message/interface'; 2 | import type { NotificationInstance } from 'antd/es/notification/interface'; 3 | import type { ModalStaticFunctions } from 'antd/es/modal/confirm'; 4 | import { 5 | message as antdMessage, 6 | notification as antdNotification, 7 | Modal as antdModal, 8 | App, 9 | } from 'antd'; 10 | 11 | let message: MessageInstance = antdMessage; 12 | let notification: NotificationInstance = antdNotification; 13 | 14 | const { ...resetFns } = antdModal; 15 | let modal: Omit = resetFns; 16 | 17 | /** 18 | * 该组件提供静态方法 19 | * 作用:跨页面message显示 20 | */ 21 | function StaticMessage() { 22 | const staticFunctions = App.useApp(); 23 | 24 | message = staticFunctions.message; 25 | notification = staticFunctions.notification; 26 | modal = staticFunctions.modal; 27 | 28 | return null; 29 | } 30 | 31 | export { message, notification, modal }; 32 | 33 | export default StaticMessage; 34 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /packages/request/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { RequestCancel } from './types'; 2 | import { message } from '@south/message'; 3 | import { getLocalInfo, removeLocalInfo } from '@south/utils'; 4 | import axios from 'axios'; 5 | import AxiosRequest from './request'; 6 | 7 | /** 8 | * 创建请求 9 | * @param url - 链接地址 10 | * @param tokenKey - 存token的key值 11 | */ 12 | function creteRequest(url: string, tokenKey: string) { 13 | return new AxiosRequest({ 14 | baseURL: url, 15 | timeout: 180 * 1000, 16 | interceptors: { 17 | // 接口请求拦截 18 | requestInterceptors(res) { 19 | const tokenLocal = getLocalInfo(tokenKey) || ''; 20 | if (res?.headers && tokenLocal) { 21 | res.headers.Authorization = `Bearer ${tokenLocal}` as string; 22 | } 23 | return res; 24 | }, 25 | // 请求拦截超时 26 | requestInterceptorsCatch(err) { 27 | message.error('请求超时!'); 28 | return err; 29 | }, 30 | // 接口响应拦截 31 | responseInterceptors(res) { 32 | const { data } = res; 33 | // 权限不足 34 | if (data?.code === 401) { 35 | const lang = localStorage.getItem('lang'); 36 | const enMsg = 'Insufficient permissions, please log in again!'; 37 | const zhMsg = '权限不足,请重新登录!'; 38 | const msg = lang === 'en' ? enMsg : zhMsg; 39 | removeLocalInfo(tokenKey); 40 | message.error({ 41 | content: msg, 42 | key: 'error', 43 | }); 44 | console.error('错误信息:', data?.message || msg); 45 | 46 | // 跳转登录页 47 | const url = window.location.href; 48 | if (url.includes('#')) { 49 | window.location.hash = '/login'; 50 | } else { 51 | // window.location.href跳转会出现message无法显示情况,所以需要延时 52 | setTimeout(() => { 53 | window.location.href = '/login'; 54 | }, 1000); 55 | } 56 | return res; 57 | } 58 | 59 | // 错误处理 60 | if (data?.code !== 200) { 61 | handleError(data?.message); 62 | return res; 63 | } 64 | 65 | return res; 66 | }, 67 | responseInterceptorsCatch(err) { 68 | // 取消重复请求则不报错 69 | if (axios.isCancel(err)) { 70 | err.data = err.data || {}; 71 | return err; 72 | } 73 | 74 | handleError((err as RequestCancel)?.response?.data?.message || '服务器错误!'); 75 | return err; 76 | }, 77 | }, 78 | }); 79 | } 80 | 81 | /** 82 | * 异常处理 83 | * @param error - 错误信息 84 | * @param content - 自定义内容 85 | */ 86 | const handleError = (error: string, content?: string) => { 87 | console.error('错误信息:', error); 88 | message.error({ 89 | content: content || error || '服务器错误', 90 | key: 'error', 91 | }); 92 | }; 93 | 94 | export { creteRequest }; 95 | export type * from './types'; 96 | -------------------------------------------------------------------------------- /packages/request/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosResponse, InternalAxiosRequestConfig, CreateAxiosDefaults, Cancel } from 'axios'; 2 | 3 | export interface RequestCancel extends Cancel { 4 | data: object; 5 | response: { 6 | status: number; 7 | data: { 8 | code?: number; 9 | message?: string; 10 | }; 11 | }; 12 | } 13 | 14 | export interface RequestInterceptors { 15 | // 请求拦截 16 | requestInterceptors?: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig; 17 | requestInterceptorsCatch?: (err: RequestCancel) => void; 18 | // 响应拦截 19 | responseInterceptors?: (config: T) => T; 20 | responseInterceptorsCatch?: (err: RequestCancel) => void; 21 | } 22 | 23 | // 自定义传入的参数 24 | export interface CreateRequestConfig extends CreateAxiosDefaults { 25 | interceptors?: RequestInterceptors; 26 | } 27 | 28 | // 接口响应数据 29 | export interface ServerResult { 30 | code: number; 31 | message?: string; 32 | data: T; 33 | } 34 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /packages/stylelintConfig/index.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ['stylelint-config-standard', 'stylelint-config-recess-order'], 3 | ignoreFiles: ['**/*.js', '**/*.jsx', '**/*.tsx', '**/*.ts', '**/*.json', '**/*.md'], 4 | overrides: [ 5 | { 6 | customSyntax: 'postcss-html', 7 | files: ['*.(html|vue)', '**/*.(html|vue)'], 8 | rules: { 9 | 'selector-pseudo-class-no-unknown': [ 10 | true, 11 | { 12 | ignorePseudoClasses: ['global', 'deep'], 13 | }, 14 | ], 15 | 'selector-pseudo-element-no-unknown': [ 16 | true, 17 | { 18 | ignorePseudoElements: ['v-deep', 'v-global', 'v-slotted'], 19 | }, 20 | ], 21 | }, 22 | }, 23 | { 24 | customSyntax: 'postcss-less', 25 | extends: ['stylelint-config-recommended-less'], 26 | files: ['*.less', '**/*.less'], 27 | }, 28 | ], 29 | plugins: [ 30 | 'stylelint-order', 31 | '@stylistic/stylelint-plugin', 32 | 'stylelint-prettier', 33 | 'stylelint-less', 34 | ], 35 | rules: { 36 | 'at-rule-no-unknown': [ 37 | true, 38 | { 39 | ignoreAtRules: [ 40 | 'extends', 41 | 'ignores', 42 | 'include', 43 | 'mixin', 44 | 'if', 45 | 'else', 46 | 'media', 47 | 'for', 48 | 'at-root', 49 | 'tailwind', 50 | 'apply', 51 | 'variants', 52 | 'responsive', 53 | 'screen', 54 | 'function', 55 | 'each', 56 | 'use', 57 | 'forward', 58 | 'return', 59 | ], 60 | }, 61 | ], 62 | 'font-family-no-missing-generic-family-keyword': null, 63 | 'function-no-unknown': null, 64 | 'import-notation': null, 65 | 'media-feature-range-notation': null, 66 | 'named-grid-areas-no-invalid': null, 67 | 'no-descending-specificity': null, 68 | 'no-empty-source': null, 69 | 'order/order': [ 70 | [ 71 | 'dollar-variables', 72 | 'custom-properties', 73 | 'at-rules', 74 | 'declarations', 75 | { 76 | name: 'supports', 77 | type: 'at-rule', 78 | }, 79 | { 80 | name: 'media', 81 | type: 'at-rule', 82 | }, 83 | { 84 | name: 'include', 85 | type: 'at-rule', 86 | }, 87 | 'rules', 88 | ], 89 | { severity: 'error' }, 90 | ], 91 | 'prettier/prettier': true, 92 | 'rule-empty-line-before': [ 93 | 'always', 94 | { 95 | ignore: ['after-comment', 'first-nested'], 96 | }, 97 | ], 98 | 'selector-not-notation': null, 99 | }, 100 | }; 101 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /packages/utils/src/crypto.ts: -------------------------------------------------------------------------------- 1 | import { encrypt, decrypt } from 'crypto-js/aes'; 2 | import UTF8 from 'crypto-js/enc-utf8'; 3 | import md5 from 'crypto-js/md5'; 4 | 5 | /** 6 | * @description: 加密/解密封装,secret值建议从后台接口获取 7 | */ 8 | const secretKey = '__Vite_Admin_Secret__'; 9 | 10 | /** 11 | * 加密 12 | * @param data - 加密数据 13 | * @param secret - 加密密钥 14 | */ 15 | export function encryption(data: object, secret: string = secretKey) { 16 | const code = JSON.stringify(data); 17 | return encrypt(code, secret).toString(); 18 | } 19 | 20 | /** 21 | * 解密 22 | * @param data - 解密数据 23 | * @param secret - 解密密钥 24 | */ 25 | export function decryption(data: string, secret: string = secretKey) { 26 | const bytes = decrypt(data, secret); 27 | const originalText = bytes.toString(UTF8); 28 | if (originalText) { 29 | return JSON.parse(originalText); 30 | } 31 | return null; 32 | } 33 | 34 | /** 35 | * md5加密 36 | * @param data - 加密数据 37 | */ 38 | export function encryptMd5(data: string) { 39 | return md5(data).toString(); 40 | } 41 | -------------------------------------------------------------------------------- /packages/utils/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './local'; 2 | export * from './crypto'; 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | // react-activation样式 79 | .ka-wrapper, 80 | .ka-content { 81 | height: 100%; 82 | } 83 | 84 | .small-btn { 85 | padding: 0 10px !important; 86 | height: 29px !important; 87 | line-height: 29px !important; 88 | 89 | span { 90 | font-size: 13px; 91 | } 92 | } 93 | 94 | .content-transition { 95 | transition: 96 | opacity 0.3s ease, 97 | transform 0.3s ease; 98 | } 99 | 100 | .content-hidden { 101 | opacity: 0; 102 | transform: translateY(10px); 103 | } 104 | 105 | .content-visible { 106 | opacity: 1; 107 | transform: translateY(0); 108 | } 109 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/assets/css/theme.less: -------------------------------------------------------------------------------- 1 | /** 2 | * 修改主题 3 | * @param color - 字体颜色 4 | * @param bg-color - 背景颜色 5 | * @param layout-content-bg - layout内容背景色 6 | * @param content-bg-color - 内容背景颜色 7 | * @param svg-color - svg颜色 8 | */ 9 | .changeTheme( 10 | @color, 11 | @bg-color, 12 | @layout-content-bg, 13 | @content-bg-color, 14 | @svg-color 15 | ) { 16 | color: @color !important; 17 | background-color: @bg-color !important; 18 | 19 | .bg { 20 | background-color: @bg-color !important; 21 | } 22 | 23 | .change svg { 24 | color: @svg-color !important; 25 | } 26 | 27 | a, 28 | h1, 29 | h2, 30 | h3, 31 | h4, 32 | h5, 33 | h6 { 34 | color: @color !important; 35 | } 36 | 37 | #layout-content { 38 | background-color: @layout-content-bg !important; 39 | } 40 | 41 | #card { 42 | background-color: @content-bg-color !important; 43 | } 44 | 45 | // 富文本 46 | .w-e-toolbar, 47 | .w-e-bar-item button, 48 | .w-e-text-container, 49 | .w-e-bar-item-menus-container { 50 | color: @color !important; 51 | background-color: @bg-color !important; 52 | fill: @color !important; 53 | } 54 | 55 | #header, 56 | #layout { 57 | color: @color !important; 58 | background-color: @bg-color !important; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/assets/fonts/DIN.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/southliu/south-admin-react/4eeb7c560e62bae409733179130a1caf5a700a98/src/assets/fonts/DIN.otf -------------------------------------------------------------------------------- /src/assets/fonts/MetroDF.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/southliu/south-admin-react/4eeb7c560e62bae409733179130a1caf5a700a98/src/assets/fonts/MetroDF.ttf -------------------------------------------------------------------------------- /src/assets/fonts/YouSheBiaoTiHei.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/southliu/south-admin-react/4eeb7c560e62bae409733179130a1caf5a700a98/src/assets/fonts/YouSheBiaoTiHei.ttf -------------------------------------------------------------------------------- /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/assets/images/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/southliu/south-admin-react/4eeb7c560e62bae409733179130a1caf5a700a98/src/assets/images/avatar.png -------------------------------------------------------------------------------- /src/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/components/Bottom/SubmitBottom.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | import { Button } from 'antd'; 3 | import { useTranslation } from 'react-i18next'; 4 | 5 | interface Props { 6 | goBack: () => void; 7 | handleSubmit: () => void; 8 | isLoading?: boolean; 9 | children?: ReactNode; 10 | } 11 | 12 | function SubmitBottom(props: Props) { 13 | const { t } = useTranslation(); 14 | const { goBack, handleSubmit, isLoading, children } = props; 15 | 16 | return ( 17 |
34 | {children} 35 | 36 | 39 | 42 |
43 | ); 44 | } 45 | 46 | export default SubmitBottom; 47 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /src/components/Dates/components/BaseDatePicker.tsx: -------------------------------------------------------------------------------- 1 | import type { DatePickerProps } from 'antd'; 2 | import { DatePicker } from 'antd'; 3 | import { string2Dayjs } from '../utils/helper'; 4 | 5 | function BaseDatePicker(props: DatePickerProps) { 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 BaseDatePicker; 16 | -------------------------------------------------------------------------------- /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/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/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/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/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/Form/components/LoadingComponent.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from 'react'; 2 | 3 | function LoadingComponent() { 4 | const { t } = useTranslation(); 5 | const [num, setNum] = useState(0); 6 | const timerRef = useRef(null); 7 | 8 | useEffect(() => { 9 | timerRef.current = setInterval(() => { 10 | setNum((prevNum) => { 11 | let num = prevNum + 1; 12 | if (num >= 3) num = 0; 13 | return num; 14 | }); 15 | }, 1000); 16 | 17 | return () => { 18 | if (timerRef.current) { 19 | clearInterval(timerRef.current); 20 | timerRef.current = null; 21 | } 22 | }; 23 | }, []); 24 | 25 | const renderDots = () => { 26 | switch (num) { 27 | case 0: 28 | return '.'; 29 | case 1: 30 | return '..'; 31 | case 2: 32 | return '...'; 33 | default: 34 | return ''; 35 | } 36 | }; 37 | 38 | return ( 39 |
40 |
{t('public.loading')}
41 |
{renderDots()}
42 |
43 | ); 44 | } 45 | 46 | export default LoadingComponent; 47 | -------------------------------------------------------------------------------- /src/components/Fullscreen/index.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip } from 'antd'; 2 | import { Icon } from '@iconify/react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { useFullscreen } from '@/hooks/useFullscreen'; 5 | 6 | /** 7 | * @description: 全屏组件 8 | */ 9 | function Fullscreen() { 10 | const { t } = useTranslation(); 11 | const [isFullscreen, toggleFullscreen] = useFullscreen(); 12 | 13 | return ( 14 | 15 |
19 | 23 | 24 |
25 |
26 | ); 27 | } 28 | 29 | export default Fullscreen; 30 | -------------------------------------------------------------------------------- /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/components/GlobalSearch/components/SearchFooter.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@iconify/react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import styles from '../index.module.less'; 4 | 5 | function SearchFooter() { 6 | const { t } = useTranslation(); 7 | 8 | return ( 9 |
10 | 11 | 15 | {t('public.confirm')} 16 | 17 | 18 | 19 | 20 | {t('public.switch')} 21 | 22 | 23 | 24 | {t('public.close')} 25 | 26 |
27 | ); 28 | } 29 | 30 | export default SearchFooter; 31 | -------------------------------------------------------------------------------- /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/GlobalSearch/index.tsx: -------------------------------------------------------------------------------- 1 | import type { SearchModalProps } from './components/SearchModal'; 2 | import { useRef } from 'react'; 3 | import { Tooltip } from 'antd'; 4 | import { Icon } from '@iconify/react'; 5 | import { useTranslation } from 'react-i18next'; 6 | import SearchModal from './components/SearchModal'; 7 | 8 | /** 9 | * @description: 全局搜索菜单组件 10 | */ 11 | function GlobalSearch() { 12 | const { t } = useTranslation(); 13 | const modalRef = useRef(null); 14 | 15 | /** 切换显示 */ 16 | const toggle = () => { 17 | modalRef.current?.toggle(); 18 | }; 19 | 20 | return ( 21 | <> 22 | 23 | 28 | 29 | 30 | 31 | 32 | ); 33 | } 34 | 35 | export default GlobalSearch; 36 | -------------------------------------------------------------------------------- /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/components/Modal/index.less: -------------------------------------------------------------------------------- 1 | .base-modal { 2 | .ant-modal-header { 3 | margin-bottom: 0; 4 | } 5 | 6 | .ant-modal-content { 7 | padding: 0 !important; 8 | 9 | .modal-custom-title { 10 | box-sizing: border-box; 11 | display: flex; 12 | align-items: center; 13 | justify-content: space-between; 14 | width: 100%; 15 | padding: 16px 24px; 16 | cursor: move; 17 | border-bottom: 1px solid rgb(0 0 0 / 6%); 18 | } 19 | 20 | .ant-modal-footer { 21 | margin-top: 0; 22 | padding: 10px 16px; 23 | border-top: 1px solid rgb(0 0 0 / 6%); 24 | } 25 | } 26 | 27 | .base-modal-content { 28 | padding: 24px; 29 | } 30 | } 31 | 32 | .full-modal { 33 | .ant-modal { 34 | position: absolute; 35 | inset: 0 !important; 36 | max-width: 100%; 37 | padding-bottom: 0; 38 | margin: 0; 39 | } 40 | 41 | .ant-modal-content { 42 | display: flex; 43 | flex-direction: column; 44 | height: calc(100vh); 45 | } 46 | 47 | .ant-modal-body { 48 | flex: 1; 49 | overflow: auto; 50 | } 51 | } 52 | 53 | .theme-dark { 54 | .ant-modal-content { 55 | .modal-custom-title { 56 | border-bottom: 1px solid #383838 !important; 57 | } 58 | 59 | .ant-modal-footer { 60 | border-top: 1px solid #383838 !important; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /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/Pagination/index.less: -------------------------------------------------------------------------------- 1 | .ant-pagination-item { 2 | margin-right: 7px !important; 3 | background-color: #f4f4f5 !important; 4 | } 5 | 6 | .ant-pagination-item-active { 7 | background-color: #0960bd !important; 8 | } 9 | 10 | .ant-pagination-item-active a { 11 | color: #fff !important; 12 | } 13 | 14 | .ant-pagination-prev { 15 | margin-right: 7px !important; 16 | background-color: #f4f4f5 !important; 17 | } 18 | 19 | .ant-pagination-next { 20 | margin-right: 7px !important; 21 | background-color: #f4f4f5 !important; 22 | } 23 | 24 | .theme-dark { 25 | .ant-pagination-item { 26 | margin-right: 7px !important; 27 | background-color: rgb(29 29 29) !important; 28 | } 29 | 30 | .ant-pagination-item-active { 31 | background-color: #0960bd !important; 32 | } 33 | 34 | .ant-pagination-item-active a { 35 | color: #ffffffd9 !important; 36 | } 37 | 38 | .ant-pagination-prev { 39 | margin-right: 7px !important; 40 | background-color: rgb(29 29 29) !important; 41 | } 42 | 43 | .ant-pagination-next { 44 | margin-right: 7px !important; 45 | background-color: rgb(29 29 29) !important; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /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/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/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 | 31 | ); 32 | } 33 | 34 | export default BaseSelect; 35 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/Table/components/DragContent.tsx: -------------------------------------------------------------------------------- 1 | import type { CheckboxList } from './TableFilter'; 2 | import { type DragEndEvent, DndContext } from '@dnd-kit/core'; 3 | import { CSS } from '@dnd-kit/utilities'; 4 | import { Icon } from '@iconify/react'; 5 | import { Checkbox } from 'antd'; 6 | import { 7 | SortableContext, 8 | arrayMove, 9 | useSortable, 10 | verticalListSortingStrategy, 11 | } from '@dnd-kit/sortable'; 12 | 13 | interface SortableItemProps { 14 | item: CheckboxList; 15 | index: number; 16 | } 17 | 18 | interface DragContentProps { 19 | list: CheckboxList[]; 20 | handleDragEnd: (list: CheckboxList[]) => void; 21 | } 22 | 23 | // 排序组件 24 | function SortableItem(props: SortableItemProps) { 25 | const { item } = props; 26 | const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ 27 | id: item.value, // 每个可拖拽对象的唯一标识 28 | }); 29 | 30 | const style = { 31 | transform: CSS.Transform.toString(transform), 32 | transition, 33 | }; 34 | 35 | return ( 36 |
49 |
53 | 54 |
55 | 56 | {item.label} 57 | 58 |
59 | ); 60 | } 61 | 62 | // 拖拽组件 63 | function DragContent(props: DragContentProps) { 64 | const { list, handleDragEnd } = props; 65 | 66 | /** 拖拽结束操作 */ 67 | const onDragEnd = (event: DragEndEvent) => { 68 | const { active, over } = event; 69 | if (!over) return; 70 | 71 | // 计算拖拽后的新顺序 72 | const oldIndex = list.findIndex((item) => item.value === active.id); 73 | const newIndex = list.findIndex((item) => item.value === over.id); 74 | const newColumns = arrayMove(list, oldIndex, newIndex); 75 | handleDragEnd(newColumns); 76 | }; 77 | 78 | return ( 79 | 80 | item.value)} 82 | strategy={verticalListSortingStrategy} 83 | > 84 | {list?.map((item, index) => )} 85 | 86 | 87 | ); 88 | } 89 | 90 | export default DragContent; 91 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/components/Table/components/ResizableTitle.tsx: -------------------------------------------------------------------------------- 1 | import type { ResizeCallbackData } from 'react-resizable'; 2 | import React from 'react'; 3 | import { Resizable } from 'react-resizable'; 4 | 5 | /** 自定义拖拽 */ 6 | function ResizableTitle( 7 | props: React.HTMLAttributes & { 8 | onResize: (e: React.SyntheticEvent, data: ResizeCallbackData) => void; 9 | width: number; 10 | }, 11 | ) { 12 | const { onResize, width, ...restProps } = props; 13 | 14 | if (!width) { 15 | return ; 16 | } 17 | 18 | return ( 19 | { 26 | e.stopPropagation(); 27 | }} 28 | /> 29 | } 30 | onResize={onResize} 31 | draggableOpts={{ enableUserSelectHack: false }} 32 | > 33 | 34 | 35 | ); 36 | } 37 | 38 | export default ResizableTitle; 39 | -------------------------------------------------------------------------------- /src/components/Table/components/VirtualWrapper.tsx: -------------------------------------------------------------------------------- 1 | import type { DetailedHTMLProps, HTMLAttributes, ReactNode } from 'react'; 2 | import { useContext } from 'react'; 3 | import { ScrollContext } from '../utils/state'; 4 | 5 | type Props = DetailedHTMLProps, HTMLTableSectionElement>; 6 | 7 | function VirtualWrapper(props: Props): ReactNode { 8 | const { children, ...restProps } = props; 9 | const { renderLen, start, offsetStart } = useContext(ScrollContext); 10 | let tempNode = null; 11 | 12 | if (children && children !== null) { 13 | const contents = (children as ReactNode[])?.[1]; 14 | 15 | if (Array.isArray(contents) && contents.length) { 16 | tempNode = [ 17 | (children as ReactNode[])?.[0], 18 | contents.slice(start, start + renderLen).map((item) => { 19 | if (Array.isArray(item)) { 20 | // 兼容antd v4.3.5 --- rc-table 7.8.1及以下 21 | return item[0]; 22 | } 23 | // 处理antd ^v4.4.0 --- rc-table ^7.8.2 24 | return item; 25 | }), 26 | ]; 27 | } else { 28 | tempNode = children; 29 | } 30 | } 31 | 32 | return ( 33 | 34 | {tempNode} 35 | 36 | ); 37 | } 38 | 39 | export default VirtualWrapper; 40 | -------------------------------------------------------------------------------- /src/components/Table/hooks/useFiler.ts: -------------------------------------------------------------------------------- 1 | import { type TableProps } from 'antd'; 2 | import { useState } from 'react'; 3 | 4 | export function useFiler() { 5 | const [isLock, setLock] = useState(true); 6 | 7 | /** 8 | * 隐藏表格未勾选数据 9 | * @param columns - 表格数据 10 | * @param checks - 勾选 11 | * @param sortList - 勾选顺序 12 | */ 13 | const handleFilterTable = ( 14 | columns: TableProps['columns'], 15 | checks: string[], 16 | sortList: string[], 17 | ) => { 18 | if (!checks?.length || !columns?.length) return []; 19 | if (isLock) { 20 | setLock(false); 21 | return columns || []; 22 | } 23 | 24 | // 顺序调整为勾选顺序 25 | columns.sort((a, b) => { 26 | const aIndex = sortList.indexOf((a as { dataIndex: string }).dataIndex); 27 | const bIndex = sortList.indexOf((b as { dataIndex: string }).dataIndex); 28 | return aIndex - bIndex; 29 | }); 30 | 31 | for (let i = 0; i < columns?.length; i++) { 32 | const item = columns[i] as { dataIndex: string; hidden: boolean }; 33 | item.hidden = !checks.includes(item.dataIndex); 34 | } 35 | 36 | return columns; 37 | }; 38 | 39 | return [handleFilterTable] as const; 40 | } 41 | -------------------------------------------------------------------------------- /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/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/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/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/Theme/index.module.less: -------------------------------------------------------------------------------- 1 | ::view-transition-new(root), 2 | ::view-transition-old(root) { 3 | /*关闭默认动画 */ 4 | animation: none; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Theme/index.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeType, usePublicStore } from '@/stores/public'; 2 | import { Tooltip } from 'antd'; 3 | import { Icon } from '@iconify/react'; 4 | import { MouseEvent, useEffect, useState } from 'react'; 5 | import { useTranslation } from 'react-i18next'; 6 | import { THEME_KEY } from '@/utils/config'; 7 | import './index.module.less'; 8 | 9 | function Theme() { 10 | const { t } = useTranslation(); 11 | const themeCache = (localStorage.getItem(THEME_KEY) || 'light') as ThemeType; 12 | const [theme, setTheme] = useState(themeCache); 13 | const setThemeValue = usePublicStore((state) => state.setThemeValue); 14 | 15 | useEffect(() => { 16 | if (!themeCache) { 17 | localStorage.setItem(THEME_KEY, 'light'); 18 | } 19 | if (themeCache === 'dark') { 20 | document.body.className = 'theme-dark'; 21 | } 22 | setThemeValue(themeCache === 'dark' ? 'dark' : 'light'); 23 | // eslint-disable-next-line react-hooks/exhaustive-deps 24 | }, [themeCache]); 25 | 26 | /** 27 | * 处理更新 28 | * @param type - 主题类型 29 | */ 30 | const onChange = (e: MouseEvent, type: ThemeType) => { 31 | const transition = document.startViewTransition(() => { 32 | toggleThemeScheme(type); 33 | }); 34 | 35 | transition.ready.then(() => { 36 | // 获取鼠标的坐标 37 | const { clientX, clientY } = e; 38 | 39 | // 计算最大半径 40 | const radius = Math.hypot( 41 | Math.max(clientX, innerWidth - clientX), 42 | Math.max(clientY, innerHeight - clientY), 43 | ); 44 | 45 | // 圆形动画扩散 46 | document.documentElement.animate( 47 | [ 48 | { clipPath: `circle(0% at ${clientX}px ${clientY}px)` }, 49 | { clipPath: `circle(${radius}px at ${clientX}px ${clientY}px)` }, 50 | ], 51 | { 52 | duration: 400, 53 | pseudoElement: '::view-transition-new(root)', 54 | }, 55 | ); 56 | }); 57 | }; 58 | 59 | /** 60 | * 切换主题 61 | */ 62 | const toggleThemeScheme = (type: ThemeType) => { 63 | localStorage.setItem(THEME_KEY, type); 64 | setThemeValue(type); 65 | setTheme(type); 66 | 67 | switch (type) { 68 | case 'dark': 69 | document.body.className = 'theme-dark'; 70 | break; 71 | 72 | default: 73 | document.body.className = 'theme-primary'; 74 | break; 75 | } 76 | }; 77 | 78 | return ( 79 | 80 |
81 |
onChange(e, 'dark')} 84 | > 85 | 86 |
87 |
onChange(e, 'light')} 90 | > 91 | 92 |
93 |
94 |
95 | ); 96 | } 97 | 98 | export default Theme; 99 | -------------------------------------------------------------------------------- /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/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/components/WangEditor/index.tsx: -------------------------------------------------------------------------------- 1 | import '@wangeditor/editor/dist/css/style.css'; 2 | import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor'; 3 | import { useState, useEffect, type HTMLAttributes } from 'react'; 4 | import { useTranslation } from 'react-i18next'; 5 | import { Editor, Toolbar } from '@wangeditor/editor-for-react'; 6 | import { FILE_API } from '@/utils/config'; 7 | 8 | export interface EditorProps extends Omit, 'onChange'> { 9 | value: string; // 富文本内容 10 | onChange: (value: string) => void; // 处理更改内容 11 | height?: number; // 富文本高度 12 | } 13 | 14 | function WangEditor(props: EditorProps) { 15 | const { value, height, style, className, onChange } = props; 16 | const { t } = useTranslation(); 17 | 18 | // editor 实例 19 | const [editor, setEditor] = useState(null); 20 | 21 | // 编辑器内容 22 | const [html, setHtml] = useState(value); 23 | 24 | // 工具栏配置 25 | const toolbarConfig: Partial = {}; 26 | 27 | // 编辑器配置 28 | const editorConfig: Partial = { 29 | placeholder: t('public.inputPleaseEnter'), 30 | MENU_CONF: { 31 | uploadImage: { 32 | // 上传图片地址 33 | server: FILE_API, 34 | }, 35 | uploadVideo: { 36 | // 上传视频地址 37 | server: FILE_API, 38 | }, 39 | }, 40 | }; 41 | 42 | // 监听值变化 43 | useEffect(() => { 44 | setHtml(value || ''); 45 | }, [value]); 46 | 47 | // 及时销毁 editor ,重要! 48 | useEffect(() => { 49 | return () => { 50 | if (editor === null) return; 51 | editor.destroy(); 52 | setEditor(null); 53 | }; 54 | }, [editor]); 55 | 56 | /** 57 | * 更改富文本内容 58 | */ 59 | const handleChange = (editor: IDomEditor) => { 60 | setHtml(editor.getHtml()); 61 | onChange(editor.getHtml()); 62 | }; 63 | 64 | return ( 65 |
66 | 72 | 73 | 81 |
82 | ); 83 | } 84 | 85 | export default WangEditor; 86 | -------------------------------------------------------------------------------- /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/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/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/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/hooks/useKeyStroke.ts: -------------------------------------------------------------------------------- 1 | interface Options { 2 | ArrowUp?: () => void; 3 | ArrowDown?: () => void; 4 | ArrowLeft?: () => void; 5 | ArrowRight?: () => void; 6 | Enter?: () => void; 7 | } 8 | 9 | /** 10 | * 键盘按键事件 11 | * @param options 12 | */ 13 | export function useKeyStroke(options: Options) { 14 | /** 15 | * 点击按键 16 | * @param even - 按键事件 17 | */ 18 | const onKeyDown = (even: KeyboardEvent) => { 19 | switch (even.key) { 20 | // 上 21 | case 'ArrowUp': 22 | options.ArrowUp?.(); 23 | break; 24 | 25 | // 下 26 | case 'ArrowDown': 27 | options.ArrowDown?.(); 28 | break; 29 | 30 | // 左 31 | case 'ArrowLeft': 32 | options.ArrowLeft?.(); 33 | break; 34 | 35 | // 右 36 | case 'ArrowRight': 37 | options.ArrowRight?.(); 38 | break; 39 | 40 | // 回车 41 | case 'Enter': 42 | options.Enter?.(); 43 | break; 44 | 45 | default: 46 | break; 47 | } 48 | }; 49 | 50 | return [onKeyDown] as const; 51 | } 52 | -------------------------------------------------------------------------------- /src/hooks/useLogout.ts: -------------------------------------------------------------------------------- 1 | import { useAliveController } from 'react-activation'; 2 | 3 | /** 4 | * 获取常用的状态数据 5 | */ 6 | export const useLogout = () => { 7 | const [, , removeToken] = useToken(); 8 | const { clear } = useAliveController(); 9 | const { closeAllTab, setActiveKey } = useTabsStore((state) => state); 10 | const clearInfo = useUserStore((state) => state.clearInfo); 11 | const navigate = useNavigate(); 12 | const location = useLocation(); 13 | /** 退出登录 */ 14 | const handleLogout = () => { 15 | clearInfo(); 16 | closeAllTab(); 17 | setActiveKey(''); 18 | removeToken(); 19 | clear(); // 清除keepalive缓存 20 | navigate(`/login?redirect=${location.pathname}${location.search}`); 21 | }; 22 | 23 | return [handleLogout] as const; 24 | }; 25 | -------------------------------------------------------------------------------- /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/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/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/hooks/useWatermark.ts: -------------------------------------------------------------------------------- 1 | interface Option { 2 | content: string; // 内容 3 | height: number; // 水印行高 4 | width: number; // 水印宽度 5 | rotate: number; // 旋转度数(可为负值) 6 | color: string; // 水印字体颜色 7 | fontSize: number; // 水印字体的大小 8 | opacity: number; // 水印透明度(0~1之间取值) 9 | } 10 | 11 | /** 12 | * 水印 13 | */ 14 | export function useWatermark() { 15 | /** 16 | * 水印 17 | * @param options - 操作值 18 | */ 19 | const Watermark = (options: Option) => { 20 | const { content, height, width, rotate, color, fontSize, opacity } = options; 21 | 22 | // 判断水印是否存在,如果存在,那么不执行 23 | if (document.getElementById('south_watermark') !== null) { 24 | return; 25 | } 26 | const TpLine = Math.floor(document.body?.clientWidth / width) * 2; // 一行显示几列 27 | let StrLine = ''; 28 | for (let i = 0; i < TpLine; i++) { 29 | const style = ` 30 | display: inline-block; 31 | line-height: ${height}px; 32 | width: ${width}px; 33 | text-align: center; 34 | transform:rotate( ${rotate}deg); 35 | color: ${color}; 36 | font-size: ${fontSize}px; 37 | opacity: ${opacity}; 38 | `; 39 | StrLine += ` 40 | 41 | ${content} 42 | 43 | `; 44 | } 45 | const DivLine = document.createElement('div'); 46 | DivLine.innerHTML = StrLine; 47 | 48 | const TpColumn = Math.floor(document.body.clientHeight / height) * 2 || 4; // 一列显示几行 49 | 50 | let StrColumn = ''; 51 | for (let i = 0; i < TpColumn; i++) { 52 | StrColumn += `
${DivLine.innerHTML}
`; 53 | } 54 | const DivLayer = document.createElement('div'); 55 | DivLayer.innerHTML = StrColumn; 56 | DivLayer.id = 'south_watermark'; // 给水印盒子添加类名 57 | DivLayer.style.position = 'fixed'; 58 | DivLayer.style.top = '0px'; // 整体水印距离顶部距离 59 | DivLayer.style.left = '-100px'; // 改变整体水印的left值 60 | DivLayer.style.zIndex = '999999'; // 水印页面层级 61 | DivLayer.style.pointerEvents = 'none'; 62 | DivLayer.style.userSelect = 'none'; 63 | 64 | document.body.appendChild(DivLayer); // 到页面中 65 | }; 66 | 67 | /** 删除水印 */ 68 | const RemoveWatermark = () => { 69 | // 判断水印是否存在,如果存在,那么执行 70 | if (document.getElementById('south_watermark') === null) { 71 | return; 72 | } 73 | if (document.getElementById('south_watermark') !== null) { 74 | const element = document.getElementById('south_watermark'); 75 | document.body.removeChild(element as HTMLElement); 76 | } 77 | }; 78 | 79 | return [Watermark, RemoveWatermark] as const; 80 | } 81 | -------------------------------------------------------------------------------- /src/layouts/components/DraggableTabNode.tsx: -------------------------------------------------------------------------------- 1 | import type { CSSProperties, DetailedHTMLProps, HTMLAttributes, ReactElement } from 'react'; 2 | import { cloneElement } from 'react'; 3 | import { CSS } from '@dnd-kit/utilities'; 4 | import { useSortable } from '@dnd-kit/sortable'; 5 | 6 | export interface DraggableTabPaneProps extends HTMLAttributes { 7 | 'data-node-key': string; 8 | } 9 | 10 | const DraggableTabNode: FC> = ({ ...props }) => { 11 | const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ 12 | id: props['data-node-key'], 13 | }); 14 | 15 | const style: CSSProperties = { 16 | ...props.style, 17 | transform: CSS.Translate.toString(transform), 18 | transition, 19 | cursor: 'move', 20 | }; 21 | 22 | return cloneElement( 23 | props.children as ReactElement< 24 | DetailedHTMLProps, HTMLDivElement> 25 | >, 26 | { 27 | ref: setNodeRef, 28 | style, 29 | ...attributes, 30 | ...listeners, 31 | }, 32 | ); 33 | }; 34 | 35 | export default DraggableTabNode; 36 | -------------------------------------------------------------------------------- /src/layouts/components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component, ErrorInfo, ReactNode } from 'react'; 2 | import { Button, Result, Tooltip } from 'antd'; 3 | import { LogoutOutlined, MessageOutlined, RedoOutlined } from '@ant-design/icons'; 4 | import { useUserStore } from '@/stores/user'; 5 | import axios from 'axios'; 6 | import dayjs from 'dayjs'; 7 | 8 | interface Props { 9 | children: ReactNode; 10 | } 11 | 12 | interface State { 13 | hasError: boolean; 14 | error: Error | null; 15 | } 16 | 17 | // 错误内容组件 18 | const ErrorContent = ({ error }: { error: Error | null }) => { 19 | const [handleLogout] = useLogout(); 20 | const { t } = useTranslation(); 21 | 22 | /** 刷新当前页面 */ 23 | const handleRefresh = () => { 24 | window.location.reload(); 25 | }; 26 | 27 | return ( 28 |
29 | 34 | 35 | 36 | 37 | {t('public.pagepageErrorSubTitle')} 38 |
39 | } 40 | extra={[ 41 | , 44 | , 47 | ]} 48 | /> 49 | 50 | ); 51 | }; 52 | 53 | class ErrorBoundary extends Component { 54 | public state: State = { 55 | hasError: false, 56 | error: null, 57 | }; 58 | 59 | public static getDerivedStateFromError(): State { 60 | return { hasError: true, error: null }; 61 | } 62 | 63 | public componentDidCatch(error: Error, errorInfo: ErrorInfo) { 64 | // 错误日志打印 65 | console.error('错误信息:', error, errorInfo); 66 | this.setState({ error }); 67 | 68 | // 将错误信息上传至服务器 69 | this.sendErrorLog(error, errorInfo); 70 | } 71 | 72 | private async sendErrorLog(error: Error, errorInfo: ErrorInfo) { 73 | try { 74 | // 获取用户信息 75 | const userInfo = useUserStore.getState().userInfo; 76 | 77 | // 准备日志数据 78 | const logData = { 79 | userInfo, 80 | error: error.toString(), 81 | errorInfo: JSON.stringify(errorInfo), 82 | createTime: dayjs().format('YYYY-MM-DD HH:mm:ss'), 83 | type: 'frontError', 84 | }; 85 | 86 | // 发送错误日志到服务器 87 | await axios.post('/log/create', logData); 88 | } catch (e) { 89 | console.error('发送错误日志失败:', e); 90 | } 91 | } 92 | 93 | public render() { 94 | if (this.state.hasError) { 95 | return ; 96 | } 97 | 98 | return this.props.children; 99 | } 100 | } 101 | 102 | export default ErrorBoundary; 103 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/locales/utils/helper.ts: -------------------------------------------------------------------------------- 1 | type FileModule = Record; 2 | type FileParams = Record; 3 | 4 | /** 获取中文翻译文件 */ 5 | export const getZhLang = () => { 6 | const langFiles = import.meta.glob('../zh/*.ts', { 7 | import: 'default', 8 | eager: true, 9 | }) as FileParams; 10 | const result = handleFileList(langFiles); 11 | return result; 12 | }; 13 | 14 | /** 获取英文翻译文件 */ 15 | export const getEnLang = () => { 16 | const langFiles = import.meta.glob('../en/*.ts', { 17 | import: 'default', 18 | eager: true, 19 | }) as FileParams; 20 | const result = handleFileList(langFiles); 21 | return result; 22 | }; 23 | 24 | /** 获取中文翻译文件命名空间数据 */ 25 | export const getZhLangNamespaces = () => { 26 | const langFiles = import.meta.glob('../zh/**/*.ts', { 27 | import: 'default', 28 | eager: true, 29 | }) as FileParams; 30 | const namespace = filterNamespaceData(langFiles); 31 | const result = handleFileNamespaceList(namespace); 32 | return result; 33 | }; 34 | 35 | /** 获取中文翻译文件命名空间数据 */ 36 | export const getEnLangNamespaces = () => { 37 | const langFiles = import.meta.glob('../en/**/*.ts', { 38 | import: 'default', 39 | eager: true, 40 | }) as FileParams; 41 | const namespace = filterNamespaceData(langFiles); 42 | const result = handleFileNamespaceList(namespace); 43 | return result; 44 | }; 45 | 46 | /** 47 | * 处理文件转为对应格式 48 | * @param files - 文件集 49 | */ 50 | const handleFileList = (files: FileParams) => { 51 | const result: Record = {}; 52 | 53 | for (const key in files) { 54 | const data = files[key]; 55 | const fileArr = key?.split('/'); 56 | const fileName = fileArr?.[fileArr?.length - 1] || ''; 57 | if (!fileName) continue; 58 | const name = fileName?.split('.ts')?.[0]; 59 | if (name) result[name] = data; 60 | } 61 | 62 | return result; 63 | }; 64 | 65 | /** 66 | * 过滤命名空间数据,过滤文件夹数小于2的数据 67 | * @param fileList - 文件列表 68 | */ 69 | const filterNamespaceData = (fileList: FileParams) => { 70 | const result: FileParams = {}; 71 | 72 | for (const key in fileList) { 73 | const list = key?.split('/'); 74 | if (list?.length > 3) { 75 | result[key] = fileList[key]; 76 | } 77 | } 78 | 79 | return result; 80 | }; 81 | 82 | /** 83 | * 处理文件转为对应格式 84 | * @param files - 文件集 85 | */ 86 | const handleFileNamespaceList = (files: FileParams) => { 87 | const result: Record> = {}; 88 | 89 | for (const key in files) { 90 | const data = files[key]; 91 | const fileArr = key?.split('/'); 92 | // 获取命名空间 93 | const namespace = fileArr?.[fileArr?.length - 2] || ''; 94 | if (!namespace) continue; 95 | // 获取文件名 96 | const fileName = fileArr?.[fileArr?.length - 1] || ''; 97 | if (!fileName) continue; 98 | const name = fileName?.split('.ts')?.[0]; 99 | if (!name) continue; 100 | 101 | if (!result?.[namespace]) { 102 | result[namespace] = {}; 103 | } 104 | 105 | result[namespace][name] = data; 106 | } 107 | 108 | return result; 109 | }; 110 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/main.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client'; 2 | import Router from './router'; 3 | import '@/assets/css/public.less'; 4 | import '@/assets/fonts/font.less'; 5 | 6 | // 样式 7 | import { StyleProvider, legacyLogicalPropertiesTransformer } from '@ant-design/cssinjs'; // 兼容低版本浏览器 8 | import 'uno.css'; 9 | import 'nprogress/nprogress.css'; 10 | import '@/assets/css/scrollbar.less'; 11 | import '@/assets/css/theme-color.less'; 12 | 13 | // 国际化i18n 14 | import './locales/config'; 15 | 16 | // antd 17 | import '@ant-design/v5-patch-for-react-19'; 18 | import '@/assets/css/antd.less'; 19 | 20 | // 时间设为中文 21 | import dayjs from 'dayjs'; 22 | import 'dayjs/locale/zh-cn'; 23 | dayjs.locale('zh-cn'); 24 | 25 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 26 | 27 | 28 | , 29 | ); 30 | 31 | // 关闭loading 32 | const firstElement = document.getElementById('first'); 33 | if (firstElement && firstElement.style?.display !== 'none') { 34 | firstElement.style.display = 'none'; 35 | } 36 | -------------------------------------------------------------------------------- /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/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/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/pages/403.tsx: -------------------------------------------------------------------------------- 1 | import { getFirstMenu, getMenuByKey } from '@/menus/utils/helper'; 2 | import { Button } from 'antd'; 3 | import styles from './all.module.less'; 4 | 5 | function Forbidden() { 6 | const navigate = useNavigate(); 7 | const { t } = useTranslation(); 8 | const { permissions, menuList } = useCommonStore(); 9 | const { addTabs, setActiveKey } = useTabsStore(); 10 | 11 | /** 跳转首页 */ 12 | const goIndex = () => { 13 | const firstMenu = getFirstMenu(menuList, permissions); 14 | navigate(firstMenu); 15 | const menuByKeyProps = { menus: menuList, permissions, key: firstMenu }; 16 | const newItems = getMenuByKey(menuByKeyProps); 17 | if (newItems?.key) { 18 | setActiveKey(newItems.key); 19 | addTabs(newItems); 20 | } 21 | }; 22 | 23 | return ( 24 |
25 |

403

26 |

{t('public.notPermissionMessage')}

27 | 30 |
31 | ); 32 | } 33 | 34 | export default Forbidden; 35 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { getFirstMenu, getMenuByKey } from '@/menus/utils/helper'; 2 | import { Button } from 'antd'; 3 | import styles from './all.module.less'; 4 | 5 | function NotFound() { 6 | const navigate = useNavigate(); 7 | const { t } = useTranslation(); 8 | const { permissions, menuList } = useCommonStore(); 9 | const { addTabs, setActiveKey } = useTabsStore(); 10 | 11 | /** 跳转首页 */ 12 | const goIndex = () => { 13 | const firstMenu = getFirstMenu(menuList, permissions); 14 | navigate(firstMenu); 15 | const menuByKeyProps = { menus: menuList, permissions, key: firstMenu }; 16 | const newItems = getMenuByKey(menuByKeyProps); 17 | if (newItems?.key) { 18 | setActiveKey(newItems.key); 19 | addTabs(newItems); 20 | } 21 | }; 22 | 23 | return ( 24 |
25 |

404

26 |

{t('public.notFindMessage')}

27 | 30 |
31 | ); 32 | } 33 | 34 | export default NotFound; 35 | -------------------------------------------------------------------------------- /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/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/content/article/model.ts: -------------------------------------------------------------------------------- 1 | import type { TFunction } from 'i18next'; 2 | import { getUserPage } from '@/servers/system/user'; 3 | import CustomizeInput from './components/CustomizeInput'; 4 | 5 | // 搜索数据 6 | export const searchList = (t: TFunction): BaseSearchList[] => [ 7 | { 8 | label: t('public.title'), 9 | name: 'title', 10 | component: 'Input', 11 | }, 12 | ]; 13 | 14 | /** 15 | * 表格数据 16 | * @param optionRender - 渲染操作函数 17 | */ 18 | export const tableColumns = (t: TFunction, optionRender: TableOptions): TableColumn[] => { 19 | return [ 20 | { 21 | title: 'ID', 22 | dataIndex: 'id', 23 | width: 400, 24 | }, 25 | { 26 | title: t('public.title'), 27 | dataIndex: 'title', 28 | width: 400, 29 | }, 30 | { 31 | title: t('content.author'), 32 | dataIndex: 'author', 33 | width: 100, 34 | }, 35 | { 36 | title: t('public.content'), 37 | dataIndex: 'content', 38 | width: 400, 39 | }, 40 | { 41 | title: t('content.creator'), 42 | dataIndex: 'creator', 43 | width: 200, 44 | }, 45 | { 46 | title: t('content.updater'), 47 | dataIndex: 'updater', 48 | width: 200, 49 | }, 50 | { 51 | title: t('public.operate'), 52 | dataIndex: 'operate', 53 | width: 200, 54 | fixed: 'right', 55 | render: (value: unknown, record: object) => optionRender(value, record), 56 | }, 57 | ]; 58 | }; 59 | 60 | // 新增数据 61 | export const createList = (t: TFunction): BaseFormList[] => [ 62 | { 63 | label: t('public.title'), 64 | name: 'title', 65 | rules: FORM_REQUIRED, 66 | component: 'customize', 67 | render: CustomizeInput, 68 | componentProps: { 69 | style: { 70 | width: '80%', 71 | }, 72 | }, 73 | }, 74 | { 75 | label: t('content.author'), 76 | name: 'author', 77 | component: 'ApiSelect', 78 | componentProps: { 79 | api: getUserPage as ApiFn, 80 | apiResultKey: 'items', 81 | fieldNames: { label: 'name', value: 'name' }, 82 | params: { 83 | page: 1, 84 | pageSize: 10, 85 | }, 86 | style: { 87 | width: '80%', 88 | }, 89 | }, 90 | }, 91 | { 92 | label: t('content.nestedData'), 93 | name: ['demo', 'name', 'test'], 94 | component: 'Input', 95 | extra: '这是描述,这是描述,这是描述。', 96 | unit: '单位', 97 | componentProps: { 98 | style: { 99 | width: '80%', 100 | }, 101 | }, 102 | }, 103 | { 104 | label: t('public.content'), 105 | name: 'content', 106 | component: 'RichEditor', 107 | componentProps: { 108 | style: { 109 | width: '80%', 110 | }, 111 | }, 112 | }, 113 | ]; 114 | -------------------------------------------------------------------------------- /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/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/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/pages/dashboard/index.tsx: -------------------------------------------------------------------------------- 1 | import { searchList } from './model'; 2 | import { useActivate } from 'react-activation'; 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 | useActivate(() => { 41 | console.log('进入和退出时执行'); 42 | 43 | return () => { 44 | console.log('退出时执行'); 45 | }; 46 | }); 47 | 48 | useActivate(() => { 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 | -------------------------------------------------------------------------------- /src/pages/dashboard/model.ts: -------------------------------------------------------------------------------- 1 | import type { ApiFn, BaseFormList } from '#/form'; 2 | import type { TFunction } from 'i18next'; 3 | import { getPartnerDemo } from '@/servers/platform/partner'; 4 | 5 | // 搜索数据 6 | export const searchList = (t: TFunction): BaseFormList[] => [ 7 | { 8 | label: t('public.date'), 9 | name: 'pay_date', 10 | component: 'RangePicker', 11 | componentProps: { 12 | allowClear: false, 13 | }, 14 | }, 15 | { 16 | label: t('dashboard.gameID'), 17 | name: 'game_ids', 18 | wrapperWidth: 200, 19 | component: 'GameSelect', 20 | }, 21 | { 22 | label: t('dashboard.cooperativeCompany'), 23 | name: 'partners', 24 | wrapperWidth: 200, 25 | component: 'ApiSelect', 26 | componentProps: { 27 | api: getPartnerDemo as ApiFn, 28 | params: [ 29 | '/platform/partner', 30 | { 31 | isAll: true, 32 | }, 33 | ], 34 | fieldNames: { 35 | label: 'name', 36 | value: 'id', 37 | }, 38 | }, 39 | }, 40 | ]; 41 | -------------------------------------------------------------------------------- /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/pages/demo/copy/index.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | import { useCommonStore } from '@/hooks/useCommonStore'; 3 | import { checkPermission } from '@/utils/permissions'; 4 | import CopyInput from '@/components/Copy/CopyInput'; 5 | import CopyBtn from '@/components/Copy/CopyBtn'; 6 | import BaseContent from '@/components/Content/BaseContent'; 7 | 8 | function CopyPage() { 9 | const { t } = useTranslation(); 10 | const { permissions } = useCommonStore(); 11 | const isPermission = checkPermission('/demo/copy', permissions); 12 | 13 | return ( 14 | 15 |
16 |

{t('content.clipboard')}:

17 | 18 | 19 |
20 | {t('content.clipboardMessage')}: 21 | 22 |
23 |
24 |
25 | ); 26 | } 27 | 28 | export default CopyPage; 29 | -------------------------------------------------------------------------------- /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/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/pages/demo/virtualScroll/components/VirtualList.tsx: -------------------------------------------------------------------------------- 1 | import { List, type RowComponentProps } from 'react-window'; 2 | import { useCommonStore } from '@/hooks/useCommonStore'; 3 | 4 | function VirtualList() { 5 | const { theme } = useCommonStore(); 6 | 7 | const names = useMemo(() => { 8 | return Array.from({ length: 10000 }, (_, i) => `Name ${i + 1}`); 9 | }, []); 10 | 11 | function RowComponent({ 12 | index, 13 | names, 14 | style, 15 | }: RowComponentProps<{ 16 | names: string[]; 17 | }>) { 18 | return ( 19 |
23 | {names[index]} 24 |
{`${index + 1} of ${names.length}`}
25 |
26 | ); 27 | } 28 | 29 | return ; 30 | } 31 | 32 | export default VirtualList; 33 | -------------------------------------------------------------------------------- /src/pages/demo/virtualScroll/components/VirtualTable.tsx: -------------------------------------------------------------------------------- 1 | import type { TableColumn } from '#/public'; 2 | import { useTranslation } from 'react-i18next'; 3 | import BaseTable from '@/components/Table/BaseTable'; 4 | 5 | function VirtualTable() { 6 | const { t } = useTranslation(); 7 | const [tableData, setTableData] = useState([]); 8 | 9 | const columns: TableColumn[] = [ 10 | { title: 'ID', dataIndex: 'id', width: 200 }, 11 | { title: t('public.name'), dataIndex: 'name', width: 200 }, 12 | { title: t('system.phone'), dataIndex: 'phone', width: 200 }, 13 | { title: t('system.age'), dataIndex: 'number', width: 200 }, 14 | ]; 15 | 16 | useEffect(() => { 17 | const data = new Array(0).fill({}); 18 | for (let i = 0; i < 10000; i++) { 19 | const num = i + 1; 20 | data.push({ 21 | id: num, 22 | name: 'name' + num, 23 | phone: num * 13, 24 | number: num * 3, 25 | }); 26 | } 27 | setTableData(data); 28 | }, []); 29 | 30 | return ( 31 | 32 | ); 33 | } 34 | 35 | export default VirtualTable; 36 | -------------------------------------------------------------------------------- /src/pages/demo/virtualScroll/index.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | import { checkPermission } from '@/utils/permissions'; 3 | import { useCommonStore } from '@/hooks/useCommonStore'; 4 | import VirtualList from './components/VirtualList'; 5 | import VirtualTable from './components/VirtualTable'; 6 | import BaseContent from '@/components/Content/BaseContent'; 7 | 8 | function VirtualScroll() { 9 | const { t } = useTranslation(); 10 | const { permissions, isPhone } = useCommonStore(); 11 | const isPermission = checkPermission('/demo/virtualScroll', permissions); 12 | 13 | return ( 14 | 15 |
16 |
17 |

{t('content.virtualScroll1')}:

18 |
19 | 20 |
21 |
22 | 23 |
24 |

{t('content.virtualScroll2')}:

25 | 26 |
27 |
28 |
29 | ); 30 | } 31 | 32 | export default VirtualScroll; 33 | -------------------------------------------------------------------------------- /src/pages/demo/watermark/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from 'antd'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { useWatermark } from '@/hooks/useWatermark'; 4 | import { checkPermission } from '@/utils/permissions'; 5 | import { useCommonStore } from '@/hooks/useCommonStore'; 6 | import BaseContent from '@/components/Content/BaseContent'; 7 | 8 | function Watermark() { 9 | const { t } = useTranslation(); 10 | const { permissions } = useCommonStore(); 11 | const [Watermark, RemoveWatermark] = useWatermark(); 12 | const isPermission = checkPermission('/demo/watermark', permissions); 13 | 14 | const openWatermark = () => { 15 | Watermark({ 16 | content: t('content.watermark'), 17 | height: 300, 18 | width: 350, 19 | rotate: -20, 20 | color: '#000', 21 | fontSize: 30, 22 | opacity: 0.07, 23 | }); 24 | }; 25 | 26 | const hidWatermark = () => { 27 | RemoveWatermark(); 28 | }; 29 | 30 | return ( 31 | 32 |
33 | 34 | 37 |
38 |
39 | ); 40 | } 41 | 42 | export default Watermark; 43 | -------------------------------------------------------------------------------- /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/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/pages/system/menu/components/IconInput.tsx: -------------------------------------------------------------------------------- 1 | import { QuestionCircleOutlined } from '@ant-design/icons'; 2 | import { Icon } from '@iconify/react'; 3 | import { Input, Tooltip, type InputProps } from 'antd'; 4 | 5 | function IconInput(props: InputProps) { 6 | const { value, onChange } = props; 7 | const { t } = useTranslation(); 8 | 9 | return ( 10 |
11 | { 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/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/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/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/user/components/PermissionDrawer.tsx: -------------------------------------------------------------------------------- 1 | import type { PermissionData } from '@/servers/system/role'; 2 | import type { Key } from 'antd/lib/table/interface'; 3 | import { Drawer, Button, message as messageApi, Spin } from 'antd'; 4 | import { saveUserPermission } from '@/servers/system/user'; 5 | import { getUserPermission } from '@/servers/system/user'; 6 | import MenuAuthorize from '../../role/components/MenuAuthorize'; 7 | 8 | interface Props { 9 | isOpen: boolean; 10 | id: string; 11 | title?: string; 12 | onClose: () => void; 13 | } 14 | 15 | function PermissionDrawer(props: Props) { 16 | const { id, title, isOpen, onClose } = props; 17 | const { t } = useTranslation(); 18 | const [isLoading, setLoading] = useState(false); 19 | const [isFetchLoading, setFetchLoading] = useState(false); 20 | const [treeData, setTreeData] = useState([]); 21 | const [checkedKeys, setCheckedKeys] = useState([]); 22 | 23 | useEffect(() => { 24 | if (isOpen) { 25 | fetchData(); 26 | } else { 27 | setTreeData([]); 28 | setCheckedKeys([]); 29 | } 30 | }, [isOpen]); 31 | 32 | /** 获取权限数据 */ 33 | const fetchData = async () => { 34 | try { 35 | setLoading(true); 36 | const params = { userId: id }; 37 | const { code, data } = await getUserPermission(params); 38 | if (Number(code) !== 200) return; 39 | const { defaultCheckedKeys, treeData } = data; 40 | setTreeData(treeData); 41 | setCheckedKeys(defaultCheckedKeys); 42 | } finally { 43 | setLoading(false); 44 | } 45 | }; 46 | 47 | /** 提交 */ 48 | const handleSubmit = async () => { 49 | try { 50 | setFetchLoading(true); 51 | const params = { 52 | menuIds: checkedKeys, 53 | userId: id, 54 | }; 55 | const { code, message } = await saveUserPermission(params); 56 | if (Number(code) !== 200) return; 57 | messageApi.success(message || t('system.authorizationSuccessful')); 58 | onClose?.(); 59 | } finally { 60 | setFetchLoading(false); 61 | } 62 | }; 63 | 64 | /** 右上角渲染 */ 65 | const extraRender = ( 66 | 69 | ); 70 | 71 | /** 72 | * 处理勾选 73 | * @param checked - 勾选值 74 | */ 75 | const handleCheck = (checked: Key[]) => { 76 | setCheckedKeys(checked); 77 | }; 78 | 79 | return ( 80 | 88 | 89 | 95 | 96 | 97 | ); 98 | } 99 | 100 | export default PermissionDrawer; 101 | -------------------------------------------------------------------------------- /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/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 | // keepalive 10 | import { AliveScope } from 'react-activation'; 11 | 12 | // antd 13 | import { theme, ConfigProvider } from 'antd'; 14 | import zhCN from 'antd/es/locale/zh_CN'; 15 | import enUS from 'antd/es/locale/en_US'; 16 | 17 | // 禁止进度条添加loading 18 | nprogress.configure({ showSpinner: false }); 19 | 20 | // antd主题 21 | const { defaultAlgorithm, darkAlgorithm } = theme; 22 | 23 | import { useCommonStore } from '@/hooks/useCommonStore'; 24 | 25 | function Page() { 26 | const { i18n } = useTranslation(); 27 | const { theme } = useCommonStore(); 28 | // 获取当前语言 29 | const currentLanguage = i18n.language; 30 | 31 | useEffect(() => { 32 | // 关闭loading 33 | const firstElement = document.getElementById('first'); 34 | if (firstElement && firstElement.style?.display !== 'none') { 35 | firstElement.style.display = 'none'; 36 | } 37 | }, []); 38 | 39 | return ( 40 | 41 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | ); 56 | } 57 | 58 | export default Page; 59 | -------------------------------------------------------------------------------- /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/router/utils/helper.tsx: -------------------------------------------------------------------------------- 1 | import type { RouteObject } from 'react-router-dom'; 2 | import type { DefaultComponent } from '@loadable/component'; 3 | import { Skeleton } from 'antd'; 4 | import { ROUTER_EXCLUDE } from './config'; 5 | import loadable from '@loadable/component'; 6 | 7 | /** 8 | * 路由添加layout 9 | * @param routes - 路由数据 10 | */ 11 | export function layoutRoutes(routes: RouteObject[]): RouteObject[] { 12 | const layouts: RouteObject[] = []; // layout内部组件 13 | 14 | for (let i = 0; i < routes.length; i++) { 15 | const { path } = routes[i]; 16 | // 路径为登录页不添加layouts 17 | if (path !== 'login') { 18 | layouts.push(routes[i]); 19 | } 20 | } 21 | 22 | return layouts; 23 | } 24 | 25 | /** 26 | * 处理路由 27 | * @param routes - 路由数据 28 | */ 29 | export function handleRoutes( 30 | routes: Record Promise>>, 31 | ): RouteObject[] { 32 | const layouts: RouteObject[] = []; // layout内部组件 33 | 34 | for (const key in routes) { 35 | // 是否在排除名单中 36 | const isExclude = handleRouterExclude(key); 37 | if (isExclude) continue; 38 | 39 | const path = getRouterPage(key); 40 | if (path === '/login') continue; 41 | 42 | const ComponentNode = loadable(routes[key], { 43 | fallback: , 44 | }); 45 | 46 | layouts.push({ 47 | path, 48 | element: , 49 | }); 50 | } 51 | 52 | return layouts; 53 | } 54 | 55 | // 预处理正则表达式,避免重复创建 56 | const ROUTER_EXCLUDE_REGEX = new RegExp( 57 | ROUTER_EXCLUDE.map((item) => (!item.includes('.') ? `/${item}/` : item)).join('|'), 58 | 'i', 59 | ); 60 | 61 | /** 62 | * 匹配路由是否在排查名单中 63 | * @param path - 路径 64 | */ 65 | function handleRouterExclude(path: string): boolean { 66 | return ROUTER_EXCLUDE_REGEX.test(path); 67 | } 68 | 69 | /** 70 | * 处理动态参数路由 71 | * @param path - 路由 72 | */ 73 | const handleRouterDynamic = (path: string): string => { 74 | path = path.replace(/\[/g, ':'); 75 | path = path.replace(/\]/g, ''); 76 | 77 | return path; 78 | }; 79 | 80 | /** 81 | * 获取路由路径 82 | * @param path - 路径 83 | */ 84 | function getRouterPage(path: string): string { 85 | // 获取page数据后面数据 86 | const pageIndex = path.indexOf('pages') + 5; 87 | // 文件后缀 88 | const lastIndex = path.lastIndexOf('.'); 89 | // 去除pages和文件后缀 90 | let result = path.substring(pageIndex, lastIndex); 91 | 92 | // 如果是首页则直接返回/ 93 | if (result === '/index') return '/'; 94 | 95 | // 如果结尾是index则去除 96 | if (result.includes('index')) { 97 | const indexIdx = result.lastIndexOf('index') + 5; 98 | if (indexIdx === result.length) { 99 | result = result.substring(0, result.length - 6); 100 | } 101 | } 102 | 103 | // 如果是动态参数路由 104 | if (result.includes('[') && result.includes(']')) { 105 | result = handleRouterDynamic(result); 106 | } 107 | 108 | return result; 109 | } 110 | -------------------------------------------------------------------------------- /src/servers/content/article.ts: -------------------------------------------------------------------------------- 1 | import type { BaseFormData } from '#/form'; 2 | import type { PageServerResult, PaginationData } from '#/public'; 3 | import { request } from '@/utils/request'; 4 | 5 | enum API { 6 | URL = '/content/article', 7 | } 8 | 9 | /** 10 | * 获取分页数据 11 | * @param data - 请求数据 12 | */ 13 | export function getArticlePage(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 getArticleById(id: string) { 22 | return request.get(`${API.URL}/detail?id=${id}`); 23 | } 24 | 25 | /** 26 | * 新增数据 27 | * @param data - 请求数据 28 | */ 29 | export function createArticle(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 updateArticle(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 deleteArticle(id: string) { 47 | return request.delete(`${API.URL}/${id}`); 48 | } 49 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /src/stores/menu.ts: -------------------------------------------------------------------------------- 1 | import type { SideMenu } from '#/public'; 2 | import { create } from 'zustand'; 3 | import { devtools } from 'zustand/middleware'; 4 | 5 | interface MenuState { 6 | isPhone: boolean; 7 | isCollapsed: boolean; 8 | selectedKeys: string; // 菜单选中值 9 | openKeys: string[]; // 菜单展开项 10 | menuList: SideMenu[]; // 菜单列表数据 11 | toggleCollapsed: (isCollapsed: boolean) => void; 12 | togglePhone: (isPhone: boolean) => void; 13 | setSelectedKeys: (selectedKeys: string) => void; 14 | setOpenKeys: (openKeys: string[]) => void; 15 | setMenuList: (menuList: SideMenu[]) => void; 16 | } 17 | 18 | export const useMenuStore = create()( 19 | devtools( 20 | (set) => ({ 21 | isPhone: false, 22 | isCollapsed: false, 23 | selectedKeys: 'dashboard', // 菜单选中值 24 | openKeys: ['Dashboard'], // 菜单展开项 25 | menuList: [], // 菜单列表数据 26 | toggleCollapsed: (isCollapsed: boolean) => set({ isCollapsed }), 27 | togglePhone: (isPhone: boolean) => set({ isPhone }), 28 | setSelectedKeys: (selectedKeys: string) => set({ selectedKeys }), 29 | setOpenKeys: (openKeys: string[]) => set({ openKeys }), 30 | setMenuList: (menuList: SideMenu[]) => set({ menuList }), 31 | }), 32 | { 33 | enabled: process.env.NODE_ENV === 'development', 34 | name: 'menuStore', 35 | }, 36 | ), 37 | ); 38 | -------------------------------------------------------------------------------- /src/stores/public.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { devtools } from 'zustand/middleware'; 3 | 4 | export type ThemeType = 'dark' | 'light'; 5 | 6 | interface PublicState { 7 | theme: ThemeType; // 主题 8 | isFullscreen: boolean; // 是否全屏 9 | isRefresh: boolean; // 重新加载 10 | isRefreshPage: boolean; // 重新加载页面 11 | /** 设置主题 */ 12 | setThemeValue: (theme: ThemeType) => void; 13 | /** 设置全屏 */ 14 | setFullscreen: (isFullscreen: boolean) => void; 15 | /** 设置重新加载 */ 16 | setRefresh: (isRefresh: boolean) => void; 17 | /** 设置重新加载页面 */ 18 | setRefreshPage: (isRefreshPage: boolean) => void; 19 | } 20 | 21 | export const usePublicStore = create()( 22 | devtools( 23 | (set) => ({ 24 | theme: 'light', 25 | isFullscreen: false, 26 | isRefresh: false, 27 | isRefreshPage: false, 28 | setThemeValue: (theme: ThemeType) => set({ theme }), 29 | setFullscreen: (isFullscreen: boolean) => set({ isFullscreen }), 30 | setRefresh: (isRefresh: boolean) => set({ isRefresh }), 31 | setRefreshPage: (isRefreshPage: boolean) => set({ isRefreshPage }), 32 | }), 33 | { 34 | enabled: process.env.NODE_ENV === 'development', 35 | name: 'publicStore', 36 | }, 37 | ), 38 | ); 39 | -------------------------------------------------------------------------------- /src/stores/user.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { devtools } from 'zustand/middleware'; 3 | 4 | interface UserInfo { 5 | id: number; 6 | username: string; 7 | email: string; 8 | phone: string; 9 | roles: number[]; 10 | } 11 | 12 | interface UserState { 13 | permissions: string[]; 14 | userInfo: UserInfo; 15 | setPermissions: (permissions: string[]) => void; 16 | setUserInfo: (userInfo: UserInfo) => void; 17 | clearInfo: () => void; 18 | } 19 | 20 | export const useUserStore = create()( 21 | devtools( 22 | (set) => ({ 23 | permissions: [], 24 | userInfo: { 25 | id: 0, 26 | username: '', 27 | email: '', 28 | phone: '', 29 | roles: [], 30 | }, 31 | /** 设置用户信息 */ 32 | setPermissions: (permissions) => set({ permissions }), 33 | /** 设置权限 */ 34 | setUserInfo: (userInfo) => set({ userInfo }), 35 | /** 清除用户信息 */ 36 | clearInfo: () => 37 | set({ 38 | userInfo: { id: 0, username: '', email: '', phone: '', roles: [] }, 39 | }), 40 | }), 41 | { 42 | enabled: process.env.NODE_ENV === 'development', 43 | name: 'userStore', 44 | }, 45 | ), 46 | ); 47 | -------------------------------------------------------------------------------- /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 | 24 | // 公共组件默认值 25 | export const FORM_REQUIRED = [{ required: true }]; // 表单必填校验 26 | 27 | // 新增/编辑标题 28 | export const ADD_TITLE = (t: TFunction, title?: string) => 29 | t('public.createTitle', { title: title ?? '' }); 30 | export const EDIT_TITLE = (t: TFunction, name: string, title?: string) => 31 | `${t('public.editTitle', { title: title ?? '' })}${name ? `(${name})` : ''}`; 32 | 33 | // 密码规则 34 | export const PASSWORD_RULE = (t: TFunction) => ({ 35 | pattern: /^(?=.*\d)(?=.*[a-zA-Z])[\da-zA-Z~!@#$%^&*+\.\_\-*]{6,30}$/, 36 | message: t('login.passwordRuleMessage'), 37 | }); 38 | 39 | // 环境判断 40 | const ENV = import.meta.env.VITE_ENV as string; 41 | // 生成环境所用的接口 42 | const URL = import.meta.env.VITE_BASE_URL as string; 43 | // 上传地址 44 | export const FILE_API = `${ENV === 'development' ? '/api' : URL}/authority/file/upload-file`; 45 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /stylelint.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ['@south/stylelint'], 3 | root: true, 4 | }; 5 | -------------------------------------------------------------------------------- /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": "Node", 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 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts", "build/**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, loadEnv } from 'vite'; 2 | import { handleEnv } from './build/utils/helper'; 3 | import { createProxy } from './build/vite/proxy'; 4 | import { createVitePlugins } from './build/plugins'; 5 | import { buildOptions } from './build/vite/build'; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig(({ mode }) => { 9 | const root = process.cwd(); 10 | const env = loadEnv(mode, root); 11 | const viteEnv = handleEnv(env); 12 | const { VITE_SERVER_PORT, VITE_PROXY } = viteEnv; 13 | 14 | return { 15 | plugins: createVitePlugins(), 16 | resolve: { 17 | alias: { 18 | '@': '/src', 19 | '#': '/types', 20 | }, 21 | }, 22 | css: { 23 | preprocessorOptions: { 24 | less: { 25 | javascriptEnabled: true, 26 | charset: false, 27 | }, 28 | }, 29 | }, 30 | server: { 31 | open: true, 32 | port: VITE_SERVER_PORT, 33 | // 跨域处理 34 | proxy: createProxy(VITE_PROXY), 35 | }, 36 | build: buildOptions(), 37 | }; 38 | }); 39 | --------------------------------------------------------------------------------