Browse Source

chore: 菜单权限调整

lujialiang 3 weeks ago
parent
commit
3d40839fcc
31 changed files with 3397 additions and 65 deletions
  1. 7 4
      .env
  2. 3 0
      eslint.config.js
  3. 4 0
      src/router/elegant/imports.ts
  4. 47 0
      src/router/elegant/routes.ts
  5. 5 0
      src/router/elegant/transform.ts
  6. 3 2
      src/service/api/route.ts
  7. 46 39
      src/service/api/system-manage.ts
  8. 76 16
      src/service/request/index.ts
  9. 7 3
      src/store/modules/auth/index.ts
  10. 63 0
      src/store/modules/route/custom.ts
  11. 2 1
      src/store/modules/route/index.ts
  12. 6 0
      src/typings/components.d.ts
  13. 10 0
      src/typings/elegant-router.d.ts
  14. 162 0
      src/views/admin/sys-dept/index.vue
  15. 105 0
      src/views/admin/sys-dept/modules/m-search.vue
  16. 248 0
      src/views/admin/sys-dept/modules/user-operate-drawer.vue
  17. 65 0
      src/views/admin/sys-dept/modules/user-resetpwd-dialog.vue
  18. 237 0
      src/views/admin/sys-menu/index.vue
  19. 615 0
      src/views/admin/sys-menu/modules/menu-operate-modal.vue
  20. 78 0
      src/views/admin/sys-menu/modules/menu-search.vue
  21. 79 0
      src/views/admin/sys-menu/modules/shared.ts
  22. 207 0
      src/views/admin/sys-role/index.vue
  23. 85 0
      src/views/admin/sys-role/modules/button-auth-modal.vue
  24. 149 0
      src/views/admin/sys-role/modules/menu-auth-modal.vue
  25. 132 0
      src/views/admin/sys-role/modules/role-modifypermis-modal.vue
  26. 206 0
      src/views/admin/sys-role/modules/role-operate-drawer.vue
  27. 80 0
      src/views/admin/sys-role/modules/role-search.vue
  28. 220 0
      src/views/admin/sys-user/index.vue
  29. 248 0
      src/views/admin/sys-user/modules/user-operate-drawer.vue
  30. 65 0
      src/views/admin/sys-user/modules/user-resetpwd-dialog.vue
  31. 137 0
      src/views/admin/sys-user/modules/user-search.vue

+ 7 - 4
.env

@@ -12,7 +12,7 @@ VITE_ICON_PREFIX=icon
 VITE_ICON_LOCAL_PREFIX=icon-local
 
 # auth route mode: static | dynamic
-VITE_AUTH_ROUTE_MODE=static
+VITE_AUTH_ROUTE_MODE=dynamic
 
 # static auth route home
 VITE_ROUTE_HOME=home
@@ -27,13 +27,16 @@ VITE_HTTP_PROXY=N
 VITE_ROUTER_HISTORY_MODE=history
 
 # success code of backend service, when the code is received, the request is successful
-VITE_SERVICE_SUCCESS_CODE=0000
+# VITE_SERVICE_SUCCESS_CODE=0000
+VITE_SERVICE_SUCCESS_CODE=200
 
 # logout codes of backend service, when the code is received, the user will be logged out and redirected to login page
-VITE_SERVICE_LOGOUT_CODES=8888,8889
+# VITE_SERVICE_LOGOUT_CODES=8888,8889
+VITE_SERVICE_LOGOUT_CODES=401
 
 # modal logout codes of backend service, when the code is received, the user will be logged out by displaying a modal
-VITE_SERVICE_MODAL_LOGOUT_CODES=7777,7778
+# VITE_SERVICE_MODAL_LOGOUT_CODES=7777,7778
+VITE_SERVICE_MODAL_LOGOUT_CODES=401
 
 # token expired codes of backend service, when the code is received, it will refresh the token and resend the request
 VITE_SERVICE_EXPIRED_TOKEN_CODES=9999,9998

+ 3 - 0
eslint.config.js

@@ -20,5 +20,8 @@ export default defineConfig(
       ],
       'unocss/order-attributify': 'off'
     }
+  },
+  {
+    ignores: ['src/views/admin/**']
   }
 );

+ 4 - 0
src/router/elegant/imports.ts

@@ -21,6 +21,10 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
   "iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
   login: () => import("@/views/_builtin/login/index.vue"),
   about: () => import("@/views/about/index.vue"),
+  "admin_sys-dept": () => import("@/views/admin/sys-dept/index.vue"),
+  "admin_sys-menu": () => import("@/views/admin/sys-menu/index.vue"),
+  "admin_sys-role": () => import("@/views/admin/sys-role/index.vue"),
+  "admin_sys-user": () => import("@/views/admin/sys-user/index.vue"),
   "function_hide-child_one": () => import("@/views/function/hide-child/one/index.vue"),
   "function_hide-child_three": () => import("@/views/function/hide-child/three/index.vue"),
   "function_hide-child_two": () => import("@/views/function/hide-child/two/index.vue"),

+ 47 - 0
src/router/elegant/routes.ts

@@ -50,6 +50,53 @@ export const generatedRoutes: GeneratedRoute[] = [
       order: 10
     }
   },
+  {
+    name: 'admin',
+    path: '/admin',
+    component: 'layout.base',
+    meta: {
+      title: 'admin',
+      i18nKey: 'route.admin'
+    },
+    children: [
+      {
+        name: 'admin_sys-dept',
+        path: '/admin/sys-dept',
+        component: 'view.admin_sys-dept',
+        meta: {
+          title: 'admin_sys-dept',
+          i18nKey: 'route.admin_sys-dept'
+        }
+      },
+      {
+        name: 'admin_sys-menu',
+        path: '/admin/sys-menu',
+        component: 'view.admin_sys-menu',
+        meta: {
+          title: 'admin_sys-menu',
+          i18nKey: 'route.admin_sys-menu'
+        }
+      },
+      {
+        name: 'admin_sys-role',
+        path: '/admin/sys-role',
+        component: 'view.admin_sys-role',
+        meta: {
+          title: 'admin_sys-role',
+          i18nKey: 'route.admin_sys-role'
+        }
+      },
+      {
+        name: 'admin_sys-user',
+        path: '/admin/sys-user',
+        component: 'view.admin_sys-user',
+        meta: {
+          title: 'admin_sys-user',
+          i18nKey: 'route.admin_sys-user'
+        }
+      }
+    ]
+  },
   {
     name: 'function',
     path: '/function',

+ 5 - 0
src/router/elegant/transform.ts

@@ -177,6 +177,11 @@ const routeMap: RouteMap = {
   "404": "/404",
   "500": "/500",
   "about": "/about",
+  "admin": "/admin",
+  "admin_sys-dept": "/admin/sys-dept",
+  "admin_sys-menu": "/admin/sys-menu",
+  "admin_sys-role": "/admin/sys-role",
+  "admin_sys-user": "/admin/sys-user",
   "function": "/function",
   "function_hide-child": "/function/hide-child",
   "function_hide-child_one": "/function/hide-child/one",

+ 3 - 2
src/service/api/route.ts

@@ -1,4 +1,4 @@
-import { request } from '../request';
+import { baseRequest, request } from '../request';
 
 /** get constant routes */
 export function fetchGetConstantRoutes() {
@@ -7,7 +7,8 @@ export function fetchGetConstantRoutes() {
 
 /** get user routes */
 export function fetchGetUserRoutes() {
-  return request<Api.Route.UserRoute>({ url: '/route/getUserRoutes' });
+  // return request<Api.Route.UserRoute>({ url: '/route/getUserRoutes' });
+  return baseRequest({ url: '/api/v1/menurole' });
 }
 
 /**

+ 46 - 39
src/service/api/system-manage.ts

@@ -17,9 +17,9 @@ export function fetchChangeRoleStatus(data?: Api.SystemManage.ChangeRoleStatusPa
   });
 }
 // 根据角色ID查询菜单权限下拉树结构 /** get menu tree */
-export function fetchGetMenuTree(roleId:number) {
+export function fetchGetMenuTree(roleId: number) {
   return baseRequest({
-    url: '/api/v1/roleMenuTreeselect/' + roleId,
+    url: `/api/v1/roleMenuTreeselect/${roleId}`,
     method: 'get'
   });
 }
@@ -28,7 +28,7 @@ export function fetchGetMenuTree(roleId:number) {
  *
  * these roles are all enabled
  */
-export function fetchGetAllRoles(params:any) {
+export function fetchGetAllRoles(params: any) {
   return baseRequest({
     url: '/api/v1/role',
     method: 'get',
@@ -36,50 +36,50 @@ export function fetchGetAllRoles(params:any) {
   });
 }
 // 根据角色来获取配置的menu 菜单
-export function fetchGetMenuIdsByRole(roleId:number) {
+export function fetchGetMenuIdsByRole(roleId: number) {
   return baseRequest({
-    url: '/api/v1/role/' + roleId,
+    url: `/api/v1/role/${roleId}`,
     method: 'get'
   });
 }
 // 新增角色
-export function fetchAddRole(data:any) {
+export function fetchAddRole(data: any) {
   return baseRequest({
     url: '/api/v1/role',
     method: 'post',
-    data: data
+    data
   });
 }
 // 修改角色
-export function fetchEditRole(data:any, roleId:number) {
+export function fetchEditRole(data: any, roleId: number) {
   return baseRequest({
-    url: '/api/v1/role/' + roleId,
+    url: `/api/v1/role/${roleId}`,
     method: 'put',
-    data: data
+    data
   });
 }
 // 删除角色
-export function fetchDeleteRole(data:any) {
+export function fetchDeleteRole(data: any) {
   return baseRequest({
     url: '/api/v1/role',
     method: 'delete',
-    data: data
+    data
   });
 }
 // 修改角色的权限范围
-export function fetchModifyRolePersion(data:any) {
+export function fetchModifyRolePersion(data: any) {
   return baseRequest({
     url: '/api/v1/roledatascope',
     method: 'put',
-    data: data
+    data
   });
 }
 // 根据角色ID查询部门树结构
-export function fetchDeptTreeByRole(roleId:number) {
+export function fetchDeptTreeByRole(roleId: number) {
   return baseRequest({
-    url: '/api/v1/roleDeptTreeselect/' + roleId,
+    url: `/api/v1/roleDeptTreeselect/${roleId}`,
     method: 'get'
-  })
+  });
 }
 /** get user list */
 export function fetchGetUserList(params?: Api.SystemManage.UserSearchParams) {
@@ -99,41 +99,40 @@ export function fetchGetMenuList(params?: Api.SystemManage.MenuSearchParams) {
   });
 }
 // 添加菜单
-export function fetchAddMenu(data:any) {
+export function fetchAddMenu(data: any) {
   return baseRequest<Api.SystemManage.MenuList>({
     url: '/api/v1/menu',
     method: 'post',
-    data:data
+    data
   });
 }
 // 修改菜单
-export function fetchUpdateMenu(data:any, id:number) {
+export function fetchUpdateMenu(data: any, id: number) {
   return baseRequest({
-    url: '/api/v1/menu/' + id,
+    url: `/api/v1/menu/${id}`,
     method: 'put',
-    data: data
-  })
+    data
+  });
 }
 // 删除菜单
-export function fetchDeleteMenu(data:any) {
+export function fetchDeleteMenu(data: any) {
   return baseRequest({
     url: '/api/v1/menu',
     method: 'delete',
-    data: data
+    data
   });
 }
 
 // 查询菜单详情
-export function fetchGetMenuDetail(menuId:number) {
+export function fetchGetMenuDetail(menuId: number) {
   return baseRequest({
-    url: '/api/v1/menu/' + menuId,
+    url: `/api/v1/menu/${menuId}`,
     method: 'get'
   });
 }
 
-
 // 获取api权限列表
-export function fetchGetSysApiList(data:any) {
+export function fetchGetSysApiList(data: any) {
   return baseRequest({
     url: '/api/v1/sys-api',
     method: 'get',
@@ -149,16 +148,15 @@ export function fetchGetAllPages() {
   });
 }
 
-
 // 根据字典类型查询字典数据信息
-export function getDicts(dictType:string) {
+export function getDicts(dictType: string) {
   return baseRequest({
-    url: '/api/v1/dict-data/option-select?dictType='+ dictType,
-    method: 'get',
+    url: `/api/v1/dict-data/option-select?dictType=${dictType}`,
+    method: 'get'
   });
 }
 // 岗位
-export function fetchGetPostList(params:any) {
+export function fetchGetPostList(params: any) {
   return baseRequest({
     url: '/api/v1/post',
     method: 'get',
@@ -173,15 +171,15 @@ export function fetchGetdeptTreeList() {
   });
 }
 // 新增用户
-export function fetchAddUser(data:any, method: string) {
+export function fetchAddUser(data: any, method: string) {
   return baseRequest({
     url: '/api/v1/sys-user',
-    method: method,
+    method,
     data
   });
 }
 // 删除用户
-export function fetchDelteUser(data:any) {
+export function fetchDelteUser(data: any) {
   return baseRequest({
     url: '/api/v1/sys-user',
     method: 'delete',
@@ -189,7 +187,7 @@ export function fetchDelteUser(data:any) {
   });
 }
 // 重置密码
-export function fetchRestPwsUser(data:any) {
+export function fetchRestPwsUser(data: any) {
   return baseRequest({
     url: '/api/v1/user/pwd/reset',
     method: 'put',
@@ -197,10 +195,19 @@ export function fetchRestPwsUser(data:any) {
   });
 }
 // 用户状态修改
-export function changeUserStatus(data:any) {
+export function changeUserStatus(data: any) {
   return baseRequest({
     url: '/api/v1/user/status',
     method: 'put',
     data
   });
 }
+
+// 获取部门列表
+export function fetchDeptList(params?: Api.SystemManage.UserSearchParams) {
+  return baseRequest<Api.SystemManage.UserList>({
+    url: '/api/v1/dept',
+    method: 'get',
+    params
+  });
+}

+ 76 - 16
src/service/request/index.ts

@@ -1,9 +1,9 @@
+import { BACKEND_ERROR_CODE, createFlatRequest, createRequest } from '@sa/axios';
+import type { AxiosRequestConfig, AxiosResponse } from 'axios';
 import { $t } from '@/locales';
 import { useAuthStore } from '@/store/modules/auth';
 import { getServiceBaseURL } from '@/utils/service';
 import { localStg } from '@/utils/storage';
-import { BACKEND_ERROR_CODE, createFlatRequest, createRequest } from '@sa/axios';
-import type { AxiosRequestConfig, AxiosResponse } from 'axios';
 import { handleRefreshToken, showErrorMsg } from './shared';
 import type { RequestInstanceState } from './type';
 
@@ -18,7 +18,7 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
     }
   },
   {
-    async onRequest(config: { headers: any; }) {
+    async onRequest(config: { headers: any }) {
       const { headers } = config;
 
       // set token
@@ -28,12 +28,12 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
 
       return config;
     },
-    isBackendSuccess(response: { data: { code: any; }; }) {
+    isBackendSuccess(response: { data: { code: any } }) {
       // when the backend response code is "0000"(default), it means the request is success
       // to change this logic by yourself, you can modify the `VITE_SERVICE_SUCCESS_CODE` in `.env` file
       return String(response.data.code) === import.meta.env.VITE_SERVICE_SUCCESS_CODE;
     },
-    async onBackendFail(response: { data: { msg: any; code: string; }; config: AxiosRequestConfig<any>; }, instance) {
+    async onBackendFail(response: { data: { msg: any; code: string }; config: AxiosRequestConfig<any> }, instance) {
       const authStore = useAuthStore();
 
       function handleLogout() {
@@ -95,10 +95,10 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
 
       return null;
     },
-    transformBackendResponse(response: { data: { data: any; }; }) {
+    transformBackendResponse(response: { data: { data: any } }) {
       return response.data.data;
     },
-    onError(error: { message: any; code: any; response: { data: { msg: any; code: string; }; }; }) {
+    onError(error: { message: any; code: any; response: { data: { msg: any; code: string } } }) {
       // when the request is fail, you can show error message
 
       let message = error.message;
@@ -132,7 +132,7 @@ export const baseRequest = createRequest<App.Service.DemoResponse>(
     baseURL: otherBaseURL.demo
   },
   {
-    async onRequest(config: { headers: any; }) {
+    async onRequest(config: { headers: any }) {
       const { headers } = config;
 
       // set token
@@ -142,20 +142,80 @@ export const baseRequest = createRequest<App.Service.DemoResponse>(
 
       return config;
     },
-    isBackendSuccess(response: { data: { code: number; }}) {
+    isBackendSuccess(response: { data: { code: number } }) {
       // when the backend response code is "200", it means the request is success
       // you can change this logic by yourself
-      return response.data.code === 200
+      return response.data.code === 200;
     },
-    async onBackendFail(_response: any) {
-      // when the backend response code is not "200", it means the request is fail
-      // for example: the token is expired, refresh token and retry request
-      console.log('onBackendFail')
+    async onBackendFail(response: { data: { msg: any; code: string }; config: AxiosRequestConfig<any> }, instance) {
+      const authStore = useAuthStore();
+
+      console.log('onBackendFail', response.data.code);
+      const responseCode = `${response.data.code}`;
+
+      function handleLogout() {
+        authStore.resetStore();
+      }
+
+      function logoutAndCleanup() {
+        handleLogout();
+        window.removeEventListener('beforeunload', handleLogout);
+
+        request.state.errMsgStack = request.state.errMsgStack.filter((msg: any) => msg !== response.data.msg);
+      }
+
+      // when the backend response code is in `logoutCodes`, it means the user will be logged out and redirected to login page
+      const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || [];
+      if (logoutCodes.includes(responseCode)) {
+        handleLogout();
+        return null;
+      }
+
+      // when the backend response code is in `modalLogoutCodes`, it means the user will be logged out by displaying a modal
+      const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || [];
+      if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
+        request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg];
+
+        // prevent the user from refreshing the page
+        window.addEventListener('beforeunload', handleLogout);
+
+        window.$modal?.error({
+          title: $t('common.error'),
+          content: response.data.msg,
+          okText: $t('common.confirm'),
+          maskClosable: false,
+          onOk() {
+            logoutAndCleanup();
+          },
+          onCancel() {
+            logoutAndCleanup();
+          }
+        });
+
+        return null;
+      }
+
+      // when the backend response code is in `expiredTokenCodes`, it means the token is expired, and refresh token
+      // the api `refreshToken` can not return error code in `expiredTokenCodes`, otherwise it will be a dead loop, should return `logoutCodes` or `modalLogoutCodes`
+      const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || [];
+      if (expiredTokenCodes.includes(responseCode) && !request.state.isRefreshingToken) {
+        request.state.isRefreshingToken = true;
+
+        const refreshConfig = await handleRefreshToken(response.config);
+
+        request.state.isRefreshingToken = false;
+
+        if (refreshConfig) {
+          return instance.request(refreshConfig) as Promise<AxiosResponse>;
+        }
+      }
+
+      return null;
     },
-    transformBackendResponse(response: { data: any; }) {
+    transformBackendResponse(response: { data: any }) {
       return response.data;
     },
-    onError(error: { message: any; code: any; response: { data: { message: any; }; }; }) {
+    onError(error: { message: any; code: any; response: { data: { message: any } } }) {
       // when the request is fail, you can show error message
 
       let message = error.message;

+ 7 - 3
src/store/modules/auth/index.ts

@@ -6,6 +6,7 @@ import { SetupStoreId } from '@/enum';
 import { useRouterPush } from '@/hooks/common/router';
 import { $t } from '@/locales';
 import { fetchGetUserInfoDemo, fetchLoginDemo } from '@/service/api';
+// import { fetchLogin, fetchGetUserInfo } from '@/service/api';
 import { localStg } from '@/utils/storage';
 import { useRouteStore } from '../route';
 import { useTabStore } from '../tab';
@@ -66,9 +67,11 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
     startLoading();
     // 待完善 此处和目前业务逻辑不同,关于refreshToken需后续处理
     const { token: currentToken, currentAuthority: refreshToken, error } = await fetchLoginDemo(params);
+    // const { data: loginToken, error } = await fetchLogin('Soybean', '123456');
     console.log('error-----', error);
     if (!error) {
-      const pass = await loginByToken({ token: currentToken, refreshToken, data: null, code: 0 });
+      const pass = await loginByToken({ token: currentToken, refreshToken, data: null, code: 200 });
+      // const pass = await loginByToken(loginToken);
 
       if (pass) {
         await routeStore.initAuthRoute();
@@ -110,12 +113,13 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
 
   async function getUserInfo() {
     const { data: info, error } = await fetchGetUserInfoDemo();
+    // const { data: info, error } = await fetchGetUserInfo();
 
     if (!error) {
       // update store
       // 此处为权限测试方式
-      const testInfo = { roles: ['R_SUPER'] };
-      Object.assign(userInfo, info, testInfo);
+      // const testInfo = { roles: ['R_SUPER'] };
+      Object.assign(userInfo, info);
 
       return true;
     }

+ 63 - 0
src/store/modules/route/custom.ts

@@ -0,0 +1,63 @@
+import type { ElegantConstRoute, LastLevelRouteKey } from '@elegant-router/types';
+
+interface DataItem {
+  name: string;
+  path: string;
+  component: string;
+  title: string;
+  icon: string;
+  sort: number;
+  children?: DataItem[];
+  visible: string;
+}
+
+function replacePath(val: string) {
+  return val.slice(1).replace('/index', '').replace('/', '_');
+}
+function getTransComponent(item: DataItem, level: number): string {
+  const { component: name, children } = item;
+  let isChildren = true;
+  if (!children || children?.length === 0) {
+    isChildren = false;
+  }
+  if (level === 0 && !isChildren) {
+    return `layout.base$view.${replacePath(name)}`;
+  } else if (name === 'Layout') {
+    return 'layout.base';
+  }
+  const newVal = replacePath(name);
+  return `view.${newVal}`;
+}
+function getTransComponentName(val: string) {
+  const newVal = replacePath(val);
+  return newVal;
+}
+
+function transData(menus: DataItem[], level: number = 0): ElegantConstRoute[] {
+  return menus.map(item => {
+    const obj: ElegantConstRoute = {
+      name: getTransComponentName(item.path),
+      path: item.path,
+      component: getTransComponent(item, level),
+      meta: {
+        title: item.title,
+        order: item.sort,
+        localIcon: item.icon,
+        _level: level,
+        hideInMenu: item.visible === '1'
+      },
+      children: []
+    };
+    if (Array.isArray(item.children)) {
+      obj.children = transData(item.children, level + 1);
+    }
+    return obj;
+  });
+}
+
+export function transformToTargetData(data: DataItem[]): {
+  routes: ElegantConstRoute[];
+  home: LastLevelRouteKey;
+} {
+  return { routes: transData(data), home: 'admin_sys-user' };
+}

+ 2 - 1
src/store/modules/route/index.ts

@@ -23,6 +23,7 @@ import {
   transformMenuToSearchMenus,
   updateLocaleOfGlobalMenus
 } from './shared';
+import { transformToTargetData } from './custom';
 
 export const useRouteStore = defineStore(SetupStoreId.Route, () => {
   const appStore = useAppStore();
@@ -249,7 +250,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
     const { data, error } = await fetchGetUserRoutes();
 
     if (!error) {
-      const { routes, home } = data;
+      const { routes, home } = transformToTargetData(data);
 
       addAuthRoutes(routes);
 

+ 6 - 0
src/typings/components.d.ts

@@ -67,9 +67,15 @@ declare module 'vue' {
     IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
     IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default']
     IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
+    IconLocal404: typeof import('~icons/local/404')['default']
+    IconLocalActivity: typeof import('~icons/local/activity')['default']
+    IconLocalApiTest: typeof import('~icons/local/api-test')['default']
     IconLocalBanner: typeof import('~icons/local/banner')['default']
     IconLocalLogo: typeof import('~icons/local/logo')['default']
+    'IconMaterialSymbolsLight:10k': typeof import('~icons/material-symbols-light/10k')['default']
+    'IconMaterialSymbolsLight:123': typeof import('~icons/material-symbols-light/123')['default']
     IconMdiDrag: typeof import('~icons/mdi/drag')['default']
+    IconMdiEmoticon: typeof import('~icons/mdi/emoticon')['default']
     IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
     LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
     LookForward: typeof import('./../components/custom/look-forward.vue')['default']

+ 10 - 0
src/typings/elegant-router.d.ts

@@ -33,6 +33,11 @@ declare module "@elegant-router/types" {
     "404": "/404";
     "500": "/500";
     "about": "/about";
+    "admin": "/admin";
+    "admin_sys-dept": "/admin/sys-dept";
+    "admin_sys-menu": "/admin/sys-menu";
+    "admin_sys-role": "/admin/sys-role";
+    "admin_sys-user": "/admin/sys-user";
     "function": "/function";
     "function_hide-child": "/function/hide-child";
     "function_hide-child_one": "/function/hide-child/one";
@@ -105,6 +110,7 @@ declare module "@elegant-router/types" {
     | "404"
     | "500"
     | "about"
+    | "admin"
     | "function"
     | "home"
     | "iframe-page"
@@ -136,6 +142,10 @@ declare module "@elegant-router/types" {
     | "iframe-page"
     | "login"
     | "about"
+    | "admin_sys-dept"
+    | "admin_sys-menu"
+    | "admin_sys-role"
+    | "admin_sys-user"
     | "function_hide-child_one"
     | "function_hide-child_three"
     | "function_hide-child_two"

+ 162 - 0
src/views/admin/sys-dept/index.vue

@@ -0,0 +1,162 @@
+<script setup lang="tsx">
+import { useTable, useTableOperate, useTableScroll } from '@/hooks/common/table';
+import { $t } from '@/locales';
+import { fetchDelteUser, fetchDeptList } from '@/service/api';
+import { Button, Popconfirm } from 'ant-design-vue';
+import dayjs from 'dayjs';
+import { ref } from 'vue';
+import UserOperateDrawer from './modules/user-operate-drawer.vue';
+import UserResetPwdDialog from './modules/user-resetpwd-dialog.vue';
+import MSearch from './modules/m-search.vue';
+const { tableWrapperRef, scrollConfig } = useTableScroll();
+const apiParams = ref({
+  pageIndex: 1,
+  pageSize: 10
+});
+let transUserId: number | null = null
+let userName = ''
+let resetDialogVisible = ref(false)
+
+const {
+  columns,
+  columnChecks,
+  data,
+  getData,
+  getDataByPage,
+  loading,
+  mobilePagination,
+  searchParams,
+  resetSearchParams
+} = useTable({
+  apiFn: fetchDeptList,
+  apiParams: apiParams.value,
+  showTotal: true,
+  columns: () => [
+    {
+      key: 'deptName',
+      title: '部门名称',
+      dataIndex: 'deptName',
+      align: 'center',
+      minWidth: 100
+    },
+    {
+      key: 'sort',
+      dataIndex: 'sort',
+      title: '排序',
+      align: 'center',
+      minWidth: 120
+    },
+    {
+      key: 'status',
+      dataIndex: 'status',
+      title: '状态',
+      align: 'center',
+      minWidth: 100,
+      customRender: ({ record }) => {
+        let statusMap = {
+          1: '停用',
+          2: '启用',
+        }
+        let typeMap = {
+          1: 'error',
+          2: 'success',
+        }
+        return <a-tag color={typeMap[record.status]}>{statusMap[record.status]}</a-tag>
+      }
+    },
+    {
+      key: 'createdAt',
+      dataIndex: 'createdAt',
+      title: '创建时间',
+      align: 'center',
+      minWidth: 150,
+      customRender: ({ record }) => {
+        return dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss')
+      }
+    },
+    {
+      key: 'operate',
+      title: $t('common.operate'),
+      align: 'center',
+      fixed: 'right',
+      width: 180,
+      customRender: ({ record }) => (
+        <div class="flex-center gap-0.1rem">
+          <Button type="primary" ghost size="small" onClick={() => handleRowEdit(record.userId)} style="margin-right:5px">
+            {$t('common.edit')}
+          </Button>
+          <Popconfirm title={$t('common.confirmDelete')} onConfirm={() => handleDelete(record.userId)}>
+            <Button danger size="small" style="margin-right:5px">
+              {$t('common.delete')}
+            </Button>
+          </Popconfirm>
+          <Button size="small" onClick={() => handleResetPwd(record)}>
+            重置
+          </Button>
+        </div>
+      )
+    }
+  ]
+});
+const {
+  drawerVisible,
+  operateType,
+  editingData,
+  handleAdd,
+  handleEdit,
+  checkedRowKeys,
+  onDeleted
+  // closeDrawer
+} = useTableOperate(data, getData);
+
+async function handleBatchDelete() {
+  // request
+  handleDelete(checkedRowKeys.value)
+}
+// 删除
+async function handleDelete(id: any) {
+  // request
+  let ids = []
+  typeof id === 'number' ? ids.push(id) : ids = [].concat(id)
+  const res = await fetchDelteUser({ ids })
+  if (res.code === 200) {
+    onDeleted();
+  } else {
+    window.$message?.error('删除失败!');
+  }
+}
+
+// 重置
+function handleResetPwd(row: any) {
+  resetDialogVisible.value = true
+  userName = row.username
+  transUserId = row.userId
+}
+
+// 编辑
+function handleRowEdit(id: number) {
+  handleEdit(id, 'userId');
+}
+</script>
+
+<template>
+  <div class="min-h-6.25rem flex-col-stretch gap-0.2rem overflow-hidden lt-sm:overflow-auto">
+    <MSearch v-model:model="searchParams" @reset="resetSearchParams" @search="getDataByPage" />
+    <ACard title="部门列表" :bordered="false" :body-style="{ flex: 1, overflow: 'hidden' }"
+      class="flex-col-stretch sm:flex-1-hidden card-wrapper">
+      <template #extra>
+        <TableHeaderOperation v-model:columns="columnChecks" :disabled-delete="checkedRowKeys.length === 0"
+          :loading="loading" @add="handleAdd" @delete="handleBatchDelete" @refresh="getData" />
+      </template>
+      <ATable ref="tableWrapperRef" :columns="columns" :data-source="data" size="small" :scroll="scrollConfig"
+        :loading="loading" row-key="userId" :pagination="mobilePagination" class="h-full"
+        :defaultExpandAllRows="true" />
+
+      <UserOperateDrawer v-model:visible="drawerVisible" :operate-type="operateType" :row-data="editingData"
+        @submitted="getDataByPage" />
+      <UserResetPwdDialog v-model:dialogVisible="resetDialogVisible" :userId="transUserId" :user-name="userName" />
+    </ACard>
+  </div>
+</template>
+
+<style scoped></style>

+ 105 - 0
src/views/admin/sys-dept/modules/m-search.vue

@@ -0,0 +1,105 @@
+/* eslint-disable */
+<script setup lang="ts">
+import { enableStatusOptions } from '@/constants/business';
+import { useAntdForm, useFormRules } from '@/hooks/common/form';
+import { $t } from '@/locales';
+import { fetchGetdeptTreeList } from '@/service/api';
+import { translateOptions } from '@/utils/common';
+import { computed, onMounted, ref } from 'vue';
+defineOptions({
+  name: 'MSearch'
+});
+
+interface Emits {
+  (e: 'reset'): void;
+  (e: 'search'): void;
+}
+
+const emit = defineEmits<Emits>();
+const departOptions = ref<CommonType.Option<string>[]>([]);
+const { formRef, validate, resetFields } = useAntdForm();
+
+const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true });
+
+type RuleKey = Extract<keyof Api.SystemManage.UserSearchParams, 'email' | 'phone'>;
+
+const rules = computed<Record<RuleKey, App.Global.FormRule>>(() => {
+  const { patternRules } = useFormRules(); // inside computed to make locale reactive
+
+  return {
+    email: patternRules.email,
+    phone: patternRules.phone
+  };
+});
+onMounted(()=>{
+  getDeptTree()
+})
+async function getDeptTree(){
+  const { data } = await fetchGetdeptTreeList()
+  departOptions.value = data
+}
+async function reset() {
+  await resetFields();
+  emit('reset');
+}
+
+async function search() {
+  await validate();
+  emit('search');
+}
+
+</script>
+
+<template>
+  <ACard :title="$t('common.search')" :bordered="false" class="card-wrapper">
+    <AForm
+      ref="formRef"
+      :model="model"
+      :rules="rules"
+      :label-col="{
+        span: 5,
+        md: 7
+      }"
+    >
+      <ARow :gutter="[16, 16]" wrap>
+        <ACol :span="24" :md="12" :lg="6">
+          <AFormItem label="部门名称" name="deptName" class="m-0">
+            <AInput v-model:value.trim="model.username" placeholder="请输入部门名称" @keyup.enter.native="search" allowClear/>
+          </AFormItem>
+        </ACol>
+        
+        <ACol :span="24" :md="12" :lg="6">
+          <AFormItem label="部门状态" name="status" class="m-0">
+            <ASelect
+              v-model:value="model.status"
+              placeholder="请选择部门状态"
+              :options="translateOptions(enableStatusOptions)"
+              @change="search"
+              allowClear
+            />
+          </AFormItem>
+        </ACol>
+        <div class="flex-1">
+          <AFormItem class="m-0">
+            <div class="w-full flex-y-center justify-end gap-12px">
+              <AButton @click="reset">
+                <template #icon>
+                  <icon-ic-round-refresh class="align-sub text-icon" />
+                </template>
+                <span class="ml-8px">{{ $t('common.reset') }}</span>
+              </AButton>
+              <AButton type="primary" ghost @click="search">
+                <template #icon>
+                  <icon-ic-round-search class="align-sub text-icon" />
+                </template>
+                <span class="ml-8px">{{ $t('common.search') }}</span>
+              </AButton>
+            </div>
+          </AFormItem>
+        </div>
+      </ARow>
+    </AForm>
+  </ACard>
+</template>
+
+<style scoped></style>

+ 248 - 0
src/views/admin/sys-dept/modules/user-operate-drawer.vue

@@ -0,0 +1,248 @@
+/* eslint-disable */
+<script setup lang="ts">
+import { useAntdForm, useFormRules } from '@/hooks/common/form';
+import { $t } from '@/locales';
+import { fetchAddUser, fetchGetAllRoles, fetchGetdeptTreeList, fetchGetPostList, getDicts } from '@/service/api';
+import { computed, nextTick, reactive, ref, watch } from 'vue';
+defineOptions({
+  name: 'UserOperateDrawer'
+});
+
+interface Props {
+  /** the type of operation */
+  operateType: AntDesign.TableOperateType;
+  /** the edit row data */
+  rowData?: Api.SystemManage.User | null;
+}
+
+const props = defineProps<Props>();
+
+interface Emits {
+  (e: 'submitted'): void;
+}
+
+const emit = defineEmits<Emits>();
+
+const visible = defineModel<boolean>('visible', {
+  default: false
+});
+
+const { formRef, validate, resetFields } = useAntdForm();
+const { defaultRequiredRule, formRules } = useFormRules();
+
+const title = computed(() => {
+  const titles: Record<AntDesign.TableOperateType, string> = {
+    add: $t('page.manage.user.addUser'),
+    edit: $t('page.manage.user.editUser')
+  };
+  return titles[props.operateType];
+});
+
+type Model = Pick<
+  Api.SystemManage.User,
+  'username' | 'sex' | 'nickName' | 'phone' | 'email' | 'status' | 'deptId' | 'password' | 'postId' | 'roleId' | 'remark'
+>;
+
+const model: Model = reactive(createDefaultModel());
+
+function createDefaultModel(): Model {
+  return {
+    username: '',
+    sex: '',
+    nickName: '',
+    phone: '',
+    email: '',
+    status: '2',
+    deptId: '',
+    password: '',
+    postId: '',
+    roleId: '',
+    remark: ''
+  };
+}
+
+type RuleKey = Extract<keyof Model, 'username' | 'status'| 'nickName'| 'deptId' | 'password'| 'email' | 'phone'>;
+
+const rules: Record<RuleKey, App.Global.FormRule> = {
+  username: defaultRequiredRule,
+  nickName: defaultRequiredRule,
+  deptId: defaultRequiredRule,
+  password: defaultRequiredRule,
+  phone: formRules.phone,
+  email:formRules.email
+};
+
+/** the enabled role options */
+const roleOptions = ref<CommonType.Option<string>[]>([]);
+const userGenderOptions = ref<CommonType.Option<string>[]>([
+  {label: "男", value: "0"},
+  {label: "女", value: "1"},
+  {label: "未知", value: "2"}
+]);
+const enableStatusOptions = ref<CommonType.Option<string>[]>([]);
+const postOptions = ref<CommonType.Option<string>[]>([]);
+const departOptions = ref<CommonType.Option<string>[]>([]);
+
+async function getSixOptions(){
+  const { data } = await getDicts('sys_user_sex')
+  userGenderOptions.value = data || []
+}
+async function getStatusOptions(){
+  const { data } = await getDicts('sys_normal_disable')
+  enableStatusOptions.value = data || []
+}
+async function getPostOptions(){
+  const { data } = await fetchGetPostList({ pageSize: 1000 })
+  const options = data?.list.map((item:any) => ({
+    label: item.postName,
+    value: item.postId,
+    postCode: item.postCode
+  }));
+  postOptions.value = [...options] || []
+}
+async function getDeptTree(){
+  const { data } = await fetchGetdeptTreeList()
+  departOptions.value = data
+}
+
+async function getRoleOptions() {
+  const { data } = await fetchGetAllRoles({ pageSize: 1000 });
+  const options = data?.list.map((item:any) => ({
+    label: item.roleName,
+    value: item.roleId,
+    status: item.status
+  }));
+  roleOptions.value = [...options];
+}
+
+async function handleInitModel() {
+  Object.assign(model, createDefaultModel());
+
+  if (props.operateType === 'edit' && props.rowData) {
+    await nextTick();
+    Object.assign(model, props.rowData);
+  }
+}
+
+function closeDrawer() {
+  visible.value = false;
+}
+
+async function handleSubmit() {
+  await validate();
+  // request 新增method 是post 修改是put
+  let method = ''
+  if (props.operateType === 'add') {
+    method = 'post'
+  } else {
+    method = 'put'
+  }
+  const res = await fetchAddUser(model, method)
+  if (res.code === 200){
+    window.$message?.success(res.msg);
+  }
+  closeDrawer();
+  emit('submitted');
+}
+
+watch(visible, () => {
+  if (visible.value) {
+    handleInitModel();
+    resetFields();
+    getRoleOptions();
+    getSixOptions()
+    getStatusOptions()
+    getPostOptions()
+    getDeptTree()
+  }
+});
+</script>
+
+<template>
+  <AModal v-model:open="visible" :title="title" width="800px">
+    <AForm ref="formRef"  :model="model" :rules="rules" :label-col="{ lg: 8, xs: 4 }" label-wrap class="pr-20px">
+      <ARow  wrap>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.user.nickName')" name="nickName">
+            <AInput v-model:value="model.nickName" :placeholder="$t('page.manage.user.form.nickName')" allowClear/>
+          </AFormItem>
+        </ACol>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.user.deptId')" name="roleId">
+            <ATreeSelect v-model:value="model.deptId" show-search  :tree-data="departOptions"
+            :fieldNames="{value: 'id', children: 'children', label: 'label'}"
+              :placeholder="$t('page.manage.user.form.deptId')" allowClear/>
+          </AFormItem>
+        </ACol>
+      </ARow>
+      <ARow wrap>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.user.userPhone')" name="phone">
+            <AInput v-model:value="model.phone" :placeholder="$t('page.manage.user.form.userPhone')" allowClear/>
+          </AFormItem>
+        </ACol>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.user.userEmail')" name="email">
+            <AInput v-model:value="model.email" :placeholder="$t('page.manage.user.form.userEmail')" allowClear/>
+          </AFormItem>
+        </ACol>
+      </ARow>
+      <ARow wrap>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.user.userName')" name="username">
+            <AInput v-model:value="model.username" :placeholder="$t('page.manage.user.form.userName')" allowClear/>
+          </AFormItem>
+        </ACol>
+        <ACol :span="12" :md="12" :xs="24" v-if="props.operateType === 'add'">
+          <AFormItem :label="$t('page.manage.user.password')" name="password" >
+            <AInput v-model:value="model.password" :placeholder="$t('page.manage.user.form.password')" allowClear/>
+          </AFormItem>
+        </ACol>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.user.userGender')" name="sex">
+            <ARadioGroup v-model:value="model.sex">
+              <ARadio v-for="item in userGenderOptions" :key="item.value" :value="item.value">
+                {{item.label }}
+              </ARadio>
+            </ARadioGroup>
+          </AFormItem>
+        </ACol>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.user.userStatus')" name="status">
+            <ARadioGroup v-model:value="model.status">
+              <ARadio v-for="item in enableStatusOptions" :key="item.value" :value="item.value">
+                {{ item.label }}
+              </ARadio>
+            </ARadioGroup>
+          </AFormItem>
+        </ACol>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.user.postId')" name="postId">
+            <ASelect v-model:value="model.postId" :options="postOptions"
+              :placeholder="$t('page.manage.user.form.postId')" allowClear/>
+          </AFormItem>
+        </ACol>
+        <ACol :span="12" :md="props.operateType === 'add'? 12: 24" :xs="24">
+          <AFormItem :label="$t('page.manage.user.userRole')" name="roleId">
+            <ASelect v-model:value="model.roleId"  :options="roleOptions"
+              :placeholder="$t('page.manage.user.form.userRole')" allowClear/>
+          </AFormItem>
+        </ACol>
+        <ACol :span="24" :md="24" :xs="24">
+          <AFormItem :label-col="{ lg: 4, xs: 2 }" :label="$t('page.manage.user.remark')" name="remark">
+            <a-textarea v-model:value="model.remark" :placeholder="$t('page.manage.user.form.remark')"
+              :auto-size="{ minRows: 2, maxRows: 5 }" allowClear/>
+          </AFormItem>
+        </ACol>
+      </ARow>
+    </AForm>
+    <template #footer>
+      <ASpace :size="16">
+        <AButton @click="closeDrawer">{{ $t('common.cancel') }}</AButton>
+        <AButton type="primary" @click="handleSubmit">{{ $t('common.confirm') }}</AButton>
+      </ASpace>
+    </template>
+  </AModal>
+</template>
+
+<style scoped></style>

+ 65 - 0
src/views/admin/sys-dept/modules/user-resetpwd-dialog.vue

@@ -0,0 +1,65 @@
+/* eslint-disable */
+<script setup lang="ts">
+import { useAntdForm, useFormRules } from '@/hooks/common/form';
+import { fetchRestPwsUser } from '@/service/api';
+import { reactive, watch } from 'vue';
+defineOptions({
+  name: 'UserResetPwdDialog'
+});
+const visible = defineModel<boolean>('dialogVisible', {
+  default: false
+});
+interface Props {
+  userId: number | null,
+  userName: string | null
+}
+const props = defineProps<Props>();
+const model = reactive(createDefaultModel());
+const { formRef, validate, resetFields } = useAntdForm();
+const { formRules } = useFormRules();
+function createDefaultModel(){
+  return {
+    password: '',
+    userId: props.userId
+  };
+}
+const rules: Record<'password', App.Global.FormRule> = {
+  password: formRules.pwd,
+};
+
+function closeDrawer() {
+  visible.value = false;
+}
+async function handleSubmit() {
+  await validate();
+  model.userId = props.userId
+
+  const res = await fetchRestPwsUser(model)
+  if (res.code === 200){
+    window.$message?.success(res.msg);
+  }
+  closeDrawer();
+}
+watch(visible, () => {
+  if (visible.value) {
+    resetFields();
+  }
+});
+</script>
+<template>
+  <AModal v-model:open="visible" title="重置密码" width="420px">
+    <!-- <p style="margin-bottom: 10px;"></p> -->
+    <AForm ref="formRef" layout="vertical" :model="model" :rules="rules">
+      <AFormItem :label="`请输入${userName}的新密码`" name="password">
+        <AInput v-model:value="model.password" placeholder="请输入新密码" allowClear/>
+      </AFormItem>
+    </AForm>
+    <template #footer>
+      <ASpace :size="16">
+        <AButton @click="closeDrawer">{{ $t('common.cancel') }}</AButton>
+        <AButton type="primary" @click="handleSubmit">{{ $t('common.confirm') }}</AButton>
+      </ASpace>
+    </template>
+  </AModal>
+</template>
+<style scoped></style>

+ 237 - 0
src/views/admin/sys-menu/index.vue

@@ -0,0 +1,237 @@
+<script setup lang="tsx">
+import SvgIcon from '@/components/custom/svg-icon.vue';
+import { useTable, useTableOperate, useTableScroll } from '@/hooks/common/table';
+import { $t } from '@/locales';
+import { fetchDeleteMenu, fetchGetAllPages, fetchGetMenuList, getDicts } from '@/service/api';
+import { useBoolean } from '@sa/hooks';
+import { Button, Popconfirm } from 'ant-design-vue';
+import type { Ref } from 'vue';
+import { onMounted, ref } from 'vue';
+import MenuOperateModal, { type OperateType } from './modules/menu-operate-modal.vue';
+import MenuSearch from './modules/menu-search.vue';
+const { bool: visible, setTrue: openModal } = useBoolean();
+const { tableWrapperRef, scrollConfig } = useTableScroll();
+const visibleOptions = ref([])
+const { columns, columnChecks, data, loading, getData, getDataByPage, searchParams, resetSearchParams } = useTable({
+  apiFn: fetchGetMenuList,
+  apiParams: {
+    title: '',
+    visible: ''
+  },
+  columns: () => [
+    {
+      key: 'menuId',
+      title: $t('page.manage.menu.id'),
+      align: 'center',
+      dataIndex: 'menuId'
+    },
+    {
+      key: 'title',
+      title: $t('page.manage.menu.menuName'),
+      align: 'center',
+      minWidth: 120,
+      dataIndex: 'title'
+    },
+    {
+      key: 'icon',
+      title: $t('page.manage.menu.icon'),
+      align: 'center',
+      width: 60,
+      customRender: ({ record }) => {
+        return (
+          <div class="flex-center">
+            <SvgIcon localIcon={record.icon}  class="text-icon" />
+          </div>
+        );
+      }
+    },
+    {
+      key: 'path',
+      title: '组件路径',
+      align: 'center',
+      customRender: ({ record }) => {
+        if (record.menuType=='A') {
+          return <a-tooltip placement="top" title={record.path}>{record.path}</a-tooltip>;
+        } else {
+          return <a-tooltip placement="top" title={record.component}>{record.component}</a-tooltip>
+        }
+      }
+    },
+    {
+      key: 'permission',
+      title: '权限标识',
+      dataIndex: 'permission',
+      align: 'center',
+      customRender: ({ record }) => {
+        return  <a-tooltip placement="top" title={record.permission}><span >{record.permission || '--' }</span></a-tooltip>
+      }
+    },
+    {
+      key: 'sort',
+      dataIndex: 'sort',
+      title: $t('page.manage.menu.order'),
+      align: 'center',
+      width: 60
+    },
+    {
+      key: 'visible',
+      dataIndex: 'visible',
+      title: '菜单状态',
+      width: 90,
+      align: 'center',
+      customRender: ({ record }) => {
+        return  <a-tag color={record.visible === '1'? 'error' : 'success'}><span >{visibleFormat(record) }</span></a-tag>
+      }
+    },
+    {
+      key: 'operate',
+      title: $t('common.operate'),
+      align: 'center',
+      width: 230,
+      customRender: ({ record }) => (
+        <div class="flex-center justify-end gap-8px">
+          {record.menuType === 'M' && (
+            <Button type="primary" ghost size="small" onClick={() => handleAddChildMenu(record)}>
+              {$t('page.manage.menu.addChildMenu')}
+            </Button>
+          )}
+          <Button type="primary" ghost size="small" onClick={() => handleEdit(record)}>
+            {$t('common.edit')}
+          </Button>
+          <Popconfirm title={$t('common.confirmDelete')} onConfirm={() => handleDelete(record.menuId)}>
+            <Button danger ghost size="small">
+              {$t('common.delete')}
+            </Button>
+          </Popconfirm>
+        </div>
+      )
+    }
+  ]
+});
+
+const { checkedRowKeys, rowSelection, onDeleted } = useTableOperate(data, getData);
+
+const operateType = ref<OperateType>('add');
+onMounted(() => {
+  getOptions();
+});
+function handleAdd() {
+  operateType.value = 'add';
+  openModal();
+}
+
+async function handleBatchDelete() {
+  // request
+  handleDelete(checkedRowKeys.value)
+}
+
+async function handleDelete(id: any) {
+  // request
+  let ids = []
+  typeof id === 'number' ? ids.push(id) : ids = [].concat(id)
+  const res = await fetchDeleteMenu({ids})
+  if (res.code === 200) {
+    onDeleted();
+  } else {
+    window.$message?.error('删除失败!');
+  }
+}
+/** the edit menu data or the parent menu data when adding a child menu */
+const editingData: Ref<Api.SystemManage.Menu | null> = ref(null);
+
+function handleEdit(item: Api.SystemManage.Menu) {
+  operateType.value = 'edit';
+  editingData.value = { ...item };
+
+  openModal();
+}
+
+function handleAddChildMenu(item: Api.SystemManage.Menu) {
+  operateType.value = 'addChild';
+
+  editingData.value = { ...item };
+
+  openModal();
+}
+
+const allPages = ref<string[]>([]);
+
+async function getAllPages() {
+  const { data: pages } = await fetchGetAllPages();
+  allPages.value = pages || [];
+}
+
+function init() {
+  getAllPages();
+}
+async function getOptions (){
+  const res  = await getDicts('sys_show_hide')
+  if (res.code == 200) {
+    visibleOptions.value = res.data
+  }
+}
+// 菜单显示状态字典翻译
+function visibleFormat(row:any) {
+  if (row.menuType === 'F') {
+    return '--'
+  }
+  return selectDictLabel(visibleOptions.value, row.visible)
+}
+function selectDictLabel(datas:any, value:any) {
+  var actions:any = []
+  Object.keys(datas).map((key) => {
+    if (datas[key].value === ('' + value)) {
+      actions.push(datas[key].label)
+      return false
+    }
+  })
+  return actions.join('')
+}
+// init
+init();
+</script>
+
+<template>
+  <div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
+    <MenuSearch v-model:model="searchParams" @reset="resetSearchParams" @search="getDataByPage(1, false)"  :visibleOptions="visibleOptions"/>
+    <ACard
+      :title="$t('page.manage.menu.title')"
+      :bordered="false"
+      :body-style="{ flex: 1, overflow: 'hidden' }"
+      class="flex-col-stretch sm:flex-1-hidden card-wrapper"
+    >
+      <template #extra>
+        <TableHeaderOperation
+          v-model:columns="columnChecks"
+          :disabled-delete="checkedRowKeys.length === 0"
+          :loading="loading"
+          @add="handleAdd"
+          @delete="handleBatchDelete"
+          @refresh="getData"
+        />
+      </template>
+      <ATable
+        ref="tableWrapperRef"
+        :columns="columns"
+        :data-source="data"
+        :row-selection="rowSelection"
+        size="small"
+        :loading="loading"
+        row-key="menuId"
+        :scroll="scrollConfig"
+        :pagination="false"
+        class="h-full"
+      />
+      <MenuOperateModal
+        v-model:visible="visible"
+        :operate-type="operateType"
+        :row-data="editingData"
+        :all-pages="allPages"
+        :visibleOptions="visibleOptions"
+        @submitted="getDataByPage"
+      />
+    </ACard>
+  </div>
+</template>
+
+<style scoped></style>

+ 615 - 0
src/views/admin/sys-menu/modules/menu-operate-modal.vue

@@ -0,0 +1,615 @@
+<script setup lang="tsx">
+import SvgIcon from '@/components/custom/svg-icon.vue';
+import { menuTypeOptions } from '@/constants/business';
+import { useAntdForm, useFormRules } from '@/hooks/common/form';
+import { $t } from '@/locales';
+import { fetchAddMenu, fetchGetMenuDetail, fetchGetMenuList, fetchGetSysApiList, fetchUpdateMenu } from '@/service/api';
+import { getLocalIcons } from '@/utils/icon';
+import { SimpleScrollbar } from '@sa/materials';
+import { computed, nextTick, reactive, ref, watch } from 'vue';
+// import {
+//   getLayoutAndPage,
+//   getPathParamFromRoutePath,
+//   getRoutePathByRouteName,
+//   getRoutePathWithParam,
+//   transformLayoutAndPageToComponent
+// } from './shared';
+
+import {
+QuestionCircleFilled
+} from '@ant-design/icons-vue';
+defineOptions({
+  name: 'MenuOperateModal'
+});
+const menuOptions = ref([])
+const sysapiList = ref([])
+export type OperateType = AntDesign.TableOperateType | 'addChild';
+interface Option {
+  label: string;
+  value: string;
+}
+interface Props {
+  /** the type of operation */
+  operateType: OperateType;
+  /** the edit menu data or the parent menu data when adding a child menu */
+  rowData?: Api.SystemManage.Menu | null;
+  /** all pages */
+  allPages: string[];
+  visibleOptions: Option[];
+}
+
+const props = defineProps<Props>();
+
+interface Emits {
+  (e: 'submitted'): void;
+}
+
+const emit = defineEmits<Emits>();
+
+const visible = defineModel<boolean>('visible', {
+  default: false
+});
+
+const { formRef, validate, resetFields } = useAntdForm();
+const { defaultRequiredRule } = useFormRules();
+
+const title = computed(() => {
+  const titles: Record<OperateType, string> = {
+    add: $t('page.manage.menu.addMenu'),
+    addChild: $t('page.manage.menu.addChildMenu'),
+    edit: $t('page.manage.menu.editMenu')
+  };
+  return titles[props.operateType];
+});
+
+type Model = Pick<
+  Api.SystemManage.Menu,
+  | 'menuType'
+  | 'menuName'
+  | 'path'
+  | 'component'
+  | 'sort'
+  | 'i18nKey'
+  | 'icon'
+  | 'visible'
+  | 'parentId'
+  | 'isFrame'
+  | 'permission'
+  | 'title'
+  | 'apis'
+> & {
+  // query: NonNullable<Api.SystemManage.Menu['query']>;
+  // buttons: NonNullable<Api.SystemManage.Menu['buttons']>;
+  layout: string;
+  path: string;
+};
+
+const model: Model = reactive(createDefaultModel());
+
+function createDefaultModel(): Model {
+  return {
+    menuType: 'M',
+    title: '',
+    path: '',
+    component: '',
+    layout: '',
+    // i18nKey: null,
+    icon: '',
+    parentId: 0,
+    visible: '0',
+    isFrame: '1',
+    sort: 0,
+    menuName: '',
+    permission: '',
+    apis: []
+  };
+}
+
+type RuleKey = Extract<keyof Model, 'title' >;
+
+const rules: Record<RuleKey, App.Global.FormRule> = {
+  title: defaultRequiredRule
+};
+
+const isEdit = computed(() => props.operateType === 'edit');
+
+const localIcons = getLocalIcons();
+const localIconOptions = localIcons.map(item => ({
+  label: () => (
+    <div class="flex-y-center gap-16px">
+      <SvgIcon localIcon={item} class="text-icon" />
+      <span>{item}</span>
+    </div>
+  ),
+  value: item
+}));
+
+const showLayout = computed(() => model.parentId === 0);
+
+
+const layoutOptions: CommonType.Option[] = [
+  {
+    label: 'base',
+    value: 'base'
+  },
+  {
+    label: 'blank',
+    value: 'blank'
+  }
+];
+
+
+
+/** - add a query input */
+// function addQuery(index: number) {
+//   model.query.splice(index + 1, 0, {
+//     key: '',
+//     value: ''
+//   });
+// }
+
+// /** - remove a query input */
+// function removeQuery(index: number) {
+//   model.query.splice(index, 1);
+// }
+
+/** - add a button input */
+// function addButton(index: number) {
+//   model.buttons.splice(index + 1, 0, {
+//     code: '',
+//     desc: ''
+//   });
+// }
+
+// /** - remove a button input */
+// function removeButton(index: number) {
+//   model.buttons.splice(index, 1);
+// }
+
+async function handleInitModel() {
+  Object.assign(model, createDefaultModel());
+
+  if (!props.rowData) return;
+
+  await nextTick();
+
+  if (props.operateType === 'addChild') {
+    const { menuId } = props.rowData;
+
+    Object.assign(model, { parentId: menuId });
+  }
+
+  if (props.operateType === 'edit') {
+    const { menuId } = props.rowData;
+    // const { apis, component, ...rest } = props.rowData;
+
+    // const { layout, page } = getLayoutAndPage(component);
+    // const { path, param } = getPathParamFromRoutePath(rest.path);
+    const res:any = await fetchGetMenuDetail(menuId)
+    if (res && res.code === 200) {
+      Object.assign(model, res.data );
+    }
+
+
+  }
+
+
+}
+
+function closeDrawer() {
+  visible.value = false;
+}
+
+// function handleUpdateRoutePathByRouteName() {
+//   if (model.routeName) {
+//     model.path = getRoutePathByRouteName(model.routeName);
+//   } else {
+//     model.path = '';
+//   }
+// }
+
+// function handleUpdateI18nKeyByRouteName() {
+//   if (model.routeName) {
+//     model.i18nKey = `route.${model.routeName}` as App.I18n.I18nKey;
+//   } else {
+//     model.i18nKey = null;
+//   }
+// }
+
+function getSubmitParams() {
+  const { layout, path, ...params } = model;
+
+  // const component = transformLayoutAndPageToComponent(layout, model.page);
+  // const routePath = getRoutePathWithParam(model.path, pathParam);
+
+  // params.component = component;
+  // params.path = routePath;
+  params.path = path
+
+  return params;
+}
+
+async function handleSubmit() {
+  await validate();
+
+  const params = getSubmitParams();
+  let res:any = {}
+  // request
+  if(isEdit.value) {
+    if (params.menuType === 'M'){
+      model.apis = []
+      params.apis = []
+    }
+    res = await fetchUpdateMenu(params, props.rowData.menuId)
+  } else {
+    res = await fetchAddMenu(params)
+  }
+  if (res && res.code == 200) {
+    if(isEdit.value){
+      window.$message?.success($t('common.updateSuccess'));
+    } else {
+      window.$message?.success('添加成功');
+    }
+
+  } else {
+    window.$message?.error(res.msg || '更新失败!');
+  }
+
+  closeDrawer();
+  emit('submitted');
+}
+/** 查询菜单下拉树结构 */
+function getTreeselect() {
+  fetchGetMenuList().then((response:any) => {
+    if (response.code === 200) {
+      menuOptions.value = []
+      const menu = { menuId: 0, title: '主类目', children: [] }
+      menu.children = response.data
+      menuOptions.value.push(menu)
+    }
+  })
+}
+async function getApiList() {
+  const res:any = await fetchGetSysApiList({ 'pageSize': 10000, 'type': 'BUS' })
+  if (res && res.code === 200) {
+    sysapiList.value = res.data.list
+  }
+}
+function handelChangeType() {
+  model.apis = []
+}
+watch(visible, () => {
+  if (visible.value) {
+    handleInitModel();
+    resetFields();
+    getTreeselect();
+    getApiList();
+  }
+});
+
+// watch(
+//   () => model.routeName,
+//   () => {
+//     handleUpdateRoutePathByRouteName();
+//     handleUpdateI18nKeyByRouteName();
+//   }
+// );
+</script>
+
+<template>
+  <AModal v-model:open="visible" :title="title"  width="800px">
+    <div class="h-480px">
+      <SimpleScrollbar>
+        <AForm ref="formRef"  :model="model" :rules="rules" :label-col="{ lg: 8, xs: 4 }" label-wrap class="pr-20px">
+          <ARow wrap>
+            <ACol :lg="12" :xs="24">
+              <AFormItem label="上级菜单" name="parentId">
+                <ATreeSelect v-model:value="model.parentId" show-search  :tree-data="menuOptions"
+                :fieldNames="{value: 'menuId', children: 'children', label: 'title'}"
+                  placeholder="选择上级菜单" allowClear/>
+              </AFormItem>
+            </ACol>
+            <ACol :lg="12" :xs="24">
+              <AFormItem  name="title">
+                <template #label>
+                  <span class="marginRight5">{{$t('page.manage.menu.menuName')}}</span>
+                  <ATooltip>
+                    <template #title>菜单位置显示的说明信息.</template>
+                    <QuestionCircleFilled />
+                  </ATooltip>
+                </template>
+
+                <AInput v-model:value="model.title" :placeholder="$t('page.manage.menu.form.menuName')" allowClear/>
+              </AFormItem>
+            </ACol>
+            <ACol :lg="12" :xs="24">
+              <AFormItem name="sort">
+                <template #label>
+                  <span class="marginRight5">显示排序</span>
+                  <ATooltip>
+                    <template #title>根据序号升序排列</template>
+                    <QuestionCircleFilled />
+                  </ATooltip>
+                </template>
+                <AInputNumber v-model:value="model.sort as number"  :placeholder="$t('page.manage.menu.form.order')"  class="w-full" />
+
+              </AFormItem>
+            </ACol>
+            <ACol :lg="12" :xs="24">
+              <AFormItem  name="menuType">
+                <template #label>
+                  <span class="marginRight5">{{ $t('page.manage.menu.menuType') }}</span>
+                  <ATooltip>
+                    <template #title>包含目录:以及菜单或者菜单组,菜单:具体对应某一个页面,按钮:功能才做按钮;</template>
+                    <QuestionCircleFilled />
+                  </ATooltip>
+                </template>
+                <ARadioGroup v-model:value="model.menuType" >
+                  <ARadio v-for="item in menuTypeOptions" :key="item.value" :value="item.value" @change="handelChangeType">
+                    {{ $t(item.label) }}
+                  </ARadio>
+                </ARadioGroup>
+              </AFormItem>
+            </ACol>
+
+            <ACol :lg="12" :xs="24" v-if="model.menuType == 'M' || model.menuType == 'C'">
+              <AFormItem  name="menuName" >
+                <template #label>
+                  <span class="marginRight5">{{ $t('page.manage.menu.routeName') }}</span>
+                  <ATooltip>
+                    <template #title>需要和页面name保持一致,对应页面即可选择缓存.</template>
+                    <QuestionCircleFilled />
+                  </ATooltip>
+                </template>
+                <AInput v-model:value="model.menuName" :placeholder="$t('page.manage.menu.form.routeName')" allowClear/>
+              </AFormItem>
+            </ACol>
+            <ACol :lg="12" :xs="24" v-if="model.menuType == 'M' || model.menuType == 'C'" >
+              <AFormItem name="component">
+                <template #label>
+                  <span class="marginRight5">{{ $t('page.manage.menu.routePath') }}</span>
+                  <ATooltip>
+                    <template #title>菜单对应的具体vue页面文件路径;</template>
+                    <QuestionCircleFilled />
+                  </ATooltip>
+                </template>
+                <AInput v-model:value="model.component"  :placeholder="$t('page.manage.menu.form.routePath')" allowClear/>
+              </AFormItem>
+            </ACol>
+
+            <ACol :lg="12" :xs="24"  v-if="model.menuType != 'F'">
+              <AFormItem label="路由地址" name="path">
+                <AInput v-model:value="model.path" placeholder="请输入路由地址" allowClear/>
+              </AFormItem>
+            </ACol>
+            <ACol :lg="12" :xs="24"  v-if="showLayout">
+              <AFormItem :label="$t('page.manage.menu.layout')" name="layout">
+                <ASelect
+                  v-model:value="model.layout"
+                  :options="layoutOptions"
+                  :placeholder="$t('page.manage.menu.form.layout')"
+                  allowClear
+                />
+              </AFormItem>
+            </ACol>
+
+            <!-- <ACol :lg="12" :xs="24">
+              <AFormItem :label="$t('page.manage.menu.i18nKey')" name="i18nKey">
+                <AInput v-model:value="model.i18nKey as string" :placeholder="$t('page.manage.menu.form.i18nKey')" />
+              </AFormItem>
+            </ACol> -->
+            <!-- <ACol :lg="12" :xs="24">
+              <AFormItem :label="$t('page.manage.menu.iconTypeTitle')" name="iconType">
+                <ARadioGroup v-model:value="model.iconType">
+                  <ARadio v-for="item in menuIconTypeOptions" :key="item.value" :value="item.value">
+                    {{ $t(item.label) }}
+                  </ARadio>
+                </ARadioGroup>
+              </AFormItem>
+            </ACol> -->
+
+            <ACol :lg="12" :xs="24">
+              <AFormItem :label="$t('page.manage.menu.icon')" name="icon">
+                <!-- <template v-if="model.iconType === '1'">
+                  <AInput v-model:value="model.icon" :placeholder="$t('page.manage.menu.form.icon')" class="flex-1">
+                    <template #suffix>
+                      <SvgIcon v-if="model.icon" :icon="model.icon" class="text-icon" />
+                    </template>
+                  </AInput>
+                </template> -->
+                  <ASelect
+                    v-model:value="model.icon"
+                    :placeholder="$t('page.manage.menu.form.localIcon')"
+                    :options="localIconOptions"
+                    allowClear
+                  />
+              </AFormItem>
+            </ACol>
+            <ACol :lg="12" :xs="24">
+              <AFormItem name="visible">
+                <template #label>
+                  <span class="marginRight5">{{$t('page.manage.menu.menuStatus') }}</span>
+                  <ATooltip>
+                    <template #title>需要显示在菜单列表的菜单设置为显示,否则设置为隐藏;</template>
+                    <QuestionCircleFilled />
+                  </ATooltip>
+                </template>
+                <ARadioGroup v-model:value="model.visible">
+                  <ARadio v-for="item in visibleOptions" :key="item.value" :value="item.value">
+                    {{ item.label }}
+                  </ARadio>
+                </ARadioGroup>
+              </AFormItem>
+            </ACol>
+            <ACol :lg="12" :xs="24"  v-if="model.menuType == 'M' || model.menuType == 'C'">
+              <AFormItem  name="isFrame">
+                <template #label>
+                  <span class="marginRight5">{{ $t('page.manage.menu.href') }}</span>
+                  <ATooltip>
+                    <template #title>可以通过iframe打开指定地址</template>
+                    <QuestionCircleFilled />
+                  </ATooltip>
+                </template>
+                <ARadioGroup v-model:value="model.isFrame">
+                  <ARadio value="0">{{ $t('common.yesOrNo.yes') }}</ARadio>
+                  <ARadio value="1">{{ $t('common.yesOrNo.no') }}</ARadio>
+                </ARadioGroup>
+              </AFormItem>
+            </ACol>
+
+            <ACol :lg="12" :xs="24" v-if="model.menuType == 'F' || model.menuType == 'C'" >
+              <AFormItem name="component">
+                <template #label>
+                  <span class="marginRight5">权限标识</span>
+                  <ATooltip>
+                    <template #title>前端权限控制按钮是否显示</template>
+                    <QuestionCircleFilled />
+                  </ATooltip>
+                </template>
+                <AInput v-model:value="model.permission"  placeholder="请输入权限标识" />
+              </AFormItem>
+            </ACol>
+            <ACol :lg="24" :xs="24" v-if="model.menuType == 'F' || model.menuType == 'C'">
+              <AFormItem name="component" :label-col="{ style: { width: '120px' } }">
+                <template  #label >
+                  <span class="marginRight5">api权限</span>
+                  <ATooltip>
+                    <template #title>配置在这个才做上需要使用到的接口,否则在设置用户角色时,接口将无权访问。</template>
+                    <QuestionCircleFilled />
+                  </ATooltip>
+                </template>
+                <ATransfer
+                  v-model:target-keys="model.apis"
+                  :data-source="sysapiList"
+                  :rowKey="record => record.id"
+                  show-search
+                  :titles="['未授权','已授权']"
+                  :list-style="{
+                    width: '250px',
+                    height: '300px',
+                  }"
+                  :operations="['授权 ','收回' ]"
+                  :render="item => `${item.title}`"
+                >
+
+                  <template #notFoundContent>
+                    <span>没数据</span>
+                  </template>
+                </ATransfer>
+              </AFormItem>
+            </ACol>
+
+            <!-- <ACol :span="24">
+              <AFormItem :label-col="{ span: 4 }" :label="$t('page.manage.menu.query')" name="query">
+                <AButton v-if="model.query.length === 0" type="dashed" block @click="addQuery(-1)">
+                  <template #icon>
+                    <icon-carbon-add class="align-sub text-icon" />
+                  </template>
+                  <span class="ml-8px">{{ $t('common.add') }}</span>
+                </AButton>
+                <template v-else>
+                  <div v-for="(item, index) in model.query" :key="index" class="flex gap-3">
+                    <ACol :span="9">
+                      <AFormItem :name="['query', index, 'key']">
+                        <AInput
+                          v-model:value="item.key"
+                          :placeholder="$t('page.manage.menu.form.queryKey')"
+                          class="flex-1"
+                        />
+                      </AFormItem>
+                    </ACol>
+                    <ACol :span="9">
+                      <AFormItem :name="['query', index, 'value']">
+                        <AInput
+                          v-model:value="item.value"
+                          :placeholder="$t('page.manage.menu.form.queryValue')"
+                          class="flex-1"
+                        />
+                      </AFormItem>
+                    </ACol>
+                    <ACol :span="5">
+                      <ASpace class="ml-12px">
+                        <AButton size="middle" @click="addQuery(index)">
+                          <template #icon>
+                            <icon-ic:round-plus class="align-sub text-icon" />
+                          </template>
+                        </AButton>
+                        <AButton size="middle" @click="removeQuery(index)">
+                          <template #icon>
+                            <icon-ic-round-remove class="align-sub text-icon" />
+                          </template>
+                        </AButton>
+                      </ASpace>
+                    </ACol>
+                  </div>
+                </template>
+              </AFormItem>
+            </ACol> -->
+            <!-- <ACol :span="24">
+              <AFormItem :label-col="{ span: 4 }" :label="$t('page.manage.menu.button')" name="buttons">
+                <AButton v-if="model.buttons.length === 0" type="dashed" block @click="addButton(-1)">
+                  <template #icon>
+                    <icon-carbon-add class="align-sub text-icon" />
+                  </template>
+                  <span class="ml-8px">{{ $t('common.add') }}</span>
+                </AButton>
+                <template v-else>
+                  <div v-for="(item, index) in model.buttons" :key="index" class="flex gap-3">
+                    <ACol :span="9">
+                      <AFormItem :name="['buttons', index, 'code']">
+                        <AInput
+                          v-model:value="item.code"
+                          :placeholder="$t('page.manage.menu.form.buttonCode')"
+                          class="flex-1"
+                        ></AInput>
+                      </AFormItem>
+                    </ACol>
+                    <ACol :span="9">
+                      <AFormItem :name="['buttons', index, 'desc']">
+                        <AInput
+                          v-model:value="item.desc"
+                          :placeholder="$t('page.manage.menu.form.buttonDesc')"
+                          class="flex-1"
+                        ></AInput>
+                      </AFormItem>
+                    </ACol>
+                    <ACol :span="5">
+                      <ASpace class="ml-12px">
+                        <AButton size="middle" @click="addButton(index)">
+                          <template #icon>
+                            <icon-ic:round-plus class="align-sub text-icon" />
+                          </template>
+                        </AButton>
+                        <AButton size="middle" @click="removeButton(index)">
+                          <template #icon>
+                            <icon-ic-round-remove class="align-sub text-icon" />
+                          </template>
+                        </AButton>
+                      </ASpace>
+                    </ACol>
+                  </div>
+                </template>
+              </AFormItem>
+            </ACol> -->
+          </ARow>
+        </AForm>
+      </SimpleScrollbar>
+    </div>
+    <template #footer>
+      <ASpace justify="end" :size="16">
+        <AButton @click="closeDrawer">{{ $t('common.cancel') }}</AButton>
+        <AButton type="primary" @click="handleSubmit">{{ $t('common.confirm') }}</AButton>
+      </ASpace>
+    </template>
+  </AModal>
+</template>
+
+<style scoped>
+.marginRight5{
+  margin-right: 5px;
+}
+::v-deep .ant-transfer-operation .ant-btn{
+  display: flex;
+  align-items: center;
+}
+</style>

+ 78 - 0
src/views/admin/sys-menu/modules/menu-search.vue

@@ -0,0 +1,78 @@
+/* eslint-disable */
+<script setup lang="ts">defineOptions({
+  name: 'MenuSearch'
+});
+interface Option {
+  label: string;
+  value: string;
+}
+interface Props {
+  visibleOptions: Option[];
+}
+interface Emits {
+  (e: 'reset'): void;
+  (e: 'search'): void;
+}
+defineProps<Props>();
+const emit = defineEmits<Emits>();
+const model = defineModel<Api.SystemManage.MenuSearchParams>('model', { required: true });
+
+
+function reset() {
+  emit('reset');
+}
+
+function search() {
+  emit('search');
+}
+</script>
+
+<template>
+  <ACard :title="$t('common.search')" :bordered="false" class="card-wrapper">
+    <AForm
+      :model="model"
+      :label-col="{
+        span: 5,
+        md: 7
+      }"
+    >
+      <ARow :gutter="[16, 16]" wrap>
+        <ACol :span="24" :md="12" :lg="6">
+          <AFormItem :label="$t('page.manage.menu.menuName')" name="title" class="m-0">
+            <AInput v-model:value.trim="model.title" :placeholder="$t('page.manage.menu.form.menuName')" @keyup.enter.native="search" allowClear/>
+          </AFormItem>
+        </ACol>
+
+        <ACol :span="24" :md="12" :lg="6">
+          <AFormItem :label="$t('page.manage.menu.menuStatus')" name="visible" class="m-0">
+            <ASelect v-model:value="model.visible"  allowClear @change="search">
+              <ASelectOption v-for="option in visibleOptions" :key="option.value" :value="option.value" >
+                {{ option.label }}
+              </ASelectOption>
+            </ASelect>
+          </AFormItem>
+        </ACol>
+        <ACol :span="24" :md="12" :lg="6">
+          <AFormItem class="m-0">
+            <div class="w-full flex-y-center justify-end gap-12px">
+              <AButton @click="reset">
+                <template #icon>
+                  <icon-ic-round-refresh class="align-sub text-icon" />
+                </template>
+                <span class="ml-8px">{{ $t('common.reset') }}</span>
+              </AButton>
+              <AButton type="primary" ghost @click="search">
+                <template #icon>
+                  <icon-ic-round-search class="align-sub text-icon" />
+                </template>
+                <span class="ml-8px">{{ $t('common.search') }}</span>
+              </AButton>
+            </div>
+          </AFormItem>
+        </ACol>
+      </ARow>
+    </AForm>
+  </ACard>
+</template>
+
+<style scoped></style>

+ 79 - 0
src/views/admin/sys-menu/modules/shared.ts

@@ -0,0 +1,79 @@
+const LAYOUT_PREFIX = 'layout.';
+const VIEW_PREFIX = 'view.';
+const FIRST_LEVEL_ROUTE_COMPONENT_SPLIT = '$';
+
+export function getLayoutAndPage(component?: string | null) {
+  let layout = '';
+  let page = '';
+
+  const [layoutOrPage = '', pageItem = ''] = component?.split(FIRST_LEVEL_ROUTE_COMPONENT_SPLIT) || [];
+
+  layout = getLayout(layoutOrPage);
+  page = getPage(pageItem || layoutOrPage);
+
+  return { layout, page };
+}
+
+function getLayout(layout: string) {
+  return layout.startsWith(LAYOUT_PREFIX) ? layout.replace(LAYOUT_PREFIX, '') : '';
+}
+
+function getPage(page: string) {
+  return page.startsWith(VIEW_PREFIX) ? page.replace(VIEW_PREFIX, '') : '';
+}
+
+export function transformLayoutAndPageToComponent(layout: string, page: string) {
+  const hasLayout = Boolean(layout);
+  const hasPage = Boolean(page);
+
+  if (hasLayout && hasPage) {
+    return `${LAYOUT_PREFIX}${layout}${FIRST_LEVEL_ROUTE_COMPONENT_SPLIT}${VIEW_PREFIX}${page}`;
+  }
+
+  if (hasLayout) {
+    return `${LAYOUT_PREFIX}${layout}`;
+  }
+
+  if (hasPage) {
+    return `${VIEW_PREFIX}${page}`;
+  }
+
+  return '';
+}
+
+/**
+ * Get route name by route path
+ *
+ * @param routeName
+ */
+export function getRoutePathByRouteName(routeName: string) {
+  return `/${routeName.replace(/_/g, '/')}`;
+}
+
+/**
+ * Get path param from route path
+ *
+ * @param routePath route path
+ */
+export function getPathParamFromRoutePath(routePath: string) {
+  const [path, param = ''] = routePath.split('/:');
+
+  return {
+    path,
+    param
+  };
+}
+
+/**
+ * Get route path with param
+ *
+ * @param routePath route path
+ * @param param path param
+ */
+export function getRoutePathWithParam(routePath: string, param: string) {
+  if (param.trim()) {
+    return `${routePath}/:${param}`;
+  }
+
+  return routePath;
+}

+ 207 - 0
src/views/admin/sys-role/index.vue

@@ -0,0 +1,207 @@
+/* eslint-disable */
+<script setup lang="tsx">
+import { useTable, useTableOperate, useTableScroll } from '@/hooks/common/table';
+import { $t } from '@/locales';
+import { fetchChangeRoleStatus, fetchDeleteRole, fetchGetRoleList } from '@/service/api';
+import { Button, Popconfirm } from 'ant-design-vue';
+import { ref } from 'vue';
+import RuleModifypermisModal from './modules/role-modifypermis-modal.vue';
+import RoleOperateDrawer from './modules/role-operate-drawer.vue';
+import RoleSearch from './modules/role-search.vue';
+const { tableWrapperRef, scrollConfig } = useTableScroll();
+const modalVisible = ref(false)
+const rowRoleKey = ref('')
+const rowRoleName = ref('')
+const rowRoleId = ref()
+const rowData = ref()
+const {
+  columns,
+  columnChecks,
+  data,
+  loading,
+  getData,
+  getDataByPage,
+  mobilePagination,
+  searchParams,
+  resetSearchParams
+} = useTable({
+  apiFn: fetchGetRoleList,
+  apiParams: {
+    pageIndex: 1,
+    pageSize: 10,
+    status: undefined,
+    roleName: undefined,
+    roleKey: undefined
+  },
+  columns: () => [
+    // {
+    //   key: 'index',
+    //   dataIndex: 'index',
+    //   title: $t('common.index'),
+    //   width: 64,
+    //   align: 'center'
+    // },
+    {
+      key: 'roleId',
+      dataIndex: 'roleId',
+      title: '编码',
+      width: 64,
+      align: 'center'
+    },
+    {
+      key: 'roleName',
+      dataIndex: 'roleName',
+      title: $t('page.manage.role.roleName'),
+      align: 'center',
+      minWidth: 120
+    },
+    {
+      key: 'roleKey',
+      dataIndex: 'roleKey',
+      title: $t('page.manage.role.roleKey'),
+      align: 'center',
+      minWidth: 120
+    },
+    {
+      key: 'roleSort',
+      dataIndex: 'roleSort',
+      align: 'center',
+      title: $t('page.manage.role.roleSort'),
+      minWidth: 120
+    },
+    {
+      key: 'status',
+      dataIndex: 'status',
+      title: $t('page.manage.user.userStatus'),
+      align: 'center',
+      minWidth: 100,
+      customRender: ({ record }) => {
+        let showStatus = record.status === '2'? true : false
+        const text = record.status === '2' ? '停用' : '启用'
+        return <Popconfirm title={`确定要${text}${record.roleName}角色吗?`} onConfirm={() => handleChangeStatus(record, text)}>
+          <a-switch v-model:checked={showStatus} />
+        </Popconfirm>
+      }
+    },
+    {
+      key: 'operate',
+      title: $t('common.operate'),
+      align: 'center',
+      width: 200,
+      customRender: ({ record }) => (
+        <div class="flex-center gap-8px">
+          <Button type="primary" ghost size="small" onClick={() => handleRowEdit(record.roleId)}>
+            {$t('common.edit')}
+          </Button>
+          <Button  block size="small" onClick={() => handleModifyPermi(record)}>
+            数据权限
+          </Button>
+          <Popconfirm onConfirm={() => handleDelete(record.roleId)} title={$t('common.confirmDelete')}>
+            <Button danger size="small">
+              {$t('common.delete')}
+            </Button>
+          </Popconfirm>
+        </div>
+      )
+    }
+  ]
+});
+
+const {
+  drawerVisible,
+  operateType,
+  editingData,
+  handleAdd,
+  handleEdit,
+  checkedRowKeys,
+  rowSelection,
+  onDeleted
+  // closeDrawer
+} = useTableOperate(data, getData);
+
+async function handleBatchDelete() {
+  // request
+  handleDelete(checkedRowKeys.value)
+}
+
+async function handleDelete(id: any) {
+  // request
+  let ids = []
+  typeof id === 'number' ? ids.push(id) : ids = [].concat(id)
+  const res = await fetchDeleteRole({ids})
+  if (res.code === 200) {
+    onDeleted();
+  } else {
+    window.$message?.error('删除失败!');
+  }
+}
+
+function handleRowEdit(id: number) {
+  handleEdit(id, 'roleId');
+}
+// 数据权限
+function handleModifyPermi(row:any){
+  modalVisible.value = true
+  rowData.value = row
+  rowRoleId.value = row.roleId
+  rowRoleKey.value = row.roleKey
+  rowRoleName.value = row.roleName
+
+}
+async function handleChangeStatus(row:any, text:string){
+  let paras = {
+    roleId:row.roleId,
+    status: text === '停用'? '1' : '2'
+  }
+  const res:any = await fetchChangeRoleStatus(paras)
+  if (res.code === 200 ){
+    window.$message?.success($t('common.updateSuccess'));
+  } else {
+    window.$message?.error('更新失败!');
+  }
+}
+</script>
+
+<template>
+  <div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
+    <RoleSearch v-model:model="searchParams" @reset="resetSearchParams" @search="getDataByPage" />
+    <ACard
+      :title="$t('page.manage.role.title')"
+      :bordered="false"
+      :body-style="{ flex: 1, overflow: 'hidden' }"
+      class="flex-col-stretch sm:flex-1-hidden card-wrapper"
+    >
+      <template #extra>
+        <TableHeaderOperation
+          v-model:columns="columnChecks"
+          :disabled-delete="checkedRowKeys.length === 0"
+          :loading="loading"
+          @add="handleAdd"
+          @delete="handleBatchDelete"
+          @refresh="getData"
+        />
+      </template>
+      <ATable
+        ref="tableWrapperRef"
+        :columns="columns"
+        :data-source="data"
+        :row-selection="rowSelection"
+        :loading="loading"
+        row-key="roleId"
+        size="small"
+        :pagination="mobilePagination"
+        :scroll="scrollConfig"
+        class="h-full"
+      />
+      <RoleOperateDrawer
+        v-model:visible="drawerVisible"
+        :operate-type="operateType"
+        :row-data="editingData"
+        @submitted="getDataByPage"
+      />
+      <RuleModifypermisModal v-model:visible="modalVisible"  :rowData="rowData"  @submitted="getDataByPage"/>
+    </ACard>
+  </div>
+</template>
+
+<style scoped></style>

+ 85 - 0
src/views/admin/sys-role/modules/button-auth-modal.vue

@@ -0,0 +1,85 @@
+<script setup lang="ts">
+import { computed, shallowRef } from 'vue';
+import type { DataNode } from 'ant-design-vue/es/tree';
+import { $t } from '@/locales';
+
+defineOptions({
+  name: 'ButtonAuthModal'
+});
+
+interface Props {
+  /** the roleId */
+  roleId: number;
+}
+
+const props = defineProps<Props>();
+
+const visible = defineModel<boolean>('visible', {
+  default: false
+});
+
+function closeModal() {
+  visible.value = false;
+}
+
+const title = computed(() => $t('common.edit') + $t('page.manage.role.buttonAuth'));
+
+const tree = shallowRef<DataNode[]>([]);
+
+async function getAllButtons() {
+  // request
+  tree.value = [
+    { key: 1, title: 'button1', code: 'code1' },
+    { key: 2, title: 'button2', code: 'code2' },
+    { key: 3, title: 'button3', code: 'code3' },
+    { key: 4, title: 'button4', code: 'code4' },
+    { key: 5, title: 'button5', code: 'code5' },
+    { key: 6, title: 'button6', code: 'code6' },
+    { key: 7, title: 'button7', code: 'code7' },
+    { key: 8, title: 'button8', code: 'code8' },
+    { key: 9, title: 'button9', code: 'code9' },
+    { key: 10, title: 'button10', code: 'code10' }
+  ];
+}
+
+const checks = shallowRef<number[]>([]);
+
+async function getChecks() {
+  console.log(props.roleId);
+  // request
+  checks.value = [1, 2, 3, 4, 5];
+}
+
+function handleSubmit() {
+  console.log(checks.value, props.roleId);
+  // request
+
+  window.$message?.success?.($t('common.modifySuccess'));
+
+  closeModal();
+}
+
+function init() {
+  getAllButtons();
+  getChecks();
+}
+
+// init
+init();
+</script>
+
+<template>
+  <AModal v-model:open="visible" :title="title" class="w-480px">
+    <ATree v-model:checked-keys="checks" :tree-data="tree" checkable :height="280" class="h-280px" />
+    <template #footer>
+      <AButton size="small" class="mt-16px" @click="closeModal">
+        {{ $t('common.cancel') }}
+      </AButton>
+      <AButton type="primary" size="small" class="mt-16px" @click="handleSubmit">
+        {{ $t('common.confirm') }}
+      </AButton>
+    </template>
+  </AModal>
+</template>
+
+<style scoped></style>

+ 149 - 0
src/views/admin/sys-role/modules/menu-auth-modal.vue

@@ -0,0 +1,149 @@
+/* eslint-disable */
+<script setup lang="ts">
+import { computed, shallowRef, watch } from 'vue';
+import type { SelectProps } from 'ant-design-vue';
+import type { DataNode } from 'ant-design-vue/es/tree';
+import { $t } from '@/locales';
+import { fetchGetMenuIdsByRole, fetchGetMenuTree } from '@/service/api';
+
+defineOptions({
+  name: 'MenuAuthModal'
+});
+
+interface Props {
+  /** the roleId */
+  roleId: number;
+  isEdit: boolean;
+}
+
+interface Emits {
+  (e: 'submitted', arg1: number[]): void;
+}
+const emit = defineEmits<Emits>();
+const props = defineProps<Props>();
+
+const visible = defineModel<boolean>('visible', {
+  default: false
+});
+
+function closeModal() {
+  visible.value = false;
+}
+
+const title = computed(() => $t('common.edit') + $t('page.manage.role.menuAuth'));
+
+const home = shallowRef('');
+
+async function getHome() {
+  console.log(props.roleId);
+
+  home.value = 'home';
+}
+
+async function updateHome(val: SelectProps['value']) {
+  // request
+
+  home.value = val as string;
+}
+
+const pages = shallowRef<string[]>([]);
+
+async function getPages() {
+  // const { code, data } = await fetchGetAllPages();
+
+  // if (code == 200) {
+  //   pages.value = data;
+  // }
+}
+
+const pageSelectOptions = computed(() => {
+  const opts: CommonType.Option[] = pages.value.map(page => ({
+    label: page,
+    value: page
+  }));
+
+  return opts;
+});
+
+const tree = shallowRef<DataNode[]>([]);
+
+async function getTree() {
+  const { code, data } = await fetchGetMenuTree(0);
+
+  if (code === 200) {
+    tree.value = recursiveTransform(data.menus);
+  }
+}
+
+function recursiveTransform(data: Api.SystemManage.MenuTree[]): DataNode[] {
+  return data.map(item => {
+    const { id: key, label } = item;
+
+    if (item.children) {
+      return {
+        key,
+        title: label,
+        children: recursiveTransform(item.children)
+      };
+    }
+
+    return {
+      key,
+      title: label
+    };
+  });
+}
+
+const menuIds = shallowRef<number[]>([]);
+
+async function getChecks() {
+  console.log(props.roleId);
+  // request
+  const res = await fetchGetMenuIdsByRole(props.roleId)
+  if (res.code === 200) {
+    menuIds.value = res.data.menuIds
+  }
+}
+
+function handleSubmit() {
+  console.log(menuIds.value, props.roleId);
+  emit('submitted', menuIds.value);
+  closeModal();
+}
+
+async function init() {
+  getHome();
+  getPages();
+  await getTree();
+  if (props.isEdit){
+    await getChecks();
+  }
+
+}
+
+watch(visible, val => {
+  if (val) {
+    init();
+  }
+});
+</script>
+
+<template>
+  <AModal v-model:open="visible" :title="title" class="w-480px">
+    <!-- <div class="flex-y-center gap-16px pb-12px">
+      <div>{{ $t('page.manage.menu.home') }}</div>
+      <ASelect :value="home" :options="pageSelectOptions" class="w-240px" @update:value="updateHome" />
+    </div> -->
+    <ATree v-model:checked-keys="menuIds" :tree-data="tree" checkable :height="280" class="h-280px" />
+    <template #footer>
+      <AButton size="small" class="mt-16px" @click="closeModal">
+        {{ $t('common.cancel') }}
+      </AButton>
+      <AButton type="primary" size="small" class="mt-16px" @click="handleSubmit">
+        {{ $t('common.confirm') }}
+      </AButton>
+    </template>
+  </AModal>
+</template>
+
+<style scoped></style>

+ 132 - 0
src/views/admin/sys-role/modules/role-modifypermis-modal.vue

@@ -0,0 +1,132 @@
+/* eslint-disable */
+<script setup lang="ts">
+import { useAntdForm, useFormRules } from '@/hooks/common/form';
+import { $t } from '@/locales';
+import { fetchDeptTreeByRole, fetchModifyRolePersion } from '@/service/api';
+import { reactive, ref, watch } from 'vue';
+defineOptions({
+  name: 'RoleModifyPermisModal'
+});
+const visible = defineModel<boolean>('visible', {
+  default: false
+});
+const dataScopeOptions = ref([
+  {
+    value: '1',
+    label: '全部数据权限'
+  },
+  {
+    value: '2',
+    label: '自定数据权限'
+  },
+  {
+    value: '3',
+    label: '本部门数据权限'
+  },
+  {
+    value: '4',
+    label: '本部门及以下数据权限'
+  },
+  {
+    value: '5',
+    label: '仅本人数据权限'
+  }
+])
+interface Emits {
+  (e: 'submitted'): void;
+}
+const emit = defineEmits<Emits>();
+const props = defineProps({
+  rowData: {
+    type: Object,
+    default: () => ({})
+  }
+});
+const { formRef, validate } = useAntdForm();
+const { defaultRequiredRule } = useFormRules();
+const treeData = ref([])
+type Model = Pick<Api.SystemManage.Role,  'roleName'| 'roleKey'| 'dataScope'| 'deptIds'>;
+let model: Model = reactive(createDefaultModel());
+function createDefaultModel(): Model {
+  return {
+    roleName: props.rowData.roleName,
+    roleKey: props.rowData.roleKey,
+    dataScope: props.rowData.dataScope,
+    deptIds: []
+  };
+}
+
+type RuleKey = Exclude<keyof Model, ''>;
+
+const rules: Record<RuleKey, App.Global.FormRule> = {
+  dataScope: defaultRequiredRule
+};
+
+
+
+function closeDrawer() {
+  visible.value = false;
+}
+async function handleSubmit(){
+  await validate()
+  const res = await fetchModifyRolePersion(model)
+  if (res.code === 200){
+    window.$message?.success($t('common.updateSuccess'));
+  } else {
+    window.$message?.error(res.msg);
+  }
+  closeDrawer()
+  emit('submitted');
+}
+function handelChange(){
+  if (treeData.value.length === 0) {
+    getDeptTreeselect()
+  }
+}
+/** 查询部门树结构 */
+async function getDeptTreeselect() {
+  const res = await fetchDeptTreeByRole(props.rowData.roleId)
+  if (res.code === 200) {
+    treeData.value = res.data.depts || []
+    model.deptIds = res.data.checkedKeys || []
+  }
+}
+watch(visible, () => {
+  if (visible.value) {
+    model = props.rowData
+    if(model.dataScope === '2') {
+      getDeptTreeselect()
+    }
+  }
+});
+</script>
+<template>
+  <AModal v-model:open="visible" title="分配数据权限" width="420px">
+    <AForm ref="formRef" layout="vertical" :model="model" :rules="rules">
+      <AFormItem :label="$t('page.manage.role.roleName')" name="roleName">
+        <AInput v-model:value="model.roleName" :placeholder="$t('page.manage.role.form.roleName')" :disabled="true" allowClear/>
+      </AFormItem>
+      <AFormItem :label="$t('page.manage.role.roleKey')" name="roleKey" >
+        <AInput v-model:value.trim="model.roleKey" :placeholder="$t('page.manage.role.form.roleKey')" :disabled="true" allowClear/>
+      </AFormItem>
+      <AFormItem label="权限范围" name="dataScope" >
+        <ASelect v-model:value="model.dataScope"   :placeholder="$t('page.manage.role.form.roleStatus')" allowClear @change="handelChange">
+          <ASelectOption v-for="option in dataScopeOptions" :key="option.value" :value="option.value" >
+            {{ option.label }}
+          </ASelectOption>
+        </ASelect>
+      </AFormItem>
+      <AFormItem label="数据权限" name="dataScope" v-if="model.dataScope === '2'">
+        <ATree v-model:checkedKeys="model.deptIds" :field-names="{children:'children', title:'label', key:'id' }" :tree-data="treeData" allowClear
+        checkable/>
+      </AFormItem>
+    </AForm>
+    <template #footer>
+      <ASpace :size="16">
+        <AButton @click="closeDrawer">{{ $t('common.cancel') }}</AButton>
+        <AButton type="primary" @click="handleSubmit">{{ $t('common.confirm') }}</AButton>
+      </ASpace>
+    </template>
+  </AModal>
+</template>
+<style scoped></style>

+ 206 - 0
src/views/admin/sys-role/modules/role-operate-drawer.vue

@@ -0,0 +1,206 @@
+/* eslint-disable */
+<script setup lang="ts">
+import { computed, nextTick, reactive, ref, watch } from 'vue';
+// import { useBoolean } from '@sa/hooks';
+import { enableStatusOptions } from '@/constants/business';
+import { useAntdForm, useFormRules } from '@/hooks/common/form';
+import { $t } from '@/locales';
+// import MenuAuthModal from './menu-auth-modal.vue';
+import { fetchAddRole, fetchEditRole, fetchGetMenuTree } from '@/service/api';
+import type { DataNode } from 'ant-design-vue/es/tree';
+defineOptions({
+  name: 'RoleOperateDrawer'
+});
+
+interface Props {
+  /** the type of operation */
+  operateType: AntDesign.TableOperateType;
+  /** the edit row data */
+  rowData?: Api.SystemManage.Role | null;
+}
+
+const props = defineProps<Props>();
+
+interface Emits {
+  (e: 'submitted'): void;
+}
+
+const emit = defineEmits<Emits>();
+const tree = ref<DataNode[]>([]);
+const visible = defineModel<boolean>('visible', {
+  default: false
+});
+
+const { formRef, validate, resetFields } = useAntdForm();
+const { defaultRequiredRule } = useFormRules();
+// const { bool: menuAuthVisible, setTrue: openMenuAuthModal } = useBoolean();
+
+const title = computed(() => {
+  const titles: Record<AntDesign.TableOperateType, string> = {
+    add: $t('page.manage.role.addRole'),
+    edit: $t('page.manage.role.editRole')
+  };
+  return titles[props.operateType];
+});
+
+type Model = Pick<Api.SystemManage.Role, 'roleName' | 'roleKey' | 'roleSort' | 'status' | 'remark' | 'menuIds'>;
+
+const model: Model = reactive(createDefaultModel());
+
+function createDefaultModel(): Model {
+  return {
+    roleName: '',
+    roleKey: '',
+    roleSort: 0,
+    status: '2',
+    remark: '',
+    menuIds: []
+  };
+}
+
+type RuleKey = Exclude<keyof Model, 'status' | 'remark' | 'menuIds'>;
+
+const rules: Record<RuleKey, App.Global.FormRule> = {
+  roleName: defaultRequiredRule,
+  roleKey: defaultRequiredRule,
+  roleSort: defaultRequiredRule
+};
+
+const roleId = computed(() => props.rowData?.roleId || 0);
+
+const isEdit = computed(() => props.operateType === 'edit');
+
+async function handleInitModel() {
+  Object.assign(model, createDefaultModel());
+  if (isEdit && props.rowData) {
+
+    await nextTick();
+    Object.assign(model, props.rowData);
+  }
+}
+
+function closeDrawer() {
+  visible.value = false;
+}
+async function getTree() {
+  const { code, data } = await fetchGetMenuTree(roleId.value);
+
+  if (code === 200) {
+    tree.value = recursiveTransform(data.menus);
+    if(isEdit.value){
+      model.menuIds=  data.checkedKeys || []
+    }
+  }
+}
+function recursiveTransform(data: Api.SystemManage.MenuTree[]): DataNode[] {
+  return data.map(item => {
+    const { id: key, label } = item;
+
+    if (item.children) {
+      return {
+        key,
+        title: label,
+        children: recursiveTransform(item.children)
+      };
+    }
+
+    return {
+      key,
+      title: label
+    };
+  });
+}
+async function handleSubmit() {
+  await validate();
+  let res = null
+  if (isEdit.value) { // 编辑
+    res = await fetchEditRole(model, roleId.value)
+  } else {// 新增
+    res = await fetchAddRole(model)
+  }
+  if (res.code === 200) {
+    if(isEdit.value){
+      window.$message?.success($t('common.updateSuccess'));
+    } else {
+      window.$message?.success($t('common.addSuccess'))
+    }
+  } else {
+    window.$message?.error(res.msg);
+  }
+
+  closeDrawer();
+  emit('submitted');
+}
+
+watch(visible, () => {
+  if (visible.value) {
+    handleInitModel();
+    resetFields();
+    getTree()
+  }
+});
+</script>
+
+<template>
+  <AModal v-model:open="visible" :title="title" width="800px">
+    <AForm ref="formRef"  :model="model" :rules="rules" :label-col="{ lg: 8, xs: 4 }" label-wrap class="pr-20px">
+      <ARow wrap>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.role.roleName')" name="roleName">
+            <AInput v-model:value="model.roleName" :disabled="isEdit" :placeholder="$t('page.manage.role.form.roleName')" allowClear />
+          </AFormItem>
+        </ACol>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.role.roleKey')" name="roleKey">
+            <AInput v-model:value="model.roleKey" :disabled="isEdit" :placeholder="$t('page.manage.role.form.roleKey')" allowClear/>
+          </AFormItem>
+        </ACol>
+      </ARow>
+      <ARow wrap>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.role.roleSort')" name="roleSort">
+            <AInputNumber v-model:value="model.roleSort" :placeholder="$t('page.manage.role.form.roleSort')"  style="width:100%"/>
+          </AFormItem>
+        </ACol>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.role.roleStatus')" name="status">
+            <ARadioGroup v-model:value="model.status">
+              <ARadio v-for="item in enableStatusOptions" :key="item.value" :value="item.value">
+                {{ $t(item.label) }}
+              </ARadio>
+            </ARadioGroup>
+          </AFormItem>
+        </ACol>
+      </ARow>
+      <ARow wrap >
+        <ACol :span="24" :md="24" :xs="24">
+          <AFormItem :label-col="{ lg: 4, xs: 2 }" :label="$t('page.manage.user.remark')" name="remark">
+          <a-textarea v-model:value="model.remark" :placeholder="$t('page.manage.user.form.remark')" allowClear
+            :auto-size="{ minRows: 2, maxRows: 5 }" />
+        </AFormItem>
+        </ACol>
+      </ARow>
+      <ARow wrap >
+        <ACol :span="24" :md="24" :xs="24">
+          <AFormItem :label-col="{ lg: 4, xs: 2 }" :label="$t('page.manage.role.menuAuth')" name="remark">
+          <ATree :label-col="{ lg: 4, xs: 2 }" v-model:checked-keys="model.menuIds" :tree-data="tree" checkable :height="280" class="h-280px" allowClear/>
+        </AFormItem>
+        </ACol>
+      </ARow>
+    </AForm>
+    <ASpace >
+      <!-- <AButton @click="openMenuAuthModal">{{ $t('page.manage.role.menuAuth') }}</AButton>
+      <MenuAuthModal v-model:visible="menuAuthVisible" :role-id="roleId" :isEdit="isEdit" @submitted="getMenuIds"/> -->
+      <!-- <AButton @click="openButtonAuthModal">{{ $t('page.manage.role.buttonAuth') }}</AButton>
+      <ButtonAuthModal v-model:visible="buttonAuthVisible" :role-id="roleId" /> -->
+    </ASpace>
+    <template #footer>
+      <div class="flex-y-center justify-end gap-12px">
+        <AButton @click="closeDrawer">{{ $t('common.cancel') }}</AButton>
+        <AButton type="primary" @click="handleSubmit">{{ $t('common.confirm') }}</AButton>
+      </div>
+    </template>
+  </AModal>
+</template>
+
+<style scoped></style>

+ 80 - 0
src/views/admin/sys-role/modules/role-search.vue

@@ -0,0 +1,80 @@
+/* eslint-disable */
+<script setup lang="ts">
+import { enableStatusOptions } from '@/constants/business';
+import { $t } from '@/locales';
+
+defineOptions({
+  name: 'RoleSearch'
+});
+
+interface Emits {
+  (e: 'reset'): void;
+  (e: 'search'): void;
+}
+
+const emit = defineEmits<Emits>();
+
+const model = defineModel<Api.SystemManage.RoleSearchParams>('model', { required: true });
+
+function reset() {
+  emit('reset');
+}
+
+function search() {
+  emit('search');
+}
+</script>
+
+<template>
+  <ACard :title="$t('common.search')" :bordered="false" class="card-wrapper">
+    <AForm
+      :model="model"
+      :label-col="{
+        span: 5,
+        md: 7
+      }"
+    >
+      <ARow :gutter="[16, 16]" wrap>
+        <ACol :span="24" :md="12" :lg="6">
+          <AFormItem :label="$t('page.manage.role.roleName')" name="roleName" class="m-0">
+            <AInput v-model:value.trim="model.roleName" :placeholder="$t('page.manage.role.form.roleName')" @keyup.enter.native="search" allowClear/>
+          </AFormItem>
+        </ACol>
+        <ACol :span="24" :md="12" :lg="6">
+          <AFormItem :label="$t('page.manage.role.roleKey')" name="roleKey" class="m-0">
+            <AInput v-model:value.trim="model.roleKey" :placeholder="$t('page.manage.role.form.roleKey')" @keyup.enter.native="search" allowClear/>
+          </AFormItem>
+        </ACol>
+        <ACol :span="24" :md="12" :lg="6">
+          <AFormItem :label="$t('page.manage.role.roleStatus')" name="status" class="m-0">
+            <ASelect v-model:value="model.status" :placeholder="$t('page.manage.role.form.roleStatus')" allowClear @change="search">
+              <ASelectOption v-for="option in enableStatusOptions" :key="option.value" :value="option.value" >
+                {{ $t(option.label) }}
+              </ASelectOption>
+            </ASelect>
+          </AFormItem>
+        </ACol>
+        <ACol :span="24" :md="12" :lg="6">
+          <AFormItem class="m-0">
+            <div class="w-full flex-y-center justify-end gap-12px">
+              <AButton @click="reset">
+                <template #icon>
+                  <icon-ic-round-refresh class="align-sub text-icon" />
+                </template>
+                <span class="ml-8px">{{ $t('common.reset') }}</span>
+              </AButton>
+              <AButton type="primary" ghost @click="search">
+                <template #icon>
+                  <icon-ic-round-search class="align-sub text-icon" />
+                </template>
+                <span class="ml-8px">{{ $t('common.search') }}</span>
+              </AButton>
+            </div>
+          </AFormItem>
+        </ACol>
+      </ARow>
+    </AForm>
+  </ACard>
+</template>
+
+<style scoped></style>

+ 220 - 0
src/views/admin/sys-user/index.vue

@@ -0,0 +1,220 @@
+<script setup lang="tsx">
+import { useTable, useTableOperate, useTableScroll } from '@/hooks/common/table';
+import { $t } from '@/locales';
+import { changeUserStatus, fetchDelteUser, fetchGetUserList } from '@/service/api';
+import { Button, Popconfirm } from 'ant-design-vue';
+import dayjs from 'dayjs';
+import { ref } from 'vue';
+import UserOperateDrawer from './modules/user-operate-drawer.vue';
+import UserResetPwdDialog from './modules/user-resetpwd-dialog.vue';
+import UserSearch from './modules/user-search.vue';
+const { tableWrapperRef, scrollConfig } = useTableScroll();
+const apiParams = ref({
+  pageIndex: 1,
+  pageSize: 10
+});
+let transUserId:number | null = null
+let userName = ''
+let resetDialogVisible = ref(false)
+
+const {
+  columns,
+  columnChecks,
+  data,
+  getData,
+  getDataByPage,
+  loading,
+  mobilePagination,
+  searchParams,
+  resetSearchParams
+} = useTable({
+  apiFn: fetchGetUserList,
+  apiParams:apiParams.value,
+  showTotal: true,
+  columns: () => [
+    {
+      key: 'userId',
+      title: '用户ID',
+      dataIndex: 'userId',
+      align: 'center',
+      width: 64
+    },
+    {
+      key: 'username',
+      dataIndex: 'username',
+      title: '登录名',
+      align: 'center',
+      minWidth: 120
+    },
+    {
+      key: 'nickName',
+      title: '昵称',
+      align: 'center',
+      dataIndex: 'nickName',
+      minWidth: 100
+    },
+    {
+      key: 'deptName',
+      dataIndex: 'deptName',
+      title: '部门',
+      align: 'center',
+      minWidth: 120,
+      customRender: ({ record }) => {
+        return record.dept.deptName;
+      }
+    },
+    {
+      key: 'phone',
+      dataIndex: 'phone',
+      title: $t('page.manage.user.userPhone'),
+      align: 'center',
+      minWidth: 120
+    },
+    {
+      key: 'status',
+      dataIndex: 'status',
+      title: $t('page.manage.user.userStatus'),
+      align: 'center',
+      minWidth: 100,
+      customRender: ({ record}) => {
+        let showStatus:boolean = record.status == '2' ? true : false;
+        const text:string = record.status == '2' ? '停用' : '启用';
+        return <Popconfirm title={`确定要${text}${record.username}的用户吗?`} onConfirm={() => handleChangeStatus(record, text)} >
+          <a-switch v-model:checked={showStatus} ></a-switch>
+        </Popconfirm>;
+      }
+    },
+    {
+      key: 'createdAt',
+      dataIndex: 'createdAt',
+      title: '创建时间',
+      align: 'center',
+      minWidth: 150,
+      customRender:({record})=>{
+        return dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss')
+      }
+    },
+    {
+      key: 'operate',
+      title: $t('common.operate'),
+      align: 'center',
+      fixed: 'right',
+      width: 180,
+      customRender: ({ record }) => (
+        <div class="flex-center gap-0.1rem">
+          <Button type="primary" ghost size="small" onClick={() => handleRowEdit(record.userId)} style="margin-right:5px">
+            {$t('common.edit')}
+          </Button>
+          <Popconfirm title={$t('common.confirmDelete')} onConfirm={() => handleDelete(record.userId)}>
+            <Button danger size="small" style="margin-right:5px">
+              {$t('common.delete')}
+            </Button>
+          </Popconfirm>
+          <Button size="small" onClick={() => handleResetPwd(record)}>
+            重置
+          </Button>
+        </div>
+      )
+    }
+  ]
+});
+const {
+  drawerVisible,
+  operateType,
+  editingData,
+  handleAdd,
+  handleEdit,
+  checkedRowKeys,
+  rowSelection,
+  onDeleted
+  // closeDrawer
+} = useTableOperate(data, getData);
+
+async function handleBatchDelete() {
+  // request
+  handleDelete(checkedRowKeys.value)
+}
+async function handleChangeStatus(row:any, text:string){
+  const data = {
+    userId: row.userId,
+    status: text === '停用'? '1' : '2'
+  }
+  const res = await changeUserStatus(data)
+  if (res.code === 200) {
+    window.$message?.success('更新成功!');
+  } else {
+    window.$message?.error('更新失败!');
+  }
+  await getData()
+
+}
+// 删除
+async function handleDelete(id: any) {
+  // request
+  let ids = []
+  typeof id === 'number' ? ids.push(id) : ids = [].concat(id)
+  const res = await fetchDelteUser({ids})
+  if (res.code === 200) {
+    onDeleted();
+  } else {
+    window.$message?.error('删除失败!');
+  }
+}
+
+// 重置
+function handleResetPwd(row:any) {
+  resetDialogVisible.value = true
+  userName = row.username
+  transUserId = row.userId
+}
+
+// 编辑
+function handleRowEdit(id: number) {
+  handleEdit(id, 'userId');
+}
+</script>
+
+<template>
+  <div class="min-h-6.25rem flex-col-stretch gap-0.2rem overflow-hidden lt-sm:overflow-auto">
+    <UserSearch v-model:model="searchParams" @reset="resetSearchParams" @search="getDataByPage" />
+    <ACard
+      :title="$t('page.manage.user.title')"
+      :bordered="false"
+      :body-style="{ flex: 1, overflow: 'hidden' }"
+      class="flex-col-stretch sm:flex-1-hidden card-wrapper"
+    >
+      <template #extra>
+        <TableHeaderOperation
+          v-model:columns="columnChecks"
+          :disabled-delete="checkedRowKeys.length === 0"
+          :loading="loading"
+          @add="handleAdd"
+          @delete="handleBatchDelete"
+          @refresh="getData"
+        />
+      </template>
+      <ATable
+        ref="tableWrapperRef"
+        :columns="columns"
+        :data-source="data"
+        size="small"
+        :row-selection="rowSelection"
+        :scroll="scrollConfig"
+        :loading="loading"
+        row-key="userId"
+        :pagination="mobilePagination"
+        class="h-full"
+      />
+
+      <UserOperateDrawer
+        v-model:visible="drawerVisible"
+        :operate-type="operateType"
+        :row-data="editingData"
+        @submitted="getDataByPage"
+      />
+      <UserResetPwdDialog v-model:dialogVisible="resetDialogVisible" :userId="transUserId" :user-name="userName"/>
+    </ACard>
+  </div>
+</template>
+
+<style scoped></style>

+ 248 - 0
src/views/admin/sys-user/modules/user-operate-drawer.vue

@@ -0,0 +1,248 @@
+/* eslint-disable */
+<script setup lang="ts">
+import { useAntdForm, useFormRules } from '@/hooks/common/form';
+import { $t } from '@/locales';
+import { fetchAddUser, fetchGetAllRoles, fetchGetdeptTreeList, fetchGetPostList, getDicts } from '@/service/api';
+import { computed, nextTick, reactive, ref, watch } from 'vue';
+defineOptions({
+  name: 'UserOperateDrawer'
+});
+
+interface Props {
+  /** the type of operation */
+  operateType: AntDesign.TableOperateType;
+  /** the edit row data */
+  rowData?: Api.SystemManage.User | null;
+}
+
+const props = defineProps<Props>();
+
+interface Emits {
+  (e: 'submitted'): void;
+}
+
+const emit = defineEmits<Emits>();
+
+const visible = defineModel<boolean>('visible', {
+  default: false
+});
+
+const { formRef, validate, resetFields } = useAntdForm();
+const { defaultRequiredRule, formRules } = useFormRules();
+
+const title = computed(() => {
+  const titles: Record<AntDesign.TableOperateType, string> = {
+    add: $t('page.manage.user.addUser'),
+    edit: $t('page.manage.user.editUser')
+  };
+  return titles[props.operateType];
+});
+
+type Model = Pick<
+  Api.SystemManage.User,
+  'username' | 'sex' | 'nickName' | 'phone' | 'email' | 'status' | 'deptId' | 'password' | 'postId' | 'roleId' | 'remark'
+>;
+
+const model: Model = reactive(createDefaultModel());
+
+function createDefaultModel(): Model {
+  return {
+    username: '',
+    sex: '',
+    nickName: '',
+    phone: '',
+    email: '',
+    status: '2',
+    deptId: '',
+    password: '',
+    postId: '',
+    roleId: '',
+    remark: ''
+  };
+}
+
+type RuleKey = Extract<keyof Model, 'username' | 'status'| 'nickName'| 'deptId' | 'password'| 'email' | 'phone'>;
+
+const rules: Record<RuleKey, App.Global.FormRule> = {
+  username: defaultRequiredRule,
+  nickName: defaultRequiredRule,
+  deptId: defaultRequiredRule,
+  password: defaultRequiredRule,
+  phone: formRules.phone,
+  email:formRules.email
+};
+
+/** the enabled role options */
+const roleOptions = ref<CommonType.Option<string>[]>([]);
+const userGenderOptions = ref<CommonType.Option<string>[]>([
+  {label: "男", value: "0"},
+  {label: "女", value: "1"},
+  {label: "未知", value: "2"}
+]);
+const enableStatusOptions = ref<CommonType.Option<string>[]>([]);
+const postOptions = ref<CommonType.Option<string>[]>([]);
+const departOptions = ref<CommonType.Option<string>[]>([]);
+
+async function getSixOptions(){
+  const { data } = await getDicts('sys_user_sex')
+  userGenderOptions.value = data || []
+}
+async function getStatusOptions(){
+  const { data } = await getDicts('sys_normal_disable')
+  enableStatusOptions.value = data || []
+}
+async function getPostOptions(){
+  const { data } = await fetchGetPostList({ pageSize: 1000 })
+  const options = data?.list.map((item:any) => ({
+    label: item.postName,
+    value: item.postId,
+    postCode: item.postCode
+  }));
+  postOptions.value = [...options] || []
+}
+async function getDeptTree(){
+  const { data } = await fetchGetdeptTreeList()
+  departOptions.value = data
+}
+
+async function getRoleOptions() {
+  const { data } = await fetchGetAllRoles({ pageSize: 1000 });
+  const options = data?.list.map((item:any) => ({
+    label: item.roleName,
+    value: item.roleId,
+    status: item.status
+  }));
+  roleOptions.value = [...options];
+}
+
+async function handleInitModel() {
+  Object.assign(model, createDefaultModel());
+
+  if (props.operateType === 'edit' && props.rowData) {
+    await nextTick();
+    Object.assign(model, props.rowData);
+  }
+}
+
+function closeDrawer() {
+  visible.value = false;
+}
+
+async function handleSubmit() {
+  await validate();
+  // request 新增method 是post 修改是put
+  let method = ''
+  if (props.operateType === 'add') {
+    method = 'post'
+  } else {
+    method = 'put'
+  }
+  const res = await fetchAddUser(model, method)
+  if (res.code === 200){
+    window.$message?.success(res.msg);
+  }
+  closeDrawer();
+  emit('submitted');
+}
+
+watch(visible, () => {
+  if (visible.value) {
+    handleInitModel();
+    resetFields();
+    getRoleOptions();
+    getSixOptions()
+    getStatusOptions()
+    getPostOptions()
+    getDeptTree()
+  }
+});
+</script>
+
+<template>
+  <AModal v-model:open="visible" :title="title" width="800px">
+    <AForm ref="formRef"  :model="model" :rules="rules" :label-col="{ lg: 8, xs: 4 }" label-wrap class="pr-20px">
+      <ARow  wrap>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.user.nickName')" name="nickName">
+            <AInput v-model:value="model.nickName" :placeholder="$t('page.manage.user.form.nickName')" allowClear/>
+          </AFormItem>
+        </ACol>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.user.deptId')" name="roleId">
+            <ATreeSelect v-model:value="model.deptId" show-search  :tree-data="departOptions"
+            :fieldNames="{value: 'id', children: 'children', label: 'label'}"
+              :placeholder="$t('page.manage.user.form.deptId')" allowClear/>
+          </AFormItem>
+        </ACol>
+      </ARow>
+      <ARow wrap>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.user.userPhone')" name="phone">
+            <AInput v-model:value="model.phone" :placeholder="$t('page.manage.user.form.userPhone')" allowClear/>
+          </AFormItem>
+        </ACol>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.user.userEmail')" name="email">
+            <AInput v-model:value="model.email" :placeholder="$t('page.manage.user.form.userEmail')" allowClear/>
+          </AFormItem>
+        </ACol>
+      </ARow>
+      <ARow wrap>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.user.userName')" name="username">
+            <AInput v-model:value="model.username" :placeholder="$t('page.manage.user.form.userName')" allowClear/>
+          </AFormItem>
+        </ACol>
+        <ACol :span="12" :md="12" :xs="24" v-if="props.operateType === 'add'">
+          <AFormItem :label="$t('page.manage.user.password')" name="password" >
+            <AInput v-model:value="model.password" :placeholder="$t('page.manage.user.form.password')" allowClear/>
+          </AFormItem>
+        </ACol>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.user.userGender')" name="sex">
+            <ARadioGroup v-model:value="model.sex">
+              <ARadio v-for="item in userGenderOptions" :key="item.value" :value="item.value">
+                {{item.label }}
+              </ARadio>
+            </ARadioGroup>
+          </AFormItem>
+        </ACol>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.user.userStatus')" name="status">
+            <ARadioGroup v-model:value="model.status">
+              <ARadio v-for="item in enableStatusOptions" :key="item.value" :value="item.value">
+                {{ item.label }}
+              </ARadio>
+            </ARadioGroup>
+          </AFormItem>
+        </ACol>
+        <ACol :span="12" :md="12" :xs="24">
+          <AFormItem :label="$t('page.manage.user.postId')" name="postId">
+            <ASelect v-model:value="model.postId" :options="postOptions"
+              :placeholder="$t('page.manage.user.form.postId')" allowClear/>
+          </AFormItem>
+        </ACol>
+        <ACol :span="12" :md="props.operateType === 'add'? 12: 24" :xs="24">
+          <AFormItem :label="$t('page.manage.user.userRole')" name="roleId">
+            <ASelect v-model:value="model.roleId"  :options="roleOptions"
+              :placeholder="$t('page.manage.user.form.userRole')" allowClear/>
+          </AFormItem>
+        </ACol>
+        <ACol :span="24" :md="24" :xs="24">
+          <AFormItem :label-col="{ lg: 4, xs: 2 }" :label="$t('page.manage.user.remark')" name="remark">
+            <a-textarea v-model:value="model.remark" :placeholder="$t('page.manage.user.form.remark')"
+              :auto-size="{ minRows: 2, maxRows: 5 }" allowClear/>
+          </AFormItem>
+        </ACol>
+      </ARow>
+    </AForm>
+    <template #footer>
+      <ASpace :size="16">
+        <AButton @click="closeDrawer">{{ $t('common.cancel') }}</AButton>
+        <AButton type="primary" @click="handleSubmit">{{ $t('common.confirm') }}</AButton>
+      </ASpace>
+    </template>
+  </AModal>
+</template>
+
+<style scoped></style>

+ 65 - 0
src/views/admin/sys-user/modules/user-resetpwd-dialog.vue

@@ -0,0 +1,65 @@
+/* eslint-disable */
+<script setup lang="ts">
+import { useAntdForm, useFormRules } from '@/hooks/common/form';
+import { fetchRestPwsUser } from '@/service/api';
+import { reactive, watch } from 'vue';
+defineOptions({
+  name: 'UserResetPwdDialog'
+});
+const visible = defineModel<boolean>('dialogVisible', {
+  default: false
+});
+interface Props {
+  userId: number | null,
+  userName: string | null
+}
+const props = defineProps<Props>();
+const model = reactive(createDefaultModel());
+const { formRef, validate, resetFields } = useAntdForm();
+const { formRules } = useFormRules();
+function createDefaultModel(){
+  return {
+    password: '',
+    userId: props.userId
+  };
+}
+const rules: Record<'password', App.Global.FormRule> = {
+  password: formRules.pwd,
+};
+
+function closeDrawer() {
+  visible.value = false;
+}
+async function handleSubmit() {
+  await validate();
+  model.userId = props.userId
+
+  const res = await fetchRestPwsUser(model)
+  if (res.code === 200){
+    window.$message?.success(res.msg);
+  }
+  closeDrawer();
+}
+watch(visible, () => {
+  if (visible.value) {
+    resetFields();
+  }
+});
+</script>
+<template>
+  <AModal v-model:open="visible" title="重置密码" width="420px">
+    <!-- <p style="margin-bottom: 10px;"></p> -->
+    <AForm ref="formRef" layout="vertical" :model="model" :rules="rules">
+      <AFormItem :label="`请输入${userName}的新密码`" name="password">
+        <AInput v-model:value="model.password" placeholder="请输入新密码" allowClear/>
+      </AFormItem>
+    </AForm>
+    <template #footer>
+      <ASpace :size="16">
+        <AButton @click="closeDrawer">{{ $t('common.cancel') }}</AButton>
+        <AButton type="primary" @click="handleSubmit">{{ $t('common.confirm') }}</AButton>
+      </ASpace>
+    </template>
+  </AModal>
+</template>
+<style scoped></style>

+ 137 - 0
src/views/admin/sys-user/modules/user-search.vue

@@ -0,0 +1,137 @@
+/* eslint-disable */
+<script setup lang="ts">
+import { enableStatusOptions } from '@/constants/business';
+import { useAntdForm, useFormRules } from '@/hooks/common/form';
+import { $t } from '@/locales';
+import { fetchGetdeptTreeList } from '@/service/api';
+import { translateOptions } from '@/utils/common';
+import { computed, onMounted, ref } from 'vue';
+defineOptions({
+  name: 'UserSearch'
+});
+
+interface Emits {
+  (e: 'reset'): void;
+  (e: 'search'): void;
+}
+
+const emit = defineEmits<Emits>();
+const departOptions = ref<CommonType.Option<string>[]>([]);
+const { formRef, validate, resetFields } = useAntdForm();
+
+const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true });
+
+type RuleKey = Extract<keyof Api.SystemManage.UserSearchParams, 'email' | 'phone'>;
+
+const rules = computed<Record<RuleKey, App.Global.FormRule>>(() => {
+  const { patternRules } = useFormRules(); // inside computed to make locale reactive
+
+  return {
+    email: patternRules.email,
+    phone: patternRules.phone
+  };
+});
+onMounted(()=>{
+  getDeptTree()
+})
+async function getDeptTree(){
+  const { data } = await fetchGetdeptTreeList()
+  departOptions.value = data
+}
+async function reset() {
+  await resetFields();
+  emit('reset');
+}
+
+async function search() {
+  await validate();
+  emit('search');
+}
+
+</script>
+
+<template>
+  <ACard :title="$t('common.search')" :bordered="false" class="card-wrapper">
+    <AForm
+      ref="formRef"
+      :model="model"
+      :rules="rules"
+      :label-col="{
+        span: 5,
+        md: 7
+      }"
+    >
+      <ARow :gutter="[16, 16]" wrap>
+        <ACol :span="24" :md="12" :lg="6">
+          <AFormItem :label="$t('page.manage.user.userName')" name="username" class="m-0">
+            <AInput v-model:value.trim="model.username" :placeholder="$t('page.manage.user.form.userName')" @keyup.enter.native="search" allowClear/>
+          </AFormItem>
+        </ACol>
+        <ACol :span="24" :md="12" :lg="6">
+          <AFormItem :label="$t('page.manage.user.deptId')" name="roleId">
+            <ATreeSelect v-model:value="model.deptId" show-search  :tree-data="departOptions"
+            :fieldNames="{value: 'id', children: 'children', label: 'label'}" @change="search"
+              :placeholder="$t('page.manage.user.form.deptId')" allowClear/>
+          </AFormItem>
+        </ACol>
+
+        <!-- <ACol :span="24" :md="12" :lg="6">
+          <AFormItem :label="$t('page.manage.user.userGender')" name="sex" class="m-0">
+            <ASelect
+              v-model:value="model.sex"
+              :placeholder="$t('page.manage.user.form.userGender')"
+              :options="translateOptions(userGenderOptions)"
+              clearable
+            />
+          </AFormItem>
+        </ACol> -->
+        <!-- <ACol :span="24" :md="12" :lg="6">
+          <AFormItem :label="$t('page.manage.user.nickName')" name="nickName" class="m-0">
+            <AInput v-model:value="model.nickName" :placeholder="$t('page.manage.user.form.nickName')" />
+          </AFormItem>
+        </ACol> -->
+        <ACol :span="24" :md="12" :lg="6">
+          <AFormItem :label="$t('page.manage.user.userPhone')" name="phone" class="m-0">
+            <AInput v-model:value.trim="model.phone" :placeholder="$t('page.manage.user.form.userPhone')" @keyup.enter.native="search" allowClear/>
+          </AFormItem>
+        </ACol>
+        <!-- <ACol :span="24" :md="12" :lg="6">
+          <AFormItem :label="$t('page.manage.user.userEmail')" name="email" class="m-0">
+            <AInput v-model:value="model.email" :placeholder="$t('page.manage.user.form.userEmail')" />
+          </AFormItem>
+        </ACol> -->
+        <ACol :span="24" :md="12" :lg="6">
+          <AFormItem :label="$t('page.manage.user.userStatus')" name="status" class="m-0">
+            <ASelect
+              v-model:value="model.status"
+              :placeholder="$t('page.manage.user.form.userStatus')"
+              :options="translateOptions(enableStatusOptions)"
+              @change="search"
+              allowClear
+            />
+          </AFormItem>
+        </ACol>
+        <div class="flex-1">
+          <AFormItem class="m-0">
+            <div class="w-full flex-y-center justify-end gap-12px">
+              <AButton @click="reset">
+                <template #icon>
+                  <icon-ic-round-refresh class="align-sub text-icon" />
+                </template>
+                <span class="ml-8px">{{ $t('common.reset') }}</span>
+              </AButton>
+              <AButton type="primary" ghost @click="search">
+                <template #icon>
+                  <icon-ic-round-search class="align-sub text-icon" />
+                </template>
+                <span class="ml-8px">{{ $t('common.search') }}</span>
+              </AButton>
+            </div>
+          </AFormItem>
+        </div>
+      </ARow>
+    </AForm>
+  </ACard>
+</template>
+
+<style scoped></style>