增加公共下载进度、增加接收SKU、密钥分别存放

This commit is contained in:
super
2026-05-28 16:41:20 +08:00
parent ca4a2cd07a
commit 2ed1250604
119 changed files with 4077 additions and 293 deletions
@@ -0,0 +1,284 @@
import { onBeforeUnmount, ref, watch, type Ref } from 'vue'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
/**
* 通用任务进度轮询组合式函数。
*
* 各模块统一使用:保存 taskId 列表(可选 localStorage 持久化),按当前可见性
* 周期性请求批量进度接口,每个任务到达终态时回调上层做状态同步、列表刷新等。
*
* 使用方式:
* const loop = useTaskProgressLoop<MyTaskDetailVo>({
* scope: 'collect-data',
* storageKey: 'brand:collect-data:polling-task-ids',
* fetchProgress: (ids) => getCollectDataTaskProgressBatch(ids),
* extractTaskId: (detail) => detail.task?.id,
* extractStatus: (detail) => detail.task?.status,
* isTerminal: (status) => status === 'SUCCESS' || status === 'FAILED',
* onUpdate: (taskId, detail) => { ... },
* onTerminal: async (taskId, detail) => { await refreshHistory() },
* })
* loop.add(taskId) // 触发轮询
* loop.dispose() // 组件卸载时调用(自动通过 onBeforeUnmount 清理)
*/
export interface TaskProgressLoopOptions<TDetail> {
/** 用于 categorized-timers 的命名空间,例如 'collect-data';同一页面内须唯一 */
scope: string
/** localStorage 持久化的 key;省略则不持久化 */
storageKey?: string
/** 拉取批量进度的接口;返回 items 数组 */
fetchProgress: (taskIds: number[]) => Promise<{ items?: TDetail[] }>
/** 从单条进度详情中提取 taskId */
extractTaskId: (detail: TDetail) => number | null | undefined
/** 从单条进度详情中提取状态字符串(如 'PENDING'/'RUNNING'/'SUCCESS'/'FAILED' */
extractStatus: (detail: TDetail) => string | null | undefined
/** 判定是否终态;默认 SUCCESS / FAILED 视为终态 */
isTerminal?: (status: string) => boolean
/** 每条进度落地时调用,用于上层缓存最新快照 */
onUpdate?: (taskId: number, detail: TDetail) => void
/**
* 任意一个任务进入终态时调用;可以是 async(轮询会等待完成再调度下一轮)。
* 若多个任务同一轮到达终态,会被分别回调。
*/
onTerminal?: (taskId: number, detail: TDetail | undefined, status: string) => void | Promise<void>
/** 轮询周期失败时的回调;默认静默 */
onError?: (error: unknown) => void
/** 自定义轮询间隔;默认根据 document.visibilityState 自适应(5s/30s */
getIntervalMs?: () => number
}
export interface TaskProgressLoopHandle<TDetail> {
taskIds: Ref<number[]>
taskStatuses: Ref<Record<number, string>>
inFlight: Ref<boolean>
add: (taskId: number) => void
remove: (taskId: number) => void
reset: (taskIds: number[]) => void
ensure: (immediate?: boolean) => void
stop: () => void
refreshOnce: () => Promise<void>
isTerminal: (taskId: number) => boolean
dispose: () => void
}
const DEFAULT_TERMINAL = (status: string) => status === 'SUCCESS' || status === 'FAILED'
function readIdsFromStorage(key?: string): number[] {
if (!key || typeof window === 'undefined') return []
try {
const raw = window.localStorage.getItem(key)
if (!raw) return []
const parsed = JSON.parse(raw) as unknown
return Array.isArray(parsed) ? parsed.filter((n): n is number => typeof n === 'number' && n > 0) : []
} catch {
return []
}
}
function writeIdsToStorage(key: string | undefined, ids: number[]) {
if (!key || typeof window === 'undefined') return
try {
if (ids.length === 0) {
window.localStorage.removeItem(key)
} else {
window.localStorage.setItem(key, JSON.stringify(ids))
}
} catch {
/* 写本地存储失败不影响功能 */
}
}
export function useTaskProgressLoop<TDetail>(
options: TaskProgressLoopOptions<TDetail>,
): TaskProgressLoopHandle<TDetail> {
const timers = createCategorizedTimers(`task-progress-loop:${options.scope}`)
const isTerminal = options.isTerminal ?? DEFAULT_TERMINAL
const intervalMs = options.getIntervalMs ?? getTaskPollIntervalMs
const taskIds = ref<number[]>(readIdsFromStorage(options.storageKey))
const taskStatuses = ref<Record<number, string>>({})
const inFlight = ref(false)
let pollTimer: number | null = null
let disposed = false
function persist() {
writeIdsToStorage(options.storageKey, taskIds.value)
}
function add(taskId: number) {
if (!Number.isFinite(taskId) || taskId <= 0) return
if (taskIds.value.includes(taskId)) return
taskIds.value = [...taskIds.value, taskId]
persist()
ensure(true)
}
function remove(taskId: number) {
if (!taskIds.value.includes(taskId)) return
taskIds.value = taskIds.value.filter((id) => id !== taskId)
persist()
if (taskStatuses.value[taskId]) {
const next = { ...taskStatuses.value }
delete next[taskId]
taskStatuses.value = next
}
}
function reset(ids: number[]) {
const cleaned = Array.from(new Set(ids.filter((n) => Number.isFinite(n) && n > 0)))
taskIds.value = cleaned
persist()
}
function isTerminalById(taskId: number) {
const s = taskStatuses.value[taskId]
return !!s && isTerminal(s)
}
async function refreshOnce() {
if (disposed) return
const ids = taskIds.value.filter((id) => id > 0)
if (!ids.length) return
inFlight.value = true
try {
const result = await options.fetchProgress(ids)
const items = result?.items || []
const terminalEvents: Array<{ taskId: number; detail: TDetail | undefined; status: string }> = []
const nextStatuses = { ...taskStatuses.value }
for (const detail of items) {
const id = options.extractTaskId(detail)
if (typeof id !== 'number' || id <= 0) continue
const status = options.extractStatus(detail) || ''
try {
options.onUpdate?.(id, detail)
} catch {
/* onUpdate 抛错不应中断本轮 */
}
if (status) {
nextStatuses[id] = status
if (isTerminal(status)) {
terminalEvents.push({ taskId: id, detail, status })
}
}
}
taskStatuses.value = nextStatuses
for (const event of terminalEvents) {
remove(event.taskId)
try {
await options.onTerminal?.(event.taskId, event.detail, event.status)
} catch {
/* onTerminal 抛错只影响一次回调 */
}
}
} catch (error) {
options.onError?.(error)
} finally {
inFlight.value = false
}
}
function clearPollTimer() {
if (pollTimer != null) {
timers.clearTimer('task-poll', pollTimer)
pollTimer = null
}
}
function scheduleNext(immediate = false) {
if (disposed) return
if (pollTimer != null && !immediate) return
clearPollTimer()
const run = async () => {
pollTimer = null
if (disposed) return
if (!taskIds.value.length) return
if (inFlight.value) {
// 上一次还没回,500ms 后再试
pollTimer = timers.setTimeout('task-poll', run, 500)
return
}
await refreshOnce()
if (!disposed && taskIds.value.length > 0) {
pollTimer = timers.setTimeout('task-poll', run, intervalMs())
}
}
if (immediate) {
void run()
} else {
pollTimer = timers.setTimeout('task-poll', run, intervalMs())
}
}
function ensure(immediate = false) {
if (disposed) return
if (!taskIds.value.length) return
if (pollTimer != null && !immediate) return
scheduleNext(immediate)
}
function stop() {
clearPollTimer()
}
// 任务列表清空时自动停止;新增时自动启动一轮
watch(
taskIds,
(ids, prev) => {
if (disposed) return
if (!ids.length) {
stop()
return
}
if (!prev || prev.length === 0) {
scheduleNext(true)
}
},
{ flush: 'post' },
)
// 切到前台后立刻拉一次,让用户回到页面看到的是最新状态
let visibilityHandler: (() => void) | null = null
if (typeof document !== 'undefined') {
visibilityHandler = () => {
if (document.visibilityState === 'visible' && taskIds.value.length > 0) {
scheduleNext(true)
}
}
document.addEventListener('visibilitychange', visibilityHandler)
}
function dispose() {
if (disposed) return
disposed = true
stop()
timers.clearScope()
if (visibilityHandler && typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', visibilityHandler)
visibilityHandler = null
}
}
onBeforeUnmount(() => dispose())
// 初始化时若已有任务(从 storage 恢复),立即开始一轮
if (taskIds.value.length > 0) {
scheduleNext(true)
}
return {
taskIds,
taskStatuses,
inFlight,
add,
remove,
reset,
ensure,
stop,
refreshOnce,
isTerminal: isTerminalById,
dispose,
}
}