优化后台业务
This commit is contained in:
@@ -153,6 +153,7 @@ import {
|
||||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
|
||||
const selectedFileNames = ref<string[]>([])
|
||||
const uploadedFiles = ref<UploadFileVo[]>([])
|
||||
@@ -171,6 +172,8 @@ const queuePayloadText = ref('')
|
||||
const pollingTaskIds = ref<number[]>([])
|
||||
const pollTimer = ref<number | null>(null)
|
||||
const pollingInFlight = ref(false)
|
||||
let disposed = false
|
||||
const timers = createCategorizedTimers('appearance-patent')
|
||||
|
||||
const dashboard = ref<AppearancePatentDashboardVo>({
|
||||
pendingTaskCount: 0,
|
||||
@@ -389,33 +392,36 @@ function removePollingTask(taskId: number) {
|
||||
}
|
||||
|
||||
function ensurePolling() {
|
||||
if (disposed) return
|
||||
if (pollTimer.value != null) return
|
||||
scheduleNextPoll(true)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer.value != null) {
|
||||
window.clearTimeout(pollTimer.value)
|
||||
timers.clearTimer('task-poll', pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNextPoll(immediate = false) {
|
||||
if (disposed) return
|
||||
if (pollTimer.value != null) {
|
||||
if (!immediate) return
|
||||
window.clearTimeout(pollTimer.value)
|
||||
timers.clearTimer('task-poll', pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
const run = async () => {
|
||||
pollTimer.value = null
|
||||
if (disposed) return
|
||||
if (!pollingTaskIds.value.length) return
|
||||
await refreshTaskProgress()
|
||||
if (pollingTaskIds.value.length) {
|
||||
pollTimer.value = window.setTimeout(run, getTaskPollIntervalMs())
|
||||
if (!disposed && pollingTaskIds.value.length) {
|
||||
pollTimer.value = timers.setTimeout('task-poll', run, getTaskPollIntervalMs())
|
||||
}
|
||||
}
|
||||
if (immediate) void run()
|
||||
else pollTimer.value = window.setTimeout(run, getTaskPollIntervalMs())
|
||||
else pollTimer.value = timers.setTimeout('task-poll', run, getTaskPollIntervalMs())
|
||||
}
|
||||
|
||||
async function refreshTaskProgress() {
|
||||
@@ -547,7 +553,11 @@ onMounted(async () => {
|
||||
])
|
||||
})
|
||||
|
||||
onUnmounted(() => stopPolling())
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
stopPolling()
|
||||
timers.clearScope()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -226,6 +226,7 @@ import {
|
||||
} from '@/shared/api/java-modules'
|
||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
|
||||
interface SessionDeleteBrandItem extends DeleteBrandResultItem {
|
||||
_pushed?: boolean
|
||||
@@ -263,6 +264,8 @@ const activeItemKey = ref('')
|
||||
const autoAdvancing = ref(false)
|
||||
const autoRetryTimer = ref<number | null>(null)
|
||||
const waitingForBackendRecovery = ref(false)
|
||||
let disposed = false
|
||||
const timers = createCategorizedTimers('delete-brand')
|
||||
const displayPaths = computed(() => selectedPaths.value.slice(0, 10))
|
||||
|
||||
const currentSectionItems = computed(() => {
|
||||
@@ -892,14 +895,16 @@ function getPollIntervalMs() {
|
||||
}
|
||||
|
||||
function scheduleNextPoll(immediate = false) {
|
||||
if (disposed) return
|
||||
if (pollTimer.value) {
|
||||
if (!immediate) return
|
||||
clearTimeout(pollTimer.value)
|
||||
timers.clearTimer('task-poll', pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
|
||||
const executePoll = async () => {
|
||||
pollTimer.value = null
|
||||
if (disposed) return
|
||||
if (pollingInFlight.value) {
|
||||
scheduleNextPoll()
|
||||
return
|
||||
@@ -933,7 +938,7 @@ function scheduleNextPoll(immediate = false) {
|
||||
syncResultState()
|
||||
}
|
||||
|
||||
if (getPollingTaskIds().length > 0) {
|
||||
if (!disposed && getPollingTaskIds().length > 0) {
|
||||
scheduleNextPoll()
|
||||
}
|
||||
}
|
||||
@@ -941,7 +946,7 @@ function scheduleNextPoll(immediate = false) {
|
||||
if (immediate) {
|
||||
executePoll()
|
||||
} else {
|
||||
pollTimer.value = window.setTimeout(executePoll, getPollIntervalMs())
|
||||
pollTimer.value = timers.setTimeout('task-poll', executePoll, getPollIntervalMs())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -969,7 +974,7 @@ function getTaskStatusInfo(item: DeleteBrandResultItem) {
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer.value) {
|
||||
window.clearTimeout(pollTimer.value)
|
||||
timers.clearTimer('task-poll', pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
clearAutoRetryTimer()
|
||||
@@ -1178,16 +1183,17 @@ function debugAutoAdvance(step: string, payload?: Record<string, unknown>) {
|
||||
|
||||
function clearAutoRetryTimer() {
|
||||
if (autoRetryTimer.value) {
|
||||
window.clearTimeout(autoRetryTimer.value)
|
||||
timers.clearTimer('auto-retry', autoRetryTimer.value)
|
||||
autoRetryTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAutoAdvanceRetry(delayMs = 3000) {
|
||||
if (disposed) return
|
||||
clearAutoRetryTimer()
|
||||
autoRetryTimer.value = window.setTimeout(() => {
|
||||
autoRetryTimer.value = timers.setTimeout('auto-retry', () => {
|
||||
autoRetryTimer.value = null
|
||||
maybeAutoAdvance().catch(() => undefined)
|
||||
if (!disposed) maybeAutoAdvance().catch(() => undefined)
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
@@ -1428,7 +1434,9 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
stopPolling()
|
||||
timers.clearScope()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
@@ -334,6 +334,7 @@ import {
|
||||
} from "@/shared/api/java-modules";
|
||||
import { getPywebviewApi } from "@/shared/bridges/pywebview";
|
||||
import { getTaskPollIntervalMs } from "@/shared/task-progress-config";
|
||||
import { createCategorizedTimers } from "@/shared/utils/categorized-timers";
|
||||
|
||||
const COUNTRY_TEMPLATE = ["德国", "英国", "法国", "意大利", "西班牙"] as const;
|
||||
const MAX_TRANSIENT_ERRORS = 30;
|
||||
@@ -360,6 +361,7 @@ const activeQueueItem = ref<PatrolDeleteShopQueueItem | null>(null);
|
||||
const queueWorkerRunning = ref(false);
|
||||
const autoQueueEnabled = ref(false);
|
||||
let historyPollTimer: number | null = null;
|
||||
const timers = createCategorizedTimers("patrol-delete");
|
||||
|
||||
const matchedRunnableItems = computed(() =>
|
||||
matchedItems.value.filter((item) => item.matched),
|
||||
@@ -399,8 +401,15 @@ function historyItemKey(item: PatrolDeleteHistoryItem) {
|
||||
return `${item.taskId ?? 0}:${item.resultId ?? 0}`;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
if (disposed) return Promise.resolve();
|
||||
return timers.sleep("queue-wait", ms);
|
||||
}
|
||||
|
||||
function clearSleepTimers() {
|
||||
timers.clearCategory("queue-wait");
|
||||
}
|
||||
|
||||
function isTransientBackendError(error: unknown) {
|
||||
@@ -425,6 +434,7 @@ async function withTransientRetry<T>(
|
||||
) {
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
if (disposed) throw new Error("component disposed");
|
||||
try {
|
||||
return await action();
|
||||
} catch (error) {
|
||||
@@ -716,21 +726,21 @@ async function refreshActiveTaskProgress(taskIds?: number[]) {
|
||||
|
||||
function startHistoryPolling() {
|
||||
stopHistoryPolling();
|
||||
if (!hasQueueWork.value) return;
|
||||
if (disposed || !hasQueueWork.value) return;
|
||||
const run = async () => {
|
||||
historyPollTimer = null;
|
||||
if (!hasQueueWork.value) return;
|
||||
if (disposed || !hasQueueWork.value) return;
|
||||
await refreshActiveTaskProgress();
|
||||
if (hasQueueWork.value) {
|
||||
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2);
|
||||
if (!disposed && hasQueueWork.value) {
|
||||
historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
|
||||
}
|
||||
};
|
||||
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2);
|
||||
historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
|
||||
}
|
||||
|
||||
function stopHistoryPolling() {
|
||||
if (historyPollTimer) {
|
||||
window.clearTimeout(historyPollTimer);
|
||||
timers.clearTimer("history-poll", historyPollTimer);
|
||||
historyPollTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -865,6 +875,7 @@ function findHistoryItemByTaskId(taskId: number) {
|
||||
async function waitForTaskTerminal(taskId: number) {
|
||||
let transientErrorCount = 0;
|
||||
while (true) {
|
||||
if (disposed) return "STOPPED";
|
||||
try {
|
||||
await refreshActiveTaskProgress([taskId]);
|
||||
if (transientErrorCount > 0) {
|
||||
@@ -893,7 +904,7 @@ async function waitForTaskTerminal(taskId: number) {
|
||||
}
|
||||
|
||||
async function processQueue() {
|
||||
if (queueWorkerRunning.value) return;
|
||||
if (disposed || queueWorkerRunning.value) return;
|
||||
queueWorkerRunning.value = true;
|
||||
pushing.value = true;
|
||||
startHistoryPolling();
|
||||
@@ -904,7 +915,7 @@ async function processQueue() {
|
||||
throw new Error("当前客户端未提供 enqueue_json");
|
||||
}
|
||||
|
||||
while (activeTaskId.value || pendingQueue.value.length) {
|
||||
while (!disposed && (activeTaskId.value || pendingQueue.value.length)) {
|
||||
if (activeTaskId.value) {
|
||||
const finalStatus = await waitForTaskTerminal(activeTaskId.value);
|
||||
queuePushResult.value =
|
||||
@@ -975,6 +986,7 @@ async function processQueue() {
|
||||
await refreshTaskViews();
|
||||
ElMessage.success("巡店删除队列已按顺序执行完成");
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
const message = error instanceof Error ? error.message : "队列执行失败";
|
||||
queuePushResult.value = message;
|
||||
ElMessage.error(message);
|
||||
@@ -1061,7 +1073,10 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true;
|
||||
stopHistoryPolling();
|
||||
clearSleepTimers();
|
||||
timers.clearScope();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -265,6 +265,7 @@ import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/c
|
||||
import { expandBrandFolderRecursive } from '@/shared/api/brand'
|
||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
import {
|
||||
addPriceTrackCandidate,
|
||||
completePriceTrackLoopChild,
|
||||
@@ -369,6 +370,17 @@ const taskDetails = ref<Record<number, string>>({})
|
||||
const taskSnapshots = ref<Record<number, PriceTrackTaskDetailVo>>({})
|
||||
const pollTimer = ref<number | null>(null)
|
||||
const pollingInFlight = ref(false)
|
||||
let disposed = false
|
||||
const timers = createCategorizedTimers('price-track')
|
||||
|
||||
function sleep(ms: number) {
|
||||
if (disposed) return Promise.resolve()
|
||||
return timers.sleep('queue-wait', ms)
|
||||
}
|
||||
|
||||
function clearSleepTimers() {
|
||||
timers.clearCategory('queue-wait')
|
||||
}
|
||||
|
||||
function uidForStorage() {
|
||||
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
|
||||
@@ -649,8 +661,8 @@ function onCountryDrop(toIndex: number) {
|
||||
}
|
||||
|
||||
function scheduleSaveCountryPref() {
|
||||
if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer)
|
||||
countryPrefSaveTimer = setTimeout(() => {
|
||||
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
|
||||
countryPrefSaveTimer = timers.setTimeout('preference-save', () => {
|
||||
countryPrefSaveTimer = null
|
||||
void persistCountryPref()
|
||||
}, 450)
|
||||
@@ -1043,6 +1055,7 @@ async function waitForTaskTerminal(taskId: number) {
|
||||
let transientErrorCount = 0
|
||||
const maxTransientErrors = 30
|
||||
while (true) {
|
||||
if (disposed) return 'STOPPED'
|
||||
try {
|
||||
const batch = await getPriceTrackTaskProgressBatch([taskId])
|
||||
if (transientErrorCount > 0) {
|
||||
@@ -1086,10 +1099,10 @@ async function waitForTaskTerminal(taskId: number) {
|
||||
if (transientErrorCount === 1 || transientErrorCount % 5 === 0) {
|
||||
ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`)
|
||||
}
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
await sleep(getPollIntervalMs())
|
||||
continue
|
||||
}
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
await sleep(getPollIntervalMs())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1103,6 +1116,7 @@ function nextMatchedQueueItem() {
|
||||
}
|
||||
|
||||
async function processMatchedQueue() {
|
||||
if (disposed) return
|
||||
if (queueWorkerRunning.value) {
|
||||
return
|
||||
}
|
||||
@@ -1131,7 +1145,7 @@ async function processMatchedQueue() {
|
||||
let failedCount = 0
|
||||
try {
|
||||
let index = 0
|
||||
while (autoQueueEnabled.value) {
|
||||
while (!disposed && autoQueueEnabled.value) {
|
||||
const row = nextMatchedQueueItem()
|
||||
if (!row) {
|
||||
break
|
||||
@@ -1141,6 +1155,7 @@ async function processMatchedQueue() {
|
||||
let attempt = 0
|
||||
const taskVo = await (async () => {
|
||||
while (true) {
|
||||
if (disposed) throw new Error('component disposed')
|
||||
try {
|
||||
return await createPriceTrackTask({
|
||||
userId: 0,
|
||||
@@ -1156,7 +1171,7 @@ async function processMatchedQueue() {
|
||||
attempt += 1
|
||||
if (!transient || attempt >= 10) throw error
|
||||
queuePushResult.value = '后端服务暂时不可用,正在重试创建任务(' + attempt + '/10)...'
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
await sleep(getPollIntervalMs())
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -1197,6 +1212,7 @@ async function processMatchedQueue() {
|
||||
successCount += 1
|
||||
queuePushResult.value = '任务 ' + taskVo.taskId + ' 已完成'
|
||||
} catch (error) {
|
||||
if (disposed) break
|
||||
failedCount += 1
|
||||
queuePushResult.value = '第 ' + index + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误')
|
||||
ElMessage.error(queuePushResult.value)
|
||||
@@ -1208,6 +1224,7 @@ async function processMatchedQueue() {
|
||||
ElMessage.success('店铺任务推送已完成:成功 ' + successCount + ' 条,失败 ' + failedCount + ' 条')
|
||||
}
|
||||
} catch (e) {
|
||||
if (disposed) return
|
||||
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
|
||||
ElMessage.error(queuePushResult.value)
|
||||
} finally {
|
||||
@@ -1282,6 +1299,7 @@ async function stopLoopExecution() {
|
||||
}
|
||||
|
||||
async function runLoopExecution(loopId?: number) {
|
||||
if (disposed) return
|
||||
if (loopDispatching.value) return
|
||||
if (loopId && activeLoopRunId.value !== loopId) {
|
||||
activeLoopRunId.value = loopId
|
||||
@@ -1294,7 +1312,7 @@ async function runLoopExecution(loopId?: number) {
|
||||
}
|
||||
loopDispatching.value = true
|
||||
try {
|
||||
while (activeLoopRunId.value) {
|
||||
while (!disposed && activeLoopRunId.value) {
|
||||
const loop = await syncActiveLoopRun()
|
||||
if (!loop || loop.status === 'SUCCESS' || loop.status === 'FAILED' || loop.status === 'STOPPED') break
|
||||
|
||||
@@ -1306,6 +1324,7 @@ async function runLoopExecution(loopId?: number) {
|
||||
let completeAttempt = 0
|
||||
const updatedLoop = await (async () => {
|
||||
while (true) {
|
||||
if (disposed) throw new Error('component disposed')
|
||||
try {
|
||||
return await completePriceTrackLoopChild(loop.id, activeTaskId)
|
||||
} catch (error) {
|
||||
@@ -1314,7 +1333,7 @@ async function runLoopExecution(loopId?: number) {
|
||||
completeAttempt += 1
|
||||
if (!transient || completeAttempt >= 10) throw error
|
||||
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后确认子任务完成(${completeAttempt}/10)...`
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
await sleep(getPollIntervalMs())
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -1332,6 +1351,7 @@ async function runLoopExecution(loopId?: number) {
|
||||
let dispatchAttempt = 0
|
||||
const dispatch = await (async () => {
|
||||
while (true) {
|
||||
if (disposed) throw new Error('component disposed')
|
||||
try {
|
||||
return await dispatchNextPriceTrackLoopRun(loop.id)
|
||||
} catch (error) {
|
||||
@@ -1340,7 +1360,7 @@ async function runLoopExecution(loopId?: number) {
|
||||
dispatchAttempt += 1
|
||||
if (!transient || dispatchAttempt >= 10) throw error
|
||||
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后继续派发(${dispatchAttempt}/10)...`
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
await sleep(getPollIntervalMs())
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -1355,6 +1375,7 @@ async function runLoopExecution(loopId?: number) {
|
||||
let createAttempt = 0
|
||||
const taskVo = await (async () => {
|
||||
while (true) {
|
||||
if (disposed) throw new Error('component disposed')
|
||||
try {
|
||||
return await createPriceTrackTask(childTaskRequest)
|
||||
} catch (error) {
|
||||
@@ -1363,7 +1384,7 @@ async function runLoopExecution(loopId?: number) {
|
||||
createAttempt += 1
|
||||
if (!transient || createAttempt >= 10) throw error
|
||||
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后重试创建子任务(${createAttempt}/10)...`
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
await sleep(getPollIntervalMs())
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -1483,25 +1504,27 @@ async function refreshTaskBatch() {
|
||||
}
|
||||
|
||||
function scheduleNextPoll(immediate = false) {
|
||||
if (disposed) return
|
||||
if (pollTimer.value) {
|
||||
if (!immediate) return
|
||||
window.clearTimeout(pollTimer.value)
|
||||
timers.clearTimer('task-poll', pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
const run = async () => {
|
||||
pollTimer.value = null
|
||||
if (disposed) return
|
||||
if (pollingInFlight.value) { scheduleNextPoll(); return }
|
||||
if (!pollingTaskIds.value.length) return
|
||||
pollingInFlight.value = true
|
||||
try { await refreshTaskBatch() } finally { pollingInFlight.value = false }
|
||||
if (pollingTaskIds.value.length > 0) pollTimer.value = window.setTimeout(run, getPollIntervalMs())
|
||||
if (!disposed && pollingTaskIds.value.length > 0) pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs())
|
||||
}
|
||||
if (immediate) void run()
|
||||
else pollTimer.value = window.setTimeout(run, getPollIntervalMs())
|
||||
else pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs())
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null }
|
||||
if (pollTimer.value) { timers.clearTimer('task-poll', pollTimer.value); pollTimer.value = null }
|
||||
}
|
||||
|
||||
function addPollingTask(taskId: number) {
|
||||
@@ -1658,8 +1681,11 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
stopPolling()
|
||||
if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer)
|
||||
clearSleepTimers()
|
||||
timers.clearScope()
|
||||
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -221,6 +221,7 @@ import {
|
||||
} from '@/shared/api/java-modules'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
|
||||
const shopInput = ref('')
|
||||
const candidates = ref<ProductRiskCandidateVo[]>([])
|
||||
@@ -327,8 +328,8 @@ function onCountryDrop(toIndex: number) {
|
||||
}
|
||||
|
||||
function scheduleSaveCountryPref() {
|
||||
if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer)
|
||||
countryPrefSaveTimer = setTimeout(() => {
|
||||
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
|
||||
countryPrefSaveTimer = timers.setTimeout('preference-save', () => {
|
||||
countryPrefSaveTimer = null
|
||||
void persistCountryPref()
|
||||
}, 450)
|
||||
@@ -374,6 +375,8 @@ const taskDetails = ref<Record<number, string>>({})
|
||||
const taskSnapshots = ref<Record<number, ProductRiskTaskDetailVo>>({})
|
||||
const pollTimer = ref<number | null>(null)
|
||||
const pollingInFlight = ref(false)
|
||||
let disposed = false
|
||||
const timers = createCategorizedTimers('product-risk')
|
||||
|
||||
function uidForStorage() {
|
||||
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
|
||||
@@ -684,9 +687,12 @@ const TRANSIENT_BACKEND_ERROR_PATTERNS = [
|
||||
] as const
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
window.setTimeout(() => resolve(), ms)
|
||||
})
|
||||
if (disposed) return Promise.resolve()
|
||||
return timers.sleep('queue-wait', ms)
|
||||
}
|
||||
|
||||
function clearSleepTimers() {
|
||||
timers.clearCategory('queue-wait')
|
||||
}
|
||||
|
||||
function isTransientBackendError(error: unknown) {
|
||||
@@ -697,6 +703,7 @@ function isTransientBackendError(error: unknown) {
|
||||
async function createProductRiskTaskWithRetry(items: ProductRiskShopQueueItem[], maxAttempts = 10) {
|
||||
let attempt = 0
|
||||
while (true) {
|
||||
if (disposed) throw new Error('component disposed')
|
||||
try {
|
||||
return await createProductRiskTask(items)
|
||||
} catch (error) {
|
||||
@@ -751,12 +758,13 @@ function getPollIntervalMs() {
|
||||
function scheduleNextPoll(immediate = false) {
|
||||
if (pollTimer.value) {
|
||||
if (!immediate) return
|
||||
window.clearTimeout(pollTimer.value)
|
||||
timers.clearTimer('task-poll', pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
pollTimer.value = null
|
||||
if (disposed) return
|
||||
if (pollingInFlight.value) {
|
||||
scheduleNextPoll()
|
||||
return
|
||||
@@ -768,13 +776,14 @@ function scheduleNextPoll(immediate = false) {
|
||||
} finally {
|
||||
pollingInFlight.value = false
|
||||
}
|
||||
if (pollingTaskIds.value.length > 0) {
|
||||
pollTimer.value = window.setTimeout(run, getPollIntervalMs())
|
||||
if (!disposed && pollingTaskIds.value.length > 0) {
|
||||
pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs())
|
||||
}
|
||||
}
|
||||
|
||||
if (disposed) return
|
||||
if (immediate) void run()
|
||||
else pollTimer.value = window.setTimeout(run, getPollIntervalMs())
|
||||
else pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs())
|
||||
}
|
||||
|
||||
function ensurePolling(immediate = false) {
|
||||
@@ -784,7 +793,7 @@ function ensurePolling(immediate = false) {
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer.value) {
|
||||
window.clearTimeout(pollTimer.value)
|
||||
timers.clearTimer('task-poll', pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
}
|
||||
@@ -793,6 +802,7 @@ async function waitForTaskTerminal(taskId: number) {
|
||||
let transientErrorCount = 0
|
||||
const maxTransientErrors = 30
|
||||
while (true) {
|
||||
if (disposed) return 'STOPPED'
|
||||
try {
|
||||
const batch = await getProductRiskTaskProgressBatch([taskId])
|
||||
if (transientErrorCount > 0) {
|
||||
@@ -1027,7 +1037,7 @@ function nextMatchedQueueItem() {
|
||||
}
|
||||
|
||||
async function processMatchedQueue() {
|
||||
if (queueWorkerRunning.value) {
|
||||
if (disposed || queueWorkerRunning.value) {
|
||||
return
|
||||
}
|
||||
if (!matchedItems.value.length) {
|
||||
@@ -1051,7 +1061,7 @@ async function processMatchedQueue() {
|
||||
let failedCount = 0
|
||||
try {
|
||||
let index = 0
|
||||
while (autoQueueEnabled.value) {
|
||||
while (!disposed && autoQueueEnabled.value) {
|
||||
const item = nextMatchedQueueItem()
|
||||
if (!item) {
|
||||
break
|
||||
@@ -1102,6 +1112,7 @@ async function processMatchedQueue() {
|
||||
successCount += 1
|
||||
queuePushResult.value = '任务 ' + taskId + ' 已完成'
|
||||
} catch (error) {
|
||||
if (disposed) break
|
||||
failedCount += 1
|
||||
queuePushResult.value = '第 ' + index + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误')
|
||||
ElMessage.error(queuePushResult.value)
|
||||
@@ -1115,6 +1126,7 @@ async function processMatchedQueue() {
|
||||
await loadHistory()
|
||||
ensurePolling(true)
|
||||
} catch (e) {
|
||||
if (disposed) return
|
||||
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
|
||||
ElMessage.error(queuePushResult.value)
|
||||
} finally {
|
||||
@@ -1141,8 +1153,11 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
stopPolling()
|
||||
if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer)
|
||||
clearSleepTimers()
|
||||
timers.clearScope()
|
||||
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -333,6 +333,7 @@ import {
|
||||
} from "@/shared/api/java-modules";
|
||||
import { getPywebviewApi } from "@/shared/bridges/pywebview";
|
||||
import { getTaskPollIntervalMs } from "@/shared/task-progress-config";
|
||||
import { createCategorizedTimers } from "@/shared/utils/categorized-timers";
|
||||
|
||||
const MAX_TRANSIENT_ERRORS = 30;
|
||||
|
||||
@@ -358,6 +359,7 @@ const activeQueueItem = ref<QueryAsinShopQueueItem | null>(null);
|
||||
const queueWorkerRunning = ref(false);
|
||||
const autoQueueEnabled = ref(false);
|
||||
let historyPollTimer: number | null = null;
|
||||
const timers = createCategorizedTimers("query-asin");
|
||||
|
||||
const matchedRunnableItems = computed(() =>
|
||||
matchedItems.value.filter((item) => item.matched && (item.queryAsins || []).some((row) => row.asins?.length)),
|
||||
@@ -398,8 +400,15 @@ function historyItemKey(item: QueryAsinHistoryItem) {
|
||||
return `${item.taskId ?? 0}:${item.resultId ?? 0}`;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
if (disposed) return Promise.resolve();
|
||||
return timers.sleep("queue-wait", ms);
|
||||
}
|
||||
|
||||
function clearSleepTimers() {
|
||||
timers.clearCategory("queue-wait");
|
||||
}
|
||||
|
||||
function isTransientBackendError(error: unknown) {
|
||||
@@ -424,6 +433,7 @@ async function withTransientRetry<T>(
|
||||
) {
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
if (disposed) throw new Error("component disposed");
|
||||
try {
|
||||
return await action();
|
||||
} catch (error) {
|
||||
@@ -747,21 +757,21 @@ async function refreshActiveTaskProgress(taskIds?: number[]) {
|
||||
|
||||
function startHistoryPolling() {
|
||||
stopHistoryPolling();
|
||||
if (!hasQueueWork.value) return;
|
||||
if (disposed || !hasQueueWork.value) return;
|
||||
const run = async () => {
|
||||
historyPollTimer = null;
|
||||
if (!hasQueueWork.value) return;
|
||||
if (disposed || !hasQueueWork.value) return;
|
||||
await refreshActiveTaskProgress();
|
||||
if (hasQueueWork.value) {
|
||||
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2);
|
||||
if (!disposed && hasQueueWork.value) {
|
||||
historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
|
||||
}
|
||||
};
|
||||
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2);
|
||||
historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
|
||||
}
|
||||
|
||||
function stopHistoryPolling() {
|
||||
if (historyPollTimer) {
|
||||
window.clearTimeout(historyPollTimer);
|
||||
timers.clearTimer("history-poll", historyPollTimer);
|
||||
historyPollTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -893,6 +903,7 @@ function findHistoryItemByTaskId(taskId: number) {
|
||||
async function waitForTaskTerminal(taskId: number) {
|
||||
let transientErrorCount = 0;
|
||||
while (true) {
|
||||
if (disposed) return "STOPPED";
|
||||
try {
|
||||
await refreshActiveTaskProgress([taskId]);
|
||||
if (activeTaskId.value !== taskId) {
|
||||
@@ -924,7 +935,7 @@ async function waitForTaskTerminal(taskId: number) {
|
||||
}
|
||||
|
||||
async function processQueue() {
|
||||
if (queueWorkerRunning.value) return;
|
||||
if (disposed || queueWorkerRunning.value) return;
|
||||
queueWorkerRunning.value = true;
|
||||
pushing.value = true;
|
||||
startHistoryPolling();
|
||||
@@ -935,7 +946,7 @@ async function processQueue() {
|
||||
throw new Error("当前客户端未提供 enqueue_json");
|
||||
}
|
||||
|
||||
while (activeTaskId.value || pendingQueue.value.length) {
|
||||
while (!disposed && (activeTaskId.value || pendingQueue.value.length)) {
|
||||
if (activeTaskId.value) {
|
||||
const finalStatus = await waitForTaskTerminal(activeTaskId.value);
|
||||
queuePushResult.value =
|
||||
@@ -1002,6 +1013,7 @@ async function processQueue() {
|
||||
await refreshTaskViews();
|
||||
ElMessage.success("查询ASIN队列已按顺序执行完成");
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
const message = error instanceof Error ? error.message : "队列执行失败";
|
||||
queuePushResult.value = message;
|
||||
ElMessage.error(message);
|
||||
@@ -1092,7 +1104,10 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true;
|
||||
stopHistoryPolling();
|
||||
clearSleepTimers();
|
||||
timers.clearScope();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/c
|
||||
import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTaskProgressBatch, getShopMatchTasksBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
|
||||
const COUNTRY_OPTIONS = [{ code: 'DE', label: '德国' }, { code: 'UK', label: '英国' }, { code: 'FR', label: '法国' }, { code: 'IT', label: '意大利' }, { code: 'ES', label: '西班牙' }] as const
|
||||
const shopInput = ref('')
|
||||
@@ -144,6 +145,8 @@ const scheduledDispatchInFlight = ref(false)
|
||||
let scheduleHeartbeatTimer: number | null = null
|
||||
let lastScheduledErrorAt = 0
|
||||
let lastScheduledErrorKey = ''
|
||||
let disposed = false
|
||||
const timers = createCategorizedTimers('shop-match')
|
||||
const currentSectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !!taskId && !isTaskTerminalItem(item) }), taskSnapshots.value, 'current'))
|
||||
const historySectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !taskId || isTaskTerminalItem(item) }), taskSnapshots.value, 'history'))
|
||||
const hasVisibleList = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
|
||||
@@ -194,7 +197,7 @@ function saveMatchedItemsToStorage() { setStorageJson(matchedItemsStorageKey(),
|
||||
function isTaskMissingError(error: unknown) { const message = error instanceof Error ? error.message : String(error || ''); return /记录不存在|任务不存在|不存在|已删除|not\s*found|404/i.test(message) }
|
||||
function syncPollingIdsWithTaskState() { const nextIds = pollingTaskIds.value.filter((taskId) => (taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status) === 'RUNNING'); if (nextIds.length === pollingTaskIds.value.length) return; pollingTaskIds.value = nextIds; savePollingIds() }
|
||||
function addPollingTask(taskId: number) { if (!pollingTaskIds.value.includes(taskId)) { pollingTaskIds.value = [...pollingTaskIds.value, taskId]; savePollingIds() } }
|
||||
function clearDispatchTimer(taskId: number) { const timer = dispatchTimers.get(taskId); if (timer) { window.clearTimeout(timer); dispatchTimers.delete(taskId) } }
|
||||
function clearDispatchTimer(taskId: number) { const timer = dispatchTimers.get(taskId); if (timer) { timers.clearTimer('scheduled-dispatch', timer); dispatchTimers.delete(taskId); if (!dispatchTimers.size) stopScheduleHeartbeat() } }
|
||||
function removePollingTask(taskId: number) {
|
||||
clearDispatchTimer(taskId)
|
||||
pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId)
|
||||
@@ -226,7 +229,7 @@ async function loadDashboard() { dashboard.value = await getShopMatchDashboard()
|
||||
async function loadHistory() { const data = await getShopMatchHistory(); historyItems.value = data.items || [] }
|
||||
async function loadCountryPreference() { try { const data = await getShopMatchCountryPreference(); if (countryPrefUserTouched.value) return; const codes = (data.country_codes || []).filter((item) => COUNTRY_OPTIONS.some((option) => option.code === item)); if (codes.length) orderedCountryCodes.value = codes } catch {} }
|
||||
async function refreshTaskViewsBestEffort() { await Promise.allSettled([loadHistory(), loadDashboard()]) }
|
||||
function scheduleSaveCountryPreference() { if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer); countryPrefSaveTimer = setTimeout(() => { countryPrefSaveTimer = null; void saveCountryPreference() }, 450) }
|
||||
function scheduleSaveCountryPreference() { if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer); countryPrefSaveTimer = timers.setTimeout('preference-save', () => { countryPrefSaveTimer = null; void saveCountryPreference() }, 450) }
|
||||
async function saveCountryPreference() { if (!orderedCountryCodes.value.length) return; countryPrefSaving.value = true; try { await putShopMatchCountryPreference(orderedCountryCodes.value) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存国家配置失败') } finally { countryPrefSaving.value = false } }
|
||||
function onSelectionChange(rows: ShopMatchCandidateVo[]) { selectedCandidates.value = rows }
|
||||
async function confirmAdd() { const name = shopInput.value.trim(); if (!name) { ElMessage.warning('请输入店铺名'); return } adding.value = true; try { await addShopMatchCandidate(name); shopInput.value = ''; await loadCandidates(); await loadDashboard() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '添加失败') } finally { adding.value = false } }
|
||||
@@ -320,25 +323,27 @@ async function pumpScheduledQueue() {
|
||||
restoreScheduledDispatches()
|
||||
}
|
||||
}
|
||||
function scheduleDispatch(taskId: number, scheduledAt?: string) { if (!scheduledAt) return; clearDispatchTimer(taskId); const time = scheduledTimestamp(scheduledAt); if (!Number.isFinite(time)) return; const delay = Math.max(0, time - Date.now()); const timer = window.setTimeout(() => { dispatchTimers.delete(taskId); void pumpScheduledQueue() }, delay); dispatchTimers.set(taskId, timer) }
|
||||
function restoreScheduledDispatches() { const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } for (const taskId of taskIds) { const snapshot = taskSnapshots.value[taskId]; const taskStatus = snapshot?.task?.status || taskDetails.value[taskId]; const stage = nextScheduleStage(taskId); if (taskStatus === 'SCHEDULED' && stage?.scheduledAt) scheduleDispatch(taskId, stage.scheduledAt); else clearDispatchTimer(taskId) } void pumpScheduledQueue() }
|
||||
function startScheduleHeartbeat() { if (scheduleHeartbeatTimer) return; scheduleHeartbeatTimer = window.setInterval(() => { void pumpScheduledQueue() }, 1000) }
|
||||
function stopScheduleHeartbeat() { if (!scheduleHeartbeatTimer) return; window.clearInterval(scheduleHeartbeatTimer); scheduleHeartbeatTimer = null }
|
||||
function scheduleDispatch(taskId: number, scheduledAt?: string) { if (disposed || !scheduledAt) return; clearDispatchTimer(taskId); const time = scheduledTimestamp(scheduledAt); if (!Number.isFinite(time)) return; const delay = Math.max(0, time - Date.now()); const timer = timers.setTimeout('scheduled-dispatch', () => { dispatchTimers.delete(taskId); if (!dispatchTimers.size) stopScheduleHeartbeat(); if (!disposed) void pumpScheduledQueue() }, delay); dispatchTimers.set(taskId, timer); startScheduleHeartbeat() }
|
||||
function restoreScheduledDispatches() { if (disposed) return; const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } for (const taskId of taskIds) { const snapshot = taskSnapshots.value[taskId]; const taskStatus = snapshot?.task?.status || taskDetails.value[taskId]; const stage = nextScheduleStage(taskId); if (taskStatus === 'SCHEDULED' && stage?.scheduledAt) scheduleDispatch(taskId, stage.scheduledAt); else clearDispatchTimer(taskId) } if (dispatchTimers.size) startScheduleHeartbeat(); else stopScheduleHeartbeat(); void pumpScheduledQueue() }
|
||||
function startScheduleHeartbeat() { if (disposed || scheduleHeartbeatTimer || !dispatchTimers.size) return; scheduleHeartbeatTimer = timers.setInterval('schedule-heartbeat', () => { if (!disposed) void pumpScheduledQueue() }, 30000) }
|
||||
function stopScheduleHeartbeat() { if (!scheduleHeartbeatTimer) return; timers.clearTimer('schedule-heartbeat', scheduleHeartbeatTimer); scheduleHeartbeatTimer = null }
|
||||
function handleScheduleRecovery() { restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) }
|
||||
const TRANSIENT_BACKEND_ERROR_PATTERNS = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'] as const
|
||||
function sleep(ms: number) { return new Promise<void>((resolve) => { window.setTimeout(() => resolve(), ms) }) }
|
||||
function sleep(ms: number) { if (disposed) return Promise.resolve(); return timers.sleep('queue-wait', ms) }
|
||||
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) { try { return await action() } catch (error) { attempt += 1; if (!isTransientBackendError(error) || attempt >= maxAttempts) throw error; onRetry(attempt, maxAttempts); await sleep(getPollIntervalMs()) } } }
|
||||
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[] } } }
|
||||
function getPollIntervalMs() { return getTaskPollIntervalMs() }
|
||||
function scheduleNextPoll(immediate = false) { if (pollTimer.value) { if (!immediate) return; window.clearTimeout(pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; 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 (pollingTaskIds.value.length) pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }
|
||||
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) }
|
||||
function stopPolling() { if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null } }
|
||||
async function waitForTaskTerminal(taskId: number) { let transientErrorCount = 0; const maxTransientErrors = 30; while (true) { try { const batch = await getShopMatchTaskProgressBatch([taskId]); if (transientErrorCount > 0) queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`; transientErrorCount = 0; if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { const prev = taskSnapshots.value[taskId]; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } } catch (error) { if (!isTransientBackendError(error)) throw error; transientErrorCount += 1; if (transientErrorCount >= maxTransientErrors) throw new Error(`任务 ${taskId} 等待后端恢复超时,请稍后手动刷新查看状态`); queuePushResult.value = `任务 ${taskId} 运行中,正在等待后端服务恢复(${transientErrorCount}/${maxTransientErrors})...`; if (transientErrorCount === 1 || transientErrorCount % 5 === 0) ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`); await sleep(getPollIntervalMs()); continue } await sleep(getPollIntervalMs()) } }
|
||||
function stopPolling() { if (pollTimer.value) { timers.clearTimer('task-poll', pollTimer.value); pollTimer.value = null } }
|
||||
async function waitForTaskTerminal(taskId: number) { let transientErrorCount = 0; const maxTransientErrors = 30; while (true) { if (disposed) return 'STOPPED'; try { const batch = await getShopMatchTaskProgressBatch([taskId]); if (transientErrorCount > 0) queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`; transientErrorCount = 0; if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { const prev = taskSnapshots.value[taskId]; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } } catch (error) { if (!isTransientBackendError(error)) throw error; transientErrorCount += 1; if (transientErrorCount >= maxTransientErrors) throw new Error(`任务 ${taskId} 等待后端恢复超时,请稍后手动刷新查看状态`); queuePushResult.value = `任务 ${taskId} 运行中,正在等待后端服务恢复(${transientErrorCount}/${maxTransientErrors})...`; if (transientErrorCount === 1 || transientErrorCount % 5 === 0) ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`); await sleep(getPollIntervalMs()); continue } await sleep(getPollIntervalMs()) } }
|
||||
async function waitForScheduledTaskStageExit(taskId: number) {
|
||||
let transientErrorCount = 0
|
||||
const maxTransientErrors = 30
|
||||
while (true) {
|
||||
if (disposed) return 'STOPPED'
|
||||
try {
|
||||
const batch = await getShopMatchTaskProgressBatch([taskId])
|
||||
if (transientErrorCount > 0) queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待当前轮次结束...`
|
||||
@@ -429,9 +434,9 @@ function formatMatchStatus(status?: string) { const value = (status || '').trim(
|
||||
function formatMatchRemark(row: ShopMatchShopQueueItem) { const message = (row.matchMessage || '').trim(); if (message) return message; if (row.matched && row.matchStatus === 'MATCHED') return '紫鸟索引已命中,可推送队列'; if (row.matched) return '已关联索引,请结合状态列查看是否可推送'; return '未命中或未就绪,请检查店铺名与索引刷新' }
|
||||
async function pushToPythonQueue() { autoQueueEnabled.value = true; await processMatchedQueue() }
|
||||
function nextMatchedQueueItem() { return matchedItems.value.find((item) => item.matched) }
|
||||
async function processMatchedQueue() { if (queueWorkerRunning.value) return; const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { const created = await withTransientRetry(() => createShopMatchTask([item], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts})...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 推送失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { failedCount += 1; queuePushResult.value = `第 ${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (successCount > 0 || failedCount > 0) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount} 条` : `店铺推送已完成:成功 ${successCount} 条,失败 ${failedCount} 条`) } catch (error) { const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } }
|
||||
onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); schedulePickerValues.value = sanitizeSchedulePickerValues(schedulePickerValues.value, scheduleEnabled.value); syncPollingIdsWithTaskState(); await Promise.allSettled([loadCandidates(), loadCountryPreference(), loadHistory(), loadDashboard()]); startScheduleHeartbeat(); window.addEventListener('focus', handleScheduleRecovery); document.addEventListener('visibilitychange', handleScheduleRecovery); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) })
|
||||
onUnmounted(() => { stopPolling(); stopScheduleHeartbeat(); window.removeEventListener('focus', handleScheduleRecovery); document.removeEventListener('visibilitychange', handleScheduleRecovery); if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer); for (const timer of dispatchTimers.values()) window.clearTimeout(timer); dispatchTimers.clear() })
|
||||
async function processMatchedQueue() { if (disposed || queueWorkerRunning.value) return; const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (!disposed && autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { const created = await withTransientRetry(() => createShopMatchTask([item], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts})...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 推送失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { if (disposed) break; failedCount += 1; queuePushResult.value = `第 ${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (!disposed && (successCount > 0 || failedCount > 0)) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount} 条` : `店铺推送已完成:成功 ${successCount} 条,失败 ${failedCount} 条`) } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } }
|
||||
onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); schedulePickerValues.value = sanitizeSchedulePickerValues(schedulePickerValues.value, scheduleEnabled.value); syncPollingIdsWithTaskState(); await Promise.allSettled([loadCandidates(), loadCountryPreference(), loadHistory(), loadDashboard()]); window.addEventListener('focus', handleScheduleRecovery); document.addEventListener('visibilitychange', handleScheduleRecovery); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) })
|
||||
onUnmounted(() => { disposed = true; stopPolling(); stopScheduleHeartbeat(); clearSleepTimers(); window.removeEventListener('focus', handleScheduleRecovery); document.removeEventListener('visibilitychange', handleScheduleRecovery); if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer); for (const timer of dispatchTimers.values()) timers.clearTimer('scheduled-dispatch', timer); dispatchTimers.clear(); timers.clearScope() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
113
frontend-vue/src/shared/utils/categorized-timers.ts
Normal file
113
frontend-vue/src/shared/utils/categorized-timers.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
type TimerKind = 'timeout' | 'interval'
|
||||
type TimerCancel = () => void
|
||||
|
||||
interface TimerEntry {
|
||||
id: number
|
||||
kind: TimerKind
|
||||
category: string
|
||||
cancel?: TimerCancel
|
||||
}
|
||||
|
||||
const timerBuckets = new Map<string, Map<number, TimerEntry>>()
|
||||
|
||||
function bucketOf(category: string) {
|
||||
let bucket = timerBuckets.get(category)
|
||||
if (!bucket) {
|
||||
bucket = new Map<number, TimerEntry>()
|
||||
timerBuckets.set(category, bucket)
|
||||
}
|
||||
return bucket
|
||||
}
|
||||
|
||||
function removeTimer(category: string, id: number) {
|
||||
const bucket = timerBuckets.get(category)
|
||||
if (!bucket) return
|
||||
bucket.delete(id)
|
||||
if (!bucket.size) timerBuckets.delete(category)
|
||||
}
|
||||
|
||||
export function setCategorizedTimeout(category: string, handler: () => void, delayMs: number) {
|
||||
const id = window.setTimeout(() => {
|
||||
removeTimer(category, id)
|
||||
handler()
|
||||
}, delayMs)
|
||||
bucketOf(category).set(id, { id, kind: 'timeout', category })
|
||||
return id
|
||||
}
|
||||
|
||||
export function setCategorizedInterval(category: string, handler: () => void, delayMs: number) {
|
||||
const id = window.setInterval(handler, delayMs)
|
||||
bucketOf(category).set(id, { id, kind: 'interval', category })
|
||||
return id
|
||||
}
|
||||
|
||||
export function clearCategorizedTimer(category: string, id: number | null | undefined) {
|
||||
if (id == null) return
|
||||
const bucket = timerBuckets.get(category)
|
||||
const entry = bucket?.get(id)
|
||||
if (entry?.kind === 'interval') window.clearInterval(id)
|
||||
else window.clearTimeout(id)
|
||||
entry?.cancel?.()
|
||||
removeTimer(category, id)
|
||||
}
|
||||
|
||||
export function clearCategorizedTimers(category: string) {
|
||||
const bucket = timerBuckets.get(category)
|
||||
if (!bucket) return
|
||||
for (const entry of bucket.values()) {
|
||||
if (entry.kind === 'interval') window.clearInterval(entry.id)
|
||||
else window.clearTimeout(entry.id)
|
||||
entry.cancel?.()
|
||||
}
|
||||
timerBuckets.delete(category)
|
||||
}
|
||||
|
||||
export function sleepWithCategory(category: string, delayMs: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const id = window.setTimeout(() => {
|
||||
removeTimer(category, id)
|
||||
resolve()
|
||||
}, delayMs)
|
||||
bucketOf(category).set(id, { id, kind: 'timeout', category, cancel: resolve })
|
||||
})
|
||||
}
|
||||
|
||||
export function clearTimerScope(scope: string) {
|
||||
const prefix = `${scope}:`
|
||||
for (const category of Array.from(timerBuckets.keys())) {
|
||||
if (category === scope || category.startsWith(prefix)) {
|
||||
clearCategorizedTimers(category)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createCategorizedTimers(scope: string) {
|
||||
const key = (category: string) => `${scope}:${category}`
|
||||
return {
|
||||
setTimeout(category: string, handler: () => void, delayMs: number) {
|
||||
return setCategorizedTimeout(key(category), handler, delayMs)
|
||||
},
|
||||
setInterval(category: string, handler: () => void, delayMs: number) {
|
||||
return setCategorizedInterval(key(category), handler, delayMs)
|
||||
},
|
||||
clearTimer(category: string, id: number | null | undefined) {
|
||||
clearCategorizedTimer(key(category), id)
|
||||
},
|
||||
clearCategory(category: string) {
|
||||
clearCategorizedTimers(key(category))
|
||||
},
|
||||
clearScope() {
|
||||
clearTimerScope(scope)
|
||||
},
|
||||
sleep(category: string, delayMs: number) {
|
||||
return sleepWithCategory(key(category), delayMs)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getCategorizedTimerStats() {
|
||||
return Array.from(timerBuckets.entries()).map(([category, bucket]) => ({
|
||||
category,
|
||||
count: bucket.size,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user