完成匹配、跟价、权限部分

This commit is contained in:
super
2026-04-17 12:52:16 +08:00
parent ecc8b6ce6c
commit d5764f3b50
63 changed files with 8858 additions and 1473 deletions

View File

@@ -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') {