2345 lines
72 KiB
Vue
2345 lines
72 KiB
Vue
<template>
|
||
<div class="page-shell module-page">
|
||
<BrandTopBar active="pricing" />
|
||
|
||
<div class="main-content">
|
||
<aside class="left-panel">
|
||
<!-- 运行模式 -->
|
||
<div class="section-title">跟价模式</div>
|
||
<div class="mode-zone">
|
||
<label class="mode-check-row">
|
||
<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="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>
|
||
</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">
|
||
<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">
|
||
<span v-for="(f, i) in asinFiles" :key="i">{{ f }}</span>
|
||
</template>
|
||
<span v-else class="text-muted">暂未选择</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-if="statusModeEnabled">
|
||
|
||
<!-- 店铺输入 -->
|
||
<div class="section-title">店铺名称</div>
|
||
<div class="input-zone">
|
||
<div class="hint">输入店铺名后点「确认」加入下方备选区。可同时配合上方模式使用。</div>
|
||
<div class="input-row">
|
||
<el-input v-model="shopInput" clearable placeholder="请输入店铺名称" @keyup.enter="confirmAdd" />
|
||
<button type="button" class="opt-btn" :disabled="adding" @click="confirmAdd">
|
||
{{ adding ? '保存中…' : '确认' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 备选区域 -->
|
||
<div class="section-title">备选店铺</div>
|
||
<div v-if="!candidates.length" class="empty-candidates">暂无备选店铺,请先输入并确认</div>
|
||
<div v-else class="candidate-table-scroll">
|
||
<el-table :data="candidates" row-key="id" height="160" class="candidate-table"
|
||
@selection-change="onSelectionChange">
|
||
<el-table-column type="selection" width="42" />
|
||
<el-table-column prop="shopName" label="店铺名" min-width="120" show-overflow-tooltip />
|
||
<el-table-column label="操作" width="72" align="center">
|
||
<template #default="{ row }">
|
||
<button type="button" class="link-danger" @click="removeCandidate(row)">删除</button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 国家顺序 -->
|
||
<div class="section-title">处理国家与顺序</div>
|
||
<p class="hint country-pref-hint">
|
||
勾选需要处理的站点;拖拽调整顺序。设置会按当前登录用户保存。
|
||
</p>
|
||
<div class="country-pref-checks">
|
||
<label v-for="row in countryCheckboxRows" :key="row.code" class="country-check-row">
|
||
<input type="checkbox" class="country-check-input" :checked="isCountrySelected(row.code)"
|
||
:disabled="isCountrySelectionLocked(row.code)"
|
||
@change="onCountryNativeChange(row.code, $event)" />
|
||
<span class="country-check-text">{{ row.label }}({{ row.code }})</span>
|
||
</label>
|
||
</div>
|
||
<div v-if="orderedCountryCodes.length" class="country-order-panel">
|
||
<div class="country-order-caption">已选顺序(拖拽 ⋮⋮ 调整)</div>
|
||
<div class="country-order-list">
|
||
<div v-for="(code, idx) in orderedCountryCodes" :key="code" class="country-drag-row"
|
||
:class="{ dragging: dragCountryIndex === idx }" draggable="true" @dragstart="onCountryDragStart(idx)"
|
||
@dragend="onCountryDragEnd" @dragover.prevent @drop.prevent="onCountryDrop(idx)">
|
||
<span class="drag-handle" title="拖动排序">⋮⋮</span>
|
||
<span class="country-drag-label">{{ countryLabel(code) }}({{ code }})</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="countryPrefSaving" class="country-pref-status">保存中…</div>
|
||
|
||
<!-- 状态指示 -->
|
||
<div v-if="statusModeEnabled" class="mode-status-bar">
|
||
<span class="mode-status-dot active"></span>
|
||
<span class="mode-status-text">商品状态模式:将在售(Active)ASIN纳入跟价,跳过ASIN由后台配置的店铺维度数据提供</span>
|
||
</div>
|
||
|
||
<div class="run-row">
|
||
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">
|
||
{{ matching ? '匹配中…' : '匹配店铺' }}
|
||
</button>
|
||
<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>
|
||
<pre v-if="queuePayloadText" class="queue-debug-payload">{{ queuePayloadText }}</pre>
|
||
</div>
|
||
</aside>
|
||
|
||
<section class="right-panel">
|
||
<div class="panel-header">匹配与任务</div>
|
||
<div class="task-list-wrap">
|
||
<div class="clean-result-summary pr-summary">
|
||
<div class="summary-card">
|
||
<span class="summary-label">备选店铺数</span>
|
||
<strong>{{ dashboard.candidateCount }}</strong>
|
||
</div>
|
||
<div class="summary-card">
|
||
<span class="summary-label">已结束任务</span>
|
||
<strong>{{ dashboard.processedTaskCount }}</strong>
|
||
</div>
|
||
<div class="summary-card">
|
||
<span class="summary-label">成功任务</span>
|
||
<strong>{{ dashboard.successTaskCount }}</strong>
|
||
</div>
|
||
<div class="summary-card">
|
||
<span class="summary-label">失败任务</span>
|
||
<strong>{{ dashboard.failedTaskCount }}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="subsection-title">匹配结果</div>
|
||
<div v-if="!matchedItems.length" class="empty-tasks narrow">完成「匹配店铺」后,在此展示紫鸟索引匹配结果,供核对后再推送队列。</div>
|
||
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false"
|
||
class="result-table match-table">
|
||
<el-table-column prop="shopName" label="店铺名" min-width="100" />
|
||
<el-table-column label="匹配" width="72" align="center">
|
||
<template #default="{ row }">
|
||
<span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="shopId" label="店铺ID" min-width="120" show-overflow-tooltip />
|
||
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
|
||
<el-table-column prop="companyName" label="公司" min-width="100" show-overflow-tooltip />
|
||
<el-table-column label="状态" width="100" show-overflow-tooltip>
|
||
<template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="说明" min-width="140" show-overflow-tooltip>
|
||
<template #default="{ row }">{{ formatMatchRemark(row) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="72" align="center">
|
||
<template #default="{ row }">
|
||
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<div class="result-list-wrap">
|
||
<div class="result-list-header">
|
||
<span>处理记录</span>
|
||
</div>
|
||
|
||
<div v-if="!hasVisibleList" class="empty-tasks">暂无处理记录。推送队列并等待 Python 回传后,将在此显示下载链接。</div>
|
||
|
||
<template v-else>
|
||
<div v-if="currentSectionItems.length" class="result-subsection">
|
||
<div class="result-subsection-title">当前任务</div>
|
||
<ul class="task-list clean-result-list">
|
||
<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>
|
||
<div v-if="item.error" class="files">错误:{{ item.error }}</div>
|
||
</div>
|
||
<div class="task-right split-result-actions">
|
||
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
|
||
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">
|
||
下载
|
||
</button>
|
||
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div v-if="historySectionItems.length" class="result-subsection">
|
||
<div class="result-subsection-title">历史记录</div>
|
||
<ul class="task-list clean-result-list">
|
||
<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>
|
||
<div v-if="item.error" class="files">错误:{{ item.error }}</div>
|
||
</div>
|
||
<div class="task-right split-result-actions">
|
||
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
|
||
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">
|
||
下载
|
||
</button>
|
||
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
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, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||
import {
|
||
addPriceTrackCandidate,
|
||
completePriceTrackLoopChild,
|
||
createPriceTrackTask,
|
||
createPriceTrackLoopRun,
|
||
dispatchNextPriceTrackLoopRun,
|
||
deletePendingPriceTrackShopResult,
|
||
deletePriceTrackCandidate,
|
||
deletePriceTrackHistory,
|
||
deletePriceTrackTask,
|
||
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'
|
||
|
||
// ========== 类型定义 ==========
|
||
const COUNTRY_OPTIONS = [
|
||
{ code: 'DE', label: '德国' },
|
||
{ code: 'UK', label: '英国' },
|
||
{ code: 'FR', label: '法国' },
|
||
{ code: 'IT', label: '意大利' },
|
||
{ code: 'ES', label: '西班牙' },
|
||
] as const
|
||
|
||
// ========== 状态 ==========
|
||
const shopInput = ref('')
|
||
const candidates = ref<PriceTrackCandidateVo[]>([])
|
||
const selectedCandidates = ref<PriceTrackCandidateVo[]>([])
|
||
const matchedItems = ref<PriceTrackShopQueueItem[]>([])
|
||
const adding = ref(false)
|
||
const matching = ref(false)
|
||
const pushing = ref(false)
|
||
const queueWorkerRunning = ref(false)
|
||
const autoQueueEnabled = 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(true) // 按商品状态(全量)
|
||
const asinModeEnabled = ref(false) // 按指定ASIN文档(自定义)
|
||
|
||
// 上传文件
|
||
const asinFiles = ref<string[]>([]) // ASIN文档
|
||
const asinUploadedFiles = ref<UploadedJavaFile[]>([])
|
||
|
||
// 是否有有效模式
|
||
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)
|
||
const countryPrefSaving = ref(false)
|
||
const countryPrefUserTouched = ref(false)
|
||
let countryPrefSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
const dashboard = ref<PriceTrackDashboardVo>({
|
||
candidateCount: 0,
|
||
processedTaskCount: 0,
|
||
successTaskCount: 0,
|
||
failedTaskCount: 0,
|
||
})
|
||
|
||
const historyItems = ref<PriceTrackHistoryItem[]>([])
|
||
const pollingTaskIds = ref<number[]>([])
|
||
const taskDetails = ref<Record<number, string>>({})
|
||
const taskSnapshots = ref<Record<number, PriceTrackTaskDetailVo>>({})
|
||
const pollTimer = ref<number | null>(null)
|
||
const pollingInFlight = ref(false)
|
||
|
||
function uidForStorage() {
|
||
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
|
||
}
|
||
|
||
function sessionKey() {
|
||
return `price-track:tasks:${uidForStorage()}`
|
||
}
|
||
|
||
function taskStatusStorageKey() {
|
||
return `price-track:task-status:${uidForStorage()}`
|
||
}
|
||
|
||
function taskSnapshotsStorageKey() {
|
||
return `price-track:tasks-batch-snapshot:${uidForStorage()}`
|
||
}
|
||
|
||
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 {
|
||
if (shouldRemove) window.localStorage.removeItem(key)
|
||
else window.localStorage.setItem(key, JSON.stringify(value))
|
||
} catch {
|
||
/* quota */
|
||
}
|
||
}
|
||
|
||
function loadPollingIdsFromStorage() {
|
||
try {
|
||
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(sessionKey()) : null
|
||
if (!raw) {
|
||
pollingTaskIds.value = []
|
||
return
|
||
}
|
||
const parsed = JSON.parse(raw) as unknown
|
||
pollingTaskIds.value = Array.isArray(parsed) ? parsed.filter((n): n is number => typeof n === 'number' && n > 0) : []
|
||
} catch {
|
||
pollingTaskIds.value = []
|
||
}
|
||
}
|
||
|
||
function savePollingIds() {
|
||
setStorageJson(sessionKey(), pollingTaskIds.value, pollingTaskIds.value.length === 0)
|
||
}
|
||
|
||
function loadTaskDetailsFromStorage() {
|
||
try {
|
||
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(taskStatusStorageKey()) : null
|
||
if (!raw) return
|
||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||
const out: Record<number, string> = {}
|
||
for (const [k, v] of Object.entries(parsed)) {
|
||
const id = Number(k)
|
||
if (id > 0 && typeof v === 'string') out[id] = v
|
||
}
|
||
taskDetails.value = out
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
function saveTaskDetailsToStorage() {
|
||
setStorageJson(taskStatusStorageKey(), taskDetails.value, Object.keys(taskDetails.value).length === 0)
|
||
}
|
||
|
||
function loadTaskSnapshotsFromStorage() {
|
||
try {
|
||
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(taskSnapshotsStorageKey()) : null
|
||
if (!raw) return
|
||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||
const out: Record<number, PriceTrackTaskDetailVo> = {}
|
||
for (const [k, v] of Object.entries(parsed)) {
|
||
const id = Number(k)
|
||
if (id > 0 && v && typeof v === 'object') out[id] = v as PriceTrackTaskDetailVo
|
||
}
|
||
taskSnapshots.value = out
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
function saveTaskSnapshotsToStorage() {
|
||
setStorageJson(taskSnapshotsStorageKey(), taskSnapshots.value, Object.keys(taskSnapshots.value).length === 0)
|
||
}
|
||
|
||
function loadMatchedItemsFromStorage() {
|
||
try {
|
||
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(matchedItemsStorageKey()) : null
|
||
if (!raw) return
|
||
const parsed = JSON.parse(raw) as unknown
|
||
if (Array.isArray(parsed)) matchedItems.value = parsed as PriceTrackShopQueueItem[]
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
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()
|
||
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
|
||
asinUploadedFiles.value = await uploadAsinPathsToJava(result.paths)
|
||
ElMessage.success(`已选择 ${result.paths.length} 个ASIN文件`)
|
||
}
|
||
return
|
||
}
|
||
if (api?.select_brand_xlsx_files) {
|
||
const paths = await api.select_brand_xlsx_files()
|
||
if (!paths?.length) return
|
||
asinFiles.value = paths
|
||
asinUploadedFiles.value = await uploadAsinPathsToJava(paths)
|
||
ElMessage.success(`已选择 ${paths.length} 个ASIN文件`)
|
||
return
|
||
}
|
||
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.relativePath || item.absolutePath)
|
||
asinUploadedFiles.value = await uploadAsinPathsToJava(result.items)
|
||
ElMessage.success(`已选择文件夹内 ${result.items.length} 个ASIN文件`)
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '选择失败')
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// ========== 国家顺序相关 ==========
|
||
async function uploadAsinPathsToJava(paths: Array<string | { absolutePath: string; relativePath?: string }>) {
|
||
const api = getPywebviewApi()
|
||
if (!api?.upload_file_to_java) {
|
||
return []
|
||
}
|
||
const uploaded: UploadedJavaFile[] = []
|
||
for (const item of paths) {
|
||
const filePath = typeof item === 'string' ? item : item.absolutePath
|
||
const relativePath = typeof item === 'string' ? undefined : item.relativePath
|
||
const result = await api.upload_file_to_java(filePath, relativePath)
|
||
if (!result?.success || !result.data) {
|
||
throw new Error(result?.error || result?.message || `上传失败:${filePath}`)
|
||
}
|
||
uploaded.push(result.data)
|
||
}
|
||
return uploaded
|
||
}
|
||
|
||
function resolveAsinRequestPaths() {
|
||
if (asinUploadedFiles.value.length) {
|
||
return asinUploadedFiles.value.map((item) => item.localPath).filter((path) => !!path)
|
||
}
|
||
return asinFiles.value
|
||
}
|
||
|
||
function selectMode(mode: 'status' | 'asin') {
|
||
statusModeEnabled.value = mode === 'status'
|
||
asinModeEnabled.value = mode === 'asin'
|
||
}
|
||
|
||
function countryLabel(code: string) {
|
||
const row = COUNTRY_OPTIONS.find((o) => o.code === code)
|
||
return row?.label ?? code
|
||
}
|
||
|
||
const countryCheckboxRows = computed(() => {
|
||
const sel = orderedCountryCodes.value
|
||
const selectedSet = new Set(sel)
|
||
const selectedPart = sel.map((code) => ({ code, label: countryLabel(code) }))
|
||
const rest = COUNTRY_OPTIONS.filter((o) => !selectedSet.has(o.code)).map((o) => ({ code: o.code, label: o.label }))
|
||
return [...selectedPart, ...rest]
|
||
})
|
||
|
||
function isCountrySelected(code: string) {
|
||
return orderedCountryCodes.value.includes(code)
|
||
}
|
||
|
||
function isCountrySelectionLocked(code: string) {
|
||
return orderedCountryCodes.value.length === 1 && orderedCountryCodes.value[0] === code
|
||
}
|
||
|
||
function onCountryNativeChange(code: string, e: Event) {
|
||
const el = e.target as HTMLInputElement | null
|
||
if (!el) return
|
||
if (!el.checked && isCountrySelectionLocked(code)) {
|
||
el.checked = true
|
||
ElMessage.warning('至少保留 1 个国家')
|
||
return
|
||
}
|
||
onCountryToggle(code, el.checked)
|
||
}
|
||
|
||
function onCountryToggle(code: string, checked: boolean) {
|
||
countryPrefUserTouched.value = true
|
||
if (checked) {
|
||
if (!orderedCountryCodes.value.includes(code)) {
|
||
orderedCountryCodes.value = [...orderedCountryCodes.value, code]
|
||
}
|
||
} else {
|
||
if (orderedCountryCodes.value.length <= 1) {
|
||
ElMessage.warning('至少保留 1 个国家')
|
||
return
|
||
}
|
||
orderedCountryCodes.value = orderedCountryCodes.value.filter((c) => c !== code)
|
||
}
|
||
scheduleSaveCountryPref()
|
||
}
|
||
|
||
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 arr = [...orderedCountryCodes.value]
|
||
const [item] = arr.splice(from, 1)
|
||
arr.splice(toIndex, 0, item)
|
||
orderedCountryCodes.value = arr
|
||
scheduleSaveCountryPref()
|
||
}
|
||
|
||
function scheduleSaveCountryPref() {
|
||
if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer)
|
||
countryPrefSaveTimer = setTimeout(() => {
|
||
countryPrefSaveTimer = null
|
||
void persistCountryPref()
|
||
}, 450)
|
||
}
|
||
|
||
async function persistCountryPref() {
|
||
if (orderedCountryCodes.value.length < 1) return
|
||
countryPrefSaving.value = true
|
||
try {
|
||
await putPriceTrackCountryPreference(orderedCountryCodes.value)
|
||
} catch (e) {
|
||
ElMessage.error(e instanceof Error ? e.message : '保存国家顺序失败')
|
||
} finally {
|
||
countryPrefSaving.value = false
|
||
}
|
||
}
|
||
|
||
async function loadCountryPreference() {
|
||
try {
|
||
const vo = await getPriceTrackCountryPreference()
|
||
if (vo?.countryCodes?.length) {
|
||
orderedCountryCodes.value = [...vo.countryCodes]
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
// ========== 店铺备选 ==========
|
||
async function loadCandidates() {
|
||
try {
|
||
candidates.value = await listPriceTrackCandidates()
|
||
} catch (e) {
|
||
ElMessage.error(e instanceof Error ? e.message : '加载备选失败')
|
||
}
|
||
}
|
||
|
||
async function loadDashboard() {
|
||
try {
|
||
dashboard.value = await getPriceTrackDashboard()
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
async function loadHistory() {
|
||
try {
|
||
const vo = await getPriceTrackHistory()
|
||
historyItems.value = vo.items || []
|
||
} catch {
|
||
historyItems.value = []
|
||
}
|
||
}
|
||
|
||
function syncPollingIdsWithHistory() {
|
||
for (const taskId of [...pollingTaskIds.value]) {
|
||
const any = historyItems.value.some((r) => r.taskId === taskId)
|
||
if (!any && !taskSnapshots.value[taskId]) {
|
||
removePollingTask(taskId)
|
||
}
|
||
}
|
||
}
|
||
|
||
function onSelectionChange(rows: PriceTrackCandidateVo[]) {
|
||
selectedCandidates.value = rows || []
|
||
}
|
||
|
||
async function confirmAdd() {
|
||
const name = shopInput.value.trim()
|
||
if (!name) {
|
||
ElMessage.warning('请输入店铺名称')
|
||
return
|
||
}
|
||
adding.value = true
|
||
try {
|
||
await addPriceTrackCandidate(name)
|
||
shopInput.value = ''
|
||
await loadCandidates()
|
||
ElMessage.success('已加入备选')
|
||
} catch (e) {
|
||
ElMessage.error(e instanceof Error ? e.message : '保存失败')
|
||
} finally {
|
||
adding.value = false
|
||
}
|
||
}
|
||
|
||
async function removeCandidate(row: PriceTrackCandidateVo) {
|
||
try {
|
||
await deletePriceTrackCandidate(row.id)
|
||
await loadCandidates()
|
||
matchedItems.value = []
|
||
saveMatchedItemsToStorage()
|
||
ElMessage.success('已删除')
|
||
} catch (e) {
|
||
ElMessage.error(e instanceof Error ? e.message : '删除失败')
|
||
}
|
||
}
|
||
|
||
// ========== 匹配 & 推送 ==========
|
||
function collectShopNamesForMatch(): string[] {
|
||
const set = new Set<string>()
|
||
const extra = shopInput.value.trim()
|
||
if (extra) set.add(extra)
|
||
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]
|
||
}
|
||
|
||
async function runMatch() {
|
||
const names = collectShopNamesForMatch()
|
||
if (!names.length) {
|
||
ElMessage.warning('请勾选备选店铺,或在输入框中填写店名')
|
||
return
|
||
}
|
||
if (asinModeEnabled.value && !asinFiles.value.length) {
|
||
ElMessage.warning('请先选择 ASIN 文件')
|
||
return
|
||
}
|
||
matching.value = true
|
||
try {
|
||
const res = await matchPriceTrackShops(names, {
|
||
asinFiles: asinModeEnabled.value ? resolveAsinRequestPaths() : [],
|
||
countryCodes: [...orderedCountryCodes.value],
|
||
})
|
||
const batch = res.items || []
|
||
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()
|
||
if (autoQueueEnabled.value) {
|
||
void processMatchedQueue()
|
||
}
|
||
ElMessage.success(`匹配 ${batch.length} 个店铺`)
|
||
} catch (e) {
|
||
ElMessage.error(e instanceof Error ? e.message : '匹配失败')
|
||
} finally {
|
||
matching.value = false
|
||
}
|
||
}
|
||
|
||
function formatMatchStatus(status: string | undefined) {
|
||
const s = (status || '').trim()
|
||
const map: Record<string, string> = { MATCHED: '已匹配', PENDING: '待匹配', CONFLICT: '需人工确认', INDEX_STALE: '索引过期' }
|
||
return map[s] || (s ? s : '—')
|
||
}
|
||
|
||
function formatMatchRemark(row: PriceTrackShopQueueItem) {
|
||
const msg = (row.matchMessage || '').trim()
|
||
if (msg) return msg
|
||
if (row.matched && row.matchStatus === 'MATCHED') return '紫鸟索引已命中,可推送队列'
|
||
if (row.matched) return '已关联索引,请结合状态列查看是否可推送'
|
||
return '未命中或未就绪,请检查店铺名与索引刷新'
|
||
}
|
||
|
||
function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | null }) {
|
||
return `${(row.shopName || '').trim()}\u0001${row.shopId ?? ''}`
|
||
}
|
||
|
||
function isRecordMissingError(error: unknown) {
|
||
const message = error instanceof Error ? error.message : String(error || '')
|
||
return /记录不存在|不存在|已删除|not\s*found|404/i.test(message)
|
||
}
|
||
|
||
function buildSkipAsinDeletePolicy() {
|
||
const enabled = statusModeEnabled.value && !asinModeEnabled.value
|
||
return {
|
||
enabled,
|
||
mode: enabled ? 'DELETE_WHEN_PRICE_BELOW_MINIMUM' : 'NONE',
|
||
deleteWhenPriceBelowMinimum: enabled,
|
||
compareField: 'price',
|
||
thresholdField: 'minimumPrice',
|
||
target: 'skip_asin',
|
||
source: 'frontend-price-track',
|
||
}
|
||
}
|
||
|
||
function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: number | string | null }>) {
|
||
if (!rows.length) return
|
||
const keys = new Set(rows.map((row) => 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()
|
||
}
|
||
|
||
function removeHistoryItemLocally(item: PriceTrackHistoryItem) {
|
||
historyItems.value = historyItems.value.filter((row) => {
|
||
if (item.resultId != null && row.resultId === item.resultId) return false
|
||
if (item.taskId != null && row.taskId === item.taskId) return false
|
||
return true
|
||
})
|
||
}
|
||
|
||
async function removeMatchedRow(row: PriceTrackShopQueueItem) {
|
||
const backup = [...matchedItems.value]
|
||
removeMatchedRowsLocally([row])
|
||
const name = (row.shopName || '').trim()
|
||
if (!name) {
|
||
ElMessage.success('已从匹配结果中移除')
|
||
return
|
||
}
|
||
try {
|
||
const res = await deletePendingPriceTrackShopResult(name)
|
||
if (res?.removed) {
|
||
await loadHistory()
|
||
await loadDashboard()
|
||
syncPollingIdsWithHistory()
|
||
}
|
||
ElMessage.success('已从匹配结果中移除')
|
||
} catch (e) {
|
||
if (isRecordMissingError(e)) {
|
||
await loadHistory()
|
||
await loadDashboard()
|
||
syncPollingIdsWithHistory()
|
||
ElMessage.success('后端记录已不存在,已同步移除本地记录')
|
||
return
|
||
}
|
||
matchedItems.value = backup
|
||
saveMatchedItemsToStorage()
|
||
ElMessage.error(e instanceof Error ? e.message : '同步后端失败')
|
||
}
|
||
}
|
||
|
||
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')
|
||
return
|
||
}
|
||
const matchedRows = matchedItems.value.filter((i) => i.matched)
|
||
if (!matchedRows.length) {
|
||
ElMessage.warning('无可用匹配店铺')
|
||
return
|
||
}
|
||
pushing.value = true
|
||
queuePayloadText.value = ''
|
||
try {
|
||
// 1. 先调用 Java API 创建任务(落库,含 skip ASIN)
|
||
const taskReq = {
|
||
userId: 0, // 会被 java-modules 自动填充
|
||
statusMode: statusModeEnabled.value,
|
||
asinMode: asinModeEnabled.value,
|
||
items: matchedRows as unknown as Record<string, unknown>[],
|
||
asinFiles: resolveAsinRequestPaths(),
|
||
countryCodes: [...orderedCountryCodes.value],
|
||
}
|
||
const taskVo = await createPriceTrackTask(taskReq)
|
||
taskSnapshots.value = {
|
||
...taskSnapshots.value,
|
||
[taskVo.taskId]: {
|
||
task: { id: taskVo.taskId, status: 'RUNNING' },
|
||
items: taskVo.items,
|
||
},
|
||
}
|
||
taskDetails.value = {
|
||
...taskDetails.value,
|
||
[taskVo.taskId]: 'RUNNING',
|
||
}
|
||
saveTaskSnapshotsToStorage()
|
||
saveTaskDetailsToStorage()
|
||
|
||
// 2. 推送 taskId 给 Python,Python 会从 Java 拉取完整任务上下文
|
||
const queuePayload = {
|
||
type: 'price-track-run',
|
||
ts: Date.now(),
|
||
data: {
|
||
task_id: taskVo.taskId,
|
||
// 店铺简要信息(用于展示)
|
||
shop_names: matchedRows.map((i) => i.shopName).filter(Boolean),
|
||
country_codes: [...orderedCountryCodes.value],
|
||
skip_asin_delete_policy: buildSkipAsinDeletePolicy(),
|
||
delete_skip_asin_when_price_below_minimum: statusModeEnabled.value && !asinModeEnabled.value,
|
||
deleteSkipAsinWhenPriceBelowMinimum: statusModeEnabled.value && !asinModeEnabled.value,
|
||
},
|
||
}
|
||
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
|
||
|
||
const pushResult = await api.enqueue_json(queuePayload)
|
||
if (pushResult?.success) {
|
||
queuePushResult.value = `任务 ${taskVo.taskId} 已入队,等待执行完成...`
|
||
addPollingTask(taskVo.taskId)
|
||
scheduleNextPoll(true)
|
||
ElMessage.success('已推送到 Python 队列')
|
||
} else {
|
||
queuePushResult.value = `推送失败:${pushResult?.error || '未知错误'}`
|
||
ElMessage.error(queuePushResult.value)
|
||
}
|
||
} catch (e) {
|
||
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
|
||
ElMessage.error(queuePushResult.value)
|
||
} finally {
|
||
pushing.value = false
|
||
}
|
||
}
|
||
|
||
// ========== 任务状态轮询 ==========
|
||
function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQueueItem) {
|
||
const taskItem = taskVo.items?.[0]
|
||
const shopName = (row.shopName || '').trim()
|
||
const skipAsinsByCountry = row.skipAsins || taskVo.skipAsinsByCountry || {}
|
||
const skipAsinDetailsByCountry = taskVo.skipAsinDetailsByCountry || {}
|
||
const minimumPriceByCountryAndAsin = taskVo.minimumPriceByCountryAndAsin || {}
|
||
const skipAsinDeletePolicy = buildSkipAsinDeletePolicy()
|
||
const resultId = taskItem?.resultId ?? null
|
||
const loopRunId = taskItem?.loopRunId ?? null
|
||
const roundIndex = taskItem?.roundIndex ?? null
|
||
const taskStatus = taskItem?.taskStatus || 'RUNNING'
|
||
const success = taskItem?.success ?? false
|
||
const error = taskItem?.error || null
|
||
const outputFilename = taskItem?.outputFilename || null
|
||
const downloadUrl = taskItem?.downloadUrl || null
|
||
return {
|
||
type: 'price-track-run',
|
||
ts: Date.now(),
|
||
data: {
|
||
task_id: taskVo.taskId,
|
||
result_id: resultId,
|
||
shop_name: shopName,
|
||
shop_id: row.shopId ?? null,
|
||
platform: row.platform || '',
|
||
company_name: row.companyName || '',
|
||
shop_mall_name: row.shopMallName || '',
|
||
matched: !!row.matched,
|
||
match_status: row.matchStatus || '',
|
||
match_message: row.matchMessage || null,
|
||
success: success,
|
||
error: error,
|
||
output_filename: outputFilename,
|
||
download_url: downloadUrl,
|
||
task_status: taskStatus,
|
||
loop_run_id: loopRunId,
|
||
round_index: roundIndex,
|
||
country_codes: [...orderedCountryCodes.value],
|
||
mode: statusModeEnabled.value ? 'status' : 'asin',
|
||
skip_asins: skipAsinsByCountry,
|
||
skip_asins_by_country: skipAsinsByCountry,
|
||
skip_asin_details_by_country: skipAsinDetailsByCountry,
|
||
minimum_price_by_country_and_asin: minimumPriceByCountryAndAsin,
|
||
skip_asin_delete_policy: skipAsinDeletePolicy,
|
||
delete_skip_asin_when_price_below_minimum: skipAsinDeletePolicy.deleteWhenPriceBelowMinimum,
|
||
deleteSkipAsinWhenPriceBelowMinimum: skipAsinDeletePolicy.deleteWhenPriceBelowMinimum,
|
||
asin_rows_by_country: Object.keys(matchAsinRowsByCountry.value).length
|
||
? matchAsinRowsByCountry.value
|
||
: (taskVo.asinRowsByCountry || {}),
|
||
taskId: taskVo.taskId,
|
||
resultId: resultId,
|
||
shopName,
|
||
shopId: row.shopId ?? null,
|
||
platformName: row.platform || '',
|
||
companyName: row.companyName || '',
|
||
shopMallName: row.shopMallName || '',
|
||
matchStatus: row.matchStatus || '',
|
||
matchMessage: row.matchMessage || null,
|
||
outputFilename: outputFilename,
|
||
downloadUrl: downloadUrl,
|
||
taskStatus,
|
||
loopRunId: loopRunId,
|
||
roundIndex: roundIndex,
|
||
},
|
||
}
|
||
}
|
||
|
||
async function waitForTaskTerminal(taskId: number) {
|
||
let transientErrorCount = 0
|
||
const maxTransientErrors = 30
|
||
while (true) {
|
||
try {
|
||
const batch = await getPriceTrackTaskProgressBatch([taskId])
|
||
if (transientErrorCount > 0) {
|
||
queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`
|
||
}
|
||
transientErrorCount = 0
|
||
if ((batch.missingTaskIds || []).includes(taskId)) {
|
||
removePollingTask(taskId)
|
||
await loadHistory()
|
||
await loadDashboard()
|
||
syncPollingIdsWithHistory()
|
||
return 'FAILED'
|
||
}
|
||
const detail = (batch.items || []).find((item) => item.task?.id === taskId)
|
||
const status = detail?.task?.status || ''
|
||
if (detail) {
|
||
const prev = taskSnapshots.value[taskId]
|
||
taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }
|
||
saveTaskSnapshotsToStorage()
|
||
}
|
||
if (status) {
|
||
taskDetails.value = { ...taskDetails.value, [taskId]: status }
|
||
saveTaskDetailsToStorage()
|
||
}
|
||
if (status === 'SUCCESS' || status === 'FAILED') {
|
||
removePollingTask(taskId)
|
||
await loadHistory()
|
||
await loadDashboard()
|
||
syncPollingIdsWithHistory()
|
||
return status
|
||
}
|
||
} catch (error) {
|
||
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
|
||
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
|
||
if (!transient) throw error
|
||
transientErrorCount += 1
|
||
if (transientErrorCount >= maxTransientErrors) {
|
||
throw new Error(`任务 ${taskId} 等待后端恢复超时,请稍后手动刷新查看状态`)
|
||
}
|
||
queuePushResult.value = `任务 ${taskId} 运行中,正在等待后端服务恢复(${transientErrorCount}/${maxTransientErrors})...`
|
||
if (transientErrorCount === 1 || transientErrorCount % 5 === 0) {
|
||
ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`)
|
||
}
|
||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||
continue
|
||
}
|
||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||
}
|
||
}
|
||
|
||
async function pushToPythonQueue() {
|
||
autoQueueEnabled.value = true
|
||
await processMatchedQueue()
|
||
}
|
||
|
||
function nextMatchedQueueItem() {
|
||
return matchedItems.value.find((item) => item.matched)
|
||
}
|
||
|
||
async function processMatchedQueue() {
|
||
if (queueWorkerRunning.value) {
|
||
return
|
||
}
|
||
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
|
||
}
|
||
queueWorkerRunning.value = true
|
||
pushing.value = true
|
||
queuePayloadText.value = ''
|
||
let successCount = 0
|
||
let failedCount = 0
|
||
try {
|
||
let index = 0
|
||
while (autoQueueEnabled.value) {
|
||
const row = nextMatchedQueueItem()
|
||
if (!row) {
|
||
break
|
||
}
|
||
index += 1
|
||
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,
|
||
},
|
||
}
|
||
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)
|
||
removeMatchedRowsLocally([row])
|
||
continue
|
||
}
|
||
addPollingTask(taskVo.taskId)
|
||
scheduleNextPoll(true)
|
||
removeMatchedRowsLocally([row])
|
||
queuePushResult.value = '任务 ' + taskVo.taskId + ' 已入队,等待执行完成'
|
||
const finalStatus = await waitForTaskTerminal(taskVo.taskId)
|
||
if (finalStatus !== 'SUCCESS') {
|
||
failedCount += 1
|
||
queuePushResult.value = '任务 ' + taskVo.taskId + ' 执行失败,已自动继续下一条'
|
||
ElMessage.error(queuePushResult.value)
|
||
continue
|
||
}
|
||
successCount += 1
|
||
queuePushResult.value = '任务 ' + taskVo.taskId + ' 已完成'
|
||
} catch (error) {
|
||
failedCount += 1
|
||
queuePushResult.value = '第 ' + index + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误')
|
||
ElMessage.error(queuePushResult.value)
|
||
removeMatchedRowsLocally([row])
|
||
continue
|
||
}
|
||
}
|
||
if (successCount > 0 || failedCount > 0) {
|
||
ElMessage.success('店铺任务推送已完成:成功 ' + successCount + ' 条,失败 ' + failedCount + ' 条')
|
||
}
|
||
} catch (e) {
|
||
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
|
||
ElMessage.error(queuePushResult.value)
|
||
} finally {
|
||
queueWorkerRunning.value = false
|
||
pushing.value = false
|
||
}
|
||
}
|
||
|
||
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 (error) {
|
||
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
|
||
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
|
||
if (transient) return activeLoopRun.value
|
||
activeLoopRun.value = null
|
||
activeLoopRunId.value = null
|
||
saveActiveLoopRunId()
|
||
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) {
|
||
const activeTaskId = loop.activeTaskId
|
||
addPollingTask(activeTaskId)
|
||
scheduleNextPoll(true)
|
||
const finalStatus = await waitForTaskTerminal(activeTaskId)
|
||
let completeAttempt = 0
|
||
const updatedLoop = await (async () => {
|
||
while (true) {
|
||
try {
|
||
return await completePriceTrackLoopChild(loop.id, activeTaskId)
|
||
} catch (error) {
|
||
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
|
||
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
|
||
completeAttempt += 1
|
||
if (!transient || completeAttempt >= 10) throw error
|
||
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后确认子任务完成(${completeAttempt}/10)...`
|
||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||
}
|
||
}
|
||
})()
|
||
setLoopRun(updatedLoop)
|
||
await loadHistory()
|
||
await loadDashboard()
|
||
syncPollingIdsWithHistory()
|
||
clearLoopRunIfTerminal()
|
||
if (finalStatus !== 'SUCCESS') {
|
||
throw new Error(updatedLoop.errorMessage || `任务 ${loop.activeTaskId} 执行失败`)
|
||
}
|
||
continue
|
||
}
|
||
|
||
let dispatchAttempt = 0
|
||
const dispatch = await (async () => {
|
||
while (true) {
|
||
try {
|
||
return await dispatchNextPriceTrackLoopRun(loop.id)
|
||
} catch (error) {
|
||
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
|
||
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
|
||
dispatchAttempt += 1
|
||
if (!transient || dispatchAttempt >= 10) throw error
|
||
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后继续派发(${dispatchAttempt}/10)...`
|
||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||
}
|
||
}
|
||
})()
|
||
setLoopRun(dispatch.loopRun || loop)
|
||
if (!dispatch.childTaskRequest) {
|
||
clearLoopRunIfTerminal()
|
||
break
|
||
}
|
||
const childTaskRequest = dispatch.childTaskRequest
|
||
const row = childTaskRequest.items?.[0]
|
||
if (!row) throw new Error('循环任务缺少待执行店铺')
|
||
let createAttempt = 0
|
||
const taskVo = await (async () => {
|
||
while (true) {
|
||
try {
|
||
return await createPriceTrackTask(childTaskRequest)
|
||
} catch (error) {
|
||
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
|
||
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
|
||
createAttempt += 1
|
||
if (!transient || createAttempt >= 10) throw error
|
||
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后重试创建子任务(${createAttempt}/10)...`
|
||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||
}
|
||
}
|
||
})()
|
||
recordCreatedTask(taskVo)
|
||
const queuePayload = buildQueuePayload(taskVo, row)
|
||
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
|
||
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: resolveAsinRequestPaths(),
|
||
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 getTaskPollIntervalMs()
|
||
}
|
||
|
||
async function refreshTaskBatch() {
|
||
const ids = pollingTaskIds.value.filter((id) => id > 0)
|
||
if (!ids.length) return
|
||
try {
|
||
const batch = await getPriceTrackTaskProgressBatch(ids)
|
||
let changed = false
|
||
const nextSnapshots = { ...taskSnapshots.value }
|
||
for (const missingId of batch.missingTaskIds || []) {
|
||
removePollingTask(missingId)
|
||
delete nextSnapshots[missingId]
|
||
}
|
||
for (const detail of batch.items || []) {
|
||
const taskId = detail.task?.id
|
||
const status = detail.task?.status
|
||
if (typeof taskId !== 'number' || taskId <= 0) continue
|
||
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') {
|
||
removePollingTask(taskId)
|
||
changed = true
|
||
}
|
||
}
|
||
}
|
||
taskSnapshots.value = nextSnapshots
|
||
saveTaskSnapshotsToStorage()
|
||
saveTaskDetailsToStorage()
|
||
await loadHistory()
|
||
syncPollingIdsWithHistory()
|
||
if (changed) await loadDashboard()
|
||
} catch {
|
||
/* polling noise */
|
||
}
|
||
}
|
||
|
||
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) { scheduleNextPoll(); return }
|
||
if (!pollingTaskIds.value.length) return
|
||
pollingInFlight.value = true
|
||
try { await refreshTaskBatch() } finally { pollingInFlight.value = false }
|
||
if (pollingTaskIds.value.length > 0) pollTimer.value = window.setTimeout(run, getPollIntervalMs())
|
||
}
|
||
if (immediate) void run()
|
||
else pollTimer.value = window.setTimeout(run, getPollIntervalMs())
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null }
|
||
}
|
||
|
||
function addPollingTask(taskId: number) {
|
||
if (!pollingTaskIds.value.includes(taskId)) {
|
||
pollingTaskIds.value = [...pollingTaskIds.value, taskId]
|
||
savePollingIds()
|
||
}
|
||
}
|
||
|
||
function removePollingTask(taskId: number) {
|
||
pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId)
|
||
savePollingIds()
|
||
delete taskDetails.value[taskId]
|
||
saveTaskDetailsToStorage()
|
||
if (taskSnapshots.value[taskId]) {
|
||
const next = { ...taskSnapshots.value }
|
||
delete next[taskId]
|
||
taskSnapshots.value = next
|
||
saveTaskSnapshotsToStorage()
|
||
}
|
||
}
|
||
|
||
const currentSectionItems = computed(() => {
|
||
const out = historyItems.value.filter((row) => row.taskId && pollingTaskIds.value.includes(row.taskId) && !isTaskTerminalById(row.taskId))
|
||
const existingTaskIds = new Set(out.map((row) => row.taskId).filter((id): id is number => typeof id === 'number'))
|
||
for (const taskId of pollingTaskIds.value) {
|
||
if (existingTaskIds.has(taskId)) continue
|
||
const first = taskSnapshots.value[taskId]?.items?.[0]
|
||
if (first) out.push(first)
|
||
}
|
||
return out
|
||
})
|
||
|
||
const historySectionItems = computed(() =>
|
||
historyItems.value.filter((row) => !row.taskId || !pollingTaskIds.value.includes(row.taskId) || isTaskTerminalById(row.taskId))
|
||
)
|
||
|
||
const hasVisibleList = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
|
||
|
||
function taskStatusOf(taskId?: number) {
|
||
if (!taskId) return ''
|
||
return taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status || ''
|
||
}
|
||
|
||
function isTaskTerminalById(taskId?: number) {
|
||
const s = taskStatusOf(taskId)
|
||
return s === 'SUCCESS' || s === 'FAILED'
|
||
}
|
||
|
||
function resolvedTaskStatus(item: PriceTrackHistoryItem) {
|
||
const tid = item.taskId
|
||
if (!tid) return item.taskStatus || ''
|
||
return taskStatusOf(tid) || item.taskStatus || ''
|
||
}
|
||
|
||
function statusText(item: PriceTrackHistoryItem) {
|
||
const st = resolvedTaskStatus(item)
|
||
if (st === 'SUCCESS') return '已完成'
|
||
if (st === 'FAILED') return '失败'
|
||
if (st === 'RUNNING') return '执行中'
|
||
if (item.success) return '已完成'
|
||
return '等待中'
|
||
}
|
||
|
||
function statusClass(item: PriceTrackHistoryItem) {
|
||
const st = resolvedTaskStatus(item)
|
||
if (st === 'SUCCESS' || item.success) return 'success'
|
||
if (st === 'FAILED') return 'failed'
|
||
return 'running'
|
||
}
|
||
|
||
function canDownload(item: PriceTrackHistoryItem) {
|
||
if (!item.resultId) return false
|
||
const st = resolvedTaskStatus(item)
|
||
return st === 'SUCCESS' || item.success === true
|
||
}
|
||
|
||
async function downloadResult(item: PriceTrackHistoryItem) {
|
||
if (!item.resultId) return
|
||
const api = getPywebviewApi()
|
||
const filename = item.outputFilename || `${item.shopName || 'result'}.xlsx`
|
||
if (!api?.save_file_from_url_new) {
|
||
ElMessage.error('当前客户端未提供 save_file_from_url_new,无法下载文件')
|
||
return
|
||
}
|
||
const url = getPriceTrackResultDownloadUrl(item.resultId)
|
||
const result = await api.save_file_from_url_new(url, filename)
|
||
if (result.success) {
|
||
ElMessage.success(`已保存:${result.path || filename}`)
|
||
} else if (result.error && result.error !== '用户取消') {
|
||
ElMessage.error(result.error)
|
||
}
|
||
}
|
||
|
||
async function deleteTaskRecord(item: PriceTrackHistoryItem) {
|
||
try {
|
||
if (item.resultId != null) {
|
||
await deletePriceTrackHistory(item.resultId)
|
||
} 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()
|
||
await loadDashboard()
|
||
syncPollingIdsWithHistory()
|
||
ElMessage.success('已删除')
|
||
} catch (e) {
|
||
if (isRecordMissingError(e)) {
|
||
if (item.taskId != null) removePollingTask(item.taskId)
|
||
removeMatchedRowsLocally([item])
|
||
removeHistoryItemLocally(item)
|
||
await loadDashboard()
|
||
syncPollingIdsWithHistory()
|
||
ElMessage.success('后端记录已不存在,已同步移除本地记录')
|
||
return
|
||
}
|
||
ElMessage.error(e instanceof Error ? e.message : '删除失败')
|
||
}
|
||
}
|
||
|
||
// ========== 生命周期 ==========
|
||
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(() => {
|
||
stopPolling()
|
||
if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.module-page {
|
||
min-height: 100vh;
|
||
background: #1a1a1a;
|
||
}
|
||
|
||
.main-content {
|
||
display: flex;
|
||
min-height: calc(100vh - 56px);
|
||
height: calc(100vh - 56px);
|
||
}
|
||
|
||
.left-panel {
|
||
width: 400px;
|
||
background: #1e1e1e;
|
||
padding: 20px;
|
||
overflow-y: auto;
|
||
border-right: 1px solid #2a2a2a;
|
||
}
|
||
|
||
.right-panel {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
background: #1a1a1a;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 13px;
|
||
color: #bbb;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.upload-zone {
|
||
border: 1px dashed #3a3a3a;
|
||
border-radius: 10px;
|
||
padding: 16px;
|
||
background: #252525;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.hint {
|
||
color: #888;
|
||
font-size: 12px;
|
||
margin-bottom: 12px;
|
||
line-height: 1.5;
|
||
text-align: left;
|
||
}
|
||
|
||
.btns {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.selected-files {
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.clean-placeholder {
|
||
color: #666;
|
||
font-size: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.selected-files span:not(.more-line) {
|
||
color: #aaa;
|
||
}
|
||
|
||
.more-line {
|
||
color: #777;
|
||
font-style: italic;
|
||
}
|
||
|
||
.text-muted {
|
||
color: #777;
|
||
font-style: italic;
|
||
}
|
||
|
||
.mode-zone {
|
||
border: 1px dashed #3a3a3a;
|
||
border-radius: 10px;
|
||
padding: 14px 16px;
|
||
background: #252525;
|
||
margin-bottom: 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.mode-check-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.mode-check-input {
|
||
width: 18px;
|
||
height: 18px;
|
||
accent-color: #409eff;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.mode-check-text {
|
||
color: #ddd;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.mode-hint-inline {
|
||
color: #888;
|
||
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;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
margin-left: 6px;
|
||
vertical-align: middle;
|
||
background: rgba(52, 152, 219, 0.2);
|
||
color: #69b6ff;
|
||
}
|
||
|
||
.mode-status-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 14px;
|
||
border-radius: 8px;
|
||
background: rgba(46, 204, 113, 0.08);
|
||
border: 1px solid rgba(46, 204, 113, 0.2);
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.mode-status-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.mode-status-dot.active {
|
||
background: #27ae60;
|
||
box-shadow: 0 0 6px rgba(46, 204, 113, 0.5);
|
||
}
|
||
|
||
.mode-status-text {
|
||
font-size: 12px;
|
||
color: #aaa;
|
||
}
|
||
|
||
.input-zone {
|
||
border: 1px dashed #3a3a3a;
|
||
border-radius: 10px;
|
||
padding: 16px;
|
||
background: #252525;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.input-row {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.input-row :deep(.el-input) {
|
||
flex: 1;
|
||
}
|
||
|
||
.opt-btn {
|
||
padding: 8px 14px;
|
||
font-size: 13px;
|
||
color: #ccc;
|
||
background: #2a2a2a;
|
||
border: 1px solid #3a3a3a;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.opt-btn:hover:not(:disabled) {
|
||
color: #3498db;
|
||
border-color: #3498db;
|
||
}
|
||
|
||
.opt-btn:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.country-pref-hint {
|
||
margin-top: -4px;
|
||
}
|
||
|
||
.country-pref-checks {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px 16px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.country-check-row {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
font-size: 12px;
|
||
color: #ccc;
|
||
}
|
||
|
||
.country-check-input {
|
||
width: 16px;
|
||
height: 16px;
|
||
accent-color: #409eff;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.country-check-text {
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.country-order-panel {
|
||
border: 1px solid #333;
|
||
border-radius: 8px;
|
||
padding: 10px 12px;
|
||
background: #252525;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.country-order-caption {
|
||
font-size: 11px;
|
||
color: #777;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.country-order-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.country-drag-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 10px;
|
||
background: #2c2c2c;
|
||
border-radius: 6px;
|
||
border: 1px solid #3a3a3a;
|
||
cursor: grab;
|
||
user-select: none;
|
||
}
|
||
|
||
.country-drag-row.dragging {
|
||
opacity: 0.55;
|
||
border-color: #3498db;
|
||
}
|
||
|
||
.drag-handle {
|
||
color: #666;
|
||
font-size: 12px;
|
||
letter-spacing: -2px;
|
||
}
|
||
|
||
.country-drag-label {
|
||
font-size: 12px;
|
||
color: #ccc;
|
||
}
|
||
|
||
.country-pref-status {
|
||
font-size: 11px;
|
||
color: #888;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.listing-filter-hint {
|
||
margin-top: -4px;
|
||
}
|
||
|
||
.listing-filter-row {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.listing-filter-select {
|
||
width: 100%;
|
||
}
|
||
|
||
.listing-filter-row :deep(.el-select__wrapper) {
|
||
background-color: #2a2a2a;
|
||
box-shadow: 0 0 0 1px #3a3a3a inset;
|
||
}
|
||
|
||
.listing-filter-row :deep(.el-select__placeholder),
|
||
.listing-filter-row :deep(.el-select__selected-item) {
|
||
color: #ccc;
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
.empty-candidates {
|
||
color: #666;
|
||
font-size: 13px;
|
||
padding: 16px;
|
||
border: 1px dashed #333;
|
||
border-radius: 8px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.candidate-table-scroll {
|
||
max-height: 240px;
|
||
overflow: auto;
|
||
margin-bottom: 16px;
|
||
border-radius: 8px;
|
||
border: 1px solid #2a2a2a;
|
||
}
|
||
|
||
.candidate-table {
|
||
--el-table-bg-color: #252525;
|
||
--el-table-tr-bg-color: #252525;
|
||
--el-table-header-bg-color: #2a2a2a;
|
||
--el-table-text-color: #ccc;
|
||
--el-table-border-color: #333;
|
||
}
|
||
|
||
.link-danger {
|
||
background: none;
|
||
border: none;
|
||
color: #e74c3c;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
padding: 0;
|
||
}
|
||
|
||
.link-danger:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.run-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.btn-run {
|
||
padding: 10px 18px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
background: #3498db;
|
||
border: none;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.btn-run:hover:not(:disabled) {
|
||
background: #2980b9;
|
||
}
|
||
|
||
.btn-run:disabled {
|
||
opacity: 0.55;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.btn-queue {
|
||
background: #27ae60;
|
||
}
|
||
|
||
.btn-queue:hover:not(:disabled) {
|
||
background: #219a52;
|
||
}
|
||
|
||
.loading-msg {
|
||
margin-top: 12px;
|
||
font-size: 12px;
|
||
color: #777;
|
||
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;
|
||
}
|
||
|
||
.queue-debug-card {
|
||
margin-top: 18px;
|
||
padding: 14px;
|
||
border: 1px solid #2a2a2a;
|
||
border-radius: 10px;
|
||
background: #202020;
|
||
}
|
||
|
||
.queue-debug-title {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.queue-debug-line {
|
||
color: #b8c1cc;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.queue-debug-payload {
|
||
margin: 0;
|
||
max-height: 220px;
|
||
overflow: auto;
|
||
padding: 10px;
|
||
border-radius: 8px;
|
||
background: #141414;
|
||
color: #8fd3ff;
|
||
font-size: 11px;
|
||
line-height: 1.5;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
|
||
/* Right panel */
|
||
.panel-header {
|
||
padding: 16px 20px;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #e0e0e0;
|
||
border-bottom: 1px solid #2a2a2a;
|
||
}
|
||
|
||
.task-list-wrap {
|
||
flex: 1;
|
||
padding: 16px 20px;
|
||
overflow: auto;
|
||
}
|
||
|
||
.pr-summary {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.summary-card {
|
||
padding: 14px 16px;
|
||
border: 1px solid #2a2a2a;
|
||
border-radius: 10px;
|
||
background: #1e1e1e;
|
||
}
|
||
|
||
.summary-card strong {
|
||
display: block;
|
||
margin-top: 8px;
|
||
font-size: 22px;
|
||
color: #eaf4ff;
|
||
}
|
||
|
||
.summary-label {
|
||
font-size: 12px;
|
||
color: #8d8d8d;
|
||
}
|
||
|
||
.subsection-title {
|
||
font-size: 13px;
|
||
color: #999;
|
||
margin: 8px 0 10px;
|
||
}
|
||
|
||
.empty-tasks {
|
||
color: #666;
|
||
font-size: 13px;
|
||
padding: 16px;
|
||
text-align: center;
|
||
}
|
||
|
||
.empty-tasks.narrow {
|
||
padding: 12px 8px;
|
||
}
|
||
|
||
.match-table {
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.result-table {
|
||
--el-table-bg-color: #222;
|
||
--el-table-tr-bg-color: #222;
|
||
--el-table-header-bg-color: #2a2a2a;
|
||
--el-table-text-color: #ccc;
|
||
--el-table-border-color: #333;
|
||
}
|
||
|
||
.ok { color: #27ae60; }
|
||
.fail { color: #e67e22; }
|
||
|
||
.result-list-wrap {
|
||
border: 1px solid #2a2a2a;
|
||
border-radius: 10px;
|
||
background: #1e1e1e;
|
||
min-height: 200px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.result-list-header {
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid #2a2a2a;
|
||
font-size: 14px;
|
||
color: #ddd;
|
||
}
|
||
|
||
.result-subsection {
|
||
padding: 12px 12px 4px;
|
||
}
|
||
|
||
.result-subsection-title {
|
||
font-size: 12px;
|
||
color: #888;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.task-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
.task-item {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 12px 14px;
|
||
border: 1px solid #2a2a2a;
|
||
border-radius: 8px;
|
||
margin-bottom: 8px;
|
||
background: #222;
|
||
}
|
||
|
||
.split-result-main {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.id {
|
||
font-weight: 600;
|
||
color: #e0e0e0;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.files {
|
||
font-size: 12px;
|
||
color: #888;
|
||
}
|
||
|
||
.task-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.status {
|
||
padding: 4px 10px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.status.success {
|
||
background: rgba(46, 204, 113, 0.18);
|
||
color: #2ecc71;
|
||
}
|
||
|
||
.status.failed {
|
||
background: rgba(231, 76, 60, 0.18);
|
||
color: #ff6b6b;
|
||
}
|
||
|
||
.status.running {
|
||
background: rgba(52, 152, 219, 0.18);
|
||
color: #3498db;
|
||
}
|
||
|
||
.download {
|
||
padding: 6px 10px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
background: rgba(52, 152, 219, 0.18);
|
||
color: #69b6ff;
|
||
border: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.download:hover {
|
||
background: rgba(52, 152, 219, 0.28);
|
||
}
|
||
|
||
.btn-delete {
|
||
padding: 6px 10px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
background: rgba(231, 76, 60, 0.12);
|
||
color: #ff8f8f;
|
||
border: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.pr-summary {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
</style>
|
||
|