fix(frontend): 修复前端审计问题并补充回归测试
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
|
||||
|
||||
const flushPromises = () => Promise.resolve()
|
||||
|
||||
describe('useKeyedDebouncedSearch', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('为不同 key 独立防抖触发搜索', async () => {
|
||||
const search = vi.fn().mockResolvedValue([])
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const searcher = useKeyedDebouncedSearch<string[]>({
|
||||
delay: 100,
|
||||
search,
|
||||
onSuccess
|
||||
})
|
||||
|
||||
searcher.trigger('a', 'foo')
|
||||
searcher.trigger('b', 'bar')
|
||||
|
||||
expect(search).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
await flushPromises()
|
||||
|
||||
expect(search).toHaveBeenCalledTimes(2)
|
||||
expect(search).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'foo',
|
||||
expect.objectContaining({ key: 'a', signal: expect.any(AbortSignal) })
|
||||
)
|
||||
expect(search).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'bar',
|
||||
expect.objectContaining({ key: 'b', signal: expect.any(AbortSignal) })
|
||||
)
|
||||
expect(onSuccess).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('同 key 新请求会取消旧请求并忽略过期响应', async () => {
|
||||
const resolves: Array<(value: string[]) => void> = []
|
||||
const search = vi.fn().mockImplementation(
|
||||
() => new Promise<string[]>((resolve) => {
|
||||
resolves.push(resolve)
|
||||
})
|
||||
)
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const searcher = useKeyedDebouncedSearch<string[]>({
|
||||
delay: 50,
|
||||
search,
|
||||
onSuccess
|
||||
})
|
||||
|
||||
searcher.trigger('rule-1', 'first')
|
||||
vi.advanceTimersByTime(50)
|
||||
await flushPromises()
|
||||
|
||||
searcher.trigger('rule-1', 'second')
|
||||
vi.advanceTimersByTime(50)
|
||||
await flushPromises()
|
||||
|
||||
expect(search).toHaveBeenCalledTimes(2)
|
||||
|
||||
resolves[1](['second'])
|
||||
await flushPromises()
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(onSuccess).toHaveBeenLastCalledWith('rule-1', ['second'])
|
||||
|
||||
resolves[0](['first'])
|
||||
await flushPromises()
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clearKey 会取消未执行任务', () => {
|
||||
const search = vi.fn().mockResolvedValue([])
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const searcher = useKeyedDebouncedSearch<string[]>({
|
||||
delay: 100,
|
||||
search,
|
||||
onSuccess
|
||||
})
|
||||
|
||||
searcher.trigger('a', 'foo')
|
||||
searcher.clearKey('a')
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(search).not.toHaveBeenCalled()
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
103
frontend/src/composables/useKeyedDebouncedSearch.ts
Normal file
103
frontend/src/composables/useKeyedDebouncedSearch.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { getCurrentInstance, onUnmounted } from 'vue'
|
||||
|
||||
export interface KeyedDebouncedSearchContext {
|
||||
key: string
|
||||
signal: AbortSignal
|
||||
}
|
||||
|
||||
interface UseKeyedDebouncedSearchOptions<T> {
|
||||
delay?: number
|
||||
search: (keyword: string, context: KeyedDebouncedSearchContext) => Promise<T>
|
||||
onSuccess: (key: string, result: T) => void
|
||||
onError?: (key: string, error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 多实例隔离的防抖搜索:每个 key 有独立的防抖、请求取消与过期响应保护。
|
||||
*/
|
||||
export function useKeyedDebouncedSearch<T>(options: UseKeyedDebouncedSearchOptions<T>) {
|
||||
const delay = options.delay ?? 300
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const controllers = new Map<string, AbortController>()
|
||||
const versions = new Map<string, number>()
|
||||
|
||||
const clearKey = (key: string) => {
|
||||
const timer = timers.get(key)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timers.delete(key)
|
||||
}
|
||||
|
||||
const controller = controllers.get(key)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
controllers.delete(key)
|
||||
}
|
||||
|
||||
versions.delete(key)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
const allKeys = new Set<string>([
|
||||
...timers.keys(),
|
||||
...controllers.keys(),
|
||||
...versions.keys()
|
||||
])
|
||||
|
||||
allKeys.forEach((key) => clearKey(key))
|
||||
}
|
||||
|
||||
const trigger = (key: string, keyword: string) => {
|
||||
const nextVersion = (versions.get(key) ?? 0) + 1
|
||||
versions.set(key, nextVersion)
|
||||
|
||||
const existingTimer = timers.get(key)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
timers.delete(key)
|
||||
}
|
||||
|
||||
const inFlight = controllers.get(key)
|
||||
if (inFlight) {
|
||||
inFlight.abort()
|
||||
controllers.delete(key)
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
timers.delete(key)
|
||||
|
||||
const controller = new AbortController()
|
||||
controllers.set(key, controller)
|
||||
const requestVersion = versions.get(key)
|
||||
|
||||
try {
|
||||
const result = await options.search(keyword, { key, signal: controller.signal })
|
||||
if (controller.signal.aborted) return
|
||||
if (versions.get(key) !== requestVersion) return
|
||||
options.onSuccess(key, result)
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
if (versions.get(key) !== requestVersion) return
|
||||
options.onError?.(key, error)
|
||||
} finally {
|
||||
if (controllers.get(key) === controller) {
|
||||
controllers.delete(key)
|
||||
}
|
||||
}
|
||||
}, delay)
|
||||
|
||||
timers.set(key, timer)
|
||||
}
|
||||
|
||||
if (getCurrentInstance()) {
|
||||
onUnmounted(() => {
|
||||
clearAll()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
trigger,
|
||||
clearKey,
|
||||
clearAll
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user