Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue
2026-07-19 22:23:26 +08:00

2429 lines
74 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">
<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>
<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 ? '推送中' : '推送到 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 ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
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 { useZiniaoVersion } from '@/shared/utils/ziniao-version'
import {
addPriceTrackCandidate,
completePriceTrackLoopChild,
createPriceTrackTask,
createPriceTrackLoopRun,
dispatchNextPriceTrackLoopRun,
deletePendingPriceTrackShopResult,
deletePriceTrackCandidate,
deletePriceTrackHistory,
deletePriceTrackTask,
getPriceTrackCountryPreference,
getPriceTrackDashboard,
getPriceTrackHistory,
getPriceTrackLoopRun,
getPriceTrackResultDownloadUrl,
getPriceTrackTaskProgressBatch,
getPriceTrackTasksBatch,
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'
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[]>([])
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)
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)
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 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 '已关联索引,请结合状态列查看是否可推送'
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 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 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: resolveCountryCodesForRequest(),
}
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()
const firstRow = matchedRows[0]
const asinRowsByCountry = await loadAsinRowsForAppClient(taskVo)
const minimumPriceByCountryAndAsin = buildMinimumPriceMapForAppClient(taskVo, asinRowsByCountry)
// 2. 推送 app_client 当前消费的字段
const queuePayload = {
type: 'price-track-run',
ts: Date.now(),
data: {
task_id: taskVo.taskId,
ziniao_version: ziniaoVersion.value,
shop_name: (firstRow.shopName || '').trim(),
shopName: (firstRow.shopName || '').trim(),
companyName: firstRow.companyName || '',
shopMallName: firstRow.shopMallName || '',
country_codes: resolveCountryCodesForRequest(),
mode: priceTrackModeForAppClient(),
asin_rows_by_country: asinRowsByCountry,
minimum_price_by_country_and_asin: minimumPriceByCountryAndAsin,
},
}
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
await enqueueCreatedTask(api, taskVo.taskId, queuePayload)
queuePushResult.value = `任务 ${taskVo.taskId} 已入队,等待执行完成...`
addPollingTask(taskVo.taskId)
scheduleNextPoll(true)
ElMessage.success('已推送到 Python 队列')
} catch (e) {
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
ElMessage.error(queuePushResult.value)
} finally {
pushing.value = false
}
}
// ========== 任务状态轮询 ==========
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')
let pushResult: Awaited<ReturnType<NonNullable<PywebviewApi['enqueue_json']>>>
try {
pushResult = await api.enqueue_json(queuePayload)
} catch (error) {
const reason = `Python 队列调用异常:${error instanceof Error ? error.message : String(error || '未知错误')}`
await compensateDispatchFailure(taskId, reason)
throw new Error(reason)
}
if (!pushResult?.success) {
const reason = pushResult?.error || 'Python 队列拒绝接收任务'
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 {
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 sleep(getPollIntervalMs())
continue
}
await sleep(getPollIntervalMs())
}
}
async function pushToPythonQueue() {
autoQueueEnabled.value = true
await processMatchedQueue()
}
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()
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()
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()
}
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()
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()
}
}
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 || (!item.fileReady && !item.downloadUrl)) return false
const st = resolvedTaskStatus(item)
return st === 'SUCCESS' || item.success === true
}
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)
})
</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>