task-86: 将隐藏页面轮询间隔、前台恢复和退避策略统一配置化

task-progress-config 新增统一配置 API:getTaskPollBackoffMs(attempt)
指数退避(base×2^attempt 封顶 max)、前台恢复开关与延迟
(getTaskForegroundRefreshEnabled/DelayMs)、configureTaskPolling/
resetTaskPollingConfig 运行时配置。校验失败抛错且零状态变更,
document 缺失时按可见间隔降级。useTaskProgressLoop 改用退避配置
替代硬编码 500ms 重试,前台恢复走可配置开关与延迟(含 in-flight 防
并发)。8 个测试覆盖默认、多配置、幂等、空、单字段、封顶、非法输入
与故障恢复。
This commit is contained in:
2026-08-30 22:51:21 +08:00
parent 62bf1fb206
commit 58e91589df
3 changed files with 261 additions and 7 deletions
@@ -1,5 +1,10 @@
import { onBeforeUnmount, ref, watch, type Ref } from 'vue'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import {
getTaskPollIntervalMs,
getTaskPollBackoffMs,
getTaskForegroundRefreshEnabled,
getTaskForegroundRefreshDelayMs,
} from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import {
createTaskPollingBaseline,
@@ -233,8 +238,8 @@ export function useTaskProgressLoop<TDetail>(
if (disposed) return
if (!taskIds.value.length) return
if (inFlight.value) {
// 上一次还没回,500ms 后再试
pollTimer = timers.setTimeout('task-poll', run, 500)
// 上一次还没回,按退避策略延迟重试(默认 500ms,可配置)
pollTimer = timers.setTimeout('task-poll', run, getTaskPollBackoffMs(0))
return
}
await refreshOnce()
@@ -277,17 +282,44 @@ export function useTaskProgressLoop<TDetail>(
{ flush: 'post' },
)
// 切到前台后立刻拉一次,让用户回到页面看到的是最新状态
// 切到前台后立刻拉一次(可配置开关与延迟),让用户回到页面看到的是最新状态
let visibilityHandler: (() => void) | null = null
if (typeof document !== 'undefined') {
visibilityHandler = () => {
if (document.visibilityState === 'visible' && taskIds.value.length > 0) {
scheduleNext(true)
if (
getTaskForegroundRefreshEnabled() &&
document.visibilityState === 'visible' &&
taskIds.value.length > 0
) {
const delay = getTaskForegroundRefreshDelayMs()
if (delay > 0) {
scheduleNextDelayed(delay)
} else {
scheduleNext(true)
}
}
}
document.addEventListener('visibilitychange', visibilityHandler)
}
function scheduleNextDelayed(delayMs: number) {
if (disposed || pollTimer != null) return
clearPollTimer()
const run = () => {
pollTimer = null
if (disposed || !taskIds.value.length) return
if (inFlight.value) {
pollTimer = timers.setTimeout('task-poll', run, getTaskPollBackoffMs(0))
return
}
void refreshOnce()
if (!disposed && taskIds.value.length > 0) {
pollTimer = timers.setTimeout('task-poll', run, intervalMs())
}
}
pollTimer = timers.setTimeout('task-poll', run, delayMs)
}
function dispose() {
if (disposed) return
disposed = true
@@ -1,10 +1,51 @@
/**
* 任务轮询配置(Task 86 扩展)。
*
* 把隐藏页面轮询间隔、前台恢复与退避策略统一收敛为可编程配置:
* - getTaskPollIntervalMs / getTaskProgressCacheTtlMs:按 document.visibilityState
* 自适应切换可见/隐藏间隔与缓存 TTL(历史行为不变);
* - getTaskPollBackoffMs(attempt):指数退避(base × 2^attempt,封顶 max),
* 供轮询失败/in-flight 重试使用,重试次数不再硬编码;
* - getTaskForegroundRefreshEnabled / getTaskForegroundRefreshDelayMs:控制
* 页面切回前台时是否立即拉取一轮及其延迟;
* - configureTaskPolling / resetTaskPollingConfig:运行时配置与恢复默认。
*
* 纯 TS 模块:配置校验失败时抛错且保持原值(零状态变更),document 缺失
* (Node/SSR)时按可见间隔降级;所有 getter 在任意配置下都有确定返回值。
*/
export const TASK_POLL_VISIBLE_INTERVAL_MS = 10000;
export const TASK_POLL_HIDDEN_INTERVAL_MS = 60000;
export const TASK_PROGRESS_VISIBLE_CACHE_MILLIS = 5000;
export const TASK_PROGRESS_HIDDEN_CACHE_MILLIS = 30000;
export const TASK_POLL_BACKOFF_BASE_MS = 500;
export const TASK_POLL_BACKOFF_MAX_MS = 5000;
export interface TaskPollingConfig {
/** 退避基数(毫秒),必须为正数 */
backoffBaseMs?: number;
/** 退避上限(毫秒),必须为正数 */
backoffMaxMs?: number;
/** 页面切回前台时是否立即拉取一轮,默认 true */
foregroundRefreshEnabled?: boolean;
/** 前台恢复延迟(毫秒),默认 0 */
foregroundRefreshDelayMs?: number;
}
const DEFAULTS: Required<TaskPollingConfig> = {
backoffBaseMs: TASK_POLL_BACKOFF_BASE_MS,
backoffMaxMs: TASK_POLL_BACKOFF_MAX_MS,
foregroundRefreshEnabled: true,
foregroundRefreshDelayMs: 0,
};
let config: Required<TaskPollingConfig> = { ...DEFAULTS };
function isVisible() {
return typeof document === "undefined" || document.visibilityState === "visible";
}
export function getTaskPollIntervalMs() {
if (typeof document !== "undefined" && document.visibilityState !== "visible") {
if (!isVisible()) {
return TASK_POLL_HIDDEN_INTERVAL_MS;
}
return TASK_POLL_VISIBLE_INTERVAL_MS;
@@ -16,3 +57,36 @@ export function getTaskProgressCacheTtlMs() {
}
return TASK_PROGRESS_VISIBLE_CACHE_MILLIS;
}
/** 第 attempt 次退避延迟:base × 2^attempt,封顶 maxattempt 非法时按首次(0)处理 */
export function getTaskPollBackoffMs(attempt: number) {
const safe = Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
const doubled = config.backoffBaseMs * Math.pow(2, safe);
return doubled > config.backoffMaxMs ? config.backoffMaxMs : doubled;
}
export function getTaskForegroundRefreshEnabled() {
return config.foregroundRefreshEnabled;
}
export function getTaskForegroundRefreshDelayMs() {
return config.foregroundRefreshDelayMs;
}
export function configureTaskPolling(partial: TaskPollingConfig) {
const next = { ...config, ...partial };
if (!(next.backoffBaseMs > 0)) {
throw new Error("退避基数必须为正数: " + next.backoffBaseMs);
}
if (!(next.backoffMaxMs > 0)) {
throw new Error("退避上限必须为正数: " + next.backoffMaxMs);
}
if (!(next.foregroundRefreshDelayMs >= 0)) {
throw new Error("恢复延迟不能为负数: " + next.foregroundRefreshDelayMs);
}
config = next;
}
export function resetTaskPollingConfig() {
config = { ...DEFAULTS };
}