Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue
T
huangzd1997 9d39705c77 fix(采集数据): 品牌检测移出任务锁 + 失败任务仍产出可下载的部分结果
taskId 28599 事故根因:分片回传在任务锁内同步做品牌检测,上游 16890 卡死时
单次持锁 103.5 秒(costMs=103556),期间该任务的心跳与客户端重试全部撞
40902「任务正在处理」,客户端 5 次重试预算耗尽后中止了整个采集。

- submitResult:行归一化 / 去重过滤 / 品牌检测移到任务锁外,锁内只保留落库与终态
- BrandCheckClient:整批加 90 秒总耗时上限(超时按查询失败降级,首轮始终执行),
  单次回传时延从「无上界」收敛到约 90 秒 + 一次读超时
- 失败且已收到分片时照常组装部分结果;任务已 FAILED 则保留失败态与真实失败原因,
  只补结果文件(乐观写结果行 + 条件更新不匹配再补偿写回,避免崩溃时留下「成功但无下载」)
- productrisk / shopmatch 按 pricetrack 的 preserveFailure 样板补齐,含组装落地时
  不再把失败行「洗成成功」
- 前端:采集 / 跟价 / 产品风险 / 店铺匹配的下载按钮放宽到失败任务
2026-09-18 15:53:15 +08:00

2315 lines
75 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-shell module-page">
<AmazonToolPageShell tool-id="follow">
<div class="main-content">
<aside class="left-panel left-panel--with-template">
<!-- 运行模式 -->
<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>
<div class="price-track-shop-candidates">
<!-- 店铺输入和备选店铺在两种 ASIN 来源模式下都可用 -->
<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>
</div>
<template v-if="!asinModeEnabled">
<!-- 国家顺序 -->
<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>
</template>
<ZiniaoVersionSetting v-model="ziniaoVersion" />
<!-- 状态指示 -->
<div v-if="statusModeEnabled" class="mode-status-bar">
<span class="mode-status-dot active"></span>
<span class="mode-status-text">商品状态模式将在售ActiveASIN纳入跟价跳过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 ? '启动中…' : '启动任务' }}
</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>
<ModuleTemplateDownload module-code="price-track" filename="指定ASIN跟价 文档格式.xlsx" />
</aside>
<section class="right-panel">
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="匹配与任务"
:cards="priceTrackCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-empty-text="暂无当前任务完成匹配店铺并启动任务后将在此展示处理进度"
history-empty-text="暂无历史记录"
>
<template #cards-extra>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="match-empty">完成匹配店铺在此展示匹配结果确认无误后再启动任务</div>
<el-table v-else :data="pagedMatchedItems" :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>
<el-pagination
v-if="matchedTotal > matchedPageSize"
class="matched-pagination"
layout="total, prev, pager, next"
:total="matchedTotal"
:page-size="matchedPageSize"
v-model:current-page="matchedPage"
/>
</template>
<template #item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
<template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import { expandBrandFolderRecursive } from '@/shared/api/brand'
import { getPywebviewApi, type PywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import {
EXCEL_CSV_EXTENSIONS,
checkQueuePayload,
checkSelectedFiles,
} from '@/shared/dispatch-guard'
import { passGuard } from '@/shared/dispatch-guard-ui'
import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
import {
addPriceTrackCandidate,
completePriceTrackLoopChild,
createPriceTrackTask,
createPriceTrackLoopRun,
dispatchNextPriceTrackLoopRun,
deletePendingPriceTrackShopResult,
deletePriceTrackCandidate,
deletePriceTrackHistory,
deletePriceTrackTask,
getPriceTrackCountryPreference,
getPriceTrackDashboard,
getPriceTrackHistory,
getPriceTrackLoopRun,
getPriceTrackResultDownloadUrl,
getPriceTrackTaskProgressBatch,
getTaskSkipPriceAsinsPaginated,
listPriceTrackCandidates,
matchPriceTrackShops,
markPriceTrackDispatchFailed,
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'
import { getPollingProgressBatch } from '@/shared/api/task-progress-polling.ts'
import { formatDateTime } from '@/shared/utils/datetime'
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
import { runBatchDelete } from '@/shared/utils/batch-delete'
import { isRecordMissingError } from '@/shared/utils/task-queue-state.ts'
import { useTablePaging } from '@/shared/composables/useTablePaging.ts'
const ziniaoVersion = useZiniaoVersion()
// ========== 类型定义 ==========
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[]>([])
// F9:匹配结果表分页(只切渲染窗口,不加选择语义)
const {
page: matchedPage,
pageSize: matchedPageSize,
total: matchedTotal,
paged: pagedMatchedItems,
} = useTablePaging(matchedItems)
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>>({})
/** 任务快照的 task 时间(开始=创建,结束=完成;运行中结束显示进行中) */
function snapTask(item: PriceTrackHistoryItem) {
return item.taskId ? taskSnapshots.value[item.taskId]?.task : undefined
}
const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false)
let disposed = false
const timers = createCategorizedTimers('price-track')
function sleep(ms: number) {
if (disposed) return Promise.resolve()
return timers.sleep('queue-wait', ms)
}
function clearSleepTimers() {
timers.clearCategory('queue-wait')
}
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)
return
}
const next = JSON.stringify(value)
// 内容未变则跳过写入:本函数在每轮轮询里对整份快照调用(可达数百 KB),
// 同步 setItem 会阻塞主线程造成周期性卡顿;相同内容重复写盘没有意义
if (window.localStorage.getItem(key) === next) return
window.localStorage.setItem(key, next)
} 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 */
}
}
/** 快照落盘最小间隔:快照可达数百 KB,全量 JSON.stringify + setItem 会阻塞主线程 */
const SNAPSHOT_SAVE_MIN_INTERVAL_MS = 30000
let lastSnapshotSaveAt = 0
let snapshotSavePending = false
/**
* 快照写盘(节流)。
*
* 每轮轮询都序列化整份快照会周期性卡顿主线程,因此:
* - 内容未变时 setStorageJson 内部已会跳过写入;
* - 这里再按 SNAPSHOT_SAVE_MIN_INTERVAL_MS 节流,被节流掉的写入挂起,
* 下一次(或 force / 组件卸载前的 flush)落盘时一并写入最新内容。
*/
function saveTaskSnapshotsToStorage(force = false) {
if (typeof window === 'undefined') return
const now = Date.now()
if (!force && lastSnapshotSaveAt > 0 && now - lastSnapshotSaveAt < SNAPSHOT_SAVE_MIN_INTERVAL_MS) {
snapshotSavePending = true
return
}
snapshotSavePending = false
lastSnapshotSaveAt = now
setStorageJson(taskSnapshotsStorageKey(), taskSnapshots.value, Object.keys(taskSnapshots.value).length === 0)
}
/** 卸载前把节流挂起的快照补写一次,避免最后一次变更丢失 */
function flushTaskSnapshotsToStorage() {
if (!snapshotSavePending) return
saveTaskSnapshotsToStorage(true)
}
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()
let paths: string[] = []
if (api?.select_files) {
const result = await api.select_files({
filters: [{ name: 'Excel/CSV', extensions: 'xlsx,xls,csv' }],
multiple: true,
})
if (!result?.paths?.length) return
if (!(await passGuard(checkSelectedFiles(result.paths, { allowedExtensions: EXCEL_CSV_EXTENSIONS })))) return
paths = result.paths
} else if (api?.select_brand_xlsx_files) {
const selected = await api.select_brand_xlsx_files()
if (!selected?.length) return
if (!(await passGuard(checkSelectedFiles(selected, { allowedExtensions: EXCEL_CSV_EXTENSIONS })))) return
paths = selected
} else {
ElMessage.warning('当前环境不支持文件选择,请在本地客户端中打开')
return
}
try {
asinFiles.value = paths
asinUploadedFiles.value = await uploadPathsToJava(getPywebviewApi(), paths, { returnEmptyWhenUnavailable: true })
ElMessage.success(`已选择 ${paths.length} 个ASIN文件`)
} catch (error) {
// 上传失败时清空选择,避免本地路径残留被当作服务器路径提交给 Java
asinFiles.value = []
asinUploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
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
}
if (!(await passGuard(checkSelectedFiles(result.items, { allowedExtensions: EXCEL_CSV_EXTENSIONS })))) return
asinFiles.value = result.items.map((item) => item.relativePath || item.absolutePath)
asinUploadedFiles.value = await uploadPathsToJava(getPywebviewApi(), result.items, { returnEmptyWhenUnavailable: true })
ElMessage.success(`已选择文件夹内 ${result.items.length} 个ASIN文件`)
} catch (error) {
// 上传失败时清空选择,避免残留上一批文件让用户误以为新文件已就绪
asinFiles.value = []
asinUploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
// ========== 国家顺序相关 ==========
function resolveAsinRequestPaths() {
// 提交上传返回的 fileKey(与去重/转换/采集等模块一致):服务端按 key 反查上传临时目录,
// 不依赖上传返回的 localPath 绝对路径形态——安全加固后服务端按拼接解析绝对路径会失败。
// 仅上传成功(上传接口返回 data)的文件才进入列表,未上传成功不兜底本地路径(服务器无法读取)。
return asinUploadedFiles.value.map((item) => item.fileKey).filter((path) => !!path)
}
function resolveCountryCodesForRequest() {
return asinModeEnabled.value ? COUNTRY_OPTIONS.map((item) => item.code) : [...orderedCountryCodes.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) timers.clearTimer('preference-save', countryPrefSaveTimer)
countryPrefSaveTimer = timers.setTimeout('preference-save', () => {
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: resolveCountryCodesForRequest(),
})
const batch = res.items || []
// 移除:不再使用后端返回的 ASIN 数据
// matchAsinRowsByCountry.value = res.asinRowsByCountry || {}
matchAsinRowsByCountry.value = {}
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 '已关联店铺,请查看状态确认'
if (row.matchStatus === 'CONFLICT') return '存在多个同名店铺,请人工确认'
if (row.matchStatus === 'PENDING') return '店铺尚未匹配完成,请稍后查看'
return '未匹配成功,请检查店铺名'
}
function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | null }) {
return `${(row.shopName || '').trim()}\u0001${row.shopId ?? ''}`
}
function priceTrackModeForAppClient() {
return statusModeEnabled.value ? 'status' : 'asin'
}
function buildMinimumPriceByCountryAndAsin(rowsByCountry: Record<string, PriceTrackAsinParsedRow[]>) {
const out: Record<string, Record<string, string>> = {}
for (const [country, rows] of Object.entries(rowsByCountry || {})) {
const bucket: Record<string, string> = {}
for (const row of rows || []) {
const asin = (row.asin || '').trim()
if (!asin) continue
bucket[asin] = row.minimumPrice == null ? '' : String(row.minimumPrice)
}
out[country] = bucket
}
return out
}
function hasCountryRows(rowsByCountry: Record<string, PriceTrackAsinParsedRow[]> | undefined) {
return Object.values(rowsByCountry || {}).some((rows) => Array.isArray(rows) && rows.length > 0)
}
function hasMinimumPriceMap(map: Record<string, Record<string, string>> | undefined) {
return Object.values(map || {}).some((rows) => rows && Object.keys(rows).length > 0)
}
function buildMinimumPriceMapForAppClient(taskVo: PriceTrackCreateTaskVo, asinRowsByCountry: Record<string, PriceTrackAsinParsedRow[]>) {
return hasMinimumPriceMap(taskVo.minimumPriceByCountryAndAsin)
? taskVo.minimumPriceByCountryAndAsin || {}
: buildMinimumPriceByCountryAndAsin(asinRowsByCountry)
}
async function loadAsinRowsForAppClient(taskVo: PriceTrackCreateTaskVo) {
const fromTask = taskVo.asinRowsByCountry || {}
if (hasCountryRows(fromTask)) {
return fromTask
}
if (!asinModeEnabled.value) {
return {}
}
const merged: Record<string, PriceTrackAsinParsedRow[]> = {}
let page = 1
let totalPages = 1
do {
const res = await getTaskSkipPriceAsinsPaginated(taskVo.taskId, page, 2000)
totalPages = Math.max(1, Number(res.totalPages) || 1)
const pageRows = (res.asin_rows_by_country || res.skipAsinDetailsByCountry || res.skip_asin_details_by_country || {}) as Record<
string,
PriceTrackAsinParsedRow[]
>
for (const [country, rows] of Object.entries(pageRows)) {
merged[country] = [...(merged[country] || []), ...(rows || [])]
}
page += 1
} while (page <= totalPages)
return merged
}
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 buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQueueItem) {
const shopName = (row.shopName || '').trim()
const asinRowsByCountry = await loadAsinRowsForAppClient(taskVo)
const minimumPriceByCountryAndAsin = buildMinimumPriceMapForAppClient(taskVo, asinRowsByCountry)
return {
type: 'price-track-run',
ts: Date.now(),
data: {
task_id: taskVo.taskId,
ziniao_version: ziniaoVersion.value,
shop_name: shopName,
country_codes: resolveCountryCodesForRequest(),
mode: priceTrackModeForAppClient(),
shopName,
companyName: row.companyName || '',
shopMallName: row.shopMallName || '',
asin_rows_by_country: asinRowsByCountry,
minimum_price_by_country_and_asin: minimumPriceByCountryAndAsin,
},
}
}
async function enqueueCreatedTask(api: PywebviewApi, taskId: number, queuePayload: unknown) {
if (!api.enqueue_json) throw new Error('当前环境未启用 pywebview enqueue_json')
// 三条推送路径(手动/串行队列/循环任务)都走这里,闸设在这一处即可全覆盖。
// 空 country_codes 会让 Python 端没有可遍历的站点,任务永远停在 RUNNING。
// asin_rows_by_country 只在 asin 模式下要求非空——status 模式下
// loadAsinRowsForAppClient 本就返回 {},那是正常的。
const mode = (queuePayload as { data?: { mode?: unknown } } | null)?.data?.mode
const guard = checkQueuePayload(queuePayload, {
expectedType: 'price-track-run',
requiredDataKeys: ['task_id', 'shop_name'],
nonEmptyArrayKeys: ['country_codes'],
nonEmptyObjectKeys: mode === 'asin' ? ['asin_rows_by_country'] : [],
})
if (!(await passGuard(guard))) {
const reason = `任务 ${taskId} 数据校验未通过,已阻止启动任务`
await compensateDispatchFailure(taskId, reason)
throw new Error(reason)
}
let pushResult: Awaited<ReturnType<NonNullable<PywebviewApi['enqueue_json']>>>
try {
pushResult = await api.enqueue_json(queuePayload)
} catch (error) {
const reason = `任务队列调用异常:${error instanceof Error ? error.message : String(error || '未知错误')}`
await compensateDispatchFailure(taskId, reason)
throw new Error(reason)
}
if (!pushResult?.success) {
const reason = pushResult?.error || '任务队列拒绝接收任务'
await compensateDispatchFailure(taskId, reason)
throw new Error(reason)
}
return pushResult
}
async function compensateDispatchFailure(taskId: number, reason: string) {
try {
await markPriceTrackDispatchFailed(taskId, reason)
removePollingTask(taskId)
taskDetails.value = { ...taskDetails.value, [taskId]: 'FAILED' }
saveTaskDetailsToStorage()
await loadHistory()
await loadDashboard()
syncPollingIdsWithHistory()
if (activeLoopRunId.value) await syncActiveLoopRun()
} catch (error) {
addPollingTask(taskId)
scheduleNextPoll(true)
const syncError = error instanceof Error ? error.message : String(error || '未知错误')
throw new Error(`${reason};后端失败状态同步失败:${syncError}`)
}
}
async function waitForTaskTerminal(taskId: number) {
let transientErrorCount = 0
const maxTransientErrors = 30
while (true) {
if (disposed) return 'STOPPED'
try {
// F5:等待终态的紧循环只读 status(轻量白名单覆盖),走 light 端点减小轮询响应体,异常回退重型 batch
const batch = await getPollingProgressBatch('priceTrack', [taskId], {
fallback: () => 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 sleep(getPollIntervalMs())
continue
}
await sleep(getPollIntervalMs())
}
}
function nextMatchedQueueItem() {
return matchedItems.value.find((item) => item.matched)
}
async function processMatchedQueue() {
if (disposed) return
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 (!disposed && autoQueueEnabled.value) {
const row = nextMatchedQueueItem()
if (!row) {
break
}
index += 1
try {
let attempt = 0
const taskVo = await (async () => {
while (true) {
if (disposed) throw new Error('component disposed')
try {
return await createPriceTrackTask({
userId: 0,
statusMode: statusModeEnabled.value,
asinMode: asinModeEnabled.value,
items: [row] as unknown as Record<string, unknown>[],
asinFiles: resolveAsinRequestPaths(),
countryCodes: resolveCountryCodesForRequest(),
})
} 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 sleep(getPollIntervalMs())
}
}
})()
taskSnapshots.value = {
...taskSnapshots.value,
[taskVo.taskId]: {
task: { id: taskVo.taskId, status: 'RUNNING' },
items: taskVo.items,
},
}
taskDetails.value = {
...taskDetails.value,
[taskVo.taskId]: 'RUNNING',
}
saveTaskSnapshotsToStorage(true)
saveTaskDetailsToStorage()
const queuePayload = await buildQueuePayload(taskVo, row)
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
await enqueueCreatedTask(api, taskVo.taskId, queuePayload)
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) {
if (disposed) break
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) {
if (disposed) return
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(true)
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 (disposed) return
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 (!disposed && 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) {
if (disposed) throw new Error('component disposed')
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 sleep(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) {
if (disposed) throw new Error('component disposed')
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 sleep(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) {
if (disposed) throw new Error('component disposed')
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 sleep(getPollIntervalMs())
}
}
})()
recordCreatedTask(taskVo)
const queuePayload = await buildQueuePayload(taskVo, row)
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
await enqueueCreatedTask(api, taskVo.taskId, queuePayload)
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: resolveCountryCodesForRequest(),
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()
}
/** 轮询可比对的轻量字段(与 /tasks/progress/light 白名单一致,外加只有重型端点才带的起止时间) */
function taskProgressSignature(task?: PriceTrackTaskDetailVo['task'] | null) {
if (!task) return ''
return [
task.status ?? '',
task.createdAt ?? '',
task.finishedAt ?? '',
task.updatedAt ?? '',
].join('|')
}
async function refreshTaskBatch() {
const ids = pollingTaskIds.value.filter((id) => id > 0)
if (!ids.length) return
try {
// F5:主轮询只读 status/fileReady 等轻量白名单字段,走 light 端点减小轮询响应体;
// 轻量端点异常或返回空结果时适配器自动回退重型 batch(否则会漏刷新结果)
const batch = await getPollingProgressBatch('priceTrack', ids, {
fallback: () => getPriceTrackTaskProgressBatch(ids),
})
let settled = false
let snapshotsChanged = false
const nextSnapshots = { ...taskSnapshots.value }
for (const missingId of batch.missingTaskIds || []) {
console.log(`[price-track] 任务 ${missingId} 在服务端已不存在,停止轮询`)
removePollingTask(missingId)
delete nextSnapshots[missingId]
snapshotsChanged = true
}
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]
const merged: PriceTrackTaskDetailVo = prev
? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } }
: detail
// 只有轮询字段真的变了才重写快照:整份快照数百 KB,无变化时重建对象 + 落盘纯属浪费
if (!prev || taskProgressSignature(prev.task) !== taskProgressSignature(merged.task)) {
nextSnapshots[taskId] = merged
snapshotsChanged = true
}
if (status) {
taskDetails.value[taskId] = status
if (status === 'SUCCESS' || status === 'FAILED') {
console.log(`[price-track] 任务 ${taskId} 已到终态 ${status},移出轮询集合并刷新列表`)
removePollingTask(taskId)
settled = true
}
}
}
if (snapshotsChanged) {
taskSnapshots.value = nextSnapshots
saveTaskSnapshotsToStorage()
}
saveTaskDetailsToStorage()
// 历史列表只在任务落定(或快照真变化)时拉取:此前每 10s 无条件重查一次,纯属浪费
if (settled || snapshotsChanged) {
await loadHistory()
syncPollingIdsWithHistory()
}
if (settled) {
await loadDashboard()
void resumeLoopExecutionIfNeeded()
}
} catch {
/* polling noise */
}
}
async function resumeLoopExecutionIfNeeded() {
if (disposed || loopDispatching.value || !activeLoopRunId.value) return
try {
const loop = await syncActiveLoopRun()
if (!loop || loop.status === 'SUCCESS' || loop.status === 'FAILED' || loop.status === 'STOPPED') return
await runLoopExecution(loop.id)
} catch {
/* loop recovery noise */
}
}
function scheduleNextPoll(immediate = false) {
if (disposed) return
if (pollTimer.value) {
if (!immediate) return
timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null
}
const run = async () => {
pollTimer.value = null
if (disposed) return
if (pollingInFlight.value) { scheduleNextPoll(); return }
if (!pollingTaskIds.value.length) return
pollingInFlight.value = true
try { await refreshTaskBatch() } finally { pollingInFlight.value = false }
if (!disposed && pollingTaskIds.value.length > 0) pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs())
}
if (immediate) void run()
else pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs())
}
function stopPolling() {
if (pollTimer.value) { timers.clearTimer('task-poll', 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(true)
}
}
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))
)
/** 统一统计卡:运行中任务按页面当前列表实时计算,其余沿用后端 dashboard */
const priceTrackCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
])
const currentTaskViews = computed<TaskItemView[]>(() => currentSectionItems.value.map(toPriceTrackTaskView))
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toPriceTrackTaskView))
function itemSource(item: TaskItemView) {
return item.source as PriceTrackHistoryItem
}
function toPriceTrackTaskView(item: PriceTrackHistoryItem): TaskItemView {
const task = snapTask(item)
return {
key: `follow-${item.resultId ?? `t${item.taskId ?? ''}`}-${item.shopName ?? ''}`,
title: item.shopName || '-',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(task?.createdAt),
finishedAt: formatDateTime(task?.finishedAt),
statusText: statusText(item),
statusClass: statusClass(item),
extraLines: [
...(item.roundIndex ? [`轮次:第 ${item.roundIndex} 轮`] : []),
...(item.platform ? [`平台:${item.platform}`] : []),
...(item.outputFilename ? [`文件:${item.outputFilename}`] : []),
...(item.error ? [`错误:${item.error}`] : []),
],
source: item,
}
}
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 || (!item.fileReady && !item.downloadUrl)) return false
const st = resolvedTaskStatus(item)
// 失败任务也可能有已跑出来的部分结果文件(后端按已收分片组装),有文件就允许下载
return st === 'SUCCESS' || item.success === true || st === 'FAILED'
}
async function downloadResult(item: PriceTrackHistoryItem) {
if (!item.resultId) return
const filename = item.outputFilename || `${item.shopName || 'result'}.xlsx`
const url = getPriceTrackResultDownloadUrl(item.resultId)
const result = await saveUrlWithProgress(url, filename, `price-track:${item.resultId}`)
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(() => {
disposed = true
stopPolling()
clearSleepTimers()
timers.clearScope()
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
// 补写被节流挂起的快照,避免最后一次变更丢失
flushTaskSnapshotsToStorage()
})
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
const { total, failed } = await runBatchDelete(views, (view) => deleteTaskRecord(itemSource(view)))
if (!total) return
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${total} 条历史记录`)
}
}
</script>
<style scoped>
.module-page {
min-height: 100vh;
background: #242424;
}
.main-content {
display: flex;
min-height: calc(100vh - 56px);
height: calc(100vh - 56px);
}
.left-panel {
width: 400px;
background: #242424;
padding: 20px;
overflow-y: auto;
border-right: 1px solid #2e3a52;
}
.price-track-shop-candidates {
flex: 0 0 auto;
}
.right-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: #242424;
}
.section-title {
font-size: 13px;
color: #a0acbe;
margin-bottom: 10px;
}
.upload-zone {
border: 1px dashed #3e4a62;
border-radius: 10px;
padding: 16px;
background: #242424;
margin-bottom: 20px;
}
.hint {
color: #5e6878;
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: #5e6878;
font-size: 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.selected-files span:not(.more-line) {
color: #a0acbe;
}
.more-line {
color: #5e6878;
font-style: italic;
}
.text-muted {
color: #5e6878;
font-style: italic;
}
.mode-zone {
border: 1px dashed #3e4a62;
border-radius: 10px;
padding: 14px 16px;
background: #242424;
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: #c8d2e2;
font-weight: 600;
}
.mode-hint-inline {
color: #5e6878;
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: #242424;
box-shadow: 0 0 0 1px #3e4a62 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: #a0acbe;
}
.input-zone {
border: 1px dashed #3e4a62;
border-radius: 10px;
padding: 16px;
background: #242424;
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: #c8d2e2;
background: #242424;
border: 1px solid #3e4a62;
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: #c8d2e2;
}
.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: #242424;
margin-bottom: 14px;
}
.country-order-caption {
font-size: 11px;
color: #5e6878;
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 #3e4a62;
cursor: grab;
user-select: none;
}
.country-drag-row.dragging {
opacity: 0.55;
border-color: #3498db;
}
.drag-handle {
color: #5e6878;
font-size: 12px;
letter-spacing: -2px;
}
.country-drag-label {
font-size: 12px;
color: #c8d2e2;
}
.country-pref-status {
font-size: 11px;
color: #5e6878;
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: #242424;
box-shadow: 0 0 0 1px #3e4a62 inset;
}
.listing-filter-row :deep(.el-select__placeholder),
.listing-filter-row :deep(.el-select__selected-item) {
color: #c8d2e2;
}
.empty-candidates {
color: #5e6878;
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 #2e3a52;
}
.candidate-table {
--el-table-bg-color: #242424;
--el-table-tr-bg-color: #242424;
--el-table-header-bg-color: #2e3a52;
--el-table-text-color: #c8d2e2;
--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: #f5f8fc;
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: #5e6878;
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 #2e3a52;
border-radius: 10px;
background: #222b3d;
}
.queue-debug-title {
margin-bottom: 8px;
}
.queue-debug-line {
color: #a0acbe;
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: #10141f;
color: #8fd3ff;
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
/* Right panel */
.subsection-title {
font-size: 13px;
color: #a0acbe;
margin: 8px 0 10px;
}
/* 匹配结果空态(#cards-extra 区块内使用) */
.match-empty {
color: #5e6878;
font-size: 13px;
padding: 12px 8px;
margin-bottom: 12px;
text-align: center;
}
.match-table {
margin-bottom: 18px;
}
.result-table {
--el-table-bg-color: #242424;
--el-table-tr-bg-color: #242424;
--el-table-header-bg-color: #2e3a52;
--el-table-text-color: #c8d2e2;
--el-table-border-color: #333;
}
.ok { color: #27ae60; }
.fail { color: #e67e22; }
.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 #2e3a52;
}
}
</style>