= 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
46 | )}
47 | filterOption={false}
48 | placeholder={`${t("jhqxJPbn" /* 搜索菜单 */)}...`}
49 | showSearch
50 | onSearch={setSearchValue}
51 | open={open}
52 | onDropdownVisibleChange={setOpen}
53 | dropdownRender={() => {
54 | return (
55 |
59 |
60 | {
62 | setOpen(false)
63 | }}
64 | to={item.path || ''}
65 | className='flex justify-between w-full'
66 | >
67 |
68 | {item.icon && React.createElement(antdIcons[item.icon])}
69 |
{item.name}
70 |
71 |
72 |
73 |
74 | }
75 | />
76 | )
77 | }}
78 | />
79 | );
80 | }
81 |
82 | export default MenuSearcher;
83 |
--------------------------------------------------------------------------------
/src/api/menu.ts:
--------------------------------------------------------------------------------
1 | // @ts-ignore
2 | /* eslint-disable */
3 | import request from "@/request";
4 |
5 | /** 更新菜单 PUT /menu/ */
6 | export async function menu_update(
7 | body: API.MenuDTO,
8 | options?: { [key: string]: any }
9 | ) {
10 | return request("/menu/", {
11 | method: "PUT",
12 | headers: {
13 | "Content-Type": "application/json",
14 | },
15 | data: body,
16 | ...(options || {}),
17 | });
18 | }
19 |
20 | /** 创建一个菜单 POST /menu/ */
21 | export async function menu_create(
22 | body: API.MenuDTO,
23 | options?: { [key: string]: any }
24 | ) {
25 | return request("/menu/", {
26 | method: "POST",
27 | headers: {
28 | "Content-Type": "application/json",
29 | },
30 | data: body,
31 | ...(options || {}),
32 | });
33 | }
34 |
35 | /** 删除一个菜单 DELETE /menu/${param0} */
36 | export async function menu_remove(
37 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
38 | params: API.menuRemoveParams,
39 | options?: { [key: string]: any }
40 | ) {
41 | const { id: param0, ...queryParams } = params;
42 | return request(`/menu/${param0}`, {
43 | method: "DELETE",
44 | params: { ...queryParams },
45 | ...(options || {}),
46 | });
47 | }
48 |
49 | /** 根据菜单查询已分配接口 GET /menu/alloc/api/list */
50 | export async function menu_getAllocAPIByMenu(
51 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
52 | params: API.menuGetAllocAPIByMenuParams,
53 | options?: { [key: string]: any }
54 | ) {
55 | return request("/menu/alloc/api/list", {
56 | method: "GET",
57 | params: {
58 | ...params,
59 | },
60 | ...(options || {}),
61 | });
62 | }
63 |
64 | /** 根据上级菜单查询子级菜单 GET /menu/children */
65 | export async function menu_children(
66 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
67 | params: API.menuChildrenParams,
68 | options?: { [key: string]: any }
69 | ) {
70 | return request("/menu/children", {
71 | method: "GET",
72 | params: {
73 | ...params,
74 | },
75 | ...(options || {}),
76 | });
77 | }
78 |
79 | /** 查询全量菜单 GET /menu/list */
80 | export async function menu_list(options?: { [key: string]: any }) {
81 | return request("/menu/list", {
82 | method: "GET",
83 | ...(options || {}),
84 | });
85 | }
86 |
87 | /** 分页查询菜单 GET /menu/page */
88 | export async function menu_page(
89 | // 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
90 | params: API.menuPageParams,
91 | options?: { [key: string]: any }
92 | ) {
93 | return request("/menu/page", {
94 | method: "GET",
95 | params: {
96 | ...params,
97 | },
98 | ...(options || {}),
99 | });
100 | }
101 |
--------------------------------------------------------------------------------
/src/pages/login/components/forget-password-modal.tsx:
--------------------------------------------------------------------------------
1 | import { auth_sendResetPasswordEmail } from '@/api/auth';
2 | import { antdUtils } from '@/utils/antd';
3 | import { useRequest } from 'ahooks';
4 | import { Button, Input, Modal } from 'antd';
5 | import { useEffect, useState } from 'react';
6 |
7 | interface Props {
8 | open: boolean;
9 | setOpen: (open: boolean) => void;
10 | }
11 |
12 | const ForgetPasswordModal = ({ open, setOpen }: Props) => {
13 |
14 | const [emailInputFocus, setEmailInputFocus] = useState(false);
15 | const [checkEmail, setCheckEmail] = useState();
16 |
17 | const { runAsync: sendResetPasswordEmail, loading: resetPasswordBtnLoading } = useRequest(
18 | auth_sendResetPasswordEmail,
19 | { manual: true }
20 | );
21 |
22 | useEffect(() => {
23 | if (open) {
24 | setCheckEmail('');
25 | }
26 | }, [open])
27 |
28 |
29 | const sendCheckEmail = async () => {
30 | if (!checkEmail) {
31 | antdUtils.message.error('无效的邮箱格式!');
32 | return;
33 | }
34 |
35 | const [error] = await sendResetPasswordEmail({ checkEmail });
36 |
37 | if (!error) {
38 | antdUtils.message.success('邮件已发送,请到邮箱查看。');
39 | setOpen(false);
40 | }
41 | }
42 |
43 |
44 | return (
45 | {
53 | setOpen(false);
54 | }}
55 | styles={{
56 | body: { padding: '20px 0', position: 'relative' }
57 | }}
58 | >
59 | {!emailInputFocus && (
60 |
64 | )}
65 | {emailInputFocus && (
66 |
70 | )}
71 | { 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 |
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 | }
106 | >
107 | {t('morEPEyc' /* 新增 */)}
108 |
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 |
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 | }
131 | >
132 | {t('morEPEyc' /* 新增 */)}
133 |
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 |
177 |
178 | )
179 | }
180 |
181 | export default NewAndEditForm;
--------------------------------------------------------------------------------
/src/pages/user/index.tsx:
--------------------------------------------------------------------------------
1 | import { t } from '@/utils/i18n';
2 | import {
3 | Avatar,
4 | Button,
5 | Popconfirm,
6 | Space,
7 | Tag
8 | } from 'antd';
9 | import dayjs from 'dayjs';
10 | import { useRef, useState } from 'react';
11 |
12 | import { user_page, user_remove } from '@/api/user';
13 | import { IconBuguang } from '@/assets/icons/buguang';
14 | import LinkButton from '@/components/link-button';
15 | import FProTable from '@/components/pro-table';
16 | import { antdUtils } from '@/utils/antd';
17 | import { toPageRequestParams } from '@/utils/utils';
18 | import { PlusOutlined } from '@ant-design/icons';
19 | import { ActionType, ProColumns } from '@ant-design/pro-components';
20 | import { useRequest } from 'ahooks';
21 | import NewAndEditForm from './new-edit-form';
22 |
23 | function UserPage() {
24 | const actionRef = useRef();
25 |
26 | const { runAsync: deleteUser } = useRequest(user_remove, {
27 | manual: true,
28 | });
29 | const [editData, setEditData] = useState(null);
30 | const [formOpen, setFormOpen] = useState(false);
31 |
32 | const columns: ProColumns[] = [{
33 | title: t("YxQffpdF" /* 头像 */),
34 | dataIndex: 'avatarPath',
35 | renderText: (value) => (
36 |
37 | {value ? (
38 |

39 | ) : (
40 |
}
43 | />
44 | )}
45 |
46 | ),
47 | align: 'center',
48 | width: 100,
49 | search: false,
50 | },
51 | {
52 | title: t('qYznwlfj' /* 用户名 */),
53 | dataIndex: 'userName',
54 | },
55 | {
56 | title: t('gohANZwy' /* 昵称 */),
57 | dataIndex: 'nickName',
58 | },
59 | {
60 | title: t('yBxFprdB' /* 手机号 */),
61 | dataIndex: 'phoneNumber',
62 | },
63 | {
64 | title: t('XWVvMWig' /* 邮箱 */),
65 | dataIndex: 'email',
66 | },
67 | {
68 | title: t("PnmzVovn" /* 角色 */),
69 | dataIndex: 'roles',
70 | valueType: 'select',
71 | renderText: (roles: API.RoleVO[]) => {
72 | return (
73 |
74 | {(roles || []).map(role => (
75 | {role.name}
76 | ))}
77 |
78 | )
79 | },
80 | search: false,
81 | },
82 | {
83 | title: t('TMuQjpWo' /* 创建时间 */),
84 | dataIndex: 'createDate',
85 | renderText: (value: number) =>
86 | value && dayjs(value).format('YYYY-MM-DD HH:mm:ss'),
87 | search: false,
88 | },
89 | {
90 | title: t('QkOmYwne' /* 操作 */),
91 | key: 'action',
92 | search: false,
93 | align: 'center',
94 | width: 150,
95 | render: (_, record) =>
96 | record.userName !== 'admin' && (
97 |
98 | {
100 | setEditData(record);
101 | setFormOpen(true);
102 | }}
103 | >
104 | {t('qEIlwmxC' /* 编辑 */)}
105 |
106 | {
110 | const [error] = await deleteUser({ id: record.id! });
111 | if (!error) {
112 | antdUtils.message.success(t('bvwOSeoJ' /* 删除成功! */));
113 | actionRef.current?.reload();
114 | }
115 | }}
116 | >
117 | {t('HJYhipnp' /* 删除 */)}
118 |
119 |
120 | ),
121 | },
122 | ];
123 |
124 |
125 | const openForm = () => {
126 | setFormOpen(true);
127 | };
128 |
129 | const closeForm = () => {
130 | setFormOpen(false);
131 | setEditData(null);
132 | };
133 |
134 | const saveHandle = () => {
135 | actionRef.current?.reload();
136 | setFormOpen(false);
137 | setEditData(null);
138 | };
139 |
140 | return (
141 | <>
142 | {
147 | return user_page(toPageRequestParams(params));
148 | }}
149 | columns={columns || []}
150 | headerTitle={(
151 |
152 | }
156 | >
157 | {t('morEPEyc' /* 新增 */)}
158 |
159 |
160 | )}
161 | />
162 |
169 | >
170 | );
171 | }
172 |
173 | export default UserPage;
174 |
--------------------------------------------------------------------------------
/src/assets/locales/en-US.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | wbTMzvDM: "A High Beauty Background Management System",
3 | wVzXBuYs: "Please enter an account",
4 | RNISycbR: "account number",
5 | DjMcEMAe: "Please input a password",
6 | HplkKxdY: "password",
7 | dDdqAAve: "Sign in",
8 | wrQwwbSV: "Chinese",
9 | hGtEfNnp: "English",
10 | jhqxJPbn: "Search Menu",
11 | wPqFuoLF: "Log out of login",
12 | yAdJryjx: "Indicator Description",
13 | nKMAkrqJ: "Total sales revenue",
14 | NpRFMJyD: "Weekly YoY",
15 | WOQnwYUS: "Daily YoY",
16 | ZPCQOWAn: "Daily sales",
17 | iLyPEqwQ: "Indicator Description",
18 | ftuxZMpL: "Visits",
19 | sehypRaO: "Daily Visits",
20 | sdOusITo: "Indicator Description",
21 | PIYkoguj: "Number of payments",
22 | BUjwpMzX: "Conversion rate",
23 | fHpiDHYH: "Total growth",
24 | yLkZTWbn: "today",
25 | QFqMuZiD: "This month",
26 | lGOcGyrv: "This year",
27 | yzUIyMhr: "Store sales",
28 | aSPCUBcK: "today",
29 | EhTpnarX: "This month",
30 | AGGPEAdX: "This year",
31 | jTSvVuJx: "Shanghai Branch",
32 | SwsawJhB: "20% profit",
33 | JYSgIJHD: "Shanghai Branch",
34 | yELACPnu: "20% profit",
35 | WAiyAuwV: "Hefei Branch",
36 | HpNzGyBz: "6% profit",
37 | nGvTAQld: "Beijing Branch",
38 | EeunYupT: "8% loss",
39 | usCBUdwp: "Suzhou Branch",
40 | TacOGPiP: "14% profit",
41 | Imkllizi: "Nanjing Branch",
42 | MzCxBxLH: "6% loss",
43 | LhjNVSoc: "name",
44 | MOlwAEMx: "Age",
45 | npxxdPKd: "address",
46 | YoERuunu: "occupation",
47 | QkOmYwne: "operation",
48 | EOSDTAVT: "name",
49 | hQeqcUTv: "Age",
50 | YHapJMTT: "search",
51 | uCkoPyVp: "eliminate",
52 | qYznwlfj: "user name",
53 | gohANZwy: "nickname",
54 | yBxFprdB: "Mobile phone number",
55 | XWVvMWig: "mailbox",
56 | ykrQSYRh: "Gender",
57 | AkkyZTUy: "male",
58 | yduIcxbx: "female",
59 | TMuQjpWo: "Creation time",
60 | qEIlwmxC: "edit",
61 | JjwFfqHG: "warning",
62 | nlZBTfzL: "Are you sure to delete this data?",
63 | bvwOSeoJ: "Successfully deleted!",
64 | HJYhipnp: "delete",
65 | rnyigssw: "nickname",
66 | SPsRnpyN: "Mobile phone number",
67 | morEPEyc: "Add",
68 | wXpnewYo: "edit",
69 | VjwnJLPY: "New",
70 | NfOSPWDa: "Updated successfully!",
71 | JANFdKFM: "Created successfully!",
72 | jwGPaPNq: "Cannot be empty",
73 | iricpuxB: "Cannot be empty",
74 | UdKeETRS: "Cannot be empty",
75 | AnDwfuuT: "Incorrect phone number format",
76 | QFkffbad: "Cannot be empty",
77 | EfwYKLsR: "Incorrect email format",
78 | qvtQYcfN: "Name",
79 | ToFVNEkU: "type",
80 | ESYcSMBi: "Icon",
81 | XBkSjYmn: "route",
82 | aqmTtwBN: "File address",
83 | lDZjrith: "Button permission code",
84 | XRfphTtu: "Sort Number",
85 | hRiGeMNr: "add to",
86 | slZKRXqL: "Refresh",
87 | FplLzQwk: "close",
88 | JPlYJWgB: "Close Other",
89 | EOnUUxNS: "Login Account",
90 | SQQeztUX: "Login IP",
91 | pcFVrbFq: "Login address",
92 | ZroXYzZI: "browser",
93 | MsdUjinV: "operating system",
94 | aQMiCXYx: "Login status",
95 | pyuYsBVW: "success",
96 | HaLzGNdm: "fail",
97 | pzvzlzLU: "logon message",
98 | AZWKRNAc: "login time",
99 | WIRfoXjK: "code",
100 | DvINURho: "Allocation menu",
101 | RCCSKHGu: "Are you sure to delete?",
102 | CVAhpQHp: "Delete successfully!",
103 | rDmZCvin: "Select menu",
104 | koENLxye: "Allocation successful",
105 | oewZmYWL: "Select type:",
106 | dWBURdsX: "All sub levels",
107 | REnCKzPW: "current",
108 | dGQCFuac: "First level sub level",
109 | kKvCUxII: "New successfully added",
110 | XLSnfaCz: "Update successful",
111 | fQwvzwUN:
112 | "Starting with/, there is no need to manually concatenate higher-level routes. Parameter format/: id",
113 | GlfSFNdD: "Must start with/",
114 | SOYRkwDk:
115 | "Starting with/, there is no need to manually concatenate higher-level routes. Parameter format/: id",
116 | ZsOhTupE: "Must start with/",
117 | kDeoPsVD: "Is it displayed",
118 | etRQPYBn: "Permission code",
119 | LxiWbxsx: "Binding interface",
120 | wuePkjHJ: "catalogue",
121 | mYuKCgjM: "menu",
122 | ZJvOOWLP: "Button",
123 | YxQffpdF: "head portrait",
124 | PnmzVovn: "role",
125 | uMzBAbdE: "Resend(",
126 | isruHRIs: "Seconds)",
127 | JekywPnc: "Send email verification code",
128 | IrkXPQuD: "File type error",
129 | ghBOBkBd: "The file size cannot exceed 2M",
130 | baHVcbPK: "Email verification code",
131 | qXyXvYKQ: "Request Address",
132 | BIHtcucU: "Request method",
133 | jUMSQtwr: "Is it successful",
134 | enuYCXKm: "Request start time",
135 | JoQzAZoH: "Request start time range",
136 | vSIQMaLG: "Request end time",
137 | CXtLVbIc: "Request end time range",
138 | icSKnRmj: "Time consumption (milliseconds)",
139 | nuCgtjoc: "Time consumption (milliseconds)",
140 | McNVQPJd: "Request IP address",
141 | DmTCRsso: "request user",
142 | OmuvkRln: "error code",
143 | isrNxNgU: "message",
144 | tdxojzse: "view",
145 | QlRHLvop: "view",
146 | RcReZbBQ: "Response results",
147 | dorOOXKc: "view",
148 | FCHoRuDz: "Request body parameter",
149 | QHcGgoYL: "Request query parameters",
150 | GvGjdJnR: "Response results",
151 | };
152 |
--------------------------------------------------------------------------------
/src/pages/role/role-menu.tsx:
--------------------------------------------------------------------------------
1 | import { menu_list } from '@/api/menu';
2 | import { role_allocMenu, role_getMenusByRoleId } from '@/api/role';
3 | import { antdUtils } from '@/utils/antd';
4 | import { t } from '@/utils/i18n';
5 | import { Modal, Radio, Spin, Tree } from 'antd';
6 | import { DataNode } from 'antd/es/tree';
7 | import to from 'await-to-js';
8 | import { Key, useEffect, useState } from 'react';
9 |
10 | interface RoleMenuProps {
11 | visible: boolean;
12 | onCancel: () => void;
13 | roleId?: string | null;
14 | onSave?: (checkedKeys: string[]) => void;
15 | }
16 |
17 | interface CheckNode {
18 | key: Key;
19 | children?: CheckNode[];
20 | }
21 |
22 | function RoleMenu({
23 | visible,
24 | onCancel,
25 | roleId,
26 | onSave
27 | }: RoleMenuProps) {
28 | const [treeData, setTreeData] = useState([]);
29 | const [getDataLoading, setGetDataLoading] = useState(false);
30 | const [checkedKeys, setCheckedKeys] = useState([]);
31 | const [saveLoading, setSaveLoading] = useState(false);
32 | const [selectType, setSelectType] = useState<"allChildren" | "current" | "firstChildren">('allChildren');
33 |
34 | const getAllChildrenKeys = (children: CheckNode[] = [], keys: Key[]): void => {
35 | (children || []).forEach((node) => {
36 | keys.push(node.key);
37 | getAllChildrenKeys(node.children, keys);
38 | });
39 | };
40 |
41 | const getFirstChildrenKeys = (children: CheckNode[] = [], keys: string[]): void => {
42 | (children || []).forEach((node) => {
43 | keys.push(node.key as string);
44 | });
45 | };
46 |
47 | const onCheck = (_: Key[] | { checked: Key[]; halfChecked: Key[]; }, { checked, node }: { checked: boolean, node: CheckNode }) => {
48 | const keys = [node.key as string];
49 | if (selectType === 'allChildren') {
50 | getAllChildrenKeys(node.children, keys);
51 | } else if (selectType === 'firstChildren') {
52 | getFirstChildrenKeys(node.children, keys);
53 | }
54 |
55 | if (checked) {
56 | setCheckedKeys((prev) => [...prev, ...keys]);
57 | } else {
58 | setCheckedKeys((prev) => prev.filter((o) => !keys.includes(o)));
59 | }
60 | };
61 |
62 | const formatTree = (roots: API.MenuVO[] = [], group: Record): DataNode[] => {
63 | return roots.map((node) => {
64 | return {
65 | key: node.id,
66 | title: node.name,
67 | children: formatTree(group[node.id!] || [], group),
68 | } as DataNode;
69 | });
70 | };
71 |
72 | const getData = async () => {
73 | setGetDataLoading(true);
74 | const [error, data] = await to(
75 | menu_list()
76 | );
77 |
78 | if (!error) {
79 | const group = data.reduce>((prev, cur) => {
80 | if (!cur.parentId) {
81 | return prev;
82 | }
83 |
84 | if (prev[cur.parentId]) {
85 | prev[cur.parentId].push(cur);
86 | } else {
87 | prev[cur.parentId] = [cur];
88 | }
89 | return prev;
90 | }, {});
91 |
92 | const roots = data.filter((o) => !o.parentId);
93 |
94 | const newTreeData = formatTree(roots, group);
95 | setTreeData(newTreeData);
96 | }
97 |
98 | setGetDataLoading(false);
99 | };
100 |
101 | const getCheckedKeys = async () => {
102 | if (!roleId) return;
103 |
104 | const [error, data] = await to(
105 | role_getMenusByRoleId({ id: roleId })
106 | );
107 |
108 | if (!error) {
109 | setCheckedKeys(data);
110 | }
111 | };
112 |
113 | const save = async () => {
114 |
115 | if (onSave) {
116 | onSave(checkedKeys);
117 | return;
118 | }
119 |
120 | if (!roleId) return;
121 |
122 | setSaveLoading(true);
123 | const [error] = await role_allocMenu({ menuIds: checkedKeys, roleId })
124 |
125 | setSaveLoading(false);
126 |
127 | if (!error) {
128 | antdUtils.message?.success(t("koENLxye" /* 分配成功 */));
129 | onCancel();
130 | }
131 | };
132 |
133 | useEffect(() => {
134 | if (visible) {
135 | getData();
136 | getCheckedKeys();
137 | } else {
138 | setCheckedKeys([]);
139 | }
140 | }, [visible]);
141 |
142 | return (
143 | {
147 | onCancel();
148 | }}
149 | width={640}
150 | onOk={save}
151 | confirmLoading={saveLoading}
152 | styles={{
153 | body: {
154 | height: 400,
155 | overflowY: 'auto',
156 | padding: '20px 0',
157 | }
158 | }}
159 | >
160 | {getDataLoading ? (
161 |
162 | ) : (
163 |
164 |
165 |
setSelectType(e.target.value)}
167 | defaultValue="allChildren"
168 | optionType="button"
169 | buttonStyle="solid"
170 | >
171 | {t("dWBURdsX" /* 所有子级 */)}
172 | {t("REnCKzPW" /* 当前 */)}
173 | {t("dGQCFuac" /* 一级子级 */)}
174 |
175 |
176 |
184 |
185 |
186 | )}
187 |
188 | );
189 | }
190 |
191 | export default RoleMenu;
192 |
--------------------------------------------------------------------------------
/src/request/axios.ts:
--------------------------------------------------------------------------------
1 | import { auth_refreshToken } from '@/api/auth';
2 | import { useGlobalStore } from '@/stores/global';
3 | import { antdUtils } from '@/utils/antd';
4 | import to from 'await-to-js';
5 | import axios, {
6 | AxiosInstance,
7 | AxiosRequestConfig,
8 | AxiosResponse,
9 | CreateAxiosDefaults,
10 | InternalAxiosRequestConfig,
11 | } from 'axios';
12 |
13 | const refreshTokenUrl = '/auth/refresh/token';
14 |
15 | class Request {
16 | constructor(config?: CreateAxiosDefaults) {
17 | this.axiosInstance = axios.create(config);
18 |
19 | this.axiosInstance.interceptors.request.use(
20 | (axiosConfig: InternalAxiosRequestConfig) =>
21 | this.requestInterceptor(axiosConfig)
22 | );
23 | this.axiosInstance.interceptors.response.use(
24 | (response: AxiosResponse) =>
25 | this.responseSuccessInterceptor(response),
26 | (error: any) => this.responseErrorInterceptor(error)
27 | );
28 | }
29 |
30 | private axiosInstance: AxiosInstance;
31 |
32 | private refreshTokenFlag = false;
33 | private requestQueue: {
34 | resolve: any;
35 | config: any;
36 | type: 'request' | 'response';
37 | }[] = [];
38 | private limit = 3;
39 |
40 | private requestingCount = 0;
41 |
42 | setLimit(limit: number) {
43 | this.limit = limit;
44 | }
45 |
46 | private async requestInterceptor(
47 | axiosConfig: InternalAxiosRequestConfig
48 | ): Promise {
49 | if ([refreshTokenUrl].includes(axiosConfig.url || '')) {
50 | return Promise.resolve(axiosConfig);
51 | }
52 |
53 | if (this.refreshTokenFlag || this.requestingCount >= this.limit) {
54 | return new Promise((resolve) => {
55 | this.requestQueue.push({
56 | resolve,
57 | config: axiosConfig,
58 | type: 'request',
59 | });
60 | });
61 | }
62 |
63 | this.requestingCount += 1;
64 |
65 | const { token } = useGlobalStore.getState();
66 |
67 | if (token) {
68 | axiosConfig.headers.Authorization = `Bearer ${token}`;
69 | }
70 | return Promise.resolve(axiosConfig);
71 | }
72 |
73 | private requestByQueue() {
74 | if (!this.requestQueue.length) return;
75 |
76 | Array.from({ length: this.limit - this.requestingCount }).forEach(
77 | async () => {
78 | const record = this.requestQueue.shift();
79 | if (!record) {
80 | return;
81 | }
82 |
83 | const { config, resolve, type } = record;
84 | if (type === 'response') {
85 | resolve(await this.request(config));
86 | } else if (type === 'request') {
87 | this.requestingCount += 1;
88 | const { token } = useGlobalStore.getState();
89 | config.headers.Authorization = `Bearer ${token}`;
90 | resolve(config);
91 | }
92 | }
93 | );
94 | }
95 |
96 | private async refreshToken() {
97 | const { refreshToken } = useGlobalStore.getState();
98 |
99 | if (!refreshToken) {
100 | this.toLoginPage();
101 | }
102 |
103 | auth_refreshToken({ refreshToken: refreshToken.toString() })
104 |
105 | const [error, data] = await to(auth_refreshToken({ refreshToken: refreshToken.toString() }));
106 | if (error) {
107 | this.toLoginPage();
108 | return;
109 | }
110 |
111 | useGlobalStore.setState({
112 | refreshToken: data.refreshToken,
113 | token: data.token,
114 | });
115 |
116 | this.refreshTokenFlag = false;
117 |
118 | this.requestByQueue();
119 | }
120 |
121 | private async responseSuccessInterceptor(
122 | response: AxiosResponse
123 | ): Promise {
124 | if (response.config.url !== refreshTokenUrl) {
125 | this.requestingCount -= 1;
126 | if (this.requestQueue.length) {
127 | this.requestByQueue();
128 | }
129 | }
130 |
131 | return Promise.resolve(response.data);
132 | }
133 |
134 | private async responseErrorInterceptor(error: any): Promise {
135 | this.requestingCount -= 1;
136 | const { config, status } = error?.response || {};
137 |
138 | if (status === 401) {
139 | return new Promise((resolve) => {
140 | this.requestQueue.unshift({ resolve, config, type: 'response' });
141 | if (this.refreshTokenFlag) return;
142 |
143 | this.refreshTokenFlag = true;
144 | this.refreshToken();
145 | });
146 | } else {
147 | console.log(error, 'error')
148 | antdUtils.notification.error({
149 | message: '出错了',
150 | description: error?.response?.data?.message || '发生未知错误,请刷新后重试',
151 | });
152 | return Promise.reject(error);
153 | }
154 | }
155 |
156 | private reset() {
157 | this.requestQueue = [];
158 | this.refreshTokenFlag = false;
159 | this.requestingCount = 0;
160 | }
161 |
162 | private toLoginPage() {
163 | // router.navigate('/user/login');
164 | this.reset();
165 | window.location.href = '/user/login';
166 | }
167 |
168 | request(config: AxiosRequestConfig): Promise {
169 | return this.axiosInstance(config);
170 | }
171 |
172 |
173 | get(url: string, config?: AxiosRequestConfig): Promise {
174 | return this.axiosInstance.get(url, config);
175 | }
176 |
177 | post(url: string, data?: D, config?: AxiosRequestConfig): Promise {
178 | return this.axiosInstance.post(url, data, config);
179 | }
180 |
181 | put(url: string, data?: D, config?: AxiosRequestConfig): Promise {
182 | return this.axiosInstance.put(url, data, config);
183 | }
184 |
185 | delete(url: string, config?: AxiosRequestConfig): Promise {
186 | return this.axiosInstance.delete(url, config);
187 | }
188 | }
189 |
190 | const request = new Request({ timeout: 60 * 1000 * 5, baseURL: '/api' });
191 |
192 | export default request;
193 |
--------------------------------------------------------------------------------