优化构架
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { adminNavigation, filterAdminNavigation, type AdminNavigationNode } from '@/core/navigation/adminNavigation'
|
||||
import AdminSidebarMenu from './AdminSidebarMenu.vue'
|
||||
|
||||
const stubs = {
|
||||
'el-menu': {
|
||||
props: ['defaultActive', 'collapse'],
|
||||
template: '<nav :data-active="defaultActive" :data-collapsed="String(collapse)"><slot /></nav>',
|
||||
},
|
||||
'el-sub-menu': {
|
||||
props: ['index'],
|
||||
template: '<section data-kind="group" :data-index="index"><header><slot name="title" /></header><slot /></section>',
|
||||
},
|
||||
'el-menu-item': {
|
||||
props: ['index'],
|
||||
template: '<a data-kind="item" :data-index="index"><slot name="title" /><slot /></a>',
|
||||
},
|
||||
}
|
||||
|
||||
describe('AdminSidebarMenu', () => {
|
||||
it('renders the visible phase-one modules and forwards the active route', () => {
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
props: { items: adminNavigation, activePath: '/system/users', collapsed: false },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
expect(wrapper.get('nav').attributes('data-active')).toBe('/system/users')
|
||||
const groupTitles = wrapper.findAll('[data-kind="group"] header').map((node) => node.text())
|
||||
const paths = wrapper.findAll('[data-kind="item"]').map((node) => node.attributes('data-index'))
|
||||
|
||||
expect(groupTitles).toContain('客户管理')
|
||||
expect(groupTitles).toContain('系统管理')
|
||||
expect(groupTitles).not.toContain('财务管理')
|
||||
expect(paths).not.toContain('/dashboard')
|
||||
expect(paths).toContain('/orders/first-leg')
|
||||
expect(paths).not.toContain('/prototype/reports/overview')
|
||||
expect(paths).toContain('/system/permissions')
|
||||
})
|
||||
|
||||
it('forwards desktop collapse state to Element Plus', () => {
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
props: { items: adminNavigation, activePath: '/', collapsed: true },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
expect(wrapper.get('nav').attributes('data-collapsed')).toBe('true')
|
||||
})
|
||||
|
||||
it('renders only entries included in the filtered navigation input', () => {
|
||||
const items = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
props: { items, activePath: '/', collapsed: false },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('菜单权限')
|
||||
expect(wrapper.text()).not.toContain('首页数据看板')
|
||||
expect(wrapper.text()).not.toContain('客户列表')
|
||||
})
|
||||
|
||||
it('recursively renders three navigation levels', () => {
|
||||
const items: AdminNavigationNode[] = [{
|
||||
key: 'operations',
|
||||
title: '运营管理',
|
||||
icon: adminNavigation[0].icon,
|
||||
children: [{
|
||||
key: 'shipment',
|
||||
title: '运输管理',
|
||||
icon: adminNavigation[0].icon,
|
||||
children: [{
|
||||
key: 'tracking',
|
||||
title: '轨迹管理',
|
||||
icon: adminNavigation[0].icon,
|
||||
path: '/tracking',
|
||||
permission: 'tracking:list',
|
||||
}],
|
||||
}],
|
||||
}]
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
props: { items, activePath: '/tracking', collapsed: false },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('[data-kind="group"]')).toHaveLength(2)
|
||||
expect(wrapper.get('[data-kind="item"]').attributes('data-index')).toBe('/tracking')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { AdminNavigationNode } from '@/core/navigation/adminNavigation'
|
||||
import AdminSidebarMenuNode from './AdminSidebarMenuNode.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
items: AdminNavigationNode[]
|
||||
activePath: string
|
||||
collapsed: boolean
|
||||
}>()
|
||||
|
||||
function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
|
||||
return nodes.flatMap((node) => node.children?.length
|
||||
? [node.key, ...getGroupKeys(node.children)]
|
||||
: [])
|
||||
}
|
||||
|
||||
const defaultOpeneds = computed(() => getGroupKeys(props.items))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-menu
|
||||
class="side-nav-menu"
|
||||
:default-active="activePath"
|
||||
:collapse="collapsed"
|
||||
:default-openeds="defaultOpeneds"
|
||||
router
|
||||
>
|
||||
<AdminSidebarMenuNode v-for="item in items" :key="item.key" :node="item" :level="0" />
|
||||
</el-menu>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminNavigationNode } from '@/core/navigation/adminNavigation'
|
||||
|
||||
defineOptions({ name: 'AdminSidebarMenuNode' })
|
||||
|
||||
defineProps<{
|
||||
node: AdminNavigationNode
|
||||
level?: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-sub-menu v-if="node.children?.length" :index="node.key">
|
||||
<template #title>
|
||||
<component
|
||||
v-if="!level"
|
||||
:is="node.icon"
|
||||
class="menu-icon"
|
||||
:style="{ color: node.iconColor }"
|
||||
:size="20"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
<span>{{ node.title }}</span>
|
||||
</template>
|
||||
|
||||
<AdminSidebarMenuNode v-for="child in node.children" :key="child.key" :node="child" :level="(level ?? 0) + 1" />
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item v-else :index="node.path || node.key">
|
||||
<component
|
||||
v-if="!level"
|
||||
:is="node.icon"
|
||||
class="menu-icon"
|
||||
:style="{ color: node.iconColor }"
|
||||
:size="20"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
<template #title>{{ node.title }}</template>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import AppLayout from './AppLayout.vue'
|
||||
|
||||
vi.mock('element-plus', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('element-plus')>()
|
||||
return { ...actual, ElMessage: { warning: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }
|
||||
})
|
||||
|
||||
async function mountLayout(path = '/') {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.user = { id: '1', name: '平台管理员', account: 'admin', roles: ['PLATFORM_ADMIN'] }
|
||||
auth.permissions = ['platform:warehouse:list']
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<p>page</p>' } },
|
||||
{ path: '/customers', component: { template: '<p>customers</p>' }, meta: { title: '客户管理', showPageHeading: false } },
|
||||
{ path: '/login', component: { template: '<p>login</p>' } },
|
||||
],
|
||||
})
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(AppLayout, {
|
||||
global: { plugins: [pinia, router], stubs: { AdminSidebarMenu: { template: '<nav />' }, 'el-button': { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
|
||||
})
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
describe('AppLayout', () => {
|
||||
beforeEach(() => vi.mocked(ElMessageBox.confirm).mockReset())
|
||||
|
||||
it('shows the authenticated identity without a role preview switcher', async () => {
|
||||
const { wrapper } = await mountLayout()
|
||||
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
|
||||
expect(wrapper.text()).not.toContain('权限预览')
|
||||
})
|
||||
|
||||
it('hides the shell heading when the route opts out', async () => {
|
||||
const { wrapper } = await mountLayout('/customers')
|
||||
expect(wrapper.find('.page-heading').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
|
||||
})
|
||||
|
||||
it('logs out after confirmation', async () => {
|
||||
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never)
|
||||
const { wrapper, router } = await mountLayout()
|
||||
await wrapper.get('[aria-label="退出登录"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { LogOut, Menu } from '@lucide/vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import baiyueLogoWhite from '@/assets/brand/baiyue-logo-white.png'
|
||||
import baiyueMarkWhite from '@/assets/brand/baiyue-mark-white.png'
|
||||
import AdminSidebarMenu from '@/app/components/AdminSidebarMenu.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const unsaved = useUnsavedWorkStore()
|
||||
const collapsed = ref(false)
|
||||
const mobileMenuOpen = ref(false)
|
||||
const pageTitle = computed(() => route.meta.title || '管理后台')
|
||||
const pageDescription = computed(() => route.meta.description || '')
|
||||
const showPageHeading = computed(() => route.meta.showPageHeading !== false)
|
||||
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
|
||||
|
||||
access.initializeAfterLogin()
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定退出管理后台吗?', '退出登录', {
|
||||
confirmButtonText: '退出',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
access.reset()
|
||||
unsaved.clear()
|
||||
try {
|
||||
await auth.logout()
|
||||
} catch (error) {
|
||||
const details = getApiErrorDetails(error, '服务器会话撤销失败')
|
||||
const trackingId = details.requestId || details.traceId
|
||||
ElMessage.warning(`本地已退出,但服务器会话撤销失败${trackingId ? `(追踪标识:${trackingId})` : ''}`)
|
||||
}
|
||||
try {
|
||||
await router.replace('/login')
|
||||
} catch {
|
||||
ElMessage.error('已退出登录,但登录页跳转失败,请刷新页面')
|
||||
}
|
||||
}
|
||||
|
||||
function handleMenuToggle() {
|
||||
if (window.matchMedia('(max-width: 720px)').matches) {
|
||||
mobileMenuOpen.value = !mobileMenuOpen.value
|
||||
return
|
||||
}
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
watch(() => route.fullPath, () => { mobileMenuOpen.value = false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell" :class="{ 'is-collapsed': collapsed, 'is-mobile-open': mobileMenuOpen }">
|
||||
<button v-if="mobileMenuOpen" class="sidebar-scrim" type="button" aria-label="关闭菜单" @click="mobileMenuOpen = false" />
|
||||
<aside class="app-sidebar">
|
||||
<div class="brand-block">
|
||||
<img class="brand-logo brand-logo-full" :src="baiyueLogoWhite" alt="柏悦物流" />
|
||||
<img class="brand-logo brand-logo-mark" :src="baiyueMarkWhite" alt="柏悦物流" />
|
||||
</div>
|
||||
<nav class="side-nav" aria-label="主导航">
|
||||
<AdminSidebarMenu :items="access.filteredNavigation" :active-path="route.path" :collapsed="menuCollapsed" />
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="app-main">
|
||||
<header class="app-header" :class="{ 'app-header--without-page-heading': !showPageHeading }">
|
||||
<button
|
||||
class="shell-menu-button"
|
||||
type="button"
|
||||
:aria-label="mobileMenuOpen ? '关闭菜单' : collapsed ? '展开导航' : '收起导航'"
|
||||
:aria-expanded="mobileMenuOpen || !collapsed"
|
||||
@click="handleMenuToggle"
|
||||
><Menu :size="22" /></button>
|
||||
<div v-if="showPageHeading" class="page-heading"><h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p></div>
|
||||
<div class="header-user">
|
||||
<div class="user-avatar">{{ auth.user?.name.slice(0, 1) }}</div>
|
||||
<div class="user-copy" :aria-label="`登录身份:${auth.user?.name ?? '-'}(${auth.user?.account ?? '-'})`"><strong>登录身份:{{ auth.user?.name }}({{ auth.user?.account }})</strong></div>
|
||||
<el-button text aria-label="退出登录" @click="handleLogout"><LogOut :size="18" /></el-button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="page-container"><RouterView /></main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-header--without-page-heading .header-user { margin-left: auto; }
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ForbiddenView from './ForbiddenView.vue'
|
||||
|
||||
describe('ForbiddenView', () => {
|
||||
it('shows server-account guidance and sanitizes the source path', async () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/403', component: ForbiddenView }] })
|
||||
await router.push({ path: '/403', query: { from: '//example.com' } })
|
||||
await router.isReady()
|
||||
const wrapper = mount(ForbiddenView, { global: { plugins: [pinia, router] } })
|
||||
expect(wrapper.text()).toContain('当前登录账号无法访问此页面')
|
||||
expect(wrapper.text()).toContain('来源页面:/')
|
||||
expect(wrapper.text()).not.toContain('权限预览')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ShieldX } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const sourcePath = computed(() => sanitizeInternalPath(route.query.from))
|
||||
|
||||
async function returnToAccessiblePage() {
|
||||
if (access.firstAccessiblePath) await router.replace(access.firstAccessiblePath)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="forbidden-page" aria-labelledby="forbidden-title">
|
||||
<div class="forbidden-icon" aria-hidden="true"><ShieldX :size="32" /></div>
|
||||
<p class="section-kicker">403 · 权限不足</p>
|
||||
<h2 id="forbidden-title">当前登录账号无法访问此页面</h2>
|
||||
<p>请联系平台管理员核对账号角色和权限配置。</p>
|
||||
<p class="forbidden-source">来源页面:{{ sourcePath }}</p>
|
||||
<el-button v-if="access.firstAccessiblePath" type="primary" @click="returnToAccessiblePage">返回可访问页面</el-button>
|
||||
<p v-else>当前账号暂无可访问的业务菜单。</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { Cable } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
moduleName: string
|
||||
description: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="surface-panel unavailable-panel" role="status" aria-live="polite">
|
||||
<Cable :size="30" aria-hidden="true" />
|
||||
<p class="section-kicker">接口尚未开放</p>
|
||||
<h2>{{ moduleName }}暂不可用</h2>
|
||||
<p>{{ description }}</p>
|
||||
<p class="secondary-text">为避免产生误导,本页面不会展示演示数据,也不会模拟新增、编辑或导入成功。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.unavailable-panel {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 10px;
|
||||
max-width: 720px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.unavailable-panel h2,
|
||||
.unavailable-panel p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Construction, Plus, Search } from '@lucide/vue'
|
||||
|
||||
const route = useRoute()
|
||||
const title = computed(() => route.meta.title || '功能模块')
|
||||
const description = computed(() => route.meta.description || '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="module-page">
|
||||
<div class="module-toolbar">
|
||||
<el-input placeholder="输入关键词搜索" clearable>
|
||||
<template #prefix><Search :size="17" /></template>
|
||||
</el-input>
|
||||
<el-button type="primary"><Plus :size="17" /> 新增</el-button>
|
||||
</div>
|
||||
|
||||
<div class="module-card">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon"><Construction :size="28" /></div>
|
||||
<h2>{{ title }}</h2>
|
||||
<p>{{ description }}</p>
|
||||
<el-tag effect="plain">页面骨架已完成,业务接口待接入</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { adminNavigation, getNavigationLeaves } from '@/core/navigation/adminNavigation'
|
||||
import router from './index'
|
||||
|
||||
describe('admin router access metadata', () => {
|
||||
it('uses the root route only as a permission-aware entry point while retaining the dashboard prototype', () => {
|
||||
const home = router.getRoutes().find((route) => route.name === 'home')
|
||||
|
||||
expect(home?.path).toBe('/')
|
||||
expect(home?.meta.permission).toBeUndefined()
|
||||
expect(router.getRoutes().some((route) => route.name === 'dashboard')).toBe(true)
|
||||
})
|
||||
|
||||
it('protects warehouse management with its stable list permission', () => {
|
||||
const warehouseRoute = router.getRoutes().find((route) => route.name === 'warehouses')
|
||||
|
||||
expect(warehouseRoute?.path).toBe('/warehouses')
|
||||
expect(warehouseRoute?.meta.permission).toBe('platform:warehouse:list')
|
||||
})
|
||||
|
||||
it('protects express company records with a stable master-data permission', () => {
|
||||
const route = router.getRoutes().find((item) => item.name === 'express-companies')
|
||||
|
||||
expect(route?.path).toBe('/master-data/express-companies')
|
||||
expect(route?.meta.permission).toBe('platform:express:list')
|
||||
})
|
||||
|
||||
it('protects counterparty records with a stable master-data permission', () => {
|
||||
const route = router.getRoutes().find((item) => item.name === 'counterparties')
|
||||
expect(route?.path).toBe('/master-data/counterparties')
|
||||
expect(route?.meta.permission).toBe('platform:counterparty:list')
|
||||
})
|
||||
|
||||
it('protects the download center with its stable list permission', () => {
|
||||
const route = router.getRoutes().find((item) => item.name === 'download-center')
|
||||
expect(route?.path).toBe('/downloads')
|
||||
expect(route?.meta.permission).toBe('platform:download:list')
|
||||
})
|
||||
|
||||
it('protects enterprise reviews with the backend review menu permission', () => {
|
||||
const route = router.getRoutes().find((item) => item.name === 'enterprise-reviews')
|
||||
|
||||
expect(route?.path).toBe('/customers/reviews')
|
||||
expect(route?.meta.permission).toBe('platform:customer:review')
|
||||
})
|
||||
|
||||
it('keeps forbidden inside the authenticated layout without a business permission', () => {
|
||||
const authenticatedLayout = router.options.routes.find((route) => route.path === '/')
|
||||
const forbidden = authenticatedLayout?.children?.find((route) => route.name === 'forbidden')
|
||||
const resolved = router.resolve('/403')
|
||||
|
||||
expect(authenticatedLayout?.meta?.requiresAuth).toBe(true)
|
||||
expect(forbidden?.path).toBe('403')
|
||||
expect(forbidden?.meta?.permission).toBeUndefined()
|
||||
expect(resolved.matched.some((record) => record.meta.requiresAuth)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps visible navigation paths and permissions aligned with routes', () => {
|
||||
for (const item of getNavigationLeaves(adminNavigation)) {
|
||||
const resolved = router.resolve(item.path)
|
||||
const pageRoute = resolved.matched.at(-1)
|
||||
|
||||
expect(pageRoute, `missing route for ${item.path}`).toBeDefined()
|
||||
expect(pageRoute?.meta.permission, `permission mismatch for ${item.path}`).toBe(item.permission)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import AppLayout from '@/app/layouts/AppLayout.vue'
|
||||
import { decideRouteAccess } from '@/core/routing/accessDecision'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { downloadRoutes } from '@/modules/downloads/routes'
|
||||
import { firstLegOrderRoutes } from '@/modules/first-leg-orders/routes'
|
||||
import { iamRoutes } from '@/modules/iam/routes'
|
||||
import { masterDataRoutes } from '@/modules/master-data/routes'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { customerRoutes } from '@/modules/customers/routes'
|
||||
import { warehouseRoutes } from '@/modules/warehouses/routes'
|
||||
import { prototypeModuleRoutes } from '@/prototype/routes'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/modules/auth/pages/LoginView.vue'),
|
||||
meta: { title: '登录' },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: AppLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'home',
|
||||
component: () => import('@/app/pages/ModuleView.vue'),
|
||||
meta: { title: '进入管理后台', description: '正在进入当前角色可访问的业务页面' },
|
||||
},
|
||||
...prototypeModuleRoutes,
|
||||
...customerRoutes,
|
||||
...warehouseRoutes,
|
||||
...firstLegOrderRoutes,
|
||||
...masterDataRoutes,
|
||||
...downloadRoutes,
|
||||
...iamRoutes,
|
||||
{
|
||||
path: '403',
|
||||
name: 'forbidden',
|
||||
component: () => import('@/app/pages/ForbiddenView.vue'),
|
||||
meta: { title: '权限不足', description: '当前登录账号没有访问此页面的权限' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
document.title = to.meta.title ? `${to.meta.title} - 柏悦物流管理后台` : '柏悦物流管理后台'
|
||||
const auth = useAuthStore()
|
||||
if (to.name === 'login' && auth.isAuthenticated) {
|
||||
return { name: 'home' }
|
||||
}
|
||||
if (!auth.isAuthenticated && !to.meta.requiresAuth) return true
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
if (auth.isAuthenticated) access.initializeAfterLogin()
|
||||
const decision = decideRouteAccess({
|
||||
requiresAuth: to.meta.requiresAuth,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
fullPath: to.fullPath,
|
||||
permission: to.meta.permission,
|
||||
hasPermission: to.meta.permission ? access.can(to.meta.permission) : true,
|
||||
})
|
||||
|
||||
if (decision.type === 'login') {
|
||||
return { name: 'login', query: { redirect: decision.redirect } }
|
||||
}
|
||||
if (decision.type === 'forbidden') {
|
||||
return { name: 'forbidden', query: { from: decision.from }, replace: true }
|
||||
}
|
||||
if (to.name === 'home') {
|
||||
return access.firstAccessiblePath
|
||||
? { path: access.firstAccessiblePath, replace: true }
|
||||
: { name: 'forbidden', replace: true }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
import 'vue-router'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string
|
||||
description?: string
|
||||
showPageHeading?: boolean
|
||||
requiresAuth?: boolean
|
||||
permission?: string
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user