优化
This commit is contained in:
+16
@@ -0,0 +1,16 @@
|
||||
import 'vue-router'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string
|
||||
description?: string
|
||||
requiresAuth?: boolean
|
||||
permission?: string
|
||||
permissionAnyOf?: string[]
|
||||
showPageHeading?: boolean
|
||||
workspaceTab?: boolean
|
||||
keepAlive?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { RouteLocationNormalizedLoaded, RouteLocationResolved } from 'vue-router'
|
||||
import type { WorkspaceTabCandidate } from './types'
|
||||
|
||||
type WorkspaceRoute = RouteLocationNormalizedLoaded | RouteLocationResolved
|
||||
|
||||
export function workspaceRouteId(route: WorkspaceRoute) {
|
||||
return route.name === undefined || route.name === null ? null : String(route.name)
|
||||
}
|
||||
|
||||
export function isWorkspaceRoute(route: WorkspaceRoute) {
|
||||
return Boolean(
|
||||
workspaceRouteId(route)
|
||||
&& route.meta.workspaceTab !== false
|
||||
&& route.matched.some((record) => record.meta.requiresAuth),
|
||||
)
|
||||
}
|
||||
|
||||
export function canAccessWorkspaceRoute(route: WorkspaceRoute, permissions: ReadonlySet<string>) {
|
||||
if (!isWorkspaceRoute(route)) return false
|
||||
const permission = typeof route.meta.permission === 'string' ? route.meta.permission : undefined
|
||||
const alternatives = route.meta.permissionAnyOf ?? []
|
||||
if (!permission && alternatives.length === 0) return true
|
||||
return Boolean(
|
||||
(permission && permissions.has(permission))
|
||||
|| alternatives.some((code) => permissions.has(code)),
|
||||
)
|
||||
}
|
||||
|
||||
export function workspaceTabCandidate(route: WorkspaceRoute): WorkspaceTabCandidate | null {
|
||||
const id = workspaceRouteId(route)
|
||||
if (!id || !isWorkspaceRoute(route)) return null
|
||||
return {
|
||||
id,
|
||||
title: typeof route.meta.title === 'string' && route.meta.title.trim() ? route.meta.title.trim() : '未命名页面',
|
||||
path: route.fullPath,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface WorkspaceTab {
|
||||
id: string
|
||||
title: string
|
||||
path: string
|
||||
lastVisitedAt: number
|
||||
}
|
||||
|
||||
export interface WorkspaceTabCandidate {
|
||||
id: string
|
||||
title: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface WorkspaceTabsSnapshot {
|
||||
tabs: WorkspaceTab[]
|
||||
activeId: string | null
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { WORKSPACE_TAB_LIMIT, readWorkspaceTabsSnapshot, useWorkspaceTabsStore } from './workspaceTabs'
|
||||
|
||||
describe('workspace tabs store', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('keeps one tab per route name and restores it only for the same account', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValueOnce(10).mockReturnValueOnce(20)
|
||||
const store = useWorkspaceTabsStore()
|
||||
store.initialize('user-a')
|
||||
store.open({ id: 'customers', title: '客户管理', path: '/customers' })
|
||||
store.open({ id: 'customers', title: '客户详情', path: '/customers/10001' })
|
||||
|
||||
expect(store.tabs).toEqual([{ id: 'customers', title: '客户详情', path: '/customers/10001', lastVisitedAt: 20 }])
|
||||
expect(readWorkspaceTabsSnapshot('user-a').activeId).toBe('customers')
|
||||
expect(readWorkspaceTabsSnapshot('user-b').tabs).toEqual([])
|
||||
})
|
||||
|
||||
it('evicts the least recently used clean inactive tab at the soft limit', () => {
|
||||
let now = 0
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => ++now)
|
||||
const store = useWorkspaceTabsStore()
|
||||
store.initialize('user-a')
|
||||
for (let index = 0; index < WORKSPACE_TAB_LIMIT; index += 1) {
|
||||
store.open({ id: `route-${index}`, title: `页面 ${index}`, path: `/route-${index}` })
|
||||
}
|
||||
|
||||
store.open({ id: 'route-new', title: '新页面', path: '/route-new' }, new Set(['route-0']))
|
||||
|
||||
expect(store.tabs).toHaveLength(WORKSPACE_TAB_LIMIT)
|
||||
expect(store.tabs.some((tab) => tab.id === 'route-0')).toBe(true)
|
||||
expect(store.tabs.some((tab) => tab.id === 'route-1')).toBe(false)
|
||||
expect(store.activeId).toBe('route-new')
|
||||
})
|
||||
|
||||
it('falls back to the most recently visited remaining tab and rotates the closed cache key', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValueOnce(10).mockReturnValueOnce(20).mockReturnValueOnce(30)
|
||||
const store = useWorkspaceTabsStore()
|
||||
store.initialize('user-a')
|
||||
store.open({ id: 'customers', title: '客户', path: '/customers' })
|
||||
store.open({ id: 'warehouses', title: '仓库', path: '/warehouses' })
|
||||
store.open({ id: 'pallets', title: '托盘', path: '/pallets' })
|
||||
const previousKey = store.cacheKey('pallets')
|
||||
|
||||
const fallback = store.removeByIds(['pallets'])
|
||||
|
||||
expect(fallback?.id).toBe('warehouses')
|
||||
expect(store.activeId).toBe('warehouses')
|
||||
expect(store.cacheKey('pallets')).not.toBe(previousKey)
|
||||
})
|
||||
|
||||
it('drops corrupt persisted data instead of breaking application startup', () => {
|
||||
sessionStorage.setItem('cc-admin-workspace-tabs:v1:user-a', '{broken')
|
||||
expect(readWorkspaceTabsSnapshot('user-a')).toEqual({ tabs: [], activeId: null })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { WorkspaceTab, WorkspaceTabCandidate, WorkspaceTabsSnapshot } from './types'
|
||||
|
||||
const STORAGE_PREFIX = 'cc-admin-workspace-tabs:v1'
|
||||
export const WORKSPACE_TAB_LIMIT = 12
|
||||
export const MAX_CACHED_WORKSPACE_VIEWS = 24
|
||||
|
||||
function storageKey(userId: string) {
|
||||
return `${STORAGE_PREFIX}:${userId}`
|
||||
}
|
||||
|
||||
function validTab(value: unknown): value is WorkspaceTab {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const tab = value as Partial<WorkspaceTab>
|
||||
return typeof tab.id === 'string'
|
||||
&& Boolean(tab.id)
|
||||
&& typeof tab.title === 'string'
|
||||
&& typeof tab.path === 'string'
|
||||
&& tab.path.startsWith('/')
|
||||
&& typeof tab.lastVisitedAt === 'number'
|
||||
&& Number.isFinite(tab.lastVisitedAt)
|
||||
}
|
||||
|
||||
export function readWorkspaceTabsSnapshot(userId: string): WorkspaceTabsSnapshot {
|
||||
if (!userId) return { tabs: [], activeId: null }
|
||||
try {
|
||||
const parsed = JSON.parse(sessionStorage.getItem(storageKey(userId)) || 'null') as Partial<WorkspaceTabsSnapshot> | null
|
||||
if (!parsed || !Array.isArray(parsed.tabs)) return { tabs: [], activeId: null }
|
||||
const tabs = parsed.tabs.filter(validTab)
|
||||
const activeId = typeof parsed.activeId === 'string' && tabs.some((tab) => tab.id === parsed.activeId)
|
||||
? parsed.activeId
|
||||
: null
|
||||
return { tabs, activeId }
|
||||
} catch {
|
||||
return { tabs: [], activeId: null }
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredActiveWorkspacePath(userId: string) {
|
||||
const snapshot = readWorkspaceTabsSnapshot(userId)
|
||||
return snapshot.tabs.find((tab) => tab.id === snapshot.activeId)?.path ?? null
|
||||
}
|
||||
|
||||
export const useWorkspaceTabsStore = defineStore('workspace-tabs', () => {
|
||||
const tabs = ref<WorkspaceTab[]>([])
|
||||
const activeId = ref<string | null>(null)
|
||||
const initializedUserId = ref('')
|
||||
const cacheEpochs = ref<Record<string, number>>({})
|
||||
|
||||
const activeTab = computed(() => tabs.value.find((tab) => tab.id === activeId.value) ?? null)
|
||||
|
||||
function persist() {
|
||||
if (!initializedUserId.value) return
|
||||
sessionStorage.setItem(storageKey(initializedUserId.value), JSON.stringify({
|
||||
tabs: tabs.value,
|
||||
activeId: activeId.value,
|
||||
} satisfies WorkspaceTabsSnapshot))
|
||||
}
|
||||
|
||||
function initialize(userId: string) {
|
||||
if (!userId || initializedUserId.value === userId) return
|
||||
initializedUserId.value = userId
|
||||
const snapshot = readWorkspaceTabsSnapshot(userId)
|
||||
tabs.value = snapshot.tabs
|
||||
activeId.value = snapshot.activeId
|
||||
}
|
||||
|
||||
function bumpCacheEpoch(tabId: string) {
|
||||
cacheEpochs.value = {
|
||||
...cacheEpochs.value,
|
||||
[tabId]: (cacheEpochs.value[tabId] ?? 0) + 1,
|
||||
}
|
||||
}
|
||||
|
||||
function removeByIds(ids: readonly string[]) {
|
||||
const idSet = new Set(ids)
|
||||
if (idSet.size === 0) return activeTab.value
|
||||
const activeWasRemoved = activeId.value !== null && idSet.has(activeId.value)
|
||||
for (const id of idSet) bumpCacheEpoch(id)
|
||||
tabs.value = tabs.value.filter((tab) => !idSet.has(tab.id))
|
||||
if (activeWasRemoved) {
|
||||
activeId.value = [...tabs.value].sort((left, right) => right.lastVisitedAt - left.lastVisitedAt)[0]?.id ?? null
|
||||
} else if (activeId.value && !tabs.value.some((tab) => tab.id === activeId.value)) {
|
||||
activeId.value = null
|
||||
}
|
||||
persist()
|
||||
return activeTab.value
|
||||
}
|
||||
|
||||
function enforceLimit(dirtyTabIds: ReadonlySet<string>) {
|
||||
while (tabs.value.length > WORKSPACE_TAB_LIMIT) {
|
||||
const candidate = tabs.value
|
||||
.filter((tab) => tab.id !== activeId.value && !dirtyTabIds.has(tab.id))
|
||||
.sort((left, right) => left.lastVisitedAt - right.lastVisitedAt)[0]
|
||||
if (!candidate) break
|
||||
removeByIds([candidate.id])
|
||||
}
|
||||
}
|
||||
|
||||
function open(candidate: WorkspaceTabCandidate, dirtyTabIds: ReadonlySet<string> = new Set()) {
|
||||
const now = Date.now()
|
||||
const existing = tabs.value.find((tab) => tab.id === candidate.id)
|
||||
if (existing) {
|
||||
existing.title = candidate.title
|
||||
existing.path = candidate.path
|
||||
existing.lastVisitedAt = now
|
||||
} else {
|
||||
tabs.value.push({ ...candidate, lastVisitedAt: now })
|
||||
}
|
||||
activeId.value = candidate.id
|
||||
enforceLimit(dirtyTabIds)
|
||||
persist()
|
||||
}
|
||||
|
||||
function prune(keep: (tab: WorkspaceTab) => boolean) {
|
||||
const removedIds = tabs.value.filter((tab) => !keep(tab)).map((tab) => tab.id)
|
||||
return { removedIds, fallback: removeByIds(removedIds) }
|
||||
}
|
||||
|
||||
function cacheKey(tabId: string | null) {
|
||||
if (!tabId) return 'workspace-route'
|
||||
return `${tabId}:${cacheEpochs.value[tabId] ?? 0}`
|
||||
}
|
||||
|
||||
function reset(removePersisted = false) {
|
||||
if (removePersisted && initializedUserId.value) sessionStorage.removeItem(storageKey(initializedUserId.value))
|
||||
tabs.value = []
|
||||
activeId.value = null
|
||||
cacheEpochs.value = {}
|
||||
initializedUserId.value = ''
|
||||
}
|
||||
|
||||
return {
|
||||
tabs,
|
||||
activeId,
|
||||
activeTab,
|
||||
initializedUserId,
|
||||
initialize,
|
||||
open,
|
||||
removeByIds,
|
||||
prune,
|
||||
cacheKey,
|
||||
bumpCacheEpoch,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user