179 lines
4.3 KiB
TypeScript
179 lines
4.3 KiB
TypeScript
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<T extends AsyncTaskProgress> {
|
|
storageKey: string
|
|
fetchTask: (taskId: number) => Promise<T>
|
|
isForbidden?: (error: unknown) => boolean
|
|
autoRestore?: boolean
|
|
}
|
|
|
|
export interface AsyncTaskPollingState<T extends AsyncTaskProgress> {
|
|
taskId: Ref<number | null>
|
|
task: ShallowRef<T | null>
|
|
loading: Ref<boolean>
|
|
forbidden: Ref<boolean>
|
|
error: Ref<string | null>
|
|
isActive: Readonly<Ref<boolean>>
|
|
start: (taskId: number) => Promise<void>
|
|
retry: () => Promise<void>
|
|
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<T extends AsyncTaskProgress>(
|
|
options: AsyncTaskPollingOptions<T>
|
|
): AsyncTaskPollingState<T> {
|
|
const taskId = ref<number | null>(readStoredTaskId(options.storageKey))
|
|
const task = shallowRef<T | null>(null)
|
|
const loading = ref(false)
|
|
const forbidden = ref(false)
|
|
const error = ref<string | null>(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<T | null>,
|
|
loading,
|
|
forbidden,
|
|
error,
|
|
isActive,
|
|
start,
|
|
retry,
|
|
stop,
|
|
clear
|
|
}
|
|
}
|