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,
}
})