Jelajahi Sumber

feat: 添加获取权限信息等

Gaokun Wang 1 bulan lalu
induk
melakukan
5859a0f177

+ 4 - 0
package.json

@@ -24,6 +24,7 @@
   },
   "dependencies": {
     "@element-plus/icons-vue": "^2.3.1",
+    "@types/node-forge": "^1.3.12",
     "@vueuse/core": "^13.5.0",
     "axios": "^1.10.0",
     "element-plus": "^2.10.3",
@@ -39,7 +40,9 @@
     "@commitlint/cli": "^19.8.1",
     "@commitlint/config-conventional": "^19.8.1",
     "@eslint/js": "^9.30.1",
+    "@types/js-cookie": "^3.0.6",
     "@types/node": "^24.0.12",
+    "@types/nprogress": "^0.2.3",
     "@vitejs/plugin-vue": "^6.0.0",
     "@vitejs/plugin-vue-jsx": "^5.0.1",
     "@vue/eslint-config-prettier": "^10.2.0",
@@ -52,6 +55,7 @@
     "eslint-plugin-vue": "^10.3.0",
     "husky": "^9.1.7",
     "lint-staged": "^16.1.2",
+    "node-forge": "^1.3.1",
     "postcss": "^8.5.6",
     "postcss-html": "^1.8.0",
     "postcss-scss": "^4.0.9",

+ 35 - 0
pnpm-lock.yaml

@@ -11,6 +11,9 @@ importers:
       '@element-plus/icons-vue':
         specifier: ^2.3.1
         version: 2.3.1(vue@3.5.17(typescript@5.8.3))
+      '@types/node-forge':
+        specifier: ^1.3.12
+        version: 1.3.12
       '@vueuse/core':
         specifier: ^13.5.0
         version: 13.5.0(vue@3.5.17(typescript@5.8.3))
@@ -51,9 +54,15 @@ importers:
       '@eslint/js':
         specifier: ^9.30.1
         version: 9.30.1
+      '@types/js-cookie':
+        specifier: ^3.0.6
+        version: 3.0.6
       '@types/node':
         specifier: ^24.0.12
         version: 24.0.12
+      '@types/nprogress':
+        specifier: ^0.2.3
+        version: 0.2.3
       '@vitejs/plugin-vue':
         specifier: ^6.0.0
         version: 6.0.0(vite@7.0.3(@types/node@24.0.12)(jiti@2.4.2)(sass@1.89.2)(yaml@2.8.0))(vue@3.5.17(typescript@5.8.3))
@@ -90,6 +99,9 @@ importers:
       lint-staged:
         specifier: ^16.1.2
         version: 16.1.2
+      node-forge:
+        specifier: ^1.3.1
+        version: 1.3.1
       postcss:
         specifier: ^8.5.6
         version: 8.5.6
@@ -867,6 +879,9 @@ packages:
   '@types/estree@1.0.8':
     resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
 
+  '@types/js-cookie@3.0.6':
+    resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
+
   '@types/json-schema@7.0.15':
     resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
 
@@ -876,9 +891,15 @@ packages:
   '@types/lodash@4.17.20':
     resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==}
 
+  '@types/node-forge@1.3.12':
+    resolution: {integrity: sha512-a0ToKlRVnUw3aXKQq2F+krxZKq7B8LEQijzPn5RdFAMatARD2JX9o8FBpMXOOrjob0uc13aN+V/AXniOXW4d9A==}
+
   '@types/node@24.0.12':
     resolution: {integrity: sha512-LtOrbvDf5ndC9Xi+4QZjVL0woFymF/xSTKZKPgrrl7H7XoeDvnD+E2IclKVDyaK9UM756W/3BXqSU+JEHopA9g==}
 
+  '@types/nprogress@0.2.3':
+    resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==}
+
   '@types/svgo@2.6.4':
     resolution: {integrity: sha512-l4cmyPEckf8moNYHdJ+4wkHvFxjyW6ulm9l4YGaOxeyBWPhBOT0gvni1InpFPdzx1dKf/2s62qGITwxNWnPQng==}
 
@@ -2763,6 +2784,10 @@ packages:
   node-addon-api@7.1.1:
     resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
 
+  node-forge@1.3.1:
+    resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==}
+    engines: {node: '>= 6.13.0'}
+
   node-releases@2.0.19:
     resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
 
@@ -4477,6 +4502,8 @@ snapshots:
 
   '@types/estree@1.0.8': {}
 
+  '@types/js-cookie@3.0.6': {}
+
   '@types/json-schema@7.0.15': {}
 
   '@types/lodash-es@4.17.12':
@@ -4485,10 +4512,16 @@ snapshots:
 
   '@types/lodash@4.17.20': {}
 
+  '@types/node-forge@1.3.12':
+    dependencies:
+      '@types/node': 24.0.12
+
   '@types/node@24.0.12':
     dependencies:
       undici-types: 7.8.0
 
+  '@types/nprogress@0.2.3': {}
+
   '@types/svgo@2.6.4':
     dependencies:
       '@types/node': 24.0.12
@@ -6608,6 +6641,8 @@ snapshots:
   node-addon-api@7.1.1:
     optional: true
 
+  node-forge@1.3.1: {}
+
   node-releases@2.0.19: {}
 
   normalize-path@3.0.0: {}

+ 33 - 0
src/api/interface/login.ts

@@ -0,0 +1,33 @@
+/**
+ * 验证码返回
+ */
+export interface VerifyCodeResult {
+  captchaEnabled: boolean
+  uuid?: string
+  img?: string
+}
+
+/**
+ * 登录返回
+ */
+export interface LoginResult {
+  access_token: string
+  tokenType: string
+}
+
+/**
+ * 登录请求
+ */
+export interface LoginData {
+  // tenantId?: number | string
+  userName: string
+  password: string
+  // rememberMe?: boolean
+  // socialCode?: string
+  // socialState?: string
+  // source?: string
+  code?: string
+  // uuid?: string
+  // clientId: string
+  // grantType: string
+}

+ 70 - 0
src/api/interface/system/menu.ts

@@ -0,0 +1,70 @@
+import { MenuTypeEnum } from '@/enums/MenuTypeEnum'
+
+/**
+ * 菜单树形结构类型
+ */
+export interface MenuTreeOption {
+  id: string | number
+  label: string
+  parentId: string | number
+  weight: number
+  children?: MenuTreeOption[]
+}
+
+export interface RoleMenuTree {
+  menus: MenuTreeOption[]
+  checkedKeys: string[]
+}
+
+/**
+ * 菜单查询参数类型
+ */
+export interface MenuQuery {
+  keywords?: string
+  menuName?: string
+  status?: string
+}
+
+/**
+ * 菜单视图对象类型
+ */
+export interface MenuVO extends BaseEntity {
+  parentName: string
+  parentId: string | number
+  children: MenuVO[]
+  menuId: string | number
+  menuName: string
+  orderNum: number
+  path: string
+  component: string
+  queryParam: string
+  isFrame: string
+  isCache: string
+  menuType: MenuTypeEnum
+  visible: string
+  status: string
+  icon: string
+  remark: string
+}
+
+export interface MenuForm {
+  parentName?: string
+  parentId?: string | number
+  children?: MenuForm[]
+  menuId?: string | number
+  menuName: string
+  orderNum: number
+  path: string
+  component?: string
+  queryParam?: string
+  isFrame?: string
+  isCache?: string
+  menuType?: MenuTypeEnum
+  visible?: string
+  status?: string
+  icon?: string
+  remark?: string
+  query?: string
+  perms?: string
+  version?: number
+}

+ 36 - 0
src/api/interface/system/user.ts

@@ -0,0 +1,36 @@
+/**
+ * 用户信息
+ */
+export interface UserInfo {
+  user: UserVO
+  roles: string[]
+  permissions: string[]
+}
+
+/**
+ * 用户返回对象
+ */
+export interface UserVO extends BaseEntity {
+  userId: string | number
+  deptId: number
+  userName: string
+  nickName: string
+  userType: string
+  email: string
+  phonenumber: string
+  dept: any
+  gender: string
+  avatar: string
+  url: string
+  status: string
+  delFlag: string
+  loginIp: string
+  loginDate: string
+  remark: string
+  deptName: string
+  roles: any[]
+  roleIds: any
+  postIds: any
+  roleId: any
+  admin: boolean
+}

+ 17 - 0
src/api/module/login.ts

@@ -0,0 +1,17 @@
+import http from '@/axios'
+import { LoginData, LoginResult, VerifyCodeResult } from '@/api/interface/login'
+
+class LoginApi {
+  static login = (data: LoginData): Promise<ResultData<any>> => {
+    return http.post<LoginResult>({ url: '/auth/login', data })
+  }
+
+  static logout = (): Promise<ResultData<any>> => {
+    return http.post({ url: '/auth/logout' })
+  }
+
+  static getCodeImg = (): Promise<ResultData<any>> => {
+    return http.get<VerifyCodeResult[]>({ url: '/captchaImage' })
+  }
+}
+export default LoginApi

+ 8 - 0
src/api/module/system/menu.ts

@@ -0,0 +1,8 @@
+import http from '@/axios'
+import { RouteRecordRaw } from 'vue-router'
+class MenuApi {
+  static getRouters = (): Promise<ResultData<any>> => {
+    return http.get<RouteRecordRaw[]>({ url: '/system/menu/getRouters' })
+  }
+}
+export default MenuApi

+ 9 - 0
src/api/module/system/user.ts

@@ -0,0 +1,9 @@
+import http from '@/axios'
+import { UserInfo } from '@/api/interface/system/user'
+
+class UserApi {
+  static getInfo = (): Promise<ResultData<any>> => {
+    return http.get<UserInfo[]>({ url: '/system/user/getInfo' })
+  }
+}
+export default UserApi

+ 22 - 0
src/constants/index.ts

@@ -0,0 +1,22 @@
+/**
+ * 不重置路由白名单
+ */
+export const NO_RESET_WHITE_LIST = ['Redirect']
+
+/**
+ * 白名单
+ */
+export const WHITE_LIST = ['/login']
+
+// 登录页地址(默认)
+export const LOGIN_URL: string = '/login'
+
+// 首页地址(默认)
+export const HOME_URL: string = '/home/index'
+
+// 默认设置
+export const DEFAULT_SETTING = {
+  PRIMARY: '#009688',
+  SHOW_TAPS: true,
+  TAPS_VIEW_ICON: true
+}

+ 52 - 0
src/router/index.ts

@@ -0,0 +1,52 @@
+import { createWebHashHistory, createWebHistory, createRouter, type RouteRecordRaw } from 'vue-router'
+import { errorRouter, staticRouter } from '@/router/modules/staticRouter'
+import type { App } from 'vue'
+
+const mode = import.meta.env.VITE_ROUTER_MODE
+import { NO_RESET_WHITE_LIST } from '@/constants'
+// 路由模式
+const routerMode = {
+  hash: () => createWebHashHistory(),
+  history: () => createWebHistory()
+}
+
+/**
+ * @description 📚 路由参数配置简介
+ * @param path ==> 路由菜单访问路径
+ * @param name ==> 路由 name (对应页面组件 name, 可用作 KeepAlive 缓存标识 && 按钮权限筛选)
+ * @param redirect ==> 路由重定向地址
+ * @param component ==> 视图文件路径
+ * @param meta ==> 路由菜单元信息
+ * @param meta.icon ==> 菜单和面包屑对应的图标
+ * @param meta.title ==> 路由标题 (用作 document.title || 菜单的名称)
+ * @param meta.activeMenu ==> 当前路由为详情页时,需要高亮的菜单
+ * @param meta.isLink ==> 路由外链时填写的访问地址
+ * @param meta.isHidden ==> 是否在菜单中隐藏 (通常列表详情页需要隐藏)
+ * @param meta.isFull ==> 菜单是否全屏 (示例:数据大屏页面)
+ * @param meta.isAffix ==> 菜单是否固定在标签页中 (首页通常是固定项)
+ * @param meta.isKeepAlive ==> 当前路由是否缓存
+ */
+const router = createRouter({
+  history: routerMode[mode](),
+  routes: [...staticRouter, ...errorRouter] as RouteRecordRaw[],
+  strict: false,
+  scrollBehavior: () => ({ left: 0, top: 0 })
+})
+
+// 全局注册 router
+export const setupRouter = (app: App<Element>) => {
+  app.use(router)
+}
+
+/**
+ * @description 重置路由
+ **/
+export const resetRouter = (): void => {
+  router.getRoutes().forEach(route => {
+    const { name } = route
+    if (name && !NO_RESET_WHITE_LIST.includes(name as string)) {
+      router.hasRoute(name) && router.removeRoute(name)
+    }
+  })
+}
+export default router

+ 36 - 0
src/router/modules/authRouts.ts

@@ -0,0 +1,36 @@
+import router from '@/router'
+import { WHITE_LIST, LOGIN_URL } from '@/constants'
+import { useAuthStore, useUserStore } from '@/stores'
+import { getToken, removeToken } from '@/utils/token'
+import { type RouteRecordRaw } from 'vue-router'
+import { useNProgress } from '@/utils/nprogress'
+const { start, done } = useNProgress()
+export const setupAuthRoutes = () => {
+  router.beforeEach(async (to, _from, next) => {
+    start()
+    if (getToken()) {
+      if (to.path.toLocaleLowerCase() === LOGIN_URL) {
+        // 如果已登录,跳转到首页
+        next({ path: '/' })
+        return
+      }
+      const authStore = useAuthStore()
+      const userStore = useUserStore()
+      const hasRoles = userStore.roles && userStore.roles.length > 0
+      if (hasRoles) return next()
+      await userStore.getUserInfo()
+      await authStore.setAuthRoutes()
+      const authRoutes = authStore.getAuthRoutes
+      authRoutes.forEach((route: RouteRecordRaw) => router.addRoute(route))
+      return next({ ...to, replace: true })
+    } else {
+      if (WHITE_LIST.includes(to.path)) return next()
+      removeToken()
+      // 重定向登录页面
+      next(`/login?redirect=${to.path}`)
+    }
+  })
+  router.afterEach(() => {
+    done() // 结束Progress
+  })
+}

+ 42 - 0
src/router/modules/dynamicRouter.ts

@@ -0,0 +1,42 @@
+import { LOGIN_URL } from '@/constants'
+import router from '@/router'
+import { useAuthStore, useUserStore } from '@/stores'
+import type { RouteRecordRaw } from 'vue-router'
+
+// 引入 views 文件夹下所有 vue 文件
+const modules = import.meta.glob('@/views/**/*.vue')
+
+/**
+ * @description 初始化动态路由
+ */
+export const initDynamicRouter = async () => {
+  const userStore = useUserStore()
+  const authStore = useAuthStore()
+  //   const optionsStore = useOptionsStore()
+  try {
+    if (authStore.isLoaded) return
+    authStore.setAuthMenuList()
+    authStore.setAuthButtonList()
+    authStore.setAuthRoleList()
+    // optionsStore.setReloadOptions()
+    // await optionsStore.getAllDictList()
+    authStore.setLoaded()
+
+    // 3.添加动态路由
+    authStore.flatMenuListGet.forEach((item: Menu.MenuOptions) => {
+      if (item.children) delete item.children
+      if (item.component && typeof item.component == 'string') {
+        item.component = modules['/src/views' + item.component + '.vue']
+      }
+      if (item.meta.isFull) {
+        router.addRoute(item as unknown as RouteRecordRaw)
+      } else {
+        router.addRoute('layout', item as unknown as RouteRecordRaw)
+      }
+    })
+  } catch (error) {
+    userStore.setToken('')
+    router.replace(LOGIN_URL)
+    return Promise.reject(error)
+  }
+}

+ 64 - 0
src/router/modules/staticRouter.ts

@@ -0,0 +1,64 @@
+import { type RouteRecordRaw } from 'vue-router'
+import { HOME_URL, LOGIN_URL } from '@/constants'
+
+// 静态路由
+export const staticRouter: RouteRecordRaw[] = [
+  {
+    path: '/',
+    redirect: HOME_URL
+  },
+  {
+    path: LOGIN_URL,
+    name: 'Login',
+    component: () => import('@/views/login/index.vue'),
+    meta: {
+      title: '登录'
+    }
+  },
+  {
+    path: '/layout',
+    name: 'layout',
+    component: () => import('@/layouts/index.vue'),
+    redirect: HOME_URL,
+    children: [
+      {
+        path: HOME_URL,
+        name: 'Home',
+        component: () => import('@/views/home/index.vue'),
+        meta: {
+          title: '首页',
+          icon: 'HomeFilled',
+          isAffix: true,
+          isFull: false,
+          isHidden: false,
+          isKeepAlive: true,
+          isLink: null
+        }
+      }
+    ]
+  }
+]
+
+// 错误页面路由
+export const errorRouter: RouteRecordRaw[] = [
+  {
+    path: '/404',
+    name: '404',
+    component: () => import('@/views/error/404.vue'),
+    meta: {
+      title: '404页面'
+    }
+  },
+  {
+    path: '/500',
+    name: '500',
+    component: () => import('@/views/error/500.vue'),
+    meta: {
+      title: '500页面'
+    }
+  },
+  {
+    path: '/:pathMatch(.*)*',
+    component: () => import('@/views/error/404.vue')
+  }
+]

+ 16 - 0
src/stores/helper/persist.ts

@@ -0,0 +1,16 @@
+import type { PersistenceOptions } from 'pinia-plugin-persistedstate'
+
+/**
+ * @description pinia 持久化参数配置
+ * @param {String} key 存储到持久化的 name
+ * @return persist
+ * */
+const piniaPersistConfig = (key: string) => {
+  const persist: PersistenceOptions = {
+    key,
+    storage: localStorage
+  }
+  return persist
+}
+
+export default piniaPersistConfig

+ 11 - 0
src/stores/index.ts

@@ -0,0 +1,11 @@
+import type { App } from 'vue'
+import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
+
+const pinia = createPinia()
+export const setupPinia = (app: App<Element>) => {
+  app.use(pinia)
+}
+pinia.use(piniaPluginPersistedstate)
+export * from './modules/auth'
+export * from './modules/user'
+export { pinia }

+ 69 - 0
src/stores/interface/index.ts

@@ -0,0 +1,69 @@
+/* AuthState */
+export interface AuthState {
+  isLoaded: boolean
+  authButtonList: string[]
+  authRoleList: string[]
+  authMenuList: Menu.MenuOptions[]
+  routeName: string
+}
+
+/* AppState */
+export interface AppState {
+  activeTopMenuPath: string
+  isCollapse: boolean
+  language: string
+}
+
+/* UserState */
+export interface UserState {
+  token: string | undefined
+  name: string
+  avatar: string
+  nickname: string
+  userId: string | number
+  tenantId: string
+  roles: string[]
+  permissions: string[]
+}
+
+/* SettingState */
+export interface SettingState {
+  layout: LayoutType
+  breadcrumb: boolean
+  breadcrumbIcon: boolean
+  footer: boolean
+  isDark: boolean
+  primary: string
+  asideInverted: boolean
+  headerInverted: boolean
+  showTaps: boolean
+  tagsViewIcon: boolean
+}
+
+/* TagsViewState */
+export interface TagsViewState {
+  visitedViews: TagView[]
+  cachedViews: string[]
+  selectedTag?: TagView
+}
+export interface TagView {
+  /** 页签名称 */
+  name: string
+  /** 页签标题 */
+  title: string
+  /** 页签路由路径 */
+  path: string
+  /** 页签路由完整路径 */
+  fullPath: string
+  /** 页签图标 */
+  icon?: string
+  /** 是否固定页签 */
+  affix?: boolean
+
+  /** 隐藏标签 */
+  noTagsView?: boolean
+  /** 是否开启缓存 */
+  keepAlive?: boolean
+  /** 路由查询参数 */
+  query?: any
+}

+ 89 - 0
src/stores/modules/auth.ts

@@ -0,0 +1,89 @@
+import { type AuthState } from '@/stores/interface'
+import { type RouteRecordRaw } from 'vue-router'
+import { pinia } from '../index'
+import { getAllBreadcrumbList, getFlatMenuList, getShowMenuList } from '@/utils'
+const modules = import.meta.glob('../../views/**/**.vue')
+const Layout = () => import('@/layouts/index.vue')
+
+export const useAuthStore = defineStore('eco-auth', {
+  state: (): AuthState => ({
+    isLoaded: false,
+    authButtonList: [],
+    authMenuList: [],
+    authRoleList: [],
+    routeName: ''
+  }),
+  getters: {
+    getLoaded: state => state.isLoaded,
+    getAuthButtonList: state => state.authButtonList,
+    getAuthMenuList: state => state.authMenuList,
+    getAuthRoleList: state => state.authRoleList,
+    getRouteName: state => state.routeName,
+    showMenuListGet: state => {
+      getShowMenuList(state.authMenuList)
+    },
+    flatMenuListGet: state => {
+      return getFlatMenuList(state.authMenuList)
+    },
+    breadcrumbListGet: state => {
+      return getAllBreadcrumbList(state.authMenuList)
+    }
+  },
+  actions: {
+    setLoaded() {
+      this.isLoaded = true
+    },
+    setAuthButtonList() {
+      // 获取 按钮权限
+    },
+    setAuthMenuList() {
+      // 获取 菜单权限
+    },
+    setAuthRoleList() {
+      // 获取 角色权限
+    },
+    setRouteName(name: string) {
+      // 获取 路由
+      this.routeName = name
+    },
+    clear() {
+      this.isLoaded = false
+      this.authButtonList = []
+      this.authMenuList = []
+      this.authRoleList = []
+    }
+  }
+})
+
+const conversionRouter = (routes: RouteRecordRaw[]) => {
+  const asyncRoutes: RouteRecordRaw[] = []
+  routes.forEach(route => {
+    const tmpRoute = { ...route } as RouteRecordRaw
+    if (tmpRoute.component?.toString() === 'ParentView') {
+      delete tmpRoute.component
+    }
+    // 顶级 目录,替换为 Layout 组件
+    if (tmpRoute.component?.toString() == 'Layout') {
+      tmpRoute.component = Layout
+    } else {
+      // 其他菜单,根据组件路径动态加载组件
+      const component = modules[`../../views/${tmpRoute.component}.vue`]
+      if (component) {
+        tmpRoute.component = component
+      } else {
+        tmpRoute.component = modules[`../../views/error/404.vue`]
+      }
+    }
+
+    // 递归子菜单
+    if (tmpRoute.children) {
+      tmpRoute.children = conversionRouter(route.children as RouteRecordRaw[])
+    }
+    asyncRoutes.push(tmpRoute)
+  })
+  return asyncRoutes
+}
+
+export const useAuthStoreWithOut = () => {
+  return useAuthStore(pinia)
+}

+ 85 - 0
src/stores/modules/user.ts

@@ -0,0 +1,85 @@
+import { type UserState } from '@/stores/interface'
+import { getToken, setToken, removeToken } from '@/utils/token'
+import { type LoginData } from '@/api/interface/login'
+import UserApi from '@/api/module/system/user'
+import { type UserInfo } from '@/api/interface/system/user'
+import LoginApi from '@/api/module/login'
+import { pinia } from '../index'
+
+export const useUserStore = defineStore('eco-user', {
+  state: (): UserState => ({
+    token: getToken(),
+    name: '',
+    avatar: '',
+    nickname: '',
+    userId: '',
+    tenantId: '',
+    roles: [],
+    permissions: []
+  }),
+  getters: {},
+  actions: {
+    userLogin(loginData: LoginData) {
+      return new Promise<Result>((resolve, reject) => {
+        LoginApi.login(loginData)
+          .then(({ code, data, msg }) => {
+            if (code === 200) {
+              const { tokenType, accessToken } = data
+              setToken(tokenType + ' ' + accessToken)
+              this.token = tokenType + ' ' + accessToken
+              resolve({ code, msg })
+            }
+          })
+          .catch(error => {
+            reject(error)
+          })
+      })
+    },
+    userLogout() {
+      return new Promise<Result>((resolve, reject) => {
+        LoginApi.logout()
+          .then(({ code, msg }) => {
+            if (code === 200) {
+              this.token = ''
+              this.roles = []
+              this.permissions = []
+              removeToken()
+              resolve({ code, msg })
+            }
+          })
+          .catch(error => {
+            reject(error)
+          })
+      })
+    },
+    getUserInfo() {
+      return new Promise<UserInfo>((resolve, reject) => {
+        UserApi.getInfo()
+          .then(({ code, data }) => {
+            if (code === 200) {
+              const { user, roles, permissions } = data
+              this.avatar = user.avatar
+              this.roles = roles
+              this.name = user.userName
+              this.permissions = permissions
+              resolve(data)
+            } else {
+              this.roles = []
+              removeToken()
+            }
+          })
+          .catch(error => {
+            this.roles = []
+            removeToken()
+            reject(error)
+          })
+      })
+    },
+    setToken(tokenStr: string) {
+      this.token = tokenStr
+    }
+  }
+})
+export const useUserStoreWithOut = () => {
+  return useUserStore(pinia)
+}

+ 27 - 0
src/types/global.d.ts

@@ -71,3 +71,30 @@ type ObjToKeyValArray<T> = {
 declare type keyOnPrefix<T> = {
   [K in keyof T as `on${Capitalize<K>}`]: T[K] extends readonly any[] ? (...t: T[K]) => void : never
 }
+
+declare namespace Menu {
+  interface MenuOptions {
+    id: string
+    pid: string
+    path: string
+    name: string
+    sort: number
+    menuTypeCd: string
+    component?: string | (() => Promise<unknown>)
+    redirect?: string
+    permissions?: string
+    meta: MetaProps
+    children?: MenuOptions[]
+  }
+
+  interface MetaProps {
+    icon: string
+    title: string
+    isLink?: string
+    isHidden: string
+    isFull: string
+    isAffix: string
+    isKeepAlive: string
+    useDataScope: string
+  }
+}

+ 350 - 0
src/utils/index.ts

@@ -0,0 +1,350 @@
+import { isArray } from '@/utils/is'
+import forge from 'node-forge'
+/**
+ * @description 获取localStorage
+ * @param {String} key Storage名称
+ * @returns {String}
+ */
+export function localGet(key: string) {
+  const value = window.localStorage.getItem(key)
+  try {
+    return JSON.parse(window.localStorage.getItem(key) as string)
+  } catch (error) {
+    console.log('localGet', error)
+    return value
+  }
+}
+
+/**
+ * @description 存储localStorage
+ * @param {String} key Storage名称
+ * @param {*} value Storage值
+ * @returns {void}
+ */
+export function localSet(key: string, value: any) {
+  window.localStorage.setItem(key, JSON.stringify(value))
+}
+
+/**
+ * @description 清除localStorage
+ * @param {String} key Storage名称
+ * @returns {void}
+ */
+export function localRemove(key: string) {
+  window.localStorage.removeItem(key)
+}
+
+/**
+ * @description 清除所有localStorage
+ * @returns {void}
+ */
+export function localClear() {
+  window.localStorage.clear()
+}
+
+/**
+ * @description 判断数据类型
+ * @param {*} val 需要判断类型的数据
+ * @returns {String}
+ */
+export function isType(val: any) {
+  if (val === null) {
+    return 'null'
+  }
+  if (typeof val !== 'object') {
+    return typeof val
+  }
+  return Object.prototype.toString.call(val).slice(8, -1).toLocaleLowerCase()
+}
+
+/**
+ * @description 生成唯一 uuid
+ * @returns {String}
+ */
+export function generateUUID() {
+  let uuid = ''
+  for (let i = 0; i < 32; i++) {
+    const random = (Math.random() * 16) | 0
+    if (i === 8 || i === 12 || i === 16 || i === 20) {
+      uuid += '-'
+    }
+    uuid += (i === 12 ? 4 : i === 16 ? (random & 3) | 8 : random).toString(16)
+  }
+  return uuid
+}
+
+/**
+ * 判断两个对象是否相同
+ * @param {Object} a 要比较的对象一
+ * @param {Object} b 要比较的对象二
+ * @returns {Boolean} 相同返回 true,反之 false
+ */
+export function isObjectValueEqual(a: { [key: string]: any }, b: { [key: string]: any }) {
+  if (!a || !b) {
+    return false
+  }
+  const aProps = Object.getOwnPropertyNames(a)
+  const bProps = Object.getOwnPropertyNames(b)
+  if (aProps.length !== bProps.length) {
+    return false
+  }
+  for (let i = 0; i < aProps.length; i++) {
+    const propName = aProps[i]
+    const propA = a[propName]
+    const propB = b[propName]
+    if (!Object.prototype.hasOwnProperty.call(b, propName)) {
+      return false
+    }
+    if (propA instanceof Object) {
+      if (!isObjectValueEqual(propA, propB)) {
+        return false
+      }
+    } else if (propA !== propB) {
+      return false
+    }
+  }
+  return true
+}
+
+/**
+ * @description 生成随机数
+ * @param {Number} min 最小值
+ * @param {Number} max 最大值
+ * @returns {Number}
+ */
+export function randomNum(min: number, max: number): number {
+  return Math.floor(Math.random() * (min - max) + max)
+}
+
+/**
+ * @description 获取当前时间对应的提示语
+ * @returns {String}
+ */
+export function getTimeState() {
+  const timeNow = new Date()
+  const hours = timeNow.getHours()
+  if (hours >= 6 && hours <= 10) {
+    return `早上好 ⛅`
+  }
+  if (hours >= 10 && hours <= 14) {
+    return `中午好 🌞`
+  }
+  if (hours >= 14 && hours <= 18) {
+    return `下午好 🌞`
+  }
+  if (hours >= 18 && hours <= 24) {
+    return `晚上好 🌛`
+  }
+  if (hours >= 0 && hours <= 6) {
+    return `凌晨好 🌛`
+  }
+}
+
+/**
+ * @description 使用递归扁平化菜单,方便添加动态路由
+ * @param {Array} menuList 菜单列表
+ * @returns {Array}
+ */
+export function getFlatMenuList(menuList: Menu.MenuOptions[]): Menu.MenuOptions[] {
+  const newMenuList: Menu.MenuOptions[] = JSON.parse(JSON.stringify(menuList))
+  return newMenuList.flatMap(item => [item, ...(item.children ? getFlatMenuList(item.children) : [])])
+}
+
+export function getShowMenuList(menuList: Menu.MenuOptions[]): Menu.MenuOptions[] {
+  const newMenuList: Menu.MenuOptions[] = JSON.parse(JSON.stringify(menuList))
+  return newMenuList.filter(item => {
+    if (item.children?.length) item.children = getShowMenuList(item.children)
+    return item.meta?.isHidden === 'F'
+  })
+}
+
+/**
+ * @description 使用递归找出所有面包屑存储到 pinia/vuex 中
+ * @param {Array} menuList 菜单列表
+ * @param {Array} parent 父级菜单
+ * @param {Object} result 处理后的结果
+ * @returns {Object}
+ */
+export const getAllBreadcrumbList = (
+  menuList: Menu.MenuOptions[],
+  parent: any[] = [],
+  result: {
+    [key: string]: any
+  } = {}
+) => {
+  for (const item of menuList) {
+    result[item.path] = [...parent, item]
+    if (item.children) {
+      getAllBreadcrumbList(item.children, result[item.path], result)
+    }
+  }
+  return result
+}
+
+/**
+ * @description 使用递归处理路由菜单 path,生成一维数组 (第一版本地路由鉴权会用到,该函数暂未使用)
+ * @param {Array} menuList 所有菜单列表
+ * @param {Array} menuPathArr 菜单地址的一维数组 ['**','**']
+ * @returns {Array}
+ */
+export function getMenuListPath(menuList: Menu.MenuOptions[], menuPathArr: string[] = []): string[] {
+  for (const item of menuList) {
+    if (typeof item === 'object' && item.path) {
+      menuPathArr.push(item.path)
+    }
+    if (item.children?.length) {
+      getMenuListPath(item.children, menuPathArr)
+    }
+  }
+  return menuPathArr
+}
+
+/**
+ * @description 递归查询当前 path 所对应的菜单对象 (该函数暂未使用)
+ * @param {Array} menuList 菜单列表
+ * @param {String} path 当前访问地址
+ * @returns {Object | null}
+ */
+export function findMenuByPath(menuList: Menu.MenuOptions[], path: string): Menu.MenuOptions | null {
+  for (const item of menuList) {
+    if (item.path === path) return item
+    if (item.children) {
+      const res = findMenuByPath(item.children, path)
+      if (res) return res
+    }
+  }
+  return null
+}
+
+/**
+ * @description 使用递归过滤需要缓存的菜单 name (该函数暂未使用)
+ * @param {Array} menuList 所有菜单列表
+ * @param {Array} keepAliveNameArr 缓存的菜单 name ['**','**']
+ * @returns {Array}
+ * */
+export function getKeepAliveRouterName(menuList: Menu.MenuOptions[], keepAliveNameArr: string[] = []): string[] {
+  menuList.forEach(item => {
+    if (item.meta.isKeepAlive === 'T' && item.name) keepAliveNameArr.push(item.name)
+    if (item.children?.length) getKeepAliveRouterName(item.children, keepAliveNameArr)
+  })
+  return keepAliveNameArr
+}
+
+/**
+ * @description 格式化表格单元格默认值 (el-table-column)
+ * @param {Number} _row 行
+ * @param {Number} _col 列
+ * @param {*} callValue 当前单元格值
+ * @returns {String}
+ * */
+export function formatTableColumn(_row: number, _col: number, callValue: any) {
+  // 如果当前值为数组,使用 / 拼接(根据需求自定义)
+  if (isArray(callValue)) {
+    return callValue.length ? callValue.join(' / ') : '--'
+  }
+  return callValue ?? '--'
+}
+
+/**
+ * @description 处理值无数据情况
+ * @param {*} callValue 需要处理的值
+ * @returns {String}
+ * */
+export function formatValue(callValue: any) {
+  // 如果当前值为数组,使用 / 拼接(根据需求自定义)
+  if (isArray(callValue)) return callValue.length ? callValue.join(' / ') : '--'
+  return callValue ?? '--'
+}
+
+/**
+ * @description 处理 prop 为多级嵌套的情况,返回的数据 (列如: prop: user.name)
+ * @param {Object} row 当前行数据
+ * @param {String} prop 当前 prop
+ * @returns {*}
+ * */
+export function handleRowAccordingToProp(row: { [key: string]: any }, prop: string) {
+  if (!prop.includes('.')) {
+    return row[prop] ?? '--'
+  }
+  prop.split('.').forEach(item => (row = row[item] ?? '--'))
+  return row
+}
+
+/**
+ * @description 处理 prop,当 prop 为多级嵌套时 ==> 返回最后一级 prop
+ * @param {String} prop 当前 prop
+ * @returns {String}
+ * */
+export function handleProp(prop: string) {
+  const propArr = prop.toString().split('.')
+  if (propArr.length === 1) {
+    return prop
+  }
+  return propArr[propArr.length - 1]
+}
+
+/**
+ * @description 根据枚举列表查询当需要的数据(如果指定了 label 和 value 的 key值,会自动识别格式化)
+ * @param {String} callValue 当前单元格值
+ * @param {Array} enumData 字典列表
+ * @param {Array} fieldNames label && value && children 的 key 值
+ * @param {String} type 过滤类型(目前只有 tag)
+ * @returns {String}
+ * */
+export function filterEnum(callValue: any, enumData?: any, fieldNames?: any, type?: 'tag') {
+  const value = fieldNames?.value ?? 'value'
+  const label = fieldNames?.label ?? 'label'
+  const children = fieldNames?.children ?? 'children'
+  const tagType = fieldNames?.tagType ?? 'tagType'
+  let filterData: { [key: string]: any } = {}
+  // 判断 enumData 是否为数组
+  if (Array.isArray(enumData)) filterData = findItemNested(enumData, callValue, value, children)
+  // 判断是否输出的结果为 tag 类型
+  if (type === 'tag') {
+    return filterData ? filterData[tagType] || '' : ''
+  } else {
+    return filterData ? filterData[label] : '--'
+  }
+}
+
+/**
+ * @description 递归查找 callValue 对应的 enum 值
+ * */
+export function findItemNested(enumData: any, callValue: any, value: string, children: string) {
+  return enumData.reduce((accumulator: any, current: any) => {
+    if (accumulator) return accumulator
+    if (current[value]?.toString() === callValue?.toString()) return current
+    if (current[children]) return findItemNested(current[children], callValue, value, children)
+  }, null)
+}
+
+/**
+ * 是否是local env
+ */
+export function isLocalEnv() {
+  return import.meta.env.MODE === 'development' || import.meta.env.MODE === 'local' || import.meta.env.MODE === 'dev'
+}
+
+/**
+ * 使用 AES-GCM 模式加密消息
+ * @param {string} message - 待加密的消息
+ * @param {string} secretKey - 加密密钥(16 字节)
+ * @returns {{ iv: string, encryptedData: string }} - 返回加密后的数据和 IV
+ */
+export function aesEncrypt(message: string, secretKey: string) {
+  const iv = forge.random.getBytesSync(12) // 生成随机 IV (12 字节)
+  const key = forge.util.createBuffer(secretKey, 'utf8').bytes()
+
+  const cipher = forge.cipher.createCipher('AES-GCM', key)
+  cipher.start({ iv: iv })
+  cipher.update(forge.util.createBuffer(message, 'utf8'))
+  cipher.finish()
+
+  const encrypted = cipher.output.getBytes()
+  const tag = cipher.mode.tag.getBytes()
+
+  return {
+    iv: forge.util.encode64(iv),
+    encryptedData: forge.util.encode64(encrypted + tag)
+  }
+}

+ 125 - 0
src/utils/is.ts

@@ -0,0 +1,125 @@
+/**
+ * @description: 判断值是否未某个类型
+ */
+export function is(val: unknown, type: string) {
+  return Object.prototype.toString.call(val) === `[object ${type}]`
+}
+
+/**
+ * @description:  是否为函数
+ */
+export function isFunction<T extends (...args: any[]) => any>(val: unknown): val is T {
+  return is(val, 'Function')
+}
+
+/**
+ * @description: 是否已定义
+ */
+export const isDef = <T = unknown>(val?: T): val is T => {
+  return typeof val !== 'undefined'
+}
+
+/**
+ * @description: 是否未定义
+ */
+export const isUnDef = <T = unknown>(val?: T): val is T => {
+  return !isDef(val)
+}
+
+/**
+ * @description: 是否为对象
+ */
+export const isObject = (val: any): val is Record<any, any> => {
+  return val !== null && is(val, 'Object')
+}
+
+/**
+ * @description:  是否为时间
+ */
+export function isDate(val: unknown): val is Date {
+  return is(val, 'Date')
+}
+
+/**
+ * @description:  是否为数值
+ */
+export function isNumber(val: unknown): val is number {
+  return is(val, 'Number')
+}
+
+/**
+ * @description:  是否为AsyncFunction
+ */
+export function isAsyncFunction<T = any>(val: unknown): val is Promise<T> {
+  return is(val, 'AsyncFunction')
+}
+
+/**
+ * @description:  是否为promise
+ */
+export function isPromise<T = any>(val: unknown): val is Promise<T> {
+  return is(val, 'Promise') && isObject(val) && isFunction(val.then) && isFunction(val.catch)
+}
+
+/**
+ * @description:  是否为字符串
+ */
+export function isString(val: unknown): val is string {
+  return is(val, 'String')
+}
+
+/**
+ * @description:  是否为boolean类型
+ */
+export function isBoolean(val: unknown): val is boolean {
+  return is(val, 'Boolean')
+}
+
+/**
+ * @description:  是否为数组
+ */
+export function isArray(val: any): val is Array<any> {
+  return val && Array.isArray(val)
+}
+
+/**
+ * @description: 是否客户端
+ */
+export const isClient = () => {
+  return typeof window !== 'undefined'
+}
+
+/**
+ * @description: 是否为浏览器
+ */
+export const isWindow = (val: any): val is Window => {
+  return typeof window !== 'undefined' && is(val, 'Window')
+}
+
+/**
+ * @description: 是否为 element 元素
+ */
+export const isElement = (val: unknown): val is Element => {
+  return isObject(val) && !!val.tagName
+}
+
+/**
+ * @description: 是否为 null
+ */
+export function isNull(val: unknown): val is null {
+  return val === null
+}
+
+/**
+ * @description: 是否为 null || undefined
+ */
+export function isNullOrUnDef(val: unknown): val is null | undefined {
+  return isUnDef(val) || isNull(val)
+}
+
+/**
+ * @description: 是否为 16 进制颜色
+ */
+export const isHexColor = (str: string) => {
+  return /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(str)
+}

+ 45 - 0
src/utils/nprogress.ts

@@ -0,0 +1,45 @@
+import { nextTick, unref } from 'vue'
+import type { NProgressOptions } from 'nprogress'
+import NProgress from 'nprogress'
+import 'nprogress/nprogress.css'
+import { useCssVar } from '@vueuse/core'
+
+const primaryColor = useCssVar('--el-color-primary', document.documentElement)
+
+export const useNProgress = () => {
+  NProgress.configure({
+    // 动画方式
+    easing: 'ease',
+    // 递增进度条的速度
+    speed: 500,
+    // 是否显示加载ico
+    showSpinner: false,
+    // 自动递增间隔
+    trickleSpeed: 200,
+    // 初始化时的最小百分比
+    minimum: 0.3
+  } as NProgressOptions)
+
+  const initColor = async () => {
+    await nextTick()
+    const bar = document.getElementById('nprogress')?.getElementsByClassName('bar')[0] as ElRef
+    if (bar && primaryColor.value) {
+      bar.style.background = unref(primaryColor.value)
+    }
+  }
+
+  initColor()
+
+  const start = () => {
+    NProgress.start()
+  }
+
+  const done = () => {
+    NProgress.done()
+  }
+
+  return {
+    start,
+    done
+  }
+}

+ 15 - 0
src/utils/token.ts

@@ -0,0 +1,15 @@
+import Cookies from 'js-cookie'
+
+export const TOKEN_KEY = 'eco-token'
+
+export const setToken = (token: string) => {
+  Cookies.set(TOKEN_KEY, token)
+}
+
+export const getToken = () => {
+  return Cookies.get(TOKEN_KEY)
+}
+
+export const removeToken = () => {
+  Cookies.remove(TOKEN_KEY)
+}

+ 20 - 0
src/views/error/404.vue

@@ -0,0 +1,20 @@
+<template>
+  <div class="not-container">
+    <img src="@/assets/images/404.png" class="not-img" alt="404" />
+    <div class="not-detail">
+      <h2>404</h2>
+      <h4>抱歉,您访问的页面不存在~🤷‍♂️🤷‍♀️</h4>
+      <el-button type="primary" @click="router.back"> 返回上一页 </el-button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useRouter } from 'vue-router'
+
+const router = useRouter()
+</script>
+
+<style scoped lang="scss">
+@use './index';
+</style>

+ 20 - 0
src/views/error/500.vue

@@ -0,0 +1,20 @@
+<template>
+  <div class="not-container">
+    <img src="@/assets/images/500.png" class="not-img" alt="500" />
+    <div class="not-detail">
+      <h2>500</h2>
+      <h4>抱歉,您的网络不见了~🤦‍♂️🤦‍♀️</h4>
+      <el-button type="primary" @click="router.back"> 返回上一页 </el-button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useRouter } from 'vue-router'
+
+const router = useRouter()
+</script>
+
+<style scoped lang="scss">
+@use './index';
+</style>

+ 38 - 0
src/views/error/index.scss

@@ -0,0 +1,38 @@
+.not-container {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+
+  .not-img {
+    margin-right: 120px;
+  }
+
+  .not-detail {
+    display: flex;
+    flex-direction: column;
+
+    h2,
+    h4 {
+      padding: 0;
+      margin: 0;
+    }
+
+    h2 {
+      font-size: 60px;
+      color: var(--el-text-color-primary);
+    }
+
+    h4 {
+      margin: 30px 0 20px;
+      font-size: 19px;
+      font-weight: normal;
+      color: var(--el-text-color-regular);
+    }
+
+    .el-button {
+      width: 100px;
+    }
+  }
+}

+ 0 - 0
src/views/login/index.vue