This commit is contained in:
2026-08-05 15:54:59 +08:00
parent eede351038
commit 4b31eb7786
43 changed files with 2117 additions and 851 deletions
+16
View File
@@ -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 {}
+37
View File
@@ -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,
}
}
+17
View File
@@ -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 })
})
})
+147
View File
@@ -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,
}
})