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
+4 -4
View File
@@ -15,13 +15,13 @@ describe('server access store', () => {
expect(access.can('platform:warehouse:list')).toBe(true)
expect(access.can('platform:user:list')).toBe(false)
expect(access.firstAccessiblePath).toBe('/warehouses')
expect(access.firstAccessiblePath).toBe('/dashboard')
})
it('exposes no permissions when the server snapshot is empty', () => {
it('keeps only the unprotected dashboard when the server snapshot is empty', () => {
const access = useAccessPreviewStore()
expect(access.permissionCodes.size).toBe(0)
expect(access.filteredNavigation).toHaveLength(0)
expect(access.firstAccessiblePath).toBeNull()
expect(access.filteredNavigation).toHaveLength(1)
expect(access.firstAccessiblePath).toBe('/dashboard')
})
})
+9 -11
View File
@@ -53,20 +53,17 @@ describe('adminNavigation', () => {
expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toEqual([
'产品信息',
'费用项目',
'FBA编码地址',
'仓库管理',
'船期价格表',
'车辆列表',
'仓库管理',
'头程订单',
'头程计费',
'下载中心',
'菜单权限',
])
expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([
'用户管理',
'角色权限',
'租户管理',
'品牌设置',
'菜单权限',
])
})
@@ -76,8 +73,8 @@ describe('adminNavigation', () => {
const permissions = leaves.flatMap((item) => item.permission ? [item.permission] : [])
expect(paths).toContain('/dashboard')
expect(paths).toContain('/orders/first-leg')
expect(paths).toContain('/first-leg-pricing/customer-rules')
expect(paths).not.toContain('/orders/first-leg')
expect(paths).not.toContain('/first-leg-pricing/customer-rules')
expect(paths).toContain('/downloads')
expect(paths).toContain('/prototype/finance/profit-report')
expect(paths).toContain('/prototype/system-management/brand-settings')
@@ -95,9 +92,9 @@ describe('adminNavigation', () => {
expect(paths).toContain('/system/permissions')
expect(paths).not.toContain('/prototype/finance/profit-report')
expect(paths).not.toContain('/customers')
expect(filtered.map((item) => item.title)).toEqual(['通用'])
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '系统管理'])
expect(filtered).not.toBe(adminNavigation)
expect(filtered[0]).not.toBe(adminNavigation.find((item) => item.key === 'general'))
expect(filtered[0]).not.toBe(adminNavigation.find((item) => item.key === 'system'))
expect(JSON.stringify(adminNavigation, (key, value) => key === 'icon' ? '[icon]' : value)).toBe(sourceSnapshot)
})
@@ -110,14 +107,15 @@ describe('adminNavigation', () => {
]))
const paths = getNavigationLeaves(filtered).map((item) => item.path)
expect(filtered.map((item) => item.title)).toEqual(['国内仓', '供应商中心', '通用'])
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内仓', '供应商中心', '通用'])
expect(paths).toEqual([
'/dashboard',
'/inbounds',
'/master-data/express-companies',
'/master-data/counterparties',
'/warehouses',
])
expect(paths).not.toContain('/dashboard')
expect(paths).toContain('/dashboard')
expect(paths).not.toContain('/orders/first-leg')
expect(paths).not.toContain('/prototype/finance/profit-report')
})
+5 -14
View File
@@ -1,6 +1,7 @@
import type { Component } from 'vue'
import {
Building2,
BarChart3,
ContactRound,
ClipboardList,
CircleDollarSign,
@@ -35,7 +36,7 @@ export interface AdminNavigationLeaf extends AdminNavigationNode {
}
export const adminNavigation: AdminNavigationNode[] = [
{ key: 'dashboard', title: '首页数据看板', icon: ClipboardList, path: '/dashboard', hidden: true },
{ key: 'dashboard', title: '首页数据看板', icon: BarChart3, path: '/dashboard' },
{
key: 'customers',
title: '客户管理',
@@ -159,22 +160,12 @@ export const adminNavigation: AdminNavigationNode[] = [
icon: Database,
iconColor: '#722ed1',
children: [
// { key: 'general-fba', title: 'FBA编码地址', icon: Database, path: '/prototype/general/fba-address', hidden: true },
// { key: 'general-voyage', title: '船期价格表', icon: Database, path: '/prototype/general/voyage-price-list', hidden: true },
// { key: 'first-leg-orders', title: '头程订单', icon: ClipboardList, path: '/orders/first-leg', permission: 'platform:order:list', hidden: true },
// {
// key: 'first-leg-pricing',
// title: '头程计费',
// icon: CircleDollarSign,
// children: [
// { key: 'fba-customer-rules', title: '客户计费规则', icon: Database, path: '/first-leg-pricing/customer-rules', permission: 'firstleg:fba-pricing:rule:view' },
// ],
// },
{ key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', hidden: false },
{ key: 'general-fee', title: '费用项目', icon: Database, path: '/prototype/general/fee-items', hidden: false },
{ key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', hidden: true },
{ key: 'general-fee', title: '费用项目', icon: Database, path: '/prototype/general/fee-items', hidden: true },
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/prototype/vehicle-management/vehicle-list', hidden: false },
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/prototype/vehicle-management/vehicle-list', hidden: true },
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
],
},
+33
View File
@@ -71,6 +71,27 @@ describe('unsaved work store', () => {
}])
})
it('does not let an obsolete component remove or overwrite a reclaimed registration', () => {
const store = useUnsavedWorkStore()
const oldOwner = Symbol('old')
const newOwner = Symbol('new')
store.claim('customer-form', oldOwner)
store.upsert({ key: 'customer-form', label: '旧页面', dirty: true, requiredPermission: null }, oldOwner)
store.claim('customer-form', newOwner)
store.upsert({ key: 'customer-form', label: '新页面', dirty: true, requiredPermission: null }, newOwner)
store.remove('customer-form', oldOwner)
store.release('customer-form', oldOwner)
store.upsert({ key: 'customer-form', label: '过期页面', dirty: false, requiredPermission: null }, oldOwner)
expect(store.entries).toEqual([{
key: 'customer-form',
label: '新页面',
dirty: true,
requiredPermission: null,
}])
})
it('clears every registered entry', () => {
const store = useUnsavedWorkStore()
store.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null })
@@ -80,4 +101,16 @@ describe('unsaved work store', () => {
expect(store.entries).toEqual([])
})
it('finds and removes dirty work owned by a workspace tab', () => {
const store = useUnsavedWorkStore()
store.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null, workspaceTabId: 'customers' })
store.upsert({ key: 'warehouse-form', label: '仓库资料', dirty: true, requiredPermission: null, workspaceTabId: 'warehouses' })
expect(store.findForWorkspace('customers').map((entry) => entry.key)).toEqual(['customer-form'])
expect(store.dirtyWorkspaceIds).toEqual(new Set(['customers', 'warehouses']))
store.removeWorkspaces(['customers'])
expect(store.entries.map((entry) => entry.key)).toEqual(['warehouse-form'])
})
})
+39 -4
View File
@@ -1,4 +1,4 @@
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
export interface UnsavedWorkEntry {
@@ -6,12 +6,19 @@ export interface UnsavedWorkEntry {
label: string
dirty: boolean
requiredPermission: string | null
workspaceTabId?: string | null
}
export const useUnsavedWorkStore = defineStore('unsaved-work', () => {
const entries = ref<UnsavedWorkEntry[]>([])
const owners = new Map<string, symbol>()
function upsert(entry: UnsavedWorkEntry) {
function claim(key: string, owner: symbol) {
owners.set(key, owner)
}
function upsert(entry: UnsavedWorkEntry, owner?: symbol) {
if (owner && owners.get(entry.key) !== owner) return
const index = entries.value.findIndex((item) => item.key === entry.key)
if (index === -1) {
entries.value.push(entry)
@@ -20,7 +27,16 @@ export const useUnsavedWorkStore = defineStore('unsaved-work', () => {
entries.value[index] = entry
}
function remove(key: string) {
function remove(key: string, owner?: symbol) {
if (owner && owners.get(key) !== owner) return
if (!owner) owners.delete(key)
const index = entries.value.findIndex((item) => item.key === key)
if (index !== -1) entries.value.splice(index, 1)
}
function release(key: string, owner: symbol) {
if (owners.get(key) !== owner) return
owners.delete(key)
const index = entries.value.findIndex((item) => item.key === key)
if (index !== -1) entries.value.splice(index, 1)
}
@@ -32,9 +48,28 @@ export const useUnsavedWorkStore = defineStore('unsaved-work', () => {
))
}
function findForWorkspace(workspaceTabId: string) {
return entries.value.filter((entry) => entry.dirty && entry.workspaceTabId === workspaceTabId)
}
function removeWorkspaces(workspaceTabIds: readonly string[]) {
const ids = new Set(workspaceTabIds)
entries.value
.filter((entry) => entry.workspaceTabId && ids.has(entry.workspaceTabId))
.forEach((entry) => owners.delete(entry.key))
entries.value = entries.value.filter((entry) => !entry.workspaceTabId || !ids.has(entry.workspaceTabId))
}
function clear() {
owners.clear()
entries.value = []
}
return { entries, upsert, remove, findBlocking, clear }
const dirtyWorkspaceIds = computed<ReadonlySet<string>>(() => new Set(
entries.value
.filter((entry) => entry.dirty && entry.workspaceTabId)
.map((entry) => entry.workspaceTabId as string),
))
return { entries, dirtyWorkspaceIds, claim, upsert, remove, release, findBlocking, findForWorkspace, removeWorkspaces, clear }
})
@@ -76,6 +76,7 @@ describe('useUnsavedWorkRegistration', () => {
label: '新增客户',
dirty: true,
requiredPermission: 'customer:update',
workspaceTabId: null,
})
harness.model.name = ''
@@ -1,4 +1,4 @@
import { computed, onScopeDispose, ref, watch, type ComputedRef, type Ref } from 'vue'
import { computed, getCurrentInstance, onActivated, onScopeDispose, ref, watch, type ComputedRef, type Ref } from 'vue'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
interface UnsavedWorkRegistrationOptions<T extends object> {
@@ -13,12 +13,16 @@ export function useUnsavedWorkRegistration<T extends object>(
options: UnsavedWorkRegistrationOptions<T>,
) {
const store = useUnsavedWorkStore()
const routeName = getCurrentInstance()?.proxy?.$route?.name
const workspaceTabId = routeName === undefined || routeName === null ? null : String(routeName)
const owner = Symbol(options.key)
const snapshot = computed(() => JSON.stringify(options.model))
const baseline = ref(snapshot.value)
store.claim(options.key, owner)
function sync() {
if (!options.isOpen.value) {
store.remove(options.key)
store.remove(options.key, owner)
return
}
@@ -27,7 +31,8 @@ export function useUnsavedWorkRegistration<T extends object>(
label: options.label.value,
dirty: snapshot.value !== baseline.value,
requiredPermission: options.requiredPermission.value,
})
workspaceTabId,
}, owner)
}
function captureBaseline() {
@@ -41,7 +46,8 @@ export function useUnsavedWorkRegistration<T extends object>(
{ flush: 'sync' },
)
sync()
onScopeDispose(() => store.remove(options.key))
onActivated(sync)
onScopeDispose(() => store.release(options.key, owner))
return { captureBaseline }
}
+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,
}
})