Commit 1ec56380d4670542f447257bc92539d1d8e6dd94

Authored by 杨刚
1 parent 74bd4291

refactor: consolidate menu handling logic and update related components

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) =&gt; {
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 &#39;@/api&#39;
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>
... ...