├── .env ├── .eslintignore ├── .dockerignore ├── src ├── vite-env.d.ts ├── check-update.ts ├── hooks │ ├── use-pc-screen │ │ └── index.tsx │ ├── use-auth │ │ └── index.tsx │ ├── use-selector │ │ └── index.tsx │ ├── use-match-router │ │ └── index.tsx │ ├── use-websocket │ │ └── index.ts │ └── use-tabs │ │ └── index.tsx ├── layouts │ ├── layout │ │ ├── message-handle │ │ │ ├── interface.ts │ │ │ └── index.tsx │ │ ├── header │ │ │ ├── notification.tsx │ │ │ ├── theme-switcher.tsx │ │ │ ├── lang-dropdown.tsx │ │ │ ├── index.tsx │ │ │ ├── header-title.tsx │ │ │ ├── menu-searcher.tsx │ │ │ └── user-info.tsx │ │ ├── slide │ │ │ ├── menu.css │ │ │ ├── index.tsx │ │ │ └── menu.tsx │ │ ├── index.tsx │ │ └── content │ │ │ └── index.tsx │ └── common │ │ ├── tabs-context.tsx │ │ ├── watermark.tsx │ │ ├── tabs-layout.tsx │ │ └── use-user-detail.tsx ├── components │ ├── icon-button │ │ ├── interface.ts │ │ └── index.tsx │ ├── link-button │ │ ├── interface.ts │ │ └── index.tsx │ ├── global-loading │ │ ├── index.tsx │ │ └── index.css │ ├── exception │ │ ├── 404.tsx │ │ └── 500.tsx │ ├── loading │ │ └── index.tsx │ ├── draggable-tab │ │ ├── index.css │ │ └── index.tsx │ ├── pro-table │ │ └── index.tsx │ └── modal-form │ │ └── index.tsx ├── router │ ├── index.tsx │ ├── provider.tsx │ ├── routes.tsx │ └── router-utils.tsx ├── assets │ ├── locales │ │ ├── zh_CN.ts │ │ ├── en_US.ts │ │ ├── zh-CN.ts │ │ └── en-US.ts │ └── icons │ │ ├── buguang.tsx │ │ ├── fangdajing.tsx │ │ ├── yueliang.tsx │ │ ├── yanzhengma01.tsx │ │ ├── shuyi_fanyi-36.tsx │ │ ├── jiaretaiyang.tsx │ │ ├── 3.tsx │ │ └── yanzhengma.tsx ├── directives │ └── auth.tsx ├── request │ ├── index.ts │ └── axios.ts ├── api │ ├── api.ts │ ├── index.ts │ ├── loginLog.ts │ ├── user.ts │ ├── auth.ts │ ├── role.ts │ ├── menu.ts │ └── apiLog.ts ├── main.tsx ├── pages │ ├── menu │ │ └── interface.ts │ ├── dashboard │ │ ├── tiny-area.tsx │ │ ├── tiny-line.tsx │ │ ├── tiny-column.tsx │ │ ├── column.tsx │ │ ├── index.css │ │ └── theme │ │ │ ├── light-column-theme.json │ │ │ └── dark-column-theme.json │ ├── login │ │ ├── index.css │ │ ├── index.tsx │ │ ├── hooks │ │ │ └── use-login.tsx │ │ ├── components │ │ │ ├── right-content.tsx │ │ │ ├── login-form.tsx │ │ │ └── forget-password-modal.tsx │ │ └── reset-password.tsx │ ├── user │ │ ├── email-input.tsx │ │ ├── avatar.tsx │ │ ├── new-edit-form.tsx │ │ └── index.tsx │ ├── login-log │ │ └── index.tsx │ └── role │ │ ├── new-edit-form.tsx │ │ ├── index.tsx │ │ └── role-menu.tsx ├── utils │ ├── auth.ts │ ├── i18n.ts │ ├── antd.ts │ └── utils.ts ├── config │ └── pages.tsx ├── stores │ ├── user.ts │ ├── message.ts │ ├── global.ts │ └── setting.ts ├── default-setting.ts ├── index.css ├── interface.ts ├── theme │ ├── light.ts │ └── dark.ts └── app.tsx ├── commitlint.config.js ├── postcss.config.js ├── public ├── images │ ├── login-image-dark.png │ ├── login-image-light.png │ └── login-right-bg.svg ├── logo.svg ├── vite.svg └── externals │ ├── vite.svg │ └── antd@5.20.6 │ └── reset.min.css ├── .vscode └── extensions.json ├── README.md ├── tsconfig.json ├── .gitignore ├── openapi2ts.config.ts ├── tsconfig.node.json ├── tailwind.config.js ├── .eslintrc.cjs ├── Dockerfile ├── tsconfig.app.json ├── index.html ├── vite.config.ts ├── script ├── create-page.js └── template │ ├── new-edit-form.template │ └── index.template ├── nginx └── config.sh ├── package.json └── .github └── workflows └── docker-publish.yml /.env: -------------------------------------------------------------------------------- 1 | VITE_PORT=5173 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | public -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /src/check-update.ts: -------------------------------------------------------------------------------- 1 | window.addEventListener('vite:preloadError', () => { 2 | window.location.reload(); 3 | }); -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/images/login-image-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dbfu/fluxy-admin-web/HEAD/public/images/login-image-dark.png -------------------------------------------------------------------------------- /public/images/login-image-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dbfu/fluxy-admin-web/HEAD/public/images/login-image-light.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbfu321.easy-i18n-helper", 4 | "dbfu321.iconfont-helper", 5 | "bradlc.vscode-tailwindcss" 6 | ] 7 | } -------------------------------------------------------------------------------- /src/hooks/use-pc-screen/index.tsx: -------------------------------------------------------------------------------- 1 | import { useResponsive } from 'ahooks'; 2 | 3 | 4 | 5 | export const usePCScreen = () => { 6 | const responsive = useResponsive(); 7 | return responsive.lg; 8 | } -------------------------------------------------------------------------------- /src/layouts/layout/message-handle/interface.ts: -------------------------------------------------------------------------------- 1 | export enum SocketMessageType { 2 | PermissionChange = 'PermissionChange', 3 | PasswordChange = 'PasswordChange', 4 | TokenExpire = 'TokenExpire', 5 | } -------------------------------------------------------------------------------- /src/components/icon-button/interface.ts: -------------------------------------------------------------------------------- 1 | import { ButtonProps } from 'antd'; 2 | 3 | export interface IconButtonProps { 4 | children: React.ReactNode; 5 | onClick?: ButtonProps['onClick']; 6 | className?: string; 7 | } -------------------------------------------------------------------------------- /src/components/link-button/interface.ts: -------------------------------------------------------------------------------- 1 | import { ButtonProps } from 'antd'; 2 | 3 | 4 | export interface LinkButtonProps { 5 | children: string; 6 | disabled?: boolean; 7 | onClick?: ButtonProps['onClick']; 8 | } -------------------------------------------------------------------------------- /src/router/index.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter } from 'react-router-dom'; 2 | import { routes } from './routes'; 3 | 4 | export const router: ReturnType = createBrowserRouter(routes); 5 | 6 | 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 快速开始 2 | 3 | ## 安装依赖 4 | 5 | ``` 6 | pnpm i 7 | ``` 8 | 9 | ## 运行项目 10 | 11 | ``` 12 | npm run dev 13 | ``` 14 | 15 | ## 预览项目 16 | 17 | 访问 地址预览项目 18 | 19 | # 开发文档 20 | 21 | 更多内容可以参考 [开发文档](https://doc.fluxyadmin.cn) 22 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/locales/zh_CN.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | FYrdjsiC: "名称1", 3 | AMiYPlnX: "类型", 4 | TrbtfIOV: "图标", 5 | eLhopNvY: "路由", 6 | oZCOqSjt: "文件地址", 7 | rCCTQauH: "按钮权限代码", 8 | iPgWtLJH: "排序号", 9 | ieErhJuS: "操作", 10 | ZTWluISV: "添加", 11 | SOmdfEBc: "编辑", 12 | OiKEMJSX: "新建", 13 | }; 14 | -------------------------------------------------------------------------------- /src/directives/auth.tsx: -------------------------------------------------------------------------------- 1 | import { isAuth } from '@/utils/auth'; 2 | import { directive } from "@dbfu/react-directive/directive"; 3 | 4 | export const registerAuthDirective = () => { 5 | directive('v-auth', { 6 | create: (authCode: string) => { 7 | return isAuth(authCode); 8 | }, 9 | }) 10 | } -------------------------------------------------------------------------------- /src/request/index.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios'; 2 | import request from './axios'; 3 | 4 | function axios(url: string, config: AxiosRequestConfig & { requestType?: string }): Promise { 5 | config.url = url; 6 | return request.request(config); 7 | } 8 | 9 | export default axios; -------------------------------------------------------------------------------- /src/api/api.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | /* eslint-disable */ 3 | import request from "@/request"; 4 | 5 | /** 获取接口列表 GET /api/list */ 6 | export async function api_apiList(options?: { [key: string]: any }) { 7 | return request("/api/list", { 8 | method: "GET", 9 | ...(options || {}), 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /src/layouts/layout/header/notification.tsx: -------------------------------------------------------------------------------- 1 | import IconButton from '@/components/icon-button'; 2 | import { BellOutlined } from '@ant-design/icons'; 3 | 4 | function Notification() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | 12 | export default Notification; 13 | -------------------------------------------------------------------------------- /src/components/global-loading/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import './index.css'; 4 | 5 | const GlobalLoading: React.FC = () => ( 6 |
7 |
8 |
9 | ) 10 | 11 | export default GlobalLoading; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./tsconfig.node.json" 9 | } 10 | ], 11 | "compilerOptions": { 12 | "jsx": "react-jsx", 13 | "jsxImportSource": "@dbfu/react-directive", 14 | }, 15 | "exclude": [ 16 | "public" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/assets/locales/en_US.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | FYrdjsiC: "Name 1", 3 | AMiYPlnX: "type", 4 | TrbtfIOV: "Icon", 5 | eLhopNvY: "route", 6 | oZCOqSjt: "File address", 7 | rCCTQauH: "Button permission code", 8 | iPgWtLJH: "Sort Number", 9 | ieErhJuS: "operation", 10 | ZTWluISV: "add to", 11 | SOmdfEBc: "edit", 12 | OiKEMJSX: "newly build", 13 | }; 14 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import 'nprogress/nprogress.css'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './app.tsx'; 4 | import './check-update.ts'; 5 | 6 | import { registerAuthDirective } from './directives/auth.tsx'; 7 | import './index.css'; 8 | 9 | registerAuthDirective() 10 | 11 | 12 | ReactDOM.createRoot(document.getElementById('root')!).render( 13 | 14 | ) 15 | -------------------------------------------------------------------------------- /.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 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | stats.html 27 | -------------------------------------------------------------------------------- /openapi2ts.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | schemaPath: 'http://127.0.0.1:7001/swagger-ui/index.json', 3 | serversPath: './src', 4 | requestLibPath: 'import request from "@/request";', 5 | isCamelCase: false, 6 | hook: { 7 | customFunctionName: (data: any) => { 8 | const res = data.tags[0] + '_' + data.operationId; 9 | return res.replace(/-/g, '_'); 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/hooks/use-auth/index.tsx: -------------------------------------------------------------------------------- 1 | import { useUserStore } from '@/stores/user'; 2 | import { isAuth } from '@/utils/auth'; 3 | import { useMemo } from 'react'; 4 | 5 | export const useAuth = (authCode: string) => { 6 | const { currentUser } = useUserStore(); 7 | const auth = useMemo(() => { 8 | return isAuth(authCode); 9 | }, [authCode, currentUser?.authList]); 10 | return auth; 11 | } 12 | 13 | -------------------------------------------------------------------------------- /src/components/exception/404.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Result } from 'antd'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | const Result404 = () => ( 5 | 11 | 首页 12 | 13 | )} 14 | /> 15 | ); 16 | 17 | export default Result404; -------------------------------------------------------------------------------- /src/pages/menu/interface.ts: -------------------------------------------------------------------------------- 1 | import { t } from '@/utils/i18n'; 2 | export enum MenuType { 3 | DIRECTORY = 1, 4 | MENU, 5 | BUTTON, 6 | LowCodePage, 7 | } 8 | 9 | export const MenuTypeName = { 10 | [MenuType.DIRECTORY.toString()]: t ("wuePkjHJ" /* 目录 */), 11 | [MenuType.MENU.toString()]: t ("mYuKCgjM" /* 菜单 */), 12 | [MenuType.BUTTON.toString()]: t ("ZJvOOWLP" /* 按钮 */), 13 | }; 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "skipLibCheck": true, 6 | "module": "ESNext", 7 | "moduleResolution": "bundler", 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "noEmit": true 11 | }, 12 | "include": ["vite.config.ts", "/script/create-page.js"] 13 | } 14 | -------------------------------------------------------------------------------- /src/components/loading/index.tsx: -------------------------------------------------------------------------------- 1 | import { Spin } from 'antd'; 2 | import NProgress from 'nprogress'; 3 | import { useEffect } from 'react'; 4 | 5 | export const Loading = () => { 6 | useEffect(() => { 7 | 8 | NProgress.start(); 9 | 10 | return () => { 11 | NProgress.done(); 12 | } 13 | }, []) 14 | 15 | return ( 16 |
17 | 18 |
19 | ); 20 | } -------------------------------------------------------------------------------- /src/components/draggable-tab/index.css: -------------------------------------------------------------------------------- 1 | .tab-layout.ant-tabs-card>.ant-tabs-nav .ant-tabs-tab { 2 | transition: none; 3 | } 4 | 5 | .tab-layout.ant-tabs-card>.ant-tabs-nav .ant-tabs-tab-remove:active { 6 | color: unset; 7 | } 8 | 9 | .tab-layout.ant-tabs-card>.ant-tabs-nav .ant-tabs-tab-btn:active { 10 | color: unset; 11 | } 12 | 13 | .tab-layout.ant-tabs-card>.ant-tabs-nav .ant-tabs-tab-btn:focus:not(:focus-visible) { 14 | color: unset; 15 | } -------------------------------------------------------------------------------- /src/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import { useUserStore } from '@/stores/user'; 2 | 3 | /** 4 | * 判断是否有权限 5 | * @param authCode 权限代码 6 | * @returns 7 | */ 8 | export const isAuth = (authCode: string) => { 9 | if (!authCode) return false; 10 | // 从全局数据中获取当前用户按钮权限列表 11 | const { currentUser } = useUserStore.getState(); 12 | 13 | const { authList = [] } = currentUser || {}; 14 | // 判断传进来权限代码是否存在权限列表中 15 | return authList.includes(authCode); 16 | }; 17 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | /* eslint-disable */ 3 | // API 更新时间: 4 | // API 唯一标识: 5 | import * as api from "./api"; 6 | import * as apiLog from "./apiLog"; 7 | import * as auth from "./auth"; 8 | import * as loginLog from "./loginLog"; 9 | import * as menu from "./menu"; 10 | import * as role from "./role"; 11 | import * as user from "./user"; 12 | export default { 13 | api, 14 | apiLog, 15 | auth, 16 | loginLog, 17 | menu, 18 | role, 19 | user, 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/link-button/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from 'antd' 2 | import { LinkButtonProps } from './interface' 3 | 4 | 5 | function LinkButton({ 6 | disabled, 7 | onClick, 8 | children 9 | }: LinkButtonProps) { 10 | return ( 11 | 19 | ) 20 | } 21 | 22 | export default LinkButton -------------------------------------------------------------------------------- /src/config/pages.tsx: -------------------------------------------------------------------------------- 1 | export const modules = import.meta.glob('../pages/**/index.tsx'); 2 | 3 | export const componentPaths = Object.keys(modules).map((path: string) => path.replace('../pages', '')); 4 | 5 | export const pages = Object.keys(modules).reduce Promise>>((prev, path: string) => { 6 | const formatPath = path.replace('../pages', ''); 7 | prev[formatPath] = async () => { 8 | // 这里其实就是动态加载js,如果报错了说明js资源不存在 9 | return await modules[path]() as any; 10 | } 11 | return prev; 12 | }, {}); 13 | 14 | -------------------------------------------------------------------------------- /src/layouts/layout/slide/menu.css: -------------------------------------------------------------------------------- 1 | ::-webkit-scrollbar-thumb { 2 | background: hsla(0, 0%, 52.9%, .4); 3 | 4 | border-radius: 4px; 5 | border: none; 6 | } 7 | 8 | .menu-slide::-webkit-scrollbar-thumb { 9 | background: transparent 10 | } 11 | 12 | .menu-slide:hover::-webkit-scrollbar-thumb { 13 | background: hsla(0, 0%, 52.9%, .4); 14 | } 15 | 16 | ::-webkit-scrollbar { 17 | width: 6px; 18 | height: 6px; 19 | background-color: transparent; 20 | } 21 | 22 | ::-webkit-scrollbar-track { 23 | background-color: transparent; 24 | } 25 | 26 | -------------------------------------------------------------------------------- /src/pages/dashboard/tiny-area.tsx: -------------------------------------------------------------------------------- 1 | import { TinyArea } from '@ant-design/plots'; 2 | 3 | const DemoTinyArea = () => { 4 | const data = [ 5 | 0, 300, 438, 287, 309, 600, 900, 575, 563, 300, 200 6 | ]; 7 | const config = { 8 | height: 95, 9 | data, 10 | smooth: true, 11 | areaStyle: { fill: 'l(360) 1:rgba(98,0,234,0.65) 0.5:rgba(177,128,245,0.5) 0.5:rgba(177,128,245,0.5)' }, 12 | }; 13 | return ; 14 | }; 15 | 16 | export default DemoTinyArea; -------------------------------------------------------------------------------- /src/stores/user.ts: -------------------------------------------------------------------------------- 1 | import { CurrentUser } from '@/interface'; 2 | import { create } from 'zustand'; 3 | import { devtools } from 'zustand/middleware'; 4 | 5 | interface State { 6 | currentUser: CurrentUser | null; 7 | } 8 | 9 | interface Action { 10 | setCurrentUser: (currentUser: State['currentUser']) => void; 11 | } 12 | 13 | export const useUserStore = create()( 14 | devtools( 15 | (set) => { 16 | return { 17 | currentUser: null, 18 | setCurrentUser: (currentUser: State['currentUser']) => 19 | set({ currentUser }), 20 | }; 21 | }, 22 | { name: 'globalUserStore' } 23 | ) 24 | ); 25 | -------------------------------------------------------------------------------- /src/router/provider.tsx: -------------------------------------------------------------------------------- 1 | import { RouterProvider } from 'react-router-dom'; 2 | 3 | import { antdUtils } from '@/utils/antd'; 4 | import { App } from 'antd'; 5 | import { useEffect } from 'react'; 6 | import { router } from '.'; 7 | 8 | 9 | export default function RootRouterProvider() { 10 | const { notification, message, modal } = App.useApp(); 11 | 12 | useEffect(() => { 13 | antdUtils.setMessageInstance(message); 14 | antdUtils.setNotificationInstance(notification); 15 | antdUtils.setModalInstance(modal); 16 | }, [notification, message, modal]); 17 | 18 | return ( 19 | 20 | ) 21 | } 22 | 23 | 24 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | import { defaultSetting } from './src/default-setting'; 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | darkMode: 'selector', 6 | content: [ 7 | "./index.html", 8 | "./src/**/*.{js,ts,jsx,tsx}", 9 | ], 10 | theme: { 11 | extend: { 12 | colors: { 13 | primary: 'var(--ant-color-primary)', 14 | shallow: 'var(--ant-color-bg-text-hover)', 15 | }, 16 | height: { 17 | header: `${defaultSetting.headerHeight}px` 18 | }, 19 | spacing: { 20 | header: `${defaultSetting.headerHeight}px` 21 | } 22 | }, 23 | }, 24 | plugins: [], 25 | } -------------------------------------------------------------------------------- /src/utils/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from "i18next"; 2 | import enUS from '@/assets/locales/en-US' 3 | import zhCN from '@/assets/locales/zh-CN' 4 | import { defaultSetting } from '@/default-setting'; 5 | 6 | i18n 7 | .init({ 8 | resources: { 9 | 'en': { 10 | translation: enUS, 11 | }, 12 | 'zh': { 13 | translation: zhCN, 14 | }, 15 | }, 16 | lng: defaultSetting.defaultLang || 'zh', 17 | fallbackLng: defaultSetting.defaultLang || 'zh', 18 | interpolation: { 19 | escapeValue: false 20 | }, 21 | }); 22 | 23 | export const t = (key: string) => { 24 | return i18n.t(key) || key; 25 | }; 26 | 27 | export { i18n }; -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh', '@typescript-eslint'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | 'react-hooks/exhaustive-deps': [0], 18 | '@typescript-eslint/no-explicit-any': [0], 19 | '@typescript-eslint/ban-ts-comment': [0] 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /src/default-setting.ts: -------------------------------------------------------------------------------- 1 | 2 | import { SystemSettingType } from './interface'; 3 | 4 | export const defaultSetting = { 5 | "primaryColor": "rgb(24,144,255)", 6 | "filterType": "light", 7 | "showFormType": "modal", 8 | "showKeepAliveTab": true, 9 | "title": "fluxy-admin", 10 | "headerHeight": 80, 11 | "slideWidth": 240, 12 | "collapsedSlideWidth": 112, 13 | "mobileMargin": 16, 14 | "showWatermark": true, 15 | "watermarkPos": "content", 16 | "languages": [ 17 | { 18 | "key": "zh", 19 | "name": "中文" 20 | }, 21 | { 22 | "key": "en", 23 | "name": "English" 24 | } 25 | ], 26 | "defaultLang": "zh" 27 | } as SystemSettingType; 28 | -------------------------------------------------------------------------------- /src/hooks/use-selector/index.tsx: -------------------------------------------------------------------------------- 1 | import { pick } from 'lodash-es'; 2 | 3 | import { useRef } from 'react'; 4 | import { shallow } from 'zustand/shallow'; 5 | 6 | type Pick = { 7 | [P in K]: T[P]; 8 | }; 9 | 10 | type Many = T | readonly T[]; 11 | 12 | export function useSelector( 13 | paths: Many

14 | ): (state: S) => Pick { 15 | const prev = useRef>({} as Pick); 16 | 17 | return (state: S) => { 18 | if (state) { 19 | const next = pick(state, paths); 20 | return shallow(prev.current, next) ? prev.current : (prev.current = next); 21 | } 22 | return prev.current; 23 | }; 24 | } 25 | 26 | -------------------------------------------------------------------------------- /src/router/routes.tsx: -------------------------------------------------------------------------------- 1 | import ErrorPage from '@/components/exception/500'; 2 | import Layout from '@/layouts/layout'; 3 | import Login from '@/pages/login'; 4 | import ResetPassword from '@/pages/login/reset-password'; 5 | import { Navigate, RouteObject } from 'react-router-dom'; 6 | 7 | export const routes: RouteObject[] = [ 8 | { 9 | path: '/user/login', 10 | Component: Login, 11 | }, 12 | { 13 | path: '/user/reset-password', 14 | Component: ResetPassword, 15 | }, 16 | { 17 | path: '/', 18 | element: ( 19 | 20 | ), 21 | }, 22 | { 23 | path: '*', 24 | Component: Layout, 25 | children: [], 26 | errorElement: 27 | }, 28 | ] -------------------------------------------------------------------------------- /src/assets/icons/buguang.tsx: -------------------------------------------------------------------------------- 1 | import Icon from "@ant-design/icons"; 2 | import type { CustomIconComponentProps } from "@ant-design/icons/lib/components/Icon"; 3 | 4 | const SVGBuguang = () => ( 5 | 16 | 17 | 18 | ); 19 | 20 | export const IconBuguang = (props: Partial) => ( 21 | 22 | ); 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM registry.cn-hangzhou.aliyuncs.com/dbfu/pnpm:8.4.0 as builder 2 | ARG SENTRY_AUTH_TOKEN 3 | 4 | WORKDIR /app/web 5 | 6 | COPY pnpm-lock.yaml . 7 | COPY package.json . 8 | 9 | RUN pnpm install 10 | 11 | COPY . . 12 | RUN pnpm run build 13 | 14 | FROM registry.cn-hangzhou.aliyuncs.com/dbfu/nginx:latest as nginx 15 | 16 | RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ 17 | && echo "Asia/Shanghai" > /etc/timezone 18 | 19 | WORKDIR /app/web 20 | 21 | RUN mkdir -p /app/www 22 | 23 | COPY --from=builder /app/web/dist /app/www 24 | 25 | EXPOSE 80 26 | EXPOSE 443 27 | 28 | RUN rm -rf /etc/nginx/conf.d/default.conf 29 | COPY ./nginx/config.sh /root 30 | RUN chmod +x /root/config.sh 31 | 32 | CMD ["/root/config.sh"] 33 | -------------------------------------------------------------------------------- /src/components/icon-button/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from 'antd' 2 | import cls from 'classnames' 3 | import { twMerge } from 'tailwind-merge' 4 | import { IconButtonProps } from './interface' 5 | 6 | 7 | function IconButton({ 8 | children, 9 | onClick, 10 | className 11 | }: IconButtonProps) { 12 | return ( 13 | 25 | ) 26 | } 27 | 28 | export default IconButton -------------------------------------------------------------------------------- /src/layouts/layout/header/theme-switcher.tsx: -------------------------------------------------------------------------------- 1 | import { Icon3 } from '@/assets/icons/3'; 2 | import { IconJiaretaiyang } from '@/assets/icons/jiaretaiyang'; 3 | import IconButton from '@/components/icon-button'; 4 | 5 | interface Props { 6 | darkMode: boolean; 7 | setDarkMode: (darkMode: boolean) => void; 8 | } 9 | 10 | function ThemeSwitcher({ 11 | darkMode, 12 | setDarkMode 13 | }: Props) { 14 | return ( 15 | { 17 | setDarkMode(!darkMode); 18 | }} 19 | > 20 | {!darkMode ? ( 21 | 22 | ) : ( 23 | 24 | )} 25 | 26 | ); 27 | } 28 | 29 | export default ThemeSwitcher; 30 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* 解决antd菜单不垂直居中的问题 */ 6 | .ant-menu-item { 7 | display: flex !important; 8 | align-items: center; 9 | } 10 | 11 | .ant-menu-submenu-title { 12 | display: flex !important; 13 | align-items: center; 14 | } 15 | 16 | /* 解决antd收起菜单选中状态背景色问题 */ 17 | .ant-menu-inline-collapsed .ant-menu-submenu.ant-menu-submenu-vertical.ant-menu-submenu-selected .ant-menu-submenu-title { 18 | background-color: var(--ant-menu-item-selected-bg); 19 | display: flex !important; 20 | } 21 | 22 | .dark .ant-menu-inline-collapsed .ant-menu-submenu.ant-menu-submenu-vertical.ant-menu-submenu-selected .ant-menu-submenu-title { 23 | background-color: var(--ant-menu-dark-item-selected-bg); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/pro-table/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from '@/hooks/use-selector'; 2 | import { useSettingStore } from '@/stores/setting'; 3 | import { ProTable, ProTableProps } from '@ant-design/pro-components'; 4 | 5 | function FProTable, U extends Record = any>( 6 | props: ProTableProps 7 | ) { 8 | 9 | const { filterType } = useSettingStore( 10 | useSelector('filterType') 11 | ) 12 | 13 | return ( 14 | 15 | {...props} 16 | search={props.search !== false ? { 17 | ...props.search, 18 | filterType, 19 | } : false} 20 | rowKey={props.rowKey || "id"} 21 | pagination={{ defaultPageSize: 10 }} 22 | /> 23 | ) 24 | } 25 | 26 | export default FProTable; -------------------------------------------------------------------------------- /src/layouts/common/tabs-context.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | 3 | import { createContext } from 'react'; 4 | 5 | interface KeepAliveTabContextType { 6 | /** 7 | * 刷新tab 8 | * @param path tab path 可以为空,为空则刷新当前tab 9 | */ 10 | refreshTab: (path?: string) => void; 11 | /** 12 | * 关闭tab 13 | * @param path tab path 可以为空,为空则关闭当前tab 14 | */ 15 | closeTab: (path?: string) => void; 16 | /** 17 | * 关闭其他tab 18 | * @param path tab path 可以为空,为空则关闭其他tab 19 | */ 20 | closeOtherTab: (path?: string) => void; 21 | } 22 | 23 | const defaultValue = { 24 | refreshTab: () => { }, 25 | closeTab: () => { }, 26 | closeOtherTab: () => { }, 27 | } 28 | 29 | 30 | export const KeepAliveTabContext = createContext(defaultValue); 31 | -------------------------------------------------------------------------------- /src/utils/antd.ts: -------------------------------------------------------------------------------- 1 | import type { MessageInstance } from 'antd/es/message/interface'; 2 | import type { ModalStaticFunctions } from 'antd/es/modal/confirm'; 3 | import type { NotificationInstance } from 'antd/es/notification/interface'; 4 | 5 | type ModalInstance = Omit; 6 | 7 | 8 | class AntdUtils { 9 | message!: MessageInstance; 10 | notification!: NotificationInstance; 11 | modal!: ModalInstance; 12 | 13 | setMessageInstance(message: MessageInstance) { 14 | this.message = message; 15 | this.message.success 16 | } 17 | 18 | setNotificationInstance(notification: NotificationInstance) { 19 | this.notification = notification; 20 | } 21 | 22 | setModalInstance(modal: ModalInstance) { 23 | this.modal = modal; 24 | } 25 | } 26 | 27 | export const antdUtils = new AntdUtils(); -------------------------------------------------------------------------------- /src/pages/dashboard/tiny-line.tsx: -------------------------------------------------------------------------------- 1 | import { TinyLine } from '@antv/g2plot'; 2 | import { useLayoutEffect, useRef } from 'react'; 3 | 4 | const DemoTinyLine = () => { 5 | 6 | const container = useRef(null); 7 | 8 | useLayoutEffect(() => { 9 | const data = [ 10 | 264, 417, 438, 887, 309, 397, 550, 575, 563, 430, 525, 592, 492, 467, 513, 546, 983, 340, 539, 243, 226, 192, 11 | ]; 12 | const tinyLine = new TinyLine(container.current!, { 13 | height: 40, 14 | autoFit: true, 15 | data, 16 | smooth: true, 17 | color: '#ffffff', 18 | }); 19 | 20 | tinyLine.render(); 21 | 22 | return () => { 23 | tinyLine.destroy(); 24 | } 25 | }, []); 26 | 27 | 28 | return ( 29 |

30 | ); 31 | }; 32 | 33 | export default DemoTinyLine; -------------------------------------------------------------------------------- /src/stores/message.ts: -------------------------------------------------------------------------------- 1 | import { SocketMessageType } from '@/layouts/layout/message-handle/interface'; 2 | import { create } from 'zustand'; 3 | import { devtools } from 'zustand/middleware'; 4 | 5 | export interface SocketMessage { 6 | type: SocketMessageType; 7 | data: any; 8 | } 9 | 10 | interface State { 11 | latestMessage?: SocketMessage | null; 12 | } 13 | 14 | interface Action { 15 | setLatestMessage: (latestMessage: State['latestMessage']) => void; 16 | } 17 | 18 | export const useMessageStore = create()( 19 | devtools( 20 | (set) => { 21 | return { 22 | latestMessage: null, 23 | setLatestMessage: (latestMessage: State['latestMessage']) => 24 | set({ 25 | latestMessage, 26 | }), 27 | }; 28 | }, 29 | { 30 | name: 'messageStore', 31 | } 32 | ) 33 | ); 34 | -------------------------------------------------------------------------------- /src/pages/dashboard/tiny-column.tsx: -------------------------------------------------------------------------------- 1 | import { TinyColumn } from '@antv/g2plot'; 2 | import { useLayoutEffect, useRef } from 'react'; 3 | 4 | const DemoTinyColumn = () => { 5 | 6 | const container = useRef(null); 7 | 8 | useLayoutEffect(() => { 9 | const data = [50, 40, 81, 400, 300, 219, 269]; 10 | 11 | const tinyColumn = new TinyColumn(container.current!, { 12 | height: 50, 13 | autoFit: true, 14 | data, 15 | tooltip: { 16 | customContent: function (x, data) { 17 | return `NO.${x}: ${data[0]?.data?.y.toFixed(2)}`; 18 | }, 19 | }, 20 | }); 21 | 22 | tinyColumn.render(); 23 | 24 | return () => { 25 | tinyColumn.destroy(); 26 | } 27 | }, []); 28 | 29 | 30 | return ( 31 |
32 | ); 33 | }; 34 | 35 | export default DemoTinyColumn; -------------------------------------------------------------------------------- /src/pages/login/index.css: -------------------------------------------------------------------------------- 1 | .img1 { 2 | animation: img1-anim 10s linear 0ms infinite normal backwards; 3 | } 4 | 5 | .img2 { 6 | animation: img2-anim 8s linear 0ms infinite normal backwards; 7 | } 8 | 9 | @keyframes img1-anim { 10 | 0% { 11 | transform: translate3d(0, 0, 0); 12 | } 13 | 14 | 50% { 15 | transform: translate3d(0px, 30px, 0) 16 | } 17 | 18 | 100% { 19 | transform: translate3d(0px, 0px, 0) 20 | } 21 | } 22 | 23 | @keyframes img2-anim { 24 | 0% { 25 | transform: translate3d(0px, 0px, 0) 26 | } 27 | 28 | 50% { 29 | transform: translate3d(0px, 20px, 0) 30 | } 31 | 32 | 100% { 33 | transform: translate3d(0px, 0px, 0) 34 | } 35 | } 36 | 37 | .custom.slick-dots .slick-active button { 38 | background: #000 !important; 39 | } 40 | 41 | .custom.slick-dots button { 42 | background: rgba(0, 0, 0, .7) !important; 43 | } -------------------------------------------------------------------------------- /src/layouts/common/watermark.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from '@/hooks/use-selector'; 2 | import { useSettingStore } from '@/stores/setting'; 3 | import { useUserStore } from '@/stores/user'; 4 | import { Watermark as AntdWatermark } from 'antd'; 5 | 6 | 7 | export default function Watermark({ children, type }: { 8 | children: React.ReactNode, 9 | type: 'full' | 'content', 10 | }) { 11 | 12 | const { currentUser } = useUserStore( 13 | useSelector('currentUser') 14 | ); 15 | 16 | const { showWatermark, watermarkPos } = useSettingStore( 17 | useSelector([ 18 | 'showWatermark', 19 | 'watermarkPos' 20 | ]) 21 | ) 22 | 23 | 24 | 25 | return ( 26 | 30 | {children} 31 | 32 | ) 33 | } -------------------------------------------------------------------------------- /src/components/modal-form/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from '@/hooks/use-selector'; 2 | import { useSettingStore } from '@/stores/setting'; 3 | import { clearFormValues } from '@/utils/utils'; 4 | import { DrawerForm, ModalForm, ModalFormProps } from '@ant-design/pro-components'; 5 | import { useUpdateEffect } from 'ahooks'; 6 | 7 | function FModalForm, U = Record>(props: ModalFormProps) { 8 | 9 | const { showFormType } = useSettingStore( 10 | useSelector('showFormType') 11 | ) 12 | 13 | useUpdateEffect(() => { 14 | if (!props.open && props.form) { 15 | clearFormValues(props.form); 16 | } 17 | }, [props.open]) 18 | 19 | if (showFormType === 'drawer') { 20 | return {...props} /> 21 | } 22 | 23 | return ( 24 | {...props} /> 25 | ) 26 | } 27 | 28 | export default FModalForm; -------------------------------------------------------------------------------- /src/components/global-loading/index.css: -------------------------------------------------------------------------------- 1 | .loading { 2 | display: block; 3 | position: relative; 4 | width: 6px; 5 | height: 10px; 6 | border-radius: 2px; 7 | animation: rectangle infinite 1s ease-in-out -0.2s; 8 | 9 | background-color: #673AB7; 10 | } 11 | 12 | .loading:before, 13 | .loading:after { 14 | position: absolute; 15 | width: 6px; 16 | height: 10px; 17 | content: ""; 18 | background-color: #673AB7; 19 | border-radius: 2px; 20 | } 21 | 22 | .loading:before { 23 | left: -14px; 24 | 25 | animation: rectangle infinite 1s ease-in-out -0.4s; 26 | } 27 | 28 | .loading:after { 29 | right: -14px; 30 | 31 | animation: rectangle infinite 1s ease-in-out; 32 | } 33 | 34 | @keyframes rectangle { 35 | 36 | 0%, 37 | 80%, 38 | 100% { 39 | height: 20px; 40 | box-shadow: 0 0 #673AB7; 41 | } 42 | 43 | 40% { 44 | height: 30px; 45 | box-shadow: 0 -20px #673AB7; 46 | } 47 | } -------------------------------------------------------------------------------- /src/assets/icons/fangdajing.tsx: -------------------------------------------------------------------------------- 1 | import Icon from "@ant-design/icons"; 2 | import type { CustomIconComponentProps } from "@ant-design/icons/lib/components/Icon"; 3 | 4 | const SVGFangdajing = () => ( 5 | 16 | 17 | 18 | ); 19 | 20 | export const IconFangdajing = (props: Partial) => ( 21 | 22 | ); 23 | -------------------------------------------------------------------------------- /src/components/exception/500.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Result, Typography } from 'antd'; 2 | import React from 'react'; 3 | import { useRouteError } from 'react-router-dom'; 4 | 5 | const ErrorPage: React.FC = () => { 6 | const error = useRouteError() as Error; 7 | 8 | 9 | return ( 10 | 18 | 19 | 回到首页 20 | 21 | , 22 | ]} 23 | > 24 | {import.meta.env.DEV && error?.stack?.split('\n').map((item, index) => ( 25 | 26 | 30 | {item} 31 | 32 | 33 | ))} 34 | 35 | ) 36 | }; 37 | 38 | export default ErrorPage; -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 5 | "target": "ES2020", 6 | "useDefineForClassFields": true, 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "module": "ESNext", 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "moduleDetection": "force", 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "baseUrl": "./", 26 | "paths": { 27 | "@/*": ["./src/*"] 28 | } 29 | }, 30 | "include": [ 31 | "src", 32 | "src/**/*.json" 33 | ], 34 | "exclude": [ 35 | "src/public/*" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | export interface PageData { 2 | data: T[]; 3 | total: number; 4 | } 5 | 6 | export interface PageParams { 7 | current?: number; 8 | pageSize?: number; 9 | } 10 | 11 | export interface PageRequestParams { 12 | page: number; 13 | size: number; 14 | } 15 | 16 | export interface SystemSettingType { 17 | title: string; 18 | headerHeight: number; 19 | slideWidth: number; 20 | collapsedSlideWidth: number; 21 | mobileMargin: number; 22 | showKeepAliveTab: boolean; 23 | primaryColor: string; 24 | filterType: 'light' | 'query'; 25 | showFormType: 'modal' | 'drawer'; 26 | showWatermark: boolean; 27 | watermarkPos: 'full' | 'content'; 28 | languages: { 29 | key: string; 30 | name: string; 31 | }[]; 32 | defaultLang: string; 33 | } 34 | 35 | export type Menu = API.MenuVO & { 36 | children?: Menu[]; 37 | parentPaths?: string[]; 38 | path?: string; 39 | }; 40 | 41 | export type CurrentUser = API.CurrentUserVO & { flatMenus: Menu[], menus: Menu[], authList: string[] } 42 | -------------------------------------------------------------------------------- /src/assets/icons/yueliang.tsx: -------------------------------------------------------------------------------- 1 | import Icon from "@ant-design/icons"; 2 | import type { CustomIconComponentProps } from "@ant-design/icons/lib/components/Icon"; 3 | 4 | const SVGYueliang = () => ( 5 | 16 | 17 | 18 | ); 19 | 20 | export const IconYueliang = (props: Partial) => ( 21 | 22 | ); 23 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { PageParams, PageRequestParams } from '@/interface'; 2 | import { FormInstance } from 'antd'; 3 | import { omit } from 'lodash-es'; 4 | 5 | export function getParamsBySearchParams(query: URLSearchParams) { 6 | const params = [...query.keys()].reduce>( 7 | (prev, cur: string) => { 8 | if (cur) { 9 | prev[cur] = query.get(cur); 10 | } 11 | return prev; 12 | }, 13 | {} 14 | ); 15 | return params as T; 16 | } 17 | 18 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 19 | export function toPageRequestParams>(pageParams: PageParams & T): PageRequestParams & Omit { 20 | return { 21 | ...omit(pageParams, 'current', 'pageSize'), 22 | page: pageParams.current ? pageParams.current - 1 : 0, 23 | size: pageParams.pageSize || 20, 24 | }; 25 | } 26 | 27 | export function clearFormValues(form: FormInstance) { 28 | form.setFieldsValue( 29 | Object.keys(form.getFieldsValue()) 30 | .reduce((prev, cur) => ({ ...prev, [cur]: undefined }), {}) 31 | ); 32 | } -------------------------------------------------------------------------------- /src/layouts/layout/header/lang-dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { IconShuyi_fanyi36 } from '@/assets/icons/shuyi_fanyi-36'; 2 | import IconButton from '@/components/icon-button'; 3 | import { defaultSetting } from '@/default-setting'; 4 | import { i18n, t } from '@/utils/i18n'; 5 | import { Dropdown } from 'antd'; 6 | 7 | interface Props { 8 | lang: string; 9 | setLang: (lang: string) => void; 10 | } 11 | 12 | function LangDropdown({ 13 | setLang, 14 | lang, 15 | }: Props) { 16 | return ( 17 | ({ 20 | label: `${t(language.name)}`, 21 | key: language.key, 22 | })), 23 | onClick: async ({ key }) => { 24 | await i18n.changeLanguage(key); 25 | setLang(key); 26 | }, 27 | selectedKeys: [lang] 28 | }} 29 | trigger={['click']} 30 | placement="bottom" 31 | > 32 |
33 | 34 | 35 | 36 |
37 |
38 | ); 39 | } 40 | 41 | export default LangDropdown; 42 | -------------------------------------------------------------------------------- /src/pages/dashboard/column.tsx: -------------------------------------------------------------------------------- 1 | import { Column } from '@ant-design/plots'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | import { useGlobalStore } from '@/stores/global'; 5 | 6 | import columnDarkTheme from './theme/dark-column-theme.json'; 7 | import columnLightTheme from './theme/light-column-theme.json'; 8 | 9 | const DemoColumn = () => { 10 | const [data, setData] = useState([]); 11 | 12 | const { darkMode } = useGlobalStore(); 13 | 14 | useEffect(() => { 15 | asyncFetch(); 16 | }, []); 17 | 18 | const asyncFetch = () => { 19 | fetch('https://gw.alipayobjects.com/os/antfincdn/8elHX%26irfq/stack-column-data.json') 20 | .then((response) => response.json()) 21 | .then((json) => setData(json)) 22 | .catch((error) => { 23 | console.log('fetch data failed', error); 24 | }); 25 | }; 26 | 27 | const config: any = { 28 | data, 29 | isStack: true, 30 | xField: 'year', 31 | yField: 'value', 32 | seriesField: 'type', 33 | height: 480, 34 | legend: { 35 | position: 'bottom' 36 | }, 37 | }; 38 | return ; 39 | }; 40 | 41 | export default DemoColumn; -------------------------------------------------------------------------------- /src/hooks/use-match-router/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useLocation, useMatches, useOutlet } from 'react-router-dom'; 3 | 4 | interface MatchRouteType { 5 | // 菜单名称 6 | title: string; 7 | // tab对应的url 8 | pathname: string; 9 | // 要渲染的组件 10 | children: any; 11 | // 路由,和pathname区别是,详情页 pathname是 /:id,routePath是 /1 12 | routePath: string; 13 | // 图标 14 | icon?: string; 15 | } 16 | 17 | export function useMatchRoute(): MatchRouteType | undefined { 18 | // 获取路由组件实例 19 | const children = useOutlet(); 20 | // 获取所有路由 21 | const matches = useMatches(); 22 | // 获取当前url 23 | const { pathname } = useLocation(); 24 | 25 | const [matchRoute, setMatchRoute] = useState(); 26 | 27 | // 监听pathname变了,说明路由有变化,重新匹配,返回新路由信息 28 | useEffect(() => { 29 | 30 | // 获取当前匹配的路由 31 | const lastRoute = matches[matches.length - 1]; 32 | 33 | if (!lastRoute?.handle) return; 34 | 35 | setMatchRoute({ 36 | title: (lastRoute?.handle as any)?.name, 37 | pathname, 38 | children, 39 | routePath: lastRoute?.pathname || '', 40 | icon: (lastRoute?.handle as any)?.icon, 41 | }); 42 | 43 | }, [pathname]) 44 | 45 | 46 | return matchRoute; 47 | } 48 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | fluxy-admin 9 | <% if(NODE_ENV !=='development') { %> 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | <% } %> 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/router/router-utils.tsx: -------------------------------------------------------------------------------- 1 | import { RouteObject } from 'react-router-dom'; 2 | import { router } from '.'; 3 | 4 | export const toLoginPage = () => { 5 | router.navigate('/user/login'); 6 | } 7 | 8 | function findNodeByPath(routes: RouteObject[], path: string) { 9 | for (let i = 0; i < routes.length; i += 1) { 10 | const element = routes[i]; 11 | 12 | if (element.path === path) return element; 13 | 14 | findNodeByPath(element.children || [], path); 15 | } 16 | } 17 | 18 | export const addRoutes = (parentPath: string, routes: RouteObject[]) => { 19 | if (!parentPath) { 20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 21 | router.routes.push(...routes as any); 22 | return; 23 | } 24 | 25 | const curNode = findNodeByPath(router.routes, parentPath); 26 | 27 | if (curNode?.children) { 28 | curNode?.children.push(...routes); 29 | } else if (curNode) { 30 | curNode.children = routes; 31 | } 32 | } 33 | 34 | export const replaceRoutes = (parentPath: string, routes: RouteObject[]) => { 35 | if (!parentPath) { 36 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 37 | router.routes.push(...routes as any); 38 | return; 39 | } 40 | 41 | const curNode = findNodeByPath(router.routes, parentPath); 42 | 43 | if (curNode) { 44 | curNode.children = routes; 45 | } 46 | } -------------------------------------------------------------------------------- /src/theme/light.ts: -------------------------------------------------------------------------------- 1 | import { type ThemeConfig } from 'antd'; 2 | import tinycolor from 'tinycolor2'; 3 | 4 | export function generateLightTheme(primaryColor: string): ThemeConfig { 5 | return { 6 | cssVar: true, 7 | token: { 8 | colorPrimary: primaryColor, 9 | colorLink: primaryColor, 10 | colorBgLayout: 'rgb(248, 248, 248)', 11 | colorBgTextHover: tinycolor(primaryColor).setAlpha(0.09).toRgbString(), 12 | controlItemBgActive: tinycolor(primaryColor).setAlpha(0.2).toRgbString(), 13 | controlItemBgActiveHover: tinycolor(primaryColor).setAlpha(0.25).toRgbString(), 14 | controlItemBgHover: tinycolor(primaryColor).setAlpha(0.1).toRgbString(), 15 | }, 16 | components: { 17 | Table: { 18 | headerBg: 'rgb(247, 248, 250)' 19 | }, 20 | Menu: { 21 | itemHeight: 50, 22 | iconSize: 18, 23 | collapsedIconSize: 18, 24 | itemColor: 'rgba(0, 0, 0, 0.88)', 25 | groupTitleColor: 'rgba(0, 0, 0, 0.88)', 26 | itemActiveBg: tinycolor(primaryColor).setAlpha(0.1).toRgbString(), 27 | itemHoverBg: tinycolor(primaryColor).setAlpha(0.1).toRgbString(), 28 | itemSelectedBg: tinycolor(primaryColor).setAlpha(0.1).toRgbString(), 29 | itemHoverColor: primaryColor, 30 | itemSelectedColor: primaryColor, 31 | subMenuItemBg: 'rgba(0, 0, 0, 0)', 32 | }, 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /src/layouts/layout/header/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { defaultSetting } from '@/default-setting'; 3 | import { useGlobalStore } from '@/stores/global'; 4 | import HeaderTitle from './header-title'; 5 | import LangDropdown from './lang-dropdown'; 6 | import MenuSearcher from './menu-searcher'; 7 | import ThemeSwitcher from './theme-switcher'; 8 | import UserInfo from './user-info'; 9 | 10 | function Header({ 11 | disconnectWS 12 | }: { 13 | disconnectWS: () => void 14 | }) { 15 | 16 | const { 17 | darkMode, 18 | collapsed, 19 | setCollapsed, 20 | setDarkMode, 21 | setLang, 22 | lang, 23 | } = useGlobalStore(); 24 | 25 | return ( 26 |
30 | 31 |
32 | 33 |
34 | 35 | 36 | 37 |
38 |
39 |
40 | ) 41 | } 42 | 43 | export default Header; -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/externals/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/layouts/layout/header/header-title.tsx: -------------------------------------------------------------------------------- 1 | import { IconBuguang } from '@/assets/icons/buguang'; 2 | import IconButton from '@/components/icon-button'; 3 | import { defaultSetting } from '@/default-setting'; 4 | import { MenuOutlined } from '@ant-design/icons'; 5 | import classNames from 'classnames'; 6 | 7 | interface Props { 8 | collapsed: boolean; 9 | setCollapsed: (collapsed: boolean) => void; 10 | } 11 | 12 | function HeaderTitle({ 13 | collapsed, 14 | setCollapsed, 15 | }: Props) { 16 | 17 | return ( 18 | <> 19 |
23 |
24 | 25 |

{defaultSetting.title}

26 |
27 | { 29 | setCollapsed(!collapsed); 30 | }}> 31 | 32 | 33 |
34 |
35 | { 37 | setCollapsed(!collapsed); 38 | }} 39 | > 40 | 41 | 42 |
43 | 44 | ); 45 | } 46 | 47 | export default HeaderTitle; 48 | -------------------------------------------------------------------------------- /src/stores/global.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | import { devtools, persist, createJSONStorage } from 'zustand/middleware'; 3 | 4 | interface State { 5 | darkMode: boolean; 6 | collapsed: boolean; 7 | lang: string; 8 | token: string; 9 | refreshToken: string; 10 | } 11 | 12 | interface Action { 13 | setDarkMode: (darkMode: State['darkMode']) => void; 14 | setCollapsed: (collapsed: State['collapsed']) => void; 15 | setLang: (lang: State['lang']) => void; 16 | setToken: (lang: State['token']) => void; 17 | setRefreshToken: (lang: State['refreshToken']) => void; 18 | } 19 | 20 | export const useGlobalStore = create()( 21 | devtools(persist( 22 | (set) => { 23 | return { 24 | darkMode: false, 25 | collapsed: false, 26 | lang: 'zh', 27 | token: '', 28 | refreshToken: '', 29 | setDarkMode: (darkMode: State['darkMode']) => set({ 30 | darkMode, 31 | }), 32 | setCollapsed: (collapsed: State['collapsed']) => set({ 33 | collapsed, 34 | }), 35 | setLang: (lang: State['lang']) => set({ 36 | lang, 37 | }), 38 | setToken: (token: State['token']) => set({ 39 | token, 40 | }), 41 | setRefreshToken: (refreshToken: State['refreshToken']) => set({ 42 | refreshToken, 43 | }), 44 | }; 45 | }, 46 | { 47 | name: 'globalStore', 48 | storage: createJSONStorage(() => localStorage), 49 | } 50 | ), 51 | { name: 'globalStore' } 52 | ) 53 | ) 54 | -------------------------------------------------------------------------------- /src/layouts/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import GlobalLoading from '@/components/global-loading'; 2 | import { useSelector } from '@/hooks/use-selector'; 3 | import { useGlobalStore } from '@/stores/global'; 4 | import { antdUtils } from '@/utils/antd'; 5 | import { useEffect } from 'react'; 6 | import { useUserDetail } from '../common/use-user-detail'; 7 | import Watermark from '../common/watermark'; 8 | import Content from './content'; 9 | import Header from './header'; 10 | import MessageHandle from './message-handle'; 11 | import Slide from './slide'; 12 | import SystemSetting from './system-setting'; 13 | 14 | export default function Layout() { 15 | 16 | const { lang } = useGlobalStore(useSelector('lang')); 17 | const { loading, disconnectWS } = useUserDetail(); 18 | 19 | useEffect(() => { 20 | if (import.meta.env.PROD) { 21 | // 加定时器是为了解决闪烁问题 22 | setTimeout(() => { 23 | antdUtils.notification.warning({ 24 | description: '请注意,每天晚上 12 点数据会重置所有数据。', 25 | message: '提示', 26 | duration: 0, 27 | placement: 'topRight' 28 | }); 29 | }, 300); 30 | } 31 | }, []) 32 | 33 | if (loading) { 34 | return ( 35 | 36 | ) 37 | } 38 | 39 | return ( 40 | 41 |
42 | 43 |
44 | 45 | 46 | {import.meta.env.DEV && } 47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/pages/login/index.tsx: -------------------------------------------------------------------------------- 1 | import { IconBuguang } from '@/assets/icons/buguang'; 2 | import { t } from '@/utils/i18n'; 3 | 4 | import { defaultSetting } from '@/default-setting'; 5 | import { useState } from 'react'; 6 | import ForgetPasswordModal from './components/forget-password-modal'; 7 | import LoginForm from './components/login-form'; 8 | import RightContent from './components/right-content'; 9 | 10 | import './index.css'; 11 | 12 | function Login() { 13 | const [emailResetPasswordOpen, setEmailResetPasswordOpen] = useState(false); 14 | 15 | return ( 16 |
17 |
18 |
19 |
20 |
21 | 22 | {defaultSetting.title} 23 |
24 |

27 | {t("wbTMzvDM" /* 一个高颜值后台管理系统 */)} 28 |

29 |
30 | { setEmailResetPasswordOpen(true) }} /> 31 |
32 |
33 | 34 | 38 |
39 | ); 40 | } 41 | 42 | export default Login; 43 | -------------------------------------------------------------------------------- /src/layouts/layout/content/index.tsx: -------------------------------------------------------------------------------- 1 | import { Loading } from '@/components/loading'; 2 | import { defaultSetting } from '@/default-setting'; 3 | import { usePCScreen } from '@/hooks/use-pc-screen'; 4 | import { useSelector } from '@/hooks/use-selector'; 5 | import Watermark from '@/layouts/common/watermark'; 6 | import { useGlobalStore } from '@/stores/global'; 7 | import { useSettingStore } from '@/stores/setting'; 8 | import { Suspense } from 'react'; 9 | import { Outlet } from 'react-router-dom'; 10 | import TabsLayout from '../../common/tabs-layout'; 11 | 12 | 13 | function Content() { 14 | 15 | const isPC = usePCScreen(); 16 | 17 | const { collapsed } = useGlobalStore(useSelector('collapsed')); 18 | const { showKeepAliveTab } = useSettingStore(useSelector('showKeepAliveTab')); 19 | 20 | const marginLeft = isPC ? collapsed ? defaultSetting.collapsedSlideWidth : defaultSetting.slideWidth : defaultSetting.mobileMargin; 21 | 22 | return ( 23 |
31 |
34 | 37 | )} 38 | > 39 | {showKeepAliveTab ? : } 40 | 41 |
42 | 43 |
44 | ); 45 | } 46 | 47 | export default Content; 48 | -------------------------------------------------------------------------------- /src/theme/dark.ts: -------------------------------------------------------------------------------- 1 | import { defaultSetting } from '@/default-setting'; 2 | import { theme, type ThemeConfig } from 'antd'; 3 | import tinycolor from "tinycolor2"; 4 | 5 | export function generateDarkTheme(primaryColor: string): ThemeConfig { 6 | return { 7 | algorithm: theme.darkAlgorithm, 8 | cssVar: true, 9 | token: { 10 | colorPrimary: primaryColor, 11 | colorBgContainer: 'rgb(26, 34, 63)', 12 | colorBgElevated: 'rgb(26, 34, 63)', 13 | colorBorder: 'rgba(189, 200, 240, 0.157)', 14 | colorBorderSecondary: 'rgba(189, 200, 240, 0.157)', 15 | colorBgTextHover: tinycolor(primaryColor).setAlpha(0.09).toRgbString(), 16 | controlItemBgActive: tinycolor(primaryColor).setAlpha(0.2).toRgbString(), 17 | controlItemBgActiveHover: tinycolor(primaryColor).setAlpha(0.25).toRgbString(), 18 | controlItemBgHover: tinycolor(primaryColor).setAlpha(0.1).toRgbString(), 19 | colorLink: primaryColor, 20 | colorBgLayout: 'rgb(17, 25, 54)', 21 | }, 22 | components: { 23 | Table: { 24 | headerBg: 'rgb(35, 43, 71)', 25 | borderColor: 'rgba(189, 200, 240, 0.157)', 26 | }, 27 | Menu: { 28 | iconSize: 18, 29 | collapsedIconSize: 18, 30 | itemHeight: 50, 31 | collapsedWidth: defaultSetting.collapsedSlideWidth - 32, 32 | darkItemSelectedBg: tinycolor(primaryColor).setAlpha(0.1).toRgbString(), 33 | darkItemHoverBg: tinycolor(primaryColor).setAlpha(0.1).toRgbString(), 34 | itemActiveBg: tinycolor(primaryColor).setAlpha(0.1).toRgbString(), 35 | darkItemSelectedColor: primaryColor, 36 | darkItemHoverColor: primaryColor, 37 | darkSubMenuItemBg: 'rgba(0, 0, 0, 0)', 38 | darkItemColor: 'rgb(255, 255, 255)', 39 | darkPopupBg: 'rgb(26, 34, 63)', 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /src/assets/icons/yanzhengma01.tsx: -------------------------------------------------------------------------------- 1 | import Icon from "@ant-design/icons"; 2 | import type { CustomIconComponentProps } from "@ant-design/icons/lib/components/Icon"; 3 | 4 | const SVGYanzhengma01 = () => ( 5 | 16 | 17 | 18 | ); 19 | 20 | export const IconYanzhengma01 = (props: Partial) => ( 21 | 22 | ); 23 | -------------------------------------------------------------------------------- /src/pages/dashboard/index.css: -------------------------------------------------------------------------------- 1 | .bg-card { 2 | @apply before:content-[''] before:absolute before:w-[210px] before:h-[210px] before:top-[-125px] before:right-[-15px] before:rounded-[50%] before:opacity-[.5]; 3 | } 4 | 5 | .dark .bg-card::before { 6 | background: linear-gradient(140.9deg, rgb(101, 31, 255) -14.02%, rgba(144, 202, 249, 0) 85.5%); 7 | } 8 | 9 | .bg-card { 10 | @apply after:content-[''] after:absolute after:w-[210px] after:h-[210px] after:top-[-85px] after:right-[-95px] after:rounded-[50%] after:dark:opacity-[.5]; 11 | } 12 | 13 | .dark .bg-card::after { 14 | background: linear-gradient(140.9deg, rgb(101, 31, 255) -14.02%, rgba(144, 202, 249, 0) 85.5%); 15 | } 16 | 17 | .dark .bg-card.theme1::before { 18 | background: linear-gradient(140.9deg, rgb(30, 136, 229) -14.02%, rgba(144, 202, 249, 0) 82.5%); 19 | } 20 | 21 | .dark .bg-card.theme1::after { 22 | background: linear-gradient(140.9deg, rgb(30, 136, 229) -14.02%, rgba(144, 202, 249, 0) 82.5%); 23 | } 24 | 25 | .dark .bg-card.theme2::before { 26 | background: linear-gradient(140.9deg, rgb(255, 193, 7) -14.02%, rgba(144, 202, 249, 0) 70.5%); 27 | } 28 | 29 | .dark .bg-card.theme2::after { 30 | background: linear-gradient(140.9deg, rgb(255, 193, 7) -14.02%, rgba(144, 202, 249, 0) 70.5%); 31 | } 32 | 33 | .bg-card::before { 34 | background: rgb(69, 39, 160); 35 | } 36 | 37 | .bg-card::after { 38 | background: rgb(69, 39, 160); 39 | } 40 | 41 | .bg-card.theme1::before { 42 | background: rgb(21, 101, 192); 43 | } 44 | 45 | .bg-card.theme1::after { 46 | background: rgb(21, 101, 192); 47 | } 48 | 49 | .dark .bg-card.theme2::before { 50 | background: linear-gradient(140.9deg, rgb(255, 193, 7) -14.02%, rgba(144, 202, 249, 0) 70.5%); 51 | } 52 | 53 | .dark .bg-card.theme2::after { 54 | background: linear-gradient(140.9deg, rgb(255, 193, 7) -14.02%, rgba(144, 202, 249, 0) 70.5%); 55 | } -------------------------------------------------------------------------------- /src/pages/user/email-input.tsx: -------------------------------------------------------------------------------- 1 | import { user_sendEmailCaptcha } from '@/api/user'; 2 | import { t } from '@/utils/i18n'; 3 | import { useRequest } from 'ahooks'; 4 | import { Button, Form, Input } from 'antd'; 5 | import { ChangeEventHandler, useEffect, useRef, useState } from "react"; 6 | 7 | interface PropsType { 8 | value?: string; 9 | onChange?: ChangeEventHandler; 10 | disabled?: boolean; 11 | } 12 | 13 | function EmailInput({ 14 | value, 15 | onChange, 16 | disabled, 17 | }: PropsType) { 18 | 19 | const [timer, setTimer] = useState(0); 20 | const form = Form.useFormInstance(); 21 | const intervalTimerRef = useRef(); 22 | 23 | const { runAsync } = useRequest(user_sendEmailCaptcha, { manual: true }); 24 | 25 | const sendEmailCaptcha = async () => { 26 | const values = await form.validateFields(['email']); 27 | setTimer(180); 28 | 29 | await runAsync(values.email); 30 | 31 | intervalTimerRef.current = window.setInterval(() => { 32 | setTimer(prev => { 33 | if (prev - 1 === 0) { 34 | window.clearInterval(intervalTimerRef.current); 35 | } 36 | return prev - 1; 37 | }); 38 | }, 1000); 39 | } 40 | 41 | 42 | useEffect(() => { 43 | return () => { 44 | if (intervalTimerRef.current) { 45 | window.clearInterval(intervalTimerRef.current); 46 | } 47 | } 48 | }, []); 49 | 50 | 51 | return ( 52 |
53 | 54 | {!disabled && ( 55 | 60 | )} 61 |
62 | ) 63 | } 64 | 65 | export default EmailInput; -------------------------------------------------------------------------------- /src/pages/login/hooks/use-login.tsx: -------------------------------------------------------------------------------- 1 | import { auth_getImageCaptcha, auth_getPublicKey, auth_login } from '@/api/auth'; 2 | import { useSelector } from '@/hooks/use-selector'; 3 | import { router } from '@/router'; 4 | import { useGlobalStore } from '@/stores/global'; 5 | import { useSettingStore } from '@/stores/setting'; 6 | import { useRequest } from 'ahooks'; 7 | import to from 'await-to-js'; 8 | import { JSEncrypt } from "jsencrypt"; 9 | const useLogin = () => { 10 | 11 | const { showKeepAliveTab } = useSettingStore( 12 | useSelector('showKeepAliveTab') 13 | ) 14 | 15 | const { data: captcha, refresh: refreshCaptcha } = useRequest( 16 | auth_getImageCaptcha 17 | ); 18 | 19 | const { runAsync: login, loading: loginLoading } = useRequest( 20 | auth_login, 21 | { manual: true } 22 | ); 23 | 24 | async function loginHandle(values: API.LoginDTO & { publicKey: string }) { 25 | if (!captcha) { 26 | return; 27 | } 28 | 29 | values.captchaId = captcha.id; 30 | 31 | // 获取公钥 32 | const [error, publicKey] = await to(auth_getPublicKey()); 33 | 34 | if (error) { 35 | return; 36 | } 37 | 38 | // 使用公钥对密码加密 39 | const encrypt = new JSEncrypt(); 40 | encrypt.setPublicKey(publicKey); 41 | const password = encrypt.encrypt(values.password!); 42 | 43 | if (!password) { 44 | return; 45 | } 46 | 47 | values.password = password; 48 | values.publicKey = publicKey; 49 | 50 | try { 51 | const data = await login(values); 52 | useGlobalStore.setState({ 53 | refreshToken: data.refreshToken, 54 | token: data.token, 55 | }); 56 | 57 | // 每次重新登录,清空keepAliveTabs 58 | if (showKeepAliveTab) { 59 | window.localStorage.removeItem('keepAliveTabs'); 60 | } 61 | 62 | router.navigate('/'); 63 | } catch { 64 | refreshCaptcha(); 65 | } 66 | } 67 | 68 | 69 | return { 70 | captcha, 71 | refreshCaptcha, 72 | login: loginHandle, 73 | loginLoading 74 | } 75 | } 76 | 77 | export default useLogin; -------------------------------------------------------------------------------- /src/pages/login-log/index.tsx: -------------------------------------------------------------------------------- 1 | import { t } from '@/utils/i18n'; 2 | import { 3 | Badge 4 | } from 'antd'; 5 | 6 | import { login_log_page } from '@/api/loginLog'; 7 | import FProTable from '@/components/pro-table'; 8 | import { toPageRequestParams } from '@/utils/utils'; 9 | import { ProColumnType } from '@ant-design/pro-components'; 10 | import dayjs from 'dayjs'; 11 | 12 | function LoginLogPage() { 13 | const columns: ProColumnType[] = [ 14 | { 15 | title: t("EOnUUxNS" /* 登录帐号 */), 16 | dataIndex: 'userName', 17 | }, 18 | { 19 | title: t("SQQeztUX" /* 登录IP */), 20 | dataIndex: 'ip', 21 | search: false, 22 | }, 23 | { 24 | title: t("pcFVrbFq" /* 登录地址 */), 25 | dataIndex: 'address', 26 | search: false, 27 | }, 28 | { 29 | title: t("ZroXYzZI" /* 浏览器 */), 30 | dataIndex: 'browser', 31 | search: false, 32 | }, 33 | { 34 | title: t("MsdUjinV" /* 操作系统 */), 35 | dataIndex: 'os', 36 | search: false, 37 | }, 38 | { 39 | title: t("aQMiCXYx" /* 登录状态 */), 40 | dataIndex: 'status', 41 | search: false, 42 | renderText: (value: boolean) => { 43 | return value ? ( 44 | 45 | ) : ( 46 | 47 | ); 48 | }, 49 | }, 50 | { 51 | title: t("pzvzlzLU" /* 登录消息 */), 52 | dataIndex: 'message', 53 | search: false, 54 | }, 55 | { 56 | title: t("AZWKRNAc" /* 登录时间 */), 57 | search: false, 58 | dataIndex: 'createDate', 59 | renderText: (value: Date) => { 60 | return dayjs(value).format('YYYY-MM-DD HH:mm:ss') 61 | } 62 | }, 63 | ]; 64 | 65 | return ( 66 | 67 | columns={columns} 68 | request={async (params) => { 69 | return login_log_page( 70 | toPageRequestParams(params) 71 | ) 72 | }} 73 | /> 74 | ); 75 | } 76 | 77 | export default LoginLogPage; 78 | -------------------------------------------------------------------------------- /src/layouts/layout/message-handle/index.tsx: -------------------------------------------------------------------------------- 1 | import { auth_refreshToken } from '@/api/auth'; 2 | import { toLoginPage } from '@/router/router-utils'; 3 | import { useGlobalStore } from '@/stores/global'; 4 | import { useMessageStore } from '@/stores/message'; 5 | import { antdUtils } from '@/utils/antd'; 6 | import { useRequest } from 'ahooks'; 7 | import { Modal } from 'antd'; 8 | import { useEffect } from 'react'; 9 | import { SocketMessageType } from './interface'; 10 | 11 | const MessageHandle = () => { 12 | 13 | const { latestMessage } = useMessageStore(); 14 | const { refreshToken, setToken } = useGlobalStore(); 15 | 16 | const { run: refreshTokenFunc } = useRequest(auth_refreshToken, { 17 | manual: true, 18 | onSuccess: (data) => { 19 | setToken(data.token!) 20 | }, 21 | onError() { 22 | toLoginPage(); 23 | }, 24 | }); 25 | 26 | const messageHandleMap = { 27 | [SocketMessageType.PermissionChange]: () => { 28 | Modal.destroyAll(); 29 | antdUtils.modal?.warning({ 30 | title: '权限变更', 31 | content: '由于你的权限已经变更,需要重新刷新页面。', 32 | onOk: () => { 33 | window.location.reload(); 34 | }, 35 | }) 36 | }, 37 | [SocketMessageType.PasswordChange]: () => { 38 | Modal.destroyAll(); 39 | const hiddenModal = antdUtils.modal?.warning({ 40 | title: '密码重置', 41 | content: '密码已经重置,需要重新登录。', 42 | onOk: () => { 43 | toLoginPage(); 44 | if (hiddenModal) { 45 | hiddenModal.destroy(); 46 | } 47 | }, 48 | }) 49 | }, 50 | [SocketMessageType.TokenExpire]: async () => { 51 | refreshTokenFunc({ refreshToken }); 52 | }, 53 | }; 54 | 55 | useEffect(() => { 56 | if (latestMessage?.type && messageHandleMap[latestMessage?.type]) { 57 | messageHandleMap[latestMessage?.type](); 58 | } 59 | }, [latestMessage]) 60 | 61 | return null; 62 | } 63 | 64 | export default MessageHandle; -------------------------------------------------------------------------------- /src/layouts/layout/slide/index.tsx: -------------------------------------------------------------------------------- 1 | import { useUpdateEffect } from 'ahooks'; 2 | import { Drawer } from 'antd'; 3 | 4 | import { IconBuguang } from '@/assets/icons/buguang'; 5 | import { defaultSetting } from '@/default-setting'; 6 | import { usePCScreen } from '@/hooks/use-pc-screen'; 7 | import { useGlobalStore } from '@/stores/global'; 8 | import SlideMenu from './menu'; 9 | 10 | 11 | function SlideIndex() { 12 | 13 | const isPC = usePCScreen(); 14 | 15 | const { 16 | collapsed, 17 | setCollapsed, 18 | } = useGlobalStore(); 19 | 20 | 21 | useUpdateEffect(() => { 22 | if (!isPC) { 23 | setCollapsed(true); 24 | } else { 25 | setCollapsed(false); 26 | } 27 | }, [isPC]); 28 | 29 | 30 | function renderMenu() { 31 | return ( 32 | 33 | ) 34 | } 35 | 36 | if (!isPC) { 37 | return ( 38 | 50 | 51 |

{defaultSetting.title}

52 |
53 | )} 54 | styles={{ 55 | header: { padding: '24px 0', border: 'none' }, 56 | body: { padding: '0 16px' } 57 | }} 58 | onClose={() => { 59 | setCollapsed(true); 60 | }} 61 | > 62 | {renderMenu()} 63 | 64 | ) 65 | } 66 | 67 | return ( 68 |
72 | {renderMenu()} 73 |
74 | ) 75 | } 76 | 77 | export default SlideIndex; -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react-swc'; 2 | import externalGlobals from 'rollup-plugin-external-globals'; 3 | import { visualizer } from 'rollup-plugin-visualizer'; 4 | import { defineConfig, loadEnv } from 'vite'; 5 | import { createHtmlPlugin } from 'vite-plugin-html'; 6 | 7 | const env = loadEnv(process.env.NODE_ENV!, process.cwd()); 8 | 9 | const plugins = [ 10 | externalGlobals({ 11 | react: 'React', 12 | 'react-dom': 'ReactDOM', 13 | antd: 'antd', 14 | 'lodash-es': '_', 15 | 'react-router-dom': 'ReactRouterDOM', 16 | 'ahooks': 'ahooks', 17 | }) 18 | ] 19 | 20 | if (process.env.ANALYZE) { 21 | plugins.push( 22 | visualizer({ 23 | open: true, // 直接在浏览器中打开分析报告 24 | gzipSize: true, // 显示gzip后的大小 25 | brotliSize: true, // 显示brotli压缩后的大小 26 | }) 27 | ) 28 | } 29 | 30 | // https://vitejs.dev/config/ 31 | export default defineConfig({ 32 | plugins: [ 33 | react({ 34 | jsxImportSource: '@dbfu/react-directive', 35 | }), 36 | createHtmlPlugin({ 37 | inject: { 38 | data: { 39 | NODE_ENV: process.env.NODE_ENV, 40 | }, 41 | }, 42 | }), 43 | ], 44 | build: { 45 | rollupOptions: { 46 | external: [ 47 | 'react', 48 | 'react-dom', 49 | 'antd', 50 | 'lodash-es', 51 | 'ahooks', 52 | 'react-router-dom', 53 | ], 54 | plugins, 55 | } 56 | }, 57 | resolve: { 58 | alias: { 59 | '@': '/src/', 60 | }, 61 | }, 62 | server: { 63 | port: env.VITE_PORT ? +env.VITE_PORT : 5173, 64 | open: true, 65 | proxy: { 66 | '/api': { 67 | target: 'http://localhost:7001', 68 | changeOrigin: true, 69 | }, 70 | '/file': { 71 | target: 'http://localhost:9002', 72 | changeOrigin: true, 73 | rewrite: (path) => path.replace(/^\/file/, ''), 74 | }, 75 | '/ws': { 76 | target: 'ws://localhost:7001', 77 | changeOrigin: true, 78 | ws: true, 79 | rewrite: (path) => path.replace(/^\/ws/, ''), 80 | }, 81 | }, 82 | }, 83 | }) 84 | -------------------------------------------------------------------------------- /script/create-page.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import fs from 'fs'; 3 | import path, { dirname } from 'path'; 4 | import { fileURLToPath } from 'url'; 5 | function firstCharToUpperCase(str) { 6 | return str[0].toUpperCase() + str.substring(1); 7 | } 8 | 9 | // 获取当前模块的路径 10 | const __filename = fileURLToPath(import.meta.url); 11 | // 获取当前模块所在的目录路径 12 | const __dirname = dirname(__filename); 13 | 14 | const [moduleName] = process.argv.slice(2, 3); 15 | 16 | if (moduleName.includes('.')) { 17 | console.log('模块名称不能包含特殊字符'); 18 | process.exit(); 19 | } 20 | 21 | if (!moduleName) { 22 | console.log('请输入模块名称'); 23 | process.exit(); 24 | } 25 | 26 | 27 | if (!fs.existsSync(path.resolve(__dirname, `../src/pages/${moduleName}`))) { 28 | fs.mkdirSync(path.resolve(__dirname, `../src/pages/${moduleName}`)); 29 | } 30 | 31 | 32 | let indexContent = fs 33 | .readFileSync(path.resolve(__dirname, './template/index.template')) 34 | .toString(); 35 | 36 | let formContent = fs 37 | .readFileSync(path.resolve(__dirname, './template/new-edit-form.template')) 38 | .toString(); 39 | 40 | let name; 41 | let varName = moduleName; 42 | 43 | if (moduleName.includes('-')) { 44 | name = moduleName 45 | .split('-') 46 | .map(o => firstCharToUpperCase(o)) 47 | .join(''); 48 | 49 | varName = moduleName 50 | .split('-') 51 | .filter((_, index) => index > 0) 52 | .map(o => firstCharToUpperCase(o)) 53 | .join(''); 54 | varName = [moduleName.split('-')[0], varName].join(''); 55 | 56 | } else { 57 | name = moduleName[0].toUpperCase() + moduleName.substring(1); 58 | } 59 | 60 | indexContent = indexContent 61 | .replace(/\$1/g, name) 62 | .replace(/\$2/g, varName); 63 | 64 | formContent = formContent 65 | .replace(/\$1/g, name) 66 | .replace(/\$2/g, varName); 67 | 68 | fs.writeFileSync( 69 | path.resolve( 70 | __dirname, 71 | `../src/pages/${moduleName}/index.tsx` 72 | ), 73 | indexContent 74 | ); 75 | 76 | fs.writeFileSync( 77 | path.resolve( 78 | __dirname, 79 | `../src/pages/${moduleName}/new-edit-form.tsx` 80 | ), 81 | formContent 82 | ); 83 | 84 | console.log('页面创建成功!'); 85 | 86 | 87 | -------------------------------------------------------------------------------- /src/assets/icons/shuyi_fanyi-36.tsx: -------------------------------------------------------------------------------- 1 | import Icon from "@ant-design/icons"; 2 | import type { CustomIconComponentProps } from "@ant-design/icons/lib/components/Icon"; 3 | 4 | const SVGShuyi_fanyi36 = () => ( 5 | 16 | 17 | 18 | ); 19 | 20 | export const IconShuyi_fanyi36 = (props: Partial) => ( 21 | 22 | ); 23 | -------------------------------------------------------------------------------- /src/pages/user/avatar.tsx: -------------------------------------------------------------------------------- 1 | import { antdUtils } from '@/utils/antd'; 2 | import { t } from '@/utils/i18n'; 3 | import { PlusOutlined } from '@ant-design/icons'; 4 | import { Upload } from 'antd'; 5 | import ImgCrop from 'antd-img-crop'; 6 | import type { UploadChangeParam } from 'antd/es/upload'; 7 | import type { RcFile, UploadFile, UploadProps } from 'antd/es/upload/interface'; 8 | 9 | const beforeUpload = (file: RcFile) => { 10 | const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png'; 11 | if (!isJpgOrPng) { 12 | antdUtils.message?.error(t("IrkXPQuD" /* 文件类型错误 */)); 13 | } 14 | const isLt2M = file.size / 1024 / 1024 < 2; 15 | if (!isLt2M) { 16 | antdUtils.message?.error(t("ghBOBkBd" /* 文件大小不能超过2M */)); 17 | } 18 | 19 | if (!(isJpgOrPng && isLt2M)) { 20 | return Upload.LIST_IGNORE; 21 | } 22 | 23 | return true; 24 | }; 25 | 26 | interface PropsType { 27 | value?: UploadFile[]; 28 | onChange?: (value: UploadFile[]) => void; 29 | } 30 | 31 | function Avatar({ 32 | value, 33 | onChange, 34 | }: PropsType) { 35 | 36 | const handleChange: UploadProps['onChange'] = (info: UploadChangeParam) => { 37 | if (onChange) { 38 | onChange(info.fileList); 39 | } 40 | }; 41 | 42 | const onPreview = async (file: UploadFile) => { 43 | const src = file.url || file?.response?.filePath; 44 | if (src) { 45 | const imgWindow = window.open(src); 46 | 47 | if (imgWindow) { 48 | const image = new Image(); 49 | image.src = src; 50 | imgWindow.document.write(image.outerHTML); 51 | } else { 52 | window.location.href = src; 53 | } 54 | } 55 | }; 56 | 57 | 58 | return ( 59 | 60 | 70 | {(value?.length || 0) < 1 && } 71 | 72 | 73 | ); 74 | } 75 | 76 | export default Avatar; -------------------------------------------------------------------------------- /src/hooks/use-websocket/index.ts: -------------------------------------------------------------------------------- 1 | import { useWebSocket } from 'ahooks'; 2 | import type { Options, Result } from 'ahooks/lib/useWebSocket'; 3 | import { useCallback, useRef } from 'react'; 4 | 5 | export function useWebSocketMessage( 6 | socketUrl: string, 7 | options?: Options 8 | ): Result { 9 | const timerRef = useRef(); 10 | 11 | const { 12 | latestMessage, 13 | sendMessage, 14 | connect, 15 | disconnect, 16 | readyState, 17 | webSocketIns, 18 | } = useWebSocket(socketUrl, { 19 | ...options, 20 | reconnectLimit: 30, 21 | reconnectInterval: 6000, 22 | onOpen: (event: Event, instance: WebSocket) => { 23 | sendHeartbeat(); 24 | 25 | options?.onOpen && options.onOpen(event, instance); 26 | }, 27 | onMessage: (message: MessageEvent, instance: WebSocket) => { 28 | // 再次发送心跳消息 29 | sendHeartbeat(); 30 | 31 | options?.onMessage && options.onMessage(message, instance); 32 | }, 33 | onClose(event, instance) { 34 | resetHeartbeat(); 35 | 36 | options?.onClose && options.onClose(event, instance); 37 | }, 38 | onError(event, instance) { 39 | resetHeartbeat(); 40 | 41 | options?.onError && options.onError(event, instance); 42 | }, 43 | }); 44 | 45 | // 清除重连的定时器 46 | function resetHeartbeat() { 47 | if (timerRef.current) { 48 | window.clearTimeout(timerRef.current); 49 | } 50 | } 51 | 52 | // 发送心跳消息 53 | function sendHeartbeat() { 54 | if (webSocketIns?.CLOSED) { 55 | return; 56 | } 57 | resetHeartbeat(); 58 | 59 | // 三秒之后发送一次心跳消息 60 | setTimeout(() => { 61 | sendMessage && sendMessage(JSON.stringify({ type: 'Ping' })); 62 | // 心跳消息发送3s后,还没得到服务器响应,说明服务器可能挂了,需要自动重连。 63 | timerRef.current = window.setTimeout(() => { 64 | disconnect && disconnect(); 65 | connect && connect(); 66 | }, 3000); 67 | }, 3000); 68 | } 69 | 70 | const disconnectWS = useCallback(() => { 71 | disconnect && disconnect(); 72 | resetHeartbeat(); 73 | }, []) 74 | 75 | return { 76 | latestMessage, 77 | connect, 78 | sendMessage, 79 | disconnect: disconnectWS, 80 | readyState, 81 | webSocketIns, 82 | }; 83 | } 84 | -------------------------------------------------------------------------------- /src/stores/setting.ts: -------------------------------------------------------------------------------- 1 | import { defaultSetting } from '@/default-setting'; 2 | import { create } from 'zustand'; 3 | import { createJSONStorage, devtools, persist } from 'zustand/middleware'; 4 | 5 | interface State { 6 | primaryColor: string; 7 | showKeepAliveTab: boolean; 8 | filterType: 'light' | 'query'; 9 | showFormType: 'drawer' | 'modal'; 10 | showWatermark: boolean; 11 | watermarkPos: 'full' | 'content'; 12 | } 13 | 14 | interface Action { 15 | setPrimaryColor: (darkMode: State['primaryColor']) => void; 16 | setShowKeepAliveTab: (collapsed: State['showKeepAliveTab']) => void; 17 | setFilterType: (type: State['filterType']) => void; 18 | setShowFormType: (type: State['showFormType']) => void; 19 | setShowWatermark: (showWatermark: State['showWatermark']) => void; 20 | setWatermarkPos: (pos: State['watermarkPos']) => void; 21 | reset: () => void; 22 | } 23 | 24 | export const useSettingStore = create()( 25 | devtools(persist( 26 | (set) => { 27 | return { 28 | primaryColor: defaultSetting.primaryColor, 29 | setPrimaryColor: (collapsed) => set({ primaryColor: collapsed }), 30 | showKeepAliveTab: defaultSetting.showKeepAliveTab, 31 | setShowKeepAliveTab: (collapsed) => set({ showKeepAliveTab: collapsed }), 32 | filterType: defaultSetting.filterType, 33 | setFilterType: (type) => set({ filterType: type }), 34 | showFormType: defaultSetting.showFormType, 35 | setShowFormType: (type) => set({ showFormType: type }), 36 | showWatermark: defaultSetting.showWatermark, 37 | setShowWatermark: (showWatermark) => set({ showWatermark }), 38 | watermarkPos: defaultSetting.watermarkPos, 39 | setWatermarkPos: (pos) => set({ watermarkPos: pos }), 40 | reset: () => { 41 | set({ 42 | primaryColor: defaultSetting.primaryColor, 43 | showKeepAliveTab: defaultSetting.showKeepAliveTab, 44 | filterType: defaultSetting.filterType, 45 | showFormType: defaultSetting.showFormType, 46 | }); 47 | } 48 | }; 49 | }, 50 | { 51 | name: 'settingStore', 52 | storage: createJSONStorage(() => localStorage), 53 | } 54 | ), 55 | { name: 'settingStore' } 56 | ) 57 | ) 58 | -------------------------------------------------------------------------------- /src/api/loginLog.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | /* eslint-disable */ 3 | import request from "@/request"; 4 | 5 | /** 编辑 PUT /login-log/ */ 6 | export async function login_log_edit( 7 | body: API.LoginLogDTO, 8 | options?: { [key: string]: any } 9 | ) { 10 | return request("/login-log/", { 11 | method: "PUT", 12 | headers: { 13 | "Content-Type": "application/json", 14 | }, 15 | data: body, 16 | ...(options || {}), 17 | }); 18 | } 19 | 20 | /** 新建 POST /login-log/ */ 21 | export async function login_log_create( 22 | body: API.LoginLogDTO, 23 | options?: { [key: string]: any } 24 | ) { 25 | return request("/login-log/", { 26 | method: "POST", 27 | headers: { 28 | "Content-Type": "application/json", 29 | }, 30 | data: body, 31 | ...(options || {}), 32 | }); 33 | } 34 | 35 | /** 根据id查询 GET /login-log/${param0} */ 36 | export async function login_log_getById( 37 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) 38 | params: API.loginLogGetByIdParams, 39 | options?: { [key: string]: any } 40 | ) { 41 | const { id: param0, ...queryParams } = params; 42 | return request(`/login-log/${param0}`, { 43 | method: "GET", 44 | params: { ...queryParams }, 45 | ...(options || {}), 46 | }); 47 | } 48 | 49 | /** 删除 DELETE /login-log/${param0} */ 50 | export async function login_log_remove( 51 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) 52 | params: API.loginLogRemoveParams, 53 | options?: { [key: string]: any } 54 | ) { 55 | const { id: param0, ...queryParams } = params; 56 | return request(`/login-log/${param0}`, { 57 | method: "DELETE", 58 | params: { ...queryParams }, 59 | ...(options || {}), 60 | }); 61 | } 62 | 63 | /** 查询全部 GET /login-log/list */ 64 | export async function login_log_list(options?: { [key: string]: any }) { 65 | return request("/login-log/list", { 66 | method: "GET", 67 | ...(options || {}), 68 | }); 69 | } 70 | 71 | /** 分页查询 GET /login-log/page */ 72 | export async function login_log_page( 73 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) 74 | params: API.loginLogPageParams, 75 | options?: { [key: string]: any } 76 | ) { 77 | return request("/login-log/page", { 78 | method: "GET", 79 | params: { 80 | ...params, 81 | }, 82 | ...(options || {}), 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /public/images/login-right-bg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/assets/icons/jiaretaiyang.tsx: -------------------------------------------------------------------------------- 1 | import Icon from "@ant-design/icons"; 2 | import type { CustomIconComponentProps } from "@ant-design/icons/lib/components/Icon"; 3 | 4 | const SVGJiaretaiyang = () => ( 5 | 16 | 17 | 18 | ); 19 | 20 | export const IconJiaretaiyang = (props: Partial) => ( 21 | 22 | ); 23 | -------------------------------------------------------------------------------- /src/api/user.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | /* eslint-disable */ 3 | import request from "@/request"; 4 | 5 | /** 更新用户 PUT /user/ */ 6 | export async function user_update( 7 | body: API.UserDTO, 8 | options?: { [key: string]: any } 9 | ) { 10 | return request("/user/", { 11 | method: "PUT", 12 | headers: { 13 | "Content-Type": "application/json", 14 | }, 15 | data: body, 16 | ...(options || {}), 17 | }); 18 | } 19 | 20 | /** 创建用户 POST /user/ */ 21 | export async function user_create( 22 | body: API.UserDTO, 23 | options?: { [key: string]: any } 24 | ) { 25 | return request("/user/", { 26 | method: "POST", 27 | headers: { 28 | "Content-Type": "application/json", 29 | }, 30 | data: body, 31 | ...(options || {}), 32 | }); 33 | } 34 | 35 | /** 根据id查询 GET /user/${param0} */ 36 | export async function user_getById( 37 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) 38 | params: API.userGetByIdParams, 39 | options?: { [key: string]: any } 40 | ) { 41 | const { id: param0, ...queryParams } = params; 42 | return request(`/user/${param0}`, { 43 | method: "GET", 44 | params: { ...queryParams }, 45 | ...(options || {}), 46 | }); 47 | } 48 | 49 | /** 删除 DELETE /user/${param0} */ 50 | export async function user_remove( 51 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) 52 | params: API.userRemoveParams, 53 | options?: { [key: string]: any } 54 | ) { 55 | const { id: param0, ...queryParams } = params; 56 | return request(`/user/${param0}`, { 57 | method: "DELETE", 58 | params: { ...queryParams }, 59 | ...(options || {}), 60 | }); 61 | } 62 | 63 | /** 分页查询 GET /user/page */ 64 | export async function user_page( 65 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) 66 | params: API.userPageParams, 67 | options?: { [key: string]: any } 68 | ) { 69 | return request("/user/page", { 70 | method: "GET", 71 | params: { 72 | ...params, 73 | }, 74 | ...(options || {}), 75 | }); 76 | } 77 | 78 | /** 发送邮箱验证码 POST /user/send/email/captcha */ 79 | export async function user_sendEmailCaptcha( 80 | body: Record, 81 | options?: { [key: string]: any } 82 | ) { 83 | return request("/user/send/email/captcha", { 84 | method: "POST", 85 | headers: { 86 | "Content-Type": "application/json", 87 | }, 88 | data: body, 89 | ...(options || {}), 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import { App as AntdApp, ConfigProvider, ThemeConfig } from 'antd'; 2 | import enUS from 'antd/locale/en_US'; 3 | import zhCN from 'antd/locale/zh_CN'; 4 | import { useEffect, useState } from 'react'; 5 | 6 | import { useGlobalStore } from '@/stores/global'; 7 | 8 | import RootRouterProvider from '@/router/provider'; 9 | import { configResponsive } from 'ahooks'; 10 | import NProgress from 'nprogress'; 11 | import { i18n } from './utils/i18n'; 12 | 13 | import { useSelector } from './hooks/use-selector'; 14 | import { useSettingStore } from './stores/setting'; 15 | import { generateDarkTheme } from './theme/dark'; 16 | import { generateLightTheme } from './theme/light'; 17 | 18 | configResponsive({ 19 | md: 768, 20 | lg: 1024, 21 | }); 22 | 23 | NProgress.configure({ 24 | minimum: 0.3, 25 | easing: 'ease', 26 | speed: 800, 27 | showSpinner: false, 28 | parent: '#root' 29 | }); 30 | 31 | function App() { 32 | 33 | const { darkMode, lang } = useGlobalStore( 34 | useSelector(['darkMode', 'lang']) 35 | ); 36 | 37 | const { primaryColor } = useSettingStore( 38 | useSelector(['primaryColor']) 39 | ) 40 | 41 | const [curTheme, setCurTheme] = useState(() => { 42 | return darkMode ? generateDarkTheme('') : generateLightTheme(''); 43 | }); 44 | 45 | useEffect(() => { 46 | if (darkMode) { 47 | const theme = generateDarkTheme(primaryColor); 48 | document.body.classList.remove('light'); 49 | document.body.classList.add('dark'); 50 | document.body.style.backgroundColor = theme.token?.colorBgLayout || ''; 51 | setCurTheme(theme); 52 | } else { 53 | const theme = generateLightTheme(primaryColor); 54 | document.body.classList.remove('dark'); 55 | document.body.classList.add('light'); 56 | document.body.style.backgroundColor = theme.token?.colorBgLayout || ''; 57 | setCurTheme(theme); 58 | } 59 | 60 | setTimeout(() => { 61 | document.body.style.transition = 'all 0.5s ease-in-out'; 62 | }, 500); 63 | 64 | }, [darkMode, primaryColor]); 65 | 66 | useEffect(() => { 67 | i18n.changeLanguage(lang); 68 | }, [lang]); 69 | 70 | return ( 71 | 76 | 77 | 78 | 79 | 80 | ) 81 | } 82 | 83 | export default App 84 | -------------------------------------------------------------------------------- /src/pages/login/components/right-content.tsx: -------------------------------------------------------------------------------- 1 | import { defaultSetting } from '@/default-setting'; 2 | import { Carousel } from 'antd'; 3 | 4 | const RightContent = () => { 5 | return ( 6 |
12 |
17 |
18 | 19 |
20 |
21 |
22 |

23 | {defaultSetting.title} 24 |

25 |
26 | 一个高颜值后台管理系统 27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |

35 | {defaultSetting.title} 36 |

37 |
38 | 一个高颜值后台管理系统 39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |

47 | {defaultSetting.title} 48 |

49 |
50 | 一个高颜值后台管理系统 51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | ); 59 | }; 60 | 61 | export default RightContent; 62 | -------------------------------------------------------------------------------- /nginx/config.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh -e 2 | 3 | echo "setting environment config" 4 | 5 | cat >> /etc/nginx/conf.d/default.conf < ( 5 | 16 | 17 | 18 | ); 19 | 20 | export const Icon3 = (props: Partial) => ( 21 | 22 | ); 23 | -------------------------------------------------------------------------------- /src/pages/login/components/login-form.tsx: -------------------------------------------------------------------------------- 1 | import { IconYanzhengma01 } from '@/assets/icons/yanzhengma01'; 2 | import LinkButton from '@/components/link-button'; 3 | import { t } from '@/utils/i18n'; 4 | import { LockOutlined, UserOutlined } from '@ant-design/icons'; 5 | import { Button, Form, Input } from 'antd'; 6 | import useLogin from '../hooks/use-login'; 7 | 8 | function LoginForm({ 9 | onForgetPasswordClick, 10 | }: { 11 | onForgetPasswordClick: () => void; 12 | }) { 13 | 14 | const { captcha, refreshCaptcha, login, loginLoading } = useLogin(); 15 | 16 | return ( 17 |
23 | 27 | } 29 | placeholder={t("RNISycbR" /* 账号 */)} 30 | size="large" 31 | /> 32 | 33 | 37 | } 39 | type="password" 40 | placeholder={t("HplkKxdY" /* 密码 */)} 41 | /> 42 | 43 | 47 | } 49 | placeholder="验证码" 50 | suffix={( 51 | 56 | )} 57 | /> 58 | 59 | 60 |
63 | 66 | 忘记密码? 67 | 68 |
69 |
70 | 71 | 79 | 80 |
81 | ) 82 | } 83 | 84 | export default LoginForm -------------------------------------------------------------------------------- /src/pages/dashboard/theme/light-column-theme.json: -------------------------------------------------------------------------------- 1 | { 2 | "background": "rgba(20, 20, 20, 0)", 3 | "components": { 4 | "axis": { 5 | "common": { 6 | "grid": { 7 | "line": { 8 | "type": "line", 9 | "style": { 10 | "stroke": "rgb(227,232,239)", 11 | "lineWidth": 1, 12 | "lineDash": null 13 | } 14 | }, 15 | "alignTick": true, 16 | "animate": true 17 | } 18 | } 19 | }, 20 | "tooltip": { 21 | "domStyles": { 22 | "g2-tooltip": { 23 | "position": "absolute", 24 | "visibility": "hidden", 25 | "zIndex": 8, 26 | "transition": "left 0.4s cubic-bezier(0.23, 1, 0.32, 1) 0s, top 0.4s cubic-bezier(0.23, 1, 0.32, 1) 0s", 27 | "backgroundColor": "#ffffff", 28 | "opacity": 0.95, 29 | "boxShadow": "rgb(174, 174, 174) 0px 0px 10px", 30 | "borderRadius": "3px", 31 | "color": "#A6A6A6", 32 | "fontSize": "12px", 33 | "fontFamily": "\"Segoe UI\", Roboto, \"Helvetica Neue\", Arial,\n \"Noto Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\",\n \"Noto Color Emoji\"", 34 | "lineHeight": "12px", 35 | "padding": "0 12px 0 12px" 36 | }, 37 | "g2-tooltip-title": { 38 | "marginBottom": "12px", 39 | "marginTop": "12px" 40 | }, 41 | "g2-tooltip-list": { 42 | "margin": 0, 43 | "listStyleType": "none", 44 | "padding": 0 45 | }, 46 | "g2-tooltip-list-item": { 47 | "listStyleType": "none", 48 | "padding": 0, 49 | "marginBottom": "12px", 50 | "marginTop": "12px", 51 | "marginLeft": 0, 52 | "marginRight": 0 53 | }, 54 | "g2-tooltip-marker": { 55 | "width": "8px", 56 | "height": "8px", 57 | "borderRadius": "50%", 58 | "display": "inline-block", 59 | "marginRight": "8px" 60 | }, 61 | "g2-tooltip-value": { 62 | "display": "inline-block", 63 | "float": "right", 64 | "marginLeft": "30px" 65 | } 66 | } 67 | } 68 | }, 69 | "styleSheet": { 70 | "brandColor": "rgba(124, 77, 255, 0.85)", 71 | "paletteQualitative10": [ 72 | "rgba(124, 77, 255, 0.85)", 73 | "rgba(144, 202, 249, 0.85)", 74 | "#65789B", 75 | "#F6BD16", 76 | "#7262fd", 77 | "#78D3F8", 78 | "#9661BC", 79 | "#F6903D", 80 | "#008685", 81 | "#F08BB4" 82 | ] 83 | } 84 | } -------------------------------------------------------------------------------- /src/pages/dashboard/theme/dark-column-theme.json: -------------------------------------------------------------------------------- 1 | { 2 | "background": "rgba(20, 20, 20, 0)", 3 | "components": { 4 | "axis": { 5 | "common": { 6 | "grid": { 7 | "line": { 8 | "type": "line", 9 | "style": { 10 | "stroke": "rgba(189, 200, 240, 0.125)", 11 | "lineWidth": 1, 12 | "lineDash": null 13 | } 14 | }, 15 | "alignTick": true, 16 | "animate": true 17 | } 18 | } 19 | }, 20 | "tooltip": { 21 | "domStyles": { 22 | "g2-tooltip": { 23 | "position": "absolute", 24 | "visibility": "hidden", 25 | "zIndex": 8, 26 | "transition": "left 0.4s cubic-bezier(0.23, 1, 0.32, 1) 0s, top 0.4s cubic-bezier(0.23, 1, 0.32, 1) 0s", 27 | "backgroundColor": "#1f1f1f", 28 | "opacity": 0.95, 29 | "boxShadow": "0px 2px 4px rgba(0,0,0,.5)", 30 | "borderRadius": "3px", 31 | "color": "#A6A6A6", 32 | "fontSize": "12px", 33 | "fontFamily": "\"Segoe UI\", Roboto, \"Helvetica Neue\", Arial,\n \"Noto Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\",\n \"Noto Color Emoji\"", 34 | "lineHeight": "12px", 35 | "padding": "0 12px 0 12px" 36 | }, 37 | "g2-tooltip-title": { 38 | "marginBottom": "12px", 39 | "marginTop": "12px" 40 | }, 41 | "g2-tooltip-list": { 42 | "margin": 0, 43 | "listStyleType": "none", 44 | "padding": 0 45 | }, 46 | "g2-tooltip-list-item": { 47 | "listStyleType": "none", 48 | "padding": 0, 49 | "marginBottom": "12px", 50 | "marginTop": "12px", 51 | "marginLeft": 0, 52 | "marginRight": 0 53 | }, 54 | "g2-tooltip-marker": { 55 | "width": "8px", 56 | "height": "8px", 57 | "borderRadius": "50%", 58 | "display": "inline-block", 59 | "marginRight": "8px" 60 | }, 61 | "g2-tooltip-value": { 62 | "display": "inline-block", 63 | "float": "right", 64 | "marginLeft": "30px" 65 | } 66 | } 67 | } 68 | }, 69 | "styleSheet": { 70 | "brandColor": "rgba(124, 77, 255, 0.85)", 71 | "paletteQualitative10": [ 72 | "rgba(124, 77, 255, 0.85)", 73 | "rgba(144, 202, 249, 0.85)", 74 | "#65789B", 75 | "#F6BD16", 76 | "#7262fd", 77 | "#78D3F8", 78 | "#9661BC", 79 | "#F6903D", 80 | "#008685", 81 | "#F08BB4" 82 | ] 83 | } 84 | } -------------------------------------------------------------------------------- /src/api/auth.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | /* eslint-disable */ 3 | import request from "@/request"; 4 | 5 | /** 获取验证码 GET /auth/captcha */ 6 | export async function auth_getImageCaptcha(options?: { [key: string]: any }) { 7 | return request("/auth/captcha", { 8 | method: "GET", 9 | ...(options || {}), 10 | }); 11 | } 12 | 13 | /** 获取当前用户信息 GET /auth/current/user */ 14 | export async function auth_getCurrentUser(options?: { [key: string]: any }) { 15 | return request("/auth/current/user", { 16 | method: "GET", 17 | ...(options || {}), 18 | }); 19 | } 20 | 21 | /** 登录 POST /auth/login */ 22 | export async function auth_login( 23 | body: API.LoginDTO, 24 | options?: { [key: string]: any } 25 | ) { 26 | return request("/auth/login", { 27 | method: "POST", 28 | headers: { 29 | "Content-Type": "application/json", 30 | }, 31 | data: body, 32 | ...(options || {}), 33 | }); 34 | } 35 | 36 | /** 此处后端没有提供注释 POST /auth/logout */ 37 | export async function auth_logout(options?: { [key: string]: any }) { 38 | return request("/auth/logout", { 39 | method: "POST", 40 | ...(options || {}), 41 | }); 42 | } 43 | 44 | /** 此处后端没有提供注释 GET /auth/publicKey */ 45 | export async function auth_getPublicKey(options?: { [key: string]: any }) { 46 | return request("/auth/publicKey", { 47 | method: "GET", 48 | ...(options || {}), 49 | }); 50 | } 51 | 52 | /** 刷新token POST /auth/refresh/token */ 53 | export async function auth_refreshToken( 54 | body: API.RefreshTokenDTO, 55 | options?: { [key: string]: any } 56 | ) { 57 | return request("/auth/refresh/token", { 58 | method: "POST", 59 | headers: { 60 | "Content-Type": "application/json", 61 | }, 62 | data: body, 63 | ...(options || {}), 64 | }); 65 | } 66 | 67 | /** 此处后端没有提供注释 POST /auth/reset/password */ 68 | export async function auth_resetPassword( 69 | body: API.ResetPasswordDTO, 70 | options?: { [key: string]: any } 71 | ) { 72 | return request("/auth/reset/password", { 73 | method: "POST", 74 | headers: { 75 | "Content-Type": "application/json", 76 | }, 77 | data: body, 78 | ...(options || {}), 79 | }); 80 | } 81 | 82 | /** 此处后端没有提供注释 POST /auth/send/reset/password/email */ 83 | export async function auth_sendResetPasswordEmail( 84 | body: Record, 85 | options?: { [key: string]: any } 86 | ) { 87 | return request("/auth/send/reset/password/email", { 88 | method: "POST", 89 | headers: { 90 | "Content-Type": "application/json", 91 | }, 92 | data: body, 93 | ...(options || {}), 94 | }); 95 | } 96 | -------------------------------------------------------------------------------- /src/api/role.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | /* eslint-disable */ 3 | import request from "@/request"; 4 | 5 | /** 更新角色 PUT /role/ */ 6 | export async function role_update( 7 | body: API.RoleDTO, 8 | options?: { [key: string]: any } 9 | ) { 10 | return request("/role/", { 11 | method: "PUT", 12 | headers: { 13 | "Content-Type": "application/json", 14 | }, 15 | data: body, 16 | ...(options || {}), 17 | }); 18 | } 19 | 20 | /** 创建角色 POST /role/ */ 21 | export async function role_create( 22 | body: API.RoleDTO, 23 | options?: { [key: string]: any } 24 | ) { 25 | return request("/role/", { 26 | method: "POST", 27 | headers: { 28 | "Content-Type": "application/json", 29 | }, 30 | data: body, 31 | ...(options || {}), 32 | }); 33 | } 34 | 35 | /** 删除角色 DELETE /role/${param0} */ 36 | export async function role_remove( 37 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) 38 | params: API.roleRemoveParams, 39 | options?: { [key: string]: any } 40 | ) { 41 | const { id: param0, ...queryParams } = params; 42 | return request(`/role/${param0}`, { 43 | method: "DELETE", 44 | params: { ...queryParams }, 45 | ...(options || {}), 46 | }); 47 | } 48 | 49 | /** 角色分配菜单 POST /role/alloc/menu */ 50 | export async function role_allocMenu( 51 | body: API.RoleMenuDTO, 52 | options?: { [key: string]: any } 53 | ) { 54 | return request("/role/alloc/menu", { 55 | method: "POST", 56 | headers: { 57 | "Content-Type": "application/json", 58 | }, 59 | data: body, 60 | ...(options || {}), 61 | }); 62 | } 63 | 64 | /** 分页获取角色列表 GET /role/list */ 65 | export async function role_list(options?: { [key: string]: any }) { 66 | return request("/role/list", { 67 | method: "GET", 68 | ...(options || {}), 69 | }); 70 | } 71 | 72 | /** 根据角色id获取菜单id列表 GET /role/menu/list */ 73 | export async function role_getMenusByRoleId( 74 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) 75 | params: API.roleGetMenusByRoleIdParams, 76 | options?: { [key: string]: any } 77 | ) { 78 | return request("/role/menu/list", { 79 | method: "GET", 80 | params: { 81 | ...params, 82 | }, 83 | ...(options || {}), 84 | }); 85 | } 86 | 87 | /** 分页获取角色列表 GET /role/page */ 88 | export async function role_page( 89 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) 90 | params: API.rolePageParams, 91 | options?: { [key: string]: any } 92 | ) { 93 | return request("/role/page", { 94 | method: "GET", 95 | params: { 96 | ...params, 97 | }, 98 | ...(options || {}), 99 | }); 100 | } 101 | -------------------------------------------------------------------------------- /script/template/new-edit-form.template: -------------------------------------------------------------------------------- 1 | import { t } from '@/utils/i18n'; 2 | import { Form, Input } from 'antd'; 3 | import { useEffect } from 'react'; 4 | 5 | import { $2_create, $2_edit } from '@/api/$2'; 6 | import FModalForm from '@/components/modal-form'; 7 | import { antdUtils } from '@/utils/antd'; 8 | import { clearFormValues } from '@/utils/utils'; 9 | import { useRequest } from 'ahooks'; 10 | 11 | interface PropsType { 12 | open: boolean; 13 | editData?: API.$1VO | null; 14 | title: string; 15 | onOpenChange: (open: boolean) => void; 16 | onSaveSuccess: () => void; 17 | } 18 | 19 | function NewAndEdit$1Form({ 20 | editData, 21 | open, 22 | title, 23 | onOpenChange, 24 | onSaveSuccess, 25 | }: PropsType) { 26 | 27 | const [form] = Form.useForm(); 28 | const { runAsync: update$1, loading: updateLoading } = useRequest($2_edit, { 29 | manual: true, 30 | onSuccess: () => { 31 | antdUtils.message?.success(t("NfOSPWDa" /* 更新成功! */)); 32 | onSaveSuccess(); 33 | }, 34 | }); 35 | const { runAsync: add$1, loading: createLoading } = useRequest($2_create, { 36 | manual: true, 37 | onSuccess: () => { 38 | antdUtils.message?.success(t("JANFdKFM" /* 创建成功! */)); 39 | onSaveSuccess(); 40 | }, 41 | }); 42 | 43 | useEffect(() => { 44 | if (!editData) { 45 | clearFormValues(form); 46 | } else { 47 | form.setFieldsValue({ 48 | ...editData, 49 | }); 50 | } 51 | }, [editData]); 52 | 53 | const finishHandle = async (values: any) => { 54 | if (editData) { 55 | update$1({ ...editData, ...values }) 56 | } else { 57 | add$1(values) 58 | } 59 | } 60 | 61 | return ( 62 | 75 | 83 | 84 | 85 | 93 | 94 | 95 | 96 | ) 97 | } 98 | 99 | export default NewAndEdit$1Form; -------------------------------------------------------------------------------- /src/layouts/layout/header/menu-searcher.tsx: -------------------------------------------------------------------------------- 1 | import { antdIcons } from '@/assets/antd-icons'; 2 | import { IconFangdajing } from '@/assets/icons/fangdajing'; 3 | import { useSelector } from '@/hooks/use-selector'; 4 | import { MenuType } from '@/pages/menu/interface'; 5 | import { useUserStore } from '@/stores/user'; 6 | import { EnterOutlined } from '@ant-design/icons'; 7 | import { List, Select } from 'antd'; 8 | import { t } from 'i18next'; 9 | import React, { useMemo, useState } from 'react'; 10 | import { Link } from 'react-router-dom'; 11 | 12 | function MenuSearcher() { 13 | 14 | const { currentUser } = useUserStore(useSelector('currentUser')); 15 | 16 | const [searchValue, setSearchValue] = useState(); 17 | const [open, setOpen] = useState(false); 18 | 19 | const menus = useMemo(() => { 20 | if (!currentUser?.flatMenus) return []; 21 | return currentUser.flatMenus.filter( 22 | o => o.type === MenuType.MENU && o.show 23 | ) 24 | }, [currentUser]) 25 | 26 | const dataSource = useMemo(() => { 27 | if (!currentUser || !searchValue) return []; 28 | return menus?.filter( 29 | o => o.name?.includes(searchValue) 30 | ) 31 | }, [menus, searchValue]) 32 | 33 | return ( 34 | { setEmailInputFocus(false) }} 75 | onFocus={() => { setEmailInputFocus(true) }} 76 | onChange={e => { 77 | setCheckEmail(e.target.value); 78 | }} 79 | value={checkEmail} 80 | /> 81 | 90 | 91 | ); 92 | }; 93 | 94 | export default ForgetPasswordModal; 95 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fluxy-admin-web", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "tsc -b && vite build", 8 | "lint": "eslint . --ext ts,tsx", 9 | "preview": "vite preview", 10 | "check-types": "tsc -b tsconfig.app.json", 11 | "prepare": "simple-git-hooks", 12 | "analyze": "tsc -b && cross-env ANALYZE=true vite build", 13 | "openapi2ts": "openapi2ts" 14 | }, 15 | "dependencies": { 16 | "@ant-design/icons": "^5.4.0", 17 | "@ant-design/plots": "^1.2.5", 18 | "@ant-design/pro-components": "^2.7.15", 19 | "@antv/g2plot": "^2.4.32", 20 | "@dbfu/react-directive": "^1.1.4", 21 | "@dnd-kit/core": "^6.0.8", 22 | "@dnd-kit/modifiers": "^6.0.1", 23 | "@dnd-kit/sortable": "^7.0.2", 24 | "@dnd-kit/utilities": "^3.2.1", 25 | "ahooks": "^3.8.1", 26 | "antd": "^5.20.2", 27 | "antd-img-crop": "^4.23.0", 28 | "await-to-js": "^3.0.0", 29 | "axios": "^1.7.4", 30 | "classnames": "^2.5.1", 31 | "copy-to-clipboard": "^3.3.3", 32 | "dayjs": "^1.11.12", 33 | "i18next": "^23.13.0", 34 | "jsencrypt": "^3.3.2", 35 | "lodash-es": "^4.17.21", 36 | "nprogress": "^0.2.0", 37 | "react": "^18.3.1", 38 | "react-dom": "^18.3.1", 39 | "react-router-dom": "^6.26.1", 40 | "tailwind-merge": "^2.5.2", 41 | "tinycolor2": "^1.6.0", 42 | "zustand": "^4.5.5" 43 | }, 44 | "devDependencies": { 45 | "@commitlint/cli": "^19.4.1", 46 | "@commitlint/config-conventional": "^19.4.1", 47 | "@types/lodash-es": "^4.17.12", 48 | "@types/nprogress": "^0.2.3", 49 | "@types/react": "^18.3.3", 50 | "@types/react-dom": "^18.3.0", 51 | "@types/tinycolor2": "^1.4.6", 52 | "@typescript-eslint/eslint-plugin": "^7.18.0", 53 | "@typescript-eslint/parser": "^7.18.0", 54 | "@umijs/openapi": "^1.13.0", 55 | "@vitejs/plugin-react-swc": "^3.5.0", 56 | "autoprefixer": "^10.4.20", 57 | "cross-env": "^7.0.3", 58 | "eslint": "^8.57.0", 59 | "eslint-plugin-react-hooks": "^4.6.2", 60 | "eslint-plugin-react-refresh": "^0.4.7", 61 | "lint-staged": "^15.2.10", 62 | "postcss": "^8.4.41", 63 | "rollup-plugin-external-globals": "^0.12.0", 64 | "rollup-plugin-visualizer": "^5.12.0", 65 | "simple-git-hooks": "^2.11.1", 66 | "tailwindcss": "^3.4.10", 67 | "typescript": "^5.2.2", 68 | "vite": "^5.3.4", 69 | "vite-plugin-html": "^3.2.2" 70 | }, 71 | "lint-staged": { 72 | "*.ts?(x)": [ 73 | "eslint --ext .js,.jsx --fix", 74 | "bash -c 'npm run check-types'" 75 | ], 76 | "*.{js,jsx}": [ 77 | "eslint --ext ts,tsx --fix" 78 | ] 79 | }, 80 | "simple-git-hooks": { 81 | "commit-msg": "pnpm commitlint --edit", 82 | "pre-commit": "pnpm lint-staged" 83 | } 84 | } -------------------------------------------------------------------------------- /src/layouts/layout/slide/menu.tsx: -------------------------------------------------------------------------------- 1 | import { useGlobalStore } from '@/stores/global'; 2 | import { Menu } from 'antd'; 3 | 4 | import { antdIcons } from '@/assets/antd-icons'; 5 | import { useUserStore } from '@/stores/user'; 6 | import { MenuItemType } from 'antd/es/menu/interface'; 7 | import React, { useCallback, useEffect, useMemo, useState } from 'react'; 8 | import { Link, useMatches } from 'react-router-dom'; 9 | import './menu.css'; 10 | 11 | type MenuObj = API.MenuVO & { children?: MenuObj[], path?: string }; 12 | 13 | function SlideMenu() { 14 | 15 | const { 16 | collapsed, 17 | darkMode, 18 | } = useGlobalStore(); 19 | 20 | const matches = useMatches(); 21 | 22 | const [openKeys, setOpenKeys] = useState([]); 23 | const [selectKeys, setSelectKeys] = useState([]); 24 | 25 | 26 | const { 27 | currentUser, 28 | } = useUserStore(); 29 | 30 | useEffect(() => { 31 | const [match] = matches || []; 32 | if (match) { 33 | // 获取当前匹配的路由,默认为最后一个 34 | const route = matches[matches.length - 1]; 35 | // 从匹配的路由中取出自定义参数 36 | const handle = route?.handle as { parentPaths: [], path: string }; 37 | // 从自定义参数中取出上级path,让菜单自动展开 38 | if (collapsed) { 39 | setOpenKeys([]); 40 | } else { 41 | setOpenKeys(handle?.parentPaths || []); 42 | } 43 | // 让当前菜单和所有上级菜单高亮显示 44 | setSelectKeys([...(handle?.parentPaths || []), handle?.path]); 45 | } 46 | }, [ 47 | matches, 48 | collapsed, 49 | ]); 50 | 51 | const getMenuTitle = (menu: MenuObj) => { 52 | if (menu?.children?.filter(menu => menu.show)?.length) { 53 | return menu.name; 54 | } 55 | return ( 56 | {menu.name} 57 | ); 58 | } 59 | 60 | const treeMenuData = useCallback((menus: MenuObj[]): MenuItemType[] => { 61 | return (menus) 62 | .map((menu: MenuObj) => { 63 | const children = menu?.children?.filter(menu => menu.show) || []; 64 | return { 65 | key: menu.path || '', 66 | label: getMenuTitle(menu), 67 | icon: menu.icon && antdIcons[menu.icon] && React.createElement(antdIcons[menu.icon]), 68 | children: children.length ? treeMenuData(children || []) : null, 69 | }; 70 | }) 71 | }, []); 72 | 73 | const menuData = useMemo(() => { 74 | return treeMenuData( 75 | (currentUser?.menus?.filter(menu => menu.show) || []) 76 | ); 77 | }, [currentUser]); 78 | 79 | 80 | return ( 81 | 92 | ) 93 | } 94 | 95 | export default SlideMenu; 96 | -------------------------------------------------------------------------------- /src/layouts/layout/header/user-info.tsx: -------------------------------------------------------------------------------- 1 | import { auth_logout } from '@/api/auth'; 2 | import { IconBuguang } from '@/assets/icons/buguang'; 3 | import IconButton from '@/components/icon-button'; 4 | import { router } from '@/router'; 5 | import { useGlobalStore } from '@/stores/global'; 6 | import { useUserStore } from '@/stores/user'; 7 | import { SettingOutlined } from '@ant-design/icons'; 8 | import { Avatar, Button, Dropdown } from 'antd'; 9 | 10 | interface Props { 11 | darkMode: boolean; 12 | disconnectWS: () => void; 13 | } 14 | 15 | function UserInfo({ 16 | darkMode, 17 | disconnectWS 18 | }: Props) { 19 | 20 | const { currentUser } = useUserStore(); 21 | const { setRefreshToken } = useGlobalStore(); 22 | 23 | async function logout() { 24 | setRefreshToken(''); 25 | await auth_logout(); 26 | // 断开 websocket 27 | disconnectWS(); 28 | router.navigate('/user/login'); 29 | } 30 | 31 | return ( 32 | node.parentElement!} 36 | dropdownRender={() => { 37 | return ( 38 |
46 |
47 |

48 | {currentUser?.nickName} 49 |

50 |

51 | {currentUser?.phoneNumber} 52 |

53 |

54 | {currentUser?.email} 55 |

56 |
57 |
58 |
59 | 60 |
61 |
62 | ) 63 | }} 64 | > 65 |
66 | 67 | {currentUser?.avatarPath ? ( 68 | 69 | ) : ( 70 | } /> 71 | )} 72 | 73 | 74 |
75 |
76 | ); 77 | } 78 | 79 | export default UserInfo; 80 | -------------------------------------------------------------------------------- /src/assets/icons/yanzhengma.tsx: -------------------------------------------------------------------------------- 1 | import Icon from "@ant-design/icons"; 2 | import type { CustomIconComponentProps } from "@ant-design/icons/lib/components/Icon"; 3 | 4 | const SVGYanzhengma = () => ( 5 | 16 | 17 | 18 | ); 19 | 20 | export const IconYanzhengma = (props: Partial) => ( 21 | 22 | ); 23 | -------------------------------------------------------------------------------- /public/externals/antd@5.20.6/reset.min.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Minified by jsDelivr using clean-css v5.3.2. 3 | * Original file: /npm/antd@5.20.6/dist/reset.css 4 | * 5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files 6 | */ 7 | body,html{width:100%;height:100%}input::-ms-clear,input::-ms-reveal{display:none}*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:transparent}@-ms-viewport{width:device-width}body{margin:0}[tabindex='-1']:focus{outline:0}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5em;font-weight:500}p{margin-top:0;margin-bottom:1em}abbr[data-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline;text-decoration:underline dotted;border-bottom:0;cursor:help}address{margin-bottom:1em;font-style:normal;line-height:inherit}input[type=number],input[type=password],input[type=text],textarea{-webkit-appearance:none}dl,ol,ul{margin-top:0;margin-bottom:1em}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:500}dd{margin-bottom:.5em;margin-left:0}blockquote{margin:0 0 1em}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}code,kbd,pre,samp{font-size:1em;font-family:SFMono-Regular,Consolas,'Liberation Mono',Menlo,Courier,monospace}pre{margin-top:0;margin-bottom:1em;overflow:auto}figure{margin:0 0 1em}img{vertical-align:middle;border-style:none}[role=button],a,area,button,input:not([type=range]),label,select,summary,textarea{touch-action:manipulation}table{border-collapse:collapse}caption{padding-top:.75em;padding-bottom:.3em;text-align:left;caption-side:bottom}button,input,optgroup,select,textarea{margin:0;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;margin:0;padding:0;border:0}legend{display:block;width:100%;max-width:100%;margin-bottom:.5em;padding:0;color:inherit;font-size:1.5em;line-height:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item}template{display:none}[hidden]{display:none!important}mark{padding:.2em;background-color:#feffe6} 8 | /*# sourceMappingURL=/sm/032064be2fa88a51346e05fa5ca38b80429a1c6e61fcfff40df882932818a4f1.map */ -------------------------------------------------------------------------------- /src/components/draggable-tab/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import type { DragEndEvent } from '@dnd-kit/core'; 4 | import { DndContext, PointerSensor, useSensor } from '@dnd-kit/core'; 5 | import { 6 | restrictToHorizontalAxis, 7 | } from '@dnd-kit/modifiers'; 8 | import { 9 | arrayMove, 10 | horizontalListSortingStrategy, 11 | SortableContext, 12 | useSortable, 13 | } from '@dnd-kit/sortable'; 14 | import { CSS } from '@dnd-kit/utilities'; 15 | import { Tabs, TabsProps } from 'antd'; 16 | import React, { useEffect, useState } from 'react'; 17 | 18 | import { useUpdateEffect } from 'ahooks'; 19 | import { omit } from 'lodash-es'; 20 | import './index.css'; 21 | 22 | type Write = Omit & U 23 | 24 | interface DraggableTabPaneProps extends React.HTMLAttributes { 25 | 'data-node-key': string; 26 | } 27 | 28 | const DraggableTabNode = (props: DraggableTabPaneProps) => { 29 | const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ 30 | id: props['data-node-key'], 31 | }); 32 | 33 | const style: React.CSSProperties = { 34 | ...props.style, 35 | transform: CSS.Transform.toString(transform && { ...transform, scaleX: 1 }), 36 | transition, 37 | zIndex: isDragging ? 1 : undefined, 38 | }; 39 | 40 | return React.cloneElement(props.children as React.ReactElement, { 41 | ref: setNodeRef, 42 | style, 43 | ...attributes, 44 | ...listeners, 45 | }); 46 | }; 47 | 48 | const DraggableTab: React.FC void, 50 | onDragEnd?: ({ activeIndex, overIndex }: { activeIndex: number, overIndex: number }) => void, 51 | }>> = ({ onItemsChange, ...props }) => { 52 | const [items, setItems] = useState(props.items || []); 53 | 54 | const sensor = useSensor(PointerSensor, { activationConstraint: { distance: 10 } }); 55 | 56 | const onDragEnd = ({ active, over }: DragEndEvent) => { 57 | if (active.id !== over?.id) { 58 | const activeIndex = items.findIndex((i) => i.key === active.id); 59 | const overIndex = items.findIndex((i) => i.key === over?.id); 60 | setItems(arrayMove(items, activeIndex, overIndex)); 61 | props.onDragEnd && props.onDragEnd({ activeIndex, overIndex }); 62 | } 63 | }; 64 | 65 | useEffect(() => { 66 | setItems(props.items || []); 67 | }, [props.items]); 68 | 69 | useUpdateEffect(() => { 70 | if (onItemsChange) { 71 | onItemsChange(items); 72 | } 73 | }, [items]); 74 | 75 | return ( 76 | ( 79 | 80 | i.key)} strategy={horizontalListSortingStrategy}> 81 | 82 | {(node) => ( 83 | 84 | {node} 85 | 86 | )} 87 | 88 | 89 | 90 | )} 91 | {...omit(props, 'onDragEnd')} 92 | items={items} 93 | className='tab-layout' 94 | /> 95 | ); 96 | }; 97 | 98 | 99 | 100 | export default DraggableTab; -------------------------------------------------------------------------------- /src/api/apiLog.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | /* eslint-disable */ 3 | import request from "@/request"; 4 | 5 | /** 编辑 PUT /api-log/ */ 6 | export async function api_log_edit( 7 | body: API.ApiLogDTO, 8 | options?: { [key: string]: any } 9 | ) { 10 | return request("/api-log/", { 11 | method: "PUT", 12 | headers: { 13 | "Content-Type": "application/json", 14 | }, 15 | data: body, 16 | ...(options || {}), 17 | }); 18 | } 19 | 20 | /** 新建 POST /api-log/ */ 21 | export async function api_log_create( 22 | body: API.ApiLogDTO, 23 | options?: { [key: string]: any } 24 | ) { 25 | return request("/api-log/", { 26 | method: "POST", 27 | headers: { 28 | "Content-Type": "application/json", 29 | }, 30 | data: body, 31 | ...(options || {}), 32 | }); 33 | } 34 | 35 | /** 根据id查询 GET /api-log/${param0} */ 36 | export async function api_log_getById( 37 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) 38 | params: API.apiLogGetByIdParams, 39 | options?: { [key: string]: any } 40 | ) { 41 | const { id: param0, ...queryParams } = params; 42 | return request(`/api-log/${param0}`, { 43 | method: "GET", 44 | params: { ...queryParams }, 45 | ...(options || {}), 46 | }); 47 | } 48 | 49 | /** 删除 DELETE /api-log/${param0} */ 50 | export async function api_log_remove( 51 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) 52 | params: API.apiLogRemoveParams, 53 | options?: { [key: string]: any } 54 | ) { 55 | const { id: param0, ...queryParams } = params; 56 | return request(`/api-log/${param0}`, { 57 | method: "DELETE", 58 | params: { ...queryParams }, 59 | ...(options || {}), 60 | }); 61 | } 62 | 63 | /** 查看请求body参数 GET /api-log/body */ 64 | export async function api_log_getBodyData( 65 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) 66 | params: API.apiLogGetBodyDataParams, 67 | options?: { [key: string]: any } 68 | ) { 69 | return request("/api-log/body", { 70 | method: "GET", 71 | params: { 72 | ...params, 73 | }, 74 | ...(options || {}), 75 | }); 76 | } 77 | 78 | /** 查询全部 GET /api-log/list */ 79 | export async function api_log_list(options?: { [key: string]: any }) { 80 | return request("/api-log/list", { 81 | method: "GET", 82 | ...(options || {}), 83 | }); 84 | } 85 | 86 | /** 分页查询 GET /api-log/page */ 87 | export async function api_log_page( 88 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) 89 | params: API.apiLogPageParams, 90 | options?: { [key: string]: any } 91 | ) { 92 | return request("/api-log/page", { 93 | method: "GET", 94 | params: { 95 | ...params, 96 | }, 97 | ...(options || {}), 98 | }); 99 | } 100 | 101 | /** 查看请求query参数 GET /api-log/query */ 102 | export async function api_log_getQueryData( 103 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) 104 | params: API.apiLogGetQueryDataParams, 105 | options?: { [key: string]: any } 106 | ) { 107 | return request("/api-log/query", { 108 | method: "GET", 109 | params: { 110 | ...params, 111 | }, 112 | ...(options || {}), 113 | }); 114 | } 115 | 116 | /** 查看响应结果 GET /api-log/result */ 117 | export async function api_log_getResultData( 118 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象) 119 | params: API.apiLogGetResultDataParams, 120 | options?: { [key: string]: any } 121 | ) { 122 | return request("/api-log/result", { 123 | method: "GET", 124 | params: { 125 | ...params, 126 | }, 127 | ...(options || {}), 128 | }); 129 | } 130 | -------------------------------------------------------------------------------- /script/template/index.template: -------------------------------------------------------------------------------- 1 | import { t } from '@/utils/i18n'; 2 | import { 3 | Button, 4 | Divider, 5 | Popconfirm, 6 | Space 7 | } from 'antd'; 8 | import { useRef, useState } from 'react'; 9 | 10 | import { $2_page, $2_remove } from '@/api/$2'; 11 | import LinkButton from '@/components/link-button'; 12 | import FProTable from '@/components/pro-table'; 13 | import { antdUtils } from '@/utils/antd'; 14 | import { toPageRequestParams } from '@/utils/utils'; 15 | import { PlusOutlined } from '@ant-design/icons'; 16 | import { ActionType, ProColumnType } from '@ant-design/pro-components'; 17 | import NewAndEditForm from './new-edit-form'; 18 | 19 | function $1Page() { 20 | const [editData, setEditData] = useState(null); 21 | const [formOpen, setFormOpen] = useState(false); 22 | const actionRef = useRef(); 23 | 24 | const columns: ProColumnType[] = [ 25 | { 26 | title: t("qvtQYcfN" /* 名称 */), 27 | dataIndex: 'name', 28 | }, 29 | { 30 | title: t("WIRfoXjK" /* 代码 */), 31 | dataIndex: 'code', 32 | valueType: 'text', 33 | }, 34 | { 35 | title: t("QkOmYwne" /* 操作 */), 36 | dataIndex: 'id', 37 | hideInForm: true, 38 | width: 240, 39 | align: 'center', 40 | search: false, 41 | renderText: (id: string, record) => ( 42 | 45 | )} 46 | > 47 | { 49 | setEditData(record); 50 | setFormOpen(true); 51 | }} 52 | > 53 | {t("wXpnewYo" /* 编辑 */)} 54 | 55 | { 58 | const [error] = await $2_remove({ id }); 59 | if (!error) { 60 | antdUtils.message?.success(t("CVAhpQHp" /* 删除成功! */)); 61 | actionRef.current?.reload(); 62 | } 63 | }} 64 | placement="topRight" 65 | > 66 | 68 | {t("HJYhipnp" /* 删除 */)} 69 | 70 | 71 | 72 | ), 73 | }, 74 | ]; 75 | 76 | 77 | const openForm = () => { 78 | setFormOpen(true); 79 | }; 80 | 81 | const closeForm = () => { 82 | setFormOpen(false); 83 | setEditData(null); 84 | }; 85 | 86 | const saveHandle = () => { 87 | actionRef.current?.reload(); 88 | setFormOpen(false); 89 | setEditData(null); 90 | }; 91 | 92 | return ( 93 | <> 94 | > 95 | actionRef={actionRef} 96 | columns={columns} 97 | request={async params => { 98 | return $2_page(toPageRequestParams(params)); 99 | }} 100 | headerTitle={( 101 | 102 | 109 | 110 | )} 111 | /> 112 | !open && closeForm()} 114 | editData={editData} 115 | onSaveSuccess={saveHandle} 116 | open={formOpen} 117 | title={editData ? t('wXpnewYo' /* 编辑 */) : t('VjwnJLPY' /* 新建 */)} 118 | /> 119 | 120 | ); 121 | } 122 | 123 | export default $1Page; 124 | -------------------------------------------------------------------------------- /src/pages/role/new-edit-form.tsx: -------------------------------------------------------------------------------- 1 | import { t } from '@/utils/i18n'; 2 | import { Form, Input } from 'antd'; 3 | import { useEffect, useState } from 'react'; 4 | 5 | import { role_create, role_update } from '@/api/role'; 6 | import LinkButton from '@/components/link-button'; 7 | import FModalForm from '@/components/modal-form'; 8 | import { antdUtils } from '@/utils/antd'; 9 | import { clearFormValues } from '@/utils/utils'; 10 | import { useRequest } from 'ahooks'; 11 | import RoleMenu from './role-menu'; 12 | 13 | interface PropsType { 14 | open: boolean; 15 | editData?: API.RoleVO | null; 16 | title: string; 17 | onOpenChange: (open: boolean) => void; 18 | onSaveSuccess: () => void; 19 | } 20 | 21 | function NewAndEditRoleForm({ 22 | editData, 23 | open, 24 | title, 25 | onOpenChange, 26 | onSaveSuccess, 27 | }: PropsType) { 28 | 29 | const [form] = Form.useForm(); 30 | const { runAsync: updateUser, loading: updateLoading } = useRequest(role_update, { 31 | manual: true, 32 | onSuccess: () => { 33 | antdUtils.message?.success(t("NfOSPWDa" /* 更新成功! */)); 34 | onSaveSuccess(); 35 | }, 36 | }); 37 | const { runAsync: addUser, loading: createLoading } = useRequest(role_create, { 38 | manual: true, 39 | onSuccess: () => { 40 | antdUtils.message?.success(t("JANFdKFM" /* 创建成功! */)); 41 | onSaveSuccess(); 42 | }, 43 | }); 44 | const [roleMenuVisible, setRoleMenuVisible] = useState(false); 45 | const [menuIds, setMenuIds] = useState(); 46 | 47 | useEffect(() => { 48 | if (!editData) { 49 | clearFormValues(form); 50 | } else { 51 | form.setFieldsValue({ 52 | ...editData, 53 | }); 54 | } 55 | }, [editData]); 56 | 57 | const finishHandle = async (values: any) => { 58 | if (editData) { 59 | updateUser({ ...editData, ...values, menuIds }) 60 | } else { 61 | addUser({ ...values, menuIds }) 62 | } 63 | } 64 | 65 | return ( 66 | 79 | 87 | 88 | 89 | 97 | 98 | 99 | 103 | { setRoleMenuVisible(true) }}>{t("rDmZCvin" /* 选择菜单 */)} 104 | 105 | { 107 | setMenuIds(menuIds); 108 | setRoleMenuVisible(false); 109 | }} 110 | visible={roleMenuVisible} 111 | onCancel={() => { 112 | setRoleMenuVisible(false); 113 | }} 114 | roleId={editData?.id} 115 | /> 116 | 117 | ) 118 | } 119 | 120 | export default NewAndEditRoleForm; -------------------------------------------------------------------------------- /src/assets/locales/zh-CN.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | wbTMzvDM: "一个高颜值后台管理系统", 3 | wVzXBuYs: "请输入账号", 4 | RNISycbR: "账号", 5 | DjMcEMAe: "请输入密码", 6 | HplkKxdY: "密码", 7 | dDdqAAve: "登录", 8 | wrQwwbSV: "中文", 9 | hGtEfNnp: "英语", 10 | jhqxJPbn: "搜索菜单", 11 | wPqFuoLF: "退出登录", 12 | yAdJryjx: "指标说明", 13 | nKMAkrqJ: "总销售额", 14 | NpRFMJyD: "周同比", 15 | WOQnwYUS: "日同比", 16 | ZPCQOWAn: "日销售额", 17 | iLyPEqwQ: "指标说明", 18 | ftuxZMpL: "访问量", 19 | sehypRaO: "日访问量", 20 | sdOusITo: "指标说明", 21 | PIYkoguj: "支付笔数", 22 | BUjwpMzX: "转化率", 23 | fHpiDHYH: "总增长", 24 | yLkZTWbn: "今日", 25 | QFqMuZiD: "本月", 26 | lGOcGyrv: "本年", 27 | yzUIyMhr: "门店销售额", 28 | aSPCUBcK: "今日", 29 | EhTpnarX: "本月", 30 | AGGPEAdX: "本年", 31 | jTSvVuJx: "上海分店", 32 | SwsawJhB: "20% 利润", 33 | JYSgIJHD: "上海分店", 34 | yELACPnu: "20% 利润", 35 | WAiyAuwV: "合肥分店", 36 | HpNzGyBz: "6% 利润", 37 | nGvTAQld: "北京分店", 38 | EeunYupT: "8% 亏损", 39 | usCBUdwp: "苏州分店", 40 | TacOGPiP: "14% 利润", 41 | Imkllizi: "南京分店", 42 | MzCxBxLH: "6% 亏损", 43 | LhjNVSoc: "名称", 44 | MOlwAEMx: "年龄", 45 | npxxdPKd: "地址", 46 | YoERuunu: "职业", 47 | QkOmYwne: "操作", 48 | EOSDTAVT: "名称", 49 | hQeqcUTv: "年龄", 50 | YHapJMTT: "搜索", 51 | uCkoPyVp: "清除", 52 | qYznwlfj: "用户名", 53 | gohANZwy: "昵称", 54 | yBxFprdB: "手机号", 55 | XWVvMWig: "邮箱", 56 | ykrQSYRh: "性别", 57 | AkkyZTUy: "男", 58 | yduIcxbx: "女", 59 | TMuQjpWo: "创建时间", 60 | qEIlwmxC: "编辑", 61 | JjwFfqHG: "警告", 62 | nlZBTfzL: "确认删除这条数据?", 63 | bvwOSeoJ: "删除成功!", 64 | HJYhipnp: "删除", 65 | rnyigssw: "昵称", 66 | SPsRnpyN: "手机号", 67 | morEPEyc: "新增", 68 | wXpnewYo: "编辑", 69 | VjwnJLPY: "新建", 70 | NfOSPWDa: "更新成功!", 71 | JANFdKFM: "创建成功!", 72 | jwGPaPNq: "不能为空", 73 | iricpuxB: "不能为空", 74 | UdKeETRS: "不能为空", 75 | AnDwfuuT: "手机号格式不正确", 76 | QFkffbad: "不能为空", 77 | EfwYKLsR: "邮箱格式不正确", 78 | qvtQYcfN: "名称", 79 | ToFVNEkU: "类型", 80 | ESYcSMBi: "图标", 81 | XBkSjYmn: "路由", 82 | aqmTtwBN: "文件地址", 83 | lDZjrith: "按钮权限代码", 84 | XRfphTtu: "排序号", 85 | hRiGeMNr: "添加", 86 | slZKRXqL: "刷新", 87 | FplLzQwk: "关闭", 88 | JPlYJWgB: "关闭其他", 89 | EOnUUxNS: "登录帐号", 90 | SQQeztUX: "登录IP", 91 | pcFVrbFq: "登录地址", 92 | ZroXYzZI: "浏览器", 93 | MsdUjinV: "操作系统", 94 | aQMiCXYx: "登录状态", 95 | pyuYsBVW: "成功", 96 | HaLzGNdm: "失败", 97 | pzvzlzLU: "登录消息", 98 | AZWKRNAc: "登录时间", 99 | WIRfoXjK: "代码", 100 | DvINURho: "分配菜单", 101 | RCCSKHGu: "确认删除?", 102 | CVAhpQHp: "删除成功!", 103 | rDmZCvin: "选择菜单", 104 | koENLxye: "分配成功", 105 | oewZmYWL: "选择类型:", 106 | dWBURdsX: "所有子级", 107 | REnCKzPW: "当前", 108 | dGQCFuac: "一级子级", 109 | kKvCUxII: "新增成功", 110 | XLSnfaCz: "更新成功", 111 | fQwvzwUN: "以/开头,不用手动拼接上级路由。参数格式/:id", 112 | GlfSFNdD: "必须以/开头", 113 | SOYRkwDk: "以/开头,不用手动拼接上级路由。参数格式/:id", 114 | ZsOhTupE: "必须以/开头", 115 | kDeoPsVD: "是否显示", 116 | etRQPYBn: "权限代码", 117 | LxiWbxsx: "绑定接口", 118 | wuePkjHJ: "目录", 119 | mYuKCgjM: "菜单", 120 | ZJvOOWLP: "按钮", 121 | YxQffpdF: "头像", 122 | PnmzVovn: "角色", 123 | uMzBAbdE: "重新发送(", 124 | isruHRIs: "秒)", 125 | JekywPnc: "发送邮箱验证码", 126 | IrkXPQuD: "文件类型错误", 127 | ghBOBkBd: "文件大小不能超过2M", 128 | baHVcbPK: "邮箱验证码", 129 | qXyXvYKQ: "请求地址", 130 | BIHtcucU: "请求方式", 131 | jUMSQtwr: "是否成功", 132 | enuYCXKm: "请求开始时间", 133 | JoQzAZoH: "请求开始时间范围", 134 | vSIQMaLG: "请求结束时间", 135 | CXtLVbIc: "请求结束时间范围", 136 | icSKnRmj: "耗时(毫秒)", 137 | nuCgtjoc: "耗时(毫秒)", 138 | McNVQPJd: "请求ip", 139 | DmTCRsso: "请求用户", 140 | OmuvkRln: "错误码", 141 | isrNxNgU: "错误消息", 142 | tdxojzse: "查看", 143 | QlRHLvop: "查看", 144 | RcReZbBQ: "响应结果", 145 | dorOOXKc: "查看", 146 | FCHoRuDz: "请求body参数", 147 | QHcGgoYL: "请求query参数", 148 | GvGjdJnR: "响应结果", 149 | }; 150 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | push: 10 | branches: ['main'] 11 | # Publish semver tags as releases. 12 | tags: ['v*.*.*'] 13 | 14 | env: 15 | # Use docker.io for Docker Hub if empty 16 | REGISTRY: registry.cn-hangzhou.aliyuncs.com 17 | # github.repository as / 18 | IMAGE_NAME: ${{ github.repository }} 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-latest 23 | permissions: 24 | contents: read 25 | packages: write 26 | # This is used to complete the identity challenge 27 | # with sigstore/fulcio when running outside of PRs. 28 | id-token: write 29 | 30 | steps: 31 | - name: Checkout repository 32 | uses: actions/checkout@v3 33 | 34 | # Workaround: https://github.com/docker/build-push-action/issues/461 35 | - name: Setup Docker buildx 36 | uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf 37 | 38 | - name: Cache Docker layers 39 | uses: actions/cache@v2 40 | with: 41 | path: /tmp/.buildx-cache 42 | key: ${{ runner.os }}-buildx-${{ github.sha }} 43 | restore-keys: | 44 | ${{ runner.os }}-buildx- 45 | 46 | # Login against a Docker registry except on PR 47 | # https://github.com/docker/login-action 48 | - name: Log into registry ${{ env.REGISTRY }} 49 | if: github.event_name != 'pull_request' 50 | uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c 51 | with: 52 | registry: ${{ env.REGISTRY }} 53 | username: ${{ secrets.USER_NAME }} 54 | password: ${{ secrets.DOCKER_TOKEN }} 55 | 56 | # Extract metadata (tags, labels) for Docker 57 | # https://github.com/docker/metadata-action 58 | - name: Extract Docker metadata 59 | id: meta 60 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 61 | with: 62 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 63 | 64 | # Build and push Docker image with Buildx (don't push on PR) 65 | # https://github.com/docker/build-push-action 66 | - name: Build and push Docker image 67 | id: build-and-push 68 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 69 | with: 70 | context: . 71 | push: ${{ github.event_name != 'pull_request' }} 72 | tags: ${{ steps.meta.outputs.tags }} 73 | labels: ${{ steps.meta.outputs.labels }} 74 | cache-from: type=local,src=/tmp/.buildx-cache 75 | cache-to: type=local,dest=/tmp/.buildx-cache-new 76 | build-args: SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} 77 | 78 | - name: Move cache 79 | run: | 80 | rm -rf /tmp/.buildx-cache 81 | mv /tmp/.buildx-cache-new /tmp/.buildx-cache 82 | 83 | - name: SSH Command 84 | # You may pin to the exact commit or the version. 85 | # uses: D3rHase/ssh-command-action@981832f056c539720824429fa91df009db0ee9cd 86 | uses: D3rHase/ssh-command-action@v0.2.1 87 | with: 88 | # hostname / IP of the server 89 | HOST: ${{ secrets.SERVER_IP }} 90 | # ssh port of the server 91 | PORT: 22 # optional, default is 22 92 | # user of the server 93 | USER: root 94 | # private ssh key registered on the server 95 | PRIVATE_SSH_KEY: ${{ secrets.SERVER_KEY }} 96 | # command to be executed 97 | COMMAND: cd /project/docker-compose && sh ./run.sh 98 | -------------------------------------------------------------------------------- /src/pages/login/reset-password.tsx: -------------------------------------------------------------------------------- 1 | import { auth_getPublicKey, auth_resetPassword } from '@/api/auth'; 2 | import { antdUtils } from '@/utils/antd'; 3 | import { t } from '@/utils/i18n'; 4 | import { getParamsBySearchParams } from '@/utils/utils'; 5 | import { LockOutlined } from '@ant-design/icons'; 6 | import { useRequest } from 'ahooks'; 7 | import { Button, Form, Input } from 'antd'; 8 | import { JSEncrypt } from "jsencrypt"; 9 | import { useEffect } from 'react'; 10 | import { Link, useNavigate, useSearchParams } from 'react-router-dom'; 11 | import RightContent from './components/right-content'; 12 | import './index.css'; 13 | 14 | const ResetPassword = () => { 15 | 16 | const navigate = useNavigate(); 17 | 18 | const { 19 | runAsync: getPublicKey 20 | } = useRequest(auth_getPublicKey, { manual: true }); 21 | const { 22 | runAsync: resetPassword, 23 | loading 24 | } = useRequest(auth_resetPassword, { manual: true }); 25 | 26 | const [query] = useSearchParams(); 27 | 28 | useEffect(() => { 29 | const params = getParamsBySearchParams(query) as any; 30 | 31 | if (!params.email || !params.emailCaptcha) { 32 | antdUtils.message?.error('重置链接不正确,请检查。') 33 | } 34 | 35 | }, [query]); 36 | 37 | const onFinish = async (values: any) => { 38 | 39 | if (values.confirmPassword !== values.password) { 40 | antdUtils.message?.error('两次密码不一致'); 41 | return; 42 | } 43 | // 获取公钥 44 | const publicKey = await getPublicKey(); 45 | // 使用公钥对密码加密 46 | const encrypt = new JSEncrypt(); 47 | encrypt.setPublicKey(publicKey); 48 | const password = encrypt.encrypt(values.password); 49 | 50 | if (!password) { 51 | return; 52 | } 53 | 54 | const params = getParamsBySearchParams(query) as unknown as any; 55 | 56 | values.password = password; 57 | values.publicKey = publicKey; 58 | values.email = params.email; 59 | values.emailCaptcha = params.emailCaptcha; 60 | 61 | await resetPassword(values); 62 | 63 | antdUtils.message?.success('密码重置成功'); 64 | 65 | navigate('/user/login'); 66 | }; 67 | 68 | return ( 69 |
70 |
71 |
72 |
73 |
74 |

重置密码

75 |
76 |
设置你的新密码
77 |
78 |
84 | 88 | } 90 | placeholder={t("HplkKxdY" /* 密码 */)} 91 | size="large" 92 | type="password" 93 | /> 94 | 95 | 99 | } 101 | type="password" 102 | placeholder="重复密码" 103 | /> 104 | 105 | 106 | 114 | 115 | 116 |
119 | 返回登录 120 |
121 |
122 |
123 |
124 |
125 | 126 |
127 | ); 128 | }; 129 | 130 | export default ResetPassword; 131 | -------------------------------------------------------------------------------- /src/hooks/use-tabs/index.tsx: -------------------------------------------------------------------------------- 1 | // /src/layouts/useTabs.tsx 2 | import { useMatchRoute } from '@/hooks/use-match-router'; 3 | import { router } from '@/router'; 4 | import { useLocalStorageState } from 'ahooks'; 5 | import { omit } from 'lodash-es'; 6 | import React, { useCallback, useEffect, useState } from 'react'; 7 | 8 | export interface KeepAliveTab { 9 | title: string; 10 | routePath: string; 11 | key: string; 12 | pathname: string; 13 | icon?: string; 14 | children: React.ReactNode; 15 | } 16 | 17 | function getKey() { 18 | return new Date().getTime().toString(); 19 | } 20 | 21 | export function useTabs() { 22 | // 存放页面记录 23 | const [keepAliveTabs, setKeepAliveTabs] = useLocalStorageState('keepAliveTabs', { 24 | defaultValue: [], 25 | serializer: value => { 26 | // 把 children 剔除掉,不然会报错 27 | return JSON.stringify(value.map(item => omit(item, ['children']))) 28 | }, 29 | }); 30 | // 当前激活的tab 31 | const [activeTabRoutePath, setActiveTabRoutePath] = useState(''); 32 | 33 | const matchRoute = useMatchRoute(); 34 | 35 | // 关闭tab 36 | const closeTab = useCallback( 37 | (routePath: string = activeTabRoutePath || '') => { 38 | 39 | if (!keepAliveTabs?.length) { 40 | return; 41 | } 42 | 43 | const index = (keepAliveTabs || []).findIndex(o => o.routePath === routePath); 44 | if (keepAliveTabs[index].routePath === activeTabRoutePath && keepAliveTabs.length > 1) { 45 | if (index > 0) { 46 | router.navigate(keepAliveTabs[index - 1].routePath); 47 | } else { 48 | router.navigate(keepAliveTabs[index + 1].routePath); 49 | } 50 | } 51 | keepAliveTabs.splice(index, 1); 52 | 53 | setKeepAliveTabs([...keepAliveTabs]); 54 | }, 55 | [activeTabRoutePath], 56 | ); 57 | 58 | // 关闭除了自己其它tab 59 | const closeOtherTab = useCallback((routePath: string = activeTabRoutePath || '') => { 60 | if (!keepAliveTabs?.length) { 61 | return; 62 | } 63 | const tab = keepAliveTabs.find(o => o.routePath === routePath); 64 | setKeepAliveTabs(keepAliveTabs.filter(o => o.routePath === routePath)); 65 | router.navigate(tab?.pathname || routePath); 66 | }, [activeTabRoutePath]); 67 | 68 | // 刷新tab 69 | const refreshTab = useCallback((routePath: string = activeTabRoutePath || '') => { 70 | setKeepAliveTabs(prev => { 71 | const index = (prev || []).findIndex(tab => tab.routePath === routePath); 72 | 73 | if (index >= 0 && prev) { 74 | // 这个是react的特性,key变了,组件会卸载重新渲染 75 | prev[index].key = getKey(); 76 | } 77 | 78 | return [...prev || []]; 79 | }); 80 | }, [activeTabRoutePath]); 81 | 82 | useEffect(() => { 83 | if (!matchRoute) return; 84 | 85 | const existKeepAliveTab = (keepAliveTabs || []).find(o => o.routePath === matchRoute?.routePath); 86 | 87 | // 如果不存在则需要插入 88 | if (!existKeepAliveTab) { 89 | setKeepAliveTabs([...(keepAliveTabs || []), { 90 | title: matchRoute.title, 91 | key: getKey(), 92 | routePath: matchRoute.routePath, 93 | pathname: matchRoute.pathname, 94 | children: matchRoute.children, 95 | icon: matchRoute.icon, 96 | }]); 97 | } else if (existKeepAliveTab.pathname !== matchRoute.pathname) { 98 | // 如果是同一个路由,但是参数不同,我们只需要刷新当前页签并且把pathname设置为新的pathname, children设置为新的children 99 | setKeepAliveTabs(prev => { 100 | const index = (prev || []).findIndex(tab => tab.routePath === matchRoute.routePath); 101 | if (index >= 0 && prev) { 102 | prev[index].key = getKey(); 103 | prev[index].pathname = matchRoute.pathname; 104 | prev[index].children = matchRoute.children; 105 | } 106 | return [...(prev || [])]; 107 | }); 108 | } else if (!existKeepAliveTab.children) { 109 | // 如果pathname相同,但是children为空,说明重缓存中加载的数据,我们只需要刷新当前页签并且把children设置为新的children 110 | setKeepAliveTabs(prev => { 111 | const index = (prev || []).findIndex(tab => tab.routePath === matchRoute.routePath); 112 | if (index >= 0 && prev) { 113 | prev[index].key = getKey(); 114 | prev[index].children = matchRoute.children; 115 | } 116 | return [...(prev || [])]; 117 | }); 118 | } 119 | setActiveTabRoutePath(matchRoute.routePath); 120 | }, [matchRoute]) 121 | 122 | return { 123 | tabs: keepAliveTabs, 124 | activeTabRoutePath, 125 | closeTab, 126 | closeOtherTab, 127 | refreshTab, 128 | setTabs: setKeepAliveTabs, 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/pages/role/index.tsx: -------------------------------------------------------------------------------- 1 | import { t } from '@/utils/i18n'; 2 | import { 3 | Button, 4 | Divider, 5 | Popconfirm, 6 | Space 7 | } from 'antd'; 8 | import { useRef, useState } from 'react'; 9 | 10 | import { role_page, role_remove } from '@/api/role'; 11 | import LinkButton from '@/components/link-button'; 12 | import FProTable from '@/components/pro-table'; 13 | import { antdUtils } from '@/utils/antd'; 14 | import { toPageRequestParams } from '@/utils/utils'; 15 | import { PlusOutlined } from '@ant-design/icons'; 16 | import { ActionType, ProColumnType } from '@ant-design/pro-components'; 17 | import dayjs from 'dayjs'; 18 | import NewAndEditForm from './new-edit-form'; 19 | import RoleMenu from './role-menu'; 20 | 21 | 22 | function RolePage() { 23 | const [editData, setEditData] = useState(null); 24 | const [roleMenuVisible, setRoleMenuVisible] = useState(false); 25 | const [curRoleId, setCurRoleId] = useState(); 26 | const [formOpen, setFormOpen] = useState(false); 27 | const actionRef = useRef(); 28 | 29 | 30 | const columns: ProColumnType[] = [ 31 | { 32 | title: t("qvtQYcfN" /* 名称 */), 33 | dataIndex: 'name', 34 | }, 35 | { 36 | title: t("WIRfoXjK" /* 代码 */), 37 | dataIndex: 'code', 38 | valueType: 'text', 39 | }, 40 | { 41 | title: t("TMuQjpWo" /* 创建时间 */), 42 | dataIndex: 'createDate', 43 | hideInForm: true, 44 | search: false, 45 | valueType: 'dateTime', 46 | width: 190, 47 | renderText: (value: Date) => { 48 | return dayjs(value).format('YYYY-MM-DD HH:mm:ss') 49 | } 50 | }, 51 | { 52 | title: t("QkOmYwne" /* 操作 */), 53 | dataIndex: 'id', 54 | hideInForm: true, 55 | width: 240, 56 | align: 'center', 57 | search: false, 58 | renderText: (id: string, record) => ( 59 | 62 | )} 63 | > 64 | { 66 | setCurRoleId(id); 67 | setRoleMenuVisible(true); 68 | }} 69 | > 70 | {t("DvINURho" /* 分配菜单 */)} 71 | 72 | { 74 | setEditData(record); 75 | setFormOpen(true); 76 | }} 77 | > 78 | {t("wXpnewYo" /* 编辑 */)} 79 | 80 | { 83 | const [error] = await role_remove({ id }); 84 | if (!error) { 85 | antdUtils.message?.success(t("CVAhpQHp" /* 删除成功! */)); 86 | actionRef.current?.reload(); 87 | } 88 | }} 89 | placement="topRight" 90 | > 91 | 93 | {t("HJYhipnp" /* 删除 */)} 94 | 95 | 96 | 97 | ), 98 | }, 99 | ]; 100 | 101 | 102 | const openForm = () => { 103 | setFormOpen(true); 104 | }; 105 | 106 | const closeForm = () => { 107 | setFormOpen(false); 108 | setEditData(null); 109 | }; 110 | 111 | const saveHandle = () => { 112 | actionRef.current?.reload(); 113 | setFormOpen(false); 114 | setEditData(null); 115 | }; 116 | 117 | return ( 118 | <> 119 | > 120 | actionRef={actionRef} 121 | columns={columns} 122 | request={async params => { 123 | return role_page(toPageRequestParams(params)); 124 | }} 125 | headerTitle={( 126 | 127 | 134 | 135 | )} 136 | /> 137 | !open && closeForm()} 139 | editData={editData} 140 | onSaveSuccess={saveHandle} 141 | open={formOpen} 142 | title={editData ? t('wXpnewYo' /* 编辑 */) : t('VjwnJLPY' /* 新建 */)} 143 | /> 144 | { 146 | setCurRoleId(null); 147 | setRoleMenuVisible(false); 148 | }} 149 | roleId={curRoleId} 150 | visible={roleMenuVisible} 151 | /> 152 | 153 | ); 154 | } 155 | 156 | export default RolePage; 157 | -------------------------------------------------------------------------------- /src/layouts/common/tabs-layout.tsx: -------------------------------------------------------------------------------- 1 | import { antdIcons } from '@/assets/antd-icons'; 2 | import DraggableTab from '@/components/draggable-tab'; 3 | import { defaultSetting } from '@/default-setting'; 4 | import { KeepAliveTab, useTabs } from '@/hooks/use-tabs'; 5 | import { router } from '@/router'; 6 | import { t } from '@/utils/i18n'; 7 | import { arrayMove } from '@dnd-kit/sortable'; 8 | import { Dropdown } from 'antd'; 9 | import { MenuItemType } from 'antd/es/menu/interface'; 10 | import React, { useCallback, useMemo } from "react"; 11 | import { KeepAliveTabContext } from './tabs-context'; 12 | import Watermark from './watermark'; 13 | 14 | enum OperationType { 15 | REFRESH = 'refresh', 16 | CLOSE = 'close', 17 | CLOSE_OTHER = 'close-other', 18 | } 19 | 20 | const TabsLayout: React.FC = () => { 21 | 22 | const { 23 | activeTabRoutePath, 24 | tabs = [], 25 | closeTab, 26 | refreshTab, 27 | closeOtherTab, 28 | setTabs, 29 | } = useTabs(); 30 | 31 | const getIcon = (icon?: string): React.ReactElement | undefined => { 32 | return icon && antdIcons[icon] && React.createElement(antdIcons[icon]); 33 | } 34 | 35 | const menuItems: MenuItemType[] = useMemo( 36 | () => [ 37 | { 38 | label: t("slZKRXqL" /* 刷新 */), 39 | key: OperationType.REFRESH, 40 | }, 41 | tabs.length <= 1 ? null : { 42 | label: t("FplLzQwk" /* 关闭 */), 43 | key: OperationType.CLOSE, 44 | }, 45 | tabs.length <= 1 ? null : { 46 | label: t("JPlYJWgB" /* 关闭其他 */), 47 | key: OperationType.CLOSE_OTHER, 48 | }, 49 | ].filter(o => o !== null) as MenuItemType[], 50 | [tabs] 51 | ); 52 | 53 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 54 | const menuClick = useCallback(({ key, domEvent }: any, tab: KeepAliveTab) => { 55 | domEvent.stopPropagation(); 56 | 57 | if (key === OperationType.REFRESH) { 58 | refreshTab(tab.routePath); 59 | } else if (key === OperationType.CLOSE) { 60 | closeTab(tab.routePath); 61 | } else if (key === OperationType.CLOSE_OTHER) { 62 | closeOtherTab(tab.routePath); 63 | } 64 | }, [closeOtherTab, closeTab, refreshTab]); 65 | 66 | const renderTabTitle = useCallback((tab: KeepAliveTab) => { 67 | return ( 68 | menuClick(e, tab), 72 | }} 73 | trigger={['contextMenu']} 74 | onOpenChange={open => { 75 | if (open) { 76 | router.navigate(tab.pathname); 77 | } 78 | }} 79 | > 80 |
81 | {getIcon(tab.icon)} 82 | {tab.title} 83 |
84 |
85 | ) 86 | }, [menuItems]); 87 | 88 | const tabItems = useMemo(() => { 89 | return tabs.map(tab => { 90 | return { 91 | key: tab.routePath, 92 | label: renderTabTitle(tab), 93 | children: ( 94 |
101 | 102 | {tab.children} 103 | 104 |
105 | ), 106 | closable: tabs.length > 1, // 剩最后一个就不能删除了 107 | } 108 | }) 109 | }, [tabs]); 110 | 111 | 112 | const onTabsChange = useCallback((tabRoutePath: string) => { 113 | router.navigate(tabRoutePath); 114 | }, []); 115 | 116 | const onTabEdit = ( 117 | targetKey: React.MouseEvent | React.KeyboardEvent | string, 118 | action: 'add' | 'remove', 119 | ) => { 120 | if (action === 'remove') { 121 | closeTab(targetKey as string); 122 | } 123 | }; 124 | 125 | const keepAliveContextValue = useMemo( 126 | () => ({ 127 | closeTab, 128 | closeOtherTab, 129 | refreshTab, 130 | }), 131 | [closeTab, closeOtherTab, refreshTab] 132 | ); 133 | 134 | return ( 135 | 136 | { 145 | setTabs( 146 | arrayMove(tabs, activeIndex as number, overIndex as number) 147 | ); 148 | }} 149 | /> 150 | 151 | ) 152 | } 153 | 154 | export default TabsLayout; -------------------------------------------------------------------------------- /src/layouts/common/use-user-detail.tsx: -------------------------------------------------------------------------------- 1 | import { auth_getCurrentUser } from '@/api/auth'; 2 | import Result404 from '@/components/exception/404'; 3 | import { pages } from '@/config/pages'; 4 | import { useWebSocketMessage } from '@/hooks/use-websocket'; 5 | import { CurrentUser, Menu } from '@/interface'; 6 | import { MenuType } from '@/pages/menu/interface'; 7 | import { router } from '@/router'; 8 | import { replaceRoutes } from '@/router/router-utils'; 9 | import { useGlobalStore } from '@/stores/global'; 10 | import { SocketMessage, useMessageStore } from '@/stores/message'; 11 | import { useUserStore } from '@/stores/user'; 12 | import { useRequest, useUpdateEffect } from 'ahooks'; 13 | import { lazy, useEffect, useState } from 'react'; 14 | 15 | export function useUserDetail() { 16 | const [loading, setLoading] = useState(true); 17 | 18 | const { refreshToken, token } = useGlobalStore(); 19 | const { setCurrentUser } = useUserStore(); 20 | const { setLatestMessage } = useMessageStore(); 21 | 22 | // 当获取完用户信息后,手动连接 23 | const { latestMessage, connect, disconnect } = useWebSocketMessage( 24 | `${window.location.protocol.replace('http', 'ws')}//${window.location.host}/ws/?token=${token}`, 25 | { manual: true } 26 | ); 27 | 28 | const { data: currentUserDetail, loading: requestLoading } = useRequest( 29 | auth_getCurrentUser, 30 | { 31 | refreshDeps: [refreshToken], 32 | onError: () => { 33 | router.navigate('/user/login'); 34 | } 35 | } 36 | ); 37 | 38 | useEffect(() => { 39 | if (!currentUserDetail) return; 40 | 41 | setLoading(true); 42 | 43 | function formatMenus( 44 | menus: Menu[], 45 | menuGroup: Record, 46 | routes: Menu[], 47 | parentMenu?: Menu 48 | ): Menu[] { 49 | return menus.map(menu => { 50 | const children = menuGroup[menu.id!]; 51 | 52 | const parentPaths = parentMenu?.parentPaths || []; 53 | const lastPath = parentPaths[parentPaths.length - 1]; 54 | const path = (parentMenu ? `${lastPath}${menu.route}` : menu.route) || ''; 55 | 56 | routes.push({ 57 | ...menu, 58 | path, 59 | parentPaths, 60 | }); 61 | 62 | return { 63 | ...menu, 64 | path, 65 | parentPaths, 66 | children: children?.length ? formatMenus(children, menuGroup, routes, { 67 | ...menu, 68 | parentPaths: [...parentPaths, path || ''].filter(o => o), 69 | }) : undefined, 70 | }; 71 | }); 72 | } 73 | 74 | const { menus = [] } = currentUserDetail; 75 | 76 | const menuGroup = menus.reduce>((prev, menu) => { 77 | if (!menu.parentId) { 78 | return prev; 79 | } 80 | 81 | if (!prev[menu.parentId]) { 82 | prev[menu.parentId] = []; 83 | } 84 | 85 | prev[menu.parentId].push(menu); 86 | return prev; 87 | }, {}); 88 | 89 | const routes: Menu[] = []; 90 | 91 | const currentUser: CurrentUser = { 92 | ...currentUserDetail, 93 | flatMenus: routes, 94 | menus: formatMenus(menus.filter(o => !o.parentId), menuGroup, routes), 95 | authList: menus 96 | .filter(menu => menu.type === MenuType.BUTTON && menu.authCode) 97 | .map(menu => menu.authCode!), 98 | }; 99 | 100 | 101 | replaceRoutes('*', [ 102 | ...routes.map(menu => { 103 | return ({ 104 | path: `/*${menu.path}`, 105 | id: `/*${menu.path}`, 106 | Component: menu.filePath ? pages[menu.filePath] ? lazy(pages[menu.filePath]) : Result404 : Result404, 107 | handle: { 108 | parentPaths: menu.parentPaths, 109 | path: menu.path, 110 | name: menu.name, 111 | icon: menu.icon, 112 | }, 113 | }) 114 | }), { 115 | id: '*', 116 | path: '*', 117 | Component: Result404, 118 | handle: { 119 | path: '404', 120 | name: '404', 121 | }, 122 | } 123 | ]); 124 | 125 | setCurrentUser(currentUser); 126 | 127 | // replace一下当前路由,为了触发路由匹配 128 | router.navigate(`${location.pathname}${location.search}`, { replace: true }); 129 | 130 | setLoading(false); 131 | 132 | connect && connect(); 133 | }, [currentUserDetail, setCurrentUser]); 134 | 135 | 136 | useUpdateEffect(() => { 137 | if (latestMessage?.data) { 138 | try { 139 | const socketMessage = JSON.parse(latestMessage?.data) as SocketMessage; 140 | setLatestMessage(socketMessage) 141 | } catch { 142 | console.error(latestMessage?.data); 143 | } 144 | } 145 | }, [latestMessage]); 146 | 147 | useUpdateEffect(() => { 148 | if (token) { 149 | connect && connect(); 150 | } 151 | }, [token]) 152 | 153 | return { 154 | loading: requestLoading || loading, 155 | disconnectWS: disconnect, 156 | } 157 | } 158 | 159 | -------------------------------------------------------------------------------- /src/pages/user/new-edit-form.tsx: -------------------------------------------------------------------------------- 1 | import { role_list } from '@/api/role'; 2 | import { user_create, user_update } from '@/api/user'; 3 | import FModalForm from '@/components/modal-form'; 4 | import { antdUtils } from '@/utils/antd'; 5 | import { t } from '@/utils/i18n'; 6 | import { clearFormValues } from '@/utils/utils'; 7 | import { useRequest, useUpdateEffect } from 'ahooks'; 8 | import { Form, Input, Select } from 'antd'; 9 | import Avatar from './avatar'; 10 | 11 | interface PropsType { 12 | open: boolean; 13 | editData?: any; 14 | title: string; 15 | onClose: () => void; 16 | onSaveSuccess: () => void; 17 | } 18 | 19 | function NewAndEditForm({ 20 | open, 21 | editData, 22 | onSaveSuccess, 23 | onClose, 24 | title, 25 | }: PropsType) { 26 | 27 | const [form] = Form.useForm(); 28 | 29 | const { 30 | runAsync: updateUser, 31 | loading: updateLoading, 32 | } = useRequest( 33 | user_update, 34 | { 35 | manual: true, 36 | onSuccess: () => { 37 | antdUtils.message.success(t("NfOSPWDa" /* 更新成功! */)); 38 | onSaveSuccess(); 39 | }, 40 | } 41 | ); 42 | 43 | const { 44 | runAsync: addUser, 45 | loading: createLoading, 46 | } = useRequest( 47 | user_create, 48 | { 49 | manual: true, 50 | onSuccess: () => { 51 | antdUtils.message.success(t("JANFdKFM" /* 创建成功! */)); 52 | onSaveSuccess(); 53 | }, 54 | } 55 | ); 56 | 57 | const { 58 | data: roles, 59 | loading: getRolesLoading, 60 | } = useRequest( 61 | role_list 62 | ); 63 | 64 | const finishHandle = async (values: any) => { 65 | if (values?.avatar?.[0]?.response?.id) { 66 | values.avatar = values?.avatar?.[0]?.response?.id; 67 | } else { 68 | values.avatar = null; 69 | } 70 | 71 | if (editData) { 72 | updateUser({ ...editData, ...values }) 73 | } else { 74 | addUser(values) 75 | } 76 | } 77 | 78 | useUpdateEffect(() => { 79 | if (editData) { 80 | form.setFieldsValue({ 81 | ...editData, 82 | avatar: editData.avatar ? [{ 83 | uid: '-1', 84 | name: editData.avatar.fileName, 85 | states: 'done', 86 | url: editData.avatar.filePath, 87 | response: { 88 | id: editData.avatar.id, 89 | }, 90 | }] : [], 91 | roleIds: (editData.roles || []).map((role: API.RoleVO) => role.id), 92 | }) 93 | } else { 94 | clearFormValues(form); 95 | } 96 | }, [editData]); 97 | 98 | return ( 99 | !open && onClose && onClose()} 109 | layout="horizontal" 110 | modalProps={{ forceRender: true }} 111 | > 112 | 116 | 117 | 118 | 126 | 127 | 128 | 136 | 137 | 138 | 149 | 150 | 151 | 162 | 163 | 164 | 168 |