更新跟价相关内容修改

This commit is contained in:
super
2026-04-20 00:41:49 +08:00
parent 7b12cebb45
commit dabb278170
19 changed files with 596 additions and 145 deletions

View File

@@ -871,8 +871,12 @@ async function refreshTaskDetails(taskIds?: number[]) {
if (changed) {
syncResultState()
}
} catch {
// ignore polling failures
} catch (error) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
if (transient && chainStarted.value) {
queuePushResult.value = '当前任务运行中,正在等待后端服务恢复...'
}
}
}
@@ -1102,7 +1106,11 @@ async function loadHistory() {
await refreshTaskDetails()
ensurePolling(true)
} catch (err) {
console.error('loadHistory error:', err)
const message = (err instanceof Error ? err.message : String(err || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
if (!transient) {
console.error('loadHistory error:', err)
}
}
}

View File

@@ -261,7 +261,7 @@ import { ElMessage } from 'element-plus'
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
import { expandBrandFolderRecursive } from '@/shared/api/brand'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import {
addPriceTrackCandidate,
completePriceTrackLoopChild,
@@ -327,6 +327,7 @@ const asinModeEnabled = ref(false) // 按指定ASIN文档自定义
// 上传文件
const asinFiles = ref<string[]>([]) // ASIN文档
const asinUploadedFiles = ref<UploadedJavaFile[]>([])
// 是否有有效模式
const hasValidMode = computed(() => statusModeEnabled.value || asinModeEnabled.value)
@@ -499,6 +500,7 @@ async function selectAsinFile() {
})
if (result?.paths?.length) {
asinFiles.value = result.paths
asinUploadedFiles.value = await uploadAsinPathsToJava(result.paths)
ElMessage.success(`已选择 ${result.paths.length} 个ASIN文件`)
}
return
@@ -507,6 +509,7 @@ async function selectAsinFile() {
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
asinFiles.value = paths
asinUploadedFiles.value = await uploadAsinPathsToJava(paths)
ElMessage.success(`已选择 ${paths.length} 个ASIN文件`)
return
}
@@ -527,7 +530,8 @@ async function selectAsinFolder() {
ElMessage.warning(result.error || '该文件夹下没有可用的 xlsx 文件')
return
}
asinFiles.value = result.items.map((item) => item.absolutePath)
asinFiles.value = result.items.map((item) => item.relativePath || item.absolutePath)
asinUploadedFiles.value = await uploadAsinPathsToJava(result.items)
ElMessage.success(`已选择文件夹内 ${result.items.length} 个ASIN文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
@@ -537,6 +541,31 @@ async function selectAsinFolder() {
// ========== 国家顺序相关 ==========
async function uploadAsinPathsToJava(paths: Array<string | { absolutePath: string; relativePath?: string }>) {
const api = getPywebviewApi()
if (!api?.upload_file_to_java) {
return []
}
const uploaded: UploadedJavaFile[] = []
for (const item of paths) {
const filePath = typeof item === 'string' ? item : item.absolutePath
const relativePath = typeof item === 'string' ? undefined : item.relativePath
const result = await api.upload_file_to_java(filePath, relativePath)
if (!result?.success || !result.data) {
throw new Error(result?.error || result?.message || `上传失败:${filePath}`)
}
uploaded.push(result.data)
}
return uploaded
}
function resolveAsinRequestPaths() {
if (asinUploadedFiles.value.length) {
return asinUploadedFiles.value.map((item) => item.localPath).filter((path) => !!path)
}
return asinFiles.value
}
function selectMode(mode: 'status' | 'asin') {
statusModeEnabled.value = mode === 'status'
asinModeEnabled.value = mode === 'asin'
@@ -744,7 +773,7 @@ async function runMatch() {
matching.value = true
try {
const res = await matchPriceTrackShops(names, {
asinFiles: asinModeEnabled.value ? asinFiles.value : [],
asinFiles: asinModeEnabled.value ? resolveAsinRequestPaths() : [],
countryCodes: [...orderedCountryCodes.value],
})
const batch = res.items || []
@@ -846,7 +875,7 @@ async function pushToPythonQueueLegacy() {
statusMode: statusModeEnabled.value,
asinMode: asinModeEnabled.value,
items: matchedRows as unknown as Record<string, unknown>[],
asinFiles: asinFiles.value,
asinFiles: resolveAsinRequestPaths(),
countryCodes: [...orderedCountryCodes.value],
}
const taskVo = await createPriceTrackTask(taskReq)
@@ -906,6 +935,7 @@ function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQu
task_id: taskVo.taskId,
shop_name: shopName,
shop_id: row.shopId ?? null,
shop_mall_name: row.shopMallName || '',
country_codes: [...orderedCountryCodes.value],
mode: statusModeEnabled.value ? 'status' : 'asin',
skip_asins: skipAsinsByCountry,
@@ -918,32 +948,51 @@ function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQu
}
async function waitForTaskTerminal(taskId: number) {
let transientErrorCount = 0
const maxTransientErrors = 30
while (true) {
const batch = await getPriceTrackTaskProgressBatch([taskId])
if ((batch.missingTaskIds || []).includes(taskId)) {
removePollingTask(taskId)
await loadHistory()
await loadDashboard()
syncPollingIdsWithHistory()
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 = { ...taskDetails.value, [taskId]: status }
saveTaskDetailsToStorage()
}
if (status === 'SUCCESS' || status === 'FAILED') {
removePollingTask(taskId)
await loadHistory()
await loadDashboard()
syncPollingIdsWithHistory()
return status
try {
const batch = await getPriceTrackTaskProgressBatch([taskId])
transientErrorCount = 0
if ((batch.missingTaskIds || []).includes(taskId)) {
removePollingTask(taskId)
await loadHistory()
await loadDashboard()
syncPollingIdsWithHistory()
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 = { ...taskDetails.value, [taskId]: status }
saveTaskDetailsToStorage()
}
if (status === 'SUCCESS' || status === 'FAILED') {
removePollingTask(taskId)
await loadHistory()
await loadDashboard()
syncPollingIdsWithHistory()
return status
}
} catch (error) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
if (!transient) 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 new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
continue
}
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
}
@@ -973,14 +1022,28 @@ async function pushToPythonQueue() {
try {
for (let index = 0; index < matchedRows.length; index += 1) {
const row = matchedRows[index]
const taskVo = await createPriceTrackTask({
userId: 0,
statusMode: statusModeEnabled.value,
asinMode: asinModeEnabled.value,
items: [row] as unknown as Record<string, unknown>[],
asinFiles: asinFiles.value,
countryCodes: [...orderedCountryCodes.value],
})
let attempt = 0
const taskVo = await (async () => {
while (true) {
try {
return await createPriceTrackTask({
userId: 0,
statusMode: statusModeEnabled.value,
asinMode: asinModeEnabled.value,
items: [row] as unknown as Record<string, unknown>[],
asinFiles: resolveAsinRequestPaths(),
countryCodes: [...orderedCountryCodes.value],
})
} catch (error) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
attempt += 1
if (!transient || attempt >= 10) throw error
queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/10...`
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
}
}
})()
taskSnapshots.value = {
...taskSnapshots.value,
[taskVo.taskId]: {
@@ -1050,7 +1113,10 @@ async function syncActiveLoopRun() {
setLoopRun(loop)
clearLoopRunIfTerminal()
return activeLoopRun.value
} catch {
} catch (error) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
if (transient) return activeLoopRun.value
activeLoopRun.value = null
activeLoopRunId.value = null
saveActiveLoopRunId()
@@ -1107,10 +1173,25 @@ async function runLoopExecution(loopId?: number) {
if (!loop || loop.status === 'SUCCESS' || loop.status === 'FAILED' || loop.status === 'STOPPED') break
if (loop.activeTaskId) {
addPollingTask(loop.activeTaskId)
const activeTaskId = loop.activeTaskId
addPollingTask(activeTaskId)
scheduleNextPoll(true)
const finalStatus = await waitForTaskTerminal(loop.activeTaskId)
const updatedLoop = await completePriceTrackLoopChild(loop.id, loop.activeTaskId)
const finalStatus = await waitForTaskTerminal(activeTaskId)
let completeAttempt = 0
const updatedLoop = await (async () => {
while (true) {
try {
return await completePriceTrackLoopChild(loop.id, activeTaskId)
} catch (error) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
completeAttempt += 1
if (!transient || completeAttempt >= 10) throw error
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后确认子任务完成(${completeAttempt}/10...`
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
}
}
})()
setLoopRun(updatedLoop)
await loadHistory()
await loadDashboard()
@@ -1122,15 +1203,44 @@ async function runLoopExecution(loopId?: number) {
continue
}
const dispatch = await dispatchNextPriceTrackLoopRun(loop.id)
let dispatchAttempt = 0
const dispatch = await (async () => {
while (true) {
try {
return await dispatchNextPriceTrackLoopRun(loop.id)
} catch (error) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
dispatchAttempt += 1
if (!transient || dispatchAttempt >= 10) throw error
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后继续派发(${dispatchAttempt}/10...`
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
}
}
})()
setLoopRun(dispatch.loopRun || loop)
if (!dispatch.childTaskRequest) {
clearLoopRunIfTerminal()
break
}
const row = dispatch.childTaskRequest.items?.[0]
const childTaskRequest = dispatch.childTaskRequest
const row = childTaskRequest.items?.[0]
if (!row) throw new Error('循环任务缺少待执行店铺')
const taskVo = await createPriceTrackTask(dispatch.childTaskRequest)
let createAttempt = 0
const taskVo = await (async () => {
while (true) {
try {
return await createPriceTrackTask(childTaskRequest)
} catch (error) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
createAttempt += 1
if (!transient || createAttempt >= 10) throw error
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后重试创建子任务(${createAttempt}/10...`
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
}
}
})()
recordCreatedTask(taskVo)
const queuePayload = buildQueuePayload(taskVo, row)
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
@@ -1185,7 +1295,7 @@ async function pushToPythonLoopQueue() {
statusMode: statusModeEnabled.value,
asinMode: asinModeEnabled.value,
items: matchedRows,
asinFiles: asinFiles.value,
asinFiles: resolveAsinRequestPaths(),
countryCodes: [...orderedCountryCodes.value],
executionMode: executionMode.value,
targetRounds: executionMode.value === 'FINITE' ? Math.max(1, Number(roundCount.value) || 1) : undefined,

View File

@@ -638,6 +638,44 @@ const historySectionItems = computed(() =>
const hasVisibleList = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
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 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 createProductRiskTaskWithRetry(items: ProductRiskShopQueueItem[], maxAttempts = 10) {
let attempt = 0
while (true) {
try {
return await createProductRiskTask(items)
} catch (error) {
attempt += 1
if (!isTransientBackendError(error) || attempt >= maxAttempts) {
throw error
}
queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...`
await sleep(getPollIntervalMs())
}
}
}
async function refreshTaskBatch() {
const ids = pollingTaskIds.value.filter((id) => id > 0)
if (!ids.length) return
@@ -718,34 +756,50 @@ function stopPolling() {
}
async function waitForTaskTerminal(taskId: number) {
let transientErrorCount = 0
const maxTransientErrors = 30
while (true) {
const batch = await getProductRiskTaskProgressBatch([taskId])
const detail = (batch.items || []).find((d) => d.task?.id === taskId)
const status = detail?.task?.status || ''
try {
const batch = await getProductRiskTaskProgressBatch([taskId])
transientErrorCount = 0
const detail = (batch.items || []).find((d) => d.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,
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()
}
saveTaskSnapshotsToStorage()
}
if (status) {
taskDetails.value[taskId] = status
saveTaskDetailsToStorage()
}
if (status === 'SUCCESS' || status === 'FAILED') {
removePollingTask(taskId)
await loadHistory()
await loadDashboard()
return status
if (status === 'SUCCESS' || status === 'FAILED') {
removePollingTask(taskId)
await loadHistory()
await loadDashboard()
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 new Promise<void>((resolve) => {
window.setTimeout(() => resolve(), getPollIntervalMs())
})
await sleep(getPollIntervalMs())
}
}
@@ -943,7 +997,7 @@ async function pushToPythonQueue() {
try {
for (let i = 0; i < toPush.length; i++) {
const item = toPush[i]
const created = await createProductRiskTask([item])
const created = await createProductRiskTaskWithRetry([item])
const taskId = created.taskId
taskSnapshots.value = {
...taskSnapshots.value,

View File

@@ -246,7 +246,7 @@ function removeMatchedRowsLocally(rows: ShopMatchHistoryItem[] | ShopMatchShopQu
function parseScheduleValues() { if (!scheduleEnabled.value) return undefined; const values = schedulePickerValues.value.map((item) => item.trim()).filter(Boolean); if (!values.length) throw new Error('请至少选择一个执行时间'); const withTime = values.map((value) => { const date = parseScheduleDateTime(value); if (!date) throw new Error(`时间格式无效: ${value}`); return { raw: formatScheduleDateTime(date), time: date.getTime() } }).sort((a, b) => a.time - b.time); for (let i = 1; i < withTime.length; i += 1) if (withTime[i].time === withTime[i - 1].time) throw new Error('执行时间不能重复'); return withTime.map((item) => item.raw) }
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 activateShopMatchTask(taskId, stage.stageIndex); 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) }
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 }
@@ -256,12 +256,16 @@ function restoreScheduledDispatches() { const taskIds = new Set<number>(pollingT
function startScheduleHeartbeat() { if (scheduleHeartbeatTimer) return; scheduleHeartbeatTimer = window.setInterval(() => { void pumpScheduledQueue() }, 1000) }
function stopScheduleHeartbeat() { if (!scheduleHeartbeatTimer) return; window.clearInterval(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 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 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 document.visibilityState === 'visible' ? 6000 : 20000 }
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 ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
function stopPolling() { if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null } }
async function waitForTaskTerminal(taskId: number) { while (true) { const batch = await getShopMatchTaskProgressBatch([taskId]); 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 } await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) }) } }
async function waitForTaskTerminal(taskId: number) { let transientErrorCount = 0; const maxTransientErrors = 30; while (true) { try { const batch = await getShopMatchTaskProgressBatch([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 resolvedTaskStatus(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); if (!taskId) return item.taskStatus || ''; return taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status || item.taskStatus || '' }
function taskSnapshotOf(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); return taskId ? taskSnapshots.value[taskId] : undefined }
function currentTaskStageText(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (!stages.length) return ''; const total = stages.length; const activeIndex = typeof task?.activeStageIndex === 'number' ? task.activeStageIndex : undefined; const currentIndex = typeof task?.currentStageIndex === 'number' ? task.currentStageIndex : undefined; if (typeof activeIndex === 'number') return `执行进度: 第 ${activeIndex + 1}/${total} 次执行中`; if (typeof currentIndex === 'number') return `执行进度: 等待第 ${currentIndex + 1}/${total}`; return `执行进度: 共 ${total}`}
@@ -298,7 +302,7 @@ 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 = ''; try { for (let index = 0; index < matched.length; index += 1) { const item = matched[index]; const created = await createShopMatchTask([item], orderedCountryCodes.value, scheduleValues); 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) { 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) throw new Error(result?.error || `任务 ${taskId} 推送失败`); 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') { throw new Error(`任务 ${taskId} 执行失败,已停止后续店铺推送`) } queuePushResult.value = index + 1 < matched.length ? `任务 ${taskId} 已完成,继续推送下一条(${index + 1}/${matched.length}` : `任务 ${taskId} 已完成` } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); ElMessage.success(scheduleValues?.length ? '定时任务已创建,后续会按时间串行推入 Python 队列' : `已按顺序完成 ${matched.length} 条店铺推送`) } catch (error) { const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { pushing.value = false } }
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 = ''; try { for (let index = 0; index < matched.length; index += 1) { const item = matched[index]; 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) { 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) throw new Error(result?.error || `任务 ${taskId} 推送失败`); 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') { throw new Error(`任务 ${taskId} 执行失败,已停止后续店铺推送`) } queuePushResult.value = index + 1 < matched.length ? `任务 ${taskId} 已完成,继续推送下一条(${index + 1}/${matched.length}` : `任务 ${taskId} 已完成` } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); ElMessage.success(scheduleValues?.length ? '定时任务已创建,后续会按时间串行推入 Python 队列' : `已按顺序完成 ${matched.length} 条店铺推送`) } catch (error) { const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { pushing.value = false } }
onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); 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() })

View File

@@ -925,6 +925,7 @@ export interface PriceTrackCandidateVo {
export interface PriceTrackShopQueueItem {
shopName?: string;
shopMallName?: string;
shopId?: number | string | null;
matched?: boolean;
matchStatus?: string;
@@ -958,6 +959,7 @@ export interface PriceTrackHistoryItem {
loopRunId?: number;
roundIndex?: number;
shopName?: string;
shopMallName?: string;
shopId?: number | string | null;
platform?: string;
companyName?: string;