Browse Source

feat: 角色管理,权限控制,快捷链接

tuonian 1 week ago
parent
commit
d667f0ec86
52 changed files with 1851 additions and 246 deletions
  1. 1 8
      frontend/public/config.js
  2. 49 0
      frontend/src/api/settings.ts
  3. 19 0
      frontend/src/api/user.ts
  4. BIN
      frontend/src/assets/ic_home.png
  5. 11 4
      frontend/src/components/curd/index.tsx
  6. 7 2
      frontend/src/components/curd/types.ts
  7. 27 17
      frontend/src/components/error/Error.tsx
  8. 6 1
      frontend/src/components/error/error.less
  9. 4 0
      frontend/src/components/form/index.ts
  10. 35 0
      frontend/src/components/form/role/index.tsx
  11. 6 0
      frontend/src/components/fullscreen/index.less
  12. 40 0
      frontend/src/components/fullscreen/index.tsx
  13. 1 0
      frontend/src/main.tsx
  14. 2 0
      frontend/src/models/user.ts
  15. 4 0
      frontend/src/pages/error/index.less
  16. 82 4
      frontend/src/pages/error/index.tsx
  17. 0 3
      frontend/src/pages/error/less.less
  18. 58 31
      frontend/src/pages/layout/MainLayout.tsx
  19. 28 0
      frontend/src/pages/layout/layout.less
  20. 17 0
      frontend/src/pages/layout/menu/index.less
  21. 58 0
      frontend/src/pages/layout/menu/index.tsx
  22. 5 5
      frontend/src/pages/ldap/layout.tsx
  23. 46 24
      frontend/src/pages/microApp/index.tsx
  24. 61 34
      frontend/src/pages/nginx/layout.tsx
  25. 26 0
      frontend/src/pages/routes/components/RouteSelect.tsx
  26. 6 0
      frontend/src/pages/routes/index.tsx
  27. 0 0
      frontend/src/pages/routes/list/index.less
  28. 227 0
      frontend/src/pages/routes/list/index.tsx
  29. 1 11
      frontend/src/pages/user/config.ts
  30. 0 0
      frontend/src/pages/user/links/index.less
  31. 178 0
      frontend/src/pages/user/links/index.tsx
  32. 0 0
      frontend/src/pages/user/role/index.less
  33. 124 0
      frontend/src/pages/user/role/index.tsx
  34. 64 32
      frontend/src/routes/hooks.ts
  35. 4 7
      frontend/src/routes/index.tsx
  36. 36 17
      frontend/src/routes/routes.tsx
  37. 20 6
      frontend/src/routes/wrap.tsx
  38. 2 0
      frontend/src/store/root.ts
  39. 22 0
      frontend/src/store/slice/settings.ts
  40. 46 33
      frontend/src/store/slice/user.ts
  41. 2 0
      server/db/db.go
  42. 1 0
      server/init/init.go
  43. 180 0
      server/init/sql.go
  44. 23 0
      server/models/settings.go
  45. 22 6
      server/models/user.go
  46. 67 0
      server/modules/settings/route_controller.go
  47. 91 0
      server/modules/settings/route_service.go
  48. 7 0
      server/modules/settings/vo.go
  49. 8 0
      server/modules/user/controller.go
  50. 60 0
      server/modules/user/role_controller.go
  51. 58 0
      server/modules/user/role_service.go
  52. 9 1
      server/routers/router.go

+ 1 - 8
frontend/public/config.js

@@ -3,11 +3,4 @@ window.CONFIG = {
     baseApi: '/api/nginx-ui/api',
     SSO: true
 }
-
-window.MICRO_APPS = [
-    {
-        name: 'LOGIN',
-        url: 'http://localhost:5173/login',
-        title: '登录测试'
-    }
-]
+window.MICRO_APPS = []

+ 49 - 0
frontend/src/api/settings.ts

@@ -1,5 +1,6 @@
 import request from "./request.ts";
 import {BaseResp} from "../models/api.ts";
+import {useEffect, useState} from "react";
 
 
 // eslint-disable-next-line @typescript-eslint/no-namespace
@@ -15,6 +16,27 @@ export namespace Settings {
         active?: boolean
     }
 
+    export type Route = {
+        id: string
+        title: string
+        path: string
+        navLink?: string
+        target: 'APP' | 'TARGET' | 'TAB'
+        type: 'MENU' | 'NAV' | 'LINK'
+        index: boolean
+        pid: string
+        uid: number
+        roles?: string
+        brief?: string
+        children?: Settings.Route[]
+        deleted?: boolean
+    }
+
+    export type RouteQuery = {
+        uid: number,
+        type?: 'MENU' | 'NAV' | 'LINK'
+        nonType?: 'MENU' | 'NAV' | 'LINK'
+    }
 
 }
 
@@ -32,4 +54,31 @@ export const SettingsApis = {
     removeSettings: (id: number) => {
         return request.post<BaseResp<void>>(`/settings/remove`, {id})
     },
+
+    queryRoutes: (query: Settings.RouteQuery) => {
+        return request.post<BaseResp<Settings.Route[]>>('/settings/route/list', query)
+    },
+    saveRoute: (data: Partial<Settings.Route>) => {
+        return request.post<BaseResp<Settings.Route>>('/settings/route/save', data)
+    },
+    removeRoute: (id: string) => {
+        return request.post<BaseResp<void>>(`/settings/route/remove`, {id})
+    },
+}
+
+
+/**
+ * 获取菜单信息
+ * @param query
+ */
+export const useSettingRoute = (query: Settings.RouteQuery) => {
+    const [routes, setRoutes] = useState<Settings.Route[]>([])
+    useEffect(() => {
+        SettingsApis.queryRoutes(query)
+            .then(res => {
+                console.log('res', res.data)
+                setRoutes(res.data.data || [])
+            })
+    }, [query])
+    return routes
 }

+ 19 - 0
frontend/src/api/user.ts

@@ -25,6 +25,15 @@ export namespace User {
         password?: string
         authCode?: string
     }
+
+    export type Role = {
+        id: number
+        code: string
+        title: string
+        brief: string
+        refCount: number
+        enable: boolean
+    }
 }
 
 /**
@@ -65,4 +74,14 @@ export const userApis = {
     save: (data: Partial<User>) => request2.post<User>(`/user/save`, data),
     getList: (req:PageReq) => request2.post<PageData<User>>('/user/list', req),
     updateMyInfo: (user: Partial<User>) => request2.post<User>(`/user/info`, user),
+
+    queryRoles: (query: any) => {
+        return request.post<BaseResp<User.Role[]>>('/role/list', query)
+    },
+    saveRole: (data: Partial<User.Role>) => {
+        return request.post<BaseResp<User.Role>>('/role/save', data)
+    },
+    removeRole: (id: number) => {
+        return request.post<BaseResp<void>>(`/role/remove`, {id})
+    },
 }

BIN
frontend/src/assets/ic_home.png


+ 11 - 4
frontend/src/components/curd/index.tsx

@@ -1,5 +1,5 @@
-import {AutoColumn, isNullOrTrue, Message} from "auto-antd";
-import {Button, Drawer, Table, TableProps} from "antd";
+import {AutoColumn, AutoText, isNullOrTrue, Message} from "auto-antd";
+import {Button, Drawer, Switch, Table, TableProps} from "antd";
 import React, {useEffect, useMemo, useRef, useState} from "react";
 import {ColumnsType} from "antd/lib/table";
 import {CurdForm, ICurdForm, IProps as IFormProps} from "./Form.tsx";
@@ -104,12 +104,19 @@ export function CurdPage<T extends ICurdData>(
     const tableColumns = useMemo(() => {
         const cols = columns.filter(item => !item.hidden)
             .map((column: CurdColumn) => {
-                return {
+                const col = {
                     ...column,
                     dataIndex: column.key,
                     title: column.title,
                     width: column.width,
                 }
+                if (col.type == 'switch' && !col.render){
+                    col.render = (value: boolean) => <Switch checked={value} disabled/>
+                }
+                if (col.type == 'select' && !col.render){
+                    col.render = (value: boolean) => <AutoText value={value} column={{...col}}/>
+                }
+                return col;
             }) as ColumnsType<T>
         cols.push({
             dataIndex: "",
@@ -130,7 +137,7 @@ export function CurdPage<T extends ICurdData>(
                         operation?.delete ? (<Button size="small" danger={true} icon={<DeleteOutlined/>}
                                                      onClick={() => handleDelete(record)}/>) : null
                     }
-                    {config?.operationRender?.(record, index)}
+                    {config?.operationRender?.(record, index, { handleDelete })}
                 </div>)
             }
         })

+ 7 - 2
frontend/src/components/curd/types.ts

@@ -2,16 +2,21 @@ import type * as React from "react";
 
 
 export type ICurdData = {
-    id?: number
+    id?: number | string
     [key: string]: any
 }
 
+export type ICurdInstance<D> = {
+
+    handleDelete: (data: D) => Promise<void>
+}
+
 export type ICurdConfig<T extends ICurdData> = {
     editDialogWidth?: number
     labelSpan?: number // Label占据的比例
     editable?: boolean // 展示编辑按钮
     details?: boolean // 展示详情按钮
-    operationRender?: (record: T, index: number) => React.ReactNode;
+    operationRender?: (record: T, index: number, instance: ICurdInstance<T>) => React.ReactNode;
     rowKey?: string
     hideAdd?: boolean
     operationWidth?: number

+ 27 - 17
frontend/src/components/error/Error.tsx

@@ -2,18 +2,21 @@ import './error.less'
 import {useNavigate} from "react-router";
 import {Button, Result, Typography} from "antd";
 import {CloseCircleOutlined} from "@ant-design/icons";
-import {useEffect, useMemo, useRef, useState} from "react";
+import {useEffect, useRef, useState} from "react";
 
 const {Paragraph, Text} = Typography;
 
 type IProps = {
     error?: any
     title?: string
+    noRedirect?: boolean
 }
 
-export const ErrorComponent = ({error, title}: IProps) => {
+export const ErrorComponent = ({error, title, noRedirect}: IProps) => {
 
     const [countdown, setCountdown] = useState(5);
+    const [details, setDetails] = useState<string>()
+    const [subTitle, setSubTitle] = useState<string>()
     const timer = useRef<any>(0);
     const navigate = useNavigate()
 
@@ -22,17 +25,21 @@ export const ErrorComponent = ({error, title}: IProps) => {
         navigate(-1)
     }
 
-    const errMsg = useMemo(() => {
+    useEffect(() => {
         if (typeof error === "string") {
-            return error as string
-        }
-        if (error instanceof Error) {
-            return error.message || '服务异常'
+            setSubTitle(error)
+        } else if (error instanceof Error) {
+            setSubTitle(error.message || '系统开小差了,请稍后重试!')
+            setDetails(error.stack)
+        } else {
+            setSubTitle('系统开小差了,请稍后重试!')
         }
-        return JSON.stringify(error, null, 2)
-    }, [error])
+    }, [error]);
 
     useEffect(() => {
+        if (noRedirect) {
+            return
+        }
         clearTimeout(timer.current)
         timer.current = setTimeout(() => {
             if (countdown > 0) {
@@ -41,7 +48,7 @@ export const ErrorComponent = ({error, title}: IProps) => {
                 onBack()
             }
         }, 1000)
-    }, [countdown])
+    }, [countdown, noRedirect])
 
 
     return (
@@ -49,12 +56,13 @@ export const ErrorComponent = ({error, title}: IProps) => {
             <Result
                 status="error"
                 title={title ?? '服务开小差了'}
-                subTitle={errMsg}
-                extra={[
-                    <Button onClick={onBack} type="primary" key="console">
-                        {countdown}秒后返回上一页
-                    </Button>,
-                ]}
+                subTitle={subTitle}
+                extra={noRedirect ? [] :
+                    [
+                        <Button onClick={onBack} type="primary" key="console">
+                            {countdown}秒后返回上一页
+                        </Button>,
+                    ]}
             >
                 <div className="desc">
                     <Paragraph>
@@ -68,7 +76,9 @@ export const ErrorComponent = ({error, title}: IProps) => {
                         </Text>
                     </Paragraph>
                     <Paragraph>
-                        <CloseCircleOutlined className="site-result-demo-error-icon"/> {errMsg}
+                        <CloseCircleOutlined className="site-result-demo-error-icon"/>
+                        <div style={{whiteSpace: "pre-line"}}
+                             dangerouslySetInnerHTML={{__html: details || '<span>--</span>'}}/>
                     </Paragraph>
                 </div>
             </Result>

+ 6 - 1
frontend/src/components/error/error.less

@@ -1 +1,6 @@
-.ErrorBoundary{}
+.ErrorBoundary{
+
+  .site-result-demo-error-icon{
+    white-space: pre-line;
+  }
+}

+ 4 - 0
frontend/src/components/form/index.ts

@@ -0,0 +1,4 @@
+import {registerInput} from "../../pages/nginx/components/basic";
+import {RoleSelect} from "./role";
+
+registerInput('roleSelect',RoleSelect)

+ 35 - 0
frontend/src/components/form/role/index.tsx

@@ -0,0 +1,35 @@
+import {AutoTypeInputProps, SelectInput} from "auto-antd";
+import {useEffect, useMemo, useState} from "react";
+import {User, userApis} from "../../../api/user.ts";
+import {SelectColumn} from "auto-antd/dist/esm/Model/column";
+
+export const RoleSelect = ({column, ...props}: AutoTypeInputProps) => {
+    const [roles, setRoles] = useState<User.Role[]>([])
+
+    useEffect(() => {
+        userApis.queryRoles({})
+            .then(res => {
+                setRoles(res.data.data || [])
+            })
+    }, [])
+
+    const config = useMemo(() => {
+        const list = roles.map(item => {
+            return {
+                label: item.title,
+                value: item.code
+            }
+        })
+        return {
+            ...column,
+            type: 'select',
+            multiple: true,
+            option: list,
+            valueAsString: true,
+        } as SelectColumn
+    }, [column, roles])
+
+    return <SelectInput {...props} column={config}
+                        style={{width: "100%"}}/>
+
+}

+ 6 - 0
frontend/src/components/fullscreen/index.less

@@ -0,0 +1,6 @@
+.full-screen-exit{
+  position: fixed;
+  transform: translateY(48px);
+  transition: all .3s;
+  opacity: 0.8;
+}

+ 40 - 0
frontend/src/components/fullscreen/index.tsx

@@ -0,0 +1,40 @@
+import {useAppDispatch, useAppSelector} from "../../store";
+import {settingsActions} from "../../store/slice/settings.ts";
+import {Button} from "antd";
+import './index.less'
+import {FullscreenExitOutlined, FullscreenOutlined} from "@ant-design/icons";
+import {useState} from "react";
+
+
+export const Fullscreen = () => {
+
+    const fullScreen = useAppSelector(state => state.settings.fullScreen)
+    const dispatch = useAppDispatch()
+    const [pageX,setPageX] = useState(10)
+    const [pageY, setPageY] = useState(10)
+
+    const setFullScreen = (full: boolean) => {
+        dispatch(settingsActions.setFullScreen(full))
+    }
+
+    const onDragEnd = (e:any)=>{
+        console.log("onDragEnd",e)
+        setPageX(e.pageX)
+        setPageY(e.pageY)
+    }
+
+    if (!fullScreen) {
+        return (
+            <Button icon={<FullscreenOutlined />} onClick={()=>setFullScreen(true)}
+                    onDragEnd={onDragEnd}
+                    style={{ marginLeft: 10,marginRight: 10, color: "#fff" }}
+                    type="text" draggable={true} />
+        )
+    }
+    return (<div className="full-screen-exit" style={{ top: pageY, left: pageX }}>
+        <Button icon={<FullscreenExitOutlined />} onClick={()=>setFullScreen(false)}
+                onDragEnd={onDragEnd}
+                type="primary" draggable={true} />
+    </div>)
+}
+

+ 1 - 0
frontend/src/main.tsx

@@ -5,6 +5,7 @@ import App from './App.tsx'
 import './index.css'
 import './styles/index.less'
 import renderWithQiankun from "vite-plugin-qiankun/es/helper";
+import './components/form/index.ts'
 
 let root: Root | null
 

+ 2 - 0
frontend/src/models/user.ts

@@ -1,4 +1,5 @@
 import {ICurdData} from "../components/curd/types.ts";
+import {Settings} from "../api/settings.ts";
 
 /**
  * 用户
@@ -13,4 +14,5 @@ export type User = ICurdData & {
      * 缓存时间
      */
     timestamp: number
+    routes?: Settings.Route[]
 }

+ 4 - 0
frontend/src/pages/error/index.less

@@ -0,0 +1,4 @@
+.error-page{
+  box-sizing: border-box;
+  padding-top: 10%;
+}

+ 82 - 4
frontend/src/pages/error/index.tsx

@@ -2,12 +2,90 @@
  * @author tuonian
  * @date 2023/12/18
  */
-import './less.less'
+import './index.less'
+import {useEffect, useMemo, useRef, useState} from "react";
+import {useNavigate, useRouteError} from "react-router";
+import {Button, Result, Typography} from "antd";
+import {CloseCircleOutlined} from "@ant-design/icons";
+const { Paragraph, Text } = Typography;
 
+type IProps = {
+  type?: 'NOT_FOUND'
+}
+
+export const ErrorPage = ({ type }: IProps) => {
+
+  const [countdown, setCountdown] = useState(10);
+  const timer = useRef<any>(0);
+  const error = useRouteError()
+  // @ts-ignore
+  const [details,setDetails] = useState<string>()
+  const navigate = useNavigate()
+
+  const onBack = () => {
+    clearTimeout(timer.current)
+    navigate(-1)
+  }
+
+  const errMsg = useMemo(()=>{
+    if (typeof error === "string"){
+      return error as string
+    }
+    if (error instanceof Error){
+      return error.message || '服务异常'
+    }
+    return JSON.stringify(error, null, 2)
+  },[error])
+
+  useEffect(()=>{
+    clearTimeout(timer.current)
+    timer.current = setTimeout(()=>{
+      if (countdown > 0){
+        setCountdown(c=>c -1)
+      }else {
+        onBack()
+      }
+    },1000)
+  },[countdown])
 
-export const ErrorPage = () => {
+  const title = useMemo(()=>{
+    if (type == 'NOT_FOUND'){
+      return '页面走丢了'
+    }
+    return '服务出错了'
+  },[type])
 
-  return (<div className="error-page">
 
-  </div>)
+  return (
+      <div className="error-page">
+        <Result
+            status="error"
+            title={ title }
+            subTitle={errMsg}
+            extra={[
+              <Button onClick={onBack} type="primary" key="console">
+                {countdown}秒后返回上一页
+              </Button>,
+            ]}
+        >
+          {
+            details ? <div className="desc">
+              <Paragraph>
+                <Text
+                    strong
+                    style={{
+                      fontSize: 16,
+                    }}
+                >
+                  错误信息如下:
+                </Text>
+              </Paragraph>
+              <Paragraph>
+                <CloseCircleOutlined className="site-result-demo-error-icon"/> {errMsg}
+              </Paragraph>
+            </div> : null
+          }
+        </Result>
+      </div>
+  )
 }

+ 0 - 3
frontend/src/pages/error/less.less

@@ -1,3 +0,0 @@
-.error-page{
-
-}

+ 58 - 31
frontend/src/pages/layout/MainLayout.tsx

@@ -8,20 +8,31 @@ import {DownOutlined} from "@ant-design/icons";
 import {ModifyPassword} from "../user/components/password";
 import {LogoutComponent} from "../user/components/logout";
 import {UserInfoDialog} from "../user/info";
-import {useMicroApps, useNavList} from "../../routes/hooks.ts";
+import HomeIcon from '../../assets/ic_home.png'
+import {Fullscreen} from "../../components/fullscreen";
+import {ItemType} from "antd/es/menu/hooks/useItems";
+import {Settings} from "../../api/settings.ts";
 
 const BreadcrumbItem = Breadcrumb.Item
 
 const {Header, Content} = Layout;
 
 
+type INavList = ItemType & {
+    data: Settings.Route
+    key: string
+    label: string
+    path: string
+}
+
 export const MainLayout = () => {
 
-    const _navList = useNavList()
-    const microApps = useMicroApps()
-    const [nav, setNav] = useState('')
+    const _navList = useAppSelector(state => state.user.navList)
+    const [nav, setNav] = useState<INavList>()
     const navigate = useNavigate()
 
+    const fullScreen = useAppSelector(state => state.settings.fullScreen)
+
     const matches = useMatches()
     const user = useAppSelector(state => state.user.user)
 
@@ -31,38 +42,52 @@ export const MainLayout = () => {
 
 
     const navList = useMemo(() => {
-        return _navList.concat(microApps.map(item => {
+        return _navList.map(item => {
             return {
+                key: item.id,
                 label: item.title,
-                key: `micro/${item.name}`,
-                roles: []
-            }
-        }))
-    }, [_navList, microApps])
-
-    const handleSetNav = (key: string) => {
-        setNav(key)
-        navigate(key)
-        console.log('handleSetNav', key)
+                data: item,
+                path: item.navLink || item.path
+            } as INavList
+        })
+    }, [_navList])
+
+    const handleSetNav = ( menu: INavList) => {
+        if (!menu.data){
+            return
+        }
+        if (menu.data.target == 'TARGET'){
+            window.open(menu.data.path)
+        }else if (menu.data.target == 'APP'){
+            setNav(menu)
+            navigate(`app/${menu.key}`)
+        }else {
+            setNav(menu)
+            navigate(menu.path)
+        }
+        console.log('handleSetNav', menu)
+    }
+
+    const handleMenuClick = (key: string) => {
+        const nav = navList.find(item=>item.key == key);
+        if (nav){
+            handleSetNav(nav)
+        }
     }
 
     useEffect(() => {
-        if (nav || !matches.length){
+        if (nav || !matches.length) {
             return
         }
         const first = matches[0]
-        const active = navList.find(item=>item.key == first.pathname);
-        if (active){
-            handleSetNav(active.key)
-        }else if (navList.length){
-            handleSetNav(navList[0].key)
+        const active = navList.find(item => item.key == first.pathname);
+        if (active) {
+            handleSetNav(active)
+        } else if (navList.length) {
+            handleSetNav(navList[0])
         }
     }, [matches, nav, navList]);
 
-
-
-
-
     const personMenus = useMemo(() => {
         const items: MenuProps['items'] = [
             {
@@ -88,19 +113,21 @@ export const MainLayout = () => {
 
 
     return (
-        <Layout className="customLayout">
+        <Layout className={`customLayout ${fullScreen ? 'fullScreen' : 'nonFullScreen'}`}>
             <Header style={{display: 'flex', alignItems: 'center'}}>
-                <div className="demo-logo"/>
+                <div className="demo-logo">
+                    <img className="home-icon" src={HomeIcon} alt="logo"/>
+                </div>
                 <Menu
                     theme="dark"
                     mode="horizontal"
-                    selectedKeys={[nav]}
-                    activeKey={nav}
+                    selectedKeys={nav ? [nav.key]: undefined}
+                    activeKey={nav?.key}
                     items={navList}
                     style={{flex: 1, minWidth: 0}}
-                    onClick={event => handleSetNav(event.key)}
+                    onClick={event => handleMenuClick(event.key)}
                 />
-                <div style={{flex: 1}}/>
+                <Fullscreen/>
                 <Dropdown menu={personMenus}>
                     <a onClick={e => e.preventDefault()}>
                         <Space style={{color: '#3f94e4'}}>

+ 28 - 0
frontend/src/pages/layout/layout.less

@@ -2,11 +2,39 @@
 .customLayout{
   height: 100%;
   overflow: hidden;
+  position: relative;
 }
 .ant-layout.customLayout{
   height: 100%;
   overflow: hidden;
+  .ant-layout-header{
+    height: 48px;
+    line-height: 48px;
+  }
   .ant-layout-content{
 
   }
+  .demo-logo{
+    margin-left: -40px;
+    margin-right: 15px;
+    padding: 0 16px;
+    cursor: pointer;
+  }
+  .home-icon{
+    width: 24px;
+    height: 24px;
+  }
+
+  &.fullScreen{
+    .ant-layout-header{
+      transform: translateY(-100%);
+      transition: transform 0.5s;
+    }
+  }
+  &.nonFullScreen{
+    .ant-layout-header{
+      transform: translateY(0);
+      transition: transform 0.5s;
+    }
+  }
 }

+ 17 - 0
frontend/src/pages/layout/menu/index.less

@@ -0,0 +1,17 @@
+.menu-layout{
+  display: flex;
+  height: 100vh;
+  width: 100%;
+  overflow: hidden;
+  flex-direction: row;
+  &-menu{
+    width: 220px;
+    height: 100%;
+    overflow-y: auto;
+    background: white;
+  }
+  &-content{
+    flex: 1;
+    overflow: auto;
+  }
+}

+ 58 - 0
frontend/src/pages/layout/menu/index.tsx

@@ -0,0 +1,58 @@
+import {useRoleData} from "../../../routes/hooks.ts";
+import {useEffect, useMemo, useState} from "react";
+import {RouteObjectData} from "../../../routes/types.ts";
+import {Menu, MenuProps} from "antd";
+import './index.less'
+import {Outlet, useNavigate} from "react-router";
+
+
+type IProps = {
+    routeId: string;
+}
+
+export const MenuLayout = ({routeId}: IProps) => {
+
+
+    const routeData = useRoleData(routeId)
+    const navigate = useNavigate()
+    const [active,setActive] = useState<RouteObjectData>()
+
+    useEffect(() => {
+        console.log('routeData', routeData)
+    }, [routeData])
+
+    const menus = useMemo(() => {
+        return (routeData.children || []).map((item: RouteObjectData) => {
+            return {
+                label: item.label || item.path,
+                key: item.path,
+                data: item
+            }
+        }) as MenuProps['items']
+
+    }, [routeData])
+
+    const onMenuClick = (item: any) => {
+        console.log('menuClick', item)
+        navigate(item.key)
+        setActive(item.data)
+    }
+
+    useEffect(() => {
+        if (menus?.length){
+            onMenuClick(menus[0])
+        }
+    }, [menus]);
+
+
+    return (<div className="menu-layout">
+        <div className="menu-layout-menu">
+            <Menu items={menus} onClick={onMenuClick}
+                  activeKey={active?.path}/>
+        </div>
+        <div className="menu-layout-content">
+            <Outlet />
+        </div>
+
+    </div>)
+}

+ 5 - 5
frontend/src/pages/ldap/layout.tsx

@@ -5,18 +5,16 @@ import {List as OrganizeList} from './organize/list'
 import {LDAPApis} from "../../api/ldap.ts";
 import {registerInput} from "../nginx/components/basic";
 import {OrganizeTreeSelect} from "./organize/components/select.tsx";
-import {RouteObjectData} from "../../routes/types.ts";
+import {RouteObject} from "react-router/dist/lib/context";
 
 registerInput('organize', OrganizeTreeSelect)
 
-export const ldapRoutes: RouteObjectData[] = [
+export const ldapRoutes: RouteObject[] = [
     {
+        id: 'LDAP',
         path: "ldap",
         element: <Server/>,
         index: true,
-        label: 'LDAP管理',
-        roles: ['ADMIN'],
-        nav: true,
     },
     {
         path: 'ldap/server/:id',
@@ -28,11 +26,13 @@ export const ldapRoutes: RouteObjectData[] = [
         },
         children: [
             {
+                id: 'LDAP_USERS',
                 index: true,
                 path: 'user',
                 Component: List
             },
             {
+                id: 'LDAP_ORGANIZE_LIST',
                 path: 'organize',
                 Component: OrganizeList
             }

+ 46 - 24
frontend/src/pages/microApp/index.tsx

@@ -1,53 +1,75 @@
 import WujieReact from 'wujie-react'
 import './index.less'
 import {useParams} from "react-router";
-import {useMicroApps} from "../../routes/hooks.ts";
-import {useEffect, useState} from "react";
-import {MicroAppType} from "../../routes/types.ts";
+import {useCallback, useEffect, useMemo, useState} from "react";
 import {ErrorComponent} from "../../components/error/Error.tsx";
+import {useAppDispatch, useAppSelector} from "../../store";
+import {LoadingText} from "../../components/loading";
+import {settingsActions} from "../../store/slice/settings.ts";
 
 // 腾讯无界: https://wujie-micro.github.io/doc/guide/start.html
 export const MicroAppPage = () => {
 
-    const params = useParams<{ name: string }>()
-    const [app, setApp] = useState<MicroAppType>()
+    const params = useParams<{ id: string }>()
     const [error, setError] = useState<any>()
+    const [loading, setLoading] = useState(false)
 
-    const appList = useMicroApps()
+    const routeMap = useAppSelector(state => state.user.routeMap)
+    const dispatch = useAppDispatch();
 
-    useEffect(() => {
-        const microApp = appList.find(item => item.name == params.name);
-        if (microApp) {
-            setApp(microApp)
-        } else {
-            setApp(undefined)
-        }
-    }, [appList, params.name]);
+    const setFullScreen = (full:boolean) => {
+        dispatch(settingsActions.setFullScreen(full))
+    }
+
+    const app = useMemo(() => {
+        return params?.id ? routeMap[params.id] : undefined
+    }, [routeMap, params.id])
 
-    console.log('MicroAppPage', params)
     const onActivated = (e: any) => {
         console.log('activated', e)
     }
 
-    const onLoadFail = (e: any) => {
-        console.log('load fail', e)
+    const onLoadFail = (url: any, e: any) => {
+        setLoading(false)
+        console.log('load fail', url)
         setError(e)
     }
     const afterMounted = () => {
-        console.log('mounted', app)
+        console.log('afterMounted', app)
     }
 
-    return (<div className="micro-app">
-        {
-            app ? <WujieReact
-                    name={app.name}
-                    url={app.url}
+    const renderContent = useCallback(() => {
+        if (error) {
+            return <ErrorComponent noRedirect={true} error={error}/>
+        }
+        if (app) {
+            return (
+                <WujieReact
+                    name={app.title}
+                    url={app.path}
                     height="100%"
                     width="100%"
+                    beforeMount={() => setLoading(false)}
                     afterMount={afterMounted}
                     loadError={onLoadFail}
                     activated={onActivated}/>
-                : <ErrorComponent error={error}/>
+            )
+        }
+        return null
+        //     @ts-ignore
+    }, [error, app])
+
+    useEffect(() => {
+        setFullScreen(true)
+        return ()=>{
+            setFullScreen(false)
+        }
+    }, []);
+
+    return (<div className="micro-app">
+        <LoadingText loading={loading} text="加载中,请稍后..."/>
+        {
+            renderContent()
         }
     </div>)
 }

+ 61 - 34
frontend/src/pages/nginx/layout.tsx

@@ -10,90 +10,117 @@ import {ServerLocation} from "./location";
 import {NewLocation} from "./location/new.tsx";
 import {NewServer} from "./server/new.tsx";
 import {HelpPage} from "./help";
-import React from "react";
 import type {RouteObject} from "react-router/dist/lib/context";
 import {NginxList} from "./list.tsx";
 
-type INginxRoute = {
-    path: string,
-    component: React.FC,
-    index?: boolean,
-    children?: INginxRoute[]
-}
-
-const nginxRoutes: INginxRoute[] = [
+/**
+ * useMatches 只能匹配到顶级路由,二级路由无法匹配
+ */
+const nginxRoutes: RouteObject[] = [
     {
         path: "",
-        component: NginxList,
+        Component: NginxList,
         index: true,
+        handle: {
+            authId: 'NGINX_LAYOUT'
+        }
     },
     {
         path: ':id',
-        component: Nginx,
+        Component: Nginx,
+        handle: {
+            authId: 'NGINX_LAYOUT'
+        },
         children: [
             {
                 index: true,
                 path: '',
-                component: NginxSettings
+                Component: NginxSettings,
+                handle: {
+                    authId: 'NGINX_LAYOUT'
+                }
             },
             {
                 path: 'http',
-                component: NginxHttp
+                Component: NginxHttp,
+                handle: {
+                    authId: 'NGINX_LAYOUT'
+                }
             },
             {
                 path: 'certs',
-                component: NginxCerts
+                Component: NginxCerts,
+                handle: {
+                    authId: 'NGINX_LAYOUT'
+                }
             },
             {
                 path: 'upstream',
-                component: NginxUpstream
+                Component: NginxUpstream,
+                handle: {
+                    authId: 'NGINX_LAYOUT'
+                }
             },
             {
                 path: 'stream',
-                component: NginxStream
+                Component: NginxStream,
+                handle: {
+                    authId: 'NGINX_LAYOUT'
+                }
             },
             {
                 path: 'server/:sid',
-                component: NginxServer
+                Component: NginxServer,
+                handle: {
+                    authId: 'NGINX_LAYOUT'
+                }
             },
             {
                 path: 'server/:sid/conf',
-                component: NginxServer
+                Component: NginxServer,
+                handle: {
+                    authId: 'NGINX_LAYOUT'
+                }
             },
             {
                 path: 'server/:sid/location/:locId',
-                component: ServerLocation
+                Component: ServerLocation,
+                handle: {
+                    authId: 'NGINX_LAYOUT'
+                }
             },
             {
                 path: 'server/:sid/location-new',
-                component: NewLocation
+                Component: NewLocation,
+                handle: {
+                    authId: 'NGINX_LAYOUT'
+                }
             },
             {
                 path: 'server-new',
-                component: NewServer
+                Component: NewServer,
+                handle: {
+                    authId: 'NGINX_LAYOUT'
+                }
             },
             {
                 path: 'help',
-                component: HelpPage
+                Component: HelpPage,
+                handle: {
+                    authId: 'NGINX_LAYOUT'
+                }
             }
         ]
     },
     {
         path: 'help',
-        component: HelpPage
+        Component: HelpPage,
+        handle: {
+            authId: 'NGINX_LAYOUT'
+        }
     }
 ]
 
-// @ts-ignore
-const buildRoutes = (r: INginxRoute) => {
-    return {
-        path: r.path,
-        Component: r.component,
-        children: (r.children || []).map(child => buildRoutes(child)),
-        index: r.index,
-    } as RouteObject
-}
-
 export const NginxLayout = () => {
-    return useRoutes(nginxRoutes.map(r => buildRoutes(r)))
+    return useRoutes(nginxRoutes)
 }

+ 26 - 0
frontend/src/pages/routes/components/RouteSelect.tsx

@@ -0,0 +1,26 @@
+import {AutoTypeInputProps} from "auto-antd";
+import {Settings, useSettingRoute} from "../../../api/settings.ts";
+import {Select} from "antd";
+import {useMemo, useRef} from "react";
+
+
+export const RouteSelect = ({ value, onChange, column}:AutoTypeInputProps) => {
+
+    const query = useRef<Settings.RouteQuery>({ uid: 0, nonType: 'LINK', ...(column as any).query });
+
+    const routes = useSettingRoute(query.current)
+    const options = useMemo(()=>{
+        const list = routes.map(item=>{
+            return {
+                label: `${item.title}(${item.path})`,
+                value: item.id
+            }
+        })
+        return [{ label: '顶级菜单', value: ""}].concat(list)
+    },[routes])
+
+    return <Select value={value} options={options} onChange={onChange}
+                   style={{ width: "100%" }}
+                   allowClear={true}/>
+
+}

+ 6 - 0
frontend/src/pages/routes/index.tsx

@@ -0,0 +1,6 @@
+import {registerInput} from "../nginx/components/basic";
+import {RouteSelect} from "./components/RouteSelect.tsx";
+
+export { List as RouteList } from './list'
+
+registerInput('routeSelect', RouteSelect)

+ 0 - 0
frontend/src/pages/routes/list/index.less


+ 227 - 0
frontend/src/pages/routes/list/index.tsx

@@ -0,0 +1,227 @@
+import {useCallback, useMemo, useState} from "react";
+import {Alert, Button} from "antd";
+import './index.less'
+import {CurdColumn, CurdPage} from "../../../components/curd";
+import {ICurdConfig} from "../../../components/curd/types.ts";
+import {Settings, SettingsApis} from "../../../api/settings.ts";
+import {PageData} from "../../../models/api.ts";
+import {DeleteOutlined} from "@ant-design/icons";
+
+const columns: CurdColumn[] = [
+    {
+        key: 'pid',
+        title: '上级菜单',
+        type: 'routeSelect',
+        hidden: true,
+        addable: true,
+    },
+    {
+        key: 'id',
+        title: 'ID',
+        type: 'string',
+        required: true,
+        width: 220,
+        addable: true,
+    },
+    {
+        key: 'title',
+        title: '菜单名称',
+        type: 'string',
+        editable: true,
+        placeholder: '菜单名称',
+        required: true,
+        addable: true,
+    },
+    {
+        key: 'path',
+        title: '路径',
+        type: 'string',
+        required: true,
+    },
+    {
+        key: 'index',
+        title: '是否首页',
+        type: 'switch',
+        required: true,
+        width: 100
+    },
+    {
+        key: 'uid',
+        type: 'number',
+        title: '菜单归属',
+        required: false,
+        placeholder: '0:公共,其它为私人',
+        render: (value: number) => <span>{value > 0 ? '私人' : '公共'}</span>,
+        editable: false,
+        addable: false,
+        width: 100,
+        editHide: true,
+    },
+    {
+        key: 'type',
+        type: 'select',
+        title: '菜单类型',
+        value: 'MENU',
+        width: 100,
+        option: [
+            {
+                label: '菜单',
+                value: 'MENU'
+            },
+            {
+                label: '导航栏',
+                value: 'NAV'
+            },
+            {
+                label: '快捷方式',
+                value: 'LINK'
+            }
+        ]
+    },
+    {
+        key: 'roles',
+        type: 'roleSelect',
+        title: '所需角色',
+        width: 100,
+        required: false,
+    },
+    {
+        key: 'navLink',
+        type: 'string',
+        title: '导航路由',
+        required: false,
+        width: 100
+    },
+    {
+        key: 'target',
+        type: 'select',
+        title: '跳转方式',
+        value: 'TAB',
+        width: 100,
+        option: [
+            {
+                label: '新标签',
+                value: 'TAB',
+            },
+            {
+                label: '微应用',
+                value: 'APP',
+            },
+            {
+                label: '新页面',
+                value: 'TARGET',
+            },
+        ]
+    },
+    {
+        key: 'sortNum',
+        type: 'number',
+        title: '排序',
+        placeholder: '越大越靠前',
+        width: 100
+    },
+    {
+        key: 'brief',
+        type: 'textarea',
+        title: '简介',
+        value: '',
+        required: false,
+        rows: 4
+    }
+]
+
+const serverConfig: ICurdConfig<Settings.Route> = {
+    editDialogWidth: 550,
+    labelSpan: 4,
+    hideAdd: false,
+    bordered: true,
+    operationWidth: 120,
+}
+
+/**
+ * 用户列表的操作
+ * @constructor
+ */
+export const List = () => {
+
+    const [success, setSuccess] = useState('')
+
+    const getList = useCallback((query: any) => {
+
+        return SettingsApis.queryRoutes({
+            ...query,
+            uid: 0,
+        }).then(res => {
+            console.log('res', res.data)
+            const list = res.data.data || []
+            const map: { [key: string]: Settings.Route } = {}
+            list.forEach(item => {
+                map[item.id] = item;
+            })
+            const tree: Settings.Route[] = [];
+            list.forEach(item => {
+                const p = map[item.pid]
+                if (p) {
+                    p.children = p.children || []
+                    p.children.push(item as Settings.Route)
+                } else {
+                    tree.push(item)
+                }
+            })
+
+            return {
+                list: tree,
+                total: (res.data.data || []).length,
+                current: 1,
+                pageSize: 1000,
+            } as PageData<Settings.Route>
+        })
+    }, [])
+
+    const getDetail = (data: Partial<Settings.Route>) => {
+        return Promise.resolve({...data} as Settings.Route)
+    }
+
+    const onSave = (data: Partial<Settings.Route>) => {
+        return SettingsApis.saveRoute({
+            pid: "",
+            uid: 0,
+            deleted: true,
+            ...data,
+        })
+            .then(res => {
+                return res.data.data as Settings.Route;
+            })
+    }
+
+    const handleDelete = (record: Settings.Route) => {
+        return SettingsApis.removeRoute(record.id)
+            .then(res => res.data)
+    }
+
+
+    const config = useMemo(() => {
+
+        return {
+            ...serverConfig,
+            operationRender: (record: Settings.Route, _, instance) => (<>
+                {record.deleted ? <Button size="small" danger={true} icon={<DeleteOutlined/>}
+                                          onClick={() => instance.handleDelete(record)}/> : null}
+            </>)
+        } as ICurdConfig<Settings.Route>
+    }, [])
+
+
+    return (<>
+        {
+            success ? (<Alert type="success" message={success} style={{margin: 5}} closable={true}
+                              onClose={() => setSuccess('')}/>) : null
+        }
+        <CurdPage columns={columns} getList={getList} getDetail={getDetail}
+                  operationRender={<></>}
+                  operation={{delete: false, add: false}}
+                  onSave={onSave}
+                  onDelete={handleDelete}
+                  config={config}/>
+    </>)
+}

+ 1 - 11
frontend/src/pages/user/config.ts

@@ -42,18 +42,8 @@ export const userColumns: CurdColumn[] = [
     {
         key: 'roles',
         title: '角色',
-        type: 'select',
+        type: 'roleSelect',
         width: 150,
-        option: [
-            {
-                value: 'ADMIN',
-                label: '管理员'
-            },
-            {
-                value: 'USER',
-                label: '用户'
-            }
-        ],
         required: false,
     },
     {

+ 0 - 0
frontend/src/pages/user/links/index.less


+ 178 - 0
frontend/src/pages/user/links/index.tsx

@@ -0,0 +1,178 @@
+import {useCallback, useMemo, useState} from "react";
+import {Alert} from "antd";
+import './index.less'
+import {CurdColumn, CurdPage} from "../../../components/curd";
+import {ICurdConfig} from "../../../components/curd/types.ts";
+import {Settings, SettingsApis} from "../../../api/settings.ts";
+import {PageData} from "../../../models/api.ts";
+
+const columns: CurdColumn[] = [
+    {
+        key: 'id',
+        title: 'ID',
+        type: 'string',
+        required: true,
+        width: 220,
+        addable: true,
+    },
+    {
+        key: 'title',
+        title: '菜单名称',
+        type: 'string',
+        editable: true,
+        placeholder: '菜单名称',
+        required: true,
+        addable: true,
+    },
+    {
+        key: 'path',
+        title: '路径',
+        type: 'string',
+        required: true,
+    },
+    {
+        key: 'type',
+        type: 'select',
+        title: '菜单类型',
+        value: 'MENU',
+        width: 100,
+        option: [
+            {
+                label: '导航栏',
+                value: 'NAV'
+            },
+            {
+                label: '快捷方式',
+                value: 'LINK'
+            }
+        ]
+    },
+    {
+        key: 'target',
+        type: 'select',
+        title: '跳转方式',
+        value: 'TAB',
+        width: 100,
+        option: [
+            {
+                label: '新页面',
+                value: 'TARGET',
+            },
+            {
+                label: '微应用',
+                value: 'APP',
+            },
+            {
+                label: '新标签',
+                value: 'TAB',
+            },
+        ]
+    },
+    {
+        key: 'sortNum',
+        type: 'number',
+        title: '排序',
+        placeholder: '越大越靠前',
+        width: 100
+    },
+    {
+        key: 'brief',
+        type: 'textarea',
+        title: '简介',
+        value: '',
+        required: false,
+        rows: 4
+    }
+]
+
+const serverConfig: ICurdConfig<Settings.Route> = {
+    editDialogWidth: 550,
+    labelSpan: 4,
+    hideAdd: false,
+    bordered: true,
+    operationWidth: 120,
+}
+
+/**
+ * 用户列表的操作
+ * @constructor
+ */
+export const UserLinks = () => {
+
+    const [success, setSuccess] = useState('')
+
+    const getList = useCallback((query: any) => {
+
+        return SettingsApis.queryRoutes({
+            ...query,
+            uid: -1,
+        }).then(res => {
+            console.log('res', res.data)
+            const list = res.data.data || []
+            const map: { [key: string]: Settings.Route } = {}
+            list.forEach(item => {
+                map[item.id] = item;
+            })
+            const tree: Settings.Route[] = [];
+            list.forEach(item => {
+                const p = map[item.pid]
+                if (p) {
+                    p.children = p.children || []
+                    p.children.push(item as Settings.Route)
+                } else {
+                    tree.push(item)
+                }
+            })
+
+            return {
+                list: tree,
+                total: (res.data.data || []).length,
+                current: 1,
+                pageSize: 1000,
+            } as PageData<Settings.Route>
+        })
+    }, [])
+
+    const getDetail = (data: Partial<Settings.Route>) => {
+        return Promise.resolve({...data} as Settings.Route)
+    }
+
+    const onSave = (data: Partial<Settings.Route>) => {
+        return SettingsApis.saveRoute({
+            pid: "",
+            uid: -1,
+            deleted: true,
+            ...data,
+        })
+            .then(res => {
+                return res.data.data as Settings.Route;
+            })
+    }
+
+    const handleDelete = (record: Settings.Route) => {
+        return SettingsApis.removeRoute(record.id)
+            .then(res => res.data)
+    }
+
+
+    const config = useMemo(() => {
+
+        return {
+            ...serverConfig,
+        } as ICurdConfig<Settings.Route>
+    }, [])
+
+
+    return (<>
+        {
+            success ? (<Alert type="success" message={success} style={{margin: 5}} closable={true}
+                              onClose={() => setSuccess('')}/>) : null
+        }
+        <CurdPage columns={columns} getList={getList} getDetail={getDetail}
+                  operationRender={<></>}
+                  operation={{delete: true, add: false}}
+                  onSave={onSave}
+                  onDelete={handleDelete}
+                  config={config}/>
+    </>)
+}

+ 0 - 0
frontend/src/pages/user/role/index.less


+ 124 - 0
frontend/src/pages/user/role/index.tsx

@@ -0,0 +1,124 @@
+import {useCallback, useMemo, useState} from "react";
+import {Alert} from "antd";
+import './index.less'
+import {CurdColumn, CurdPage} from "../../../components/curd";
+import {ICurdConfig} from "../../../components/curd/types.ts";
+import {PageData} from "../../../models/api.ts";
+import {User, userApis} from "../../../api/user.ts";
+
+const columns: CurdColumn[] = [
+    {
+        key: 'id',
+        title: 'ID',
+        type: 'number',
+        required: true,
+        width: 120,
+        addable: false,
+        editable: false,
+    },
+    {
+        key: 'title',
+        title: '角色名称',
+        type: 'string',
+        editable: true,
+        placeholder: '角色名称',
+        required: true,
+        addable: true,
+    },
+    {
+        key: 'code',
+        title: '角色编码',
+        type: 'string',
+        required: true,
+        placeholder: '唯一不可修改',
+        editable: false,
+    },
+    {
+        key: 'enable',
+        title: '是否启用',
+        type: 'switch',
+        required: true,
+        width: 100
+    },
+    {
+        key: 'brief',
+        type: 'textarea',
+        title: '简介',
+        value: '',
+        required: false,
+        rows: 4
+    }
+]
+
+const serverConfig: ICurdConfig<User.Role> = {
+    editDialogWidth: 550,
+    labelSpan: 4,
+    hideAdd: false,
+    bordered: true,
+    operationWidth: 120,
+}
+
+/**
+ * 用户列表的操作
+ * @constructor
+ */
+export const List = () => {
+
+    const [success, setSuccess] = useState('')
+
+    const getList = useCallback((query: any) => {
+
+        return userApis.queryRoles({
+            ...query,
+            uid: 0,
+        }).then(res => {
+            console.log('res', res.data)
+            return {
+                list: res.data.data,
+                total: (res.data.data || []).length,
+                current: 1,
+                pageSize: 1000,
+            } as PageData<User.Role>
+        })
+    }, [])
+
+    const getDetail = (data: Partial<User.Role>) => {
+        return Promise.resolve({...data} as User.Role)
+    }
+
+    const onSave = (data: Partial<User.Role>) => {
+        return userApis.saveRole({
+            ...data,
+        })
+            .then(res => {
+                return res.data.data as User.Role;
+            })
+    }
+
+    const handleDelete = (record: User.Role) => {
+        return userApis.removeRole(record.id)
+            .then(res => res.data)
+    }
+
+
+    const config = useMemo(() => {
+        return {
+            ...serverConfig,
+        } as ICurdConfig<User.Role>
+
+    }, [])
+
+
+    return (<>
+        {
+            success ? (<Alert type="success" message={success} style={{margin: 5}} closable={true}
+                              onClose={() => setSuccess('')}/>) : null
+        }
+        <CurdPage columns={columns} getList={getList} getDetail={getDetail}
+                  operationRender={<></>}
+                  operation={{delete: true, add: false}}
+                  onSave={onSave}
+                  onDelete={handleDelete}
+                  config={config}/>
+    </>)
+}

+ 64 - 32
frontend/src/routes/hooks.ts

@@ -1,47 +1,38 @@
 import {useAppSelector} from "../store";
-import {useMemo} from "react";
+import {useCallback, useMemo} from "react";
 import {MicroAppType, NavItem, RouteObjectData} from "./types.ts";
 import {openRoutes, routes} from "./routes.tsx";
 import {RouteObject} from "react-router/dist/lib/context";
+import {Settings} from "../api/settings.ts";
 
 /**
  * 前者是否包含 后者
  * @param roles
  * @param rRoles
  */
-const hasRoles = (roles: string[] , rRoles: string[] = []) => {
-    if (!rRoles.length){
+const hasRoles = (roles: string[], rRoles: string[] = []) => {
+    if (!rRoles.length) {
         return true
     }
-    return !!rRoles.find((r: string)=>roles.includes(r))
-}
-
-// @ts-ignore
-const mapRouteData = (r: RouteObjectData) => {
-    // @ts-ignore
-    return {
-        ...r,
-        handle: {
-            label: r.label || '',
-            roles: r.roles || [],
-        },
-        children: r.children ? (r.children as RouteObjectData[]).map((item)=>mapRouteData(item)) : undefined
-    } as RouteObjectData
+    return !!rRoles.find((r: string) => roles.includes(r))
 }
 
+/**
+ * 微应用
+ */
 export const useMicroApps = () => {
-    return useMemo(()=>{
+    return useMemo(() => {
         // @ts-ignore
         const microApps = window['MICRO_APPS'] || []
         return microApps as MicroAppType[]
-    },[])
+    }, [])
 }
 
 export const useMicroApp = (name?: string) => {
     const appList = useMicroApps()
-    return useMemo(()=>{
-        return appList.find(item=>item.name == name)
-    },[name,appList])
+    return useMemo(() => {
+        return appList.find(item => item.name == name)
+    }, [name, appList])
 }
 
 /**
@@ -49,7 +40,7 @@ export const useMicroApp = (name?: string) => {
  */
 export const useNavList = () => {
     const user = useAppSelector(state => state.user.user)
-    return useMemo(()=>{
+    return useMemo(() => {
         let navList: NavItem[] = []
         const addNav = (r: RouteObjectData) => {
             if (r.nav) {
@@ -65,24 +56,65 @@ export const useNavList = () => {
             addNav(route)
         })
         const roles = user?.roles?.split(",") || []
-        navList = navList.filter(item=>hasRoles(roles,item.roles))
+        navList = navList.filter(item => hasRoles(roles, item.roles))
         return navList
-    },[user])
+    }, [user])
 }
 
 export const useDynamicRoutes = () => {
     const user = useAppSelector(state => state.user.user)
+
+    const routeMap = useMemo(()=>{
+        const map:{[key:string]:Settings.Route} = {}
+        user?.routes?.forEach(route => {
+            map[route.id] = route
+        })
+        return map
+    },[user])
+
+    // @ts-ignore
+    const mapRouteData = useCallback((r: RouteObjectData) => {
+        return {
+            ...r,
+            handle: {
+                label: r.label || '',
+                roles: r.roles || [],
+                data: r.id ? routeMap[r.id] : {},
+            },
+            children: r.children ? (r.children as RouteObjectData[]).map((item) => mapRouteData(item)) : undefined
+        } as RouteObjectData
+    },[routeMap])
+
     return useMemo(() => {
-        const roles = user?.roles?.split(",") || []
         const filterRoute = (r: RouteObjectData) => {
-            if (!hasRoles(roles,r.roles)){
+            if (r.id && !routeMap[r.id]) {
                 return false
             }
-            r.children = r.children?.filter(c=>filterRoute(c))
+            r.children = r.children?.filter(c => filterRoute(c))
             return true
         }
-        const list = routes.map(item=>mapRouteData(item))
-            .filter(item=>filterRoute(item))
-        return [...openRoutes,...list] as RouteObject[]
-    }, [user])
+        const list = routes.map(item => mapRouteData(item))
+            .filter(item => filterRoute(item))
+        return [...openRoutes, ...list] as RouteObject[]
+    }, [routeMap, mapRouteData])
+}
+
+
+export const useRoleData = (routeId = "") => {
+    const routeMap = useMemo(() => {
+        const map: { [key: string]: RouteObjectData } = {}
+        const addMap = (r: RouteObjectData) => {
+            if (r.id) {
+                map[r.id] = r
+            }
+            if (r.children?.length) {
+                r.children.forEach(c => addMap(c))
+            }
+        }
+        routes.forEach(route => addMap(route))
+        return map;
+    }, [])
+    return useMemo(() => {
+        return routeMap[routeId] || {}
+    }, [routeId, routeMap])
 }

+ 4 - 7
frontend/src/routes/index.tsx

@@ -6,8 +6,7 @@ import {
 } from 'react-router-dom';
 import './index.less'
 import {SSOWrapper} from "../pages/login/sso.tsx";
-import {useDynamicRoutes} from "./hooks.ts";
-import {openRoutes} from "./routes.tsx";
+import {openRoutes, routes} from "./routes.tsx";
 import {MainLayout} from "../pages/layout/MainLayout.tsx";
 import {RouteWrapper} from "./wrap.tsx";
 import {RouteObject} from "react-router/dist/lib/context";
@@ -24,8 +23,6 @@ import {MicroAppPage} from "../pages/microApp";
 
 export const MyRouter = () => {
 
-    const routes = useDynamicRoutes()
-
     const buildRoutes = (r: RouteObject, index: number) => {
         return (<Route path={r.path}
                        key={`${index}_${r.path}`}
@@ -49,10 +46,10 @@ export const MyRouter = () => {
         <SSOWrapper>
             <RouterProvider router={createBrowserRouter(createRoutesFromElements(
                 <Route ErrorBoundary={ErrorBoundary}>
-                    <Route path="/*" element={<RouteWrapper Component={MainLayout}/>}>
-                        <Route path="micro/:name" Component={MicroAppPage} index={true} />
+                    <Route id="INDEX" path="/*" element={<RouteWrapper Component={MainLayout}/>} handle={{ skipAuth: true }}>
+                        <Route path="app/:id" Component={MicroAppPage} index={true}  handle={{ skipAuth: true }}/>
                         {routes.map((item, idx) => (buildRoutes(item, idx)))}
-                        <Route path="modifyPassword" Component={ModifyPasswordPage}/>
+                        <Route path="modifyPassword" Component={ModifyPasswordPage}  handle={{ skipAuth: true }}/>
                     </Route>
                     {
                         openRoutes.map((item,idx) => (<Route key={`${idx}_${item.path}`}

+ 36 - 17
frontend/src/routes/routes.tsx

@@ -4,19 +4,23 @@ import {SignupPage} from "../pages/signup";
 import {NginxLayout} from "../pages/nginx/layout.tsx";
 import {ldapRoutes} from "../pages/ldap/layout.tsx";
 import {UserList} from "../pages/user/list";
+import {List as RoleList} from "../pages/user/role";
 import {RouteObject} from "react-router/dist/lib/context";
 import {SettingPage} from "../pages/settings";
-import {RouteObjectData} from "./types.ts";
 import {ResetPassword} from "../pages/user/resetPassword";
+import {RouteList} from "../pages/routes";
+import {MenuLayout} from "../pages/layout/menu";
+import {UserLinks} from "../pages/user/links";
 
 
 export const openRoutes: RouteObject[] = [
     {
         path: "error",
         element: <ErrorPage/>,
-        handle: {
-            roles: []
-        }
+    },
+    {
+        path: "404",
+        element: <ErrorPage type="NOT_FOUND"/>,
     },
     {
         path: "login",
@@ -37,29 +41,44 @@ export const openRoutes: RouteObject[] = [
 ]
 
 
-export const routes: RouteObjectData[] = [
+export const routes: RouteObject[] = [
     {
+        id: 'NGINX_LAYOUT',
         path: 'nginx/*',
         Component: NginxLayout,
-        label: 'Nginx管理',
-        roles: ['ADMIN'],
-        nav: true,
-        navLink: 'nginx'
     },
     ...ldapRoutes,
     {
-        path: 'user/list',
-        Component: UserList,
-        label: '用户管理',
-        roles: ['ADMIN'],
-        nav: true,
+        id: 'USER_MANAGER',
+        path: 'user',
+        element: <MenuLayout routeId="User"/>,
+        children: [
+            {
+                id: 'USER_LIST',
+                path: 'list',
+                Component: UserList,
+            },
+            {
+                id: 'USER_ROLE',
+                path: 'role',
+                Component: RoleList,
+            },
+        ]
     },
     {
+        id: 'SETTING_ID',
         path: 'settings',
         Component: SettingPage,
-        label: '系统设置',
-        roles: ['ADMIN'],
-        nav: true,
     },
+    {
+        id: 'ROUTE_LIST',
+        path: 'routes',
+        Component: RouteList,
+    },
+    {
+        id: 'USER_LINKS',
+        path: 'links',
+        Component: UserLinks,
+    }
 ]
 

+ 20 - 6
frontend/src/routes/wrap.tsx

@@ -1,7 +1,7 @@
 import * as React from "react";
 import {useEffect, useState} from "react";
 import {useAppDispatch, useAppSelector} from "../store";
-import {useLocation, useNavigate} from "react-router";
+import {useLocation, useMatches, useNavigate} from "react-router";
 import {LoginApis} from "../api/user.ts";
 import {UserActions} from "../store/slice/user.ts";
 import {Spin} from "antd";
@@ -12,19 +12,33 @@ type RouteWrapperProps = {
 }
 export const RouteWrapper = ({Component, ...props}: RouteWrapperProps) => {
 
-    const [loading, setLoading] = useState(false)
-    const user = useAppSelector(state => state.user.user)
+    const [loading, setLoading] = useState(true)
     const isLogin = useAppSelector(state => state.user.isLogin)
+    const routeMap = useAppSelector(state => state.user.routeMap)
 
     const navigate = useNavigate()
     const location = useLocation()
     const dispatch = useAppDispatch()
 
+    // useMatches 只能匹配到顶级路由,二级路由无法匹配,或者说只能匹配当前界别的路由
+    // 使用useRoutes加载的路由也是二级路由,无法匹配
+    const matches = useMatches()
 
     useEffect(() => {
-        if (!isLogin) {
-            navigate('/login')
+        const current = matches[matches.length - 1]
+        if (current.id && !routeMap[current.id] && !(current.handle as any)?.skipAuth){
+            console.log('current match NOT FOUND',current)
+            navigate('/404', { replace: true })
         }
+    }, [matches, routeMap]);
+
+
+    useEffect(() => {
+        setTimeout(()=>{
+            if (!isLogin) {
+                navigate('/login')
+            }
+        },500)
     }, [isLogin]);
 
     const fetchUser = () => {
@@ -45,7 +59,7 @@ export const RouteWrapper = ({Component, ...props}: RouteWrapperProps) => {
         fetchUser()
     }, [])
 
-    if (!user || loading) {
+    if (!isLogin || loading) {
         return (<div className="empty-loading">
             <Spin></Spin>
             <div className="hint-msg">加载中,请稍等...</div>

+ 2 - 0
frontend/src/store/root.ts

@@ -3,6 +3,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
 import userReducer from './slice/user.ts';
 import nginxReducer from './slice/nginx.ts'
 import routeReducer from './slice/route.ts';
+import settingReducer from './slice/settings.ts';
 
 export type IAppState = {
   responsive: {
@@ -37,6 +38,7 @@ const rootReducer = combineReducers({
   user: userReducer,
   nginx: nginxReducer,
   route: routeReducer,
+  settings: settingReducer,
 });
 
 export default rootReducer;

+ 22 - 0
frontend/src/store/slice/settings.ts

@@ -0,0 +1,22 @@
+import {createSlice, PayloadAction} from '@reduxjs/toolkit';
+
+export type ISettingsState = {
+    fullScreen: boolean;
+};
+
+const initialState: ISettingsState = {
+    fullScreen: false
+};
+
+const settingsSlice = createSlice({
+    name: 'settings',
+    initialState,
+    reducers: {
+        setFullScreen(state, action: PayloadAction<boolean>) {
+            state.fullScreen = action.payload;
+        },
+    },
+});
+
+export default settingsSlice.reducer;
+export const settingsActions = settingsSlice.actions;

+ 46 - 33
frontend/src/store/slice/user.ts

@@ -1,48 +1,61 @@
-import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import {createSlice, PayloadAction} from '@reduxjs/toolkit';
 import {User} from "../../models/user.ts";
 import dayjs from "dayjs";
-import {MenuProps} from "antd";
+import {Settings} from "../../api/settings.ts";
 
 export type IUserState = {
-  user?: User & { timestamp: number };
-  isLogin: boolean;
-  isAdmin?: boolean;
-  navList: MenuProps['items'],
-  nav: string
+    user?: User & { timestamp: number };
+    isLogin: boolean;
+    isAdmin?: boolean;
+    navList: Settings.Route[],
+    nav: string
+    routeMap: { [key: string]: Settings.Route }
+    links: Settings.Route[]
 };
 
 const initialState: IUserState = {
-  isLogin: false,
-  isAdmin: false,
-  nav: '/nginx',
-  navList: [
-    {
-      key: '/nginx',
-      label: 'Nginx管理'
-    },
-  ],
+    isLogin: false,
+    isAdmin: false,
+    nav: '/nginx',
+    navList: [],
+    routeMap: {},
+    links: []
 };
 
 const userSlice = createSlice({
-  name: 'user',
-  initialState,
-  reducers: {
-    setUser(state, action: PayloadAction<User>) {
-      state.user = action.payload;
-      state.user.timestamp = dayjs().unix()
-      const roles = state.user?.roles || '';
-      state.isAdmin = roles.indexOf('ADMIN') > -1;
-      state.isLogin = true;
-    },
-    clearUser(state) {
-      state.isLogin = false;
-      state.isAdmin = false;
-      state.user = undefined;
-      console.log('======clearUser======');
+    name: 'user',
+    initialState,
+    reducers: {
+        setUser(state, action: PayloadAction<User>) {
+            state.user = action.payload;
+            state.user.timestamp = dayjs().unix()
+            const roles = state.user?.roles || '';
+            state.isAdmin = roles.indexOf('ADMIN') > -1;
+            const routeMap: { [key: string]: Settings.Route } = {};
+            const navList: Settings.Route[] = []
+            const linkList: Settings.Route[] = []
+            action.payload.routes?.forEach((route: Settings.Route) => {
+                routeMap[route.id] = route
+                if (route.type == 'NAV') {
+                    navList.push({...route})
+                } else if (route.type == 'LINK') {
+                    linkList.push({...route})
+                }
+            })
+            state.routeMap = routeMap
+            state.navList = navList;
+            state.links = linkList;
+            state.isLogin = true;
+        },
+        clearUser(state) {
+            state.isLogin = false;
+            state.isAdmin = false;
+            state.user = undefined;
+            console.log('======clearUser======');
+        },
     },
-  },
 });
 
-export const { setUser, clearUser } = userSlice.actions;
+export const {setUser, clearUser} = userSlice.actions;
 export default userSlice.reducer;
 export const UserActions = userSlice.actions;

+ 2 - 0
server/db/db.go

@@ -38,12 +38,14 @@ func Init() {
 	orm.RegisterModel(new(models.ServerHost))
 	orm.RegisterModel(new(models.NginxCerts))
 	orm.RegisterModel(new(models.User))
+	orm.RegisterModel(new(models.UserRole))
 
 	orm.RegisterModel(new(models.LdapServer))
 	orm.RegisterModel(new(models.LdapUser))
 	orm.RegisterModel(new(models.LdapOrganize))
 
 	orm.RegisterModel(new(models.Setting))
+	orm.RegisterModel(new(models.SettingRoute))
 
 	orm.RunSyncdb("default", false, true)
 

+ 1 - 0
server/init/init.go

@@ -16,4 +16,5 @@ func init() {
 	db.Init()
 	config.InitAdmin()
 	fmt.Println("init success")
+	ensureRoutes()
 }

+ 180 - 0
server/init/sql.go

@@ -0,0 +1,180 @@
+package init
+
+import (
+	"nginx-ui/server/models"
+	"nginx-ui/server/modules/settings"
+)
+
+func ensureRoutes() {
+	routes := []models.SettingRoute{
+		{
+			Id:      "NGINX_LAYOUT",
+			Path:    "nginx/*",
+			Index:   false,
+			Pid:     "",
+			Uid:     100,
+			Roles:   "ADMIN",
+			Type:    "NAV",
+			Target:  "TAB",
+			Title:   "Nginx管理",
+			Brief:   "Nginx在线管理工具",
+			NavLink: "/nginx",
+			Deleted: false,
+			SortNum: 0,
+		},
+		{
+			Id:      "LDAP",
+			Path:    "ldap",
+			Index:   true,
+			Pid:     "",
+			Uid:     10,
+			Roles:   "ADMIN",
+			Type:    "NAV",
+			Target:  "TAB",
+			Title:   "LDAP管理",
+			Brief:   "LDAP用户管理",
+			NavLink: "",
+			Deleted: false,
+			SortNum: 0,
+		},
+		{
+			Id:      "LDAPServerUsers",
+			Path:    "ldap/server/:id",
+			Index:   false,
+			Pid:     "",
+			Uid:     0,
+			Roles:   "ADMIN",
+			Type:    "MENU",
+			Target:  "TAB",
+			Title:   "LDAP",
+			Brief:   "",
+			NavLink: "",
+			Deleted: false,
+			SortNum: 0,
+		},
+		{
+			Id:      "LDAP_USERS",
+			Path:    "user",
+			Index:   true,
+			Pid:     "LDAPServerUsers",
+			Uid:     0,
+			Roles:   "ADMIN",
+			Type:    "MENU",
+			Target:  "TAB",
+			Title:   "用户列表",
+			Brief:   "",
+			NavLink: "",
+			Deleted: false,
+			SortNum: 10,
+		},
+		{
+			Id:      "LDAP_ORGANIZE_LIST",
+			Path:    "organize",
+			Index:   false,
+			Pid:     "LDAPServerUsers",
+			Uid:     0,
+			Roles:   "ADMIN",
+			Type:    "MENU",
+			Target:  "TAB",
+			Title:   "组织管理",
+			Brief:   "",
+			NavLink: "",
+			Deleted: false,
+			SortNum: 0,
+		},
+		{
+			Id:      "ROUTE_LIST",
+			Path:    "routes",
+			Index:   false,
+			Pid:     "",
+			Uid:     0,
+			Roles:   "ADMIN",
+			Type:    "NAV",
+			Target:  "TAB",
+			Title:   "菜单配置",
+			Brief:   "配置系统菜单",
+			NavLink: "",
+			Deleted: false,
+			SortNum: 0,
+		},
+		{
+			Id:      "USER_MANAGER",
+			Path:    "user",
+			Index:   false,
+			Pid:     "",
+			Uid:     0,
+			Roles:   "",
+			Type:    "NAV",
+			Target:  "TAB",
+			Title:   "用户管理",
+			Brief:   "系统用户管理",
+			NavLink: "",
+			Deleted: false,
+			SortNum: 5,
+		},
+		{
+			Id:      "USER_LIST",
+			Path:    "list",
+			Index:   true,
+			Pid:     "USER_MANAGER",
+			Uid:     0,
+			Roles:   "",
+			Type:    "MENU",
+			Target:  "TAB",
+			Title:   "用户列表",
+			Brief:   "用户列表",
+			NavLink: "",
+			Deleted: false,
+			SortNum: 4,
+		},
+		{
+			Id:      "USER_ROLE",
+			Path:    "role",
+			Index:   false,
+			Pid:     "USER_MANAGER",
+			Uid:     0,
+			Roles:   "",
+			Type:    "MENU",
+			Target:  "TAB",
+			Title:   "角色管理",
+			Brief:   "角色管理",
+			NavLink: "",
+			Deleted: false,
+			SortNum: 3,
+		},
+		{
+			Id:      "SETTING_ID",
+			Path:    "settings",
+			Index:   false,
+			Pid:     "",
+			Uid:     0,
+			Roles:   "ADMIN",
+			Type:    "NAV",
+			Target:  "TAB",
+			Title:   "系统设置",
+			Brief:   "系统设置",
+			NavLink: "",
+			Deleted: false,
+			SortNum: 0,
+		},
+		{
+			Id:      "USER_LINKS",
+			Path:    "links",
+			Index:   false,
+			Pid:     "",
+			Uid:     0,
+			Roles:   "",
+			Type:    "NAV",
+			Target:  "TAB",
+			Title:   "快捷菜单",
+			Brief:   "快捷菜单",
+			NavLink: "",
+			Deleted: false,
+			SortNum: 0,
+		},
+	}
+
+	for _, route := range routes {
+		settings.Route.InsertWhenNotExist(route)
+	}
+}

+ 23 - 0
server/models/settings.go

@@ -15,3 +15,26 @@ func (s *Setting) UniqueClone() (*Setting, string) {
 		ConfigKey: s.ConfigKey,
 	}, "ConfigKey"
 }
+
+// 前端路由设置,Uid不为0表示个人独有
+type SettingRoute struct {
+	Id      string `orm:"pk;size(255)" json:"id"`
+	Path    string `json:"path"`
+	Index   bool   `json:"index"`
+	Pid     string `json:"pid"`
+	Uid     int    `orm:"default(0)" json:"uid"`
+	Roles   string `json:"roles"`
+	Type    string `orm:"size(64)" json:"type"`
+	Target  string `json:"target"`
+	Title   string `json:"title"`
+	Brief   string `json:"brief"`
+	NavLink string `json:"navLink"`
+	Deleted bool   `orm:"default(1)" json:"deleted"`
+	SortNum int    `orm:"default(0)" json:"sortNum"`
+}
+
+func (s *SettingRoute) UniqueClone() (*SettingRoute, string) {
+	return &SettingRoute{
+		Id: s.Id,
+	}, "Id"
+}

+ 22 - 6
server/models/user.go

@@ -14,12 +14,22 @@ type User struct {
 	Email    string `json:"email"`
 	// 用户角色,admin为管理员,多个使用逗号分割
 	// 现在没多大用
-	Roles     string    `json:"roles"`
-	Password  string    `json:"password"`
-	Remark    string    `json:"remark"`
-	Source    string    `json:"source"`   // 账号来源,默认或者自有账号
-	TempCode  string    `json:"tempCode"` // 临时授权码,重置密码
-	CreatedAt time.Time `orm:"auto_now_add;type(datetime);default(0001-01-01 00:00:00)" json:"createdAt"`
+	Roles     string         `json:"roles"`
+	Password  string         `json:"password"`
+	Remark    string         `json:"remark"`
+	Source    string         `json:"source"`   // 账号来源,默认或者自有账号
+	TempCode  string         `json:"tempCode"` // 临时授权码,重置密码
+	CreatedAt time.Time      `orm:"auto_now_add;type(datetime);default(0001-01-01 00:00:00)" json:"createdAt"`
+	Routes    []SettingRoute `orm:"-" json:"routes"`
+}
+
+type UserRole struct {
+	Id       int    `orm:"pk;auto" json:"id"`
+	Code     string `orm:"size(20)" json:"code"`
+	Title    string `orm:"size(255)" json:"title"`
+	Brief    string `orm:"size(255)" json:"brief"`
+	Enable   bool   `orm:"default(true)" json:"enable"`
+	RefCount int    `orm:"default(0)" json:"refCount"` // 引用次数
 }
 
 func (u *User) IsAdmin() bool {
@@ -31,3 +41,9 @@ func (u *User) IsAdmin() bool {
 	}
 	return false
 }
+
+func (s *UserRole) UniqueClone() (*UserRole, string) {
+	return &UserRole{
+		Id: s.Id,
+	}, "Id"
+}

+ 67 - 0
server/modules/settings/route_controller.go

@@ -0,0 +1,67 @@
+package settings
+
+import (
+	"nginx-ui/server/base"
+	"nginx-ui/server/models"
+)
+
+type RouteController struct {
+	base.Controller
+}
+
+// List 查看全部路由
+func (c *RouteController) List() {
+	current := c.RequiredUser()
+	if current == nil {
+		return
+	}
+	listVo := RouteListVo{}
+	if !c.ReadBody(&listVo) {
+		return
+	}
+	if listVo.Uid == -1 {
+		listVo.Uid = current.Id
+	}
+
+	list, err := Route.GetList(listVo)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	c.SetData(list).Json()
+}
+
+// Save 新增或者修改用户
+func (c *RouteController) Save() {
+	current := c.RequiredUser()
+	if current == nil {
+		return
+	}
+	route := models.SettingRoute{}
+	if !c.ReadBody(&route) {
+		return
+	}
+	if route.Uid == -1 {
+		route.Uid = current.Id
+	}
+	resp, err := Route.Save(&route)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	c.SetData(resp).Json()
+}
+
+func (c *RouteController) Delete() {
+	current := c.RequiredUser()
+	if current == nil {
+		return
+	}
+	id := c.GetQuery("id")
+	err := Route.Delete(id)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	c.Json()
+}

+ 91 - 0
server/modules/settings/route_service.go

@@ -0,0 +1,91 @@
+package settings
+
+import (
+	"errors"
+	"fmt"
+	"github.com/astaxie/beego/orm"
+	"nginx-ui/server/models"
+	"strings"
+)
+
+type RouteService struct {
+}
+
+// Save 保存或者修改
+func (c *RouteService) Save(body *models.SettingRoute) (*models.SettingRoute, error) {
+	o := orm.NewOrm()
+	_, err := models.InsertOrUpdate[models.SettingRoute](o, body)
+	return body, err
+}
+
+// GetList 获取指定用户的全部路由
+// Uid为0表示系统菜单,大于0表示用户个人菜单
+func (c *RouteService) GetList(vo RouteListVo) ([]models.SettingRoute, error) {
+	o := orm.NewOrm()
+	qs := o.QueryTable(&models.SettingRoute{})
+	if vo.Uid > -1 {
+		qs = qs.FilterRaw("Uid", fmt.Sprintf("=%d", vo.Uid))
+	}
+	if vo.NonType != "" {
+		qs = qs.FilterRaw("Type", fmt.Sprintf("<>'%s'", vo.NonType))
+	}
+	qs = qs.OrderBy("-SortNum")
+
+	var list []models.SettingRoute
+	_, err := qs.All(&list)
+	if err != nil {
+		return nil, err
+	}
+	return list, nil
+}
+
+// GetUserRoutes 获取用户的菜单
+func (c *RouteService) GetUserRoutes(user *models.User) ([]models.SettingRoute, error) {
+	o := orm.NewOrm()
+	qs := o.QueryTable(&models.SettingRoute{})
+	qs = qs.FilterRaw("Uid", fmt.Sprintf("=%d OR uid=0", user.Id))
+	qs = qs.OrderBy("-SortNum")
+
+	var list []models.SettingRoute
+	_, err := qs.All(&list)
+	if err != nil {
+		return nil, err
+	}
+	if !user.IsAdmin() {
+		userRoles := strings.Split(user.Roles, ",")
+		var routes []models.SettingRoute
+		for _, route := range list {
+			if route.Roles == "" || strings.Contains(route.Roles, "USER") {
+				routes = append(routes, route)
+				continue
+			}
+			for _, role := range userRoles {
+				if strings.Contains(route.Roles, role) {
+					routes = append(routes, route)
+					continue
+				}
+			}
+		}
+		return routes, nil
+	}
+	return list, nil
+}
+
+// Delete 获取全部组织
+func (c *RouteService) Delete(id string) error {
+	o := orm.NewOrm()
+	_, err := o.Delete(&models.SettingRoute{Id: id})
+	return err
+}
+
+// InsertWhenNotExist 插入如果当ID不存在时
+func (c *RouteService) InsertWhenNotExist(route models.SettingRoute) error {
+	o := orm.NewOrm()
+	err := o.Read(&route, "Id")
+	if err != nil && errors.Is(err, orm.ErrNoRows) {
+		_, err = o.Insert(&route)
+	}
+	return err
+}
+
+var Route = new(RouteService)

+ 7 - 0
server/modules/settings/vo.go

@@ -5,3 +5,10 @@ type ListVo struct {
 	// 0 表示禁用,1表示启用,-1 表示全部
 	Enable int
 }
+
+type RouteListVo struct {
+	Uid     int    `json:"uid"`
+	Roles   string `json:"roles"`
+	Type    string `json:"type"`
+	NonType string `json:"nonType"`
+}

+ 8 - 0
server/modules/user/controller.go

@@ -6,6 +6,7 @@ import (
 	"github.com/astaxie/beego/logs"
 	"nginx-ui/server/base"
 	"nginx-ui/server/models"
+	"nginx-ui/server/modules/settings"
 	"nginx-ui/server/vo"
 )
 
@@ -51,6 +52,13 @@ func (c *Controller) User() {
 	if user == nil {
 		return
 	}
+
+	routes, _ := settings.Route.GetUserRoutes(user)
+	if routes != nil {
+		user.Routes = routes
+	} else {
+		user.Routes = []models.SettingRoute{}
+	}
 	c.SetData(user).Json()
 }
 

+ 60 - 0
server/modules/user/role_controller.go

@@ -0,0 +1,60 @@
+package user
+
+import (
+	"nginx-ui/server/base"
+	"nginx-ui/server/models"
+)
+
+type RoleController struct {
+	base.Controller
+}
+
+// List 查看全部路由
+func (c *RoleController) List() {
+	current := c.RequiredUser()
+	if current == nil {
+		return
+	}
+	list, err := Role.List()
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	c.SetData(list).Json()
+}
+
+// Save 新增或者修改用户
+func (c *RoleController) Save() {
+	current := c.RequiredUser()
+	if current == nil {
+		return
+	}
+	route := models.UserRole{}
+	if !c.ReadBody(&route) {
+		return
+	}
+	resp, err := Role.Save(&route)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	c.SetData(resp).Json()
+}
+
+func (c *RoleController) Delete() {
+	current := c.RequiredUser()
+	if current == nil {
+		return
+	}
+	id, err := c.GetIntQuery("id")
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	err = Role.Delete(id)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	c.Json()
+}

+ 58 - 0
server/modules/user/role_service.go

@@ -0,0 +1,58 @@
+package user
+
+import (
+	"errors"
+	"github.com/astaxie/beego/logs"
+	"github.com/astaxie/beego/orm"
+	"nginx-ui/server/models"
+)
+
+type RoleService struct {
+}
+
+var Role = RoleService{}
+
+func (u *RoleService) List() ([]models.UserRole, error) {
+
+	qs := orm.NewOrm().QueryTable(new(models.UserRole))
+	var list []models.UserRole
+	_, err := qs.All(&list)
+	if err != nil {
+		return nil, err
+	}
+	return list, err
+}
+
+func (u *RoleService) Save(body *models.UserRole) (*models.UserRole, error) {
+	o := orm.NewOrm()
+	_, err := models.InsertOrUpdate[models.UserRole](o, body)
+	return body, err
+}
+
+func (u *RoleService) Delete(id int) error {
+	o := orm.NewOrm()
+
+	role := models.UserRole{}
+	err := o.Read(&role)
+	if err != nil && errors.Is(err, orm.ErrNoRows) {
+		return nil
+	} else if err != nil {
+		return err
+	}
+	if role.RefCount > 0 {
+		return errors.New("角色有引用资源,请先删除后再操作!")
+	}
+	_, err = o.Delete(&models.UserRole{Id: id})
+	return err
+}
+
+func (u *RoleService) Ref(size int, role string) error {
+	o := orm.NewOrm()
+
+	_, err := o.Raw("UPDATE user_role SET ref_count=ref_count+? WHERE code=?", size, role).Exec()
+	if err != nil {
+		logs.Error("update role ref error", err)
+		return err
+	}
+	return nil
+}

+ 9 - 1
server/routers/router.go

@@ -51,7 +51,11 @@ func init() {
 		beego.NSRouter("/nginx/:id/file/deploy", &nginx_controller.FileController{}, "post:Deploy"),
 		beego.NSRouter("/file", &nginx_controller.FileController{}),
 		beego.NSRouter("/logger", &nginx_controller.LoggerController{}),
-
+		//	角色
+		beego.NSRouter("/role/list", &user.RoleController{}, "post:List"),
+		beego.NSRouter("/role/save", &user.RoleController{}, "post:Save"),
+		beego.NSRouter("/role/delete", &user.RoleController{}, "post:Delete"),
+		// 用户
 		beego.NSRouter("/user/login", userController, "post:Login"),
 		beego.NSRouter("/user/logout", userController, "post:Logout"),
 		beego.NSRouter("/user/info", userController, "get:User"),
@@ -68,6 +72,10 @@ func init() {
 		beego.NSRouter("/settings/list", &settings.SettingController{}, "post:List"),
 		beego.NSRouter("/settings/save", &settings.SettingController{}, "post:Save"),
 		beego.NSRouter("/settings/delete", &settings.SettingController{}, "post:Delete"),
+		//	路由
+		beego.NSRouter("/settings/route/list", &settings.RouteController{}, "post:List"),
+		beego.NSRouter("/settings/route/save", &settings.RouteController{}, "post:Save"),
+		beego.NSRouter("/settings/route/delete", &settings.RouteController{}, "post:Delete"),
 		//	其它设置
 		beego.NSRouter("/wechat/webhook/gitlab", &wechat.Controller{}, "post:WebhookFromGitlab"),
 	)