完成后端架构重构等

This commit is contained in:
super
2026-04-23 15:25:41 +08:00
parent 46f46039ea
commit 34bc980eea
76 changed files with 3242 additions and 1111 deletions

View File

@@ -358,6 +358,7 @@ const pendingQueue = ref<PatrolDeleteShopQueueItem[]>([]);
const activeTaskId = ref<number | null>(null);
const activeQueueItem = ref<PatrolDeleteShopQueueItem | null>(null);
const queueWorkerRunning = ref(false);
const autoQueueEnabled = ref(false);
let historyPollTimer: number | null = null;
const matchedRunnableItems = computed(() =>
@@ -774,6 +775,11 @@ async function runMatch() {
const data = await matchPatrolDeleteShops(names);
matchedItems.value = mergeMatchedItems(matchedItems.value, data.items || []);
saveMatchedItems();
if (autoQueueEnabled.value) {
pendingQueue.value = mergeQueueItems(pendingQueue.value, matchedRunnableItems.value);
saveQueueState();
void processQueue();
}
ElMessage.success(`本次返回 ${data.items?.length || 0} 条匹配结果`);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "匹配失败");
@@ -963,6 +969,7 @@ async function processQueue() {
}
async function pushToPythonQueue() {
autoQueueEnabled.value = true;
const runnable = matchedRunnableItems.value;
if (!runnable.length) {
ElMessage.warning("请先匹配可用店铺");
@@ -1016,6 +1023,7 @@ onMounted(async () => {
await Promise.all([loadCandidates(), loadDashboard(), loadHistory()]);
if (activeTaskId.value || pendingQueue.value.length) {
autoQueueEnabled.value = true;
queuePushResult.value =
activeTaskId.value != null
? `检测到未完成队列,继续等待任务 ${activeTaskId.value} 完成并自动接续后续店铺`

View File

@@ -312,6 +312,8 @@ const matchedItems = ref<PriceTrackShopQueueItem[]>([])
const adding = ref(false)
const matching = ref(false)
const pushing = ref(false)
const queueWorkerRunning = ref(false)
const autoQueueEnabled = ref(false)
const queuePushResult = ref('')
const queuePayloadText = ref('')
const matchAsinRowsByCountry = ref<Record<string, PriceTrackAsinParsedRow[]>>({})
@@ -787,6 +789,9 @@ async function runMatch() {
}
matchedItems.value = [...nextByShop.values()]
saveMatchedItemsToStorage()
if (autoQueueEnabled.value) {
void processMatchedQueue()
}
ElMessage.success(`匹配 ${batch.length} 个店铺`)
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '匹配失败')
@@ -813,6 +818,19 @@ function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | nul
return `${(row.shopName || '').trim()}\u0001${row.shopId ?? ''}`
}
function buildSkipAsinDeletePolicy() {
const enabled = statusModeEnabled.value && !asinModeEnabled.value
return {
enabled,
mode: enabled ? 'DELETE_WHEN_PRICE_BELOW_MINIMUM' : 'NONE',
deleteWhenPriceBelowMinimum: enabled,
compareField: 'price',
thresholdField: 'minimumPrice',
target: 'skip_asin',
source: 'frontend-price-track',
}
}
function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: number | string | null }>) {
if (!rows.length) return
const keys = new Set(rows.map((row) => rowKeyForMatch(row)))
@@ -903,6 +921,9 @@ async function pushToPythonQueueLegacy() {
// 店铺简要信息(用于展示)
shop_names: matchedRows.map((i) => i.shopName).filter(Boolean),
country_codes: [...orderedCountryCodes.value],
skip_asin_delete_policy: buildSkipAsinDeletePolicy(),
delete_skip_asin_when_price_below_minimum: statusModeEnabled.value && !asinModeEnabled.value,
deleteSkipAsinWhenPriceBelowMinimum: statusModeEnabled.value && !asinModeEnabled.value,
},
}
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
@@ -930,6 +951,9 @@ function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQu
const taskItem = taskVo.items?.[0]
const shopName = (row.shopName || '').trim()
const skipAsinsByCountry = row.skipAsins || taskVo.skipAsinsByCountry || {}
const skipAsinDetailsByCountry = taskVo.skipAsinDetailsByCountry || {}
const minimumPriceByCountryAndAsin = taskVo.minimumPriceByCountryAndAsin || {}
const skipAsinDeletePolicy = buildSkipAsinDeletePolicy()
const resultId = taskItem?.resultId ?? null
const loopRunId = taskItem?.loopRunId ?? null
const roundIndex = taskItem?.roundIndex ?? null
@@ -963,6 +987,11 @@ function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQu
mode: statusModeEnabled.value ? 'status' : 'asin',
skip_asins: skipAsinsByCountry,
skip_asins_by_country: skipAsinsByCountry,
skip_asin_details_by_country: skipAsinDetailsByCountry,
minimum_price_by_country_and_asin: minimumPriceByCountryAndAsin,
skip_asin_delete_policy: skipAsinDeletePolicy,
delete_skip_asin_when_price_below_minimum: skipAsinDeletePolicy.deleteWhenPriceBelowMinimum,
deleteSkipAsinWhenPriceBelowMinimum: skipAsinDeletePolicy.deleteWhenPriceBelowMinimum,
asin_rows_by_country: Object.keys(matchAsinRowsByCountry.value).length
? matchAsinRowsByCountry.value
: (taskVo.asinRowsByCountry || {}),
@@ -1039,6 +1068,18 @@ async function waitForTaskTerminal(taskId: number) {
}
async function pushToPythonQueue() {
autoQueueEnabled.value = true
await processMatchedQueue()
}
function nextMatchedQueueItem() {
return matchedItems.value.find((item) => item.matched)
}
async function processMatchedQueue() {
if (queueWorkerRunning.value) {
return
}
if (!hasValidMode.value) {
ElMessage.warning('请至少选择一个跟价模式')
return
@@ -1057,13 +1098,19 @@ async function pushToPythonQueue() {
ElMessage.warning('无可用匹配店铺')
return
}
queueWorkerRunning.value = true
pushing.value = true
queuePayloadText.value = ''
let successCount = 0
let failedCount = 0
try {
for (let index = 0; index < matchedRows.length; index += 1) {
const row = matchedRows[index]
let index = 0
while (autoQueueEnabled.value) {
const row = nextMatchedQueueItem()
if (!row) {
break
}
index += 1
try {
let attempt = 0
const taskVo = await (async () => {
@@ -1107,37 +1154,38 @@ async function pushToPythonQueue() {
failedCount += 1
queuePushResult.value = '任务 ' + taskVo.taskId + ' 推送失败,已自动继续下一条:' + (pushResult?.error || '未知错误')
ElMessage.error(queuePushResult.value)
removeMatchedRowsLocally([row])
continue
}
addPollingTask(taskVo.taskId)
scheduleNextPoll(true)
removeMatchedRowsLocally([row])
queuePushResult.value = matchedRows.length > 1
? '任务 ' + taskVo.taskId + ' 已入队,等待完成后继续下一条(' + (index + 1) + '/' + matchedRows.length + ''
: '任务 ' + taskVo.taskId + ' 已入队,等待执行完成'
queuePushResult.value = '任务 ' + taskVo.taskId + ' 已入队,等待执行完成'
const finalStatus = await waitForTaskTerminal(taskVo.taskId)
if (finalStatus !== 'SUCCESS') {
failedCount += 1
queuePushResult.value = '任务 ' + taskVo.taskId + ' 执行失败,已自动继续下一条' + (index + 1) + '/' + matchedRows.length + ''
queuePushResult.value = '任务 ' + taskVo.taskId + ' 执行失败,已自动继续下一条'
ElMessage.error(queuePushResult.value)
continue
}
successCount += 1
queuePushResult.value = index + 1 < matchedRows.length
? '任务 ' + taskVo.taskId + ' 已完成,继续推送下一条(' + (index + 1) + '/' + matchedRows.length + ''
: '任务 ' + taskVo.taskId + ' 已完成'
queuePushResult.value = '任务 ' + taskVo.taskId + ' 已完成'
} catch (error) {
failedCount += 1
queuePushResult.value = '第 ' + (index + 1) + '/' + matchedRows.length + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误')
queuePushResult.value = '第 ' + index + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误')
ElMessage.error(queuePushResult.value)
removeMatchedRowsLocally([row])
continue
}
}
ElMessage.success('店铺任务推送已完成:成功 ' + successCount + ' 条,失败 ' + failedCount + ' 条')
if (successCount > 0 || failedCount > 0) {
ElMessage.success('店铺任务推送已完成:成功 ' + successCount + ' 条,失败 ' + failedCount + ' 条')
}
} catch (e) {
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
ElMessage.error(queuePushResult.value)
} finally {
queueWorkerRunning.value = false
pushing.value = false
}
}

View File

@@ -229,6 +229,8 @@ const matchedItems = ref<ProductRiskShopQueueItem[]>([])
const adding = ref(false)
const matching = ref(false)
const pushing = ref(false)
const queueWorkerRunning = ref(false)
const autoQueueEnabled = ref(false)
const queuePushResult = ref('')
const queuePayloadText = ref('')
@@ -897,6 +899,9 @@ async function runMatch() {
const batch = res.items || []
matchedItems.value = mergeMatchedItems(matchedItems.value, batch)
saveMatchedItemsToStorage()
if (autoQueueEnabled.value) {
void processMatchedQueue()
}
if (!batch.length) {
ElMessage.warning('未返回匹配结果')
} else {
@@ -982,6 +987,18 @@ function formatMatchRemark(row: ProductRiskShopQueueItem) {
}
async function pushToPythonQueue() {
autoQueueEnabled.value = true
await processMatchedQueue()
}
function nextMatchedQueueItem() {
return matchedItems.value.find((item) => item.matched)
}
async function processMatchedQueue() {
if (queueWorkerRunning.value) {
return
}
if (!matchedItems.value.length) {
ElMessage.warning('请先匹配店铺')
return
@@ -996,13 +1013,19 @@ async function pushToPythonQueue() {
ElMessage.warning('没有已匹配的店铺可推送')
return
}
queueWorkerRunning.value = true
pushing.value = true
queuePayloadText.value = ''
let successCount = 0
let failedCount = 0
try {
for (let i = 0; i < toPush.length; i++) {
const item = toPush[i]
let index = 0
while (autoQueueEnabled.value) {
const item = nextMatchedQueueItem()
if (!item) {
break
}
index += 1
try {
const created = await createProductRiskTaskWithRetry([item])
const taskId = created.taskId
@@ -1031,39 +1054,40 @@ async function pushToPythonQueue() {
if (!pushResult?.success) {
removePollingTask(taskId)
failedCount += 1
queuePushResult.value = '第 ' + (i + 1) + '/' + toPush.length + ' 条推送失败:' + (pushResult?.error || '未知错误') + ',已自动继续下一条'
queuePushResult.value = '第 ' + index + ' 条推送失败:' + (pushResult?.error || '未知错误') + ',已自动继续下一条'
ElMessage.error(queuePushResult.value)
removeMatchedRowsLocally([item])
continue
}
removeMatchedRowsLocally([item])
queuePushResult.value = toPush.length > 1
? '任务 ' + taskId + ':第 ' + (i + 1) + '/' + toPush.length + ' 条店铺已入队,等待执行完成...'
: '任务 ' + taskId + ' 已入队,等待执行完成...'
queuePushResult.value = '任务 ' + taskId + ' 已入队,等待执行完成...'
const finalStatus = await waitForTaskTerminal(taskId)
if (finalStatus !== 'SUCCESS') {
failedCount += 1
queuePushResult.value = '任务 ' + taskId + ' 执行失败,已自动继续下一条' + (i + 1) + '/' + toPush.length + ''
queuePushResult.value = '任务 ' + taskId + ' 执行失败,已自动继续下一条'
ElMessage.error(queuePushResult.value)
continue
}
successCount += 1
queuePushResult.value = i + 1 < toPush.length
? '任务 ' + taskId + ' 已完成,继续推送下一条(' + (i + 1) + '/' + toPush.length + ''
: '任务 ' + taskId + ' 已完成'
queuePushResult.value = '任务 ' + taskId + ' 已完成'
} catch (error) {
failedCount += 1
queuePushResult.value = '第 ' + (i + 1) + '/' + toPush.length + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误')
queuePushResult.value = '第 ' + index + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误')
ElMessage.error(queuePushResult.value)
removeMatchedRowsLocally([item])
continue
}
}
ElMessage.success('店铺推送已完成:成功 ' + successCount + ' 条,失败 ' + failedCount + ' 条')
if (successCount > 0 || failedCount > 0) {
ElMessage.success('店铺推送已完成:成功 ' + successCount + ' 条,失败 ' + failedCount + ' 条')
}
await loadHistory()
ensurePolling(true)
} catch (e) {
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
ElMessage.error(queuePushResult.value)
} finally {
queueWorkerRunning.value = false
pushing.value = false
}
}

View File

@@ -121,6 +121,8 @@ const shopMatchListingFilter = ref<ListingFilterValue>('Active')
const adding = ref(false)
const matching = ref(false)
const pushing = ref(false)
const queueWorkerRunning = ref(false)
const autoQueueEnabled = ref(false)
const queuePushResult = ref('')
const queuePayloadText = ref('')
const orderedCountryCodes = ref<string[]>(['DE', 'UK', 'FR', 'IT', 'ES'])
@@ -241,7 +243,7 @@ function addScheduleValue() { const last = schedulePickerValues.value[schedulePi
function removeScheduleValue(index: number) { if (schedulePickerValues.value.length <= 1) return; schedulePickerValues.value = schedulePickerValues.value.filter((_, idx) => idx !== index) }
function updateScheduleValue(index: number, value: string | null) { const list = [...schedulePickerValues.value]; list[index] = value || ''; schedulePickerValues.value = sanitizeSchedulePickerValues(list, false) }
function mergeMatchedItems(base: ShopMatchShopQueueItem[], incoming: ShopMatchShopQueueItem[]) { const map = new Map<string, ShopMatchShopQueueItem>(); for (const item of base) map.set(rowKeyForMatch(item), item); for (const item of incoming) map.set(rowKeyForMatch(item), item); return Array.from(map.values()) }
async function runMatch() { const names = selectedCandidates.value.map((item) => item.shop_name).filter(Boolean); if (!names.length) { ElMessage.warning('请先选择候选店铺'); return } matching.value = true; try { const data = await matchShopMatchShops(names); matchedItems.value = mergeMatchedItems(matchedItems.value, data.items || []); saveMatchedItemsToStorage(); ElMessage.success(`本次返回 ${data.items?.length || 0} 条匹配结果`) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '匹配失败') } finally { matching.value = false } }
async function runMatch() { const names = selectedCandidates.value.map((item) => item.shop_name).filter(Boolean); if (!names.length) { ElMessage.warning('请先选择候选店铺'); return } matching.value = true; try { const data = await matchShopMatchShops(names); matchedItems.value = mergeMatchedItems(matchedItems.value, data.items || []); saveMatchedItemsToStorage(); if (autoQueueEnabled.value) void processMatchedQueue(); ElMessage.success(`本次返回 ${data.items?.length || 0} 条匹配结果`) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '匹配失败') } finally { matching.value = false } }
function removeMatchedRow(row: ShopMatchShopQueueItem) { matchedItems.value = matchedItems.value.filter((item) => rowKeyForMatch(item) !== rowKeyForMatch(row)); saveMatchedItemsToStorage() }
function removeMatchedRowsLocally(rows: ShopMatchHistoryItem[] | ShopMatchShopQueueItem[]) { const keys = new Set(rows.map((row) => rowKeyForMatch(row as ShopMatchShopQueueItem))); matchedItems.value = matchedItems.value.filter((item) => !keys.has(rowKeyForMatch(item))); saveMatchedItemsToStorage() }
function parseScheduleValues() {
@@ -289,10 +291,9 @@ function sanitizeSchedulePickerValues(values: string[], ensureOne: boolean) {
function buildQueuePayload(taskId: number, item: Pick<ShopMatchHistoryItem, 'shopName' | 'shopId' | 'platform' | 'companyName' | 'matchStatus' | 'matchMessage'>, countryCodes: string[], stageIndex?: number, finalStage = true) { return { type: 'shop-match-run', ts: Date.now(), data: { taskId, user_id: currentUserId(), items: [{ shopName: item.shopName, shopId: item.shopId, platform: item.platform, companyName: item.companyName, matched: true, matchStatus: item.matchStatus, matchMessage: item.matchMessage }], country_codes: [...countryCodes], risk_listing_filter: shopMatchListingFilter.value, stage_index: stageIndex, final_stage: finalStage } } }
function nextScheduleStage(taskId: number) { const task = taskSnapshots.value[taskId]?.task; const stages = task?.scheduleStages || []; const currentStageIndex = task?.currentStageIndex; if (typeof currentStageIndex !== 'number') return null; const stage = stages.find((item) => item.stageIndex === currentStageIndex); if (!stage?.scheduledAt) return null; return { stageIndex: currentStageIndex, scheduledAt: stage.scheduledAt, finalStage: currentStageIndex === stages.length - 1 } }
async function dispatchScheduledTask(taskId: number, item: ShopMatchHistoryItem) { const api = getPywebviewApi(); if (!api?.enqueue_json) throw new Error('当前客户端未提供 enqueue_json'); const stage = nextScheduleStage(taskId); const snapshot = taskSnapshots.value[taskId]; const countryCodes = snapshot?.task?.countryCodes || orderedCountryCodes.value; if (!stage) throw new Error(`任务 ${taskId} 缺少待执行阶段`); clearDispatchTimer(taskId); await withTransientRetry(() => activateShopMatchTask(taskId, stage.stageIndex), (attempt, maxAttempts) => { queuePushResult.value = `任务 ${taskId} 等待后端恢复后再激活执行(${attempt}/${maxAttempts}...` }); taskDetails.value = { ...taskDetails.value, [taskId]: 'RUNNING' }; if (snapshot?.task) taskSnapshots.value = { ...taskSnapshots.value, [taskId]: { ...snapshot, task: { ...snapshot.task, status: 'RUNNING', activeStageIndex: stage.stageIndex } } }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const payload = buildQueuePayload(taskId, item, countryCodes, stage.stageIndex, stage.finalStage); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); queuePushResult.value = `任务 ${taskId} 已推送第 ${stage.stageIndex + 1}`; ensurePolling(true) }
function hasRunningTask() { return Object.values(taskDetails.value).some((status) => status === 'RUNNING') || Object.values(taskSnapshots.value).some((detail) => detail.task?.status === 'RUNNING') }
function scheduledTimestamp(value?: string) { const date = parseScheduleDateTime(value); return date ? date.getTime() : Number.NaN }
function findNextReadyScheduledTask() { 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) } const ready = Array.from(taskIds).map((taskId) => { const snapshot = taskSnapshots.value[taskId]; const taskStatus = taskDetails.value[taskId] || snapshot?.task?.status; const item = snapshot?.items?.[0] || historyItems.value.find((row) => normalizeTaskId(row.taskId) === taskId); const stage = nextScheduleStage(taskId); if (taskStatus !== 'SCHEDULED' || !item || !stage?.scheduledAt) return null; const scheduledTime = scheduledTimestamp(stage.scheduledAt); if (!Number.isFinite(scheduledTime) || scheduledTime > Date.now()) return null; return { taskId, item, scheduledTime } }).filter((item): item is { taskId: number; item: ShopMatchHistoryItem; scheduledTime: number } => !!item); ready.sort((a, b) => a.scheduledTime - b.scheduledTime || a.taskId - b.taskId); return ready[0] || null }
async function pumpScheduledQueue() { if (scheduledDispatchInFlight.value || hasRunningTask()) return; const readyTask = findNextReadyScheduledTask(); if (!readyTask) return; scheduledDispatchInFlight.value = true; try { await dispatchScheduledTask(readyTask.taskId, readyTask.item) } catch (error) { const message = error instanceof Error ? error.message : `任务 ${readyTask.taskId} 调度失败`; const key = `${readyTask.taskId}:${message}`; const now = Date.now(); queuePushResult.value = message; if (lastScheduledErrorKey !== key || now - lastScheduledErrorAt > 10000) { lastScheduledErrorKey = key; lastScheduledErrorAt = now; ElMessage.error(message) } await Promise.allSettled([loadHistory(), refreshTaskBatch()]) } finally { scheduledDispatchInFlight.value = false } }
function findReadyScheduledTasks() { 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) } const ready = Array.from(taskIds).map((taskId) => { const snapshot = taskSnapshots.value[taskId]; const taskStatus = taskDetails.value[taskId] || snapshot?.task?.status; const item = snapshot?.items?.[0] || historyItems.value.find((row) => normalizeTaskId(row.taskId) === taskId); const stage = nextScheduleStage(taskId); if (taskStatus !== 'SCHEDULED' || !item || !stage?.scheduledAt) return null; const scheduledTime = scheduledTimestamp(stage.scheduledAt); if (!Number.isFinite(scheduledTime) || scheduledTime > Date.now()) return null; return { taskId, item, scheduledTime } }).filter((item): item is { taskId: number; item: ShopMatchHistoryItem; scheduledTime: number } => !!item); ready.sort((a, b) => a.scheduledTime - b.scheduledTime || a.taskId - b.taskId); return ready }
async function pumpScheduledQueue() { if (scheduledDispatchInFlight.value) return; const readyTasks = findReadyScheduledTasks(); if (!readyTasks.length) return; scheduledDispatchInFlight.value = true; try { for (const readyTask of readyTasks) { try { await dispatchScheduledTask(readyTask.taskId, readyTask.item) } catch (error) { const message = error instanceof Error ? error.message : `任务 ${readyTask.taskId} 调度失败`; const key = `${readyTask.taskId}:${message}`; const now = Date.now(); queuePushResult.value = message; if (lastScheduledErrorKey !== key || now - lastScheduledErrorAt > 10000) { lastScheduledErrorKey = key; lastScheduledErrorAt = now; ElMessage.error(message) } await Promise.allSettled([loadHistory(), refreshTaskBatch()]) } } } finally { scheduledDispatchInFlight.value = false } }
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) }
@@ -344,7 +345,9 @@ async function deleteTaskRecord(item: ShopMatchHistoryItem) {
}
function formatMatchStatus(status?: string) { const value = (status || '').trim(); return { MATCHED: '已匹配', PENDING: '待匹配', CONFLICT: '需人工确认', INDEX_STALE: '索引过期' }[value] || value || '—' }
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() { 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 } pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; try { for (let index = 0; index < matched.length; index += 1) { const item = matched[index]; 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) 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); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = matched.length > 1 ? `任务 ${taskId} 已入队,等待完成后继续推送下一条(${index + 1}/${matched.length}` : `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条(${index + 1}/${matched.length}`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = index + 1 < matched.length ? `任务 ${taskId} 已完成,继续推送下一条(${index + 1}/${matched.length}` : `任务 ${taskId} 已完成` } catch (error) { failedCount += 1; queuePushResult.value = `${index + 1}/${matched.length} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount}` : `店铺推送已完成:成功 ${successCount} 条,失败 ${failedCount}`) } catch (error) { const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { pushing.value = false } }
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() })
</script>

View File

@@ -1211,7 +1211,9 @@ export interface PriceTrackShopQueueItem {
export interface PriceTrackMatchShopsVo {
items: PriceTrackShopQueueItem[];
skipAsinsByCountry?: Record<string, string[]>;
skipAsinDetailsByCountry?: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
asinRowsByCountry?: Record<string, PriceTrackAsinParsedRow[]>;
minimumPriceByCountryAndAsin?: Record<string, Record<string, string>>;
}
export interface PriceTrackCountryPreferenceVo {
@@ -1268,7 +1270,9 @@ export interface PriceTrackCreateTaskVo {
taskId: number;
items: PriceTrackHistoryItem[];
skipAsinsByCountry?: Record<string, string[]>;
skipAsinDetailsByCountry?: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
asinRowsByCountry?: Record<string, PriceTrackAsinParsedRow[]>;
minimumPriceByCountryAndAsin?: Record<string, Record<string, string>>;
}
export interface PriceTrackTaskSummary {