Commit 1ec56380d4670542f447257bc92539d1d8e6dd94
1 parent
74bd4291
refactor: consolidate menu handling logic and update related components
Showing
20 changed files
with
2136 additions
and
310 deletions
src/App.vue
| 1 | 1 | <template> |
| 2 | - <a-config-provider | |
| 3 | - :theme="{ | |
| 4 | - algorithm: app.isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm, | |
| 5 | - token: { | |
| 6 | - colorPrimary: '#8c7cf0', | |
| 7 | - borderRadius: 12, | |
| 8 | - fontFamily: 'Outfit, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif' | |
| 9 | - } | |
| 10 | - }" | |
| 11 | - > | |
| 2 | + <a-config-provider :theme="themeConfig"> | |
| 12 | 3 | <router-view /> |
| 13 | 4 | </a-config-provider> |
| 14 | 5 | </template> |
| 15 | 6 | |
| 16 | 7 | <script setup lang="ts"> |
| 17 | -import { theme } from 'ant-design-vue' | |
| 8 | +import { computed } from 'vue' | |
| 18 | 9 | import { useAppStore } from '@/stores/app' |
| 19 | 10 | |
| 20 | 11 | const app = useAppStore() |
| 12 | + | |
| 13 | +const themeConfig = computed(() => ({ | |
| 14 | + token: { | |
| 15 | + colorPrimary: '#8c7cf0', | |
| 16 | + borderRadius: 12, | |
| 17 | + fontFamily: 'Outfit, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif', | |
| 18 | + ...(app.isDarkMode | |
| 19 | + ? { | |
| 20 | + colorBgBase: '#14111d', | |
| 21 | + colorBgLayout: '#14111d', | |
| 22 | + colorBgContainer: '#1e1a2e', | |
| 23 | + colorBgElevated: '#28243c', | |
| 24 | + colorTextBase: '#eeecf1', | |
| 25 | + colorText: '#eeecf1', | |
| 26 | + colorTextSecondary: '#c7c3d5', | |
| 27 | + colorTextTertiary: '#a4a0b8', | |
| 28 | + colorTextQuaternary: '#8a869b', | |
| 29 | + colorTextHeading: '#ffffff', | |
| 30 | + colorTextDescription: '#a4a0b8', | |
| 31 | + colorTextPlaceholder: '#8a869b', | |
| 32 | + colorTextDisabled: '#7f7a93', | |
| 33 | + colorIcon: '#d9d6e5', | |
| 34 | + colorIconHover: '#ffffff', | |
| 35 | + colorBorder: 'rgba(255, 255, 255, 0.14)', | |
| 36 | + colorBorderSecondary: 'rgba(255, 255, 255, 0.1)', | |
| 37 | + colorFill: 'rgba(255, 255, 255, 0.16)', | |
| 38 | + colorFillSecondary: 'rgba(255, 255, 255, 0.12)', | |
| 39 | + colorFillTertiary: 'rgba(255, 255, 255, 0.08)', | |
| 40 | + colorFillQuaternary: 'rgba(255, 255, 255, 0.06)', | |
| 41 | + colorFillAlter: 'rgba(255, 255, 255, 0.06)', | |
| 42 | + } | |
| 43 | + : {}), | |
| 44 | + }, | |
| 45 | +})) | |
| 21 | 46 | </script> |
| 22 | 47 | |
| 23 | 48 | <style> |
| 24 | -/* Loading bar simulation */ | |
| 25 | 49 | body::before { |
| 26 | 50 | content: ""; |
| 27 | 51 | position: fixed; | ... | ... |
src/api/index.ts
| 1 | 1 | import request from '@/utils/request' |
| 2 | 2 | |
| 3 | +// 系统菜单管理 | |
| 4 | +export const systemMenuApi = { | |
| 5 | + tree: () => request.get('/api/platform/system/menu/tree'), | |
| 6 | + add: (data: any) => request.post('/api/platform/system/menu/add', data), | |
| 7 | + edit: (data: any) => request.put('/api/platform/system/menu/edit', data), | |
| 8 | + del: (id: number) => request.delete('/api/platform/system/menu/del', { params: { id } }), | |
| 9 | + setVisible: (id: number, visible: number) => | |
| 10 | + request.post('/api/platform/system/menu/setVisible', null, { params: { id, visible } }), | |
| 11 | + setStatus: (id: number, status: number) => | |
| 12 | + request.post('/api/platform/system/menu/setStatus', null, { params: { id, status } }), | |
| 13 | +} | |
| 14 | + | |
| 15 | +export const systemRoleApi = { | |
| 16 | + list: (includeDisabled = false) => request.get('/api/platform/system/role/list', { params: { includeDisabled } }), | |
| 17 | + add: (data: any) => request.post('/api/platform/system/role/add', data), | |
| 18 | + edit: (data: any) => request.put('/api/platform/system/role/edit', data), | |
| 19 | + ban: (id: number) => request.post('/api/platform/system/role/ban', null, { params: { id } }), | |
| 20 | + cancelBan: (id: number) => request.post('/api/platform/system/role/cancelBan', null, { params: { id } }), | |
| 21 | + del: (id: number) => request.delete('/api/platform/system/role/del', { params: { id } }), | |
| 22 | + menuTree: (roleId: number) => request.get(`/api/platform/system/role/${roleId}/menu-tree`), | |
| 23 | + assignMenus: (roleId: number, data: { menuIds: number[] }) => | |
| 24 | + request.post(`/api/platform/system/role/${roleId}/menus`, data), | |
| 25 | +} | |
| 26 | + | |
| 3 | 27 | // 城市管理 |
| 4 | 28 | export const cityApi = { |
| 5 | 29 | tree: () => request.get('/api/platform/city/tree'), |
| ... | ... | @@ -36,7 +60,18 @@ export const substationApi = { |
| 36 | 60 | cancelBan: (id: number) => request.post('/api/platform/substation/cancelBan', null, { params: { id } }), |
| 37 | 61 | del: (id: number) => request.delete('/api/platform/substation/del', { params: { id } }), |
| 38 | 62 | changePassword: (data: { id: number; oldPassword: string; newPassword: string }) => |
| 39 | - request.post('/api/platform/substation/changePassword', data), | |
| 63 | + request.post('/api/platform/substation/changePassword', data, { params: { id: data.id } }), | |
| 64 | +} | |
| 65 | + | |
| 66 | +export const adminUserApi = { | |
| 67 | + list: (keyword?: string) => request.get('/api/platform/admin-user/list', { params: { keyword } }), | |
| 68 | + add: (data: any) => request.post('/api/platform/admin-user/add', data), | |
| 69 | + edit: (data: any) => request.put('/api/platform/admin-user/edit', data), | |
| 70 | + ban: (id: number) => request.post('/api/platform/admin-user/ban', null, { params: { id } }), | |
| 71 | + cancelBan: (id: number) => request.post('/api/platform/admin-user/cancelBan', null, { params: { id } }), | |
| 72 | + del: (id: number) => request.delete('/api/platform/admin-user/del', { params: { id } }), | |
| 73 | + changePassword: (data: { id: number; oldPassword: string; newPassword: string }) => | |
| 74 | + request.post('/api/platform/admin-user/changePassword', data, { params: { id: data.id } }), | |
| 40 | 75 | } |
| 41 | 76 | |
| 42 | 77 | // 商家管理 | ... | ... |
src/components/AppMenuTree.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <template v-for="menu in menus" :key="menu.code"> | |
| 3 | + <a-sub-menu v-if="menu.children?.length" :key="menu.code"> | |
| 4 | + <template #icon> | |
| 5 | + <component :is="resolveIcon(menu.icon)" v-if="resolveIcon(menu.icon)" /> | |
| 6 | + </template> | |
| 7 | + <template #title>{{ menu.name }}</template> | |
| 8 | + <AppMenuTree :menus="menu.children" /> | |
| 9 | + </a-sub-menu> | |
| 10 | + <a-menu-item v-else :key="menu.path || menu.code"> | |
| 11 | + <template #icon> | |
| 12 | + <component :is="resolveIcon(menu.icon)" v-if="resolveIcon(menu.icon)" /> | |
| 13 | + </template> | |
| 14 | + {{ menu.name }} | |
| 15 | + </a-menu-item> | |
| 16 | + </template> | |
| 17 | +</template> | |
| 18 | + | |
| 19 | +<script setup lang="ts"> | |
| 20 | +import type { Component } from 'vue' | |
| 21 | +import type { MenuNode } from '@/types/auth' | |
| 22 | +import { | |
| 23 | + ApiOutlined, | |
| 24 | + ApartmentOutlined, | |
| 25 | + ControlOutlined, | |
| 26 | + GlobalOutlined, | |
| 27 | + HomeOutlined, | |
| 28 | + ShopOutlined, | |
| 29 | + StarOutlined, | |
| 30 | + UnorderedListOutlined, | |
| 31 | + UserOutlined, | |
| 32 | +} from '@ant-design/icons-vue' | |
| 33 | + | |
| 34 | +withDefaults(defineProps<{ menus: MenuNode[] }>(), { | |
| 35 | + menus: () => [], | |
| 36 | +}) | |
| 37 | + | |
| 38 | +const iconMap: Record<string, Component> = { | |
| 39 | + HomeOutlined, | |
| 40 | + GlobalOutlined, | |
| 41 | + ApartmentOutlined, | |
| 42 | + ShopOutlined, | |
| 43 | + UserOutlined, | |
| 44 | + StarOutlined, | |
| 45 | + UnorderedListOutlined, | |
| 46 | + ControlOutlined, | |
| 47 | + ApiOutlined, | |
| 48 | +} | |
| 49 | + | |
| 50 | +function resolveIcon(icon?: string) { | |
| 51 | + if (!icon) return null | |
| 52 | + return iconMap[icon] || null | |
| 53 | +} | |
| 54 | +</script> | ... | ... |
src/config/menu.ts
0 → 100644
| 1 | +import type { MenuNode } from '@/types/auth' | |
| 2 | + | |
| 3 | +export interface QuickLinkItem { | |
| 4 | + path: string | |
| 5 | + title: string | |
| 6 | + desc: string | |
| 7 | +} | |
| 8 | + | |
| 9 | +export const iconRegistry: Record<string, string> = { | |
| 10 | + HomeOutlined: 'home', | |
| 11 | + GlobalOutlined: 'global', | |
| 12 | + ApartmentOutlined: 'apartment', | |
| 13 | + ShopOutlined: 'shop', | |
| 14 | + UserOutlined: 'user', | |
| 15 | + StarOutlined: 'star', | |
| 16 | + UnorderedListOutlined: 'unordered-list', | |
| 17 | + ControlOutlined: 'control', | |
| 18 | + ApiOutlined: 'api', | |
| 19 | +} | |
| 20 | + | |
| 21 | +export const pinnedQuickLinks: QuickLinkItem[] = [ | |
| 22 | + { path: '/city', title: '租户管理', desc: '配置配送费、骑手等级与租户信息' }, | |
| 23 | + { path: '/rider', title: '骑手管理', desc: '查看骑手、设置等级和账号状态' }, | |
| 24 | + { path: '/order', title: '订单列表', desc: '集中处理配送中的订单流转' }, | |
| 25 | + { path: '/substation', title: '分站管理', desc: '维护租户站点账号和菜单分配' }, | |
| 26 | + { path: '/system/menu', title: '菜单管理', desc: '维护平台与分站菜单树结构' }, | |
| 27 | + { path: '/admin-user', title: '平台账号', desc: '维护平台账号与平台菜单角色绑定' }, | |
| 28 | +] | |
| 29 | + | |
| 30 | +export function hasVisibleChildren(menu: MenuNode) { | |
| 31 | + return Array.isArray(menu.children) && menu.children.length > 0 | |
| 32 | +} | ... | ... |
src/layouts/MainLayout.vue
| ... | ... | @@ -17,56 +17,11 @@ |
| 17 | 17 | <div class="menu-scroll"> |
| 18 | 18 | <a-menu |
| 19 | 19 | v-model:selectedKeys="selectedKeys" |
| 20 | - :theme="app.isDarkMode ? 'dark' : 'light'" | |
| 21 | 20 | mode="inline" |
| 22 | 21 | :inline-collapsed="collapsed" |
| 23 | 22 | @click="onMenuClick" |
| 24 | 23 | > |
| 25 | - <a-menu-item key="/dashboard"> | |
| 26 | - <template #icon><home-outlined /></template> | |
| 27 | - 工作台 | |
| 28 | - </a-menu-item> | |
| 29 | - <a-menu-item v-if="isAdmin" key="/city"> | |
| 30 | - <template #icon><global-outlined /></template> | |
| 31 | - 租户管理 | |
| 32 | - </a-menu-item> | |
| 33 | - <a-menu-item v-if="isAdmin" key="/substation"> | |
| 34 | - <template #icon><apartment-outlined /></template> | |
| 35 | - 分站管理 | |
| 36 | - </a-menu-item> | |
| 37 | - <a-sub-menu key="merchant"> | |
| 38 | - <template #icon><shop-outlined /></template> | |
| 39 | - <template #title>商家管理</template> | |
| 40 | - <a-menu-item key="/merchant/enter">入驻申请</a-menu-item> | |
| 41 | - <a-menu-item key="/merchant/store">店铺管理</a-menu-item> | |
| 42 | - </a-sub-menu> | |
| 43 | - <a-menu-item key="/rider"> | |
| 44 | - <template #icon><user-outlined /></template> | |
| 45 | - 骑手管理 | |
| 46 | - </a-menu-item> | |
| 47 | - <a-menu-item key="/rider/evaluate"> | |
| 48 | - <template #icon><star-outlined /></template> | |
| 49 | - 骑手评价 | |
| 50 | - </a-menu-item> | |
| 51 | - <a-sub-menu key="orders"> | |
| 52 | - <template #icon><unordered-list-outlined /></template> | |
| 53 | - <template #title>订单管理</template> | |
| 54 | - <a-menu-item key="/order">订单列表</a-menu-item> | |
| 55 | - <a-menu-item key="/refund">退款管理</a-menu-item> | |
| 56 | - <a-menu-item key="/delivery/order">配送订单</a-menu-item> | |
| 57 | - </a-sub-menu> | |
| 58 | - <a-sub-menu key="config"> | |
| 59 | - <template #icon><control-outlined /></template> | |
| 60 | - <template #title>配置中心</template> | |
| 61 | - <a-menu-item key="/config/fee-plan">配送费配置</a-menu-item> | |
| 62 | - <a-menu-item key="/dispatch/rule">调度配置</a-menu-item> | |
| 63 | - </a-sub-menu> | |
| 64 | - <a-sub-menu key="open"> | |
| 65 | - <template #icon><api-outlined /></template> | |
| 66 | - <template #title>开放平台</template> | |
| 67 | - <a-menu-item key="/open">应用管理</a-menu-item> | |
| 68 | - <a-menu-item key="/open/mock-delivery">模拟推单</a-menu-item> | |
| 69 | - </a-sub-menu> | |
| 24 | + <AppMenuTree :menus="auth.menus" /> | |
| 70 | 25 | </a-menu> |
| 71 | 26 | </div> |
| 72 | 27 | |
| ... | ... | @@ -83,18 +38,22 @@ |
| 83 | 38 | <menu-unfold-outlined /> |
| 84 | 39 | </button> |
| 85 | 40 | <div class="topbar-header-info"> |
| 86 | - <transition name="breadcrumb-fade" mode="out-in"> | |
| 87 | - <a-breadcrumb :key="route.path" class="custom-breadcrumb"> | |
| 88 | - <template #separator> | |
| 89 | - <span class="bc-separator"><right-outlined /></span> | |
| 90 | - </template> | |
| 91 | - <a-breadcrumb-item v-for="(bc, idx) in breadcrumbs" :key="idx"> | |
| 92 | - <span :class="idx === breadcrumbs.length - 1 ? 'bc-last' : 'bc-parent'"> | |
| 93 | - {{ bc.title }} | |
| 94 | - </span> | |
| 95 | - </a-breadcrumb-item> | |
| 96 | - </a-breadcrumb> | |
| 97 | - </transition> | |
| 41 | + <a-breadcrumb class="custom-breadcrumb" separator="/"> | |
| 42 | + <a-breadcrumb-item key="home"> | |
| 43 | + <router-link :to="auth.fallbackHomePath"> | |
| 44 | + <home-outlined /> | |
| 45 | + </router-link> | |
| 46 | + </a-breadcrumb-item> | |
| 47 | + <a-breadcrumb-item v-for="bc in breadcrumbs" :key="bc.code || bc.path || bc.name"> | |
| 48 | + <router-link v-if="bc.path && bc.path !== route.path" :to="bc.path"> | |
| 49 | + {{ bc.name }} | |
| 50 | + </router-link> | |
| 51 | + <span v-else :class="bc.path === route.path ? 'bc-last' : 'bc-parent'"> | |
| 52 | + {{ bc.name }} | |
| 53 | + </span> | |
| 54 | + </a-breadcrumb-item> | |
| 55 | + </a-breadcrumb> | |
| 56 | + <h1>{{ currentTitle }}</h1> | |
| 98 | 57 | </div> |
| 99 | 58 | </div> |
| 100 | 59 | <div class="topbar-actions"> |
| ... | ... | @@ -129,8 +88,8 @@ |
| 129 | 88 | <a-button type="text" class="profile-button"> |
| 130 | 89 | <span class="profile-avatar">{{ avatarText }}</span> |
| 131 | 90 | <span class="profile-copy"> |
| 132 | - <strong>{{ auth.userInfo?.userNickname || '管理员' }}</strong> | |
| 133 | - <small>{{ auth.userInfo?.role === 'admin' ? '超级管理员' : '分站管理员' }}</small> | |
| 91 | + <strong>{{ auth.user?.userNickname || '管理员' }}</strong> | |
| 92 | + <small>{{ auth.user?.role === 'admin' ? '超级管理员' : '分站管理员' }}</small> | |
| 134 | 93 | </span> |
| 135 | 94 | <down-outlined /> |
| 136 | 95 | </a-button> |
| ... | ... | @@ -165,72 +124,33 @@ |
| 165 | 124 | </div> |
| 166 | 125 | <a-menu |
| 167 | 126 | v-model:selectedKeys="selectedKeys" |
| 168 | - :theme="app.isDarkMode ? 'dark' : 'light'" | |
| 169 | 127 | mode="inline" |
| 170 | - @click="onMenuClick(); collapsed = true" | |
| 128 | + @click="onMobileMenuClick" | |
| 171 | 129 | > |
| 172 | - <a-menu-item key="/dashboard"> | |
| 173 | - <template #icon><home-outlined /></template> | |
| 174 | - 工作台 | |
| 175 | - </a-menu-item> | |
| 176 | - <a-menu-item v-if="isAdmin" key="/city"> | |
| 177 | - <template #icon><global-outlined /></template> | |
| 178 | - 租户管理 | |
| 179 | - </a-menu-item> | |
| 180 | - <a-menu-item v-if="isAdmin" key="/substation"> | |
| 181 | - <template #icon><apartment-outlined /></template> | |
| 182 | - 分站管理 | |
| 183 | - </a-menu-item> | |
| 184 | - <a-sub-menu key="merchant"> | |
| 185 | - <template #icon><shop-outlined /></template> | |
| 186 | - <template #title>商家管理</template> | |
| 187 | - <a-menu-item key="/merchant/enter">入驻申请</a-menu-item> | |
| 188 | - <a-menu-item key="/merchant/store">店铺管理</a-menu-item> | |
| 189 | - </a-sub-menu> | |
| 190 | - <a-menu-item key="/rider"> | |
| 191 | - <template #icon><user-outlined /></template> | |
| 192 | - 骑手管理 | |
| 193 | - </a-menu-item> | |
| 194 | - <a-menu-item key="/rider/evaluate"> | |
| 195 | - <template #icon><star-outlined /></template> | |
| 196 | - 骑手评价 | |
| 197 | - </a-menu-item> | |
| 198 | - <a-sub-menu key="orders"> | |
| 199 | - <template #icon><unordered-list-outlined /></template> | |
| 200 | - <template #title>订单管理</template> | |
| 201 | - <a-menu-item key="/order">订单列表</a-menu-item> | |
| 202 | - <a-menu-item key="/refund">退款管理</a-menu-item> | |
| 203 | - <a-menu-item key="/delivery/order">配送订单</a-menu-item> | |
| 204 | - </a-sub-menu> | |
| 205 | - <a-sub-menu key="config"> | |
| 206 | - <template #icon><control-outlined /></template> | |
| 207 | - <template #title>配置中心</template> | |
| 208 | - <a-menu-item key="/config/fee-plan">配送费配置</a-menu-item> | |
| 209 | - <a-menu-item key="/dispatch/rule">调度配置</a-menu-item> | |
| 210 | - </a-sub-menu> | |
| 211 | - <a-sub-menu key="open"> | |
| 212 | - <template #icon><api-outlined /></template> | |
| 213 | - <template #title>开放平台</template> | |
| 214 | - <a-menu-item key="/open">应用管理</a-menu-item> | |
| 215 | - <a-menu-item key="/open/mock-delivery">模拟推单</a-menu-item> | |
| 216 | - </a-sub-menu> | |
| 130 | + <AppMenuTree :menus="auth.menus" /> | |
| 217 | 131 | </a-menu> |
| 218 | 132 | </a-drawer> |
| 219 | 133 | </div> |
| 220 | 134 | </template> |
| 221 | 135 | |
| 222 | 136 | <script setup lang="ts"> |
| 223 | -import { theme } from 'ant-design-vue' | |
| 224 | -import { computed, ref, watch, onMounted, onUnmounted } from 'vue' | |
| 225 | -import { useRouter, useRoute } from 'vue-router' | |
| 137 | +import { computed, onMounted, onUnmounted, ref, watch } from 'vue' | |
| 138 | +import { useRoute, useRouter } from 'vue-router' | |
| 226 | 139 | import { useAuthStore } from '@/stores/auth' |
| 227 | 140 | import { useAppStore } from '@/stores/app' |
| 141 | +import { findMenuTrailByPath } from '@/utils/menu' | |
| 142 | +import AppMenuTree from '@/components/AppMenuTree.vue' | |
| 228 | 143 | import { |
| 229 | - GlobalOutlined, ApartmentOutlined, ShopOutlined, | |
| 230 | - UserOutlined, UnorderedListOutlined, ApiOutlined, DownOutlined, StarOutlined, | |
| 231 | - CalendarOutlined, MenuFoldOutlined, MenuUnfoldOutlined, HomeOutlined, ControlOutlined, | |
| 232 | - FullscreenOutlined, FullscreenExitOutlined, BellOutlined, | |
| 233 | - BulbOutlined, BulbFilled, RightOutlined | |
| 144 | + BellOutlined, | |
| 145 | + BulbFilled, | |
| 146 | + BulbOutlined, | |
| 147 | + CalendarOutlined, | |
| 148 | + DownOutlined, | |
| 149 | + FullscreenExitOutlined, | |
| 150 | + FullscreenOutlined, | |
| 151 | + HomeOutlined, | |
| 152 | + MenuFoldOutlined, | |
| 153 | + MenuUnfoldOutlined, | |
| 234 | 154 | } from '@ant-design/icons-vue' |
| 235 | 155 | |
| 236 | 156 | const router = useRouter() |
| ... | ... | @@ -239,8 +159,9 @@ const auth = useAuthStore() |
| 239 | 159 | const app = useAppStore() |
| 240 | 160 | const collapsed = ref(false) |
| 241 | 161 | const selectedKeys = ref([route.path]) |
| 242 | - | |
| 243 | 162 | const isMobile = ref(window.innerWidth <= 960) |
| 163 | +const isFullscreen = ref(false) | |
| 164 | + | |
| 244 | 165 | function handleResize() { |
| 245 | 166 | isMobile.value = window.innerWidth <= 960 |
| 246 | 167 | } |
| ... | ... | @@ -248,53 +169,18 @@ function handleResize() { |
| 248 | 169 | onMounted(() => { |
| 249 | 170 | window.addEventListener('resize', handleResize) |
| 250 | 171 | }) |
| 172 | + | |
| 251 | 173 | onUnmounted(() => { |
| 252 | 174 | window.removeEventListener('resize', handleResize) |
| 253 | 175 | }) |
| 254 | 176 | |
| 255 | -watch(() => route.path, (p) => { selectedKeys.value = [p] }) | |
| 256 | - | |
| 257 | -const isAdmin = computed(() => auth.userInfo?.role === 'admin') | |
| 258 | - | |
| 259 | -// Breadcrumbs logic | |
| 260 | -const menuParents: Record<string, string> = { | |
| 261 | - '/merchant/enter': '商家管理', | |
| 262 | - '/merchant/store': '商家管理', | |
| 263 | - '/order': '订单管理', | |
| 264 | - '/refund': '订单管理', | |
| 265 | - '/delivery/order': '订单管理', | |
| 266 | - '/config/fee-plan': '配置中心', | |
| 267 | - '/dispatch/rule': '配置中心', | |
| 268 | - '/open': '开放平台', | |
| 269 | - '/open/mock-delivery': '开放平台', | |
| 270 | -} | |
| 271 | - | |
| 272 | -const breadcrumbs = computed(() => { | |
| 273 | - const list = [] | |
| 274 | - const parentTitle = menuParents[route.path] | |
| 275 | - if (parentTitle) { | |
| 276 | - list.push({ title: parentTitle, path: '' }) | |
| 277 | - } | |
| 278 | - if (route.meta && route.meta.title) { | |
| 279 | - list.push({ title: route.meta.title as string, path: route.path }) | |
| 280 | - } | |
| 281 | - return list | |
| 177 | +watch(() => route.path, (path) => { | |
| 178 | + selectedKeys.value = [path] | |
| 282 | 179 | }) |
| 283 | 180 | |
| 284 | -const isFullscreen = ref(false) | |
| 285 | -function toggleFullscreen() { | |
| 286 | - if (!document.fullscreenElement) { | |
| 287 | - document.documentElement.requestFullscreen() | |
| 288 | - isFullscreen.value = true | |
| 289 | - } else { | |
| 290 | - if (document.exitFullscreen) { | |
| 291 | - document.exitFullscreen() | |
| 292 | - isFullscreen.value = false | |
| 293 | - } | |
| 294 | - } | |
| 295 | -} | |
| 296 | - | |
| 297 | -const avatarText = computed(() => (auth.userInfo?.userNickname || '管理员').slice(0, 1)) | |
| 181 | +const breadcrumbs = computed(() => findMenuTrailByPath(auth.menus, route.path)) | |
| 182 | +const currentTitle = computed(() => breadcrumbs.value[breadcrumbs.value.length - 1]?.name || (route.meta.title as string) || '外卖管理') | |
| 183 | +const avatarText = computed(() => (auth.user?.userNickname || '管理员').slice(0, 1)) | |
| 298 | 184 | const todayLabel = computed(() => new Intl.DateTimeFormat('zh-CN', { |
| 299 | 185 | month: 'long', |
| 300 | 186 | day: 'numeric', |
| ... | ... | @@ -305,6 +191,23 @@ function onMenuClick({ key }: { key: string }) { |
| 305 | 191 | router.push(key) |
| 306 | 192 | } |
| 307 | 193 | |
| 194 | +function onMobileMenuClick({ key }: { key: string }) { | |
| 195 | + router.push(key) | |
| 196 | + collapsed.value = true | |
| 197 | +} | |
| 198 | + | |
| 199 | +function toggleFullscreen() { | |
| 200 | + if (!document.fullscreenElement) { | |
| 201 | + document.documentElement.requestFullscreen() | |
| 202 | + isFullscreen.value = true | |
| 203 | + return | |
| 204 | + } | |
| 205 | + if (document.exitFullscreen) { | |
| 206 | + document.exitFullscreen() | |
| 207 | + isFullscreen.value = false | |
| 208 | + } | |
| 209 | +} | |
| 210 | + | |
| 308 | 211 | function handleLogout() { |
| 309 | 212 | auth.logout() |
| 310 | 213 | router.push('/login') |
| ... | ... | @@ -378,7 +281,7 @@ function handleLogout() { |
| 378 | 281 | border-radius: 12px; |
| 379 | 282 | border: none; |
| 380 | 283 | background: var(--line-strong); |
| 381 | - color: var(--brand); | |
| 284 | + color: var(--brand-deep); | |
| 382 | 285 | cursor: pointer; |
| 383 | 286 | margin-bottom: 12px; |
| 384 | 287 | } |
| ... | ... | @@ -417,38 +320,44 @@ function handleLogout() { |
| 417 | 320 | flex-direction: column; |
| 418 | 321 | } |
| 419 | 322 | |
| 420 | -.brand-copy strong { | |
| 421 | - font-size: 15px; | |
| 422 | - line-height: 1.35; | |
| 423 | -} | |
| 424 | - | |
| 425 | -.brand-copy span { | |
| 426 | - margin-top: 2px; | |
| 427 | - font-size: 11px; | |
| 428 | - line-height: 1.4; | |
| 429 | -} | |
| 430 | - | |
| 431 | 323 | .brand-copy strong, |
| 432 | 324 | .profile-copy strong, |
| 433 | -.hero-copy h2, | |
| 434 | -.insight-card strong, | |
| 435 | -h1 { | |
| 325 | +.topbar-header-info h1, | |
| 326 | +.drawer-brand strong { | |
| 436 | 327 | font-family: 'Outfit', sans-serif; |
| 437 | 328 | color: var(--text-dark); |
| 438 | 329 | } |
| 439 | 330 | |
| 331 | +.brand-copy strong { | |
| 332 | + font-size: 15px; | |
| 333 | + line-height: 1.35; | |
| 334 | +} | |
| 335 | + | |
| 440 | 336 | .brand-copy span, |
| 441 | 337 | .profile-copy small, |
| 442 | -.eyebrow, | |
| 443 | -.hero-copy p, | |
| 444 | -.insight-card p, | |
| 445 | -.note-list { | |
| 338 | +.date-pill, | |
| 339 | +.bc-parent { | |
| 446 | 340 | color: var(--text-soft); |
| 447 | 341 | } |
| 448 | 342 | |
| 343 | +.brand-copy span { | |
| 344 | + margin-top: 2px; | |
| 345 | + font-size: 11px; | |
| 346 | + line-height: 1.4; | |
| 347 | +} | |
| 348 | + | |
| 449 | 349 | :deep(.ant-menu) { |
| 450 | 350 | min-height: 100%; |
| 451 | 351 | background: transparent; |
| 352 | + border-inline-end: none; | |
| 353 | +} | |
| 354 | + | |
| 355 | +.soft-sider :deep(.ant-menu-item), | |
| 356 | +.soft-sider :deep(.ant-menu-submenu-title), | |
| 357 | +.soft-sider :deep(.ant-menu-title-content), | |
| 358 | +.soft-sider :deep(.ant-menu-item .anticon), | |
| 359 | +.soft-sider :deep(.ant-menu-submenu-title .anticon) { | |
| 360 | + color: var(--text-main); | |
| 452 | 361 | } |
| 453 | 362 | |
| 454 | 363 | .soft-sider.collapsed :deep(.ant-menu) { |
| ... | ... | @@ -463,6 +372,13 @@ h1 { |
| 463 | 372 | padding: 12px; |
| 464 | 373 | } |
| 465 | 374 | |
| 375 | +.sider-foot p { | |
| 376 | + margin: 8px 0 0; | |
| 377 | + color: var(--text-soft); | |
| 378 | + font-size: 12px; | |
| 379 | + line-height: 1.6; | |
| 380 | +} | |
| 381 | + | |
| 466 | 382 | .soft-chip { |
| 467 | 383 | display: inline-flex; |
| 468 | 384 | align-items: center; |
| ... | ... | @@ -490,21 +406,46 @@ h1 { |
| 490 | 406 | gap: 18px; |
| 491 | 407 | } |
| 492 | 408 | |
| 493 | -.soft-topbar h1 { | |
| 494 | - margin: 4px 0 0; | |
| 409 | +.topbar-left { | |
| 410 | + display: flex; | |
| 411 | + align-items: center; | |
| 412 | + gap: 16px; | |
| 413 | +} | |
| 414 | + | |
| 415 | +.topbar-header-info { | |
| 416 | + display: flex; | |
| 417 | + flex-direction: column; | |
| 418 | + gap: 4px; | |
| 419 | +} | |
| 420 | + | |
| 421 | +.topbar-header-info h1 { | |
| 422 | + margin: 0; | |
| 495 | 423 | font-size: 22px; |
| 496 | 424 | line-height: 1.3; |
| 497 | 425 | } |
| 498 | 426 | |
| 499 | -.eyebrow { | |
| 500 | - margin: 0; | |
| 501 | - text-transform: uppercase; | |
| 502 | - letter-spacing: 0.12em; | |
| 503 | - font-size: 10px; | |
| 504 | - font-weight: 700; | |
| 427 | +:deep(.custom-breadcrumb) { | |
| 428 | + display: flex; | |
| 429 | + align-items: center; | |
| 430 | + margin-bottom: 0; | |
| 431 | +} | |
| 432 | + | |
| 433 | +:deep(.custom-breadcrumb li) { | |
| 434 | + display: inline-flex; | |
| 435 | + align-items: center; | |
| 436 | +} | |
| 437 | + | |
| 438 | +.bc-parent, | |
| 439 | +.bc-last { | |
| 440 | + font-size: 13px; | |
| 441 | +} | |
| 442 | + | |
| 443 | +.bc-last { | |
| 444 | + color: var(--text-dark); | |
| 505 | 445 | } |
| 506 | 446 | |
| 507 | -.topbar-actions { | |
| 447 | +.topbar-actions, | |
| 448 | +.header-action-group { | |
| 508 | 449 | display: flex; |
| 509 | 450 | align-items: center; |
| 510 | 451 | gap: 10px; |
| ... | ... | @@ -593,69 +534,6 @@ h1 { |
| 593 | 534 | display: none !important; |
| 594 | 535 | } |
| 595 | 536 | |
| 596 | -.topbar-left { | |
| 597 | - display: flex; | |
| 598 | - align-items: center; | |
| 599 | - gap: 16px; | |
| 600 | -} | |
| 601 | - | |
| 602 | -.topbar-header-info { | |
| 603 | - display: flex; | |
| 604 | - align-items: center; | |
| 605 | - height: 40px; | |
| 606 | -} | |
| 607 | - | |
| 608 | -:deep(.custom-breadcrumb) { | |
| 609 | - display: flex; | |
| 610 | - align-items: center; | |
| 611 | - margin-bottom: 0; | |
| 612 | -} | |
| 613 | - | |
| 614 | -:deep(.custom-breadcrumb li) { | |
| 615 | - display: inline-flex; | |
| 616 | - align-items: center; | |
| 617 | -} | |
| 618 | - | |
| 619 | -.bc-separator { | |
| 620 | - font-size: 11px; | |
| 621 | - opacity: 0.6; | |
| 622 | - color: var(--text-soft); | |
| 623 | - margin: 0 4px; | |
| 624 | -} | |
| 625 | - | |
| 626 | -.bc-parent { | |
| 627 | - color: var(--text-soft) !important; | |
| 628 | - font-size: 14px; | |
| 629 | -} | |
| 630 | - | |
| 631 | -.bc-last { | |
| 632 | - font-size: 18px; | |
| 633 | - font-weight: 600; | |
| 634 | - color: var(--text-main) !important; | |
| 635 | - letter-spacing: 0.5px; | |
| 636 | -} | |
| 637 | - | |
| 638 | -/* Breadcrumb transition */ | |
| 639 | -.breadcrumb-fade-enter-active, | |
| 640 | -.breadcrumb-fade-leave-active { | |
| 641 | - transition: all 0.25s cubic-bezier(0.25, 0.8, 0.25, 1); | |
| 642 | -} | |
| 643 | -.breadcrumb-fade-enter-from { | |
| 644 | - opacity: 0; | |
| 645 | - transform: translateX(10px); | |
| 646 | -} | |
| 647 | -.breadcrumb-fade-leave-to { | |
| 648 | - opacity: 0; | |
| 649 | - transform: translateX(-10px); | |
| 650 | -} | |
| 651 | - | |
| 652 | -.header-action-group { | |
| 653 | - display: flex; | |
| 654 | - align-items: center; | |
| 655 | - gap: 4px; | |
| 656 | - margin-right: 8px; | |
| 657 | -} | |
| 658 | - | |
| 659 | 537 | .mobile-menu-trigger { |
| 660 | 538 | width: 40px; |
| 661 | 539 | height: 40px; |
| ... | ... | @@ -677,15 +555,16 @@ h1 { |
| 677 | 555 | padding: 12px 16px 24px; |
| 678 | 556 | } |
| 679 | 557 | |
| 680 | - | |
| 681 | 558 | .fade-slide-enter-active, |
| 682 | 559 | .fade-slide-leave-active { |
| 683 | 560 | transition: all 0.25s cubic-bezier(0.25, 0.8, 0.25, 1); |
| 684 | 561 | } |
| 562 | + | |
| 685 | 563 | .fade-slide-enter-from { |
| 686 | 564 | opacity: 0; |
| 687 | 565 | transform: translateY(10px); |
| 688 | 566 | } |
| 567 | + | |
| 689 | 568 | .fade-slide-leave-to { |
| 690 | 569 | opacity: 0; |
| 691 | 570 | transform: translateY(-10px); |
| ... | ... | @@ -696,7 +575,7 @@ h1 { |
| 696 | 575 | grid-template-columns: 1fr; |
| 697 | 576 | padding: 12px; |
| 698 | 577 | } |
| 699 | - | |
| 578 | + | |
| 700 | 579 | .hide-sm { |
| 701 | 580 | display: none !important; |
| 702 | 581 | } |
| ... | ... | @@ -714,12 +593,8 @@ h1 { |
| 714 | 593 | width: auto; |
| 715 | 594 | } |
| 716 | 595 | |
| 717 | - .soft-topbar h1 { | |
| 596 | + .topbar-header-info h1 { | |
| 718 | 597 | font-size: 20px; |
| 719 | 598 | } |
| 720 | - | |
| 721 | - .hero-copy h2 { | |
| 722 | - font-size: 28px; | |
| 723 | - } | |
| 724 | 599 | } |
| 725 | 600 | </style> | ... | ... |
src/router/index.ts
| ... | ... | @@ -99,6 +99,30 @@ const router = createRouter({ |
| 99 | 99 | component: () => import('@/views/open/OpenMockDelivery.vue'), |
| 100 | 100 | meta: { title: '模拟推单' }, |
| 101 | 101 | }, |
| 102 | + { | |
| 103 | + path: 'system/menu', | |
| 104 | + name: 'SystemMenu', | |
| 105 | + component: () => import('@/views/system/MenuManage.vue'), | |
| 106 | + meta: { title: '菜单管理' }, | |
| 107 | + }, | |
| 108 | + { | |
| 109 | + path: 'system/role', | |
| 110 | + name: 'SystemRole', | |
| 111 | + component: () => import('@/views/system/RoleList.vue'), | |
| 112 | + meta: { title: '角色管理' }, | |
| 113 | + }, | |
| 114 | + { | |
| 115 | + path: 'system/role-menu', | |
| 116 | + name: 'SystemRoleMenu', | |
| 117 | + component: () => import('@/views/system/RoleMenuAssign.vue'), | |
| 118 | + meta: { title: '角色菜单' }, | |
| 119 | + }, | |
| 120 | + { | |
| 121 | + path: 'admin-user', | |
| 122 | + name: 'AdminUser', | |
| 123 | + component: () => import('@/views/admin/AdminUserList.vue'), | |
| 124 | + meta: { title: '平台账号' }, | |
| 125 | + }, | |
| 102 | 126 | ], |
| 103 | 127 | }, |
| 104 | 128 | { path: '/:pathMatch(.*)*', redirect: '/' }, |
| ... | ... | @@ -110,13 +134,21 @@ router.beforeEach((to) => { |
| 110 | 134 | if (!to.meta.public && !auth.token) { |
| 111 | 135 | return { path: '/login' } |
| 112 | 136 | } |
| 113 | - | |
| 114 | - // Start loading progress | |
| 137 | + | |
| 138 | + if (!to.meta.public && auth.token) { | |
| 139 | + if (!auth.menus.length) { | |
| 140 | + auth.logout() | |
| 141 | + return { path: '/login' } | |
| 142 | + } | |
| 143 | + if (to.path !== '/' && !auth.hasPath(to.path)) { | |
| 144 | + return { path: auth.fallbackHomePath } | |
| 145 | + } | |
| 146 | + } | |
| 147 | + | |
| 115 | 148 | document.body.classList.add('loading') |
| 116 | 149 | }) |
| 117 | 150 | |
| 118 | 151 | router.afterEach(() => { |
| 119 | - // Stop loading progress | |
| 120 | 152 | setTimeout(() => { |
| 121 | 153 | document.body.classList.remove('loading') |
| 122 | 154 | }, 300) | ... | ... |
src/stores/auth.ts
| 1 | +import { computed, ref } from 'vue' | |
| 1 | 2 | import { defineStore } from 'pinia' |
| 2 | -import { ref } from 'vue' | |
| 3 | +import type { AuthUser, LoginResponse, MenuNode } from '@/types/auth' | |
| 4 | +import { findFirstMenuPath, flattenMenuPaths } from '@/utils/menu' | |
| 3 | 5 | |
| 4 | 6 | export const useAuthStore = defineStore('auth', () => { |
| 5 | 7 | const token = ref(localStorage.getItem('token') || '') |
| 6 | - const userInfo = ref<any>(JSON.parse(localStorage.getItem('userInfo') || 'null')) | |
| 8 | + const user = ref<AuthUser | null>(JSON.parse(localStorage.getItem('user') || 'null')) | |
| 9 | + const menus = ref<MenuNode[]>(JSON.parse(localStorage.getItem('menus') || '[]')) | |
| 10 | + const homePath = ref(localStorage.getItem('homePath') || '') | |
| 11 | + | |
| 12 | + const isAdmin = computed(() => user.value?.role === 'admin') | |
| 13 | + const flatMenuPaths = computed(() => flattenMenuPaths(menus.value)) | |
| 14 | + const fallbackHomePath = computed(() => homePath.value || findFirstMenuPath(menus.value) || '/dashboard') | |
| 7 | 15 | |
| 8 | 16 | function setToken(t: string) { |
| 9 | 17 | token.value = t |
| 10 | 18 | localStorage.setItem('token', t) |
| 11 | 19 | } |
| 12 | 20 | |
| 13 | - function setUserInfo(info: any) { | |
| 14 | - userInfo.value = info | |
| 15 | - localStorage.setItem('userInfo', JSON.stringify(info)) | |
| 21 | + function setSession(payload: LoginResponse) { | |
| 22 | + token.value = payload.token | |
| 23 | + user.value = payload.user | |
| 24 | + menus.value = payload.menus || [] | |
| 25 | + homePath.value = payload.homePath || findFirstMenuPath(menus.value) || '/dashboard' | |
| 26 | + localStorage.setItem('token', token.value) | |
| 27 | + localStorage.setItem('user', JSON.stringify(user.value)) | |
| 28 | + localStorage.setItem('menus', JSON.stringify(menus.value)) | |
| 29 | + localStorage.setItem('homePath', homePath.value) | |
| 30 | + } | |
| 31 | + | |
| 32 | + function hasPath(path: string) { | |
| 33 | + return flatMenuPaths.value.includes(path) | |
| 16 | 34 | } |
| 17 | 35 | |
| 18 | 36 | function logout() { |
| 19 | 37 | token.value = '' |
| 20 | - userInfo.value = null | |
| 38 | + user.value = null | |
| 39 | + menus.value = [] | |
| 40 | + homePath.value = '' | |
| 21 | 41 | localStorage.removeItem('token') |
| 22 | - localStorage.removeItem('userInfo') | |
| 42 | + localStorage.removeItem('user') | |
| 43 | + localStorage.removeItem('menus') | |
| 44 | + localStorage.removeItem('homePath') | |
| 23 | 45 | } |
| 24 | 46 | |
| 25 | - return { token, userInfo, setToken, setUserInfo, logout } | |
| 47 | + return { | |
| 48 | + token, | |
| 49 | + user, | |
| 50 | + menus, | |
| 51 | + homePath, | |
| 52 | + isAdmin, | |
| 53 | + flatMenuPaths, | |
| 54 | + fallbackHomePath, | |
| 55 | + setToken, | |
| 56 | + setSession, | |
| 57 | + hasPath, | |
| 58 | + logout, | |
| 59 | + } | |
| 26 | 60 | }) | ... | ... |
src/style.css
| ... | ... | @@ -326,6 +326,45 @@ a { |
| 326 | 326 | font-size: var(--font-size-sm); |
| 327 | 327 | } |
| 328 | 328 | |
| 329 | +.dark .ant-tag, | |
| 330 | +.dark .ant-tag.ant-tag-default, | |
| 331 | +.dark .ant-tag:not([class*='ant-tag-']) { | |
| 332 | + background: rgba(255, 255, 255, 0.1) !important; | |
| 333 | + color: var(--text-main) !important; | |
| 334 | +} | |
| 335 | + | |
| 336 | +.dark .ant-tag-green { | |
| 337 | + background: rgba(82, 196, 26, 0.18) !important; | |
| 338 | + color: #b7eb8f !important; | |
| 339 | +} | |
| 340 | + | |
| 341 | +.dark .ant-tag-red, | |
| 342 | +.dark .ant-tag-volcano, | |
| 343 | +.dark .ant-tag-error { | |
| 344 | + background: rgba(255, 77, 79, 0.18) !important; | |
| 345 | + color: #ffb3b3 !important; | |
| 346 | +} | |
| 347 | + | |
| 348 | +.dark .ant-tag-orange, | |
| 349 | +.dark .ant-tag-gold, | |
| 350 | +.dark .ant-tag-warning { | |
| 351 | + background: rgba(250, 173, 20, 0.18) !important; | |
| 352 | + color: #ffd666 !important; | |
| 353 | +} | |
| 354 | + | |
| 355 | +.dark .ant-tag-blue, | |
| 356 | +.dark .ant-tag-processing, | |
| 357 | +.dark .ant-tag-cyan { | |
| 358 | + background: rgba(22, 119, 255, 0.2) !important; | |
| 359 | + color: #91caff !important; | |
| 360 | +} | |
| 361 | + | |
| 362 | +.dark .ant-tag-purple, | |
| 363 | +.dark .ant-tag-magenta { | |
| 364 | + background: rgba(114, 46, 209, 0.2) !important; | |
| 365 | + color: #d3adf7 !important; | |
| 366 | +} | |
| 367 | + | |
| 329 | 368 | .ant-menu { |
| 330 | 369 | background: transparent !important; |
| 331 | 370 | font-size: var(--font-size-md); |
| ... | ... | @@ -338,7 +377,6 @@ a { |
| 338 | 377 | margin-block: 4px !important; |
| 339 | 378 | width: calc(100% - 16px) !important; |
| 340 | 379 | font-size: var(--font-size-md) !important; |
| 341 | - color: var(--text-main); | |
| 342 | 380 | } |
| 343 | 381 | |
| 344 | 382 | .ant-form-item { |
| ... | ... | @@ -352,10 +390,11 @@ a { |
| 352 | 390 | font-size: var(--font-size-md); |
| 353 | 391 | } |
| 354 | 392 | |
| 355 | -.ant-menu-item-selected, | |
| 356 | -.ant-menu-submenu-selected > .ant-menu-submenu-title { | |
| 357 | - background: var(--panel-tint) !important; | |
| 358 | - color: var(--brand) !important; | |
| 393 | +.ant-menu-light .ant-menu-item-selected, | |
| 394 | +.ant-menu-light > .ant-menu .ant-menu-item-selected, | |
| 395 | +.ant-menu-light .ant-menu-submenu-selected > .ant-menu-submenu-title { | |
| 396 | + background: linear-gradient(135deg, rgba(140, 124, 240, 0.18), rgba(255, 212, 235, 0.3)) !important; | |
| 397 | + color: var(--brand-deep) !important; | |
| 359 | 398 | } |
| 360 | 399 | |
| 361 | 400 | .ant-menu-submenu-popup .ant-menu { |
| ... | ... | @@ -384,10 +423,183 @@ a { |
| 384 | 423 | |
| 385 | 424 | .ant-menu-item:hover, |
| 386 | 425 | .ant-menu-submenu-title:hover { |
| 387 | - color: var(--brand) !important; | |
| 426 | + color: var(--brand-deep) !important; | |
| 388 | 427 | background: var(--line) !important; |
| 389 | 428 | } |
| 390 | 429 | |
| 430 | +.dark .ant-menu-item, | |
| 431 | +.dark .ant-menu-submenu-title, | |
| 432 | +.dark .ant-menu-title-content, | |
| 433 | +.dark .ant-menu-item a, | |
| 434 | +.dark .ant-menu-item .anticon, | |
| 435 | +.dark .ant-menu-submenu-title .anticon, | |
| 436 | +.dark .ant-dropdown-menu-item, | |
| 437 | +.dark .ant-dropdown-menu-title-content, | |
| 438 | +.dark .ant-breadcrumb, | |
| 439 | +.dark .ant-breadcrumb a, | |
| 440 | +.dark .ant-breadcrumb-link, | |
| 441 | +.dark .ant-breadcrumb-separator, | |
| 442 | +.dark .ant-empty-description, | |
| 443 | +.dark .ant-form-item-label > label, | |
| 444 | +.dark .ant-form-item-explain, | |
| 445 | +.dark .ant-modal .ant-modal-close, | |
| 446 | +.dark .ant-modal .ant-modal-close-x, | |
| 447 | +.dark .ant-modal-confirm-body, | |
| 448 | +.dark .ant-modal-confirm-content, | |
| 449 | +.dark .ant-modal-confirm-title, | |
| 450 | +.dark .ant-popconfirm-message, | |
| 451 | +.dark .ant-popconfirm-description, | |
| 452 | +.dark .ant-tree, | |
| 453 | +.dark .ant-tree .ant-tree-node-content-wrapper, | |
| 454 | +.dark .ant-tree .ant-tree-iconEle, | |
| 455 | +.dark .ant-tree .ant-tree-switcher, | |
| 456 | +.dark .ant-select-arrow, | |
| 457 | +.dark .ant-select-clear, | |
| 458 | +.dark .ant-picker-suffix, | |
| 459 | +.dark .ant-input-password-icon, | |
| 460 | +.dark .ant-pagination, | |
| 461 | +.dark .ant-pagination .ant-pagination-item-link, | |
| 462 | +.dark .ant-radio-wrapper, | |
| 463 | +.dark .ant-radio-button-wrapper, | |
| 464 | +.dark .ant-switch, | |
| 465 | +.dark .ant-descriptions, | |
| 466 | +.dark .ant-descriptions-item-content, | |
| 467 | +.dark .ant-descriptions-item-label, | |
| 468 | +.dark .ant-tag, | |
| 469 | +.dark .ant-btn .anticon, | |
| 470 | +.dark .ant-btn-text, | |
| 471 | +.dark .ant-btn-link, | |
| 472 | +.dark .ant-btn-default { | |
| 473 | + color: var(--text-main) !important; | |
| 474 | +} | |
| 475 | + | |
| 476 | +.dark .ant-menu-submenu-arrow, | |
| 477 | +.dark .ant-menu-submenu-arrow::before, | |
| 478 | +.dark .ant-menu-submenu-arrow::after, | |
| 479 | +.dark .ant-breadcrumb-separator, | |
| 480 | +.dark .ant-select-arrow, | |
| 481 | +.dark .ant-picker-suffix, | |
| 482 | +.dark .ant-input-password-icon, | |
| 483 | +.dark .ant-tree .ant-tree-switcher, | |
| 484 | +.dark .ant-modal .ant-modal-close-x, | |
| 485 | +.dark .ant-btn .anticon { | |
| 486 | + color: var(--text-soft) !important; | |
| 487 | + border-color: var(--text-soft) !important; | |
| 488 | +} | |
| 489 | + | |
| 490 | +.dark .ant-menu-light .ant-menu-item-selected, | |
| 491 | +.dark .ant-menu-light > .ant-menu .ant-menu-item-selected, | |
| 492 | +.dark .ant-menu-light .ant-menu-submenu-selected > .ant-menu-submenu-title, | |
| 493 | +.dark .ant-tree .ant-tree-node-content-wrapper:hover, | |
| 494 | +.dark .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected, | |
| 495 | +.dark .ant-pagination .ant-pagination-item-active { | |
| 496 | + background: rgba(140, 124, 240, 0.2) !important; | |
| 497 | + color: var(--brand-soft) !important; | |
| 498 | +} | |
| 499 | + | |
| 500 | +.dark .ant-menu-light .ant-menu-item-selected .anticon, | |
| 501 | +.dark .ant-menu-light .ant-menu-item-selected .ant-menu-title-content, | |
| 502 | +.dark .ant-menu-light > .ant-menu .ant-menu-item-selected .anticon, | |
| 503 | +.dark .ant-menu-light > .ant-menu .ant-menu-item-selected .ant-menu-title-content, | |
| 504 | +.dark .ant-menu-light .ant-menu-submenu-selected > .ant-menu-submenu-title .anticon, | |
| 505 | +.dark .ant-menu-light .ant-menu-submenu-selected > .ant-menu-submenu-title .ant-menu-title-content, | |
| 506 | +.dark .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected, | |
| 507 | +.dark .ant-pagination .ant-pagination-item-active a, | |
| 508 | +.dark .ant-pagination .ant-pagination-item-active .anticon { | |
| 509 | + color: var(--brand-soft) !important; | |
| 510 | +} | |
| 511 | + | |
| 512 | +.dark .ant-popover .ant-popover-inner, | |
| 513 | +.dark .ant-popconfirm .ant-popover-inner, | |
| 514 | +.dark .ant-dropdown .ant-dropdown-menu, | |
| 515 | +.dark .ant-select-dropdown, | |
| 516 | +.dark .ant-picker-dropdown, | |
| 517 | +.dark .ant-tooltip .ant-tooltip-inner, | |
| 518 | +.dark .ant-drawer .ant-drawer-content, | |
| 519 | +.dark .ant-drawer .ant-drawer-header, | |
| 520 | +.dark .ant-drawer .ant-drawer-body, | |
| 521 | +.dark .ant-table-wrapper .ant-table, | |
| 522 | +.dark .ant-table-wrapper .ant-table-container, | |
| 523 | +.dark .ant-table-wrapper .ant-table-thead > tr > th, | |
| 524 | +.dark .ant-table-wrapper .ant-table-tbody > tr > td { | |
| 525 | + background: var(--panel-strong) !important; | |
| 526 | + color: var(--text-main) !important; | |
| 527 | + border-color: var(--line) !important; | |
| 528 | +} | |
| 529 | + | |
| 530 | +.dark .ant-select-dropdown .ant-select-item, | |
| 531 | +.dark .ant-select-dropdown .ant-select-item-option-content, | |
| 532 | +.dark .ant-select-dropdown .ant-empty-description { | |
| 533 | + color: var(--text-main) !important; | |
| 534 | +} | |
| 535 | + | |
| 536 | +.dark .ant-select-dropdown .ant-select-item-option-active, | |
| 537 | +.dark .ant-select-dropdown .ant-select-item-option-selected, | |
| 538 | +.dark .ant-dropdown-menu-item:hover, | |
| 539 | +.dark .ant-picker-cell-in-view.ant-picker-cell-selected .ant-picker-cell-inner { | |
| 540 | + background: rgba(140, 124, 240, 0.2) !important; | |
| 541 | + color: var(--text-main) !important; | |
| 542 | +} | |
| 543 | + | |
| 544 | +.dark .ant-select-dropdown .ant-select-item-option-selected .ant-select-item-option-content, | |
| 545 | +.dark .ant-select-dropdown .ant-select-item-option-active .ant-select-item-option-content { | |
| 546 | + color: var(--text-main) !important; | |
| 547 | +} | |
| 548 | + | |
| 549 | +.dark .ant-select .ant-select-selection-item, | |
| 550 | +.dark .ant-select .ant-select-selection-placeholder, | |
| 551 | +.dark .ant-tree-select .ant-select-selection-item, | |
| 552 | +.dark .ant-tree-select .ant-select-selection-placeholder, | |
| 553 | +.dark .ant-input::placeholder, | |
| 554 | +.dark .ant-input-password input::placeholder, | |
| 555 | +.dark .ant-input-number input::placeholder, | |
| 556 | +.dark .ant-picker input::placeholder, | |
| 557 | +.dark .ant-form-item-extra, | |
| 558 | +.dark .ant-form-item-explain, | |
| 559 | +.dark .ant-form-item-explain-error, | |
| 560 | +.dark .ant-form-item-explain-warning, | |
| 561 | +.dark .ant-empty-footer, | |
| 562 | +.dark .ant-empty-normal, | |
| 563 | +.dark .ant-empty-image, | |
| 564 | +.dark .ant-empty-image svg, | |
| 565 | +.dark .ant-pagination .ant-pagination-item, | |
| 566 | +.dark .ant-pagination .ant-pagination-prev .ant-pagination-item-link, | |
| 567 | +.dark .ant-pagination .ant-pagination-next .ant-pagination-item-link, | |
| 568 | +.dark .ant-radio-button-wrapper:not(.ant-radio-button-wrapper-disabled), | |
| 569 | +.dark .ant-radio-group-solid .ant-radio-button-wrapper:not(.ant-radio-button-wrapper-disabled), | |
| 570 | +.dark .ant-table-wrapper .ant-table-cell, | |
| 571 | +.dark .ant-table-wrapper .ant-table-column-sorter, | |
| 572 | +.dark .ant-table-wrapper .ant-table-filter-trigger, | |
| 573 | +.dark .ant-table-wrapper .ant-empty-description, | |
| 574 | +.dark .ant-modal .ant-modal-header, | |
| 575 | +.dark .ant-modal .ant-modal-footer, | |
| 576 | +.dark .ant-modal .ant-modal-title, | |
| 577 | +.dark .ant-modal .ant-modal-close, | |
| 578 | +.dark .ant-popover .ant-popover-title, | |
| 579 | +.dark .ant-popconfirm-title, | |
| 580 | +.dark .ant-drawer .ant-drawer-title, | |
| 581 | +.dark .ant-drawer .ant-drawer-close, | |
| 582 | +.dark .ant-drawer .ant-drawer-header, | |
| 583 | +.dark .ant-drawer .ant-drawer-body, | |
| 584 | +.dark .ant-drawer .ant-drawer-footer, | |
| 585 | +.dark .ant-drawer .ant-drawer-close .anticon, | |
| 586 | +.dark .ant-btn-background-ghost, | |
| 587 | +.dark .ant-btn-default:disabled, | |
| 588 | +.dark .ant-btn[disabled], | |
| 589 | +.dark .ant-input[disabled], | |
| 590 | +.dark .ant-input-affix-wrapper-disabled, | |
| 591 | +.dark .ant-input-number-disabled, | |
| 592 | +.dark .ant-select-disabled .ant-select-selector, | |
| 593 | +.dark .ant-picker.ant-picker-disabled { | |
| 594 | + color: var(--text-main) !important; | |
| 595 | + border-color: var(--line) !important; | |
| 596 | +} | |
| 597 | + | |
| 598 | +.dark .ant-popconfirm-buttons .ant-btn-default, | |
| 599 | +.dark .ant-modal .ant-btn-default { | |
| 600 | + color: var(--text-main) !important; | |
| 601 | +} | |
| 602 | + | |
| 391 | 603 | .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) { |
| 392 | 604 | color: var(--brand); |
| 393 | 605 | background: var(--panel-strong); |
| ... | ... | @@ -548,6 +760,50 @@ a { |
| 548 | 760 | padding-top: 16px; |
| 549 | 761 | } |
| 550 | 762 | |
| 763 | +.dark .ant-input::placeholder, | |
| 764 | +.dark .ant-input-password input::placeholder, | |
| 765 | +.dark .ant-input-number input::placeholder, | |
| 766 | +.dark .ant-picker input::placeholder, | |
| 767 | +.dark .ant-select .ant-select-selection-placeholder, | |
| 768 | +.dark .ant-tree-select .ant-select-selection-placeholder, | |
| 769 | +.dark .ant-form-item-extra, | |
| 770 | +.dark .ant-form-item-explain, | |
| 771 | +.dark .ant-form-item-explain-error, | |
| 772 | +.dark .ant-form-item-explain-warning { | |
| 773 | + color: var(--text-soft) !important; | |
| 774 | +} | |
| 775 | + | |
| 776 | +.dark .ant-btn-default:disabled, | |
| 777 | +.dark .ant-btn[disabled], | |
| 778 | +.dark .ant-input[disabled], | |
| 779 | +.dark .ant-input-affix-wrapper-disabled, | |
| 780 | +.dark .ant-input-number-disabled, | |
| 781 | +.dark .ant-select-disabled .ant-select-selector, | |
| 782 | +.dark .ant-picker.ant-picker-disabled { | |
| 783 | + background: rgba(255, 255, 255, 0.04) !important; | |
| 784 | +} | |
| 785 | + | |
| 786 | +.dark .ant-table-wrapper .ant-table-thead > tr > th, | |
| 787 | +.dark .ant-table-wrapper .ant-table-tbody > tr > td { | |
| 788 | + background: transparent !important; | |
| 789 | +} | |
| 790 | + | |
| 791 | +.dark .ant-table-wrapper .ant-table-tbody > tr:hover > td { | |
| 792 | + background: rgba(255, 255, 255, 0.04) !important; | |
| 793 | +} | |
| 794 | + | |
| 795 | +.dark .ant-pagination .ant-pagination-item, | |
| 796 | +.dark .ant-pagination .ant-pagination-prev .ant-pagination-item-link, | |
| 797 | +.dark .ant-pagination .ant-pagination-next .ant-pagination-item-link, | |
| 798 | +.dark .ant-radio-button-wrapper:not(.ant-radio-button-wrapper-disabled), | |
| 799 | +.dark .ant-radio-group-solid .ant-radio-button-wrapper:not(.ant-radio-button-wrapper-disabled) { | |
| 800 | + background: var(--panel) !important; | |
| 801 | +} | |
| 802 | + | |
| 803 | +.dark .ant-drawer .ant-drawer-mask { | |
| 804 | + background: rgba(8, 8, 16, 0.55) !important; | |
| 805 | +} | |
| 806 | + | |
| 551 | 807 | @media (max-width: 1200px) { |
| 552 | 808 | .soft-page-shell { |
| 553 | 809 | padding: 18px; | ... | ... |
src/types/auth.ts
0 → 100644
| 1 | +export interface MenuNode { | |
| 2 | + id: number | |
| 3 | + code: string | |
| 4 | + name: string | |
| 5 | + type: 'DIR' | 'MENU' | |
| 6 | + path?: string | |
| 7 | + icon?: string | |
| 8 | + children: MenuNode[] | |
| 9 | +} | |
| 10 | + | |
| 11 | +export interface AuthUser { | |
| 12 | + id: number | |
| 13 | + userLogin: string | |
| 14 | + userNickname: string | |
| 15 | + role: 'admin' | 'substation' | |
| 16 | + roleType: 'admin' | 'substation' | |
| 17 | + roleCode: string | |
| 18 | + cityId?: number | |
| 19 | + cityName?: string | |
| 20 | +} | |
| 21 | + | |
| 22 | +export interface LoginResponse { | |
| 23 | + token: string | |
| 24 | + user: AuthUser | |
| 25 | + menus: MenuNode[] | |
| 26 | + homePath?: string | |
| 27 | +} | ... | ... |
src/utils/menu.ts
0 → 100644
| 1 | +import type { MenuNode } from '@/types/auth' | |
| 2 | + | |
| 3 | +export function flattenMenuPaths(menus: MenuNode[]): string[] { | |
| 4 | + const paths: string[] = [] | |
| 5 | + const walk = (items: MenuNode[]) => { | |
| 6 | + for (const item of items) { | |
| 7 | + if (item.path) paths.push(item.path) | |
| 8 | + if (item.children?.length) walk(item.children) | |
| 9 | + } | |
| 10 | + } | |
| 11 | + walk(menus) | |
| 12 | + return paths | |
| 13 | +} | |
| 14 | + | |
| 15 | +export function flattenMenus(menus: MenuNode[]): MenuNode[] { | |
| 16 | + const result: MenuNode[] = [] | |
| 17 | + const walk = (items: MenuNode[]) => { | |
| 18 | + for (const item of items) { | |
| 19 | + result.push(item) | |
| 20 | + if (item.children?.length) walk(item.children) | |
| 21 | + } | |
| 22 | + } | |
| 23 | + walk(menus) | |
| 24 | + return result | |
| 25 | +} | |
| 26 | + | |
| 27 | +export function findMenuByPath(menus: MenuNode[], path: string): MenuNode | null { | |
| 28 | + for (const item of menus) { | |
| 29 | + if (item.path === path) return item | |
| 30 | + if (item.children?.length) { | |
| 31 | + const found = findMenuByPath(item.children, path) | |
| 32 | + if (found) return found | |
| 33 | + } | |
| 34 | + } | |
| 35 | + return null | |
| 36 | +} | |
| 37 | + | |
| 38 | +export function findMenuTrailByPath(menus: MenuNode[], path: string): MenuNode[] { | |
| 39 | + for (const item of menus) { | |
| 40 | + if (item.path === path) return [item] | |
| 41 | + if (item.children?.length) { | |
| 42 | + const childTrail = findMenuTrailByPath(item.children, path) | |
| 43 | + if (childTrail.length) return [item, ...childTrail] | |
| 44 | + } | |
| 45 | + } | |
| 46 | + return [] | |
| 47 | +} | |
| 48 | + | |
| 49 | +export function findFirstMenuPath(menus: MenuNode[]): string { | |
| 50 | + for (const item of menus) { | |
| 51 | + if (item.path) return item.path | |
| 52 | + if (item.children?.length) { | |
| 53 | + const childPath = findFirstMenuPath(item.children) | |
| 54 | + if (childPath) return childPath | |
| 55 | + } | |
| 56 | + } | |
| 57 | + return '/dashboard' | |
| 58 | +} | |
| 59 | + | |
| 60 | +export function filterPinnedMenuEntries( | |
| 61 | + menus: MenuNode[], | |
| 62 | + pinned: Array<{ path: string; title: string; desc: string }> | |
| 63 | +) { | |
| 64 | + const available = new Set(flattenMenuPaths(menus)) | |
| 65 | + return pinned.filter(item => available.has(item.path)) | |
| 66 | +} | ... | ... |
src/views/Login.vue
| ... | ... | @@ -59,10 +59,10 @@ |
| 59 | 59 | <script setup lang="ts"> |
| 60 | 60 | import { reactive, ref } from 'vue' |
| 61 | 61 | import { useRouter } from 'vue-router' |
| 62 | -import { message } from 'ant-design-vue' | |
| 63 | 62 | import { UserOutlined, LockOutlined } from '@ant-design/icons-vue' |
| 64 | 63 | import { useAuthStore } from '@/stores/auth' |
| 65 | 64 | import request from '@/utils/request' |
| 65 | +import type { LoginResponse } from '@/types/auth' | |
| 66 | 66 | import heroImage from '@/assets/hero.png' |
| 67 | 67 | |
| 68 | 68 | const router = useRouter() |
| ... | ... | @@ -74,9 +74,8 @@ async function onSubmit() { |
| 74 | 74 | loading.value = true |
| 75 | 75 | try { |
| 76 | 76 | const res: any = await request.post('/api/admin/auth/login', form) |
| 77 | - auth.setToken(res.data.token) | |
| 78 | - auth.setUserInfo(res.data) | |
| 79 | - router.push('/') | |
| 77 | + auth.setSession(res.data as LoginResponse) | |
| 78 | + router.push(auth.fallbackHomePath) | |
| 80 | 79 | } catch { |
| 81 | 80 | // 错误已由 request 拦截器处理 |
| 82 | 81 | } finally { | ... | ... |
src/views/admin/AdminUserList.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div> | |
| 3 | + <a-card title="平台账号管理" :bordered="false" class="list-table-card"> | |
| 4 | + <div class="list-toolbar"> | |
| 5 | + <div class="list-toolbar-left"> | |
| 6 | + <a-input-search v-model:value="keyword" placeholder="搜索账号/昵称" @search="loadList" class="list-search" /> | |
| 7 | + </div> | |
| 8 | + <div class="list-toolbar-right"> | |
| 9 | + <a-button type="primary" @click="openAdd">新增平台账号</a-button> | |
| 10 | + </div> | |
| 11 | + </div> | |
| 12 | + <a-table :dataSource="list" :columns="columns" :loading="loading" rowKey="id" :pagination="false"> | |
| 13 | + <template #bodyCell="{ column, record }"> | |
| 14 | + <template v-if="column.key === 'roleId'"> | |
| 15 | + {{ getRoleName(record.roleId) }} | |
| 16 | + </template> | |
| 17 | + <template v-if="column.key === 'status'"> | |
| 18 | + <a-tag :color="record.userStatus === 1 ? 'green' : 'red'"> | |
| 19 | + {{ record.userStatus === 1 ? '正常' : '禁用' }} | |
| 20 | + </a-tag> | |
| 21 | + </template> | |
| 22 | + <template v-if="column.key === 'action'"> | |
| 23 | + <a-space> | |
| 24 | + <a @click="openEdit(record)">编辑</a> | |
| 25 | + <a @click="openChangePwd(record)">改密码</a> | |
| 26 | + <a-popconfirm | |
| 27 | + :title="record.userStatus === 1 ? '确认禁用?' : '确认启用?'" | |
| 28 | + @confirm="toggleBan(record)" | |
| 29 | + > | |
| 30 | + <a :style="record.userStatus === 1 ? 'color:red' : ''"> | |
| 31 | + {{ record.userStatus === 1 ? '禁用' : '启用' }} | |
| 32 | + </a> | |
| 33 | + </a-popconfirm> | |
| 34 | + <a-popconfirm title="确认删除?" @confirm="handleDel(record.id)"> | |
| 35 | + <a style="color:red">删除</a> | |
| 36 | + </a-popconfirm> | |
| 37 | + </a-space> | |
| 38 | + </template> | |
| 39 | + </template> | |
| 40 | + </a-table> | |
| 41 | + </a-card> | |
| 42 | + | |
| 43 | + <a-modal v-model:open="modalVisible" :title="editingId ? '编辑平台账号' : '新增平台账号'" @ok="handleSave" :confirmLoading="saving"> | |
| 44 | + <div class="soft-page-stack"> | |
| 45 | + <div class="soft-note-card"> | |
| 46 | + <strong>平台账号说明</strong> | |
| 47 | + <p>平台账号用于后台平台功能管理,不绑定租户,通过菜单角色控制显示哪些平台菜单。</p> | |
| 48 | + </div> | |
| 49 | + <a-form :model="form" layout="vertical"> | |
| 50 | + <a-form-item label="菜单角色"> | |
| 51 | + <a-select v-model:value="form.roleId" placeholder="选择角色"> | |
| 52 | + <a-select-option v-for="item in roleOptions" :key="item.id" :value="item.id">{{ item.name }}</a-select-option> | |
| 53 | + </a-select> | |
| 54 | + </a-form-item> | |
| 55 | + <a-form-item label="登录账号"> | |
| 56 | + <a-input v-model:value="form.userLogin" :disabled="!!editingId" /> | |
| 57 | + </a-form-item> | |
| 58 | + <a-form-item label="昵称"> | |
| 59 | + <a-input v-model:value="form.userNickname" /> | |
| 60 | + </a-form-item> | |
| 61 | + <a-form-item :label="editingId ? '新密码(不填不修改)' : '密码'"> | |
| 62 | + <a-input-password v-model:value="form.userPass" /> | |
| 63 | + </a-form-item> | |
| 64 | + </a-form> | |
| 65 | + </div> | |
| 66 | + </a-modal> | |
| 67 | + | |
| 68 | + <a-modal v-model:open="pwdVisible" title="修改密码" @ok="handleChangePwd" :confirmLoading="pwdSaving"> | |
| 69 | + <div class="soft-page-stack"> | |
| 70 | + <div class="soft-note-card"> | |
| 71 | + <strong>密码修改说明</strong> | |
| 72 | + <p>这里修改的是当前选中平台账号的登录密码,提交时会按目标平台账号执行。</p> | |
| 73 | + </div> | |
| 74 | + <a-form layout="vertical"> | |
| 75 | + <a-form-item label="原密码"> | |
| 76 | + <a-input-password v-model:value="pwdForm.oldPassword" /> | |
| 77 | + </a-form-item> | |
| 78 | + <a-form-item label="新密码"> | |
| 79 | + <a-input-password v-model:value="pwdForm.newPassword" /> | |
| 80 | + </a-form-item> | |
| 81 | + </a-form> | |
| 82 | + </div> | |
| 83 | + </a-modal> | |
| 84 | + </div> | |
| 85 | +</template> | |
| 86 | + | |
| 87 | +<script setup lang="ts"> | |
| 88 | +import { reactive, ref, onMounted } from 'vue' | |
| 89 | +import { message } from 'ant-design-vue' | |
| 90 | +import { adminUserApi, systemRoleApi } from '@/api' | |
| 91 | + | |
| 92 | +const loading = ref(false) | |
| 93 | +const saving = ref(false) | |
| 94 | +const list = ref<any[]>([]) | |
| 95 | +const roleOptions = ref<any[]>([]) | |
| 96 | +const keyword = ref('') | |
| 97 | +const modalVisible = ref(false) | |
| 98 | +const editingId = ref<number | null>(null) | |
| 99 | +const form = reactive({ roleId: undefined, userLogin: '', userNickname: '', userPass: '' }) | |
| 100 | + | |
| 101 | +const columns = [ | |
| 102 | + { title: 'ID', dataIndex: 'id', width: 80 }, | |
| 103 | + { title: '账号', dataIndex: 'userLogin' }, | |
| 104 | + { title: '昵称', dataIndex: 'userNickname' }, | |
| 105 | + { title: '角色', key: 'roleId' }, | |
| 106 | + { title: '状态', key: 'status' }, | |
| 107 | + { title: '操作', key: 'action' }, | |
| 108 | +] | |
| 109 | + | |
| 110 | +function getRoleName(roleId?: number) { | |
| 111 | + const role = roleOptions.value.find(item => item.id === roleId) | |
| 112 | + return role?.name || (roleId ? `角色#${roleId}` : '-') | |
| 113 | +} | |
| 114 | + | |
| 115 | +async function loadList() { | |
| 116 | + loading.value = true | |
| 117 | + try { | |
| 118 | + const res: any = await adminUserApi.list(keyword.value) | |
| 119 | + list.value = res.data | |
| 120 | + } finally { loading.value = false } | |
| 121 | +} | |
| 122 | + | |
| 123 | +async function loadRoles() { | |
| 124 | + const res: any = await systemRoleApi.list() | |
| 125 | + roleOptions.value = Array.isArray(res?.data) ? res.data.filter((item: any) => item.roleScope === 'PLATFORM') : [] | |
| 126 | +} | |
| 127 | + | |
| 128 | +function openAdd() { | |
| 129 | + editingId.value = null | |
| 130 | + Object.assign(form, { roleId: roleOptions.value[0]?.id, userLogin: '', userNickname: '', userPass: '' }) | |
| 131 | + modalVisible.value = true | |
| 132 | +} | |
| 133 | + | |
| 134 | +function openEdit(record: any) { | |
| 135 | + editingId.value = record.id | |
| 136 | + Object.assign(form, { roleId: record.roleId, userLogin: record.userLogin, userNickname: record.userNickname, userPass: '' }) | |
| 137 | + modalVisible.value = true | |
| 138 | +} | |
| 139 | + | |
| 140 | +async function handleSave() { | |
| 141 | + if (!form.roleId) { | |
| 142 | + message.error('请选择菜单角色') | |
| 143 | + return | |
| 144 | + } | |
| 145 | + saving.value = true | |
| 146 | + try { | |
| 147 | + if (editingId.value) { | |
| 148 | + await adminUserApi.edit({ ...form, id: editingId.value }) | |
| 149 | + } else { | |
| 150 | + await adminUserApi.add(form) | |
| 151 | + } | |
| 152 | + message.success('保存成功') | |
| 153 | + modalVisible.value = false | |
| 154 | + loadList() | |
| 155 | + } finally { saving.value = false } | |
| 156 | +} | |
| 157 | + | |
| 158 | +async function toggleBan(record: any) { | |
| 159 | + if (record.userStatus === 1) { | |
| 160 | + await adminUserApi.ban(record.id) | |
| 161 | + } else { | |
| 162 | + await adminUserApi.cancelBan(record.id) | |
| 163 | + } | |
| 164 | + message.success('操作成功') | |
| 165 | + loadList() | |
| 166 | +} | |
| 167 | + | |
| 168 | +async function handleDel(id: number) { | |
| 169 | + await adminUserApi.del(id) | |
| 170 | + message.success('删除成功') | |
| 171 | + loadList() | |
| 172 | +} | |
| 173 | + | |
| 174 | +const pwdVisible = ref(false) | |
| 175 | +const pwdSaving = ref(false) | |
| 176 | +const pwdForm = reactive({ oldPassword: '', newPassword: '' }) | |
| 177 | +const pwdTargetId = ref(0) | |
| 178 | + | |
| 179 | +function openChangePwd(record: any) { | |
| 180 | + pwdTargetId.value = record.id | |
| 181 | + Object.assign(pwdForm, { oldPassword: '', newPassword: '' }) | |
| 182 | + pwdVisible.value = true | |
| 183 | +} | |
| 184 | + | |
| 185 | +async function handleChangePwd() { | |
| 186 | + if (!pwdForm.oldPassword || !pwdForm.newPassword) { | |
| 187 | + message.error('请填写完整密码') | |
| 188 | + return | |
| 189 | + } | |
| 190 | + pwdSaving.value = true | |
| 191 | + try { | |
| 192 | + await adminUserApi.changePassword({ id: pwdTargetId.value, ...pwdForm }) | |
| 193 | + message.success('密码修改成功') | |
| 194 | + pwdVisible.value = false | |
| 195 | + } finally { pwdSaving.value = false } | |
| 196 | +} | |
| 197 | + | |
| 198 | +onMounted(() => { loadList(); loadRoles() }) | |
| 199 | +</script> | ... | ... |
src/views/config/FeePlanList.vue
| ... | ... | @@ -451,11 +451,11 @@ type PieceRuleForm = { |
| 451 | 451 | |
| 452 | 452 | const route = useRoute() |
| 453 | 453 | const auth = useAuthStore() |
| 454 | -const isAdmin = computed(() => auth.userInfo?.role === 'admin') | |
| 455 | -const managedCityId = computed<number | undefined>(() => auth.userInfo?.cityId) | |
| 454 | +const isAdmin = computed(() => auth.user?.role === 'admin') | |
| 455 | +const managedCityId = computed<number | undefined>(() => auth.user?.cityId) | |
| 456 | 456 | const cityList = ref<any[]>([]) |
| 457 | 457 | const selectedCityId = ref<number | undefined>() |
| 458 | -const currentCityName = computed(() => cityList.value.find(item => item.id === selectedCityId.value)?.name || auth.userInfo?.cityName || '') | |
| 458 | +const currentCityName = computed(() => cityList.value.find(item => item.id === selectedCityId.value)?.name || auth.user?.cityName || '') | |
| 459 | 459 | |
| 460 | 460 | const config = ref<any>(null) |
| 461 | 461 | const planList = ref<any[]>([]) |
| ... | ... | @@ -491,7 +491,7 @@ async function loadCities() { |
| 491 | 491 | } |
| 492 | 492 | } else { |
| 493 | 493 | selectedCityId.value = managedCityId.value |
| 494 | - cityList.value = selectedCityId.value ? [{ id: selectedCityId.value, name: auth.userInfo?.cityName || `租户#${selectedCityId.value}` }] : [] | |
| 494 | + cityList.value = selectedCityId.value ? [{ id: selectedCityId.value, name: auth.user?.cityName || `租户#${selectedCityId.value}` }] : [] | |
| 495 | 495 | } |
| 496 | 496 | |
| 497 | 497 | if (selectedCityId.value) { | ... | ... |
src/views/dashboard/DashboardHome.vue
| ... | ... | @@ -57,17 +57,17 @@ |
| 57 | 57 | </template> |
| 58 | 58 | |
| 59 | 59 | <script setup lang="ts"> |
| 60 | +import { computed } from 'vue' | |
| 60 | 61 | import { useRouter } from 'vue-router' |
| 61 | 62 | import heroImage from '@/assets/hero.png' |
| 63 | +import { useAuthStore } from '@/stores/auth' | |
| 64 | +import { pinnedQuickLinks } from '@/config/menu' | |
| 65 | +import { filterPinnedMenuEntries } from '@/utils/menu' | |
| 62 | 66 | |
| 63 | 67 | const router = useRouter() |
| 68 | +const auth = useAuthStore() | |
| 64 | 69 | |
| 65 | -const quickLinks = [ | |
| 66 | - { path: '/city', title: '租户管理', desc: '配置配送费、骑手等级与租户信息' }, | |
| 67 | - { path: '/rider', title: '骑手管理', desc: '查看骑手、设置等级和账号状态' }, | |
| 68 | - { path: '/order', title: '订单列表', desc: '集中处理配送中的订单流转' }, | |
| 69 | - { path: '/substation', title: '分站管理', desc: '维护租户站点账号和权限' }, | |
| 70 | -] | |
| 70 | +const quickLinks = computed(() => filterPinnedMenuEntries(auth.menus, pinnedQuickLinks)) | |
| 71 | 71 | |
| 72 | 72 | function go(path: string) { |
| 73 | 73 | router.push(path) | ... | ... |
src/views/dispatch/DispatchRuleList.vue
| ... | ... | @@ -241,11 +241,11 @@ import { cityApi, dispatchRuleApi } from '@/api' |
| 241 | 241 | import { useAuthStore } from '@/stores/auth' |
| 242 | 242 | |
| 243 | 243 | const auth = useAuthStore() |
| 244 | -const isAdmin = computed(() => auth.userInfo?.role === 'admin') | |
| 245 | -const managedCityId = computed<number | undefined>(() => auth.userInfo?.cityId) | |
| 244 | +const isAdmin = computed(() => auth.user?.role === 'admin') | |
| 245 | +const managedCityId = computed<number | undefined>(() => auth.user?.cityId) | |
| 246 | 246 | const cityList = ref<any[]>([]) |
| 247 | 247 | const selectedCityId = ref<number | undefined>() |
| 248 | -const currentCityName = computed(() => cityList.value.find(item => item.id === selectedCityId.value)?.name || auth.userInfo?.cityName || '') | |
| 248 | +const currentCityName = computed(() => cityList.value.find(item => item.id === selectedCityId.value)?.name || auth.user?.cityName || '') | |
| 249 | 249 | const templateList = ref<any[]>([]) |
| 250 | 250 | const selectedTemplateId = ref<number | null>(null) |
| 251 | 251 | const loadingTemplates = ref(false) |
| ... | ... | @@ -300,7 +300,7 @@ async function loadCities() { |
| 300 | 300 | } |
| 301 | 301 | } else { |
| 302 | 302 | selectedCityId.value = managedCityId.value |
| 303 | - cityList.value = selectedCityId.value ? [{ id: selectedCityId.value, name: auth.userInfo?.cityName || `租户#${selectedCityId.value}` }] : [] | |
| 303 | + cityList.value = selectedCityId.value ? [{ id: selectedCityId.value, name: auth.user?.cityName || `租户#${selectedCityId.value}` }] : [] | |
| 304 | 304 | } |
| 305 | 305 | |
| 306 | 306 | if (selectedCityId.value) { | ... | ... |
src/views/rider/RiderList.vue
| ... | ... | @@ -146,7 +146,7 @@ const levelSaving = ref(false) |
| 146 | 146 | const levelTargetId = ref<number>(0) |
| 147 | 147 | const levelTargetName = ref('') |
| 148 | 148 | const selectedLevelId = ref<number>(0) |
| 149 | -const isAdmin = computed(() => auth.userInfo?.role === 'admin') | |
| 149 | +const isAdmin = computed(() => auth.user?.role === 'admin') | |
| 150 | 150 | |
| 151 | 151 | const statusMap: Record<number, string> = { 0: '已拒绝', 1: '已通过', 2: '待审核' } |
| 152 | 152 | ... | ... |
src/views/substation/SubstationList.vue
| ... | ... | @@ -14,6 +14,9 @@ |
| 14 | 14 | <template v-if="column.key === 'cityId'"> |
| 15 | 15 | {{ getCityName(record.cityId) }} |
| 16 | 16 | </template> |
| 17 | + <template v-if="column.key === 'roleId'"> | |
| 18 | + {{ getRoleName(record.roleId) }} | |
| 19 | + </template> | |
| 17 | 20 | <template v-if="column.key === 'status'"> |
| 18 | 21 | <a-tag :color="record.userStatus === 1 ? 'green' : 'red'"> |
| 19 | 22 | {{ record.userStatus === 1 ? '正常' : '禁用' }} |
| ... | ... | @@ -51,6 +54,11 @@ |
| 51 | 54 | <a-select-option v-for="c in cityList" :key="c.id" :value="c.id">{{ c.name }}</a-select-option> |
| 52 | 55 | </a-select> |
| 53 | 56 | </a-form-item> |
| 57 | + <a-form-item label="菜单角色"> | |
| 58 | + <a-select v-model:value="form.roleId" placeholder="选择角色"> | |
| 59 | + <a-select-option v-for="item in roleOptions" :key="item.id" :value="item.id">{{ item.name }}</a-select-option> | |
| 60 | + </a-select> | |
| 61 | + </a-form-item> | |
| 54 | 62 | <a-form-item label="登录账号"> |
| 55 | 63 | <a-input v-model:value="form.userLogin" :disabled="!!editingId" /> |
| 56 | 64 | </a-form-item> |
| ... | ... | @@ -87,29 +95,36 @@ |
| 87 | 95 | </template> |
| 88 | 96 | |
| 89 | 97 | <script setup lang="ts"> |
| 90 | -import { ref, reactive, onMounted } from 'vue' | |
| 98 | +import { reactive, ref, onMounted } from 'vue' | |
| 91 | 99 | import { message } from 'ant-design-vue' |
| 92 | -import { substationApi, cityApi } from '@/api' | |
| 100 | +import { cityApi, substationApi, systemRoleApi } from '@/api' | |
| 93 | 101 | |
| 94 | 102 | const loading = ref(false) |
| 95 | 103 | const saving = ref(false) |
| 96 | 104 | const list = ref<any[]>([]) |
| 97 | 105 | const cityList = ref<any[]>([]) |
| 106 | +const roleOptions = ref<any[]>([]) | |
| 98 | 107 | const keyword = ref('') |
| 99 | 108 | const modalVisible = ref(false) |
| 100 | 109 | const editingId = ref<number | null>(null) |
| 101 | -const form = reactive({ cityId: undefined, userLogin: '', userNickname: '', mobile: '', userPass: '' }) | |
| 110 | +const form = reactive({ cityId: undefined, roleId: undefined, userLogin: '', userNickname: '', mobile: '', userPass: '' }) | |
| 102 | 111 | |
| 103 | 112 | const columns = [ |
| 104 | 113 | { title: 'ID', dataIndex: 'id', width: 80 }, |
| 105 | 114 | { title: '账号', dataIndex: 'userLogin' }, |
| 106 | 115 | { title: '昵称', dataIndex: 'userNickname' }, |
| 107 | 116 | { title: '手机', dataIndex: 'mobile' }, |
| 117 | + { title: '角色', key: 'roleId' }, | |
| 108 | 118 | { title: '租户', key: 'cityId' }, |
| 109 | 119 | { title: '状态', key: 'status' }, |
| 110 | 120 | { title: '操作', key: 'action' }, |
| 111 | 121 | ] |
| 112 | 122 | |
| 123 | +function getRoleName(roleId?: number) { | |
| 124 | + const role = roleOptions.value.find(item => item.id === roleId) | |
| 125 | + return role?.name || (roleId ? `角色#${roleId}` : '-') | |
| 126 | +} | |
| 127 | + | |
| 113 | 128 | function getCityName(cityId?: number) { |
| 114 | 129 | const city = cityList.value.find(item => item.id === cityId) |
| 115 | 130 | return city?.name || (cityId ? `租户#${cityId}` : '-') |
| ... | ... | @@ -128,19 +143,28 @@ async function loadCities() { |
| 128 | 143 | cityList.value = res.data |
| 129 | 144 | } |
| 130 | 145 | |
| 146 | +async function loadRoles() { | |
| 147 | + const res: any = await systemRoleApi.list() | |
| 148 | + roleOptions.value = Array.isArray(res?.data) ? res.data.filter((item: any) => item.roleScope === 'SUBSTATION') : [] | |
| 149 | +} | |
| 150 | + | |
| 131 | 151 | function openAdd() { |
| 132 | 152 | editingId.value = null |
| 133 | - Object.assign(form, { cityId: undefined, userLogin: '', userNickname: '', mobile: '', userPass: '' }) | |
| 153 | + Object.assign(form, { cityId: undefined, roleId: roleOptions.value[0]?.id, userLogin: '', userNickname: '', mobile: '', userPass: '' }) | |
| 134 | 154 | modalVisible.value = true |
| 135 | 155 | } |
| 136 | 156 | |
| 137 | 157 | function openEdit(record: any) { |
| 138 | 158 | editingId.value = record.id |
| 139 | - Object.assign(form, { cityId: record.cityId, userLogin: record.userLogin, userNickname: record.userNickname, mobile: record.mobile, userPass: '' }) | |
| 159 | + Object.assign(form, { cityId: record.cityId, roleId: record.roleId, userLogin: record.userLogin, userNickname: record.userNickname, mobile: record.mobile, userPass: '' }) | |
| 140 | 160 | modalVisible.value = true |
| 141 | 161 | } |
| 142 | 162 | |
| 143 | 163 | async function handleSave() { |
| 164 | + if (!form.roleId) { | |
| 165 | + message.error('请选择菜单角色') | |
| 166 | + return | |
| 167 | + } | |
| 144 | 168 | saving.value = true |
| 145 | 169 | try { |
| 146 | 170 | if (editingId.value) { |
| ... | ... | @@ -195,5 +219,5 @@ async function handleChangePwd() { |
| 195 | 219 | } finally { pwdSaving.value = false } |
| 196 | 220 | } |
| 197 | 221 | |
| 198 | -onMounted(() => { loadList(); loadCities() }) | |
| 222 | +onMounted(() => { loadList(); loadCities(); loadRoles() }) | |
| 199 | 223 | </script> | ... | ... |
src/views/system/MenuManage.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div> | |
| 3 | + <a-card title="菜单管理" :bordered="false" class="list-table-card"> | |
| 4 | + <div class="list-toolbar"> | |
| 5 | + <div class="list-toolbar-left"> | |
| 6 | + <a-space wrap> | |
| 7 | + <a-tag v-if="currentNode">{{ currentNode.menuScope }}</a-tag> | |
| 8 | + <a-tag v-if="currentNode" :color="currentNode.visible === 1 ? 'green' : 'default'">{{ currentNode.visible === 1 ? '显示中' : '已隐藏' }}</a-tag> | |
| 9 | + <a-tag v-if="currentNode" :color="currentNode.status === 1 ? 'processing' : 'default'">{{ currentNode.status === 1 ? '已启用' : '已停用' }}</a-tag> | |
| 10 | + </a-space> | |
| 11 | + </div> | |
| 12 | + <div class="list-toolbar-right"> | |
| 13 | + <a-button type="primary" @click="openAddRoot">新增根菜单</a-button> | |
| 14 | + </div> | |
| 15 | + </div> | |
| 16 | + | |
| 17 | + <div class="plan-layout dispatch-layout"> | |
| 18 | + <div class="plan-sidebar"> | |
| 19 | + <div class="plan-sidebar-header"> | |
| 20 | + <div> | |
| 21 | + <div class="plan-sidebar-title">菜单树</div> | |
| 22 | + <div class="plan-sidebar-subtitle">维护平台和分站菜单结构</div> | |
| 23 | + </div> | |
| 24 | + </div> | |
| 25 | + <a-spin :spinning="loading"> | |
| 26 | + <a-tree | |
| 27 | + v-if="treeData.length" | |
| 28 | + class="menu-tree" | |
| 29 | + :tree-data="treeData" | |
| 30 | + :selected-keys="selectedKeys" | |
| 31 | + :field-names="fieldNames" | |
| 32 | + block-node | |
| 33 | + default-expand-all | |
| 34 | + @select="handleSelect" | |
| 35 | + > | |
| 36 | + <template #title="node"> | |
| 37 | + <span>{{ node.name }}</span> | |
| 38 | + <a-tag style="margin-left: 8px">{{ node.menuScope }}</a-tag> | |
| 39 | + <a-tag style="margin-left: 4px" :color="node.visible === 1 ? 'green' : 'default'">{{ node.visible === 1 ? '显' : '隐' }}</a-tag> | |
| 40 | + <a-tag style="margin-left: 4px" :color="node.status === 1 ? 'processing' : 'default'">{{ node.status === 1 ? '启' : '停' }}</a-tag> | |
| 41 | + </template> | |
| 42 | + </a-tree> | |
| 43 | + <a-empty v-else description="暂无菜单" /> | |
| 44 | + </a-spin> | |
| 45 | + </div> | |
| 46 | + | |
| 47 | + <div class="plan-content"> | |
| 48 | + <template v-if="currentNode"> | |
| 49 | + <div class="plan-content-top"> | |
| 50 | + <div class="soft-note-card plan-note-card"> | |
| 51 | + <strong>菜单配置说明</strong> | |
| 52 | + <p>目录节点可不填路由路径,菜单节点建议绑定前端静态路由。删除前请先清空子节点。</p> | |
| 53 | + </div> | |
| 54 | + | |
| 55 | + <div class="plan-toolbar"> | |
| 56 | + <div class="plan-toolbar-meta"> | |
| 57 | + <span class="plan-toolbar-eyebrow">当前编辑</span> | |
| 58 | + <strong>{{ currentNode.name || '未命名菜单' }}</strong> | |
| 59 | + </div> | |
| 60 | + <a-space wrap> | |
| 61 | + <a-button @click="openAddChild">新增子菜单</a-button> | |
| 62 | + <a-button @click="toggleVisible">{{ form.visible === 1 ? '隐藏菜单' : '显示菜单' }}</a-button> | |
| 63 | + <a-button @click="toggleStatus">{{ form.status === 1 ? '停用菜单' : '启用菜单' }}</a-button> | |
| 64 | + <a-popconfirm title="确认删除当前菜单?" @confirm="handleDelete"> | |
| 65 | + <a-button danger>删除菜单</a-button> | |
| 66 | + </a-popconfirm> | |
| 67 | + </a-space> | |
| 68 | + <div class="plan-toolbar-submit"> | |
| 69 | + <span class="plan-toolbar-tip">保存后重新登录即可看到侧边栏变化</span> | |
| 70 | + <a-button type="primary" class="plan-save-button" :loading="saving" @click="handleSave">保存菜单</a-button> | |
| 71 | + </div> | |
| 72 | + </div> | |
| 73 | + </div> | |
| 74 | + | |
| 75 | + <div class="plan-content-body"> | |
| 76 | + <a-form :model="form" layout="vertical"> | |
| 77 | + <div class="plan-section"> | |
| 78 | + <div class="plan-section-head"> | |
| 79 | + <div class="plan-section-chip">基础</div> | |
| 80 | + <div class="soft-section-header"> | |
| 81 | + <div class="soft-section-heading"> | |
| 82 | + <h3 class="soft-section-title">基础信息</h3> | |
| 83 | + <p class="soft-section-subtitle">维护菜单名称、编码和层级关系。</p> | |
| 84 | + </div> | |
| 85 | + </div> | |
| 86 | + </div> | |
| 87 | + <a-row :gutter="16"> | |
| 88 | + <a-col :span="12"> | |
| 89 | + <a-form-item label="菜单名称"> | |
| 90 | + <a-input v-model:value="form.name" /> | |
| 91 | + </a-form-item> | |
| 92 | + </a-col> | |
| 93 | + <a-col :span="12"> | |
| 94 | + <a-form-item label="菜单编码"> | |
| 95 | + <a-input v-model:value="form.code" /> | |
| 96 | + </a-form-item> | |
| 97 | + </a-col> | |
| 98 | + </a-row> | |
| 99 | + <a-row :gutter="16"> | |
| 100 | + <a-col :span="12"> | |
| 101 | + <a-form-item label="父级菜单"> | |
| 102 | + <a-tree-select | |
| 103 | + v-model:value="form.parentId" | |
| 104 | + :tree-data="parentOptions" | |
| 105 | + :field-names="fieldNames" | |
| 106 | + tree-default-expand-all | |
| 107 | + style="width: 100%" | |
| 108 | + /> | |
| 109 | + </a-form-item> | |
| 110 | + </a-col> | |
| 111 | + <a-col :span="12"> | |
| 112 | + <a-form-item label="排序"> | |
| 113 | + <a-input-number v-model:value="form.listOrder" :min="0" style="width: 100%" /> | |
| 114 | + </a-form-item> | |
| 115 | + </a-col> | |
| 116 | + </a-row> | |
| 117 | + </div> | |
| 118 | + | |
| 119 | + <div class="plan-section"> | |
| 120 | + <div class="plan-section-head"> | |
| 121 | + <div class="plan-section-chip">展示</div> | |
| 122 | + <div class="soft-section-header"> | |
| 123 | + <div class="soft-section-heading"> | |
| 124 | + <h3 class="soft-section-title">路由与展示</h3> | |
| 125 | + <p class="soft-section-subtitle">菜单节点绑定前端路由,目录节点只做分组展示。</p> | |
| 126 | + </div> | |
| 127 | + </div> | |
| 128 | + </div> | |
| 129 | + <a-row :gutter="16"> | |
| 130 | + <a-col :span="8"> | |
| 131 | + <a-form-item label="类型"> | |
| 132 | + <a-select v-model:value="form.type"> | |
| 133 | + <a-select-option value="DIR">目录</a-select-option> | |
| 134 | + <a-select-option value="MENU">菜单</a-select-option> | |
| 135 | + </a-select> | |
| 136 | + </a-form-item> | |
| 137 | + </a-col> | |
| 138 | + <a-col :span="8"> | |
| 139 | + <a-form-item label="路由路径"> | |
| 140 | + <a-input v-model:value="form.path" placeholder="例如 /system/menu" /> | |
| 141 | + </a-form-item> | |
| 142 | + </a-col> | |
| 143 | + <a-col :span="8"> | |
| 144 | + <a-form-item label="图标"> | |
| 145 | + <a-select v-model:value="form.icon" allow-clear> | |
| 146 | + <a-select-option v-for="icon in iconOptions" :key="icon" :value="icon">{{ icon }}</a-select-option> | |
| 147 | + </a-select> | |
| 148 | + </a-form-item> | |
| 149 | + </a-col> | |
| 150 | + </a-row> | |
| 151 | + <a-row :gutter="16"> | |
| 152 | + <a-col :span="8"> | |
| 153 | + <a-form-item label="菜单范围"> | |
| 154 | + <a-select v-model:value="form.menuScope"> | |
| 155 | + <a-select-option value="PLATFORM">平台</a-select-option> | |
| 156 | + <a-select-option value="SUBSTATION">分站</a-select-option> | |
| 157 | + <a-select-option value="BOTH">通用</a-select-option> | |
| 158 | + </a-select> | |
| 159 | + </a-form-item> | |
| 160 | + </a-col> | |
| 161 | + <a-col :span="8"> | |
| 162 | + <a-form-item label="是否显示"> | |
| 163 | + <a-switch v-model:checked="visibleChecked" /> | |
| 164 | + </a-form-item> | |
| 165 | + </a-col> | |
| 166 | + <a-col :span="8"> | |
| 167 | + <a-form-item label="是否启用"> | |
| 168 | + <a-switch v-model:checked="statusChecked" /> | |
| 169 | + </a-form-item> | |
| 170 | + </a-col> | |
| 171 | + </a-row> | |
| 172 | + </div> | |
| 173 | + </a-form> | |
| 174 | + </div> | |
| 175 | + </template> | |
| 176 | + | |
| 177 | + <a-empty v-else description="请选择左侧菜单或新增根菜单" /> | |
| 178 | + </div> | |
| 179 | + </div> | |
| 180 | + </a-card> | |
| 181 | + </div> | |
| 182 | +</template> | |
| 183 | + | |
| 184 | +<script setup lang="ts"> | |
| 185 | +import { computed, reactive, ref } from 'vue' | |
| 186 | +import { message } from 'ant-design-vue' | |
| 187 | +import { systemMenuApi } from '@/api' | |
| 188 | + | |
| 189 | +interface MenuNode { | |
| 190 | + id: number | |
| 191 | + code: string | |
| 192 | + name: string | |
| 193 | + type: string | |
| 194 | + path?: string | |
| 195 | + icon?: string | |
| 196 | + menuScope: string | |
| 197 | + visible: number | |
| 198 | + status: number | |
| 199 | + listOrder: number | |
| 200 | + children: MenuNode[] | |
| 201 | +} | |
| 202 | + | |
| 203 | +const fieldNames = { title: 'name', key: 'id', value: 'id', children: 'children' } | |
| 204 | +const iconOptions = [ | |
| 205 | + 'HomeOutlined', | |
| 206 | + 'GlobalOutlined', | |
| 207 | + 'ApartmentOutlined', | |
| 208 | + 'ShopOutlined', | |
| 209 | + 'UserOutlined', | |
| 210 | + 'StarOutlined', | |
| 211 | + 'UnorderedListOutlined', | |
| 212 | + 'ControlOutlined', | |
| 213 | + 'ApiOutlined', | |
| 214 | +] | |
| 215 | + | |
| 216 | +const treeData = ref<MenuNode[]>([]) | |
| 217 | +const selectedKeys = ref<number[]>([]) | |
| 218 | +const loading = ref(false) | |
| 219 | +const saving = ref(false) | |
| 220 | +const form = reactive({ | |
| 221 | + id: undefined as number | undefined, | |
| 222 | + code: '', | |
| 223 | + name: '', | |
| 224 | + type: 'MENU', | |
| 225 | + path: '', | |
| 226 | + icon: '', | |
| 227 | + parentId: 0, | |
| 228 | + menuScope: 'PLATFORM', | |
| 229 | + listOrder: 0, | |
| 230 | + visible: 1, | |
| 231 | + status: 1, | |
| 232 | +}) | |
| 233 | + | |
| 234 | +const currentNode = computed(() => findNode(treeData.value, selectedKeys.value[0])) | |
| 235 | +const parentOptions = computed(() => buildParentOptions(treeData.value, form.id)) | |
| 236 | +const visibleChecked = computed({ | |
| 237 | + get: () => form.visible === 1, | |
| 238 | + set: (val: boolean) => { form.visible = val ? 1 : 0 }, | |
| 239 | +}) | |
| 240 | +const statusChecked = computed({ | |
| 241 | + get: () => form.status === 1, | |
| 242 | + set: (val: boolean) => { form.status = val ? 1 : 0 }, | |
| 243 | +}) | |
| 244 | + | |
| 245 | +function resetForm(parentId = 0) { | |
| 246 | + Object.assign(form, { | |
| 247 | + id: undefined, | |
| 248 | + code: '', | |
| 249 | + name: '', | |
| 250 | + type: 'MENU', | |
| 251 | + path: '', | |
| 252 | + icon: '', | |
| 253 | + parentId, | |
| 254 | + menuScope: 'PLATFORM', | |
| 255 | + listOrder: 0, | |
| 256 | + visible: 1, | |
| 257 | + status: 1, | |
| 258 | + }) | |
| 259 | +} | |
| 260 | + | |
| 261 | +function assignForm(node: MenuNode) { | |
| 262 | + Object.assign(form, { | |
| 263 | + id: node.id, | |
| 264 | + code: node.code, | |
| 265 | + name: node.name, | |
| 266 | + type: node.type, | |
| 267 | + path: node.path || '', | |
| 268 | + icon: node.icon || '', | |
| 269 | + parentId: findParentId(treeData.value, node.id), | |
| 270 | + menuScope: node.menuScope, | |
| 271 | + listOrder: node.listOrder || 0, | |
| 272 | + visible: node.visible ?? 1, | |
| 273 | + status: node.status ?? 1, | |
| 274 | + }) | |
| 275 | +} | |
| 276 | + | |
| 277 | +function handleSelect(keys: (string | number)[]) { | |
| 278 | + const id = Number(keys[0]) | |
| 279 | + if (!id) return | |
| 280 | + selectedKeys.value = [id] | |
| 281 | + const node = findNode(treeData.value, id) | |
| 282 | + if (node) assignForm(node) | |
| 283 | +} | |
| 284 | + | |
| 285 | +function openAddRoot() { | |
| 286 | + selectedKeys.value = [] | |
| 287 | + resetForm(0) | |
| 288 | +} | |
| 289 | + | |
| 290 | +function openAddChild() { | |
| 291 | + const parentId = form.id || selectedKeys.value[0] || 0 | |
| 292 | + selectedKeys.value = [] | |
| 293 | + resetForm(parentId) | |
| 294 | +} | |
| 295 | + | |
| 296 | +async function loadTree() { | |
| 297 | + loading.value = true | |
| 298 | + try { | |
| 299 | + const res: any = await systemMenuApi.tree() | |
| 300 | + treeData.value = Array.isArray(res?.data) ? res.data : [] | |
| 301 | + if (!selectedKeys.value.length && treeData.value.length) { | |
| 302 | + const first = treeData.value[0] | |
| 303 | + selectedKeys.value = [first.id] | |
| 304 | + assignForm(first) | |
| 305 | + return | |
| 306 | + } | |
| 307 | + if (selectedKeys.value.length) { | |
| 308 | + const current = findNode(treeData.value, selectedKeys.value[0]) | |
| 309 | + if (current) { | |
| 310 | + assignForm(current) | |
| 311 | + } else if (treeData.value.length) { | |
| 312 | + const first = treeData.value[0] | |
| 313 | + selectedKeys.value = [first.id] | |
| 314 | + assignForm(first) | |
| 315 | + } | |
| 316 | + } | |
| 317 | + } finally { | |
| 318 | + loading.value = false | |
| 319 | + } | |
| 320 | +} | |
| 321 | + | |
| 322 | +async function handleSave() { | |
| 323 | + if (!form.name || !form.code) { | |
| 324 | + message.error('请填写菜单名称和编码') | |
| 325 | + return | |
| 326 | + } | |
| 327 | + saving.value = true | |
| 328 | + try { | |
| 329 | + const payload = { ...form } | |
| 330 | + if (payload.id) { | |
| 331 | + await systemMenuApi.edit(payload) | |
| 332 | + message.success('保存成功') | |
| 333 | + } else { | |
| 334 | + await systemMenuApi.add(payload) | |
| 335 | + message.success('新增成功') | |
| 336 | + } | |
| 337 | + await loadTree() | |
| 338 | + } finally { | |
| 339 | + saving.value = false | |
| 340 | + } | |
| 341 | +} | |
| 342 | + | |
| 343 | +async function toggleVisible() { | |
| 344 | + if (!form.id) { | |
| 345 | + message.warning('请先选择已有菜单') | |
| 346 | + return | |
| 347 | + } | |
| 348 | + await systemMenuApi.setVisible(form.id, form.visible === 1 ? 0 : 1) | |
| 349 | + message.success('操作成功') | |
| 350 | + await loadTree() | |
| 351 | +} | |
| 352 | + | |
| 353 | +async function toggleStatus() { | |
| 354 | + if (!form.id) { | |
| 355 | + message.warning('请先选择已有菜单') | |
| 356 | + return | |
| 357 | + } | |
| 358 | + await systemMenuApi.setStatus(form.id, form.status === 1 ? 0 : 1) | |
| 359 | + message.success('操作成功') | |
| 360 | + await loadTree() | |
| 361 | +} | |
| 362 | + | |
| 363 | +async function handleDelete() { | |
| 364 | + if (!form.id) { | |
| 365 | + message.warning('请先选择已有菜单') | |
| 366 | + return | |
| 367 | + } | |
| 368 | + await systemMenuApi.del(form.id) | |
| 369 | + message.success('删除成功') | |
| 370 | + selectedKeys.value = [] | |
| 371 | + resetForm(0) | |
| 372 | + await loadTree() | |
| 373 | +} | |
| 374 | + | |
| 375 | +function findNode(nodes: MenuNode[], id?: number): MenuNode | null { | |
| 376 | + if (!id) return null | |
| 377 | + for (const node of nodes) { | |
| 378 | + if (node.id === id) return node | |
| 379 | + const child = findNode(node.children || [], id) | |
| 380 | + if (child) return child | |
| 381 | + } | |
| 382 | + return null | |
| 383 | +} | |
| 384 | + | |
| 385 | +function findParentId(nodes: MenuNode[], childId: number, parentId = 0): number { | |
| 386 | + for (const node of nodes) { | |
| 387 | + if (node.id === childId) return parentId | |
| 388 | + if (containsNode(node.children || [], childId)) { | |
| 389 | + return findParentId(node.children || [], childId, node.id) | |
| 390 | + } | |
| 391 | + } | |
| 392 | + return 0 | |
| 393 | +} | |
| 394 | + | |
| 395 | +function containsNode(nodes: MenuNode[], id: number): boolean { | |
| 396 | + for (const node of nodes) { | |
| 397 | + if (node.id === id) return true | |
| 398 | + if (containsNode(node.children || [], id)) return true | |
| 399 | + } | |
| 400 | + return false | |
| 401 | +} | |
| 402 | + | |
| 403 | +function buildParentOptions(nodes: MenuNode[], excludeId?: number): MenuNode[] { | |
| 404 | + return nodes | |
| 405 | + .filter(node => node.id !== excludeId) | |
| 406 | + .map(node => ({ | |
| 407 | + ...node, | |
| 408 | + children: buildParentOptions((node.children || []).filter(child => child.id !== excludeId), excludeId), | |
| 409 | + })) | |
| 410 | +} | |
| 411 | + | |
| 412 | +loadTree() | |
| 413 | +</script> | |
| 414 | + | |
| 415 | +<style scoped> | |
| 416 | +.dispatch-layout { | |
| 417 | + margin-top: 8px; | |
| 418 | +} | |
| 419 | + | |
| 420 | +.plan-layout { | |
| 421 | + display: grid; | |
| 422 | + grid-template-columns: 280px minmax(0, 1fr); | |
| 423 | + gap: 18px; | |
| 424 | + min-height: 640px; | |
| 425 | +} | |
| 426 | + | |
| 427 | +.plan-sidebar, | |
| 428 | +.plan-content { | |
| 429 | + border-radius: 24px; | |
| 430 | + border: 1px solid var(--line); | |
| 431 | + background: var(--panel); | |
| 432 | + padding: 18px; | |
| 433 | +} | |
| 434 | + | |
| 435 | +.plan-content { | |
| 436 | + display: flex; | |
| 437 | + flex-direction: column; | |
| 438 | + gap: 18px; | |
| 439 | + min-width: 0; | |
| 440 | +} | |
| 441 | + | |
| 442 | +.plan-content-top { | |
| 443 | + display: flex; | |
| 444 | + flex-direction: column; | |
| 445 | + gap: 12px; | |
| 446 | +} | |
| 447 | + | |
| 448 | +.plan-content-body { | |
| 449 | + display: flex; | |
| 450 | + flex-direction: column; | |
| 451 | + gap: 18px; | |
| 452 | +} | |
| 453 | + | |
| 454 | +.plan-sidebar-header, | |
| 455 | +.plan-toolbar { | |
| 456 | + display: flex; | |
| 457 | + align-items: center; | |
| 458 | + justify-content: space-between; | |
| 459 | + gap: 12px; | |
| 460 | +} | |
| 461 | + | |
| 462 | +.plan-sidebar-title, | |
| 463 | +.soft-section-title { | |
| 464 | + color: var(--text-dark); | |
| 465 | + font-family: var(--font-display); | |
| 466 | +} | |
| 467 | + | |
| 468 | +.plan-sidebar-title { | |
| 469 | + font-size: 16px; | |
| 470 | + font-weight: 700; | |
| 471 | +} | |
| 472 | + | |
| 473 | +.plan-sidebar-subtitle, | |
| 474 | +.plan-toolbar-eyebrow, | |
| 475 | +.plan-toolbar-tip, | |
| 476 | +.soft-section-subtitle { | |
| 477 | + color: var(--text-soft); | |
| 478 | + font-size: 12px; | |
| 479 | +} | |
| 480 | + | |
| 481 | +.plan-toolbar { | |
| 482 | + flex-wrap: wrap; | |
| 483 | + align-items: center; | |
| 484 | + padding: 14px 16px; | |
| 485 | + border: 1px solid var(--line); | |
| 486 | + border-radius: 20px; | |
| 487 | + background: var(--panel-strong); | |
| 488 | + box-shadow: var(--shadow-sm); | |
| 489 | +} | |
| 490 | + | |
| 491 | +.plan-toolbar-meta, | |
| 492 | +.plan-toolbar-submit { | |
| 493 | + display: flex; | |
| 494 | + flex-direction: column; | |
| 495 | + gap: 4px; | |
| 496 | +} | |
| 497 | + | |
| 498 | +.plan-toolbar-meta strong { | |
| 499 | + color: var(--text-dark); | |
| 500 | + font-size: 15px; | |
| 501 | + line-height: 1.4; | |
| 502 | +} | |
| 503 | + | |
| 504 | +.plan-toolbar-submit { | |
| 505 | + margin-left: auto; | |
| 506 | + align-items: flex-end; | |
| 507 | +} | |
| 508 | + | |
| 509 | +.plan-save-button { | |
| 510 | + min-width: 108px; | |
| 511 | + height: 36px; | |
| 512 | +} | |
| 513 | + | |
| 514 | +.plan-section { | |
| 515 | + padding: 18px 20px 20px; | |
| 516 | + border: 1px solid var(--line); | |
| 517 | + border-radius: 22px; | |
| 518 | + background: var(--panel-strong); | |
| 519 | +} | |
| 520 | + | |
| 521 | +.plan-section-head { | |
| 522 | + display: flex; | |
| 523 | + flex-direction: column; | |
| 524 | + gap: 10px; | |
| 525 | + margin-bottom: 16px; | |
| 526 | +} | |
| 527 | + | |
| 528 | +.plan-section-chip { | |
| 529 | + display: inline-flex; | |
| 530 | + align-items: center; | |
| 531 | + align-self: flex-start; | |
| 532 | + min-height: 26px; | |
| 533 | + padding: 0 12px; | |
| 534 | + border-radius: 999px; | |
| 535 | + background: var(--panel-strong); | |
| 536 | + border: 1px solid var(--line-strong); | |
| 537 | + color: var(--brand); | |
| 538 | + font-size: 12px; | |
| 539 | + font-weight: 700; | |
| 540 | +} | |
| 541 | + | |
| 542 | +.soft-section-title { | |
| 543 | + margin: 0; | |
| 544 | + font-size: 18px; | |
| 545 | + line-height: 1.35; | |
| 546 | +} | |
| 547 | + | |
| 548 | +.soft-section-subtitle { | |
| 549 | + margin: 4px 0 0; | |
| 550 | + line-height: 1.6; | |
| 551 | +} | |
| 552 | + | |
| 553 | +.menu-tree { | |
| 554 | + padding: 8px 0; | |
| 555 | +} | |
| 556 | + | |
| 557 | +.plan-content > :deep(.ant-empty) { | |
| 558 | + margin: auto 0; | |
| 559 | +} | |
| 560 | + | |
| 561 | +@media (max-width: 960px) { | |
| 562 | + .plan-layout { | |
| 563 | + grid-template-columns: 1fr; | |
| 564 | + } | |
| 565 | + | |
| 566 | + .plan-toolbar-submit { | |
| 567 | + margin-left: 0; | |
| 568 | + align-items: flex-start; | |
| 569 | + } | |
| 570 | +} | |
| 571 | +</style> | ... | ... |
src/views/system/RoleList.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div> | |
| 3 | + <a-card title="角色管理" :bordered="false" class="list-table-card"> | |
| 4 | + <div class="list-toolbar"> | |
| 5 | + <div class="list-toolbar-left"> | |
| 6 | + <a-input-search v-model:value="keyword" placeholder="搜索角色名称/编码" class="list-search" /> | |
| 7 | + </div> | |
| 8 | + <div class="list-toolbar-right"> | |
| 9 | + <a-button type="primary" @click="openAdd">新增角色</a-button> | |
| 10 | + </div> | |
| 11 | + </div> | |
| 12 | + | |
| 13 | + <a-table :dataSource="filteredList" :columns="columns" :loading="loading" rowKey="id" :pagination="false"> | |
| 14 | + <template #bodyCell="{ column, record }"> | |
| 15 | + <template v-if="column.key === 'name'"> | |
| 16 | + <div class="role-name-cell"> | |
| 17 | + <span>{{ record.name }}</span> | |
| 18 | + <a-tag v-if="record.builtIn" color="purple">内置</a-tag> | |
| 19 | + </div> | |
| 20 | + </template> | |
| 21 | + | |
| 22 | + <template v-else-if="column.key === 'roleScope'"> | |
| 23 | + <a-tag :color="record.roleScope === 'PLATFORM' ? 'blue' : 'cyan'"> | |
| 24 | + {{ formatScope(record.roleScope) }} | |
| 25 | + </a-tag> | |
| 26 | + </template> | |
| 27 | + | |
| 28 | + <template v-else-if="column.key === 'status'"> | |
| 29 | + <a-tag :color="record.status === 1 ? 'green' : 'red'"> | |
| 30 | + {{ record.status === 1 ? '正常' : '禁用' }} | |
| 31 | + </a-tag> | |
| 32 | + </template> | |
| 33 | + | |
| 34 | + <template v-else-if="column.key === 'binding'"> | |
| 35 | + <span class="binding-copy">{{ formatBinding(record) }}</span> | |
| 36 | + </template> | |
| 37 | + | |
| 38 | + <template v-else-if="column.key === 'action'"> | |
| 39 | + <a-space wrap> | |
| 40 | + <a v-if="!record.builtIn" @click="openEdit(record)">编辑</a> | |
| 41 | + <a-tooltip v-else title="内置角色不允许编辑"> | |
| 42 | + <span class="action-disabled">编辑</span> | |
| 43 | + </a-tooltip> | |
| 44 | + | |
| 45 | + <a v-if="record.status === 1" @click="goAssignMenus(record)">分配菜单</a> | |
| 46 | + <a-tooltip v-else title="请先启用角色再分配菜单"> | |
| 47 | + <span class="action-disabled">分配菜单</span> | |
| 48 | + </a-tooltip> | |
| 49 | + | |
| 50 | + <a-popconfirm | |
| 51 | + v-if="canToggleStatus(record)" | |
| 52 | + :title="record.status === 1 ? '确认禁用?' : '确认启用?'" | |
| 53 | + @confirm="toggleStatus(record)" | |
| 54 | + > | |
| 55 | + <a :style="record.status === 1 ? 'color:red' : ''"> | |
| 56 | + {{ record.status === 1 ? '禁用' : '启用' }} | |
| 57 | + </a> | |
| 58 | + </a-popconfirm> | |
| 59 | + <a-tooltip v-else :title="getToggleBlockedReason(record)"> | |
| 60 | + <span class="action-disabled">{{ record.status === 1 ? '禁用' : '启用' }}</span> | |
| 61 | + </a-tooltip> | |
| 62 | + | |
| 63 | + <a-popconfirm v-if="canDelete(record)" title="确认删除?" @confirm="handleDel(record.id)"> | |
| 64 | + <a style="color:red">删除</a> | |
| 65 | + </a-popconfirm> | |
| 66 | + <a-tooltip v-else :title="getDeleteBlockedReason(record)"> | |
| 67 | + <span class="action-disabled">删除</span> | |
| 68 | + </a-tooltip> | |
| 69 | + </a-space> | |
| 70 | + </template> | |
| 71 | + </template> | |
| 72 | + </a-table> | |
| 73 | + </a-card> | |
| 74 | + | |
| 75 | + <a-modal | |
| 76 | + v-model:open="modalVisible" | |
| 77 | + :title="editingId ? '编辑角色' : '新增角色'" | |
| 78 | + @ok="handleSave" | |
| 79 | + :confirmLoading="saving" | |
| 80 | + > | |
| 81 | + <div class="soft-page-stack"> | |
| 82 | + <div class="soft-note-card"> | |
| 83 | + <strong>角色说明</strong> | |
| 84 | + <p>角色只控制菜单显示,不做完整权限点授权。创建后可继续进入“分配菜单”为角色配置可见菜单。</p> | |
| 85 | + </div> | |
| 86 | + <a-form :model="form" layout="vertical"> | |
| 87 | + <a-form-item label="角色名称"> | |
| 88 | + <a-input v-model:value="form.name" /> | |
| 89 | + </a-form-item> | |
| 90 | + <a-form-item label="角色编码"> | |
| 91 | + <a-input v-model:value="form.code" /> | |
| 92 | + </a-form-item> | |
| 93 | + <a-form-item label="角色范围"> | |
| 94 | + <a-select v-model:value="form.roleScope" :disabled="!!editingId"> | |
| 95 | + <a-select-option value="PLATFORM">平台角色</a-select-option> | |
| 96 | + <a-select-option value="SUBSTATION">分站角色</a-select-option> | |
| 97 | + </a-select> | |
| 98 | + </a-form-item> | |
| 99 | + </a-form> | |
| 100 | + </div> | |
| 101 | + </a-modal> | |
| 102 | + </div> | |
| 103 | +</template> | |
| 104 | + | |
| 105 | +<script setup lang="ts"> | |
| 106 | +import { computed, onMounted, reactive, ref } from 'vue' | |
| 107 | +import { message } from 'ant-design-vue' | |
| 108 | +import { useRouter } from 'vue-router' | |
| 109 | +import { systemRoleApi } from '@/api' | |
| 110 | + | |
| 111 | +interface RoleItem { | |
| 112 | + id: number | |
| 113 | + code: string | |
| 114 | + name: string | |
| 115 | + roleScope: string | |
| 116 | + status: number | |
| 117 | + builtIn?: boolean | |
| 118 | + adminUserCount?: number | |
| 119 | + substationCount?: number | |
| 120 | + createTime?: number | |
| 121 | +} | |
| 122 | + | |
| 123 | +const router = useRouter() | |
| 124 | +const loading = ref(false) | |
| 125 | +const saving = ref(false) | |
| 126 | +const keyword = ref('') | |
| 127 | +const list = ref<RoleItem[]>([]) | |
| 128 | +const modalVisible = ref(false) | |
| 129 | +const editingId = ref<number | null>(null) | |
| 130 | +const form = reactive({ name: '', code: '', roleScope: 'PLATFORM' }) | |
| 131 | + | |
| 132 | +const columns = [ | |
| 133 | + { title: 'ID', dataIndex: 'id', width: 80 }, | |
| 134 | + { title: '角色名称', key: 'name' }, | |
| 135 | + { title: '角色编码', dataIndex: 'code' }, | |
| 136 | + { title: '角色范围', key: 'roleScope', width: 110 }, | |
| 137 | + { title: '状态', key: 'status', width: 100 }, | |
| 138 | + { title: '绑定情况', key: 'binding' }, | |
| 139 | + { title: '操作', key: 'action', width: 320 }, | |
| 140 | +] | |
| 141 | + | |
| 142 | +const filteredList = computed(() => { | |
| 143 | + const value = keyword.value.trim().toLowerCase() | |
| 144 | + if (!value) return list.value | |
| 145 | + return list.value.filter(item => | |
| 146 | + item.name.toLowerCase().includes(value) || item.code.toLowerCase().includes(value), | |
| 147 | + ) | |
| 148 | +}) | |
| 149 | + | |
| 150 | +function formatScope(scope: string) { | |
| 151 | + return scope === 'PLATFORM' ? '平台' : '分站' | |
| 152 | +} | |
| 153 | + | |
| 154 | +function formatBinding(record: RoleItem) { | |
| 155 | + const parts: string[] = [] | |
| 156 | + if ((record.adminUserCount || 0) > 0) parts.push(`平台账号 ${record.adminUserCount}`) | |
| 157 | + if ((record.substationCount || 0) > 0) parts.push(`分站账号 ${record.substationCount}`) | |
| 158 | + return parts.length ? parts.join(' / ') : '未绑定账号' | |
| 159 | +} | |
| 160 | + | |
| 161 | +function isBound(record: RoleItem) { | |
| 162 | + return (record.adminUserCount || 0) > 0 || (record.substationCount || 0) > 0 | |
| 163 | +} | |
| 164 | + | |
| 165 | +function canToggleStatus(record: RoleItem) { | |
| 166 | + if (record.builtIn) return false | |
| 167 | + if (record.status === 1 && isBound(record)) return false | |
| 168 | + return true | |
| 169 | +} | |
| 170 | + | |
| 171 | +function canDelete(record: RoleItem) { | |
| 172 | + return !record.builtIn && !isBound(record) | |
| 173 | +} | |
| 174 | + | |
| 175 | +function getToggleBlockedReason(record: RoleItem) { | |
| 176 | + if (record.builtIn) return '内置角色不允许禁用' | |
| 177 | + if (record.status === 1 && isBound(record)) return '角色已绑定账号,不能禁用' | |
| 178 | + return '当前角色不可操作' | |
| 179 | +} | |
| 180 | + | |
| 181 | +function getDeleteBlockedReason(record: RoleItem) { | |
| 182 | + if (record.builtIn) return '内置角色不允许删除' | |
| 183 | + if (isBound(record)) return '角色已绑定账号,不能删除' | |
| 184 | + return '当前角色不可删除' | |
| 185 | +} | |
| 186 | + | |
| 187 | +async function loadList() { | |
| 188 | + loading.value = true | |
| 189 | + try { | |
| 190 | + const res: any = await systemRoleApi.list(true) | |
| 191 | + list.value = Array.isArray(res?.data) ? res.data : [] | |
| 192 | + } finally { | |
| 193 | + loading.value = false | |
| 194 | + } | |
| 195 | +} | |
| 196 | + | |
| 197 | +function openAdd() { | |
| 198 | + editingId.value = null | |
| 199 | + Object.assign(form, { name: '', code: '', roleScope: 'PLATFORM' }) | |
| 200 | + modalVisible.value = true | |
| 201 | +} | |
| 202 | + | |
| 203 | +function openEdit(record: RoleItem) { | |
| 204 | + editingId.value = record.id | |
| 205 | + Object.assign(form, { name: record.name, code: record.code, roleScope: record.roleScope }) | |
| 206 | + modalVisible.value = true | |
| 207 | +} | |
| 208 | + | |
| 209 | +async function handleSave() { | |
| 210 | + if (!form.name.trim() || !form.code.trim()) { | |
| 211 | + message.error('请填写角色名称和编码') | |
| 212 | + return | |
| 213 | + } | |
| 214 | + saving.value = true | |
| 215 | + try { | |
| 216 | + const payload = { name: form.name.trim(), code: form.code.trim(), roleScope: form.roleScope } | |
| 217 | + if (editingId.value) { | |
| 218 | + await systemRoleApi.edit({ id: editingId.value, ...payload }) | |
| 219 | + } else { | |
| 220 | + await systemRoleApi.add(payload) | |
| 221 | + } | |
| 222 | + message.success('保存成功') | |
| 223 | + modalVisible.value = false | |
| 224 | + await loadList() | |
| 225 | + } finally { | |
| 226 | + saving.value = false | |
| 227 | + } | |
| 228 | +} | |
| 229 | + | |
| 230 | +async function toggleStatus(record: RoleItem) { | |
| 231 | + if (record.status === 1) { | |
| 232 | + await systemRoleApi.ban(record.id) | |
| 233 | + } else { | |
| 234 | + await systemRoleApi.cancelBan(record.id) | |
| 235 | + } | |
| 236 | + message.success('操作成功') | |
| 237 | + await loadList() | |
| 238 | +} | |
| 239 | + | |
| 240 | +async function handleDel(id: number) { | |
| 241 | + await systemRoleApi.del(id) | |
| 242 | + message.success('删除成功') | |
| 243 | + await loadList() | |
| 244 | +} | |
| 245 | + | |
| 246 | +function goAssignMenus(record: RoleItem) { | |
| 247 | + router.push({ path: '/system/role-menu', query: { roleId: String(record.id) } }) | |
| 248 | +} | |
| 249 | + | |
| 250 | +onMounted(loadList) | |
| 251 | +</script> | |
| 252 | + | |
| 253 | +<style scoped> | |
| 254 | +.role-name-cell { | |
| 255 | + display: inline-flex; | |
| 256 | + align-items: center; | |
| 257 | + gap: 8px; | |
| 258 | + flex-wrap: wrap; | |
| 259 | +} | |
| 260 | + | |
| 261 | +.binding-copy { | |
| 262 | + color: var(--text-soft); | |
| 263 | +} | |
| 264 | + | |
| 265 | +.action-disabled { | |
| 266 | + color: var(--text-soft); | |
| 267 | + cursor: not-allowed; | |
| 268 | +} | |
| 269 | +</style> | ... | ... |
src/views/system/RoleMenuAssign.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div> | |
| 3 | + <a-card title="角色菜单分配" :bordered="false" class="list-table-card"> | |
| 4 | + <div class="plan-layout dispatch-layout"> | |
| 5 | + <div class="plan-sidebar"> | |
| 6 | + <div class="plan-sidebar-header"> | |
| 7 | + <div> | |
| 8 | + <div class="plan-sidebar-title">角色列表</div> | |
| 9 | + <div class="plan-sidebar-subtitle">选择角色后配置可见菜单</div> | |
| 10 | + </div> | |
| 11 | + </div> | |
| 12 | + <div class="plan-list"> | |
| 13 | + <button | |
| 14 | + v-for="role in roles" | |
| 15 | + :key="role.id" | |
| 16 | + type="button" | |
| 17 | + class="plan-item" | |
| 18 | + :class="{ active: role.id === selectedRoleId }" | |
| 19 | + @click="selectRole(role.id)" | |
| 20 | + > | |
| 21 | + <div class="plan-item-top"> | |
| 22 | + <span class="plan-item-name">{{ role.name }}</span> | |
| 23 | + <a-tag>{{ role.roleScope }}</a-tag> | |
| 24 | + </div> | |
| 25 | + <div class="plan-item-bottom"> | |
| 26 | + <span>{{ role.code }}</span> | |
| 27 | + </div> | |
| 28 | + </button> | |
| 29 | + <a-empty v-if="!roles.length" description="暂无角色" /> | |
| 30 | + </div> | |
| 31 | + </div> | |
| 32 | + | |
| 33 | + <div class="plan-content"> | |
| 34 | + <template v-if="selectedRole"> | |
| 35 | + <div class="plan-content-top"> | |
| 36 | + <div class="soft-note-card plan-note-card"> | |
| 37 | + <strong>分配说明</strong> | |
| 38 | + <p>这里只控制菜单可见性,不做完整接口权限。保存后用户重新登录即可看到最新菜单。</p> | |
| 39 | + </div> | |
| 40 | + | |
| 41 | + <div class="plan-toolbar"> | |
| 42 | + <div class="plan-toolbar-meta"> | |
| 43 | + <span class="plan-toolbar-eyebrow">当前角色</span> | |
| 44 | + <strong>{{ selectedRole.name }}</strong> | |
| 45 | + </div> | |
| 46 | + <div class="plan-toolbar-submit"> | |
| 47 | + <span class="plan-toolbar-tip">平台角色只能分平台/通用菜单,分站角色只能分分站/通用菜单</span> | |
| 48 | + <a-button type="primary" class="plan-save-button" :loading="saving" @click="handleSave">保存分配</a-button> | |
| 49 | + </div> | |
| 50 | + </div> | |
| 51 | + </div> | |
| 52 | + | |
| 53 | + <div class="plan-content-body"> | |
| 54 | + <a-spin :spinning="loadingTree"> | |
| 55 | + <div class="soft-note-card" style="margin-bottom: 12px"> | |
| 56 | + <strong>当前角色范围:{{ selectedRole.roleScope }}</strong> | |
| 57 | + <p style="margin: 6px 0 0">可勾选节点已经按角色范围过滤;保存后目标账号重新登录即可看到最新菜单。</p> | |
| 58 | + </div> | |
| 59 | + <a-tree | |
| 60 | + v-if="treeData.length" | |
| 61 | + checkable | |
| 62 | + block-node | |
| 63 | + check-strictly | |
| 64 | + :tree-data="treeData" | |
| 65 | + :field-names="fieldNames" | |
| 66 | + :checked-keys="checkedKeys" | |
| 67 | + @check="handleCheck" | |
| 68 | + > | |
| 69 | + <template #title="node"> | |
| 70 | + <span>{{ node.name }}</span> | |
| 71 | + <a-tag style="margin-left: 8px">{{ node.menuScope }}</a-tag> | |
| 72 | + <a-tag style="margin-left: 4px" :color="node.checked ? 'processing' : 'default'">{{ node.checked ? '已分配' : '未分配' }}</a-tag> | |
| 73 | + </template> | |
| 74 | + </a-tree> | |
| 75 | + <a-empty v-else description="当前角色暂无可分配菜单" /> | |
| 76 | + </a-spin> | |
| 77 | + </div> | |
| 78 | + </template> | |
| 79 | + | |
| 80 | + <a-empty v-else description="请选择左侧角色" /> | |
| 81 | + </div> | |
| 82 | + </div> | |
| 83 | + </a-card> | |
| 84 | + </div> | |
| 85 | +</template> | |
| 86 | + | |
| 87 | +<script setup lang="ts"> | |
| 88 | +import { computed, ref, watch } from 'vue' | |
| 89 | +import { useRoute } from 'vue-router' | |
| 90 | +import { message } from 'ant-design-vue' | |
| 91 | +import { systemRoleApi } from '@/api' | |
| 92 | + | |
| 93 | +interface RoleVO { | |
| 94 | + id: number | |
| 95 | + code: string | |
| 96 | + name: string | |
| 97 | + roleScope: string | |
| 98 | +} | |
| 99 | + | |
| 100 | +interface MenuTreeNode { | |
| 101 | + id: number | |
| 102 | + name: string | |
| 103 | + code: string | |
| 104 | + menuScope: string | |
| 105 | + checked: boolean | |
| 106 | + children: MenuTreeNode[] | |
| 107 | +} | |
| 108 | + | |
| 109 | +const route = useRoute() | |
| 110 | +const fieldNames = { title: 'name', key: 'id', children: 'children' } | |
| 111 | +const roles = ref<RoleVO[]>([]) | |
| 112 | +const selectedRoleId = ref<number>() | |
| 113 | +const loadingTree = ref(false) | |
| 114 | +const treeData = ref<MenuTreeNode[]>([]) | |
| 115 | +const checkedKeys = ref<number[]>([]) | |
| 116 | +const saving = ref(false) | |
| 117 | + | |
| 118 | +const selectedRole = computed(() => roles.value.find(item => item.id === selectedRoleId.value) || null) | |
| 119 | + | |
| 120 | +async function loadRoles() { | |
| 121 | + const res: any = await systemRoleApi.list() | |
| 122 | + roles.value = Array.isArray(res?.data) ? res.data : [] | |
| 123 | + if (!roles.value.length) { | |
| 124 | + selectedRoleId.value = undefined | |
| 125 | + treeData.value = [] | |
| 126 | + checkedKeys.value = [] | |
| 127 | + return | |
| 128 | + } | |
| 129 | + | |
| 130 | + const queryRoleId = Number(route.query.roleId) | |
| 131 | + const preferredRoleId = Number.isFinite(queryRoleId) && queryRoleId > 0 ? queryRoleId : selectedRoleId.value | |
| 132 | + const targetRole = roles.value.find(item => item.id === preferredRoleId) || roles.value[0] | |
| 133 | + await selectRole(targetRole.id) | |
| 134 | +} | |
| 135 | + | |
| 136 | +async function selectRole(roleId: number) { | |
| 137 | + selectedRoleId.value = roleId | |
| 138 | + loadingTree.value = true | |
| 139 | + try { | |
| 140 | + const res: any = await systemRoleApi.menuTree(roleId) | |
| 141 | + treeData.value = Array.isArray(res?.data) ? res.data : [] | |
| 142 | + checkedKeys.value = collectCheckedIds(treeData.value) | |
| 143 | + } finally { | |
| 144 | + loadingTree.value = false | |
| 145 | + } | |
| 146 | +} | |
| 147 | + | |
| 148 | +async function handleSave() { | |
| 149 | + if (!selectedRoleId.value) { | |
| 150 | + message.warning('请先选择角色') | |
| 151 | + return | |
| 152 | + } | |
| 153 | + saving.value = true | |
| 154 | + try { | |
| 155 | + await systemRoleApi.assignMenus(selectedRoleId.value, { menuIds: checkedKeys.value.map(Number) }) | |
| 156 | + message.success('保存成功') | |
| 157 | + await selectRole(selectedRoleId.value) | |
| 158 | + } finally { | |
| 159 | + saving.value = false | |
| 160 | + } | |
| 161 | +} | |
| 162 | + | |
| 163 | +function handleCheck(keys: any) { | |
| 164 | + checkedKeys.value = (Array.isArray(keys) ? keys : keys.checked).map(Number) | |
| 165 | +} | |
| 166 | + | |
| 167 | +function collectCheckedIds(nodes: MenuTreeNode[]): number[] { | |
| 168 | + const ids: number[] = [] | |
| 169 | + const walk = (items: MenuTreeNode[]) => { | |
| 170 | + for (const item of items) { | |
| 171 | + if (item.checked) ids.push(item.id) | |
| 172 | + if (item.children?.length) walk(item.children) | |
| 173 | + } | |
| 174 | + } | |
| 175 | + walk(nodes) | |
| 176 | + return ids | |
| 177 | +} | |
| 178 | + | |
| 179 | +watch(() => route.query.roleId, async (value) => { | |
| 180 | + const roleId = Number(value) | |
| 181 | + if (!Number.isFinite(roleId) || roleId < 1 || !roles.value.length) { | |
| 182 | + return | |
| 183 | + } | |
| 184 | + if (roles.value.some(item => item.id === roleId) && selectedRoleId.value !== roleId) { | |
| 185 | + await selectRole(roleId) | |
| 186 | + } | |
| 187 | +}) | |
| 188 | + | |
| 189 | +loadRoles() | |
| 190 | +</script> | |
| 191 | + | |
| 192 | +<style scoped> | |
| 193 | +.dispatch-layout { | |
| 194 | + margin-top: 8px; | |
| 195 | +} | |
| 196 | + | |
| 197 | +.plan-layout { | |
| 198 | + display: grid; | |
| 199 | + grid-template-columns: 280px minmax(0, 1fr); | |
| 200 | + gap: 18px; | |
| 201 | + min-height: 560px; | |
| 202 | +} | |
| 203 | + | |
| 204 | +.plan-sidebar, | |
| 205 | +.plan-content { | |
| 206 | + border-radius: 24px; | |
| 207 | + border: 1px solid var(--line); | |
| 208 | + background: var(--panel); | |
| 209 | + padding: 18px; | |
| 210 | +} | |
| 211 | + | |
| 212 | +.plan-content { | |
| 213 | + display: flex; | |
| 214 | + flex-direction: column; | |
| 215 | + gap: 18px; | |
| 216 | + min-width: 0; | |
| 217 | +} | |
| 218 | + | |
| 219 | +.plan-content-top, | |
| 220 | +.plan-content-body { | |
| 221 | + display: flex; | |
| 222 | + flex-direction: column; | |
| 223 | + gap: 12px; | |
| 224 | +} | |
| 225 | + | |
| 226 | +.plan-sidebar-header, | |
| 227 | +.plan-toolbar, | |
| 228 | +.plan-item-top, | |
| 229 | +.plan-item-bottom { | |
| 230 | + display: flex; | |
| 231 | + align-items: center; | |
| 232 | + justify-content: space-between; | |
| 233 | + gap: 12px; | |
| 234 | +} | |
| 235 | + | |
| 236 | +.plan-sidebar-title, | |
| 237 | +.plan-item-name { | |
| 238 | + color: var(--text-dark); | |
| 239 | + font-family: var(--font-display); | |
| 240 | +} | |
| 241 | + | |
| 242 | +.plan-sidebar-title { | |
| 243 | + font-size: 16px; | |
| 244 | + font-weight: 700; | |
| 245 | +} | |
| 246 | + | |
| 247 | +.plan-sidebar-subtitle, | |
| 248 | +.plan-item-bottom, | |
| 249 | +.plan-toolbar-eyebrow, | |
| 250 | +.plan-toolbar-tip { | |
| 251 | + color: var(--text-soft); | |
| 252 | + font-size: 12px; | |
| 253 | +} | |
| 254 | + | |
| 255 | +.plan-list { | |
| 256 | + display: flex; | |
| 257 | + flex-direction: column; | |
| 258 | + gap: 10px; | |
| 259 | + margin-top: 14px; | |
| 260 | +} | |
| 261 | + | |
| 262 | +.plan-item { | |
| 263 | + border: 1px solid var(--line); | |
| 264 | + background: var(--panel-strong); | |
| 265 | + border-radius: 18px; | |
| 266 | + padding: 14px; | |
| 267 | + text-align: left; | |
| 268 | + cursor: pointer; | |
| 269 | + color: inherit; | |
| 270 | +} | |
| 271 | + | |
| 272 | +.plan-item.active { | |
| 273 | + border-color: var(--brand); | |
| 274 | + background: var(--panel-tint); | |
| 275 | + box-shadow: var(--shadow-sm); | |
| 276 | +} | |
| 277 | + | |
| 278 | +.plan-toolbar { | |
| 279 | + flex-wrap: wrap; | |
| 280 | + align-items: center; | |
| 281 | + padding: 14px 16px; | |
| 282 | + border: 1px solid var(--line); | |
| 283 | + border-radius: 20px; | |
| 284 | + background: var(--panel-strong); | |
| 285 | + box-shadow: var(--shadow-sm); | |
| 286 | +} | |
| 287 | + | |
| 288 | +.plan-toolbar-meta, | |
| 289 | +.plan-toolbar-submit { | |
| 290 | + display: flex; | |
| 291 | + flex-direction: column; | |
| 292 | + gap: 4px; | |
| 293 | +} | |
| 294 | + | |
| 295 | +.plan-toolbar-meta strong { | |
| 296 | + color: var(--text-dark); | |
| 297 | + font-size: 15px; | |
| 298 | + line-height: 1.4; | |
| 299 | +} | |
| 300 | + | |
| 301 | +.plan-toolbar-submit { | |
| 302 | + margin-left: auto; | |
| 303 | + align-items: flex-end; | |
| 304 | +} | |
| 305 | + | |
| 306 | +.plan-save-button { | |
| 307 | + min-width: 108px; | |
| 308 | + height: 36px; | |
| 309 | +} | |
| 310 | + | |
| 311 | +.plan-content > :deep(.ant-empty) { | |
| 312 | + margin: auto 0; | |
| 313 | +} | |
| 314 | + | |
| 315 | +:deep(.ant-tree) { | |
| 316 | + background: transparent; | |
| 317 | +} | |
| 318 | + | |
| 319 | +@media (max-width: 960px) { | |
| 320 | + .plan-layout { | |
| 321 | + grid-template-columns: 1fr; | |
| 322 | + } | |
| 323 | + | |
| 324 | + .plan-toolbar-submit { | |
| 325 | + margin-left: 0; | |
| 326 | + align-items: flex-start; | |
| 327 | + } | |
| 328 | +} | |
| 329 | +</style> | ... | ... |