fix(frontend): track multiple in-flight generation keys (regen vs preview)

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-14 14:22:40 +08:00
parent 4847003a05
commit 6f04326400
4 changed files with 69 additions and 46 deletions

View File

@ -5,29 +5,43 @@ const STORAGE_TS = 'ma_generation_inflight_ts'
/** 含 LLM 的重新生成可能较久;超时后视为未进行,避免按钮永久禁用 */ /** 含 LLM 的重新生成可能较久;超时后视为未进行,避免按钮永久禁用 */
const TTL_MS = 45 * 60 * 1000 const TTL_MS = 45 * 60 * 1000
function readPersisted() { /**
if (typeof sessionStorage === 'undefined') return null * @returns {string[]}
*/
function readPersistedKeys() {
if (typeof sessionStorage === 'undefined') return []
try { try {
const k = sessionStorage.getItem(STORAGE_KEY) const raw = sessionStorage.getItem(STORAGE_KEY)
const ts = sessionStorage.getItem(STORAGE_TS) const ts = sessionStorage.getItem(STORAGE_TS)
if (!k || ts == null) return null if (!raw || ts == null) return []
const t = Number(ts) const t = Number(ts)
if (!Number.isFinite(t) || Date.now() - t > TTL_MS) { if (!Number.isFinite(t) || Date.now() - t > TTL_MS) {
sessionStorage.removeItem(STORAGE_KEY) sessionStorage.removeItem(STORAGE_KEY)
sessionStorage.removeItem(STORAGE_TS) sessionStorage.removeItem(STORAGE_TS)
return null return []
}
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed.filter((x) => typeof x === 'string' && x)
if (typeof parsed === 'string') return [parsed]
return []
} catch {
/* 旧版:存的是 JSON 字符串化的单个 key或非法 JSON 时按原字符串当作一个 key */
return raw ? [raw] : []
} }
return k
} catch { } catch {
return null return []
} }
} }
function writePersisted(k) { /**
* @param {string[]} keys
*/
function writePersistedKeys(keys) {
if (typeof sessionStorage === 'undefined') return if (typeof sessionStorage === 'undefined') return
try { try {
if (k) { if (keys.length) {
sessionStorage.setItem(STORAGE_KEY, k) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(keys))
sessionStorage.setItem(STORAGE_TS, String(Date.now())) sessionStorage.setItem(STORAGE_TS, String(Date.now()))
} else { } else {
sessionStorage.removeItem(STORAGE_KEY) sessionStorage.removeItem(STORAGE_KEY)
@ -38,29 +52,38 @@ function writePersisted(k) {
} }
} }
/** /** 多个耗时请求可并行:例如「重新生成报告」未完成时又点了「报告预览」,不得互相覆盖。 */
* 长耗时生成类 POST/GET 进行中标记 const inFlightKeys = ref(readPersistedKeys())
* 除模块级 ref 外写入 sessionStorage避免 Vite HMR / 整页刷新后丢失
* 从而出现切换页签后重新生成又可点的假象
*
* key 示例`strategy-draft:12``regenerate-report:12``preview-report:12`
*/
const inFlightKey = ref(readPersisted())
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => { window.addEventListener('storage', (e) => {
if (e.key !== STORAGE_KEY && e.key !== STORAGE_TS) return if (e.key !== STORAGE_KEY && e.key !== STORAGE_TS) return
inFlightKey.value = readPersisted() inFlightKeys.value = readPersistedKeys()
}) })
} }
/**
* 当前进行中的请求 key 列表同一 key 不会重复
* key 示例`strategy-draft:12``regenerate-report:12``preview-report:12`
*/
export function generationInFlightKey() { export function generationInFlightKey() {
return inFlightKey return inFlightKeys
}
function addInFlightKey(key) {
const cur = inFlightKeys.value
if (cur.includes(key)) return
inFlightKeys.value = [...cur, key]
writePersistedKeys(inFlightKeys.value)
}
function removeInFlightKey(key) {
inFlightKeys.value = inFlightKeys.value.filter((k) => k !== key)
writePersistedKeys(inFlightKeys.value)
} }
/** /**
* 切换页签/隐藏标签时浏览器可能中止 fetch表现为 TypeError 此时服务端可能仍在执行 * 切换页签/隐藏标签时浏览器可能中止 fetch服务端可能仍在执行不应移除该 key
* 不应清除进行中标记否则按钮误恢复可点HTTP 4xx/5xx 仍由业务层 return走正常清除
*/ */
function isAmbiguousClientFailure(err) { function isAmbiguousClientFailure(err) {
if (err == null) return false if (err == null) return false
@ -78,19 +101,14 @@ function isAmbiguousClientFailure(err) {
* @returns {Promise<T>} * @returns {Promise<T>}
*/ */
export async function withGenerationInFlight(key, fn) { export async function withGenerationInFlight(key, fn) {
inFlightKey.value = key addInFlightKey(key)
writePersisted(key)
try { try {
const out = await fn() const out = await fn()
if (inFlightKey.value === key) { removeInFlightKey(key)
inFlightKey.value = null
writePersisted(null)
}
return out return out
} catch (e) { } catch (e) {
if (inFlightKey.value === key && !isAmbiguousClientFailure(e)) { if (!isAmbiguousClientFailure(e)) {
inFlightKey.value = null removeInFlightKey(key)
writePersisted(null)
} }
throw e throw e
} }

View File

@ -17,9 +17,10 @@ const regenErr = ref('')
const genInFlight = generationInFlightKey() const genInFlight = generationInFlightKey()
const REGEN_PREFIX = 'regenerate-report:' const REGEN_PREFIX = 'regenerate-report:'
const regenPendingJobId = computed(() => { const regenPendingJobId = computed(() => {
const k = genInFlight.value for (const k of genInFlight.value) {
if (!k || !k.startsWith(REGEN_PREFIX)) return null if (k.startsWith(REGEN_PREFIX)) return k.slice(REGEN_PREFIX.length)
return k.slice(REGEN_PREFIX.length) }
return null
}) })
const regenBusyThisTask = computed( const regenBusyThisTask = computed(
() => regenPendingJobId.value != null && regenPendingJobId.value === selectedId.value, () => regenPendingJobId.value != null && regenPendingJobId.value === selectedId.value,

View File

@ -47,19 +47,21 @@ const K_PACK = 'brief-pack:'
function genKeyMatches(prefix) { function genKeyMatches(prefix) {
const id = selectedId.value const id = selectedId.value
if (!id) return false if (!id) return false
return genInFlight.value === `${prefix}${id}` return genInFlight.value.includes(`${prefix}${id}`)
} }
const loading = computed(() => genKeyMatches(K_PREVIEW)) const loading = computed(() => genKeyMatches(K_PREVIEW))
const briefLoading = computed(() => genKeyMatches(K_BRIEF)) const briefLoading = computed(() => genKeyMatches(K_BRIEF))
const packLoading = computed(() => genKeyMatches(K_PACK)) const packLoading = computed(() => genKeyMatches(K_PACK))
const viewInFlightOtherJobId = computed(() => { const viewInFlightOtherJobId = computed(() => {
const k = genInFlight.value const sid = selectedId.value
if (!k) return null if (!sid) return null
const i = k.lastIndexOf(':') for (const k of genInFlight.value) {
if (i < 0) return null const i = k.lastIndexOf(':')
const jid = k.slice(i + 1) if (i < 0) continue
if (jid === selectedId.value) return null const jid = k.slice(i + 1)
return jid if (jid && jid !== sid) return jid
}
return null
}) })
const successJobs = computed(() => const successJobs = computed(() =>

View File

@ -17,10 +17,12 @@ const err = ref('')
const genInFlight = generationInFlightKey() const genInFlight = generationInFlightKey()
const STRATEGY_PREFIX = 'strategy-draft:' const STRATEGY_PREFIX = 'strategy-draft:'
const strategyDraftPendingJobId = computed(() => { const strategyDraftPendingJobId = computed(() => {
const k = genInFlight.value for (const k of genInFlight.value) {
if (!k || !k.startsWith(STRATEGY_PREFIX)) return null if (k.startsWith(STRATEGY_PREFIX)) return k.slice(STRATEGY_PREFIX.length)
return k.slice(STRATEGY_PREFIX.length) }
return null
}) })
const strategyGeneratingAny = computed(() => strategyDraftPendingJobId.value != null)
const strategyGeneratingThisTask = computed( const strategyGeneratingThisTask = computed(
() => () =>
strategyDraftPendingJobId.value != null && strategyDraftPendingJobId.value != null &&
@ -192,7 +194,7 @@ watch(
<button <button
type="button" type="button"
class="ma-btn ma-btn-primary" class="ma-btn ma-btn-primary"
:disabled="!selectedId || strategyGeneratingThisTask" :disabled="!selectedId || strategyGeneratingAny"
@click="generateAndGoPreview" @click="generateAndGoPreview"
> >
{{ strategyGeneratingThisTask ? '生成中…' : '生成并前往预览' }} {{ strategyGeneratingThisTask ? '生成中…' : '生成并前往预览' }}