mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
refactor(workspace): enforce layered crate boundaries
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})()
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user