This commit is contained in:
2026-08-05 15:55:02 +08:00
parent a6828df5b9
commit 356bec03fb
16 changed files with 640 additions and 27 deletions
@@ -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,
}
})
+78
View File
@@ -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/)
})