import { computed, onBeforeUnmount, onMounted, ref, shallowRef, type Ref, type ShallowRef } from 'vue' import { isAsyncTaskActive, isAsyncTaskTerminal, type AsyncTaskProgress } from '@/types/api' import { normalizeApiError } from '@/utils/business/apiError' const POLL_DELAYS = [2000, 3000, 5000] const MAX_POLL_DELAY = 10000 export interface AsyncTaskPollingOptions { storageKey: string fetchTask: (taskId: number) => Promise isForbidden?: (error: unknown) => boolean autoRestore?: boolean } export interface AsyncTaskPollingState { taskId: Ref task: ShallowRef loading: Ref forbidden: Ref error: Ref isActive: Readonly> start: (taskId: number) => Promise retry: () => Promise stop: () => void clear: () => void } const readStoredTaskId = (storageKey: string): number | null => { try { const value = Number(localStorage.getItem(storageKey)) return Number.isInteger(value) && value > 0 ? value : null } catch { return null } } const writeStoredTaskId = (storageKey: string, taskId: number | null) => { try { if (taskId) localStorage.setItem(storageKey, String(taskId)) else localStorage.removeItem(storageKey) } catch { // Storage may be unavailable in private browsing or restricted webviews. } } const getErrorMessage = (error: unknown) => normalizeApiError(error).message export function useAsyncTaskPolling( options: AsyncTaskPollingOptions ): AsyncTaskPollingState { const taskId = ref(readStoredTaskId(options.storageKey)) const task = shallowRef(null) const loading = ref(false) const forbidden = ref(false) const error = ref(null) const isActive = computed(() => isAsyncTaskActive(task.value?.status)) let timer: number | undefined let pollIndex = 0 let requestInFlight = false const clearTimer = () => { if (timer !== undefined) { window.clearTimeout(timer) timer = undefined } } const stop = () => { clearTimer() } const clear = () => { stop() taskId.value = null task.value = null error.value = null forbidden.value = false writeStoredTaskId(options.storageKey, null) } const schedule = () => { clearTimer() if (document.hidden || !isActive.value || !taskId.value) return const delay = POLL_DELAYS[pollIndex] ?? MAX_POLL_DELAY pollIndex += 1 timer = window.setTimeout(() => { timer = undefined void refresh() }, delay) } const refresh = async () => { if (!taskId.value || requestInFlight || document.hidden) return requestInFlight = true loading.value = true forbidden.value = false error.value = null try { const nextTask = await options.fetchTask(taskId.value) task.value = nextTask if (isAsyncTaskTerminal(nextTask.status)) { stop() writeStoredTaskId(options.storageKey, null) } else { schedule() } } catch (requestError) { if (options.isForbidden?.(requestError) ?? false) { forbidden.value = true stop() } else { error.value = getErrorMessage(requestError) stop() } } finally { loading.value = false requestInFlight = false } } const start = async (nextTaskId: number) => { if (!Number.isInteger(nextTaskId) || nextTaskId <= 0) return taskId.value = nextTaskId task.value = null pollIndex = 0 writeStoredTaskId(options.storageKey, nextTaskId) await refresh() } const retry = async () => { if (!taskId.value) return await refresh() } const handleVisibilityChange = () => { if (document.hidden) { stop() return } if (taskId.value && isActive.value) { pollIndex = 0 void refresh() } } onMounted(() => { document.addEventListener('visibilitychange', handleVisibilityChange) if (options.autoRestore !== false && taskId.value) void refresh() }) onBeforeUnmount(() => { stop() document.removeEventListener('visibilitychange', handleVisibilityChange) }) return { taskId, task: task as ShallowRef, loading, forbidden, error, isActive, start, retry, stop, clear } }