feat: 实现剧本创作与拆解前端工作台

This commit is contained in:
GouJ
2026-08-27 19:33:51 +08:00
parent edc003443f
commit 2ee6f049f6
56 changed files with 7597 additions and 2 deletions
+45
View File
@@ -0,0 +1,45 @@
import { effectScope, nextTick, ref } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { usePolling } from './usePolling'
afterEach(() => vi.useRealTimers())
describe('异步轮询生命周期', () => {
it('切换项目忽略旧响应,卸载时不再排队请求', async () => {
vi.useFakeTimers()
const key = ref('first')
const finish = new Map<string, (value: string) => void>()
const loader = vi.fn<(id: string) => Promise<string>>(
(id: string) =>
new Promise<string>(resolve => {
finish.set(id, resolve)
})
)
const scope = effectScope()
const query = scope.run(() => usePolling(key, loader, 1000))!
key.value = 'second'
await nextTick()
finish.get('second')!('新项目')
await Promise.resolve()
finish.get('first')!('旧项目')
await Promise.resolve()
expect(query.data.value).toBe('新项目')
scope.stop()
await vi.advanceTimersByTimeAsync(10_000)
expect(loader).toHaveBeenCalledTimes(2)
})
it('失败时保留已有数据,并向页面显示错误', async () => {
const scope = effectScope()
const loader = vi
.fn<() => Promise<string>>()
.mockResolvedValueOnce('上次成功数据')
.mockRejectedValueOnce(new Error('连接已断开'))
const query = scope.run(() => usePolling(ref('p'), loader))!
await Promise.resolve()
await query.refresh()
expect(query.data.value).toBe('上次成功数据')
expect(query.error.value).toBe('连接已断开')
scope.stop()
})
})
+69
View File
@@ -0,0 +1,69 @@
import { onScopeDispose, ref, watch, type Ref } from 'vue'
import { errorMessage } from '../lib/http'
/** 串行轮询:请求完成后才计时;切换项目立即取消,过期响应不会覆盖新项目。 */
export function usePolling<T>(
key: Ref<string>,
loader: (key: string, signal: AbortSignal) => Promise<T>,
interval = 6000
) {
const data = ref<T | null>(null) as Ref<T | null>
const loading = ref(false)
const error = ref('')
const updatedAt = ref('')
let generation = 0
let controller: AbortController | undefined
let timer: ReturnType<typeof setTimeout> | undefined
let disposed = false
/** 隐藏页面只安排下一次检查,不继续拉取完整 checkpoint。 */
function schedule() {
timer = setTimeout(() => {
if (document.visibilityState === 'hidden') schedule()
else void refresh()
}, interval)
}
/** 手动刷新也取消上一次查询,避免同时存在多个轮询链。 */
async function refresh() {
if (disposed) return
const current = ++generation
clearTimeout(timer)
controller?.abort()
controller = new AbortController()
const signal = controller.signal
loading.value = true
try {
const result = await loader(key.value, signal)
if (current !== generation || disposed) return
data.value = result
error.value = ''
updatedAt.value = new Date().toISOString()
} catch (cause) {
if (current === generation && !signal.aborted && !disposed) error.value = errorMessage(cause)
} finally {
if (current === generation && !disposed) {
loading.value = false
schedule()
}
}
}
watch(
key,
() => {
data.value = null
error.value = ''
updatedAt.value = ''
void refresh()
},
{ immediate: true }
)
onScopeDispose(() => {
disposed = true
generation++
clearTimeout(timer)
controller?.abort()
})
return { data, loading, error, updatedAt, refresh }
}