Browse Source

Merge branch 'master' of https://gitee.com/yudaocode/yudao-ui-admin-vue3 into feature/bpm

YunaiV 7 months ago
parent
commit
89c3af5207

+ 2 - 2
package.json

@@ -38,7 +38,7 @@
     "animate.css": "^4.1.1",
     "axios": "^1.6.8",
     "benz-amr-recorder": "^1.1.5",
-    "bpmn-js-token-simulation": "^0.10.0",
+    "bpmn-js-token-simulation": "^0.36.0",
     "camunda-bpmn-moddle": "^7.0.1",
     "cropperjs": "^1.6.1",
     "crypto-js": "^4.2.0",
@@ -47,7 +47,7 @@
     "driver.js": "^1.3.1",
     "echarts": "^5.5.0",
     "echarts-wordcloud": "^2.1.0",
-    "element-plus": "2.8.4",
+    "element-plus": "2.9.1",
     "fast-xml-parser": "^4.3.2",
     "highlight.js": "^11.9.0",
     "jsencrypt": "^3.3.2",

+ 5 - 5
pnpm-lock.yaml

@@ -72,8 +72,8 @@ dependencies:
     specifier: ^2.1.0
     version: 2.1.0(echarts@5.5.1)
   element-plus:
-    specifier: 2.8.4
-    version: 2.8.4(vue@3.5.12)
+    specifier: 2.9.1
+    version: 2.9.1(vue@3.5.12)
   fast-xml-parser:
     specifier: ^4.3.2
     version: 4.5.0
@@ -2122,7 +2122,7 @@ packages:
       '@form-create/element-ui': 3.2.14(vue@3.5.12)
       '@form-create/utils': 3.2.14
       codemirror: 6.65.7
-      element-plus: 2.8.4(vue@3.5.12)
+      element-plus: 2.9.1(vue@3.5.12)
       vue: 3.5.12(typescript@5.3.3)
       vuedraggable: 4.1.0(vue@3.5.12)
     transitivePeerDependencies:
@@ -5795,8 +5795,8 @@ packages:
     resolution: {integrity: sha512-nz88NNBsD7kQSAGGJyp8hS6xSPtWwqNogA0mjtc2nUYeEf3nURK9qpV18TuBdDmEDgVWotS8Wkzf+V52dSQ/LQ==, tarball: https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.67.tgz}
     dev: true
 
-  /element-plus@2.8.4(vue@3.5.12):
-    resolution: {integrity: sha512-ZlVAdUOoJliv4kW3ntWnnSHMT+u/Os7mXJjk2xzOlqNeHaI2/ozlF+R58ZCEak8ZnDi6+5A2viWEYRsq64IuiA==, tarball: https://registry.npmmirror.com/element-plus/-/element-plus-2.8.4.tgz}
+  /element-plus@2.9.1(vue@3.5.12):
+    resolution: {integrity: sha512-9Agqf/jt4Ugk7EZ6C5LME71sgkvauPCsnvJN12Xid2XVobjufxMGpRE4L7pS4luJMOmFAH3J0NgYEGZT5r+NDg==, tarball: https://registry.npmmirror.com/element-plus/-/element-plus-2.9.1.tgz}
     peerDependencies:
       vue: ^3.2.0
     dependencies:

+ 7 - 7
src/api/login/index.ts

@@ -22,11 +22,6 @@ export const register = (data: RegisterVO) => {
   return request.post({ url: '/system/auth/register', data })
 }
 
-// 刷新访问令牌
-export const refreshToken = () => {
-  return request.post({ url: '/system/auth/refresh-token?refreshToken=' + getRefreshToken() })
-}
-
 // 使用租户名,获得租户编号
 export const getTenantIdByName = (name: string) => {
   return request.get({ url: '/system/tenant/get-id-by-name?name=' + name })
@@ -76,12 +71,17 @@ export const socialAuthRedirect = (type: number, redirectUri: string) => {
   })
 }
 // 获取验证图片以及 token
-export const getCode = (data) => {
+export const getCode = (data: any) => {
   debugger
   return request.postOriginal({ url: 'system/captcha/get', data })
 }
 
 // 滑动或者点选验证
-export const reqCheck = (data) => {
+export const reqCheck = (data: any) => {
   return request.postOriginal({ url: 'system/captcha/check', data })
 }
+
+// 通过短信重置密码
+export const smsResetPassword = (data: any) => {
+  return request.post({ url: '/system/auth/sms-reset-password', data })
+}

+ 4 - 0
src/components/Echart/src/Echart.vue

@@ -9,6 +9,10 @@ import { useAppStore } from '@/store/modules/app'
 import { isString } from '@/utils/is'
 import { useDesign } from '@/hooks/web/useDesign'
 
+import 'echarts/lib/component/markPoint'
+import 'echarts/lib/component/markLine'
+import 'echarts/lib/component/markArea'
+
 defineOptions({ name: 'EChart' })
 
 const { getPrefixCls, variables } = useDesign()

+ 5 - 0
src/components/RouterSearch/index.vue

@@ -79,9 +79,14 @@ function remoteMethod(data) {
 
 function handleChange(path) {
   router.push({ path })
+  hiddenSearch()
   hiddenTopSearch()
 }
 
+function hiddenSearch() {
+  showSearch.value = false
+}
+
 function hiddenTopSearch() {
   showTopSearch.value = false
 }

+ 6 - 8
src/components/bpmnProcessDesigner/package/theme/process-designer.scss

@@ -1,6 +1,4 @@
 @use 'bpmn-js-token-simulation/assets/css/bpmn-js-token-simulation.css';
-@use 'bpmn-js-token-simulation/assets/css/font-awesome.min.css';
-@use 'bpmn-js-token-simulation/assets/css/normalize.css';
 
 // 边框被 token-simulation 样式覆盖了
 .djs-palette {
@@ -97,12 +95,12 @@
         box-sizing: border-box;
       }
     }
-    svg {
-      width: 100%;
-      height: 100%;
-      min-height: 100%;
-      overflow: hidden;
-    }
+    // svg {
+    //   width: 100%;
+    //   height: 100%;
+    //   min-height: 100%;
+    //   overflow: hidden;
+    // }
   }
 }
 

+ 12 - 9
src/directives/permission/hasPermi.ts

@@ -5,18 +5,10 @@ const { t } = useI18n() // 国际化
 
 export function hasPermi(app: App<Element>) {
   app.directive('hasPermi', (el, binding) => {
-    const { wsCache } = useCache()
     const { value } = binding
-    const all_permission = '*:*:*'
-    const userInfo = wsCache.get(CACHE_KEY.USER)
-    const permissions = userInfo?.permissions || []
 
     if (value && value instanceof Array && value.length > 0) {
-      const permissionFlag = value
-
-      const hasPermissions = permissions.some((permission: string) => {
-        return all_permission === permission || permissionFlag.includes(permission)
-      })
+      const hasPermissions = hasPermission(value)
 
       if (!hasPermissions) {
         el.parentNode && el.parentNode.removeChild(el)
@@ -26,3 +18,14 @@ export function hasPermi(app: App<Element>) {
     }
   })
 }
+
+export const hasPermission = (permission: string[]) => {
+  const { wsCache } = useCache()
+  const all_permission = '*:*:*'
+  const userInfo = wsCache.get(CACHE_KEY.USER)
+  const permissions = userInfo?.permissions || []
+
+  return permissions.some((p: string) => {
+    return all_permission === p || permission.includes(p)
+  })
+}

+ 4 - 1
src/layout/components/Footer/src/Footer.vue

@@ -12,6 +12,9 @@ const prefixCls = getPrefixCls('footer')
 const appStore = useAppStore()
 
 const title = computed(() => appStore.getTitle)
+
+// 添加当前年份计算属性
+const currentYear = computed(() => new Date().getFullYear())
 </script>
 
 <template>
@@ -19,6 +22,6 @@ const title = computed(() => appStore.getTitle)
     :class="prefixCls"
     class="h-[var(--app-footer-height)] bg-[var(--app-content-bg-color)] text-center leading-[var(--app-footer-height)] text-[var(--el-text-color-placeholder)] dark:bg-[var(--el-bg-color)] overflow-hidden"
   >
-    <span class="text-14px">Copyright ©2022-{{ title }}</span>
+    <span class="text-14px">Copyright ©{{ currentYear }} {{ title }}</span>
   </div>
 </template>

+ 4 - 1
src/locales/en.ts

@@ -140,7 +140,10 @@ export default {
     btnQRCode: 'QR code sign in',
     qrcode: 'Scan the QR code to log in',
     btnRegister: 'Sign up',
-    SmsSendMsg: 'code has been sent'
+    SmsSendMsg: 'code has been sent',
+    resetPassword: "Reset Password",
+    resetPasswordSuccess: "Reset Password Success",
+    invalidTenantName:"Invalid Tenant Name"
   },
   captcha: {
     verification: 'Please complete security verification',

+ 4 - 1
src/locales/zh-CN.ts

@@ -141,7 +141,10 @@ export default {
     btnQRCode: '二维码登录',
     qrcode: '扫描二维码登录',
     btnRegister: '注册',
-    SmsSendMsg: '验证码已发送'
+    SmsSendMsg: '验证码已发送',
+    resetPassword: "重置密码",
+    resetPasswordSuccess: "重置密码成功",
+    invalidTenantName: "无效的租户名称"
   },
   captcha: {
     verification: '请完成安全验证',

+ 1 - 2
src/utils/routerHelper.ts

@@ -73,7 +73,7 @@ export const generateRoute = (routes: AppCustomRouteRecordRaw[]): AppRouteRecord
       noCache: !route.keepAlive,
       alwaysShow:
         route.children &&
-        route.children.length === 1 &&
+        route.children.length > 0 &&
         (route.alwaysShow !== undefined ? route.alwaysShow : true)
     } as any
     // 特殊逻辑:如果后端配置的 MenuDO.component 包含 ?,则表示需要传递参数
@@ -100,7 +100,6 @@ export const generateRoute = (routes: AppCustomRouteRecordRaw[]): AppRouteRecord
     //处理顶级非目录路由
     if (!route.children && route.parentId == 0 && route.component) {
       data.component = Layout
-      data.meta = {}
       data.name = toCamelCase(route.path, true) + 'Parent'
       data.redirect = ''
       meta.alwaysShow = true

+ 3 - 1
src/views/Login/Login.vue

@@ -59,6 +59,8 @@
             <RegisterForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
             <!-- 三方登录 -->
             <SSOLoginVue class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
+            <!-- 忘记密码 -->
+            <ForgetPasswordForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
           </div>
         </Transition>
       </div>
@@ -73,7 +75,7 @@ import { useAppStore } from '@/store/modules/app'
 import { ThemeSwitch } from '@/layout/components/ThemeSwitch'
 import { LocaleDropdown } from '@/layout/components/LocaleDropdown'
 
-import { LoginForm, MobileForm, QrCodeForm, RegisterForm, SSOLoginVue } from './components'
+import { LoginForm, MobileForm, QrCodeForm, RegisterForm, SSOLoginVue, ForgetPasswordForm } from './components'
 
 defineOptions({ name: 'Login' })
 

+ 278 - 0
src/views/Login/components/ForgetPasswordForm.vue

@@ -0,0 +1,278 @@
+<template>
+  <el-form
+    v-show="getShow"
+    ref="formSmsResetPassword"
+    :model="resetPasswordData"
+    :rules="rules"
+    class="login-form"
+    label-position="top"
+    label-width="120px"
+    size="large"
+  >
+    <el-row style="margin-right: -10px; margin-left: -10px">
+      <!-- 租户名 -->
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item>
+          <LoginFormTitle style="width: 100%" />
+        </el-form-item>
+      </el-col>
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item v-if="resetPasswordData.tenantEnable === 'true'" prop="tenantName">
+          <el-input
+            v-model="resetPasswordData.tenantName"
+            :placeholder="t('login.tenantNamePlaceholder')"
+            :prefix-icon="iconHouse"
+            type="primary"
+            link
+          />
+        </el-form-item>
+      </el-col>
+      <!-- 手机号 -->
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item prop="mobile">
+          <el-input
+            v-model="resetPasswordData.mobile"
+            :placeholder="t('login.mobileNumberPlaceholder')"
+            :prefix-icon="iconCellphone"
+          />
+        </el-form-item>
+      </el-col>
+      <Verify
+        ref="verify"
+        :captchaType="captchaType"
+        :imgSize="{ width: '400px', height: '200px' }"
+        mode="pop"
+        @success="getSmsCode"
+      />
+      <!-- 验证码 -->
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item prop="code">
+          <el-row :gutter="5" justify="space-between" style="width: 100%">
+            <el-col :span="24">
+              <el-input
+                v-model="resetPasswordData.code"
+                :placeholder="t('login.codePlaceholder')"
+                :prefix-icon="iconCircleCheck"
+              >
+                <template #append>
+                  <span
+                    v-if="mobileCodeTimer <= 0"
+                    class="getMobileCode"
+                    style="cursor: pointer"
+                    @click="getCode"
+                  >
+                    {{ t('login.getSmsCode') }}
+                  </span>
+                  <span v-if="mobileCodeTimer > 0" class="getMobileCode" style="cursor: pointer">
+                    {{ mobileCodeTimer }}秒后可重新获取
+                  </span>
+                </template>
+              </el-input>
+              <!-- </el-button> -->
+            </el-col>
+          </el-row>
+        </el-form-item>
+      </el-col>
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item prop="password">
+          <InputPassword
+            v-model="resetPasswordData.password"
+            :placeholder="t('login.passwordPlaceholder')"
+            style="width: 100%"
+            strength="true"
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item prop="check_password">
+          <InputPassword
+            v-model="resetPasswordData.check_password"
+            :placeholder="t('login.checkPassword')"
+            style="width: 100%"
+            strength="true"
+          />
+        </el-form-item>
+      </el-col>
+      <!-- 登录按钮 / 返回按钮 -->
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item>
+          <XButton
+            :loading="loginLoading"
+            :title="t('login.resetPassword')"
+            class="w-[100%]"
+            type="primary"
+            @click="resetPassword()"
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item>
+          <XButton
+            :loading="loginLoading"
+            :title="t('login.backLogin')"
+            class="w-[100%]"
+            @click="handleBackLogin()"
+          />
+        </el-form-item>
+      </el-col>
+    </el-row>
+  </el-form>
+</template>
+<script lang="ts" setup>
+import type { RouteLocationNormalizedLoaded } from 'vue-router'
+
+import { useIcon } from '@/hooks/web/useIcon'
+
+import { sendSmsCode, smsResetPassword } from '@/api/login'
+import LoginFormTitle from './LoginFormTitle.vue'
+import { LoginStateEnum, useFormValid, useLoginState } from './useLogin'
+import { ElLoading } from 'element-plus'
+import * as authUtil from '@/utils/auth'
+import * as LoginApi from '@/api/login'
+defineOptions({ name: 'ForgetPasswordForm' })
+const verify = ref()
+
+const { t } = useI18n()
+const message = useMessage()
+const { currentRoute, push } = useRouter()
+const formSmsResetPassword = ref()
+const loginLoading = ref(false)
+const iconHouse = useIcon({ icon: 'ep:house' })
+const iconCellphone = useIcon({ icon: 'ep:cellphone' })
+const iconCircleCheck = useIcon({ icon: 'ep:circle-check' })
+const { validForm } = useFormValid(formSmsResetPassword)
+const { handleBackLogin, getLoginState, setLoginState } = useLoginState()
+const getShow = computed(() => unref(getLoginState) === LoginStateEnum.RESET_PASSWORD)
+const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字
+
+const validatePass2 = (rule, value, callback) => {
+  if (value === '') {
+    callback(new Error('请再次输入密码'))
+  } else if (value !== resetPasswordData.password) {
+    callback(new Error('两次输入密码不一致!'))
+  } else {
+    callback()
+  }
+}
+
+const rules = {
+  tenantName: [{ required: true, min: 2, max: 20, trigger: 'blur', message: '长度为4到16位' }],
+  mobile: [{ required: true, min: 11, max: 11, trigger: 'blur', message: '手机号长度为11位' }],
+  password: [
+    {
+      required: true,
+      min: 4,
+      max: 16,
+      validator: validatePass2,
+      trigger: 'blur',
+      message: '密码长度为4到16位'
+    }
+  ],
+  check_password: [{ required: true, validator: validatePass2, trigger: 'blur' }],
+  code: [required]
+}
+
+const resetPasswordData = reactive({
+  captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE,
+  tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE,
+  tenantName: '',
+  username: '',
+  password: '',
+  check_password: '',
+  mobile: '',
+  code: ''
+})
+
+const smsVO = reactive({
+  tenantName: '',
+  mobile: '',
+  captchaVerification: '',
+  scene: 23
+})
+const mobileCodeTimer = ref(0)
+const redirect = ref<string>('')
+
+// 获取验证码
+const getCode = async () => {
+  // 情况一,未开启:则直接发送验证码
+  if (resetPasswordData.captchaEnable === 'false') {
+    await getSmsCode({})
+  } else {
+    // 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行发送验证码
+    // 弹出验证码
+    verify.value.show()
+  }
+}
+
+const getSmsCode = async (params) => {
+  if (resetPasswordData.tenantEnable === 'true') {
+    await getTenantId()
+  }
+  smsVO.captchaVerification = params.captchaVerification
+  smsVO.mobile = resetPasswordData.mobile
+  await sendSmsCode(smsVO).then(async () => {
+    message.success(t('login.SmsSendMsg'))
+    // 设置倒计时
+    mobileCodeTimer.value = 60
+    let msgTimer = setInterval(() => {
+      mobileCodeTimer.value = mobileCodeTimer.value - 1
+      if (mobileCodeTimer.value <= 0) {
+        clearInterval(msgTimer)
+      }
+    }, 1000)
+  })
+}
+watch(
+  () => currentRoute.value,
+  (route: RouteLocationNormalizedLoaded) => {
+    redirect.value = route?.query?.redirect as string
+  },
+  {
+    immediate: true
+  }
+)
+
+const getTenantId = async () => {
+  if (resetPasswordData.tenantEnable === 'true') {
+    const res = await LoginApi.getTenantIdByName(resetPasswordData.tenantName)
+    if (res == null) {
+      message.error(t('login.invalidTenantName'))
+      throw t('login.invalidTenantName')
+    }
+    authUtil.setTenantId(res)
+  }
+}
+
+// 重置密码
+const resetPassword = async () => {
+  const data = await validForm()
+  if (!data) return
+  await getTenantId()
+  loginLoading.value = true
+  await smsResetPassword(resetPasswordData)
+    .then(async () => {
+      message.success(t('login.resetPasswordSuccess'))
+      setLoginState(LoginStateEnum.LOGIN)
+    })
+    .catch(() => {})
+    .finally(() => {
+      loginLoading.value = false
+      setTimeout(() => {
+        const loadingInstance = ElLoading.service()
+        loadingInstance.close()
+      }, 400)
+    })
+}
+</script>
+
+<style lang="scss" scoped>
+:deep(.anticon) {
+  &:hover {
+    color: var(--el-color-primary) !important;
+  }
+}
+
+.smsbtn {
+  margin-top: 33px;
+}
+</style>

+ 7 - 1
src/views/Login/components/LoginForm.vue

@@ -59,7 +59,13 @@
               </el-checkbox>
             </el-col>
             <el-col :offset="6" :span="12">
-              <el-link style="float: right" type="primary">{{ t('login.forgetPassword') }}</el-link>
+              <el-link
+                style="float: right"
+                type="primary"
+                @click="setLoginState(LoginStateEnum.RESET_PASSWORD)"
+              >
+                {{ t('login.forgetPassword') }}
+              </el-link>
             </el-col>
           </el-row>
         </el-form-item>

+ 2 - 1
src/views/Login/components/index.ts

@@ -4,5 +4,6 @@ import LoginFormTitle from './LoginFormTitle.vue'
 import RegisterForm from './RegisterForm.vue'
 import QrCodeForm from './QrCodeForm.vue'
 import SSOLoginVue from './SSOLogin.vue'
+import ForgetPasswordForm from './ForgetPasswordForm.vue'
 
-export { LoginForm, MobileForm, LoginFormTitle, RegisterForm, QrCodeForm, SSOLoginVue }
+export { LoginForm, MobileForm, LoginFormTitle, RegisterForm, QrCodeForm, SSOLoginVue, ForgetPasswordForm }

+ 10 - 0
src/views/infra/file/index.vue

@@ -95,6 +95,9 @@
       />
       <el-table-column label="操作" align="center">
         <template #default="scope">
+          <el-button link type="primary" @click="copyToClipboard(scope.row.url)">
+            复制链接
+          </el-button>
           <el-button
             link
             type="danger"
@@ -172,6 +175,13 @@ const openForm = () => {
   formRef.value.open()
 }
 
+/** 复制到剪贴板方法 */
+const copyToClipboard = (text: string) => {
+  navigator.clipboard.writeText(text).then(() => {
+    message.success('复制成功')
+  })
+}
+
 /** 删除按钮操作 */
 const handleDelete = async (id: number) => {
   try {

+ 12 - 8
src/views/system/area/index.vue

@@ -16,6 +16,7 @@
         <template #default="{ height, width }">
           <!-- Virtualized Table 虚拟化表格:高性能,解决表格在大数据量下的卡顿问题 -->
           <el-table-v2
+            v-loading="loading"
             :columns="columns"
             :data="list"
             :width="width"
@@ -31,7 +32,7 @@
   <AreaForm ref="formRef" />
 </template>
 <script setup lang="tsx">
-import type { Column } from 'element-plus'
+import { Column } from 'element-plus'
 import AreaForm from './AreaForm.vue'
 import * as AreaApi from '@/api/system/area'
 
@@ -40,7 +41,7 @@ defineOptions({ name: 'SystemArea' })
 // 表格的 column 字段
 const columns: Column[] = [
   {
-    dataKey: 'id', // 需要渲染当前列的数据字段。例如说:{id:9527, name:'Mike'},则填 id
+    dataKey: 'id', // 需要渲染当前列的数据字段
     title: '编号', // 显示在单元格表头的文本
     width: 400, // 当前列的宽度,必须设置
     fixed: true, // 是否固定列
@@ -52,14 +53,17 @@ const columns: Column[] = [
     width: 200
   }
 ]
-// 表格的数据
-const list = ref([])
+const loading = ref(true) // 列表的加载中
+const list = ref([]) // 表格的数据
 
-/**
- * 获得数据列表
- */
+/** 获得数据列表 */
 const getList = async () => {
-  list.value = await AreaApi.getAreaTree()
+  loading.value = true
+  try {
+    list.value = await AreaApi.getAreaTree()
+  } finally {
+    loading.value = false
+  }
 }
 
 /** 添加/修改操作 */

+ 113 - 72
src/views/system/menu/index.vue

@@ -53,10 +53,6 @@
           <Icon class="mr-5px" icon="ep:plus" />
           新增
         </el-button>
-        <el-button plain type="danger" @click="toggleExpandAll">
-          <Icon class="mr-5px" icon="ep:sort" />
-          展开/折叠
-        </el-button>
         <el-button plain @click="refreshMenu">
           <Icon class="mr-5px" icon="ep:refresh" />
           刷新菜单缓存
@@ -67,65 +63,22 @@
 
   <!-- 列表 -->
   <ContentWrap>
-    <el-table
-      v-if="refreshTable"
-      v-loading="loading"
-      :data="list"
-      :default-expand-all="isExpandAll"
-      row-key="id"
-    >
-      <el-table-column :show-overflow-tooltip="true" label="菜单名称" prop="name" width="250" />
-      <el-table-column align="center" label="图标" prop="icon" width="100">
-        <template #default="scope">
-          <Icon :icon="scope.row.icon" />
-        </template>
-      </el-table-column>
-      <el-table-column label="排序" prop="sort" width="60" />
-      <el-table-column :show-overflow-tooltip="true" label="权限标识" prop="permission" />
-      <el-table-column :show-overflow-tooltip="true" label="组件路径" prop="component" />
-      <el-table-column :show-overflow-tooltip="true" label="组件名称" prop="componentName" />
-      <el-table-column label="状态" prop="status">
-        <template #default="scope">
-          <el-switch
-            class="ml-4px"
-            v-model="scope.row.status"
-            v-hasPermi="['system:menu:update']"
-            :active-value="CommonStatusEnum.ENABLE"
-            :inactive-value="CommonStatusEnum.DISABLE"
-            :loading="menuStatusUpdating[scope.row.id]"
-            @change="(val) => handleStatusChanged(scope.row, val as number)"
+    <div style="height: 700px">
+      <!-- AutoResizer 自动调节大小 -->
+      <el-auto-resizer>
+        <template #default="{ height, width }">
+          <!-- Virtualized Table 虚拟化表格:高性能,解决表格在大数据量下的卡顿问题 -->
+          <el-table-v2
+            v-loading="loading"
+            :columns="columns"
+            :data="list"
+            :width="width"
+            :height="height"
+            expand-column-key="name"
           />
         </template>
-      </el-table-column>
-      <el-table-column align="center" label="操作">
-        <template #default="scope">
-          <el-button
-            v-hasPermi="['system:menu:update']"
-            link
-            type="primary"
-            @click="openForm('update', scope.row.id)"
-          >
-            修改
-          </el-button>
-          <el-button
-            v-hasPermi="['system:menu:create']"
-            link
-            type="primary"
-            @click="openForm('create', undefined, scope.row.id)"
-          >
-            新增
-          </el-button>
-          <el-button
-            v-hasPermi="['system:menu:delete']"
-            link
-            type="danger"
-            @click="handleDelete(scope.row.id)"
-          >
-            删除
-          </el-button>
-        </template>
-      </el-table-column>
-    </el-table>
+      </el-auto-resizer>
+    </div>
   </ContentWrap>
 
   <!-- 表单弹窗:添加/修改 -->
@@ -138,6 +91,10 @@ import * as MenuApi from '@/api/system/menu'
 import { MenuVO } from '@/api/system/menu'
 import MenuForm from './MenuForm.vue'
 import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
+import { h } from 'vue'
+import { Column, ElButton } from 'element-plus'
+import { Icon } from '@/components/Icon'
+import { hasPermission } from '@/directives/permission/hasPermi'
 import { CommonStatusEnum } from '@/utils/constants'
 
 defineOptions({ name: 'SystemMenu' })
@@ -146,6 +103,101 @@ const { wsCache } = useCache()
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗
 
+// 表格的 column 字段
+const columns: Column[] = [
+  {
+    dataKey: 'name',
+    title: '菜单名称',
+    width: 250
+  },
+  {
+    dataKey: 'icon',
+    title: '图标',
+    width: 150,
+    cellRenderer: ({ rowData }) => {
+      return h(Icon, {
+        icon: rowData.icon
+      })
+    }
+  },
+  {
+    dataKey: 'sort',
+    title: '排序',
+    width: 100
+  },
+  {
+    dataKey: 'permission',
+    title: '权限标识',
+    width: 240
+  },
+  {
+    dataKey: 'component',
+    title: '组件路径',
+    width: 240
+  },
+  {
+    dataKey: 'componentName',
+    title: '组件名称',
+    width: 240
+  },
+  {
+    dataKey: 'status',
+    title: '状态',
+    width: 160,
+    cellRenderer: ({ rowData }) => {
+      return h(ElSwitch, {
+        modelValue: rowData.status,
+        activeValue: CommonStatusEnum.ENABLE,
+        inactiveValue: CommonStatusEnum.DISABLE,
+        loading: menuStatusUpdating.value[rowData.id],
+        disabled: !hasPermission(['system:menu:update']),
+        onChange: (val) => handleStatusChanged(rowData, val as number)
+      })
+    }
+  },
+  {
+    dataKey: 'operation',
+    title: '操作',
+    width: 200,
+    cellRenderer: ({ rowData }) => {
+      return h(
+        'div',
+        [
+          hasPermission(['system:menu:update']) &&
+            h(
+              ElButton,
+              {
+                link: true,
+                type: 'primary',
+                onClick: () => openForm('update', rowData.id)
+              },
+              '修改'
+            ),
+          hasPermission(['system:menu:create']) &&
+            h(
+              ElButton,
+              {
+                link: true,
+                type: 'primary',
+                onClick: () => openForm('create', undefined, rowData.id)
+              },
+              '新增'
+            ),
+          hasPermission(['system:menu:delete']) &&
+            h(
+              ElButton,
+              {
+                link: true,
+                type: 'danger',
+                onClick: () => handleDelete(rowData.id)
+              },
+              '删除'
+            )
+        ].filter(Boolean)
+      )
+    }
+  }
+]
 const loading = ref(true) // 列表的加载中
 const list = ref<any>([]) // 列表的数据
 const queryParams = reactive({
@@ -153,8 +205,6 @@ const queryParams = reactive({
   status: undefined
 })
 const queryFormRef = ref() // 搜索的表单
-const isExpandAll = ref(false) // 是否展开,默认全部折叠
-const refreshTable = ref(true) // 重新渲染表格状态
 
 /** 查询列表 */
 const getList = async () => {
@@ -184,15 +234,6 @@ const openForm = (type: string, id?: number, parentId?: number) => {
   formRef.value.open(type, id, parentId)
 }
 
-/** 展开/折叠操作 */
-const toggleExpandAll = () => {
-  refreshTable.value = false
-  isExpandAll.value = !isExpandAll.value
-  nextTick(() => {
-    refreshTable.value = true
-  })
-}
-
 /** 刷新菜单缓存按钮操作 */
 const refreshMenu = async () => {
   try {