改造后台权限和APP权限
This commit is contained in:
@@ -20,6 +20,24 @@
|
||||
</div>
|
||||
|
||||
<!-- 按指定ASIN文档:上传 -->
|
||||
<div class="section-title">执行配置</div>
|
||||
<div class="mode-zone">
|
||||
<label class="mode-check-row">
|
||||
<input v-model="executionMode" type="radio" class="mode-check-input" value="FINITE" />
|
||||
<span class="mode-check-text">执行固定轮次</span>
|
||||
<span class="mode-hint-inline">按整批店铺重复执行指定次数</span>
|
||||
</label>
|
||||
<label class="mode-check-row">
|
||||
<input v-model="executionMode" type="radio" class="mode-check-input" value="INFINITE" />
|
||||
<span class="mode-check-text">无限执行</span>
|
||||
<span class="mode-hint-inline">上一轮全部成功后立即开始下一轮</span>
|
||||
</label>
|
||||
<div v-if="executionMode === 'FINITE'" class="loop-round-input">
|
||||
<span class="hint">轮次</span>
|
||||
<el-input-number v-model="roundCount" :min="1" :max="9999" :disabled="hasRunningLoop || pushing" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="asinModeEnabled">
|
||||
<div class="section-title">ASIN文档 <span class="tag-badge">自定义模式</span></div>
|
||||
<div class="upload-zone">
|
||||
@@ -101,12 +119,23 @@
|
||||
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">
|
||||
{{ matching ? '匹配中…' : '匹配店铺' }}
|
||||
</button>
|
||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !hasValidMode" @click="pushToPythonQueue">
|
||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !hasValidMode" @click="pushToPythonLoopQueue">
|
||||
{{ pushing ? '推送中…' : '推送到 Python 队列' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="loading-msg">上传ASIN文档和多选店铺<strong>可同时生效</strong>,跟价结果合并输出。全量和自定义的区别仅在于ASIN来源不同。</p>
|
||||
|
||||
<div v-if="activeLoopRun" class="loop-status-card">
|
||||
<div class="loop-status-line">循环任务 ID:{{ activeLoopRun.id }}</div>
|
||||
<div class="loop-status-line">状态:{{ activeLoopRun.status || '-' }}</div>
|
||||
<div class="loop-status-line">进度:{{ loopProgressText }}</div>
|
||||
<div class="loop-status-line">当前店铺:{{ ((activeLoopRun.currentShopIndex || 0) + 1) }}/{{ activeLoopRun.totalShopCount || 0 }}</div>
|
||||
<div v-if="activeLoopRun.errorMessage" class="loop-status-line loop-error">{{ activeLoopRun.errorMessage }}</div>
|
||||
<button type="button" class="opt-btn loop-stop-btn" :disabled="!hasRunningLoop || stopLoopPending" @click="stopLoopExecution">
|
||||
{{ stopLoopPending ? '停止中...' : '停止循环' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="queuePushResult" class="queue-debug-card">
|
||||
<div class="section-title queue-debug-title">推送结果</div>
|
||||
<div class="queue-debug-line">{{ queuePushResult }}</div>
|
||||
@@ -176,6 +205,7 @@
|
||||
<li v-for="item in currentSectionItems" :key="`cur-${item.resultId}-${item.taskId}`"
|
||||
class="task-item split-result-item">
|
||||
<div class="left split-result-main">
|
||||
<div v-if="item.roundIndex" class="files">轮次:第 {{ item.roundIndex }} 轮</div>
|
||||
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
|
||||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
||||
<div v-if="item.platform" class="files">平台:{{ item.platform }}</div>
|
||||
@@ -198,6 +228,7 @@
|
||||
<li v-for="item in historySectionItems" :key="`his-${item.resultId}-${item.taskId}`"
|
||||
class="task-item split-result-item">
|
||||
<div class="left split-result-main">
|
||||
<div v-if="item.roundIndex" class="files">轮次:第 {{ item.roundIndex }} 轮</div>
|
||||
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
|
||||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
||||
<div v-if="item.outputFilename" class="files">文件:{{ item.outputFilename }}</div>
|
||||
@@ -230,7 +261,10 @@ import { expandBrandFolderRecursive } from '@/shared/api/brand'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import {
|
||||
addPriceTrackCandidate,
|
||||
completePriceTrackLoopChild,
|
||||
createPriceTrackTask,
|
||||
createPriceTrackLoopRun,
|
||||
dispatchNextPriceTrackLoopRun,
|
||||
deletePendingPriceTrackShopResult,
|
||||
deletePriceTrackCandidate,
|
||||
deletePriceTrackHistory,
|
||||
@@ -238,17 +272,21 @@ import {
|
||||
getPriceTrackCountryPreference,
|
||||
getPriceTrackDashboard,
|
||||
getPriceTrackHistory,
|
||||
getPriceTrackLoopRun,
|
||||
getPriceTrackResultDownloadUrl,
|
||||
getPriceTrackTaskProgressBatch,
|
||||
getPriceTrackTasksBatch,
|
||||
listPriceTrackCandidates,
|
||||
matchPriceTrackShops,
|
||||
putPriceTrackCountryPreference,
|
||||
stopPriceTrackLoopRun,
|
||||
type PriceTrackAsinParsedRow,
|
||||
type PriceTrackCandidateVo,
|
||||
type PriceTrackCreateTaskVo,
|
||||
type PriceTrackDashboardVo,
|
||||
type PriceTrackExecutionMode,
|
||||
type PriceTrackHistoryItem,
|
||||
type PriceTrackLoopRunVo,
|
||||
type PriceTrackShopQueueItem,
|
||||
type PriceTrackTaskDetailVo,
|
||||
} from '@/shared/api/java-modules'
|
||||
@@ -273,9 +311,15 @@ const pushing = ref(false)
|
||||
const queuePushResult = ref('')
|
||||
const queuePayloadText = ref('')
|
||||
const matchAsinRowsByCountry = ref<Record<string, PriceTrackAsinParsedRow[]>>({})
|
||||
const executionMode = ref<PriceTrackExecutionMode>('FINITE')
|
||||
const roundCount = ref(1)
|
||||
const activeLoopRunId = ref<number | null>(null)
|
||||
const activeLoopRun = ref<PriceTrackLoopRunVo | null>(null)
|
||||
const loopDispatching = ref(false)
|
||||
const stopLoopPending = ref(false)
|
||||
|
||||
// 跟价模式(可同时启用)
|
||||
const statusModeEnabled = ref(false) // 按商品状态(全量)
|
||||
const statusModeEnabled = ref(true) // 按商品状态(全量)
|
||||
const asinModeEnabled = ref(false) // 按指定ASIN文档(自定义)
|
||||
|
||||
// 上传文件
|
||||
@@ -283,6 +327,19 @@ const asinFiles = ref<string[]>([]) // ASIN文档
|
||||
|
||||
// 是否有有效模式
|
||||
const hasValidMode = computed(() => statusModeEnabled.value || asinModeEnabled.value)
|
||||
const loopIsTerminal = computed(() => {
|
||||
const status = activeLoopRun.value?.status || ''
|
||||
return status === 'SUCCESS' || status === 'FAILED' || status === 'STOPPED'
|
||||
})
|
||||
const hasRunningLoop = computed(() => !!activeLoopRunId.value && !loopIsTerminal.value)
|
||||
const loopProgressText = computed(() => {
|
||||
const loop = activeLoopRun.value
|
||||
if (!loop) return ''
|
||||
const round = loop.currentRound || 1
|
||||
if (loop.executionMode === 'INFINITE') return `第 ${round} 轮(无限)`
|
||||
const total = Math.max(1, loop.targetRounds || roundCount.value || 1)
|
||||
return `第 ${round}/${total} 轮`
|
||||
})
|
||||
|
||||
const orderedCountryCodes = ref<string[]>(['DE', 'UK', 'FR', 'IT', 'ES'])
|
||||
const dragCountryIndex = ref<number | null>(null)
|
||||
@@ -324,6 +381,10 @@ function matchedItemsStorageKey() {
|
||||
return `price-track:matched-items:${uidForStorage()}`
|
||||
}
|
||||
|
||||
function activeLoopStorageKey() {
|
||||
return `price-track:active-loop:${uidForStorage()}`
|
||||
}
|
||||
|
||||
function setStorageJson(key: string, value: unknown, shouldRemove: boolean) {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
@@ -407,6 +468,24 @@ function saveMatchedItemsToStorage() {
|
||||
setStorageJson(matchedItemsStorageKey(), matchedItems.value, matchedItems.value.length === 0)
|
||||
}
|
||||
|
||||
function loadActiveLoopRunIdFromStorage() {
|
||||
try {
|
||||
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(activeLoopStorageKey()) : null
|
||||
if (!raw) {
|
||||
activeLoopRunId.value = null
|
||||
return
|
||||
}
|
||||
const parsed = Number(JSON.parse(raw))
|
||||
activeLoopRunId.value = Number.isFinite(parsed) && parsed > 0 ? parsed : null
|
||||
} catch {
|
||||
activeLoopRunId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function saveActiveLoopRunId() {
|
||||
setStorageJson(activeLoopStorageKey(), activeLoopRunId.value, !activeLoopRunId.value)
|
||||
}
|
||||
|
||||
// ========== 文件上传 ==========
|
||||
async function selectAsinFile() {
|
||||
const api = getPywebviewApi()
|
||||
@@ -704,15 +783,18 @@ function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | nul
|
||||
function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: number | string | null }>) {
|
||||
if (!rows.length) return
|
||||
const keys = new Set(rows.map((row) => rowKeyForMatch(row)))
|
||||
matchedItems.value = matchedItems.value.filter((row) => !keys.has(rowKeyForMatch(row)))
|
||||
const names = new Set(rows.map((row) => (row.shopName || '').trim()).filter((name) => !!name))
|
||||
matchedItems.value = matchedItems.value.filter((row) => {
|
||||
const key = rowKeyForMatch(row)
|
||||
const name = (row.shopName || '').trim()
|
||||
return !keys.has(key) && (!name || !names.has(name))
|
||||
})
|
||||
saveMatchedItemsToStorage()
|
||||
}
|
||||
|
||||
async function removeMatchedRow(row: PriceTrackShopQueueItem) {
|
||||
const key = rowKeyForMatch(row)
|
||||
const backup = [...matchedItems.value]
|
||||
matchedItems.value = matchedItems.value.filter((r) => rowKeyForMatch(r) !== key)
|
||||
saveMatchedItemsToStorage()
|
||||
removeMatchedRowsLocally([row])
|
||||
const name = (row.shopName || '').trim()
|
||||
if (!name) {
|
||||
ElMessage.success('已从匹配结果中移除')
|
||||
@@ -942,6 +1024,185 @@ async function pushToPythonQueue() {
|
||||
}
|
||||
}
|
||||
|
||||
function setLoopRun(next: PriceTrackLoopRunVo | null) {
|
||||
activeLoopRun.value = next
|
||||
activeLoopRunId.value = next?.id ?? null
|
||||
saveActiveLoopRunId()
|
||||
}
|
||||
|
||||
function clearLoopRunIfTerminal() {
|
||||
if (!activeLoopRun.value) return
|
||||
const status = activeLoopRun.value.status || ''
|
||||
if (status === 'SUCCESS' || status === 'FAILED' || status === 'STOPPED') {
|
||||
activeLoopRun.value = null
|
||||
activeLoopRunId.value = null
|
||||
saveActiveLoopRunId()
|
||||
}
|
||||
}
|
||||
|
||||
async function syncActiveLoopRun() {
|
||||
if (!activeLoopRunId.value) return null
|
||||
try {
|
||||
const loop = await getPriceTrackLoopRun(activeLoopRunId.value)
|
||||
setLoopRun(loop)
|
||||
clearLoopRunIfTerminal()
|
||||
return activeLoopRun.value
|
||||
} catch {
|
||||
activeLoopRun.value = null
|
||||
activeLoopRunId.value = null
|
||||
saveActiveLoopRunId()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function recordCreatedTask(taskVo: PriceTrackCreateTaskVo) {
|
||||
taskSnapshots.value = {
|
||||
...taskSnapshots.value,
|
||||
[taskVo.taskId]: {
|
||||
task: { id: taskVo.taskId, status: 'RUNNING' },
|
||||
items: taskVo.items,
|
||||
},
|
||||
}
|
||||
taskDetails.value = {
|
||||
...taskDetails.value,
|
||||
[taskVo.taskId]: 'RUNNING',
|
||||
}
|
||||
saveTaskSnapshotsToStorage()
|
||||
saveTaskDetailsToStorage()
|
||||
}
|
||||
|
||||
async function stopLoopExecution() {
|
||||
if (!activeLoopRunId.value || stopLoopPending.value) return
|
||||
stopLoopPending.value = true
|
||||
try {
|
||||
const loop = await stopPriceTrackLoopRun(activeLoopRunId.value)
|
||||
setLoopRun(loop)
|
||||
clearLoopRunIfTerminal()
|
||||
ElMessage.success(loop.status === 'STOPPED' ? '循环已停止' : '已请求停止,当前子任务完成后将收尾')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '停止循环失败')
|
||||
} finally {
|
||||
stopLoopPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runLoopExecution(loopId?: number) {
|
||||
if (loopDispatching.value) return
|
||||
if (loopId && activeLoopRunId.value !== loopId) {
|
||||
activeLoopRunId.value = loopId
|
||||
saveActiveLoopRunId()
|
||||
}
|
||||
if (!activeLoopRunId.value) return
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.enqueue_json) {
|
||||
throw new Error('当前环境未启用 pywebview enqueue_json')
|
||||
}
|
||||
loopDispatching.value = true
|
||||
try {
|
||||
while (activeLoopRunId.value) {
|
||||
const loop = await syncActiveLoopRun()
|
||||
if (!loop || loop.status === 'SUCCESS' || loop.status === 'FAILED' || loop.status === 'STOPPED') break
|
||||
|
||||
if (loop.activeTaskId) {
|
||||
addPollingTask(loop.activeTaskId)
|
||||
scheduleNextPoll(true)
|
||||
const finalStatus = await waitForTaskTerminal(loop.activeTaskId)
|
||||
const updatedLoop = await completePriceTrackLoopChild(loop.id, loop.activeTaskId)
|
||||
setLoopRun(updatedLoop)
|
||||
await loadHistory()
|
||||
await loadDashboard()
|
||||
syncPollingIdsWithHistory()
|
||||
clearLoopRunIfTerminal()
|
||||
if (finalStatus !== 'SUCCESS') {
|
||||
throw new Error(updatedLoop.errorMessage || `任务 ${loop.activeTaskId} 执行失败`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const dispatch = await dispatchNextPriceTrackLoopRun(loop.id)
|
||||
setLoopRun(dispatch.loopRun || loop)
|
||||
if (!dispatch.childTaskRequest) {
|
||||
clearLoopRunIfTerminal()
|
||||
break
|
||||
}
|
||||
const row = dispatch.childTaskRequest.items?.[0]
|
||||
if (!row) throw new Error('循环任务缺少待执行店铺')
|
||||
const taskVo = await createPriceTrackTask(dispatch.childTaskRequest)
|
||||
recordCreatedTask(taskVo)
|
||||
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 = `循环任务 ${loop.id} 第 ${dispatch.childTaskRequest.roundIndex || 1} 轮已推送:${row.shopName || taskVo.taskId}`
|
||||
}
|
||||
} finally {
|
||||
loopDispatching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function pushToPythonLoopQueue() {
|
||||
if (hasRunningLoop.value) {
|
||||
try {
|
||||
await runLoopExecution(activeLoopRunId.value || undefined)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && /子任务不存在|循环任务不存在|404/.test(error.message)) {
|
||||
activeLoopRun.value = null
|
||||
activeLoopRunId.value = null
|
||||
saveActiveLoopRunId()
|
||||
}
|
||||
const message = error instanceof Error ? error.message : '循环执行失败'
|
||||
queuePushResult.value = message
|
||||
ElMessage.error(message)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!hasValidMode.value) {
|
||||
ElMessage.warning('请至少选择一个跟价模式')
|
||||
return
|
||||
}
|
||||
if (asinModeEnabled.value && !asinFiles.value.length) {
|
||||
ElMessage.warning('请先选择 ASIN 文件')
|
||||
return
|
||||
}
|
||||
const matchedRows = matchedItems.value.filter((item) => item.matched)
|
||||
if (!matchedRows.length) {
|
||||
ElMessage.warning('请先匹配可用店铺')
|
||||
return
|
||||
}
|
||||
pushing.value = true
|
||||
queuePayloadText.value = ''
|
||||
try {
|
||||
const loop = await createPriceTrackLoopRun({
|
||||
userId: 0,
|
||||
statusMode: statusModeEnabled.value,
|
||||
asinMode: asinModeEnabled.value,
|
||||
items: matchedRows,
|
||||
asinFiles: asinFiles.value,
|
||||
countryCodes: [...orderedCountryCodes.value],
|
||||
executionMode: executionMode.value,
|
||||
targetRounds: executionMode.value === 'FINITE' ? Math.max(1, Number(roundCount.value) || 1) : undefined,
|
||||
})
|
||||
setLoopRun(loop)
|
||||
queuePushResult.value = `循环任务 ${loop.id} 已创建`
|
||||
await runLoopExecution(loop.id)
|
||||
clearLoopRunIfTerminal()
|
||||
if (!activeLoopRunId.value) {
|
||||
ElMessage.success('跟价循环已启动')
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '启动循环失败'
|
||||
queuePushResult.value = message
|
||||
ElMessage.error(message)
|
||||
} finally {
|
||||
pushing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getPollIntervalMs() {
|
||||
return document.visibilityState === 'visible' ? 6000 : 20000
|
||||
}
|
||||
@@ -1103,6 +1364,9 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
|
||||
} else if (item.taskId != null) {
|
||||
await deletePriceTrackTask(item.taskId)
|
||||
}
|
||||
if (activeLoopRun.value && ((item.loopRunId != null && item.loopRunId === activeLoopRun.value.id) || (item.taskId != null && item.taskId === activeLoopRun.value.activeTaskId))) {
|
||||
await syncActiveLoopRun()
|
||||
}
|
||||
if (item.taskId != null) removePollingTask(item.taskId)
|
||||
removeMatchedRowsLocally([item])
|
||||
await loadHistory()
|
||||
@@ -1116,15 +1380,33 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
|
||||
|
||||
// ========== 生命周期 ==========
|
||||
onMounted(() => {
|
||||
if (!statusModeEnabled.value && !asinModeEnabled.value) {
|
||||
selectMode('status')
|
||||
}
|
||||
loadPollingIdsFromStorage()
|
||||
loadTaskDetailsFromStorage()
|
||||
loadTaskSnapshotsFromStorage()
|
||||
loadMatchedItemsFromStorage()
|
||||
loadActiveLoopRunIdFromStorage()
|
||||
loadCandidates().catch(() => undefined)
|
||||
loadCountryPreference().catch(() => undefined)
|
||||
loadHistory().catch(() => undefined)
|
||||
loadDashboard().catch(() => undefined)
|
||||
if (pollingTaskIds.value.length) scheduleNextPoll(true)
|
||||
if (activeLoopRunId.value) {
|
||||
void syncActiveLoopRun().then((loop) => {
|
||||
if (loop?.activeTaskId) {
|
||||
addPollingTask(loop.activeTaskId)
|
||||
scheduleNextPoll(true)
|
||||
}
|
||||
if (loop && loop.status !== 'SUCCESS' && loop.status !== 'FAILED' && loop.status !== 'STOPPED') {
|
||||
void runLoopExecution(loop.id)
|
||||
}
|
||||
}).catch(() => {
|
||||
activeLoopRunId.value = null
|
||||
saveActiveLoopRunId()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -1258,6 +1540,22 @@ onUnmounted(() => {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.loop-round-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.loop-round-input :deep(.el-input-number) {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.loop-round-input :deep(.el-input__wrapper) {
|
||||
background-color: #2a2a2a;
|
||||
box-shadow: 0 0 0 1px #3a3a3a inset;
|
||||
}
|
||||
|
||||
.tag-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
@@ -1533,6 +1831,28 @@ onUnmounted(() => {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.loop-status-card {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2f3d2f;
|
||||
background: rgba(39, 174, 96, 0.08);
|
||||
}
|
||||
|
||||
.loop-status-line {
|
||||
font-size: 12px;
|
||||
color: #d6e6d8;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.loop-error {
|
||||
color: #ff8a80;
|
||||
}
|
||||
|
||||
.loop-stop-btn {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.loading-msg code {
|
||||
font-size: 11px;
|
||||
color: #8fd3ff;
|
||||
|
||||
Reference in New Issue
Block a user