9d39705c77
taskId 28599 事故根因:分片回传在任务锁内同步做品牌检测,上游 16890 卡死时 单次持锁 103.5 秒(costMs=103556),期间该任务的心跳与客户端重试全部撞 40902「任务正在处理」,客户端 5 次重试预算耗尽后中止了整个采集。 - submitResult:行归一化 / 去重过滤 / 品牌检测移到任务锁外,锁内只保留落库与终态 - BrandCheckClient:整批加 90 秒总耗时上限(超时按查询失败降级,首轮始终执行), 单次回传时延从「无上界」收敛到约 90 秒 + 一次读超时 - 失败且已收到分片时照常组装部分结果;任务已 FAILED 则保留失败态与真实失败原因, 只补结果文件(乐观写结果行 + 条件更新不匹配再补偿写回,避免崩溃时留下「成功但无下载」) - productrisk / shopmatch 按 pricetrack 的 preserveFailure 样板补齐,含组装落地时 不再把失败行「洗成成功」 - 前端:采集 / 跟价 / 产品风险 / 店铺匹配的下载按钮放宽到失败任务
2315 lines
75 KiB
Vue
2315 lines
75 KiB
Vue
<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">商品状态模式:将在售(Active)ASIN纳入跟价,跳过ASIN由后台配置的店铺维度数据提供</span>
|
||
</div>
|
||
|
||
<div class="run-row">
|
||
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">
|
||
{{ matching ? '匹配中…' : '匹配店铺' }}
|
||
</button>
|
||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !hasValidMode" @click="pushToPythonLoopQueue">
|
||
{{ pushing ? '启动中…' : '启动任务' }}
|
||
</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>
|
||
|