优化
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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