perf(全链路): 连接池/事务边界/轮询与 IO 效率收口

Java:
- LLM 180s 读超时不再被全局 call-timeout 静默截断成 90s(长思考请求被掐断→重试→付费网关二次计费)
- 代理 HttpClient 缓存改有界 LRU(jikip 每次提取新 IP,无界缓存持续泄漏 selector 线程与连接池)
- 12 个 service 的 Redis 任务锁移出 @Transactional(自旋最坏 10s 白占 DB 连接,池仅 30),远端对象删/传改 afterCommit
- 结果文件 Job 闸门拒绝时不再回退内联执行(改重新入队,避免把背压转嫁给 MQ 消费线程)
- imagevideo 每秒扫描加列投影、过期清理加 LIMIT;权限页整表查询改列投影(不再拉回密码哈希)
- 哈希改 HexFormat;补 5 处"不能改"的技术依据注释(批量插入会丢回填主键、流式丢模板与图片等)

前端:4 个工具页轮询改轻量端点(带 fallback);PriceTrack 快照节流写盘;候选店铺表分页;页面隐藏时停表

客户端:HTTP 连接池按出口复用(Session 仍每请求新建,保持无跨请求状态);品牌检测 WIPO 逐请求握手;
代理配置按 mtime 缓存;串行任务改专属池;异常降级为标签页重连;紫鸟启动改端口轮询;模板编译缓存;
日志上报连接与落盘收口;Flask 版本 API 改按请求复用连接
This commit is contained in:
2026-09-15 23:01:59 +08:00
parent d6f8368493
commit 228d481211
44 changed files with 1550 additions and 423 deletions
@@ -194,23 +194,29 @@ async function pollOnce() {
for (const key of Object.keys(lineProgressMap.value)) {
if (!busy.some((b) => String(b.id) === key)) delete lineProgressMap.value[key]
}
for (const item of busy) {
const id = Number(item.id)
if (!id) continue
try {
const res = await getBrandTask(id)
const lp = res?.line_progress
if (lp?.has_progress && lp.info) {
lineProgressMap.value[String(id)] = {
current: Number(lp.info.current_line) || 0,
total: Number(lp.info.total_lines) || 0,
phase: lp.info.phase,
}
} else {
delete lineProgressMap.value[String(id)]
// 并发拉取:此前在 for 循环里逐个 await,任务多时一轮耗时随任务数线性叠加
//(brand 模块没有批量进度端点,见 shared/api/endpoints.ts,只能逐个查,故用并发而非 batch)
const busyIds = busy.map((item) => Number(item.id)).filter((id) => Number.isFinite(id) && id > 0)
const details = await Promise.all(
busyIds.map(async (id) => {
try {
return { id, res: await getBrandTask(id) }
} catch {
// 单条详情失败不中断整轮轮询
return { id, res: null }
}
} catch {
// 单条详情失败不中断整轮轮询
}),
)
for (const { id, res } of details) {
const lp = res?.line_progress
if (lp?.has_progress && lp.info) {
lineProgressMap.value[String(id)] = {
current: Number(lp.info.current_line) || 0,
total: Number(lp.info.total_lines) || 0,
phase: lp.info.phase,
}
} else {
delete lineProgressMap.value[String(id)]
}
}
scheduleNextPoll()
@@ -179,6 +179,7 @@ import {
type CollectDataTaskDetailVo,
type UploadFileVo,
} from '@/shared/api/java-modules'
import { getPollingProgressBatch } from '@/shared/api/task-progress-polling.ts'
import { formatDateTime } from '@/shared/utils/datetime'
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
import { runBatchDelete } from '@/shared/utils/batch-delete'
@@ -236,24 +237,70 @@ const taskSnapshots = ref<Record<number, CollectDataTaskDetailVo>>({})
const POLLING_STORAGE_KEY = 'brand:collect-data:polling-task-ids'
/** 轮询可比对的轻量字段(/tasks/progress/light 白名单里本模块快照有的字段),只有它们变化才重建快照对象 */
function taskProgressSignatureOf(detail: CollectDataTaskDetailVo) {
const task = detail.task
if (!task) return ''
return [task.status ?? '', task.updatedAt ?? ''].join('|')
}
/**
* currentItems 的 taskId → 下标索引。
* 轮询每轮都要按 taskId 定位行,此前每次都对整份列表 map 一遍;有索引后只改命中的那一行。
* 列表被整体替换(loadHistory)时必须重建;使用处还会做一次 taskId 校验兜底。
*/
let currentItemsIndex = new Map<number, number>()
function rebuildCurrentItemsIndex() {
const next = new Map<number, number>()
currentItems.value.forEach((item, index) => {
const taskId = Number(item.taskId)
if (Number.isFinite(taskId) && taskId > 0) next.set(taskId, index)
})
currentItemsIndex = next
}
/** 原地更新 currentItems 里指定任务的那一行(找不到就什么都不做) */
function patchCurrentItem(
taskId: number,
latestItem: CollectDataHistoryItem | undefined,
status: string | undefined,
) {
let index = currentItemsIndex.get(taskId)
if (index === undefined || Number(currentItems.value[index]?.taskId) !== taskId) {
rebuildCurrentItemsIndex()
index = currentItemsIndex.get(taskId)
}
if (index === undefined) return
const current = currentItems.value[index]
if (!current) return
currentItems.value[index] = {
...current,
...(latestItem || {}),
taskStatus: status ?? latestItem?.taskStatus ?? current.taskStatus,
}
}
const progressLoop = useTaskProgressLoop<CollectDataTaskDetailVo>({
scope: 'collect-data-tab',
storageKey: POLLING_STORAGE_KEY,
fetchProgress: (ids) => getCollectDataTaskProgressBatch(ids),
// F5:主轮询只读 status/fileReady 等轻量白名单字段,走 light 端点减小响应体;
// 轻量端点异常/空结果时适配器自动回退重型 batch(否则会漏刷新结果)
fetchProgress: (ids) => getPollingProgressBatch('collectData', ids, {
fallback: () => getCollectDataTaskProgressBatch(ids),
}),
extractTaskId: (detail) => detail.task?.id ?? null,
extractStatus: (detail) => detail.task?.status ?? '',
onUpdate: (taskId, detail) => {
const prev = taskSnapshots.value[taskId]
taskSnapshots.value = {
...taskSnapshots.value,
[taskId]: prev
? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) }, items: detail.items ?? prev.items }
: detail,
const merged: CollectDataTaskDetailVo = prev
? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) }, items: detail.items ?? prev.items }
: detail
// 只有轮询字段(或行明细)真的变了才展开快照对象:整份快照含行明细,无变化时重建纯属浪费
if (!prev || merged.items !== prev.items || taskProgressSignatureOf(prev) !== taskProgressSignatureOf(merged)) {
taskSnapshots.value = { ...taskSnapshots.value, [taskId]: merged }
}
const latestItem = detail.items?.[0]
currentItems.value = currentItems.value.map((item) => item.taskId === taskId
? { ...item, ...(latestItem || {}), taskStatus: detail.task?.status ?? latestItem?.taskStatus ?? item.taskStatus }
: item)
patchCurrentItem(taskId, detail.items?.[0], detail.task?.status)
},
onTerminal: async (taskId) => {
if (taskSnapshots.value[taskId]) {
@@ -624,6 +671,8 @@ async function loadHistory() {
}
}
currentItems.value = merged
// 列表整体替换后重建 taskId 索引,轮询就能原地更新命中的那一行
rebuildCurrentItemsIndex()
} catch {
/* 忽略:历史接口失败时保持空列表 */
}
@@ -33,7 +33,7 @@
</div>
<div v-else class="candidate-table-scroll">
<el-table
:data="candidates"
:data="pagedCandidates"
row-key="id"
height="260"
class="candidate-table"
@@ -59,6 +59,14 @@
</el-table-column>
</el-table>
</div>
<el-pagination
v-if="candidateTotal > candidatePageSize"
class="candidate-pagination"
layout="total, prev, pager, next"
:total="candidateTotal"
:page-size="candidatePageSize"
v-model:current-page="candidatePage"
/>
<div class="section-title condition-title">
<span>删除条件</span>
@@ -310,6 +318,13 @@ const {
total: matchedTotal,
paged: pagedMatchedItems,
} = useTablePaging(matchedItems)
// F9:备选店铺表同样只切渲染窗口(分页不改变数据源,勾选/删除语义不变)
const {
page: candidatePage,
pageSize: candidatePageSize,
total: candidateTotal,
paged: pagedCandidates,
} = useTablePaging(candidates)
const historyItems = ref<PatrolDeleteHistoryItem[]>([]);
const dashboard = ref<PatrolDeleteDashboardVo>({
candidateCount: 0,
@@ -467,10 +467,37 @@ function loadTaskSnapshotsFromStorage() {
}
}
function saveTaskSnapshotsToStorage() {
/** 快照落盘最小间隔:快照可达数百 KB,全量 JSON.stringify + setItem 会阻塞主线程 */
const SNAPSHOT_SAVE_MIN_INTERVAL_MS = 30000
let lastSnapshotSaveAt = 0
let snapshotSavePending = false
/**
* 快照写盘(节流)。
*
* 每轮轮询都序列化整份快照会周期性卡顿主线程,因此:
* - 内容未变时 setStorageJson 内部已会跳过写入;
* - 这里再按 SNAPSHOT_SAVE_MIN_INTERVAL_MS 节流,被节流掉的写入挂起,
* 下一次(或 force / 组件卸载前的 flush)落盘时一并写入最新内容。
*/
function saveTaskSnapshotsToStorage(force = false) {
if (typeof window === 'undefined') return
const now = Date.now()
if (!force && lastSnapshotSaveAt > 0 && now - lastSnapshotSaveAt < SNAPSHOT_SAVE_MIN_INTERVAL_MS) {
snapshotSavePending = true
return
}
snapshotSavePending = false
lastSnapshotSaveAt = now
setStorageJson(taskSnapshotsStorageKey(), taskSnapshots.value, Object.keys(taskSnapshots.value).length === 0)
}
/** 卸载前把节流挂起的快照补写一次,避免最后一次变更丢失 */
function flushTaskSnapshotsToStorage() {
if (!snapshotSavePending) return
saveTaskSnapshotsToStorage(true)
}
function loadMatchedItemsFromStorage() {
try {
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(matchedItemsStorageKey()) : null
@@ -1147,7 +1174,7 @@ async function processMatchedQueue() {
...taskDetails.value,
[taskVo.taskId]: 'RUNNING',
}
saveTaskSnapshotsToStorage()
saveTaskSnapshotsToStorage(true)
saveTaskDetailsToStorage()
const queuePayload = await buildQueuePayload(taskVo, row)
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
@@ -1233,7 +1260,7 @@ function recordCreatedTask(taskVo: PriceTrackCreateTaskVo) {
...taskDetails.value,
[taskVo.taskId]: 'RUNNING',
}
saveTaskSnapshotsToStorage()
saveTaskSnapshotsToStorage(true)
saveTaskDetailsToStorage()
}
@@ -1418,37 +1445,68 @@ function getPollIntervalMs() {
return getTaskPollIntervalMs()
}
/** 轮询可比对的轻量字段(与 /tasks/progress/light 白名单一致,外加只有重型端点才带的起止时间) */
function taskProgressSignature(task?: PriceTrackTaskDetailVo['task'] | null) {
if (!task) return ''
return [
task.status ?? '',
task.createdAt ?? '',
task.finishedAt ?? '',
task.updatedAt ?? '',
].join('|')
}
async function refreshTaskBatch() {
const ids = pollingTaskIds.value.filter((id) => id > 0)
if (!ids.length) return
try {
const batch = await getPriceTrackTaskProgressBatch(ids)
let changed = false
// F5:主轮询只读 status/fileReady 等轻量白名单字段,走 light 端点减小轮询响应体;
// 轻量端点异常或返回空结果时适配器自动回退重型 batch(否则会漏刷新结果)
const batch = await getPollingProgressBatch('priceTrack', ids, {
fallback: () => getPriceTrackTaskProgressBatch(ids),
})
let settled = false
let snapshotsChanged = false
const nextSnapshots = { ...taskSnapshots.value }
for (const missingId of batch.missingTaskIds || []) {
console.log(`[price-track] 任务 ${missingId} 在服务端已不存在,停止轮询`)
removePollingTask(missingId)
delete nextSnapshots[missingId]
snapshotsChanged = true
}
for (const detail of batch.items || []) {
const taskId = detail.task?.id
const status = detail.task?.status
if (typeof taskId !== 'number' || taskId <= 0) continue
const prev = nextSnapshots[taskId]
nextSnapshots[taskId] = prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail
const merged: PriceTrackTaskDetailVo = prev
? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } }
: detail
// 只有轮询字段真的变了才重写快照:整份快照数百 KB,无变化时重建对象 + 落盘纯属浪费
if (!prev || taskProgressSignature(prev.task) !== taskProgressSignature(merged.task)) {
nextSnapshots[taskId] = merged
snapshotsChanged = true
}
if (status) {
taskDetails.value[taskId] = status
if (status === 'SUCCESS' || status === 'FAILED') {
console.log(`[price-track] 任务 ${taskId} 已到终态 ${status},移出轮询集合并刷新列表`)
removePollingTask(taskId)
changed = true
settled = true
}
}
}
taskSnapshots.value = nextSnapshots
saveTaskSnapshotsToStorage()
if (snapshotsChanged) {
taskSnapshots.value = nextSnapshots
saveTaskSnapshotsToStorage()
}
saveTaskDetailsToStorage()
await loadHistory()
syncPollingIdsWithHistory()
if (changed) {
// 历史列表只在任务落定(或快照真变化)时拉取:此前每 10s 无条件重查一次,纯属浪费
if (settled || snapshotsChanged) {
await loadHistory()
syncPollingIdsWithHistory()
}
if (settled) {
await loadDashboard()
void resumeLoopExecutionIfNeeded()
}
@@ -1508,7 +1566,8 @@ function removePollingTask(taskId: number) {
const next = { ...taskSnapshots.value }
delete next[taskId]
taskSnapshots.value = next
saveTaskSnapshotsToStorage()
// 终态清理属于关键变更,跳过节流立即落盘
saveTaskSnapshotsToStorage(true)
}
}
@@ -1679,6 +1738,8 @@ onUnmounted(() => {
clearSleepTimers()
timers.clearScope()
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
// 补写被节流挂起的快照,避免最后一次变更丢失
flushTaskSnapshotsToStorage()
})
/**
@@ -33,7 +33,7 @@
</div>
<div v-else class="candidate-table-scroll">
<el-table
:data="candidates"
:data="pagedCandidates"
row-key="id"
height="260"
class="candidate-table"
@@ -59,6 +59,14 @@
</el-table-column>
</el-table>
</div>
<el-pagination
v-if="candidateTotal > candidatePageSize"
class="candidate-pagination"
layout="total, prev, pager, next"
:total="candidateTotal"
:page-size="candidatePageSize"
v-model:current-page="candidatePage"
/>
<ZiniaoVersionSetting v-model="ziniaoVersion" />
@@ -245,6 +253,13 @@ const {
total: matchedTotal,
paged: pagedMatchedItems,
} = useTablePaging(matchedItems)
// F9:备选店铺表同样只切渲染窗口(分页不改变数据源,勾选/删除语义不变)
const {
page: candidatePage,
pageSize: candidatePageSize,
total: candidateTotal,
paged: pagedCandidates,
} = useTablePaging(candidates)
const historyItems = ref<QueryAsinHistoryItem[]>([]);
const dashboard = ref<QueryAsinDashboardVo>({
candidateCount: 0,
@@ -17,7 +17,7 @@
<div class="section-title">备选区</div>
<div v-if="!candidates.length" class="empty-candidates">暂无备选店铺</div>
<div v-else class="candidate-table-scroll">
<el-table :data="candidates" row-key="id" height="250" class="candidate-table"
<el-table :data="pagedCandidates" row-key="id" height="250" class="candidate-table"
@selection-change="onSelectionChange">
<el-table-column type="selection" width="42" />
<el-table-column prop="shop_name" label="店铺名" min-width="140" show-overflow-tooltip />
@@ -28,6 +28,9 @@
</el-table-column>
</el-table>
</div>
<el-pagination v-if="candidateTotal > candidatePageSize" class="candidate-pagination"
layout="total, prev, pager, next" :total="candidateTotal" :page-size="candidatePageSize"
v-model:current-page="candidatePage" />
<div class="section-title">抓取国家与顺序</div>
<div class="country-pref-checks">
@@ -187,6 +190,13 @@ const {
total: matchedTotal,
paged: pagedMatchedItems,
} = useTablePaging(matchedItems)
// F9:备选店铺表同样只切渲染窗口(分页不改变数据源,勾选/删除语义不变)
const {
page: candidatePage,
pageSize: candidatePageSize,
total: candidateTotal,
paged: pagedCandidates,
} = useTablePaging(candidates)
const historyItems = ref<ShopDataCrawlHistoryItem[]>([])
const dashboard = ref<ShopDataCrawlDashboardVo>({ candidateCount: 0, processedTaskCount: 0, successTaskCount: 0, failedTaskCount: 0 })
const orderedCountryCodes = ref<string[]>(COUNTRY_OPTIONS.map((row) => row.code))
@@ -452,7 +452,8 @@ function sleep(ms: number) { if (disposed) return Promise.resolve(); return time
function clearSleepTimers() { timers.clearCategory('queue-wait') }
function isTransientBackendError(error: unknown) { const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase(); return TRANSIENT_BACKEND_ERROR_PATTERNS.some((pattern) => message.includes(pattern.toLowerCase())) }
async function withTransientRetry<T>(action: () => Promise<T>, onRetry: (attempt: number, maxAttempts: number) => void, maxAttempts = 10) { let attempt = 0; while (true) { if (disposed) throw new Error('component disposed'); try { return await action() } catch (error) { attempt += 1; if (!isTransientBackendError(error) || attempt >= maxAttempts) throw error; onRetry(attempt, maxAttempts); await sleep(getPollIntervalMs()) } } }
async function refreshTaskBatch() { if (!pollingTaskIds.value.length) return { settledTaskIds: [] as number[] }; try { const batch = await getShopMatchTaskProgressBatch(pollingTaskIds.value); const nextSnapshots = { ...taskSnapshots.value }; const settledTaskIds = new Set<number>(); for (const missingId of batch.missingTaskIds || []) { removeTaskLocally(missingId); delete nextSnapshots[missingId]; settledTaskIds.add(missingId) } for (const detail of batch.items || []) { const taskId = detail.task?.id; if (!taskId) continue; const prev = nextSnapshots[taskId]; nextSnapshots[taskId] = prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail; const status = detail.task?.status || ''; if (!status) continue; taskDetails.value[taskId] = status; if (isTaskTerminalStatus(status)) { removePollingTask(taskId); settledTaskIds.add(taskId); continue } if (status === 'SCHEDULED') { stopPollingTask(taskId); settledTaskIds.add(taskId) } } taskSnapshots.value = nextSnapshots; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); restoreScheduledDispatches(); return { settledTaskIds: Array.from(settledTaskIds) } } catch { return { settledTaskIds: [] as number[] } } }
async function refreshTaskBatch() { if (!pollingTaskIds.value.length) return { settledTaskIds: [] as number[] }; try { // F5:主轮询只读 status 等轻量白名单字段,走 light 端点减小响应体;轻量异常/空结果时回退重型 batch(否则会漏刷新结果)
const batch = await getPollingProgressBatch('shopMatch', pollingTaskIds.value, { fallback: () => getShopMatchTaskProgressBatch(pollingTaskIds.value) }); const nextSnapshots = { ...taskSnapshots.value }; const settledTaskIds = new Set<number>(); for (const missingId of batch.missingTaskIds || []) { removeTaskLocally(missingId); delete nextSnapshots[missingId]; settledTaskIds.add(missingId) } for (const detail of batch.items || []) { const taskId = detail.task?.id; if (!taskId) continue; const prev = nextSnapshots[taskId]; nextSnapshots[taskId] = prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail; const status = detail.task?.status || ''; if (!status) continue; taskDetails.value[taskId] = status; if (isTaskTerminalStatus(status)) { removePollingTask(taskId); settledTaskIds.add(taskId); continue } if (status === 'SCHEDULED') { stopPollingTask(taskId); settledTaskIds.add(taskId) } } taskSnapshots.value = nextSnapshots; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); restoreScheduledDispatches(); return { settledTaskIds: Array.from(settledTaskIds) } } catch { return { settledTaskIds: [] as number[] } } }
function getPollIntervalMs() { return getTaskPollIntervalMs() }
function scheduleNextPoll(immediate = false) { if (disposed) return; if (pollTimer.value) { if (!immediate) return; timers.clearTimer('task-poll', pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (disposed) return; if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingTaskIds.value.length) scheduleNextPoll(); return } pollingInFlight.value = true; try { const { settledTaskIds } = await refreshTaskBatch(); if (settledTaskIds.length) await Promise.allSettled([loadHistory(), loadDashboard()]) } finally { pollingInFlight.value = false } if (!disposed && pollingTaskIds.value.length) pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs()) }
function ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
@@ -44,7 +44,7 @@
</div>
<div v-else class="candidate-table-scroll">
<el-table
:data="candidates"
:data="pagedCandidates"
row-key="id"
height="260"
class="candidate-table"
@@ -70,6 +70,14 @@
</el-table-column>
</el-table>
</div>
<el-pagination
v-if="candidateTotal > candidatePageSize"
class="candidate-pagination"
layout="total, prev, pager, next"
:total="candidateTotal"
:page-size="candidatePageSize"
v-model:current-page="candidatePage"
/>
<ZiniaoVersionSetting v-model="ziniaoVersion" />
@@ -296,6 +304,13 @@ const {
total: matchedTotal,
paged: pagedMatchedItems,
} = useTablePaging(matchedItems)
// F9:备选店铺表同样只切渲染窗口(分页不改变数据源,勾选/删除语义不变)
const {
page: candidatePage,
pageSize: candidatePageSize,
total: candidateTotal,
paged: pagedCandidates,
} = useTablePaging(candidates)
const historyItems = ref<WithdrawHistoryItem[]>([]);
const dashboard = ref<WithdrawDashboardVo>({
candidateCount: 0,
@@ -139,6 +139,8 @@ export function useTaskProgressLoop<TDetail>(
let pollTimer: number | null = null
let disposed = false
let failureCount = 0
/** 页面隐藏导致停表时为 true;恢复可见后据此判断需要重启轮询 */
let pausedForVisibility = false
function persist() {
writeIdsToStorage(options.storageKey, taskIds.value)
@@ -237,13 +239,13 @@ export function useTaskProgressLoop<TDetail>(
}
function scheduleNext(immediate = false) {
if (disposed) return
if (disposed || pausedForVisibility) return
if (pollTimer != null && !immediate) return
clearPollTimer()
const run = async () => {
pollTimer = null
if (disposed) return
if (disposed || pausedForVisibility) return
if (!taskIds.value.length) return
if (inFlight.value) {
// 上一次还没回,按退避策略延迟重试(默认 500ms,可配置)
@@ -251,7 +253,7 @@ export function useTaskProgressLoop<TDetail>(
return
}
await refreshOnce()
if (!disposed && taskIds.value.length > 0) {
if (!disposed && !pausedForVisibility && taskIds.value.length > 0) {
// 连续失败时按指数退避拉长间隔,避免后端故障时每 5s 撞一次(成功即复位)
const delay = failureCount > 0 ? getTaskPollBackoffMs(failureCount) : intervalMs()
pollTimer = timers.setTimeout('task-poll', run, delay)
@@ -266,7 +268,7 @@ export function useTaskProgressLoop<TDetail>(
}
function ensure(immediate = false) {
if (disposed) return
if (disposed || pausedForVisibility) return
if (!taskIds.value.length) return
if (pollTimer != null && !immediate) return
scheduleNext(immediate)
@@ -292,38 +294,67 @@ export function useTaskProgressLoop<TDetail>(
{ flush: 'post' },
)
/**
* 页面隐藏时停表:后台标签页不再按隐藏间隔空转请求(每轮仍是完整的进度查询)。
* 恢复可见时立即补拉一轮并重启轮询(见下面的 visibilitychange 处理器)。
*/
function pauseForVisibility() {
if (disposed || !taskIds.value.length) return
pausedForVisibility = true
stop()
console.log(`[task-poll] 页面隐藏,暂停轮询: ${options.scope}`)
}
/** 恢复可见:按前台刷新开关决定是否立即补拉,但无论如何都要把轮询重启起来 */
function resumeAfterVisibility() {
if (disposed) return
const wasPaused = pausedForVisibility
pausedForVisibility = false
if (!taskIds.value.length) return
if (getTaskForegroundRefreshEnabled()) {
const delay = getTaskForegroundRefreshDelayMs()
if (delay > 0) {
scheduleNextDelayed(delay)
} else {
scheduleNext(true)
}
if (wasPaused) {
console.log(`[task-poll] 页面恢复可见,已补拉一轮并重启轮询: ${options.scope}`)
}
return
}
if (wasPaused) {
// 开关关闭时不立即补拉,但必须重启轮询,否则隐藏过一次就再也不轮询
console.log(`[task-poll] 页面恢复可见,重启轮询(前台即时刷新已关闭): ${options.scope}`)
scheduleNext()
}
}
// 切到前台后立刻拉一次(可配置开关与延迟),让用户回到页面看到的是最新状态
let visibilityHandler: (() => void) | null = null
if (typeof document !== 'undefined') {
visibilityHandler = () => {
if (
getTaskForegroundRefreshEnabled() &&
document.visibilityState === 'visible' &&
taskIds.value.length > 0
) {
const delay = getTaskForegroundRefreshDelayMs()
if (delay > 0) {
scheduleNextDelayed(delay)
} else {
scheduleNext(true)
}
if (document.visibilityState === 'visible') {
resumeAfterVisibility()
} else {
pauseForVisibility()
}
}
document.addEventListener('visibilitychange', visibilityHandler)
}
function scheduleNextDelayed(delayMs: number) {
if (disposed) return
if (disposed || pausedForVisibility) return
clearPollTimer()
const run = () => {
pollTimer = null
if (disposed || !taskIds.value.length) return
if (disposed || pausedForVisibility || !taskIds.value.length) return
if (inFlight.value) {
pollTimer = timers.setTimeout('task-poll', run, getTaskPollBackoffMs(0))
return
}
void refreshOnce()
if (!disposed && taskIds.value.length > 0) {
if (!disposed && !pausedForVisibility && taskIds.value.length > 0) {
const delay = failureCount > 0 ? getTaskPollBackoffMs(failureCount) : intervalMs()
pollTimer = timers.setTimeout('task-poll', run, delay)
}
+40 -2
View File
@@ -57,6 +57,8 @@ interface VisibilityHarness {
loop: ReturnType<typeof useTaskProgressLoop<{ taskId?: number; status?: string }>>
doc: FakeDoc
sink: TimerSinkEntry[]
/** 被 clearTimeout 取消过的定时器 id(用于断言隐藏时确实停了表) */
cleared: unknown[]
fetchCount: () => number
}
@@ -72,6 +74,7 @@ async function makeVisibilityLoop(
}
const doc = installFakeDocument(visibility)
const sink: TimerSinkEntry[] = []
const cleared: unknown[] = []
const storage = new Map<string, string>()
let fetchCount = 0
;(globalThis as Record<string, unknown>).window = {
@@ -84,7 +87,10 @@ async function makeVisibilityLoop(
sink.push({ fn, ms })
return globalThis.setTimeout(fn, ms)
},
clearTimeout: (id: unknown) => globalThis.clearTimeout(id as number),
clearTimeout: (id: unknown) => {
cleared.push(id)
globalThis.clearTimeout(id as number)
},
setInterval: (fn: () => void, ms: number) => globalThis.setInterval(fn, ms),
clearInterval: (id: unknown) => globalThis.clearInterval(id as number),
}
@@ -98,7 +104,7 @@ async function makeVisibilityLoop(
extractStatus: (d) => d?.status,
})
loop.reset([1])
return { loop, doc, sink, fetchCount: () => fetchCount }
return { loop, doc, sink, cleared, fetchCount: () => fetchCount }
}
test('test_hidden_long_interval', async () => {
@@ -187,3 +193,35 @@ test('test_hidden_no_immediate_refresh', async () => {
h.loop.dispose()
cleanupDocument()
})
test('test_hidden_stops_polling', async () => {
const h = await makeVisibilityLoop('visible')
await tick(10)
const scheduledBefore = h.sink.length
const before = h.fetchCount()
h.doc.setVisibility('hidden')
h.doc.dispatch('visibilitychange')
await tick(10)
assert.equal(h.fetchCount(), before, '隐藏后不再发起请求')
assert.equal(h.sink.length, scheduledBefore, '隐藏后不再排定新的轮询定时器')
assert.ok(h.cleared.length > 0, '隐藏时清掉了已排定的轮询定时器')
h.loop.dispose()
cleanupDocument()
})
test('test_visible_restarts_polling', async () => {
const h = await makeVisibilityLoop('visible')
await tick(10)
h.doc.setVisibility('hidden')
h.doc.dispatch('visibilitychange')
await tick(10)
const before = h.fetchCount()
h.doc.setVisibility('visible')
h.doc.dispatch('visibilitychange')
await tick(10)
assert.equal(h.fetchCount(), before + 1, '恢复可见立即补拉一轮')
assert.equal(h.sink.at(-1)?.ms, TASK_POLL_VISIBLE_INTERVAL_MS, '恢复可见后按可见间隔重启轮询')
assert.deepEqual(h.loop.taskIds.value, [1], '停表期间任务集合不变')
h.loop.dispose()
cleanupDocument()
})