处理后台管理系统、修复BUG、处理权限
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-shell module-page">
|
||||
<BrandTopBar active="delete-brand" />
|
||||
|
||||
@@ -225,6 +225,7 @@ import {
|
||||
type DeleteBrandTaskDetailVo,
|
||||
} from '@/shared/api/java-modules'
|
||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
|
||||
interface SessionDeleteBrandItem extends DeleteBrandResultItem {
|
||||
_pushed?: boolean
|
||||
@@ -261,6 +262,7 @@ const chainStarted = ref(false)
|
||||
const activeItemKey = ref('')
|
||||
const autoAdvancing = ref(false)
|
||||
const autoRetryTimer = ref<number | null>(null)
|
||||
const waitingForBackendRecovery = ref(false)
|
||||
const displayPaths = computed(() => selectedPaths.value.slice(0, 10))
|
||||
|
||||
const currentSectionItems = computed(() => {
|
||||
@@ -855,6 +857,10 @@ async function refreshTaskDetails(taskIds?: number[]) {
|
||||
|
||||
try {
|
||||
const batch = await getDeleteBrandTaskProgress(ids)
|
||||
if (waitingForBackendRecovery.value && chainStarted.value) {
|
||||
queuePushResult.value = '后端已恢复,继续执行当前任务并自动衔接后续任务...'
|
||||
}
|
||||
waitingForBackendRecovery.value = false
|
||||
let changed = false
|
||||
for (const detail of batch.items || []) {
|
||||
const id = detail?.task?.id
|
||||
@@ -875,13 +881,14 @@ async function refreshTaskDetails(taskIds?: number[]) {
|
||||
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) {
|
||||
waitingForBackendRecovery.value = true
|
||||
queuePushResult.value = '当前任务运行中,正在等待后端服务恢复...'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPollIntervalMs() {
|
||||
return document.visibilityState === 'visible' ? 6000 : 20000
|
||||
return getTaskPollIntervalMs()
|
||||
}
|
||||
|
||||
function scheduleNextPoll(immediate = false) {
|
||||
@@ -1898,3 +1905,4 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
1099
frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue
Normal file
1099
frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-shell module-page">
|
||||
<BrandTopBar active="pricing" />
|
||||
|
||||
@@ -262,6 +262,7 @@ 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, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import {
|
||||
addPriceTrackCandidate,
|
||||
completePriceTrackLoopChild,
|
||||
@@ -989,6 +990,9 @@ async function waitForTaskTerminal(taskId: number) {
|
||||
while (true) {
|
||||
try {
|
||||
const batch = await getPriceTrackTaskProgressBatch([taskId])
|
||||
if (transientErrorCount > 0) {
|
||||
queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`
|
||||
}
|
||||
transientErrorCount = 0
|
||||
if ((batch.missingTaskIds || []).includes(taskId)) {
|
||||
removePollingTask(taskId)
|
||||
@@ -1055,69 +1059,81 @@ async function pushToPythonQueue() {
|
||||
}
|
||||
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 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()) })
|
||||
try {
|
||||
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]: {
|
||||
task: { id: taskVo.taskId, status: 'RUNNING' },
|
||||
items: taskVo.items,
|
||||
},
|
||||
}
|
||||
})()
|
||||
taskSnapshots.value = {
|
||||
...taskSnapshots.value,
|
||||
[taskVo.taskId]: {
|
||||
task: { id: taskVo.taskId, status: 'RUNNING' },
|
||||
items: taskVo.items,
|
||||
},
|
||||
taskDetails.value = {
|
||||
...taskDetails.value,
|
||||
[taskVo.taskId]: 'RUNNING',
|
||||
}
|
||||
saveTaskSnapshotsToStorage()
|
||||
saveTaskDetailsToStorage()
|
||||
const queuePayload = buildQueuePayload(taskVo, row)
|
||||
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
|
||||
const pushResult = await api.enqueue_json(queuePayload)
|
||||
if (!pushResult?.success) {
|
||||
failedCount += 1
|
||||
queuePushResult.value = '任务 ' + taskVo.taskId + ' 推送失败,已自动继续下一条:' + (pushResult?.error || '未知错误')
|
||||
ElMessage.error(queuePushResult.value)
|
||||
continue
|
||||
}
|
||||
addPollingTask(taskVo.taskId)
|
||||
scheduleNextPoll(true)
|
||||
removeMatchedRowsLocally([row])
|
||||
queuePushResult.value = matchedRows.length > 1
|
||||
? '任务 ' + taskVo.taskId + ' 已入队,等待完成后继续下一条(' + (index + 1) + '/' + matchedRows.length + ')'
|
||||
: '任务 ' + taskVo.taskId + ' 已入队,等待执行完成'
|
||||
const finalStatus = await waitForTaskTerminal(taskVo.taskId)
|
||||
if (finalStatus !== 'SUCCESS') {
|
||||
failedCount += 1
|
||||
queuePushResult.value = '任务 ' + taskVo.taskId + ' 执行失败,已自动继续下一条(' + (index + 1) + '/' + matchedRows.length + ')'
|
||||
ElMessage.error(queuePushResult.value)
|
||||
continue
|
||||
}
|
||||
successCount += 1
|
||||
queuePushResult.value = index + 1 < matchedRows.length
|
||||
? '任务 ' + taskVo.taskId + ' 已完成,继续推送下一条(' + (index + 1) + '/' + matchedRows.length + ')'
|
||||
: '任务 ' + taskVo.taskId + ' 已完成'
|
||||
} catch (error) {
|
||||
failedCount += 1
|
||||
queuePushResult.value = '第 ' + (index + 1) + '/' + matchedRows.length + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误')
|
||||
ElMessage.error(queuePushResult.value)
|
||||
continue
|
||||
}
|
||||
taskDetails.value = {
|
||||
...taskDetails.value,
|
||||
[taskVo.taskId]: 'RUNNING',
|
||||
}
|
||||
saveTaskSnapshotsToStorage()
|
||||
saveTaskDetailsToStorage()
|
||||
|
||||
const queuePayload = buildQueuePayload(taskVo, row)
|
||||
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
|
||||
const pushResult = await api.enqueue_json(queuePayload)
|
||||
if (!pushResult?.success) {
|
||||
throw new Error(pushResult?.error || `任务 ${taskVo.taskId} 推送失败`)
|
||||
}
|
||||
|
||||
addPollingTask(taskVo.taskId)
|
||||
scheduleNextPoll(true)
|
||||
removeMatchedRowsLocally([row])
|
||||
queuePushResult.value = matchedRows.length > 1
|
||||
? `任务 ${taskVo.taskId} 已入队,等待完成后继续下一条(${index + 1}/${matchedRows.length})`
|
||||
: `任务 ${taskVo.taskId} 已入队,等待执行完成`
|
||||
|
||||
const finalStatus = await waitForTaskTerminal(taskVo.taskId)
|
||||
if (finalStatus !== 'SUCCESS') {
|
||||
throw new Error(`任务 ${taskVo.taskId} 执行失败,已停止后续任务`)
|
||||
}
|
||||
|
||||
queuePushResult.value = index + 1 < matchedRows.length
|
||||
? `任务 ${taskVo.taskId} 已完成,继续推送下一条(${index + 1}/${matchedRows.length})`
|
||||
: `任务 ${taskVo.taskId} 已完成`
|
||||
}
|
||||
ElMessage.success(`已串行完成 ${matchedRows.length} 条店铺任务推送`)
|
||||
ElMessage.success('店铺任务推送已完成:成功 ' + successCount + ' 条,失败 ' + failedCount + ' 条')
|
||||
} catch (e) {
|
||||
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
|
||||
ElMessage.error(queuePushResult.value)
|
||||
@@ -1353,7 +1369,7 @@ async function pushToPythonLoopQueue() {
|
||||
}
|
||||
|
||||
function getPollIntervalMs() {
|
||||
return document.visibilityState === 'visible' ? 6000 : 20000
|
||||
return getTaskPollIntervalMs()
|
||||
}
|
||||
|
||||
async function refreshTaskBatch() {
|
||||
@@ -2248,3 +2264,4 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-shell module-page">
|
||||
<BrandTopBar active="product-risk" />
|
||||
|
||||
@@ -220,6 +220,7 @@ import {
|
||||
type ProductRiskTaskDetailVo,
|
||||
} from '@/shared/api/java-modules'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
|
||||
const shopInput = ref('')
|
||||
const candidates = ref<ProductRiskCandidateVo[]>([])
|
||||
@@ -711,7 +712,7 @@ async function refreshTaskBatch() {
|
||||
}
|
||||
|
||||
function getPollIntervalMs() {
|
||||
return document.visibilityState === 'visible' ? 6000 : 20000
|
||||
return getTaskPollIntervalMs()
|
||||
}
|
||||
|
||||
function scheduleNextPoll(immediate = false) {
|
||||
@@ -761,6 +762,9 @@ async function waitForTaskTerminal(taskId: number) {
|
||||
while (true) {
|
||||
try {
|
||||
const batch = await getProductRiskTaskProgressBatch([taskId])
|
||||
if (transientErrorCount > 0) {
|
||||
queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`
|
||||
}
|
||||
transientErrorCount = 0
|
||||
const detail = (batch.items || []).find((d) => d.task?.id === taskId)
|
||||
const status = detail?.task?.status || ''
|
||||
@@ -984,7 +988,7 @@ async function pushToPythonQueue() {
|
||||
}
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.enqueue_json) {
|
||||
ElMessage.error('当前环境未启用 pywebview enqueue_json(浏览器内无法推队列)')
|
||||
ElMessage.error('当前环境未启用 pywebview enqueue_json')
|
||||
return
|
||||
}
|
||||
const toPush = matchedItems.value.filter((i) => i.matched)
|
||||
@@ -994,58 +998,66 @@ async function pushToPythonQueue() {
|
||||
}
|
||||
pushing.value = true
|
||||
queuePayloadText.value = ''
|
||||
let successCount = 0
|
||||
let failedCount = 0
|
||||
try {
|
||||
for (let i = 0; i < toPush.length; i++) {
|
||||
const item = toPush[i]
|
||||
const created = await createProductRiskTaskWithRetry([item])
|
||||
const taskId = created.taskId
|
||||
taskSnapshots.value = {
|
||||
...taskSnapshots.value,
|
||||
[taskId]: {
|
||||
task: { id: taskId, status: 'RUNNING' },
|
||||
items: created.items,
|
||||
},
|
||||
}
|
||||
saveTaskSnapshotsToStorage()
|
||||
addPollingTask(taskId)
|
||||
ensurePolling(true)
|
||||
|
||||
const payload = {
|
||||
type: 'product-risk-resolve-run',
|
||||
ts: Date.now(),
|
||||
data: {
|
||||
taskId,
|
||||
items: [item],
|
||||
country_codes: [...orderedCountryCodes.value],
|
||||
risk_listing_filter: productRiskListingFilter.value,
|
||||
},
|
||||
}
|
||||
queuePayloadText.value = JSON.stringify(payload, null, 2)
|
||||
const pushResult = await api.enqueue_json(payload)
|
||||
if (!pushResult?.success) {
|
||||
removePollingTask(taskId)
|
||||
queuePushResult.value = `第 ${i + 1}/${toPush.length} 条推送失败:${pushResult?.error || '未知错误'}`
|
||||
try {
|
||||
const created = await createProductRiskTaskWithRetry([item])
|
||||
const taskId = created.taskId
|
||||
taskSnapshots.value = {
|
||||
...taskSnapshots.value,
|
||||
[taskId]: {
|
||||
task: { id: taskId, status: 'RUNNING' },
|
||||
items: created.items,
|
||||
},
|
||||
}
|
||||
saveTaskSnapshotsToStorage()
|
||||
addPollingTask(taskId)
|
||||
ensurePolling(true)
|
||||
const payload = {
|
||||
type: 'product-risk-resolve-run',
|
||||
ts: Date.now(),
|
||||
data: {
|
||||
taskId,
|
||||
items: [item],
|
||||
country_codes: [...orderedCountryCodes.value],
|
||||
risk_listing_filter: productRiskListingFilter.value,
|
||||
},
|
||||
}
|
||||
queuePayloadText.value = JSON.stringify(payload, null, 2)
|
||||
const pushResult = await api.enqueue_json(payload)
|
||||
if (!pushResult?.success) {
|
||||
removePollingTask(taskId)
|
||||
failedCount += 1
|
||||
queuePushResult.value = '第 ' + (i + 1) + '/' + toPush.length + ' 条推送失败:' + (pushResult?.error || '未知错误') + ',已自动继续下一条'
|
||||
ElMessage.error(queuePushResult.value)
|
||||
continue
|
||||
}
|
||||
removeMatchedRowsLocally([item])
|
||||
queuePushResult.value = toPush.length > 1
|
||||
? '任务 ' + taskId + ':第 ' + (i + 1) + '/' + toPush.length + ' 条店铺已入队,等待执行完成...'
|
||||
: '任务 ' + taskId + ' 已入队,等待执行完成...'
|
||||
const finalStatus = await waitForTaskTerminal(taskId)
|
||||
if (finalStatus !== 'SUCCESS') {
|
||||
failedCount += 1
|
||||
queuePushResult.value = '任务 ' + taskId + ' 执行失败,已自动继续下一条(' + (i + 1) + '/' + toPush.length + ')'
|
||||
ElMessage.error(queuePushResult.value)
|
||||
continue
|
||||
}
|
||||
successCount += 1
|
||||
queuePushResult.value = i + 1 < toPush.length
|
||||
? '任务 ' + taskId + ' 已完成,继续推送下一条(' + (i + 1) + '/' + toPush.length + ')'
|
||||
: '任务 ' + taskId + ' 已完成'
|
||||
} catch (error) {
|
||||
failedCount += 1
|
||||
queuePushResult.value = '第 ' + (i + 1) + '/' + toPush.length + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误')
|
||||
ElMessage.error(queuePushResult.value)
|
||||
return
|
||||
continue
|
||||
}
|
||||
|
||||
removeMatchedRowsLocally([item])
|
||||
queuePushResult.value = toPush.length > 1
|
||||
? `任务 ${taskId}:第 ${i + 1}/${toPush.length} 条店铺已入队,等待执行完成...`
|
||||
: `任务 ${taskId} 已入队,等待执行完成...`
|
||||
|
||||
const finalStatus = await waitForTaskTerminal(taskId)
|
||||
if (finalStatus !== 'SUCCESS') {
|
||||
queuePushResult.value = `任务 ${taskId} 执行失败,已停止后续店铺推送`
|
||||
ElMessage.error(queuePushResult.value)
|
||||
return
|
||||
}
|
||||
|
||||
queuePushResult.value = i + 1 < toPush.length
|
||||
? `任务 ${taskId} 已完成,继续推送下一条(${i + 1}/${toPush.length})`
|
||||
: `任务 ${taskId} 已完成`
|
||||
}
|
||||
ElMessage.success(`已按顺序完成 ${toPush.length} 条店铺推送`)
|
||||
ElMessage.success('店铺推送已完成:成功 ' + successCount + ' 条,失败 ' + failedCount + ' 条')
|
||||
await loadHistory()
|
||||
ensurePolling(true)
|
||||
} catch (e) {
|
||||
@@ -1611,3 +1623,4 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
||||
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
|
||||
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'
|
||||
|
||||
const COUNTRY_OPTIONS = [{ code: 'DE', label: '德国' }, { code: 'UK', label: '英国' }, { code: 'FR', label: '法国' }, { code: 'IT', label: '意大利' }, { code: 'ES', label: '西班牙' }] as const
|
||||
const shopInput = ref('')
|
||||
@@ -159,7 +160,7 @@ function taskRowKey(item: ShopMatchHistoryItem) { return `${normalizeTaskId(item
|
||||
function countryLabel(code: string) { return COUNTRY_OPTIONS.find((item) => item.code === code)?.label || code }
|
||||
function formatScheduleDateTime(date: Date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0'); return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}` }
|
||||
function formatPickerDateTime(date: Date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${year}-${month}-${day} ${hours}:${minutes}:00` }
|
||||
function getDefaultScheduleValue() { const date = new Date(); date.setSeconds(0, 0); date.setMinutes(date.getMinutes() + 1); return formatPickerDateTime(date) }
|
||||
function getDefaultScheduleValue() { const date = new Date(); date.setSeconds(0, 0); date.setMinutes(date.getMinutes() + 5); return formatPickerDateTime(date) }
|
||||
function parseScheduleDateTime(value?: string | null) {
|
||||
if (!value) return null
|
||||
const normalized = value.trim().replace('T', ' ')
|
||||
@@ -235,15 +236,56 @@ function onCountryToggle(code: string, checked: boolean) { countryPrefUserTouche
|
||||
function onCountryDragStart(index: number) { dragCountryIndex.value = index }
|
||||
function onCountryDragEnd() { dragCountryIndex.value = null }
|
||||
function onCountryDrop(toIndex: number) { const from = dragCountryIndex.value; dragCountryIndex.value = null; if (from == null || from === toIndex) return; countryPrefUserTouched.value = true; const list = [...orderedCountryCodes.value]; const [item] = list.splice(from, 1); list.splice(toIndex, 0, item); orderedCountryCodes.value = list; scheduleSaveCountryPreference() }
|
||||
function onScheduleToggle() { if (scheduleEnabled.value && schedulePickerValues.value.length === 0) schedulePickerValues.value = [getDefaultScheduleValue()]; if (!scheduleEnabled.value) schedulePickerValues.value = [] }
|
||||
function onScheduleToggle() { if (scheduleEnabled.value) schedulePickerValues.value = sanitizeSchedulePickerValues(schedulePickerValues.value, true); if (!scheduleEnabled.value) schedulePickerValues.value = [] }
|
||||
function addScheduleValue() { const last = schedulePickerValues.value[schedulePickerValues.value.length - 1]; const nextValue = last ? nextScheduleValueAfter(last, 60) : getDefaultScheduleValue(); schedulePickerValues.value = [...schedulePickerValues.value, nextValue] }
|
||||
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 = list }
|
||||
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 } }
|
||||
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() { 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 parseScheduleValues() {
|
||||
if (!scheduleEnabled.value) return undefined
|
||||
const now = Date.now()
|
||||
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), picker: formatPickerDateTime(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('执行时间不能重复')
|
||||
const futureOnly = withTime.filter((item) => item.time > now)
|
||||
if (!futureOnly.length) throw new Error('执行时间必须晚于当前时间,请重新选择')
|
||||
if (futureOnly.length !== withTime.length) {
|
||||
schedulePickerValues.value = futureOnly.map((item) => item.picker)
|
||||
ElMessage.warning('已自动移除早于当前时间的执行时间')
|
||||
}
|
||||
return futureOnly.map((item) => item.raw)
|
||||
}
|
||||
function sanitizeSchedulePickerValues(values: string[], ensureOne: boolean) {
|
||||
const now = Date.now()
|
||||
const normalized = values
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((value) => {
|
||||
const date = parseScheduleDateTime(value)
|
||||
return date ? { value: formatPickerDateTime(date), time: date.getTime() } : null
|
||||
})
|
||||
.filter((item): item is { value: string; time: number } => !!item)
|
||||
.filter((item) => item.time > now)
|
||||
.sort((a, b) => a.time - b.time)
|
||||
|
||||
const deduped: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const item of normalized) {
|
||||
if (seen.has(item.value)) continue
|
||||
seen.add(item.value)
|
||||
deduped.push(item.value)
|
||||
}
|
||||
if (!deduped.length && ensureOne) return [getDefaultScheduleValue()]
|
||||
return deduped
|
||||
}
|
||||
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) }
|
||||
@@ -261,11 +303,11 @@ function sleep(ms: number) { return new Promise<void>((resolve) => { window.setT
|
||||
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 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 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]); 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 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 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} 次`}
|
||||
@@ -302,9 +344,8 @@ 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 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) })
|
||||
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 } }
|
||||
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>
|
||||
|
||||
@@ -394,3 +435,4 @@ onUnmounted(() => { stopPolling(); stopScheduleHeartbeat(); window.removeEventLi
|
||||
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
|
||||
@media (max-width: 1100px) { .main-content { flex-direction: column; height: auto; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2a2a2a; } .clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .schedule-row { flex-direction: column; align-items: stretch; } }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ type ActiveNavKey =
|
||||
| 'product-risk'
|
||||
| 'shop-match'
|
||||
| 'pricing'
|
||||
| 'patrol-delete'
|
||||
|
||||
type NavItem = {
|
||||
key: string
|
||||
@@ -93,7 +94,7 @@ const navGroups: ReadonlyArray<NavGroup> = [
|
||||
{ key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' },
|
||||
{ key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' },
|
||||
{ key: 'pricing', label: '跟价', href: '/new_web_source/price-track.html' },
|
||||
{ key: 'patrol-delete', label: '巡店删除' },
|
||||
{ key: 'patrol-delete', label: '巡店删除', href: '/new_web_source/patrol-delete.html' },
|
||||
{ key: 'withdraw', label: '取款' },
|
||||
{ key: 'shop-status', label: '店铺状态查询' },
|
||||
],
|
||||
|
||||
12
frontend-vue/src/patrol-delete-main.ts
Normal file
12
frontend-vue/src/patrol-delete-main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import BrandPatrolDeleteTab from '@/pages/brand/components/BrandPatrolDeleteTab.vue'
|
||||
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
createApp(BrandPatrolDeleteTab).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
@@ -7,8 +7,16 @@
|
||||
type JavaApiResponse,
|
||||
unwrapJavaResponse,
|
||||
} from "@/shared/api/http";
|
||||
import { getTaskProgressCacheTtlMs } from "@/shared/task-progress-config";
|
||||
|
||||
const JAVA_API_PREFIX = "/newApi/api";
|
||||
type TaskProgressCacheEntry = {
|
||||
expiresAt: number;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
const taskProgressResponseCache = new Map<string, TaskProgressCacheEntry>();
|
||||
const taskProgressInflightRequests = new Map<string, Promise<unknown>>();
|
||||
|
||||
function getCurrentUserId() {
|
||||
const raw =
|
||||
@@ -22,6 +30,56 @@ function getCurrentUserId() {
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeTaskIds(taskIds: number[]) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(taskIds || [])
|
||||
.map((taskId) => Number(taskId))
|
||||
.filter((taskId) => Number.isFinite(taskId) && taskId > 0),
|
||||
),
|
||||
).sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function buildTaskProgressRequestKey(path: string, taskIds: number[]) {
|
||||
return `${path}::${taskIds.join(",")}`;
|
||||
}
|
||||
|
||||
async function postTaskProgressBatch<T>(path: string, taskIds: number[]) {
|
||||
const normalizedTaskIds = normalizeTaskIds(taskIds);
|
||||
if (!normalizedTaskIds.length) {
|
||||
return { items: [], missingTaskIds: [] } as T;
|
||||
}
|
||||
|
||||
const cacheKey = buildTaskProgressRequestKey(path, normalizedTaskIds);
|
||||
const now = Date.now();
|
||||
const cached = taskProgressResponseCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.data as T;
|
||||
}
|
||||
|
||||
const inflight = taskProgressInflightRequests.get(cacheKey);
|
||||
if (inflight) {
|
||||
return (await inflight) as T;
|
||||
}
|
||||
|
||||
const requestPromise = unwrapJavaResponse(
|
||||
post<JavaApiResponse<T>, { taskIds: number[] }>(path, { taskIds: normalizedTaskIds }),
|
||||
)
|
||||
.then((data) => {
|
||||
taskProgressResponseCache.set(cacheKey, {
|
||||
data,
|
||||
expiresAt: Date.now() + getTaskProgressCacheTtlMs(),
|
||||
});
|
||||
return data;
|
||||
})
|
||||
.finally(() => {
|
||||
taskProgressInflightRequests.delete(cacheKey);
|
||||
});
|
||||
|
||||
taskProgressInflightRequests.set(cacheKey, requestPromise);
|
||||
return requestPromise;
|
||||
}
|
||||
|
||||
export interface UploadedFileRef {
|
||||
fileKey: string;
|
||||
originalFilename?: string;
|
||||
@@ -916,6 +974,221 @@ export function getShopMatchResultDownloadUrl(resultId: number) {
|
||||
return getJavaDownloadUrl(`/shop-match/results/${resultId}/download`);
|
||||
}
|
||||
|
||||
export type PatrolDeleteCandidateVo = ProductRiskCandidateVo;
|
||||
export type PatrolDeleteDashboardVo = ProductRiskDashboardVo;
|
||||
export type PatrolDeleteShopQueueItem = ProductRiskShopQueueItem;
|
||||
|
||||
export interface PatrolDeleteCountryMetricRow {
|
||||
status: string;
|
||||
quantity: string;
|
||||
deleteQuantity: string;
|
||||
processStatus: string;
|
||||
}
|
||||
|
||||
export interface PatrolDeleteCountrySection {
|
||||
country: string;
|
||||
rows: PatrolDeleteCountryMetricRow[];
|
||||
}
|
||||
|
||||
export interface PatrolDeleteCartRatio {
|
||||
country: string;
|
||||
ratio: string;
|
||||
}
|
||||
|
||||
export interface PatrolDeleteTaskItem {
|
||||
shopName?: string;
|
||||
matched?: boolean;
|
||||
shopId?: string;
|
||||
platform?: string;
|
||||
companyName?: string;
|
||||
matchStatus?: string;
|
||||
matchMessage?: string;
|
||||
countrySections: PatrolDeleteCountrySection[];
|
||||
cartRatios: PatrolDeleteCartRatio[];
|
||||
}
|
||||
|
||||
export interface PatrolDeleteHistoryItem {
|
||||
resultId?: number;
|
||||
taskId?: number;
|
||||
shopName?: string;
|
||||
shopId?: string;
|
||||
platform?: string;
|
||||
companyName?: string;
|
||||
matched?: boolean;
|
||||
matchStatus?: string;
|
||||
matchMessage?: string;
|
||||
taskStatus?: string;
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
createdAt?: string;
|
||||
finishedAt?: string;
|
||||
outputFilename?: string;
|
||||
downloadUrl?: string;
|
||||
countrySections: PatrolDeleteCountrySection[];
|
||||
cartRatios: PatrolDeleteCartRatio[];
|
||||
}
|
||||
|
||||
export interface PatrolDeleteHistoryVo {
|
||||
items: PatrolDeleteHistoryItem[];
|
||||
}
|
||||
|
||||
export interface PatrolDeleteTaskBatchVo {
|
||||
items: PatrolDeleteHistoryItem[];
|
||||
missingTaskIds: number[];
|
||||
}
|
||||
|
||||
export interface PatrolDeleteCreateTaskVo {
|
||||
taskId: number;
|
||||
items: PatrolDeleteHistoryItem[];
|
||||
}
|
||||
|
||||
export function listPatrolDeleteCandidates() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<PatrolDeleteCandidateVo[]>>(
|
||||
`${JAVA_API_PREFIX}/patrol-delete/candidates`,
|
||||
{
|
||||
params: { user_id: getCurrentUserId() },
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function addPatrolDeleteCandidate(shopName: string) {
|
||||
return unwrapJavaResponse(
|
||||
post<
|
||||
JavaApiResponse<PatrolDeleteCandidateVo>,
|
||||
{ user_id: number; shop_name: string }
|
||||
>(`${JAVA_API_PREFIX}/patrol-delete/candidates`, {
|
||||
user_id: getCurrentUserId(),
|
||||
shop_name: shopName,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function deletePatrolDeleteCandidate(id: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(
|
||||
`${JAVA_API_PREFIX}/patrol-delete/candidates/${id}`,
|
||||
{
|
||||
params: { user_id: getCurrentUserId() },
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function matchPatrolDeleteShops(shopNames: string[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<
|
||||
JavaApiResponse<ProductRiskMatchShopsVo>,
|
||||
{ user_id: number; shop_names: string[] }
|
||||
>(`${JAVA_API_PREFIX}/patrol-delete/match-shops`, {
|
||||
user_id: getCurrentUserId(),
|
||||
shop_names: shopNames,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPatrolDeleteDashboard() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<PatrolDeleteDashboardVo>>(
|
||||
`${JAVA_API_PREFIX}/patrol-delete/dashboard`,
|
||||
{
|
||||
params: { user_id: getCurrentUserId() },
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPatrolDeleteHistory() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<PatrolDeleteHistoryVo>>(
|
||||
`${JAVA_API_PREFIX}/patrol-delete/history`,
|
||||
{
|
||||
params: { user_id: getCurrentUserId() },
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPatrolDeleteTaskProgressBatch(taskIds: number[]) {
|
||||
return postTaskProgressBatch<PatrolDeleteTaskBatchVo>(
|
||||
`${JAVA_API_PREFIX}/patrol-delete/tasks/progress/batch`,
|
||||
taskIds,
|
||||
);
|
||||
}
|
||||
|
||||
export function createPatrolDeleteTask(items: PatrolDeleteTaskItem[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<
|
||||
JavaApiResponse<PatrolDeleteCreateTaskVo>,
|
||||
{ user_id: number; items: PatrolDeleteTaskItem[] }
|
||||
>(`${JAVA_API_PREFIX}/patrol-delete/tasks`, {
|
||||
user_id: getCurrentUserId(),
|
||||
items,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function submitPatrolDeleteTaskResult(
|
||||
taskId: number,
|
||||
payload: {
|
||||
shops: Array<{
|
||||
shopName: string;
|
||||
error?: string;
|
||||
countrySections: PatrolDeleteCountrySection[];
|
||||
cartRatios: PatrolDeleteCartRatio[];
|
||||
shopDone?: boolean;
|
||||
submissionId?: string;
|
||||
chunkIndex?: number;
|
||||
chunkTotal?: number;
|
||||
}>;
|
||||
},
|
||||
) {
|
||||
return unwrapJavaResponse(
|
||||
post<
|
||||
JavaApiResponse<null>,
|
||||
{
|
||||
shops: Array<{
|
||||
shopName: string;
|
||||
error?: string;
|
||||
countrySections: PatrolDeleteCountrySection[];
|
||||
cartRatios: PatrolDeleteCartRatio[];
|
||||
shopDone?: boolean;
|
||||
submissionId?: string;
|
||||
chunkIndex?: number;
|
||||
chunkTotal?: number;
|
||||
}>;
|
||||
}
|
||||
>(`${JAVA_API_PREFIX}/patrol-delete/tasks/${taskId}/result`, payload),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPatrolDeleteResultDownloadUrl(resultId: number) {
|
||||
return getJavaDownloadUrl(`/patrol-delete/results/${resultId}/download`);
|
||||
}
|
||||
|
||||
export function deletePatrolDeleteTask(taskId: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(
|
||||
`${JAVA_API_PREFIX}/patrol-delete/tasks/${taskId}`,
|
||||
{
|
||||
params: { user_id: getCurrentUserId() },
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function deletePatrolDeleteHistory(resultId: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(
|
||||
`${JAVA_API_PREFIX}/patrol-delete/history/${resultId}`,
|
||||
{
|
||||
params: { user_id: getCurrentUserId() },
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ========== 跟价 ==========
|
||||
|
||||
export interface PriceTrackCandidateVo {
|
||||
@@ -1095,29 +1368,23 @@ export function matchPriceTrackShops(
|
||||
}
|
||||
|
||||
export function getShopMatchTaskProgressBatch(taskIds: number[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ShopMatchTaskBatchVo>, { taskIds: number[] }>(
|
||||
`${JAVA_API_PREFIX}/shop-match/tasks/progress/batch`,
|
||||
{ taskIds },
|
||||
),
|
||||
return postTaskProgressBatch<ShopMatchTaskBatchVo>(
|
||||
`${JAVA_API_PREFIX}/shop-match/tasks/progress/batch`,
|
||||
taskIds,
|
||||
);
|
||||
}
|
||||
|
||||
export function getProductRiskTaskProgressBatch(taskIds: number[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ProductRiskTaskBatchVo>, { taskIds: number[] }>(
|
||||
`${JAVA_API_PREFIX}/product-risk-resolve/tasks/progress/batch`,
|
||||
{ taskIds },
|
||||
),
|
||||
return postTaskProgressBatch<ProductRiskTaskBatchVo>(
|
||||
`${JAVA_API_PREFIX}/product-risk-resolve/tasks/progress/batch`,
|
||||
taskIds,
|
||||
);
|
||||
}
|
||||
|
||||
export function getDeleteBrandTaskProgress(taskIds: number[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<DeleteBrandTaskBatchVo>, { taskIds: number[] }>(
|
||||
`${JAVA_API_PREFIX}/delete-brand/tasks/progress/batch`,
|
||||
{ taskIds },
|
||||
),
|
||||
return postTaskProgressBatch<DeleteBrandTaskBatchVo>(
|
||||
`${JAVA_API_PREFIX}/delete-brand/tasks/progress/batch`,
|
||||
taskIds,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1268,11 +1535,9 @@ export function stopPriceTrackLoopRun(loopRunId: number) {
|
||||
}
|
||||
|
||||
export function getPriceTrackTaskProgressBatch(taskIds: number[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<PriceTrackTaskBatchVo>, { taskIds: number[] }>(
|
||||
`${JAVA_API_PREFIX}/price-track/tasks/progress/batch`,
|
||||
{ taskIds },
|
||||
),
|
||||
return postTaskProgressBatch<PriceTrackTaskBatchVo>(
|
||||
`${JAVA_API_PREFIX}/price-track/tasks/progress/batch`,
|
||||
taskIds,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
18
frontend-vue/src/shared/task-progress-config.ts
Normal file
18
frontend-vue/src/shared/task-progress-config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const TASK_POLL_VISIBLE_INTERVAL_MS = 6000;
|
||||
export const TASK_POLL_HIDDEN_INTERVAL_MS = 20000;
|
||||
export const TASK_PROGRESS_VISIBLE_CACHE_MILLIS = 2500;
|
||||
export const TASK_PROGRESS_HIDDEN_CACHE_MILLIS = 10000;
|
||||
|
||||
export function getTaskPollIntervalMs() {
|
||||
if (typeof document !== "undefined" && document.visibilityState !== "visible") {
|
||||
return TASK_POLL_HIDDEN_INTERVAL_MS;
|
||||
}
|
||||
return TASK_POLL_VISIBLE_INTERVAL_MS;
|
||||
}
|
||||
|
||||
export function getTaskProgressCacheTtlMs() {
|
||||
if (typeof document !== "undefined" && document.hidden) {
|
||||
return TASK_PROGRESS_HIDDEN_CACHE_MILLIS;
|
||||
}
|
||||
return TASK_PROGRESS_VISIBLE_CACHE_MILLIS;
|
||||
}
|
||||
Reference in New Issue
Block a user