refactor(workspace): enforce layered crate boundaries

This commit is contained in:
elky
2026-07-15 23:47:19 +08:00
parent a728c090a9
commit 8616fe6ee2
969 changed files with 40187 additions and 27240 deletions
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest'
import { createMemoryHistory, createRouter } from 'vue-router'
import { view } from '@/router/routes/helpers'
import { prefetchNavigationTarget } from '@/utils/adminNavigationPrefetch'
describe('navigation prefetch', () => {
it('warms the async component resolved from the target route', async () => {
const component = { render: () => null }
const rawLoader = vi.fn(async () => component)
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/settings', component: view(rawLoader) }],
})
prefetchNavigationTarget(router, '/settings')
await vi.waitFor(() => {
expect(rawLoader).toHaveBeenCalledTimes(1)
})
})
})
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cache, cachedRequest } from '@/utils/cache'
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
describe('request cache invalidation', () => {
beforeEach(() => {
cache.clear()
})
it('does not let an invalidated request overwrite a newer in-flight request', async () => {
const oldResponse = deferred<string>()
const newResponse = deferred<string>()
const staleFetcher = vi.fn(() => oldResponse.promise)
const freshFetcher = vi.fn(() => newResponse.promise)
const unexpectedFetcher = vi.fn(async () => 'unexpected')
const staleRequest = cachedRequest('config', staleFetcher, 30_000)
cache.delete('config')
const freshRequest = cachedRequest('config', freshFetcher, 30_000)
oldResponse.resolve('stale')
await expect(staleRequest).resolves.toBe('stale')
expect(cache.get('config')).toBeNull()
const deduplicatedRequest = cachedRequest('config', unexpectedFetcher, 30_000)
expect(unexpectedFetcher).not.toHaveBeenCalled()
newResponse.resolve('fresh')
await expect(freshRequest).resolves.toBe('fresh')
await expect(deduplicatedRequest).resolves.toBe('fresh')
expect(cache.get('config')).toBe('fresh')
})
it('does not refill the cache from a request started before a global clear', async () => {
const response = deferred<string>()
const request = cachedRequest('dashboard', () => response.promise, 30_000)
cache.clear()
response.resolve('previous-user-data')
await expect(request).resolves.toBe('previous-user-data')
expect(cache.get('dashboard')).toBeNull()
})
})
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { observeNearViewportOnce } from '@/utils/nearViewport'
interface MockObserverInstance {
callback: IntersectionObserverCallback
options?: IntersectionObserverInit
observed: Element[]
disconnect: ReturnType<typeof vi.fn>
}
const instances: MockObserverInstance[] = []
class MockIntersectionObserver {
readonly root = null
readonly rootMargin = ''
readonly thresholds = [0]
readonly callback: IntersectionObserverCallback
readonly options?: IntersectionObserverInit
readonly observed: Element[] = []
readonly disconnect = vi.fn()
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
this.callback = callback
this.options = options
instances.push(this)
}
observe(target: Element) {
this.observed.push(target)
}
unobserve() {}
takeRecords(): IntersectionObserverEntry[] { return [] }
}
afterEach(() => {
instances.length = 0
vi.unstubAllGlobals()
})
describe('observeNearViewportOnce', () => {
it('uses the provided scroll root and loads once on intersection', () => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
const target = document.createElement('section')
const root = document.createElement('main')
const onNearViewport = vi.fn()
observeNearViewportOnce({
target,
root,
rootMargin: '600px 0px',
onNearViewport,
})
const observer = instances[0]
expect(observer?.observed).toEqual([target])
expect(observer?.options).toMatchObject({ root, rootMargin: '600px 0px', threshold: 0 })
expect(onNearViewport).not.toHaveBeenCalled()
observer?.callback([
{ isIntersecting: true, target } as IntersectionObserverEntry,
], observer as unknown as IntersectionObserver)
observer?.callback([
{ isIntersecting: true, target } as IntersectionObserverEntry,
], observer as unknown as IntersectionObserver)
expect(onNearViewport).toHaveBeenCalledTimes(1)
expect(observer?.disconnect).toHaveBeenCalledTimes(1)
})
it('disconnects without loading when disposed before intersection', () => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
const target = document.createElement('section')
const onNearViewport = vi.fn()
const stop = observeNearViewportOnce({ target, onNearViewport })
const observer = instances[0]
stop()
observer?.callback([
{ isIntersecting: true, target } as IntersectionObserverEntry,
], observer as unknown as IntersectionObserver)
expect(observer?.disconnect).toHaveBeenCalledTimes(1)
expect(onNearViewport).not.toHaveBeenCalled()
})
it('falls back to immediate loading when IntersectionObserver is unavailable', () => {
vi.stubGlobal('IntersectionObserver', undefined)
const onNearViewport = vi.fn()
const stop = observeNearViewportOnce({
target: document.createElement('section'),
onNearViewport,
})
expect(onNearViewport).toHaveBeenCalledTimes(1)
expect(() => stop()).not.toThrow()
})
})
+27 -29
View File
@@ -1,45 +1,43 @@
import { log } from '@/utils/logger'
import type { Router } from 'vue-router'
const PREFETCH_COOLDOWN_MS = 5 * 1000
const lastPrefetchAt = new Map<string, number>()
const adminRouteWarmers: Record<string, () => Promise<void>> = {
'/admin/users': async () => {
await import('@/views/admin/Users.vue')
},
'/admin/providers': async () => {
await import('@/views/admin/ProviderManagement.vue')
},
'/admin/models': async () => {
await import('@/views/admin/ModelManagement.vue')
},
'/admin/routing': async () => {
await import('@/views/admin/RoutingProfiles.vue')
},
'/admin/pool': async () => {
await import('@/views/admin/PoolManagement.vue')
},
'/admin/payment-gateways': async () => {
await import('@/views/admin/PaymentGatewaySettings.vue')
},
'/admin/billing-plans': async () => {
await import('@/views/admin/BillingPlansManagement.vue')
},
type PrefetchableRouteLoader = (() => unknown) & {
prefetch?: () => Promise<unknown>
}
export function prefetchAdminNavigationTarget(href: string): void {
const warmer = adminRouteWarmers[href]
if (!warmer) return
/**
* 预取目标路由链中的异步组件。
*
* 直接从 Router 解析组件,避免维护一份容易遗漏用户页和新增管理页的硬编码表。
*/
export function prefetchNavigationTarget(router: Router, href: string): void {
const now = Date.now()
const lastRun = lastPrefetchAt.get(href) ?? 0
if (now - lastRun < PREFETCH_COOLDOWN_MS) {
return
}
const loaders = router.resolve(href).matched.flatMap((record) =>
Object.values(record.components ?? {})
.filter((component): component is PrefetchableRouteLoader =>
typeof component === 'function'
&& typeof (component as PrefetchableRouteLoader).prefetch === 'function'
)
.map(component => component.prefetch as () => Promise<unknown>)
)
if (loaders.length === 0) return
lastPrefetchAt.set(href, now)
void warmer().catch((err) => {
log.debug('[adminNavigationPrefetch] ignore prefetch failure', err)
})
for (const loader of new Set(loaders)) {
void Promise.resolve()
.then(() => loader())
.catch((err) => {
log.debug('[navigationPrefetch] ignore prefetch failure', err)
})
}
}
+29 -5
View File
@@ -12,6 +12,8 @@ interface CacheItem<T> {
class MemoryCache {
private cache: Map<string, CacheItem<unknown>> = new Map()
private inFlight: Map<string, Promise<unknown>> = new Map()
private epoch = 0
private keyVersions: Map<string, number> = new Map()
private defaultTTL = 60000 // 默认缓存60秒
/**
@@ -55,14 +57,18 @@ class MemoryCache {
*/
delete(key: string): void {
this.cache.delete(key)
this.inFlight.delete(key)
this.keyVersions.set(key, (this.keyVersions.get(key) ?? 0) + 1)
}
/**
* 清空所有缓存
*/
clear(): void {
this.epoch += 1
this.cache.clear()
this.inFlight.clear()
this.keyVersions.clear()
}
/**
@@ -101,8 +107,21 @@ class MemoryCache {
/**
* 清除进行中的请求
*/
deleteInFlight(key: string): void {
this.inFlight.delete(key)
deleteInFlight(key: string, request?: Promise<unknown>): void {
if (!request || this.inFlight.get(key) === request) {
this.inFlight.delete(key)
}
}
/**
* 捕获 key 当前的失效版本,防止退出登录或写操作之后,旧请求重新回填缓存。
*/
version(key: string): readonly [number, number] {
return [this.epoch, this.keyVersions.get(key) ?? 0]
}
isCurrentVersion(key: string, version: readonly [number, number]): boolean {
return version[0] === this.epoch && version[1] === (this.keyVersions.get(key) ?? 0)
}
}
@@ -140,15 +159,20 @@ export async function cachedRequest<T>(
}
// 缓存未命中,执行请求并登记为 in-flight
const request = (async () => {
const version = cache.version(key)
// Keep this mutable declaration: a synchronously throwing fetcher can reach `finally`
// before the Promise assignment completes, so a self-referencing `const` would hit TDZ.
let request: Promise<T>
// eslint-disable-next-line prefer-const
request = (async () => {
try {
const data = await fetcher()
if (ttl !== 0) {
if (ttl !== 0 && cache.isCurrentVersion(key, version)) {
cache.set(key, data, ttl)
}
return data
} finally {
cache.deleteInFlight(key)
cache.deleteInFlight(key, request)
}
})()
+45
View File
@@ -0,0 +1,45 @@
export interface ObserveNearViewportOnceOptions {
target: Element
root?: Element | null
rootMargin?: string
onNearViewport: () => void
}
/**
* Run a callback once when an element reaches the viewport's preload margin.
* Browsers without IntersectionObserver load eagerly so content never remains stuck.
*/
export function observeNearViewportOnce({
target,
root = null,
rootMargin = '0px',
onNearViewport,
}: ObserveNearViewportOnceOptions): () => void {
if (typeof IntersectionObserver === 'undefined') {
onNearViewport()
return () => {}
}
let stopped = false
let observer: IntersectionObserver | null = null
observer = new IntersectionObserver((entries) => {
if (stopped || !entries.some(entry => entry.isIntersecting)) return
stopped = true
observer?.disconnect()
onNearViewport()
}, {
root,
rootMargin,
threshold: 0,
})
observer.observe(target)
return () => {
if (stopped) return
stopped = true
observer?.disconnect()
}
}