优化
This commit is contained in:
@@ -9,7 +9,6 @@ const visibleNavigation = filterAdminNavigation(adminNavigation, new Set([
|
||||
'platform:firstleg:inbound:list',
|
||||
'platform:location:list',
|
||||
'platform:pallet:list',
|
||||
'firstleg:fba-pricing:rule:view',
|
||||
'firstleg:fba-pricing:rate:view',
|
||||
'platform:express:list',
|
||||
'platform:counterparty:list',
|
||||
@@ -49,11 +48,11 @@ describe('AdminSidebarMenu', () => {
|
||||
expect(groupTitles).toContain('供应商中心')
|
||||
expect(groupTitles).toContain('系统管理')
|
||||
expect(groupTitles).not.toContain('财务管理')
|
||||
expect(paths).not.toContain('/dashboard')
|
||||
expect(paths).toContain('/dashboard')
|
||||
expect(paths).toContain('/inbounds')
|
||||
expect(paths).toContain('/master-data/express-companies')
|
||||
expect(paths).not.toContain('/orders/first-leg')
|
||||
expect(paths).toContain('/first-leg-pricing/customer-rules')
|
||||
expect(paths).not.toContain('/first-leg-pricing/customer-rules')
|
||||
expect(paths).toContain('/downloads')
|
||||
expect(paths).not.toContain('/prototype/reports/overview')
|
||||
expect(paths).toContain('/system/permissions')
|
||||
@@ -76,7 +75,7 @@ describe('AdminSidebarMenu', () => {
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('菜单权限')
|
||||
expect(wrapper.text()).not.toContain('首页数据看板')
|
||||
expect(wrapper.text()).toContain('首页数据看板')
|
||||
expect(wrapper.text()).not.toContain('客户列表')
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||
import WorkspaceTabs from './WorkspaceTabs.vue'
|
||||
|
||||
vi.mock('element-plus', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('element-plus')>()
|
||||
return { ...actual, ElMessage: { error: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }
|
||||
})
|
||||
|
||||
async function mountTabs() {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.user = { id: '1', name: '平台管理员', account: 'admin', roles: ['PLATFORM_ADMIN'] }
|
||||
auth.permissions = ['platform:customer:list', 'platform:warehouse:list']
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/customers', name: 'customers', component: { template: '<p />' }, meta: { requiresAuth: true, title: '客户管理' } },
|
||||
{ path: '/warehouses', name: 'warehouses', component: { template: '<p />' }, meta: { requiresAuth: true, title: '仓库管理' } },
|
||||
],
|
||||
})
|
||||
await router.push('/customers')
|
||||
await router.isReady()
|
||||
const workspace = useWorkspaceTabsStore()
|
||||
workspace.initialize('1')
|
||||
workspace.open({ id: 'warehouses', title: '仓库管理', path: '/warehouses' })
|
||||
workspace.open({ id: 'customers', title: '客户管理', path: '/customers' })
|
||||
const wrapper = mount(WorkspaceTabs, { attachTo: document.body, global: { plugins: [pinia, router, ElementPlus] } })
|
||||
return { wrapper, router, workspace, unsaved: useUnsavedWorkStore() }
|
||||
}
|
||||
|
||||
describe('WorkspaceTabs', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.mocked(ElMessageBox.confirm).mockReset()
|
||||
})
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('keeps a dirty tab open when discard confirmation is cancelled', async () => {
|
||||
const { wrapper, workspace, unsaved } = await mountTabs()
|
||||
unsaved.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null, workspaceTabId: 'customers' })
|
||||
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
|
||||
|
||||
await wrapper.get('[aria-label="关闭页签:客户管理"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(workspace.tabs.map((tab) => tab.id)).toContain('customers')
|
||||
expect(wrapper.find('.workspace-tab.is-dirty').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('closes confirmed dirty work and activates the most recent remaining tab', async () => {
|
||||
const { wrapper, router, workspace, unsaved } = await mountTabs()
|
||||
unsaved.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null, workspaceTabId: 'customers' })
|
||||
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never)
|
||||
|
||||
await wrapper.get('[aria-label="关闭页签:客户管理"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(workspace.tabs.map((tab) => tab.id)).toEqual(['warehouses'])
|
||||
expect(router.currentRoute.value.path).toBe('/warehouses')
|
||||
expect(unsaved.findForWorkspace('customers')).toEqual([])
|
||||
})
|
||||
|
||||
it('uses the mouse wheel to scroll an overflowing tab list horizontally', async () => {
|
||||
const { wrapper } = await mountTabs()
|
||||
const tabList = wrapper.get<HTMLElement>('.workspace-tab-list')
|
||||
Object.defineProperties(tabList.element, {
|
||||
clientWidth: { configurable: true, value: 320 },
|
||||
scrollWidth: { configurable: true, value: 720 },
|
||||
})
|
||||
|
||||
await tabList.trigger('wheel', { deltaY: 120 })
|
||||
|
||||
expect(tabList.element.scrollLeft).toBe(120)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { MoreHorizontal, X } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||
import { workspaceTabCandidate } from '@/core/workspace-tabs/routeWorkspace'
|
||||
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||
|
||||
type CloseCommand = 'current' | 'others' | 'right' | 'all'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const unsaved = useUnsavedWorkStore()
|
||||
const workspace = useWorkspaceTabsStore()
|
||||
const tabListRef = ref<HTMLElement>()
|
||||
|
||||
const activeIndex = computed(() => workspace.tabs.findIndex((tab) => tab.id === workspace.activeId))
|
||||
const hasRightTabs = computed(() => activeIndex.value >= 0 && activeIndex.value < workspace.tabs.length - 1)
|
||||
|
||||
function blockingEntries(tabIds: readonly string[]) {
|
||||
return tabIds.flatMap((tabId) => unsaved.findForWorkspace(tabId))
|
||||
}
|
||||
|
||||
async function confirmDiscard(tabIds: readonly string[]) {
|
||||
const blocking = blockingEntries(tabIds)
|
||||
if (blocking.length === 0) return true
|
||||
const labels = [...new Set(blocking.map((entry) => entry.label))]
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`以下内容尚未保存:${labels.join('、')}。关闭页签后将放弃这些修改。`,
|
||||
'放弃未保存的修改?',
|
||||
{ confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑', type: 'warning' },
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function focusActiveTab() {
|
||||
await nextTick()
|
||||
const active = tabListRef.value?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
|
||||
active?.focus({ preventScroll: true })
|
||||
active?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
|
||||
}
|
||||
|
||||
async function navigateToFallback(path: string | null) {
|
||||
const destination = path || access.firstAccessiblePath
|
||||
if (!destination) {
|
||||
await router.push('/403')
|
||||
return
|
||||
}
|
||||
if (destination === route.fullPath) {
|
||||
const candidate = workspaceTabCandidate(route)
|
||||
if (candidate) workspace.open(candidate, unsaved.dirtyWorkspaceIds)
|
||||
await focusActiveTab()
|
||||
return
|
||||
}
|
||||
await router.push(destination)
|
||||
}
|
||||
|
||||
async function closeTabs(tabIds: readonly string[]) {
|
||||
const uniqueIds = [...new Set(tabIds)].filter((id) => workspace.tabs.some((tab) => tab.id === id))
|
||||
if (uniqueIds.length === 0 || !await confirmDiscard(uniqueIds)) return
|
||||
const activeWillClose = workspace.activeId !== null && uniqueIds.includes(workspace.activeId)
|
||||
unsaved.removeWorkspaces(uniqueIds)
|
||||
const fallback = workspace.removeByIds(uniqueIds)
|
||||
if (activeWillClose) await navigateToFallback(fallback?.path ?? null)
|
||||
else await focusActiveTab()
|
||||
}
|
||||
|
||||
async function activateTab(path: string) {
|
||||
if (path === route.fullPath) return
|
||||
try {
|
||||
await router.push(path)
|
||||
} catch {
|
||||
ElMessage.error('页签切换失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
function idsForCommand(command: CloseCommand) {
|
||||
if (command === 'all') return workspace.tabs.map((tab) => tab.id)
|
||||
if (command === 'others') return workspace.tabs.filter((tab) => tab.id !== workspace.activeId).map((tab) => tab.id)
|
||||
if (command === 'right') return workspace.tabs.slice(activeIndex.value + 1).map((tab) => tab.id)
|
||||
return workspace.activeId ? [workspace.activeId] : []
|
||||
}
|
||||
|
||||
function handleCommand(command: CloseCommand) {
|
||||
void closeTabs(idsForCommand(command))
|
||||
}
|
||||
|
||||
function handleTabKeydown(event: KeyboardEvent, index: number) {
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
||||
event.preventDefault()
|
||||
const buttons = [...(tabListRef.value?.querySelectorAll<HTMLElement>('[role="tab"]') ?? [])]
|
||||
if (buttons.length === 0) return
|
||||
const direction = event.key === 'ArrowRight' ? 1 : -1
|
||||
buttons[(index + direction + buttons.length) % buttons.length]?.focus()
|
||||
}
|
||||
|
||||
function handleTabWheel(event: WheelEvent) {
|
||||
const tabList = tabListRef.value
|
||||
if (!tabList || tabList.scrollWidth <= tabList.clientWidth) return
|
||||
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
|
||||
if (delta === 0) return
|
||||
const maxScrollLeft = tabList.scrollWidth - tabList.clientWidth
|
||||
const canScroll = delta < 0 ? tabList.scrollLeft > 0 : tabList.scrollLeft < maxScrollLeft
|
||||
if (!canScroll) return
|
||||
event.preventDefault()
|
||||
tabList.scrollLeft = Math.max(0, Math.min(maxScrollLeft, tabList.scrollLeft + delta))
|
||||
}
|
||||
|
||||
watch(
|
||||
() => workspace.activeId,
|
||||
async () => {
|
||||
await nextTick()
|
||||
const active = tabListRef.value?.querySelector<HTMLElement>('[aria-selected="true"]')
|
||||
active?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace-tabs" aria-label="页面工作区">
|
||||
<div ref="tabListRef" class="workspace-tab-list" role="tablist" aria-label="已打开页面" @wheel="handleTabWheel">
|
||||
<div
|
||||
v-for="(tab, index) in workspace.tabs"
|
||||
:key="tab.id"
|
||||
class="workspace-tab"
|
||||
:class="{ 'is-active': tab.id === workspace.activeId, 'is-dirty': unsaved.dirtyWorkspaceIds.has(tab.id) }"
|
||||
>
|
||||
<button
|
||||
class="workspace-tab-activate"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="tab.id === workspace.activeId"
|
||||
:tabindex="tab.id === workspace.activeId ? 0 : -1"
|
||||
:title="tab.title"
|
||||
@click="activateTab(tab.path)"
|
||||
@keydown="handleTabKeydown($event, index)"
|
||||
>
|
||||
<span class="workspace-tab-label">{{ tab.title }}</span>
|
||||
<span v-if="unsaved.dirtyWorkspaceIds.has(tab.id)" class="workspace-tab-dirty" aria-label="有未保存修改" />
|
||||
</button>
|
||||
<button
|
||||
class="workspace-tab-close"
|
||||
type="button"
|
||||
:aria-label="`关闭页签:${tab.title}`"
|
||||
:title="`关闭 ${tab.title}`"
|
||||
@click="closeTabs([tab.id])"
|
||||
><X :size="14" aria-hidden="true" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dropdown trigger="click" placement="bottom-end" @command="handleCommand">
|
||||
<el-button class="workspace-tab-more" text aria-label="页签管理" title="页签管理">
|
||||
<MoreHorizontal :size="18" aria-hidden="true" />
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="current" :disabled="!workspace.activeId">关闭当前</el-dropdown-item>
|
||||
<el-dropdown-item command="others" :disabled="workspace.tabs.length <= 1">关闭其他</el-dropdown-item>
|
||||
<el-dropdown-item command="right" :disabled="!hasRightTabs">关闭右侧</el-dropdown-item>
|
||||
<el-dropdown-item command="all" :disabled="workspace.tabs.length === 0" divided>关闭全部</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workspace-tabs {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
z-index: 14;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
height: 44px;
|
||||
align-items: stretch;
|
||||
border-bottom: 1px solid var(--cc-border);
|
||||
background: #f7f9fc;
|
||||
box-shadow: 0 5px 14px rgba(23, 42, 70, .045);
|
||||
}
|
||||
.workspace-tab-list {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding: 6px 8px 0;
|
||||
overscroll-behavior-inline: contain;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.workspace-tab-list::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
||||
.workspace-tab {
|
||||
display: flex;
|
||||
min-width: 126px;
|
||||
max-width: 220px;
|
||||
height: 38px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 0;
|
||||
border-radius: 8px 8px 0 0;
|
||||
color: #526178;
|
||||
}
|
||||
.workspace-tab:hover { background: #eef3fa; color: #24344d; }
|
||||
.workspace-tab.is-active { border-color: var(--cc-border); background: #fff; color: #172033; }
|
||||
.workspace-tab-activate {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 4px 0 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.workspace-tab-label { overflow: hidden; font-size: 12px; font-weight: 620; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workspace-tab-dirty { width: 6px; height: 6px; flex: 0 0 auto; border-radius: 50%; background: #d97706; }
|
||||
.workspace-tab-close {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 30px;
|
||||
place-items: center;
|
||||
margin-right: 3px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #7b8799;
|
||||
cursor: pointer;
|
||||
}
|
||||
.workspace-tab-close:hover { background: #dde6f2; color: #26364d; }
|
||||
.workspace-tab-activate:focus-visible,
|
||||
.workspace-tab-close:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
|
||||
.workspace-tab-more { width: 44px; height: 44px; flex: 0 0 44px; border-radius: 0; border-left: 1px solid var(--cc-border); color: #526178; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.workspace-tabs { top: 64px; height: 48px; }
|
||||
.workspace-tab-list { align-items: center; padding: 4px 6px; }
|
||||
.workspace-tab { min-width: 140px; height: 40px; border-bottom: 1px solid transparent; border-radius: 8px; }
|
||||
.workspace-tab.is-active { border-color: #cbd7e7; }
|
||||
.workspace-tab-close { width: 34px; height: 34px; flex-basis: 34px; }
|
||||
.workspace-tab-more { height: 48px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.workspace-tab-list { scroll-behavior: auto; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,20 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
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'
|
||||
|
||||
const CachedPage = defineComponent({
|
||||
setup() {
|
||||
const count = ref(0)
|
||||
return { count }
|
||||
},
|
||||
template: '<button class="cached-count" @click="count += 1">{{ count }}</button>',
|
||||
})
|
||||
|
||||
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() } }
|
||||
@@ -21,15 +30,16 @@ async function mountLayout(path = '/') {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<p>page</p>' } },
|
||||
{ path: '/customers', component: { template: '<p>customers</p>' }, meta: { title: '客户管理', showPageHeading: false } },
|
||||
{ path: '/', name: 'home', component: { template: '<p>page</p>' }, meta: { requiresAuth: true, workspaceTab: false, keepAlive: false } },
|
||||
{ path: '/customers', name: 'customers', component: CachedPage, meta: { title: '客户管理', showPageHeading: false, requiresAuth: true } },
|
||||
{ path: '/warehouses', name: 'warehouses', component: { template: '<p>warehouses</p>' }, meta: { title: '仓库管理', requiresAuth: true } },
|
||||
{ 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>' } } },
|
||||
global: { plugins: [pinia, router], stubs: { AdminSidebarMenu: { template: '<nav />' }, WorkspaceTabs: { template: '<div class="workspace-tabs-stub" />' }, 'el-button': { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
|
||||
})
|
||||
return { wrapper, router }
|
||||
}
|
||||
@@ -56,4 +66,18 @@ describe('AppLayout', () => {
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
|
||||
it('opens business routes as workspace tabs and keeps route component state while switching', async () => {
|
||||
const { wrapper, router } = await mountLayout('/customers')
|
||||
expect(wrapper.find('.workspace-tabs-stub').exists()).toBe(true)
|
||||
await wrapper.get('.cached-count').trigger('click')
|
||||
expect(wrapper.get('.cached-count').text()).toBe('1')
|
||||
|
||||
await router.push('/warehouses')
|
||||
await flushPromises()
|
||||
await router.push('/customers')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.cached-count').text()).toBe('1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,15 +7,19 @@ 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 WorkspaceTabs from '@/app/components/WorkspaceTabs.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||
import { canAccessWorkspaceRoute, workspaceRouteId, workspaceTabCandidate } from '@/core/workspace-tabs/routeWorkspace'
|
||||
import { MAX_CACHED_WORKSPACE_VIEWS, useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const unsaved = useUnsavedWorkStore()
|
||||
const workspace = useWorkspaceTabsStore()
|
||||
const collapsed = ref(false)
|
||||
const mobileMenuOpen = ref(false)
|
||||
const pageTitle = computed(() => route.meta.title || '管理后台')
|
||||
@@ -24,10 +28,38 @@ const showPageHeading = computed(() => route.meta.showPageHeading !== false)
|
||||
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
|
||||
|
||||
access.initializeAfterLogin()
|
||||
workspace.initialize(auth.user?.id || auth.user?.account || '')
|
||||
|
||||
function syncCurrentRoute() {
|
||||
const candidate = workspaceTabCandidate(route)
|
||||
if (candidate) workspace.open(candidate, unsaved.dirtyWorkspaceIds)
|
||||
}
|
||||
|
||||
async function pruneInaccessibleTabs() {
|
||||
const inaccessible = workspace.tabs.filter((tab) => !canAccessWorkspaceRoute(router.resolve(tab.path), access.permissionCodes))
|
||||
const discardedDirtyWork = inaccessible.flatMap((tab) => unsaved.findForWorkspace(tab.id))
|
||||
if (discardedDirtyWork.length) {
|
||||
await ElMessageBox.alert(
|
||||
`账号权限已变化,以下未保存内容无法继续编辑:${[...new Set(discardedDirtyWork.map((entry) => entry.label))].join('、')}。相关页签将关闭。`,
|
||||
'权限已变化',
|
||||
{ confirmButtonText: '知道了', type: 'warning' },
|
||||
).catch(() => undefined)
|
||||
}
|
||||
const previousActiveId = workspace.activeId
|
||||
const result = workspace.prune((tab) => canAccessWorkspaceRoute(router.resolve(tab.path), access.permissionCodes))
|
||||
if (result.removedIds.length === 0) return
|
||||
unsaved.removeWorkspaces(result.removedIds)
|
||||
if (!previousActiveId || !result.removedIds.includes(previousActiveId)) return
|
||||
const destination = result.fallback?.path || access.firstAccessiblePath || '/403'
|
||||
if (destination !== route.fullPath) await router.replace(destination)
|
||||
else syncCurrentRoute()
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定退出管理后台吗?', '退出登录', {
|
||||
const dirtyLabels = unsaved.entries.filter((entry) => entry.dirty).map((entry) => entry.label)
|
||||
const dirtyNotice = dirtyLabels.length ? ` 当前还有未保存内容:${[...new Set(dirtyLabels)].join('、')},退出后将丢失。` : ''
|
||||
await ElMessageBox.confirm(`确定退出管理后台吗?${dirtyNotice}`, '退出登录', {
|
||||
confirmButtonText: '退出',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
@@ -37,6 +69,7 @@ async function handleLogout() {
|
||||
|
||||
access.reset()
|
||||
unsaved.clear()
|
||||
workspace.reset(true)
|
||||
try {
|
||||
await auth.logout()
|
||||
} catch (error) {
|
||||
@@ -59,7 +92,17 @@ function handleMenuToggle() {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
watch(() => route.fullPath, () => { mobileMenuOpen.value = false })
|
||||
watch(() => route.fullPath, () => {
|
||||
mobileMenuOpen.value = false
|
||||
syncCurrentRoute()
|
||||
}, { immediate: true })
|
||||
watch(() => access.permissionSignature, () => { void pruneInaccessibleTabs() }, { immediate: true })
|
||||
watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId) => {
|
||||
if (!userId || userId === previousUserId) return
|
||||
workspace.reset(false)
|
||||
workspace.initialize(userId)
|
||||
void pruneInaccessibleTabs().then(syncCurrentRoute)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -91,7 +134,19 @@ watch(() => route.fullPath, () => { mobileMenuOpen.value = false })
|
||||
<el-button text aria-label="退出登录" @click="handleLogout"><LogOut :size="18" /></el-button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="page-container"><RouterView /></main>
|
||||
<WorkspaceTabs v-if="workspace.tabs.length" />
|
||||
<main class="page-container">
|
||||
<RouterView v-slot="{ Component, route: renderedRoute }">
|
||||
<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="renderedRoute.meta.keepAlive !== false"
|
||||
:key="workspace.cacheKey(workspaceRouteId(renderedRoute))"
|
||||
/>
|
||||
</KeepAlive>
|
||||
<component :is="Component" v-if="renderedRoute.meta.keepAlive === false" />
|
||||
</RouterView>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,12 +3,14 @@ import { adminNavigation, getNavigationLeaves } from '@/core/navigation/adminNav
|
||||
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', () => {
|
||||
it('uses the root route as a permission-aware entry point and registers the formal dashboard', () => {
|
||||
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)
|
||||
const dashboard = router.getRoutes().find((route) => route.name === 'dashboard')
|
||||
expect(dashboard?.path).toBe('/dashboard')
|
||||
expect(dashboard?.meta.permission).toBeUndefined()
|
||||
})
|
||||
|
||||
it('protects warehouse management with its stable list permission', () => {
|
||||
@@ -22,7 +24,6 @@ describe('admin router access metadata', () => {
|
||||
const expected = [
|
||||
['inbound-orders', '/inbounds', 'platform:firstleg:inbound:list'],
|
||||
['inbound-order-detail', '/inbounds/:inboundId', 'platform:firstleg:inbound:view'],
|
||||
['fba-customer-rules', '/first-leg-pricing/customer-rules', 'firstleg:fba-pricing:rule:view'],
|
||||
['fba-carrier-rates', '/first-leg-pricing/carrier-rates', 'firstleg:fba-pricing:rate:view'],
|
||||
['locations', '/master-data/locations', 'platform:location:list'],
|
||||
['pallets', '/master-data/pallets', 'platform:pallet:list'],
|
||||
@@ -35,6 +36,14 @@ describe('admin router access metadata', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not register removed duplicate pricing and FBA placeholder pages', () => {
|
||||
const removedRouteNames = ['fba-customer-rules', 'general-fba', 'general-voyage']
|
||||
|
||||
for (const name of removedRouteNames) {
|
||||
expect(router.getRoutes().some((route) => route.name === name)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('protects express company records with a stable master-data permission', () => {
|
||||
const route = router.getRoutes().find((item) => item.name === 'express-companies')
|
||||
|
||||
|
||||
+38
-2
@@ -1,4 +1,5 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import AppLayout from '@/app/layouts/AppLayout.vue'
|
||||
import { decideRouteAccess } from '@/core/routing/accessDecision'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -10,7 +11,11 @@ import { iamRoutes } from '@/modules/iam/routes'
|
||||
import { masterDataRoutes } from '@/modules/master-data/routes'
|
||||
import { warehouseResourceRoutes } from '@/modules/warehouse-resources/routes'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||
import { canAccessWorkspaceRoute, isWorkspaceRoute, workspaceRouteId } from '@/core/workspace-tabs/routeWorkspace'
|
||||
import { readStoredActiveWorkspacePath, useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||
import { customerRoutes } from '@/modules/customers/routes'
|
||||
import { dashboardRoutes } from '@/modules/dashboard/routes'
|
||||
import { warehouseRoutes } from '@/modules/warehouses/routes'
|
||||
import { prototypeModuleRoutes } from '@/prototype/routes'
|
||||
|
||||
@@ -32,8 +37,9 @@ const router = createRouter({
|
||||
path: '',
|
||||
name: 'home',
|
||||
component: () => import('@/app/pages/ModuleView.vue'),
|
||||
meta: { title: '进入管理后台', description: '正在进入当前角色可访问的业务页面' },
|
||||
meta: { title: '进入管理后台', description: '正在进入当前角色可访问的业务页面', workspaceTab: false, keepAlive: false },
|
||||
},
|
||||
...dashboardRoutes,
|
||||
...prototypeModuleRoutes,
|
||||
...customerRoutes,
|
||||
...warehouseRoutes,
|
||||
@@ -48,7 +54,7 @@ const router = createRouter({
|
||||
path: '403',
|
||||
name: 'forbidden',
|
||||
component: () => import('@/app/pages/ForbiddenView.vue'),
|
||||
meta: { title: '权限不足', description: '当前登录账号没有访问此页面的权限' },
|
||||
meta: { title: '权限不足', description: '当前登录账号没有访问此页面的权限', workspaceTab: false, keepAlive: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -56,6 +62,29 @@ const router = createRouter({
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async (to, from) => {
|
||||
const fromId = workspaceRouteId(from)
|
||||
if (!fromId || to.fullPath === from.fullPath) return true
|
||||
const isNonDestructiveWorkspaceSwitch = isWorkspaceRoute(to) && workspaceRouteId(to) !== fromId
|
||||
if (isNonDestructiveWorkspaceSwitch) return true
|
||||
|
||||
const unsaved = useUnsavedWorkStore()
|
||||
const blocking = unsaved.findForWorkspace(fromId)
|
||||
if (blocking.length === 0) return true
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`当前页面还有未保存内容:${[...new Set(blocking.map((entry) => entry.label))].join('、')}。继续后将放弃这些修改。`,
|
||||
'放弃未保存的修改?',
|
||||
{ confirmButtonText: '放弃并继续', cancelButtonText: '继续编辑', type: 'warning' },
|
||||
)
|
||||
unsaved.removeWorkspaces([fromId])
|
||||
useWorkspaceTabsStore().bumpCacheEpoch(fromId)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
document.title = to.meta.title ? `${to.meta.title} - 柏悦物流管理后台` : '柏悦物流管理后台'
|
||||
const auth = useAuthStore()
|
||||
@@ -87,6 +116,13 @@ router.beforeEach((to) => {
|
||||
return { name: 'forbidden', query: { from: decision.from }, replace: true }
|
||||
}
|
||||
if (to.name === 'home') {
|
||||
const restoredPath = auth.user?.id ? readStoredActiveWorkspacePath(auth.user.id) : null
|
||||
if (restoredPath) {
|
||||
const restoredRoute = router.resolve(restoredPath)
|
||||
if (canAccessWorkspaceRoute(restoredRoute, access.permissionCodes)) {
|
||||
return { path: restoredPath, replace: true }
|
||||
}
|
||||
}
|
||||
return access.firstAccessiblePath
|
||||
? { path: access.firstAccessiblePath, replace: true }
|
||||
: { name: 'forbidden', replace: true }
|
||||
|
||||
Reference in New Issue
Block a user