完成匹配、跟价、权限部分
This commit is contained in:
@@ -8,12 +8,12 @@
|
||||
<div class="section-title">跟价模式</div>
|
||||
<div class="mode-zone">
|
||||
<label class="mode-check-row">
|
||||
<input type="checkbox" class="mode-check-input" v-model="statusModeEnabled" />
|
||||
<input type="radio" name="price-track-mode" class="mode-check-input" :checked="statusModeEnabled" @change="selectMode('status')" />
|
||||
<span class="mode-check-text">按商品状态(全量)</span>
|
||||
<span class="mode-hint-inline">— 获取店铺所有在售ASIN跑跟价</span>
|
||||
</label>
|
||||
<label class="mode-check-row">
|
||||
<input type="checkbox" class="mode-check-input" v-model="asinModeEnabled" />
|
||||
<input type="radio" name="price-track-mode" class="mode-check-input" :checked="asinModeEnabled" @change="selectMode('asin')" />
|
||||
<span class="mode-check-text">按指定ASIN文档(自定义)</span>
|
||||
<span class="mode-hint-inline">— 上传ASIN表格,按文档内ASIN跑跟价</span>
|
||||
</label>
|
||||
@@ -26,6 +26,7 @@
|
||||
<div class="hint">上传包含ASIN的表格文件,按文档内的ASIN进行跟价。</div>
|
||||
<div class="btns">
|
||||
<button type="button" class="opt-btn" @click="selectAsinFile">选择ASIN文件</button>
|
||||
<button type="button" class="opt-btn" @click="selectAsinFolder">选择文件夹</button>
|
||||
</div>
|
||||
<div class="selected-files clean-placeholder">
|
||||
<template v-if="asinFiles.length">
|
||||
@@ -225,6 +226,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
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 {
|
||||
addPriceTrackCandidate,
|
||||
@@ -237,11 +239,14 @@ import {
|
||||
getPriceTrackDashboard,
|
||||
getPriceTrackHistory,
|
||||
getPriceTrackResultDownloadUrl,
|
||||
getPriceTrackTaskProgressBatch,
|
||||
getPriceTrackTasksBatch,
|
||||
listPriceTrackCandidates,
|
||||
matchPriceTrackShops,
|
||||
putPriceTrackCountryPreference,
|
||||
type PriceTrackAsinParsedRow,
|
||||
type PriceTrackCandidateVo,
|
||||
type PriceTrackCreateTaskVo,
|
||||
type PriceTrackDashboardVo,
|
||||
type PriceTrackHistoryItem,
|
||||
type PriceTrackShopQueueItem,
|
||||
@@ -267,6 +272,7 @@ const matching = ref(false)
|
||||
const pushing = ref(false)
|
||||
const queuePushResult = ref('')
|
||||
const queuePayloadText = ref('')
|
||||
const matchAsinRowsByCountry = ref<Record<string, PriceTrackAsinParsedRow[]>>({})
|
||||
|
||||
// 跟价模式(可同时启用)
|
||||
const statusModeEnabled = ref(false) // 按商品状态(全量)
|
||||
@@ -404,20 +410,56 @@ function saveMatchedItemsToStorage() {
|
||||
// ========== 文件上传 ==========
|
||||
async function selectAsinFile() {
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.select_files) {
|
||||
ElMessage.error('当前环境不支持选择文件')
|
||||
if (api?.select_files) {
|
||||
const result = await api.select_files({
|
||||
filters: [{ name: 'Excel/CSV', extensions: 'xlsx,xls,csv' }],
|
||||
multiple: true,
|
||||
})
|
||||
if (result?.paths?.length) {
|
||||
asinFiles.value = result.paths
|
||||
ElMessage.success(`已选择 ${result.paths.length} 个ASIN文件`)
|
||||
}
|
||||
return
|
||||
}
|
||||
const result = await api.select_files({ filters: [{ name: 'Excel/CSV', extensions: 'xlsx,xls,csv' }] })
|
||||
if (result?.paths?.length) {
|
||||
asinFiles.value = result.paths
|
||||
ElMessage.success(`已选择 ${result.paths.length} 个ASIN文件`)
|
||||
if (api?.select_brand_xlsx_files) {
|
||||
const paths = await api.select_brand_xlsx_files()
|
||||
if (!paths?.length) return
|
||||
asinFiles.value = paths
|
||||
ElMessage.success(`已选择 ${paths.length} 个ASIN文件`)
|
||||
return
|
||||
}
|
||||
ElMessage.warning('当前环境不支持文件选择,请在本地客户端中打开')
|
||||
}
|
||||
|
||||
async function selectAsinFolder() {
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.select_brand_folder) {
|
||||
ElMessage.warning('当前环境不支持文件夹选择,请在本地客户端中打开')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const folder = await api.select_brand_folder()
|
||||
if (!folder) return
|
||||
const result = await expandBrandFolderRecursive(folder)
|
||||
if (!result.success || !result.items?.length) {
|
||||
ElMessage.warning(result.error || '该文件夹下没有可用的 xlsx 文件')
|
||||
return
|
||||
}
|
||||
asinFiles.value = result.items.map((item) => item.absolutePath)
|
||||
ElMessage.success(`已选择文件夹内 ${result.items.length} 个ASIN文件`)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '选择失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ========== 国家顺序相关 ==========
|
||||
function selectMode(mode: 'status' | 'asin') {
|
||||
statusModeEnabled.value = mode === 'status'
|
||||
asinModeEnabled.value = mode === 'asin'
|
||||
}
|
||||
|
||||
function countryLabel(code: string) {
|
||||
const row = COUNTRY_OPTIONS.find((o) => o.code === code)
|
||||
return row?.label ?? code
|
||||
@@ -595,6 +637,15 @@ function collectShopNamesForMatch(): string[] {
|
||||
for (const r of selectedCandidates.value) {
|
||||
if (r?.shopName?.trim()) set.add(r.shopName.trim())
|
||||
}
|
||||
if (asinModeEnabled.value) {
|
||||
for (const filePath of asinFiles.value) {
|
||||
const normalizedPath = (filePath || '').trim()
|
||||
if (!normalizedPath) continue
|
||||
const filename = normalizedPath.split(/[/\\]/).filter(Boolean).pop() || ''
|
||||
const shopName = filename.replace(/\.[^.]+$/, '').trim()
|
||||
if (shopName) set.add(shopName)
|
||||
}
|
||||
}
|
||||
return [...set]
|
||||
}
|
||||
|
||||
@@ -604,11 +655,25 @@ async function runMatch() {
|
||||
ElMessage.warning('请勾选备选店铺,或在输入框中填写店名')
|
||||
return
|
||||
}
|
||||
if (asinModeEnabled.value && !asinFiles.value.length) {
|
||||
ElMessage.warning('请先选择 ASIN 文件')
|
||||
return
|
||||
}
|
||||
matching.value = true
|
||||
try {
|
||||
const res = await matchPriceTrackShops(names)
|
||||
const res = await matchPriceTrackShops(names, {
|
||||
asinFiles: asinModeEnabled.value ? asinFiles.value : [],
|
||||
countryCodes: [...orderedCountryCodes.value],
|
||||
})
|
||||
const batch = res.items || []
|
||||
matchedItems.value = [...matchedItems.value, ...batch.filter((n) => !matchedItems.value.some((e) => e.shopName === n.shopName))]
|
||||
matchAsinRowsByCountry.value = res.asinRowsByCountry || {}
|
||||
const nextByShop = new Map(
|
||||
matchedItems.value.map((item) => [((item.shopName || '').trim()), item] as const),
|
||||
)
|
||||
for (const item of batch) {
|
||||
nextByShop.set((item.shopName || '').trim(), item)
|
||||
}
|
||||
matchedItems.value = [...nextByShop.values()]
|
||||
saveMatchedItemsToStorage()
|
||||
ElMessage.success(`匹配 ${batch.length} 个店铺`)
|
||||
} catch (e) {
|
||||
@@ -668,11 +733,15 @@ async function removeMatchedRow(row: PriceTrackShopQueueItem) {
|
||||
}
|
||||
}
|
||||
|
||||
async function pushToPythonQueue() {
|
||||
async function pushToPythonQueueLegacy() {
|
||||
if (!hasValidMode.value) {
|
||||
ElMessage.warning('请至少选择一个跟价模式')
|
||||
return
|
||||
}
|
||||
if (asinModeEnabled.value && !asinFiles.value.length) {
|
||||
ElMessage.warning('请先选择 ASIN 文件')
|
||||
return
|
||||
}
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.enqueue_json) {
|
||||
ElMessage.error('当前环境未启用 pywebview enqueue_json')
|
||||
@@ -742,15 +811,146 @@ async function pushToPythonQueue() {
|
||||
}
|
||||
|
||||
// ========== 任务状态轮询 ==========
|
||||
function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQueueItem) {
|
||||
const shopName = (row.shopName || '').trim()
|
||||
const skipAsinsByCountry = row.skipAsins || taskVo.skipAsinsByCountry || {}
|
||||
return {
|
||||
type: 'price-track-run',
|
||||
ts: Date.now(),
|
||||
data: {
|
||||
task_id: taskVo.taskId,
|
||||
shop_name: shopName,
|
||||
shop_id: row.shopId ?? null,
|
||||
country_codes: [...orderedCountryCodes.value],
|
||||
mode: statusModeEnabled.value ? 'status' : 'asin',
|
||||
skip_asins: skipAsinsByCountry,
|
||||
skip_asins_by_country: skipAsinsByCountry,
|
||||
asin_rows_by_country: Object.keys(matchAsinRowsByCountry.value).length
|
||||
? matchAsinRowsByCountry.value
|
||||
: (taskVo.asinRowsByCountry || {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForTaskTerminal(taskId: number) {
|
||||
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
|
||||
}
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
}
|
||||
}
|
||||
|
||||
async function pushToPythonQueue() {
|
||||
if (!hasValidMode.value) {
|
||||
ElMessage.warning('请至少选择一个跟价模式')
|
||||
return
|
||||
}
|
||||
if (asinModeEnabled.value && !asinFiles.value.length) {
|
||||
ElMessage.warning('请先选择 ASIN 文件')
|
||||
return
|
||||
}
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.enqueue_json) {
|
||||
ElMessage.error('当前环境未启用 pywebview enqueue_json')
|
||||
return
|
||||
}
|
||||
const matchedRows = matchedItems.value.filter((i) => i.matched)
|
||||
if (!matchedRows.length) {
|
||||
ElMessage.warning('无可用匹配店铺')
|
||||
return
|
||||
}
|
||||
pushing.value = true
|
||||
queuePayloadText.value = ''
|
||||
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],
|
||||
})
|
||||
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) {
|
||||
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} 条店铺任务推送`)
|
||||
} catch (e) {
|
||||
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
|
||||
ElMessage.error(queuePushResult.value)
|
||||
} finally {
|
||||
pushing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getPollIntervalMs() {
|
||||
return document.visibilityState === 'visible' ? 4500 : 10000
|
||||
return document.visibilityState === 'visible' ? 6000 : 20000
|
||||
}
|
||||
|
||||
async function refreshTaskBatch() {
|
||||
const ids = pollingTaskIds.value.filter((id) => id > 0)
|
||||
if (!ids.length) return
|
||||
try {
|
||||
const batch = await getPriceTrackTasksBatch(ids)
|
||||
const batch = await getPriceTrackTaskProgressBatch(ids)
|
||||
let changed = false
|
||||
const nextSnapshots = { ...taskSnapshots.value }
|
||||
for (const missingId of batch.missingTaskIds || []) {
|
||||
@@ -761,7 +961,8 @@ async function refreshTaskBatch() {
|
||||
const taskId = detail.task?.id
|
||||
const status = detail.task?.status
|
||||
if (typeof taskId !== 'number' || taskId <= 0) continue
|
||||
nextSnapshots[taskId] = detail
|
||||
const prev = nextSnapshots[taskId]
|
||||
nextSnapshots[taskId] = prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail
|
||||
if (status) {
|
||||
taskDetails.value[taskId] = status
|
||||
if (status === 'SUCCESS' || status === 'FAILED') {
|
||||
|
||||
Reference in New Issue
Block a user