优化
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { computed, onBeforeUnmount, ref, watchEffect, type Ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export interface UnsavedWorkEntry {
|
||||
key: string
|
||||
routeKey: string
|
||||
label: string
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
export const useUnsavedWorkStore = defineStore('client-unsaved-work', () => {
|
||||
const entries = ref<UnsavedWorkEntry[]>([])
|
||||
const owners = new Map<string, symbol>()
|
||||
const dirtyEntries = computed(() => entries.value.filter((entry) => entry.dirty))
|
||||
const hasDirty = computed(() => dirtyEntries.value.length > 0)
|
||||
const dirtyRouteKeys = computed(() => new Set(dirtyEntries.value.map((entry) => entry.routeKey)))
|
||||
|
||||
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)
|
||||
else entries.value[index] = entry
|
||||
}
|
||||
|
||||
function remove(key: string, owner?: symbol) {
|
||||
if (owner && owners.get(key) !== owner) return
|
||||
owners.delete(key)
|
||||
entries.value = entries.value.filter((entry) => entry.key !== key)
|
||||
}
|
||||
|
||||
function removeRoutes(routeKeys: readonly string[]) {
|
||||
const removed = new Set(routeKeys)
|
||||
entries.value
|
||||
.filter((entry) => removed.has(entry.routeKey))
|
||||
.forEach((entry) => owners.delete(entry.key))
|
||||
entries.value = entries.value.filter((entry) => !removed.has(entry.routeKey))
|
||||
}
|
||||
|
||||
function findDirty(routeKeys: readonly string[]) {
|
||||
const candidates = new Set(routeKeys)
|
||||
return dirtyEntries.value.filter((entry) => candidates.has(entry.routeKey))
|
||||
}
|
||||
|
||||
function clear() {
|
||||
owners.clear()
|
||||
entries.value = []
|
||||
}
|
||||
|
||||
return { entries, dirtyEntries, hasDirty, dirtyRouteKeys, claim, upsert, remove, removeRoutes, findDirty, clear }
|
||||
})
|
||||
|
||||
export function useUnsavedWorkRegistration(options: {
|
||||
key: string
|
||||
routeKey: string
|
||||
label: string
|
||||
dirty: Readonly<Ref<boolean>>
|
||||
}) {
|
||||
const store = useUnsavedWorkStore()
|
||||
const owner = Symbol(options.key)
|
||||
store.claim(options.key, owner)
|
||||
watchEffect(() => {
|
||||
store.upsert({
|
||||
key: options.key,
|
||||
routeKey: options.routeKey,
|
||||
label: options.label,
|
||||
dirty: options.dirty.value,
|
||||
}, owner)
|
||||
})
|
||||
onBeforeUnmount(() => store.remove(options.key, owner))
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
|
||||
export const MAX_WORKSPACE_TABS = 12
|
||||
export const MAX_CACHED_WORKSPACE_VIEWS = 24
|
||||
|
||||
const excludedRouteNames = new Set(['login', 'register', 'terms', 'privacy', 'forbidden'])
|
||||
|
||||
export interface WorkspaceTab {
|
||||
key: string
|
||||
routeName: string
|
||||
title: string
|
||||
fullPath: string
|
||||
permission: string | null
|
||||
lastVisitedAt: number
|
||||
}
|
||||
|
||||
interface PersistedWorkspace {
|
||||
activeKey: string | null
|
||||
tabs: WorkspaceTab[]
|
||||
}
|
||||
|
||||
function storageKey(ownerId: string) {
|
||||
return `cc:client-workspace-tabs:${ownerId}`
|
||||
}
|
||||
|
||||
function isWorkspaceTab(value: unknown): value is WorkspaceTab {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const tab = value as Partial<WorkspaceTab>
|
||||
return typeof tab.key === 'string'
|
||||
&& typeof tab.routeName === 'string'
|
||||
&& typeof tab.title === 'string'
|
||||
&& typeof tab.fullPath === 'string'
|
||||
&& (typeof tab.permission === 'string' || tab.permission === null)
|
||||
&& typeof tab.lastVisitedAt === 'number'
|
||||
}
|
||||
|
||||
function readWorkspace(ownerId: string): PersistedWorkspace {
|
||||
try {
|
||||
const parsed = JSON.parse(sessionStorage.getItem(storageKey(ownerId)) ?? 'null') as Partial<PersistedWorkspace> | null
|
||||
return {
|
||||
activeKey: typeof parsed?.activeKey === 'string' ? parsed.activeKey : null,
|
||||
tabs: Array.isArray(parsed?.tabs) ? parsed.tabs.filter(isWorkspaceTab) : [],
|
||||
}
|
||||
} catch {
|
||||
return { activeKey: null, tabs: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function isWorkspaceRoute(route: { name?: unknown; meta: { workspaceTab?: boolean } }) {
|
||||
const name = typeof route.name === 'string' ? route.name : ''
|
||||
return Boolean(name && !excludedRouteNames.has(name) && route.meta.workspaceTab !== false)
|
||||
}
|
||||
|
||||
export const useWorkspaceTabsStore = defineStore('client-workspace-tabs', () => {
|
||||
const tabs = ref<WorkspaceTab[]>([])
|
||||
const activeKey = ref<string | null>(null)
|
||||
const ownerId = ref('')
|
||||
const cacheEpochs = ref<Record<string, number>>({})
|
||||
const initialized = ref(false)
|
||||
|
||||
const activeTab = computed(() => tabs.value.find((tab) => tab.key === activeKey.value) ?? null)
|
||||
|
||||
function persist() {
|
||||
if (!ownerId.value) return
|
||||
try {
|
||||
sessionStorage.setItem(storageKey(ownerId.value), JSON.stringify({
|
||||
activeKey: activeKey.value,
|
||||
tabs: tabs.value,
|
||||
} satisfies PersistedWorkspace))
|
||||
} catch {
|
||||
// The workspace remains usable when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function initialize(nextOwnerId: string, isAllowed: (tab: WorkspaceTab) => boolean) {
|
||||
if (!nextOwnerId) return null
|
||||
if (initialized.value && ownerId.value === nextOwnerId) return activeTab.value?.fullPath ?? null
|
||||
|
||||
ownerId.value = nextOwnerId
|
||||
initialized.value = true
|
||||
cacheEpochs.value = {}
|
||||
const restored = readWorkspace(nextOwnerId)
|
||||
tabs.value = restored.tabs.filter(isAllowed).slice(-MAX_WORKSPACE_TABS)
|
||||
activeKey.value = tabs.value.some((tab) => tab.key === restored.activeKey)
|
||||
? restored.activeKey
|
||||
: tabs.value.at(-1)?.key ?? null
|
||||
persist()
|
||||
return activeTab.value?.fullPath ?? null
|
||||
}
|
||||
|
||||
function bumpCacheEpoch(key: string) {
|
||||
cacheEpochs.value = { ...cacheEpochs.value, [key]: (cacheEpochs.value[key] ?? 0) + 1 }
|
||||
}
|
||||
|
||||
function removeKeys(keys: ReadonlySet<string>) {
|
||||
for (const key of keys) bumpCacheEpoch(key)
|
||||
tabs.value = tabs.value.filter((tab) => !keys.has(tab.key))
|
||||
}
|
||||
|
||||
function enforceLimit(protectedKeys: ReadonlySet<string>) {
|
||||
while (tabs.value.length > MAX_WORKSPACE_TABS) {
|
||||
const candidate = [...tabs.value]
|
||||
.filter((tab) => tab.key !== activeKey.value && !protectedKeys.has(tab.key))
|
||||
.sort((left, right) => left.lastVisitedAt - right.lastVisitedAt)[0]
|
||||
if (!candidate) return
|
||||
removeKeys(new Set([candidate.key]))
|
||||
}
|
||||
}
|
||||
|
||||
function openRoute(route: RouteLocationNormalizedLoaded, protectedKeys: ReadonlySet<string> = new Set()) {
|
||||
if (!isWorkspaceRoute(route)) return
|
||||
const routeName = String(route.name)
|
||||
const current = tabs.value.find((tab) => tab.key === routeName)
|
||||
const next: WorkspaceTab = {
|
||||
key: routeName,
|
||||
routeName,
|
||||
title: String(route.meta.title || routeName),
|
||||
fullPath: route.fullPath,
|
||||
permission: typeof route.meta.permission === 'string' ? route.meta.permission : null,
|
||||
lastVisitedAt: Date.now(),
|
||||
}
|
||||
if (current) Object.assign(current, next)
|
||||
else tabs.value.push(next)
|
||||
activeKey.value = routeName
|
||||
enforceLimit(protectedKeys)
|
||||
persist()
|
||||
}
|
||||
|
||||
function close(keys: readonly string[]) {
|
||||
const closing = new Set(keys)
|
||||
const closedActive = activeKey.value !== null && closing.has(activeKey.value)
|
||||
removeKeys(closing)
|
||||
if (closedActive) {
|
||||
activeKey.value = [...tabs.value]
|
||||
.sort((left, right) => right.lastVisitedAt - left.lastVisitedAt)[0]?.key ?? null
|
||||
}
|
||||
persist()
|
||||
return { closedActive, fallbackPath: activeTab.value?.fullPath ?? null }
|
||||
}
|
||||
|
||||
function reconcile(isAllowed: (tab: WorkspaceTab) => boolean) {
|
||||
const removedKeys = tabs.value.filter((tab) => !isAllowed(tab)).map((tab) => tab.key)
|
||||
return close(removedKeys)
|
||||
}
|
||||
|
||||
function cacheKey(routeName: unknown) {
|
||||
const key = typeof routeName === 'string' ? routeName : 'anonymous'
|
||||
return `${key}:${cacheEpochs.value[key] ?? 0}`
|
||||
}
|
||||
|
||||
function clear(removePersisted = true) {
|
||||
if (removePersisted && ownerId.value) {
|
||||
try {
|
||||
sessionStorage.removeItem(storageKey(ownerId.value))
|
||||
} catch {
|
||||
// Ignore storage failures while clearing an authenticated session.
|
||||
}
|
||||
}
|
||||
tabs.value = []
|
||||
activeKey.value = null
|
||||
cacheEpochs.value = {}
|
||||
ownerId.value = ''
|
||||
initialized.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
tabs,
|
||||
activeKey,
|
||||
activeTab,
|
||||
ownerId,
|
||||
initialized,
|
||||
initialize,
|
||||
openRoute,
|
||||
close,
|
||||
reconcile,
|
||||
cacheKey,
|
||||
clear,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const storeSource = readFileSync(new URL('./workspace-tabs/workspaceTabs.ts', import.meta.url), 'utf8')
|
||||
const unsavedSource = readFileSync(new URL('./workspace-tabs/unsavedWork.ts', import.meta.url), 'utf8')
|
||||
const tabsSource = readFileSync(new URL('../components/WorkspaceTabs.vue', import.meta.url), 'utf8')
|
||||
const layoutSource = readFileSync(new URL('../layouts/AppLayout.vue', import.meta.url), 'utf8')
|
||||
const tabStyles = readFileSync(new URL('../styles/workspace-tabs.css', import.meta.url), 'utf8')
|
||||
|
||||
test('workspace tabs are isolated by account, restored from session storage, and capped without evicting protected work', () => {
|
||||
assert.match(storeSource, /cc:client-workspace-tabs:\$\{ownerId\}/)
|
||||
assert.match(storeSource, /sessionStorage\.getItem/)
|
||||
assert.match(storeSource, /sessionStorage\.setItem/)
|
||||
assert.match(storeSource, /MAX_WORKSPACE_TABS = 12/)
|
||||
assert.match(storeSource, /MAX_CACHED_WORKSPACE_VIEWS = 24/)
|
||||
assert.match(storeSource, /!protectedKeys\.has\(tab\.key\)/)
|
||||
assert.match(storeSource, /sort\(\(left, right\) => left\.lastVisitedAt - right\.lastVisitedAt\)/)
|
||||
})
|
||||
|
||||
test('workspace tabs exclude public and forbidden routes and use one tab per route name', () => {
|
||||
assert.match(storeSource, /excludedRouteNames = new Set\(\['login', 'register', 'terms', 'privacy', 'forbidden'\]\)/)
|
||||
assert.match(storeSource, /const routeName = String\(route\.name\)/)
|
||||
assert.match(storeSource, /key: routeName/)
|
||||
assert.match(storeSource, /tabs\.value\.find\(\(tab\) => tab\.key === routeName\)/)
|
||||
})
|
||||
|
||||
test('closing tabs invalidates their cached instance and returns the most recently visited fallback', () => {
|
||||
assert.match(storeSource, /function bumpCacheEpoch/)
|
||||
assert.match(storeSource, /for \(const key of keys\) bumpCacheEpoch\(key\)/)
|
||||
assert.match(storeSource, /sort\(\(left, right\) => right\.lastVisitedAt - left\.lastVisitedAt\)/)
|
||||
assert.match(storeSource, /return \{ closedActive, fallbackPath:/)
|
||||
})
|
||||
|
||||
test('the shell renders cached route views below an accessible workspace tab strip', () => {
|
||||
assert.match(layoutSource, /<WorkspaceTabs[\s\S]*:inert="mobileMenuOpen"/)
|
||||
assert.match(layoutSource, /<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">/)
|
||||
assert.match(layoutSource, /workspace\.cacheKey\(viewRoute\.name\)/)
|
||||
assert.match(layoutSource, /scrollPositions\.set\(previousRouteName, window\.scrollY\)/)
|
||||
assert.match(layoutSource, /window\.scrollTo\(0, scrollPositions\.get\(routeName\) \?\? 0\)/)
|
||||
assert.match(tabsSource, /role="tablist"/)
|
||||
assert.match(tabsSource, /role="tab"/)
|
||||
assert.match(tabsSource, /:aria-selected=/)
|
||||
assert.match(tabsSource, /关闭当前/)
|
||||
assert.match(tabsSource, /关闭其他/)
|
||||
assert.match(tabsSource, /关闭右侧/)
|
||||
assert.match(tabsSource, /关闭全部/)
|
||||
})
|
||||
|
||||
test('unsaved work blocks destructive closes and browser unload without blocking cached tab switches', () => {
|
||||
assert.match(unsavedSource, /dirtyRouteKeys/)
|
||||
assert.match(tabsSource, /unsaved\.findDirty\(keys\)/)
|
||||
assert.match(tabsSource, /放弃未保存的修改/)
|
||||
assert.match(layoutSource, /window\.addEventListener\('beforeunload', handleBeforeUnload\)/)
|
||||
assert.match(layoutSource, /if \(!unsaved\.hasDirty\) return/)
|
||||
assert.doesNotMatch(tabsSource, /beforeEach|onBeforeRouteLeave/)
|
||||
})
|
||||
|
||||
test('unsaved registrations use instance ownership so stale cached views cannot replace or remove a reopened view', () => {
|
||||
assert.match(unsavedSource, /const owners = new Map<string, symbol>\(\)/)
|
||||
assert.match(unsavedSource, /function claim\(key: string, owner: symbol\)/)
|
||||
assert.match(unsavedSource, /if \(owner && owners\.get\(entry\.key\) !== owner\) return/)
|
||||
assert.match(unsavedSource, /if \(owner && owners\.get\(key\) !== owner\) return/)
|
||||
assert.match(unsavedSource, /store\.claim\(options\.key, owner\)/)
|
||||
assert.match(unsavedSource, /store\.remove\(options\.key, owner\)/)
|
||||
})
|
||||
|
||||
test('the tab strip keeps touch targets and horizontal navigation usable on narrow screens', () => {
|
||||
assert.match(tabStyles, /overflow-x: auto/)
|
||||
assert.match(tabStyles, /scrollbar-width: none/)
|
||||
assert.match(tabStyles, /::-webkit-scrollbar \{ display: none; width: 0; height: 0; \}/)
|
||||
assert.match(tabsSource, /@wheel="handleTabWheel"/)
|
||||
assert.match(tabsSource, /scroller\.scrollLeft = Math\.max/)
|
||||
assert.match(tabStyles, /@media \(max-width: 720px\)/)
|
||||
assert.match(tabStyles, /\.workspace-tab__close \{ width: 40px; height: 40px;/)
|
||||
assert.match(tabStyles, /\.workspace-tabs__more \{ width: 48px; height: 48px;/)
|
||||
assert.match(tabStyles, /prefers-reduced-motion: reduce/)
|
||||
})
|
||||
Reference in New Issue
Block a user