Browse Source

✨ feat: 增加权限管理

Pchen0 1 month ago
parent
commit
b0aaaf870c

+ 1 - 1
.env

@@ -1,3 +1,3 @@
 VITE_APP_API_BASE_URL=https://lepao-api.xxoo365.top
 VITE_RSA_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF6Z1lGYzVRMGVqbTh4akZsSjdMSQpBZDJGeC9TalM0OWQ5emwyZHlaNzNDMGZLU3Fuc1pJQUZkREpWZWV6bUp6T1hOWFdoYVZHaHFwM0dCUWVvcDBKClIxekZ3bUs1em9ReElTTDc5WVF3SmxoSjdaellhL0xNcGtGZDRDVFQ4UzUwTGFzN1FpcUtqRE1BQjFLZEpaTnIKNE5HcjNUWVV4MVVpTzlUTW9YV3lBdFZRQVN2a3lFSVFIb3B4T2Vod0ZuNGRhVE8vLzF5TXRyNnZoclE4enJRMwpxUG01YWJmY0lRM3B1WDVJd1MrekRmSkI5Rm9rc0paa3RWNHI2KzM2U1E3WGp2MDFBQjJvK20yejZqNzNuWjQ1Ci95TEx5NmZHTG5lTWpTaUxHMDhNUWFCUjV1dTNITTRnMkpnanA4eU10Rkg1Tkc5Zys1dXRhTDNzd3JGQjhxd1UKdFFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==
-VITE_APP_VERSION=2.5
+VITE_APP_VERSION=2.6

+ 3 - 2
src/api/login.js

@@ -43,10 +43,11 @@ export function getImageCaptcha () {
   })
 }
 
-export function GetPermissions() {
+export function GetPermissions(parameter) {
   return request({
     url: api.GetPermissions,
-    method: 'get'
+    method: 'get',
+    params: parameter
   })
 }
 

+ 48 - 0
src/api/permission.js

@@ -0,0 +1,48 @@
+import request from '@/utils/request'
+
+const api = {
+  Points: '/Admin/Permission/Points',
+  User: '/Admin/Permission/User',
+  Resources: '/Admin/Permission/Resources',
+  Resource: '/Admin/Permission/Resource'
+}
+
+export function getPermissionPoints(parameter) {
+  return request({
+    url: api.Points,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function getUserPermissions(parameter) {
+  return request({
+    url: api.User,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function setUserPermissions(parameter) {
+  return request({
+    url: api.User,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function getPermissionResources(parameter) {
+  return request({
+    url: api.Resources,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function updatePermissionResource(parameter) {
+  return request({
+    url: api.Resource,
+    method: 'post',
+    data: parameter
+  })
+}

+ 6 - 3
src/components/Menu/index.vue

@@ -29,6 +29,7 @@ import { routes } from '@/router'
 import { useUserStore } from '@/store/modules/user'
 import { useRouteListener } from '@/utils/route-listener'
 import { isElectron } from '../../utils/electron'
+import { canAccessRoute } from '@/utils/permission'
 
 const userStore = useUserStore()
 const user = ref({})
@@ -38,9 +39,7 @@ const menuData = ref([])
 const { selectedKey } = useRouteListener()
 
 const hasPermission = (route) => {
-    if (!route.meta || !route.meta.permission) return true
-    if (!user.value.roles || user.value.roles.length === 0) return false
-    return route.meta.permission.some((perm) => user.value.roles.includes(perm))
+    return canAccessRoute(route, user.value)
 }
 
 const checkEnv = (route) => {
@@ -72,6 +71,10 @@ const generateMenu = (routes, parentPath = '') => {
 
 onMounted(async () => {
     user.value = await userStore.getInfo()
+    if ((!user.value.permissionCodes || user.value.permissionCodes.length === 0) && userStore.refreshPermissions) {
+        await userStore.refreshPermissions()
+        user.value = userStore.$state
+    }
     electron.value = isElectron()
     menuData.value = generateMenu(routes)
 })

+ 9 - 1
src/components/userCard/userCard.vue

@@ -10,7 +10,7 @@
         v-else>
         <span><icon-thunderbolt /> 去乐跑</span>
       </a-button>
-      <a-button type="primary" size="large" @click="SendCount()">
+      <a-button v-if="hasPermission('action.goods.sendCount')" type="primary" size="large" @click="SendCount()">
         <span><icon-gift /> 赠送次数</span>
       </a-button>
       <a-button size="large" @click="goSendCountRecords">
@@ -37,6 +37,7 @@ import { ref, reactive, onUnmounted, onMounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { getCount, sendCount } from '@/api/goods'
 import { Notification, Message } from '@arco-design/web-vue'
+import { hasPermission } from '@/utils/permission'
 
 const props = defineProps({
   "type": {
@@ -59,6 +60,13 @@ const sendform = reactive({
 })
 
 const handleBeforeOk = async (done) => {
+  if (!hasPermission('action.goods.sendCount')) {
+    Notification.warning({
+      title: '无权限',
+      content: '当前账号暂无赠送乐跑次数权限'
+    })
+    return false
+  }
   try {
     ok_loading.value = true
     if (!sendform.username) {

+ 2 - 1
src/pages/admin/goods/sendCountRequestList.vue

@@ -68,7 +68,7 @@
           {{ record.reject_reason || '-' }}
         </template>
         <template #optional="{ record }">
-          <a-space v-if="record.status === 'pending'">
+          <a-space v-if="record.status === 'pending' && hasPermission('action.goods.reviewSendCount')">
             <a-button type="primary" size="small" @click="approveRequest(record.id)">通过</a-button>
             <a-button status="danger" size="small" @click="openRejectModal(record.id)">拒绝</a-button>
           </a-space>
@@ -100,6 +100,7 @@ import {
   adminRejectSendCountRequest,
   adminSendCountRequestList
 } from '@/api/goods'
+import { hasPermission } from '@/utils/permission'
 
 const loading = ref(false)
 const data = ref([])

+ 6 - 5
src/pages/admin/lepaoAccount/accountList.vue

@@ -182,15 +182,15 @@
                     <a-dropdown :popup-max-height="false" trigger="hover">
                         <a-button>操作 <icon-down /></a-button>
                         <template #content>
-                            <a-doption @click="editAccount(record)"><icon-edit /> 编辑账号</a-doption>
+                            <a-doption v-if="hasPermission('action.lepao.admin.updateAccount')" @click="editAccount(record)"><icon-edit /> 编辑账号</a-doption>
                             <a-doption @click="faceRecoRef.openModal(record)"><icon-video-camera /> 人脸采集</a-doption>
                             <a-doption @click="bindBotRef.openModal(record)"><icon-robot-add /> 绑定智能机器人</a-doption>
                             <a-doption @click="openBindAudit(record)"><icon-history /> 绑定解绑记录</a-doption>
-                            <a-doption @click="SingleRun(record)"><icon-play-circle /> 开始单次乐跑</a-doption>
-                            <a-doption @click="ChangeAutoRun(record)"><icon-translate /> {{ record.auto_run ? '关闭' :
+                            <a-doption v-if="hasPermission('action.lepao.singleRun')" @click="SingleRun(record)"><icon-play-circle /> 开始单次乐跑</a-doption>
+                            <a-doption v-if="hasPermission('action.lepao.changeAutoRun')" @click="ChangeAutoRun(record)"><icon-translate /> {{ record.auto_run ? '关闭' :
                                 '开启' }}自动乐跑</a-doption>
-                            <a-doption @click="UpdateAccountInfo(record)"><icon-refresh /> 更新账号信息</a-doption>
-                            <a-doption @click="DeleteAccount(record)"><icon-minus-circle /> 解绑账号</a-doption>
+                            <a-doption v-if="hasPermission('action.lepao.admin.updateAccount')" @click="UpdateAccountInfo(record)"><icon-refresh /> 更新账号信息</a-doption>
+                            <a-doption v-if="hasPermission('action.lepao.deleteAccount')" @click="DeleteAccount(record)"><icon-minus-circle /> 解绑账号</a-doption>
                         </template>
                     </a-dropdown>
                 </template>
@@ -303,6 +303,7 @@ import { Modal, Notification, Message } from '@arco-design/web-vue'
 import faceModal from '@/components/FaceModal/faceModal.vue'
 import bindBot from '@/components/BindBot/bindBot.vue'
 import { getSemesterTimestamps } from '@/utils/util'
+import { hasPermission } from '@/utils/permission'
 
 const faceRecoRef = ref(null)
 const bindBotRef = ref(null)

+ 272 - 26
src/pages/admin/user/userList.vue

@@ -99,27 +99,29 @@
                 <template #last_login_type="{ record }">
                     <span>{{ formatLastLoginType(record.last_login_type) }}</span>
                 </template>
-                <template #send_count_auto_approve="{ record }">
-                    <a-switch
-                        :model-value="isSendCountAutoApprove(record)"
-                        :loading="whitelistLoadingUuid === record.uuid"
-                        @change="(checked) => onSendCountAutoApproveChange(record, checked)"
-                    />
-                </template>
                 <template #is_banned_status="{ record }">
                     <a-tag v-if="isBanned(record)" color="red">已封禁</a-tag>
                     <a-tag v-else color="green">正常</a-tag>
                 </template>
-                <template #is_banned="{ record }">
-                    <a-switch
-                        :model-value="isBanned(record)"
-                        :loading="banLoadingUuid === record.uuid"
-                        checked-color="rgb(var(--red-6))"
-                        @change="(checked) => onBanChange(record, checked)"
-                    />
-                </template>
                 <template #optional="{ record }">
-                    <a-button @click="changeCount(record)">更改次数</a-button>
+                    <a-dropdown :popup-max-height="false" trigger="hover">
+                        <a-button>操作 <icon-down /></a-button>
+                        <template #content>
+                            <a-doption v-if="hasPermission('action.user.changeCount')" @click="changeCount(record)">
+                                <icon-edit /> 更改次数
+                            </a-doption>
+                            <a-doption v-if="hasPermission('action.user.setSendCountAutoApprove')" @click="onSendCountAutoApproveChange(record, !isSendCountAutoApprove(record))">
+                                <icon-check-circle /> {{ isSendCountAutoApprove(record) ? '关闭赠送免审' : '开启赠送免审' }}
+                            </a-doption>
+                            <a-doption v-if="hasPermission('action.user.ban')" @click="onBanChange(record, !isBanned(record))">
+                                <icon-minus-circle /> {{ isBanned(record) ? '解除封禁' : '封禁账户' }}
+                            </a-doption>
+                            <a-doption v-if="hasPermission('action.user.permissionManage')" @click="openPermissionManage(record)">
+                                <icon-user /> 权限管理
+                            </a-doption>
+                            <a-doption v-if="!hasAnyUserOperationPermission" disabled>暂无可用操作</a-doption>
+                        </template>
+                    </a-dropdown>
                 </template>
             </a-table>
         </a-card>
@@ -140,12 +142,91 @@
             </a-form-item>
         </a-form>
     </a-modal>
+
+    <a-modal v-model:visible="permissionVisible" title="权限管理" width="860px" @before-ok="handlePermissionBeforeOk"
+        :ok-loading="permissionSaving" :mask-closable="false" draggable esc-to-close closable>
+        <a-spin :loading="permissionLoading">
+            <a-tabs v-model:active-key="permissionTab">
+                <a-tab-pane key="user" title="用户权限">
+                    <a-space direction="vertical" fill>
+                        <a-alert>
+                            正在配置用户 {{ permissionUser?.username || permissionUser?.uuid || '-' }} 的权限。乐跑账号、赠送次数、发起工单等基础操作默认全员开放,取消勾选即可单独关闭;管理端等高级权限需额外勾选。旧角色继承权限会展示在下方,但不会随本窗口保存而改变。
+                        </a-alert>
+                        <a-radio-group v-model="permissionCategory" type="button">
+                            <a-radio value="">全部</a-radio>
+                            <a-radio value="page">页面</a-radio>
+                            <a-radio value="action">操作</a-radio>
+                        </a-radio-group>
+                        <a-checkbox-group v-model="selectedPermissionCodes" class="permission-list">
+                            <a-grid :cols="2" :col-gap="12" :row-gap="12">
+                                <a-grid-item v-for="item in filteredPermissionPoints" :key="item.code">
+                                    <a-card size="small" :bordered="true" :class="{ 'permission-card-basic': isBasicPermission(item.code) }">
+                                        <a-checkbox :value="item.code">
+                                            <div class="permission-title">
+                                                {{ item.name }}
+                                                <a-tag v-if="isBasicPermission(item.code)" size="small" color="arcoblue">默认开放</a-tag>
+                                            </div>
+                                            <div class="permission-code">{{ item.code }}</div>
+                                            <div v-if="item.remark" class="permission-remark">{{ item.remark }}</div>
+                                        </a-checkbox>
+                                    </a-card>
+                                </a-grid-item>
+                            </a-grid>
+                        </a-checkbox-group>
+                        <div>
+                            <div class="permission-section-title">当前生效权限</div>
+                            <a-space wrap>
+                                <a-tag v-for="code in effectivePermissionCodes" :key="code">{{ code }}</a-tag>
+                                <span v-if="effectivePermissionCodes.length === 0" class="muted">暂无</span>
+                            </a-space>
+                        </div>
+                    </a-space>
+                </a-tab-pane>
+                <a-tab-pane key="resource" title="权限规则">
+                    <a-alert style="margin-bottom: 12px;">
+                        这里配置页面、操作或接口需要哪些权限。关闭规则或清空权限后,该资源默认不需要权限。
+                    </a-alert>
+                    <div class="permission-resource-list">
+                        <a-card v-for="rule in permissionResources" :key="rule.id" size="small" class="permission-resource-card">
+                            <a-space direction="vertical" fill>
+                                <a-row align="center">
+                                    <a-col :flex="1">
+                                        <div class="permission-title">{{ formatResourceRule(rule) }}</div>
+                                        <div class="permission-code">{{ rule.resource_type }}:{{ rule.resource_key }}</div>
+                                    </a-col>
+                                    <a-col>
+                                        <a-switch v-model="rule.enabled" :checked-value="1" :unchecked-value="0" />
+                                    </a-col>
+                                </a-row>
+                                <a-select v-model="rule.required_codes" multiple allow-search allow-clear placeholder="请选择该资源需要的权限点">
+                                    <a-option v-for="item in permissionPoints" :key="item.code" :value="item.code">
+                                        {{ item.name }}({{ item.code }})
+                                    </a-option>
+                                </a-select>
+                                <a-button size="small" type="primary" :loading="resourceSavingId === rule.id" @click="saveResourceRule(rule)">
+                                    保存规则
+                                </a-button>
+                            </a-space>
+                        </a-card>
+                    </div>
+                </a-tab-pane>
+            </a-tabs>
+        </a-spin>
+    </a-modal>
 </template>
 
 <script setup>
-import { ref, reactive, onMounted } from 'vue'
+import { ref, reactive, onMounted, computed } from 'vue'
 import { adminGetUserList, adminChangeLepaoCount, adminSetSendCountAutoApprove, adminSetUserBan } from '@/api/user'
+import { getPermissionPoints, getPermissionResources, getUserPermissions, setUserPermissions, updatePermissionResource } from '@/api/permission'
 import { Notification, Message, Modal } from '@arco-design/web-vue'
+import {
+    buildSelectedPermissionCodes,
+    extractDeniedBasicPermissionCodes,
+    filterSavablePermissionCodes,
+    hasPermission,
+    isBasicPermission
+} from '@/utils/permission'
 
 const visible = ref(false)
 const ok_loading = ref(false)
@@ -172,6 +253,31 @@ const loading = ref(false)
 const data = ref([])
 const whitelistLoadingUuid = ref('')
 const banLoadingUuid = ref('')
+const permissionVisible = ref(false)
+const permissionLoading = ref(false)
+const permissionSaving = ref(false)
+const permissionCategory = ref('')
+const permissionTab = ref('user')
+const permissionUser = ref(null)
+const permissionPoints = ref([])
+const permissionResources = ref([])
+const selectedPermissionCodes = ref([])
+const effectivePermissionCodes = ref([])
+const resourceSavingId = ref(0)
+
+const hasAnyUserOperationPermission = computed(() => {
+    return [
+        'action.user.changeCount',
+        'action.user.setSendCountAutoApprove',
+        'action.user.ban',
+        'action.user.permissionManage'
+    ].some(code => hasPermission(code))
+})
+
+const filteredPermissionPoints = computed(() => {
+    if (!permissionCategory.value) return permissionPoints.value
+    return permissionPoints.value.filter(item => item.category === permissionCategory.value)
+})
 
 const isSendCountAutoApprove = (record) => Number(record?.send_count_auto_approve) === 1
 const isBanned = (record) => Number(record?.is_banned) === 1
@@ -246,6 +352,118 @@ const onBanChange = async (record, checked) => {
     await setUserBan(record, 0)
 }
 
+const openPermissionManage = async (record) => {
+    if (!record?.uuid) return
+    permissionUser.value = record
+    permissionVisible.value = true
+    permissionLoading.value = true
+    permissionTab.value = 'user'
+    permissionCategory.value = ''
+    selectedPermissionCodes.value = []
+    effectivePermissionCodes.value = []
+
+    try {
+        const [pointRes, userRes, resourceRes] = await Promise.all([
+            getPermissionPoints(),
+            getUserPermissions({ userid: record.uuid }),
+            getPermissionResources()
+        ])
+
+        if (!pointRes || pointRes.code !== 0)
+            throw new Error(pointRes?.msg || '获取权限点失败')
+        if (!userRes || userRes.code !== 0)
+            throw new Error(userRes?.msg || '获取用户权限失败')
+        if (!resourceRes || resourceRes.code !== 0)
+            throw new Error(resourceRes?.msg || '获取权限规则失败')
+
+        permissionPoints.value = pointRes.data || []
+        permissionResources.value = (resourceRes.data || []).map(item => ({
+            ...item,
+            enabled: Number(item.enabled) === 0 ? 0 : 1,
+            required_codes: item.required_codes || []
+        }))
+        selectedPermissionCodes.value = buildSelectedPermissionCodes({
+            directPermissionCodes: userRes.data?.directPermissionCodes,
+            deniedBasicPermissionCodes: userRes.data?.deniedBasicPermissionCodes
+        })
+        effectivePermissionCodes.value = userRes.data?.effectivePermissionCodes || []
+    } catch (error) {
+        Notification.error({
+            title: '获取权限失败',
+            content: error.message || '请稍后再试'
+        })
+        permissionVisible.value = false
+    } finally {
+        permissionLoading.value = false
+    }
+}
+
+const formatResourceRule = (rule) => {
+    if (rule.resource_type === 'page') return `页面:${rule.resource_key}`
+    if (rule.resource_type === 'action') return `操作:${rule.resource_key}`
+    if (rule.resource_type === 'api') return `接口:${rule.api_method || ''} ${rule.api_path || rule.resource_key}`
+    return rule.resource_key
+}
+
+const saveResourceRule = async (rule) => {
+    resourceSavingId.value = rule.id
+    try {
+        const res = await updatePermissionResource({
+            id: rule.id,
+            enabled: rule.enabled,
+            required_codes: rule.required_codes || []
+        })
+        if (!res || res.code !== 0) {
+            Notification.error({
+                title: '保存权限规则失败',
+                content: res?.msg ?? '请稍后再试'
+            })
+            return
+        }
+        Message.success('权限规则已保存')
+    } catch (error) {
+        Notification.error({
+            title: '保存权限规则失败',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        resourceSavingId.value = 0
+    }
+}
+
+const handlePermissionBeforeOk = async (done) => {
+    if (!permissionUser.value?.uuid) return false
+    permissionSaving.value = true
+    try {
+        const res = await setUserPermissions({
+            userid: permissionUser.value.uuid,
+            permissionCodes: filterSavablePermissionCodes(selectedPermissionCodes.value),
+            deniedBasicPermissionCodes: extractDeniedBasicPermissionCodes(selectedPermissionCodes.value)
+        })
+
+        if (!res || res.code !== 0) {
+            Notification.error({
+                title: '保存权限失败',
+                content: res?.msg ?? '请稍后再试'
+            })
+            return false
+        }
+
+        effectivePermissionCodes.value = res.data?.effectivePermissionCodes || []
+        Message.success('权限已保存')
+        done()
+        return true
+    } catch (error) {
+        Notification.error({
+            title: '保存权限失败',
+            content: error.message || '请稍后再试'
+        })
+        return false
+    } finally {
+        permissionSaving.value = false
+    }
+}
+
 const columns = [
 
     {
@@ -287,22 +505,14 @@ const columns = [
         title: '剩余乐跑次数',
         dataIndex: 'lepao_count',
         width: 120,
-    }, {
-        title: '赠送免审',
-        slotName: 'send_count_auto_approve',
-        width: 110,
     }, {
         title: '账号状态',
         slotName: 'is_banned_status',
         width: 100,
-    }, {
-        title: '封禁',
-        slotName: 'is_banned',
-        width: 80,
     }, {
         title: '操作',
         slotName: 'optional',
-        width: 100,
+        width: 120,
         align: 'center',
         fixed: 'right'
     }]
@@ -437,4 +647,40 @@ const formatLastLoginType = (type) => {
 .muted {
     color: var(--color-text-3);
 }
+
+.permission-list {
+    width: 100%;
+    max-height: 420px;
+    overflow-y: auto;
+}
+
+.permission-title {
+    font-weight: 500;
+    color: var(--color-text-1);
+}
+
+.permission-code,
+.permission-remark {
+    margin-top: 4px;
+    color: var(--color-text-3);
+    font-size: 12px;
+}
+
+.permission-section-title {
+    margin-bottom: 8px;
+    font-weight: 500;
+}
+
+.permission-card-basic {
+    opacity: 0.85;
+}
+
+.permission-resource-list {
+    max-height: 480px;
+    overflow-y: auto;
+}
+
+.permission-resource-card + .permission-resource-card {
+    margin-top: 12px;
+}
 </style>

+ 7 - 6
src/pages/lepao/accountList/index.vue

@@ -6,7 +6,7 @@
 
     <a-card title="账号列表" style="margin-top: 15px;">
       <div class="buttonGroup">
-        <a-button type="primary" size="large" @click="editAccount()">
+        <a-button v-if="hasPermission('action.lepao.addAccount')" type="primary" size="large" @click="editAccount()">
           <template #icon>
             <icon-plus />
           </template>
@@ -269,14 +269,14 @@
               <a-dropdown :popup-max-height="false" trigger="hover">
                 <a-button>操作 <icon-down /></a-button>
                 <template #content>
-                  <a-doption @click="editAccount(record)"><icon-edit /> 编辑账号</a-doption>
+                  <a-doption v-if="hasPermission('action.lepao.updateAccount')" @click="editAccount(record)"><icon-edit /> 编辑账号</a-doption>
                   <!-- <a-doption @click="faceRecoRef.openModal(record)"><icon-video-camera /> 人脸采集</a-doption> -->
                   <!-- <a-doption @click="bindBotRef.openModal(record)"><icon-robot-add /> 绑定智能机器人</a-doption> -->
-                  <a-doption @click="SingleRun(record)"><icon-play-circle /> 开始单次乐跑</a-doption>
-                  <a-doption @click="ChangeAutoRun(record)"><icon-translate /> {{ record.auto_run ? '关闭' :
+                  <a-doption v-if="hasPermission('action.lepao.singleRun')" @click="SingleRun(record)"><icon-play-circle /> 开始单次乐跑</a-doption>
+                  <a-doption v-if="hasPermission('action.lepao.changeAutoRun')" @click="ChangeAutoRun(record)"><icon-translate /> {{ record.auto_run ? '关闭' :
                     '开启' }}自动乐跑</a-doption>
-                  <a-doption @click="UpdateSelfAccount(record)"><icon-refresh /> 更新账号信息</a-doption>
-                  <a-doption @click="DeleteAccount(record)"><icon-minus-circle /> 解绑账号</a-doption>
+                  <a-doption v-if="hasPermission('action.lepao.updateAccount')" @click="UpdateSelfAccount(record)"><icon-refresh /> 更新账号信息</a-doption>
+                  <a-doption v-if="hasPermission('action.lepao.deleteAccount')" @click="DeleteAccount(record)"><icon-minus-circle /> 解绑账号</a-doption>
                 </template>
               </a-dropdown>
             </template>
@@ -367,6 +367,7 @@ import faceModal from '@/components/FaceModal/faceModal.vue'
 import bindBot from '@/components/BindBot/bindBot.vue'
 import { useRoute } from 'vue-router'
 import { getNotice, getSemesterTimestamps } from '@/utils/util'
+import { hasPermission } from '@/utils/permission'
 
 const notice = ref('')
 

+ 13 - 1
src/pages/service/createOrder.vue

@@ -24,8 +24,12 @@
                     <a-input v-model="form.email" :max-length="30" allow-clear placeholder="请填写您的邮箱,客服回复后会通过邮件通知您" />
                 </a-form-item>
 
+                <a-alert v-if="!hasPermission('action.service.createOrder')" type="warning" style="margin-bottom: 16px;">
+                    当前账号暂无发起工单权限
+                </a-alert>
+
                 <a-form-item>
-                    <a-button html-type="submit" :loading="loading">提交</a-button>
+                    <a-button html-type="submit" :loading="loading" :disabled="!hasPermission('action.service.createOrder')">提交</a-button>
                 </a-form-item>
             </a-form>
         </a-card>
@@ -37,6 +41,7 @@ import { ref, reactive } from 'vue'
 import { createOrder } from '@/api/workOrder'
 import { useRouter } from 'vue-router'
 import { Notification } from '@arco-design/web-vue'
+import { hasPermission } from '@/utils/permission'
 
 const loading = ref(false)
 const router = useRouter()
@@ -71,6 +76,13 @@ const form = reactive({
 })
 
 const handleSubmit = async () => {
+    if (!hasPermission('action.service.createOrder')) {
+        Notification.warning({
+            title: '无权限',
+            content: '当前账号暂无发起工单权限'
+        })
+        return
+    }
     try {
         loading.value = true
         const res = await createOrder(form)

+ 9 - 1
src/pages/service/orderDetail.vue

@@ -63,7 +63,7 @@
             </div>
         </a-card>
 
-        <a-card title="回复工单" style="margin-top: 15px;" v-if="data.state !== 2">
+        <a-card title="回复工单" style="margin-top: 15px;" v-if="data.state !== 2 && hasPermission('action.service.createOrder')">
             <a-form :model="form" :rules="rules" layout="vertical" :style="{ width: '600px' }"
                 @submit-success="handleSubmit">
                 <a-form-item field="content" label="内容">
@@ -91,6 +91,7 @@ import { ref, reactive, onMounted, onUnmounted } from 'vue'
 import { orderDetail, closeOrder, createOrder } from '@/api/workOrder'
 import { Notification } from '@arco-design/web-vue'
 import { useRoute } from 'vue-router'
+import { hasPermission } from '@/utils/permission'
 
 const route = useRoute()
 
@@ -139,6 +140,13 @@ function getState(state) {
 }
 
 const handleSubmit = async () => {
+    if (!hasPermission('action.service.createOrder')) {
+        Notification.warning({
+            title: '无权限',
+            content: '当前账号暂无回复工单权限'
+        })
+        return
+    }
     try {
         formLoading.value = true
         const data = {

+ 25 - 11
src/router/index.js

@@ -1,6 +1,7 @@
 import * as VueRouter from 'vue-router'
 import { useUserStore } from '@/store'
 import { isElectron } from '../utils/electron'
+import { canAccessRoute } from '@/utils/permission'
 const DEFAULT_LAYOUT = () => import('@/layout/default-layout.vue')
 const HTML_VIEW = () => import('@/layout/html-view.vue')
 // import { Message } from '@arco-design/web-vue'
@@ -146,7 +147,8 @@ const routes = [
                 name: 'lepao.accountList',
                 component: () => import('../pages/lepao/accountList/index.vue'),
                 meta: {
-                    title: '乐跑账号'
+                    title: '乐跑账号',
+                    permissionCode: 'page.lepao.accountList'
                 }
             },
             {
@@ -230,7 +232,8 @@ const routes = [
                 name: 'service.createOrder',
                 component: () => import('../pages/service/createOrder.vue'),
                 meta: {
-                    title: '提交工单'
+                    title: '提交工单',
+                    permissionCode: 'page.service.createOrder'
                 }
             },
             {
@@ -281,7 +284,13 @@ const routes = [
         meta: {
             title: '网站管理',
             icon: 'icon-computer',
-            permission: ['admin', 'service', 'product']
+            permission: ['admin', 'service', 'product'],
+            permissionCodes: [
+                'page.admin.userList',
+                'page.admin.lepaoAccount',
+                'page.admin.service.orderList',
+                'page.admin.goods.sendCountRequestList'
+            ]
         },
         children: [
             {
@@ -290,7 +299,8 @@ const routes = [
                 component: () => import('../pages/admin/user/userList.vue'),
                 meta: {
                     title: '用户管理',
-                    permission: ['admin', 'userList']
+                    permission: ['admin', 'userList'],
+                    permissionCode: 'page.admin.userList'
                 }
             },
             {
@@ -299,7 +309,8 @@ const routes = [
                 component: () => import('../pages/admin/lepaoAccount/accountList.vue'),
                 meta: {
                     title: '乐跑账号管理',
-                    permission: ['admin', 'lepaoAccount']
+                    permission: ['admin', 'lepaoAccount'],
+                    permissionCode: 'page.admin.lepaoAccount'
                 }
             },
             {
@@ -363,7 +374,8 @@ const routes = [
                 component: () => import('../pages/admin/workOrder/orderList.vue'),
                 meta: {
                     title: '工单管理',
-                    permission: ['admin', 'service']
+                    permission: ['admin', 'service'],
+                    permissionCode: 'page.admin.service.orderList'
                 }
             },
             {
@@ -392,7 +404,8 @@ const routes = [
                 component: () => import('../pages/admin/goods/sendCountRequestList.vue'),
                 meta: {
                     title: '赠送审核',
-                    permission: ['admin', 'service']
+                    permission: ['admin', 'service'],
+                    permissionCode: 'page.admin.goods.sendCountRequestList'
                 }
             },
             {
@@ -569,10 +582,11 @@ router.beforeEach(async (to, from, next) => {
             return router.push(`/login?from=${to.path}`)
         }
 
-        if (to.meta && to.meta.permission && to.meta.permission.length > 0) {
-            if (!to.meta.permission.some((perm) => user.roles.includes(perm)))
-                return next('/')
-        }
+        if ((!user.permissionCodes || user.permissionCodes.length === 0) && userStore.refreshPermissions)
+            await userStore.refreshPermissions()
+
+        user = userStore.$state
+        if (!canAccessRoute(to, user)) return next('/')
     }
 
     if (to.meta.viewport) {

+ 20 - 1
src/store/modules/user.js

@@ -1,7 +1,7 @@
 import { defineStore } from 'pinia'
 import storage from 'store'
 import expirePlugin from 'store/plugins/expire'
-import { bindSocial, login, unbindSocial, uniLogin } from '@/api/login'
+import { bindSocial, GetPermissions, login, unbindSocial, uniLogin } from '@/api/login'
 import { ChangeUsername, GetUserInfo } from '@/api/user'
 
 storage.addPlugin(expirePlugin)
@@ -14,6 +14,8 @@ export const useUserStore = defineStore('user', {
     avatar: '',
     email: '',
     roles: [],
+    permissionCodes: [],
+    permissionPoints: [],
     socialBindings: [],
     boundTypes: []
   }),
@@ -26,6 +28,7 @@ export const useUserStore = defineStore('user', {
         if (!res || res.code !== 0) throw new Error(res?.msg ?? '登录失败!请稍后再试')
         storage.set('user', res.data, new Date().getTime() + 7 * 24 * 60 * 60 * 1000)
         this.setUser(res.data)
+        await this.refreshPermissions()
         return res.data
       } catch (error) {
         throw error
@@ -38,6 +41,7 @@ export const useUserStore = defineStore('user', {
         if (!res || res.code !== 0) throw new Error(res?.msg ?? '登录失败!请稍后再试')
         storage.set('user', res.data, new Date().getTime() + 7 * 24 * 60 * 60 * 1000)
         this.setUser(res.data)
+        await this.refreshPermissions()
         return res.data
       } catch (error) {
         throw error
@@ -88,6 +92,19 @@ export const useUserStore = defineStore('user', {
       return this.$state
     },
 
+    async refreshPermissions() {
+      if (!this.uuid || !this.session) return
+      try {
+        const res = await GetPermissions()
+        if (!res || res.code !== 0) return
+        const payload = res.data || {}
+        this.roles = payload.roles || res.roles || this.roles || []
+        this.permissionCodes = payload.permissionCodes || []
+        this.permissionPoints = payload.permissionPoints || []
+        storage.set('user', this.$state, new Date().getTime() + 7 * 24 * 60 * 60 * 1000)
+      } catch (_) { }
+    },
+
     async getInfoFromServer() {
       try {
         const res = await GetUserInfo()
@@ -114,6 +131,8 @@ export const useUserStore = defineStore('user', {
       this.username = user.username || ''
       this.email = user.email || ''
       this.roles = user.roles || []
+      this.permissionCodes = user.permissionCodes || []
+      this.permissionPoints = user.permissionPoints || []
       this.socialBindings = user.socialBindings || []
       this.boundTypes = user.boundTypes || []
     }

+ 64 - 0
src/utils/permission.js

@@ -0,0 +1,64 @@
+import { useUserStore } from '@/store'
+
+/** 全员默认拥有的基础操作权限,与后端 DEFAULT_BASIC_USER_PERMISSION_CODES 保持一致 */
+export const BASIC_PERMISSION_CODES = [
+  'page.lepao.accountList',
+  'action.lepao.addAccount',
+  'action.lepao.singleRun',
+  'action.lepao.changeAutoRun',
+  'action.lepao.updateAccount',
+  'action.lepao.deleteAccount',
+  'action.goods.sendCount',
+  'page.service.createOrder',
+  'action.service.createOrder'
+]
+
+export const isBasicPermission = (code) => BASIC_PERMISSION_CODES.includes(code)
+
+/** 根据额外授权与已关闭的基础权限,生成弹窗勾选状态 */
+export const buildSelectedPermissionCodes = ({
+  directPermissionCodes = [],
+  deniedBasicPermissionCodes = []
+} = {}) => {
+  const deniedSet = new Set(deniedBasicPermissionCodes)
+  const enabledBasics = BASIC_PERMISSION_CODES.filter((code) => !deniedSet.has(code))
+  return [...new Set([...enabledBasics, ...directPermissionCodes])]
+}
+
+/** 保存时:额外授权(不含基础权限) */
+export const filterSavablePermissionCodes = (codes = []) => {
+  return codes.filter((code) => !isBasicPermission(code))
+}
+
+/** 保存时:未勾选的基础权限视为对该用户关闭 */
+export const extractDeniedBasicPermissionCodes = (selectedCodes = []) => {
+  const selectedSet = new Set(selectedCodes)
+  return BASIC_PERMISSION_CODES.filter((code) => !selectedSet.has(code))
+}
+
+export const isSuperAdmin = (user) => {
+  const roles = user?.roles || []
+  return roles.includes('admin')
+}
+
+export const hasPermission = (code, user) => {
+  if (!code) return true
+  const currentUser = user || useUserStore()
+  if (isSuperAdmin(currentUser)) return true
+  return (currentUser.permissionCodes || []).includes(code)
+}
+
+export const hasAnyPermission = (codes, user) => {
+  if (!codes || codes.length === 0) return true
+  return codes.some(code => hasPermission(code, user))
+}
+
+export const canAccessRoute = (route, user) => {
+  const meta = route?.meta || {}
+  if (meta.permissionCode) return hasPermission(meta.permissionCode, user)
+  if (meta.permissionCodes) return hasAnyPermission(meta.permissionCodes, user)
+
+  const currentUser = user || useUserStore()
+  if (!meta.permission || meta.permission.length === 0) return true
+  return meta.permission.some(perm => (currentUser.roles || []).includes(perm))
+}

+ 4 - 0
src/utils/request.js

@@ -93,6 +93,10 @@ request.interceptors.response.use(async (response) => {
     aesKeyCache.delete(requestId)
   }
 
+  if (response.data?.code === -403) {
+    Message.error(response.data.msg || '权限不足')
+  }
+
   return response.data
 }, errorHandler)