832 lines
33 KiB
Vue
832 lines
33 KiB
Vue
<template>
|
||
<div class="page-shell module-page">
|
||
<BrandTopBar active="collect-data" />
|
||
|
||
<div class="main-content">
|
||
<aside class="left-panel left-panel--with-template">
|
||
<div class="section-title">上传文件</div>
|
||
<div class="upload-zone">
|
||
<div class="hint">选择需要采集的 Excel 文件或文件夹(默认根目录:采集前数据.xlsx),后续将按下方筛选条件交给 Python 端进行数据采集。</div>
|
||
<div class="btns">
|
||
<button type="button" class="opt-btn" @click="selectFiles">选择 Excel</button>
|
||
<button type="button" class="opt-btn" @click="selectFolder">选择文件夹</button>
|
||
</div>
|
||
<div class="selected-files">
|
||
<template v-if="selectedFileNames.length">
|
||
<span v-for="name in displayFileNames" :key="name">{{ name }}</span>
|
||
<span v-if="selectedFileNames.length > displayFileNames.length" class="more-line">
|
||
还有 {{ selectedFileNames.length - displayFileNames.length }} 个文件未展开显示
|
||
</span>
|
||
</template>
|
||
<span v-else>暂未选择文件</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section-title">筛选条件</div>
|
||
<div class="filter-zone">
|
||
<div class="filter-row">
|
||
<label class="filter-label">金额区间</label>
|
||
<div class="range-inputs">
|
||
<input
|
||
v-model.number="filters.minAmount"
|
||
type="number"
|
||
step="0.01"
|
||
min="0"
|
||
class="filter-input"
|
||
placeholder="最低"
|
||
/>
|
||
<span class="range-separator">至</span>
|
||
<input
|
||
v-model.number="filters.maxAmount"
|
||
type="number"
|
||
step="0.01"
|
||
min="0"
|
||
class="filter-input"
|
||
placeholder="最高"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="filter-checks">
|
||
<label class="filter-check-row">
|
||
<input type="checkbox" class="filter-check-input" v-model="filters.rank" />
|
||
<span class="filter-check-text">排名</span>
|
||
</label>
|
||
<label class="filter-check-row">
|
||
<input type="checkbox" class="filter-check-input" v-model="filters.fba" />
|
||
<span class="filter-check-text">FBA</span>
|
||
</label>
|
||
<label class="filter-check-row">
|
||
<input type="checkbox" class="filter-check-input" v-model="filters.fbm" />
|
||
<span class="filter-check-text">FBM</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section-title country-section-title">
|
||
<span>采集国家</span>
|
||
<span v-if="countryPrefSaving" class="country-pref-saving">保存中…</span>
|
||
</div>
|
||
<p class="hint country-pref-hint">
|
||
每次任务只采集一个国家,选择后会自动保存,下次进入默认使用该国家。
|
||
</p>
|
||
<div class="country-pref-radios">
|
||
<label
|
||
v-for="row in COUNTRY_OPTIONS"
|
||
:key="row.code"
|
||
class="country-check-row"
|
||
:class="{ selected: selectedCountryCode === row.code }"
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="collect-data-country"
|
||
class="country-check-input"
|
||
:checked="selectedCountryCode === row.code"
|
||
@change="selectCountry(row.code)"
|
||
/>
|
||
<span class="country-check-text">{{ row.label }}({{ row.code }})</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="run-row">
|
||
<button
|
||
type="button"
|
||
class="btn-run"
|
||
:disabled="submitting || !uploadedFiles.length"
|
||
@click="submitCollect"
|
||
>
|
||
{{ submitting ? '提交中...' : '提交采集任务' }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn-run btn-queue"
|
||
:disabled="pushing || !lastTaskId"
|
||
@click="pushToPythonQueue"
|
||
>
|
||
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
|
||
</button>
|
||
</div>
|
||
<p class="loading-msg">提交后由 Java 解析 Excel 并落库为待采集任务(PENDING);推送到 Python 队列将激活任务(RUNNING),Python 端按 50 条/页拉取明细执行采集。</p>
|
||
|
||
<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="collect-data" filename="数据采集 文档格式.xlsx" />
|
||
</aside>
|
||
|
||
<section class="right-panel">
|
||
<div class="panel-header">采集数据</div>
|
||
<div class="task-list-wrap">
|
||
<div class="clean-result-summary">
|
||
<div class="summary-card">
|
||
<span class="summary-label">运行中任务</span>
|
||
<strong>{{ dashboard.pendingTaskCount }}</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 class="result-list-wrap">
|
||
<div class="result-list-header">
|
||
<span>当前任务</span>
|
||
</div>
|
||
<div v-if="!currentItems.length" class="empty-tasks">暂无当前任务</div>
|
||
<ul v-else class="task-list clean-result-list">
|
||
<li v-for="item in currentItems" :key="`cur-${item.taskId}`" class="task-item">
|
||
<div class="left">
|
||
<span class="id">{{ item.sourceFilename || '采集数据' }}</span>
|
||
<div class="files">任务 ID:{{ item.taskId }}</div>
|
||
<div class="files">开始时间:{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
|
||
<div class="files">行数:{{ item.rowCount ?? '-' }}</div>
|
||
<div class="files">总数据去重过滤:{{ item.dedupeFilteredCount ?? 0 }}</div>
|
||
<div class="files">不符合 ASIN 过滤:{{ item.invalidFilteredCount ?? 0 }}</div>
|
||
<div v-if="taskKeywordProgress(item)" class="files">{{ taskKeywordProgress(item) }}</div>
|
||
<div v-if="taskStageProgress(item)" class="files">{{ taskStageProgress(item) }}</div>
|
||
<div class="task-progress">
|
||
<div class="task-progress-meta"><span>关键词进度</span><span>{{ taskProgressPercent(item) }}%</span></div>
|
||
<div class="task-progress-track"><div class="task-progress-fill" :style="{ width: `${taskProgressPercent(item)}%` }"></div></div>
|
||
</div>
|
||
</div>
|
||
<div class="task-right">
|
||
<span class="status running">
|
||
{{ statusText(item) }}
|
||
<span v-if="taskFilterSummary(item)" class="status-filter-summary">({{ taskFilterSummary(item) }})</span>
|
||
</span>
|
||
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="result-list-wrap">
|
||
<div class="result-list-header">
|
||
<span>历史记录</span>
|
||
</div>
|
||
<div v-if="!historyItems.length" class="empty-tasks">暂无历史记录</div>
|
||
<ul v-else class="task-list clean-result-list">
|
||
<li v-for="item in historyItems" :key="`his-${item.resultId}-${item.taskId}`" class="task-item">
|
||
<div class="left">
|
||
<span class="id">{{ item.sourceFilename || '采集数据' }}</span>
|
||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
||
<div class="files">开始时间:{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
|
||
<div class="files">结束时间:{{ formatDateTime(item.finishedAt) }}</div>
|
||
<div class="files">总数据去重过滤:{{ item.dedupeFilteredCount ?? 0 }}</div>
|
||
<div class="files">不符合 ASIN 过滤:{{ item.invalidFilteredCount ?? 0 }}</div>
|
||
<div v-if="item.resultFilename" class="files">{{ item.resultFilename }}</div>
|
||
<div v-if="item.error" class="files">错误:{{ item.error }}</div>
|
||
<div class="task-progress">
|
||
<div class="task-progress-meta"><span>关键词进度</span><span>{{ taskProgressPercent(item) }}%</span></div>
|
||
<div class="task-progress-track"><div class="task-progress-fill" :style="{ width: `${taskProgressPercent(item)}%` }"></div></div>
|
||
</div>
|
||
</div>
|
||
<div class="task-right">
|
||
<span class="status" :class="statusClass(item)">
|
||
{{ statusText(item) }}
|
||
<span v-if="taskFilterSummary(item)" class="status-filter-summary">({{ taskFilterSummary(item) }})</span>
|
||
</span>
|
||
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">下载</button>
|
||
<button v-if="item.resultId" type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
||
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
|
||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||
import { useTaskProgressLoop } from '@/shared/composables/useTaskProgressLoop'
|
||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||
import {
|
||
parseCollectData,
|
||
activateCollectDataTask,
|
||
getCollectDataDashboard,
|
||
getCollectDataHistory,
|
||
getCollectDataCountryPreference,
|
||
putCollectDataCountryPreference,
|
||
getCollectDataTaskProgressBatch,
|
||
deleteCollectDataTask,
|
||
deleteCollectDataHistory,
|
||
failCollectDataTask,
|
||
type CollectDataDashboardVo,
|
||
type CollectDataHistoryItem,
|
||
type CollectDataParseVo,
|
||
type CollectDataTaskDetailVo,
|
||
type UploadFileVo,
|
||
} from '@/shared/api/java-modules'
|
||
|
||
const COUNTRY_OPTIONS = [
|
||
{ code: 'DE', label: '德国' },
|
||
{ code: 'UK', label: '英国' },
|
||
{ code: 'FR', label: '法国' },
|
||
{ code: 'IT', label: '意大利' },
|
||
{ code: 'ES', label: '西班牙' },
|
||
] as const
|
||
|
||
const selectedFileNames = ref<string[]>([])
|
||
const uploadedFiles = ref<UploadFileVo[]>([])
|
||
const submitting = ref(false)
|
||
const pushing = ref(false)
|
||
|
||
const filters = reactive<{
|
||
minAmount: number | null
|
||
maxAmount: number | null
|
||
rank: boolean
|
||
fba: boolean
|
||
fbm: boolean
|
||
}>({
|
||
minAmount: null,
|
||
maxAmount: null,
|
||
rank: false,
|
||
fba: false,
|
||
fbm: false,
|
||
})
|
||
|
||
const selectedCountryCode = ref<string>('DE')
|
||
const selectedCountryCodes = computed(() => selectedCountryCode.value ? [selectedCountryCode.value] : [])
|
||
/** 防止接口慢回包覆盖用户已经做出的修改 */
|
||
const countryPrefUserTouched = ref(false)
|
||
const countryPrefSaving = ref(false)
|
||
const timers = createCategorizedTimers('collect-data-tab')
|
||
let countryPrefSaveTimer: number | null = null
|
||
|
||
const lastTaskId = ref<number | null>(null)
|
||
const lastParseVo = ref<CollectDataParseVo | null>(null)
|
||
const queuePushResult = ref('')
|
||
const queuePayloadText = ref('')
|
||
|
||
const dashboard = ref<CollectDataDashboardVo>({
|
||
pendingTaskCount: 0,
|
||
processedTaskCount: 0,
|
||
successTaskCount: 0,
|
||
failedTaskCount: 0,
|
||
})
|
||
const currentItems = ref<CollectDataHistoryItem[]>([])
|
||
const historyItems = ref<CollectDataHistoryItem[]>([])
|
||
/** 轮询过程中收到的任务最新快照,按 taskId 缓存,用于历史返回前的占位展示 */
|
||
const taskSnapshots = ref<Record<number, CollectDataTaskDetailVo>>({})
|
||
|
||
const POLLING_STORAGE_KEY = 'brand:collect-data:polling-task-ids'
|
||
|
||
const progressLoop = useTaskProgressLoop<CollectDataTaskDetailVo>({
|
||
scope: 'collect-data-tab',
|
||
storageKey: POLLING_STORAGE_KEY,
|
||
fetchProgress: (ids) => getCollectDataTaskProgressBatch(ids),
|
||
extractTaskId: (detail) => detail.task?.id ?? null,
|
||
extractStatus: (detail) => detail.task?.status ?? '',
|
||
onUpdate: (taskId, detail) => {
|
||
const prev = taskSnapshots.value[taskId]
|
||
taskSnapshots.value = {
|
||
...taskSnapshots.value,
|
||
[taskId]: prev
|
||
? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) }, items: detail.items ?? prev.items }
|
||
: detail,
|
||
}
|
||
const latestItem = detail.items?.[0]
|
||
currentItems.value = currentItems.value.map((item) => item.taskId === taskId
|
||
? { ...item, ...(latestItem || {}), taskStatus: detail.task?.status ?? latestItem?.taskStatus ?? item.taskStatus }
|
||
: item)
|
||
},
|
||
onTerminal: async (taskId) => {
|
||
if (taskSnapshots.value[taskId]) {
|
||
const next = { ...taskSnapshots.value }
|
||
delete next[taskId]
|
||
taskSnapshots.value = next
|
||
}
|
||
await Promise.all([loadDashboard(), loadHistory()])
|
||
},
|
||
})
|
||
|
||
const displayFileNames = computed(() => selectedFileNames.value.slice(0, 8))
|
||
|
||
function countryLabel(code: string) {
|
||
const row = COUNTRY_OPTIONS.find((o) => o.code === code)
|
||
return row?.label ?? code
|
||
}
|
||
|
||
function selectCountry(code: string) {
|
||
if (!COUNTRY_OPTIONS.some((row) => row.code === code)) return
|
||
if (selectedCountryCode.value === code) return
|
||
countryPrefUserTouched.value = true
|
||
selectedCountryCode.value = code
|
||
scheduleSaveCountryPref()
|
||
}
|
||
|
||
function scheduleSaveCountryPref() {
|
||
if (countryPrefSaveTimer != null) {
|
||
timers.clearTimer('country-pref-save', countryPrefSaveTimer)
|
||
}
|
||
countryPrefSaveTimer = timers.setTimeout('country-pref-save', () => {
|
||
countryPrefSaveTimer = null
|
||
void persistCountryPref()
|
||
}, 450)
|
||
}
|
||
|
||
async function persistCountryPref() {
|
||
if (!selectedCountryCode.value) return
|
||
countryPrefSaving.value = true
|
||
try {
|
||
await putCollectDataCountryPreference([selectedCountryCode.value])
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '保存国家顺序失败')
|
||
} finally {
|
||
countryPrefSaving.value = false
|
||
}
|
||
}
|
||
|
||
async function loadCountryPreference() {
|
||
try {
|
||
const vo = await getCollectDataCountryPreference()
|
||
if (countryPrefUserTouched.value) return
|
||
const codes = vo?.country_codes
|
||
if (Array.isArray(codes) && codes.length) {
|
||
selectedCountryCode.value = String(codes[0] || 'DE')
|
||
}
|
||
} catch {
|
||
/* 接口失败时保留本地默认 */
|
||
}
|
||
}
|
||
|
||
async function uploadPathsToJava(paths: Array<string | BrandExpandFolderItem>) {
|
||
const api = getPywebviewApi()
|
||
if (!api?.upload_file_to_java) {
|
||
throw new Error('当前桌面端未提供文件上传能力')
|
||
}
|
||
const files: UploadFileVo[] = []
|
||
for (const item of paths) {
|
||
const filePath = typeof item === 'string' ? item : item.absolutePath
|
||
const relativePath = typeof item === 'string' ? undefined : item.relativePath
|
||
const uploaded = await api.upload_file_to_java(filePath, relativePath)
|
||
if (!uploaded?.success || !uploaded.data) {
|
||
throw new Error(uploaded?.error || uploaded?.message || `上传失败:${filePath}`)
|
||
}
|
||
files.push(uploaded.data)
|
||
}
|
||
return files
|
||
}
|
||
|
||
async function selectFiles() {
|
||
const api = getPywebviewApi()
|
||
if (!api?.select_brand_xlsx_files || !api.upload_file_to_java) {
|
||
ElMessage.warning('当前环境不支持文件选择或上传')
|
||
return
|
||
}
|
||
try {
|
||
const paths = await api.select_brand_xlsx_files()
|
||
if (!paths?.length) return
|
||
const files = await uploadPathsToJava(paths)
|
||
uploadedFiles.value = files
|
||
selectedFileNames.value = files.map((f) => f.relativePath || f.originalFilename || f.fileKey)
|
||
lastTaskId.value = null
|
||
lastParseVo.value = null
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '文件选择失败')
|
||
}
|
||
}
|
||
|
||
async function selectFolder() {
|
||
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
|
||
}
|
||
const files = await uploadPathsToJava(result.items)
|
||
uploadedFiles.value = files
|
||
selectedFileNames.value = result.items.map((item) => item.relativePath || item.absolutePath)
|
||
lastTaskId.value = null
|
||
lastParseVo.value = null
|
||
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 Excel 文件`)
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '选择失败')
|
||
}
|
||
}
|
||
|
||
function buildFiltersPayload() {
|
||
const minAmount = filters.minAmount === null || filters.minAmount === undefined || Number.isNaN(filters.minAmount)
|
||
? null
|
||
: filters.minAmount
|
||
const maxAmount = filters.maxAmount === null || filters.maxAmount === undefined || Number.isNaN(filters.maxAmount)
|
||
? null
|
||
: filters.maxAmount
|
||
return {
|
||
amount: maxAmount ?? minAmount,
|
||
minAmount,
|
||
maxAmount,
|
||
min_amount: minAmount,
|
||
max_amount: maxAmount,
|
||
rank: !!filters.rank,
|
||
fba: !!filters.fba,
|
||
fbm: !!filters.fbm,
|
||
countryCodes: [...selectedCountryCodes.value],
|
||
countryCode: selectedCountryCode.value,
|
||
country_code: selectedCountryCode.value,
|
||
}
|
||
}
|
||
|
||
async function submitCollect() {
|
||
if (!uploadedFiles.value.length) {
|
||
ElMessage.warning('请先选择 Excel 文件')
|
||
return
|
||
}
|
||
if (!selectedCountryCode.value) {
|
||
ElMessage.warning('请选择采集国家')
|
||
return
|
||
}
|
||
submitting.value = true
|
||
try {
|
||
const vo = await parseCollectData({
|
||
files: uploadedFiles.value.map((f) => ({
|
||
fileKey: f.fileKey,
|
||
originalFilename: f.originalFilename,
|
||
relativePath: f.relativePath,
|
||
})),
|
||
task_type: 'collect-data',
|
||
filters: buildFiltersPayload(),
|
||
})
|
||
lastParseVo.value = vo
|
||
lastTaskId.value = vo.taskId ?? null
|
||
ElMessage.success(`提交成功:任务 ${vo.taskNo || vo.taskId},落库 ${vo.acceptedRows ?? 0} 行`)
|
||
await Promise.all([loadDashboard(), loadHistory()])
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '提交失败')
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
async function pushToPythonQueue() {
|
||
if (!lastTaskId.value) {
|
||
ElMessage.warning('请先提交采集任务')
|
||
return
|
||
}
|
||
const api = getPywebviewApi()
|
||
if (!api?.enqueue_json) {
|
||
ElMessage.warning('当前环境未提供 Python 队列能力')
|
||
return
|
||
}
|
||
pushing.value = true
|
||
let activated = false
|
||
try {
|
||
await activateCollectDataTask(lastTaskId.value)
|
||
activated = true
|
||
const payload = {
|
||
type: 'collect-data-run',
|
||
ts: Date.now(),
|
||
data: {
|
||
taskId: lastTaskId.value,
|
||
taskNo: lastParseVo.value?.taskNo,
|
||
taskType: 'collect-data',
|
||
totalRows: lastParseVo.value?.acceptedRows ?? 0,
|
||
pageSize: lastParseVo.value?.pageSize ?? 50,
|
||
filters: buildFiltersPayload(),
|
||
},
|
||
}
|
||
const result = await api.enqueue_json(payload)
|
||
if (!result?.success) {
|
||
throw new Error(result?.error || '入队失败')
|
||
}
|
||
queuePushResult.value = `已推送至 Python 队列(队列长度:${result.queue_size ?? '-'})`
|
||
queuePayloadText.value = JSON.stringify(payload, null, 2)
|
||
if (lastTaskId.value) {
|
||
progressLoop.add(lastTaskId.value)
|
||
}
|
||
ElMessage.success('已推送到 Python 队列')
|
||
await loadDashboard()
|
||
} catch (error) {
|
||
if (activated && lastTaskId.value) {
|
||
try {
|
||
await failCollectDataTask(lastTaskId.value, error instanceof Error ? error.message : 'Python queue dispatch failed')
|
||
} catch {
|
||
/* 保留原始入队错误 */
|
||
}
|
||
}
|
||
ElMessage.error(error instanceof Error ? error.message : '推送失败')
|
||
await Promise.all([loadDashboard(), loadHistory()])
|
||
} finally {
|
||
pushing.value = false
|
||
}
|
||
}
|
||
|
||
function formatDateTime(value?: string) {
|
||
if (!value) return '-'
|
||
const date = new Date(value)
|
||
if (Number.isNaN(date.getTime())) return value
|
||
const year = date.getFullYear()
|
||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||
const day = String(date.getDate()).padStart(2, '0')
|
||
const hours = String(date.getHours()).padStart(2, '0')
|
||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||
}
|
||
|
||
function normalizeTaskStatus(item: CollectDataHistoryItem) {
|
||
return (item.taskStatus || '').toUpperCase()
|
||
}
|
||
|
||
function statusText(item: CollectDataHistoryItem) {
|
||
const status = normalizeTaskStatus(item)
|
||
if (status === 'RUNNING') return '执行中'
|
||
if (status === 'PENDING') return '等待中'
|
||
if (status === 'SUCCESS' || item.success) return '已完成'
|
||
if (status === 'FAILED') return '失败'
|
||
return '等待中'
|
||
}
|
||
|
||
function statusClass(item: CollectDataHistoryItem) {
|
||
const status = normalizeTaskStatus(item)
|
||
if (status === 'RUNNING') return 'running'
|
||
if (status === 'FAILED') return 'failed'
|
||
if (status === 'SUCCESS' || item.success) return 'success'
|
||
return 'pending'
|
||
}
|
||
|
||
function taskFilterSummary(item: CollectDataHistoryItem) {
|
||
const status = normalizeTaskStatus(item)
|
||
if (status !== 'SUCCESS' && !item.success) return ''
|
||
|
||
const dedupeFiltered = Number(item.dedupeFilteredCount) || 0
|
||
const invalidFiltered = Number(item.invalidFilteredCount) || 0
|
||
const brandRejected = Number(item.brandRejectedCount) || 0
|
||
const finalRowCount = Number(item.finalRowCount ?? item.rowCount) || 0
|
||
return `去重 ${dedupeFiltered} · 无效品牌 ${invalidFiltered} · 品牌拒绝 ${brandRejected} · 保留 ${finalRowCount}`
|
||
}
|
||
|
||
function taskProgressPercent(item: CollectDataHistoryItem) {
|
||
const total = Number(item.totalRows)
|
||
const processed = Number(item.processedRows)
|
||
if (Number.isFinite(total) && total > 0 && Number.isFinite(processed) && processed >= 0) {
|
||
return Math.max(0, Math.min(100, Math.round((processed / total) * 100)))
|
||
}
|
||
const value = Number(item.progressPercent)
|
||
if (Number.isFinite(value)) return Math.max(0, Math.min(100, Math.round(value)))
|
||
const status = normalizeTaskStatus(item)
|
||
return status === 'SUCCESS' || item.success ? 100 : 0
|
||
}
|
||
|
||
function taskStageProgress(item: CollectDataHistoryItem) {
|
||
if (item.collectStage === 'search') {
|
||
return `搜索页面:${item.searchCurrentPage ?? 0}/${item.searchTotalPages ?? '-'} 页`
|
||
}
|
||
if (item.collectStage === 'detail') {
|
||
return `商品详情采集:${item.detailProcessedAsins ?? 0}/${item.detailTotalAsins ?? 0} 个商品`
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function taskKeywordProgress(item: CollectDataHistoryItem) {
|
||
const total = Number(item.totalRows)
|
||
if (!Number.isFinite(total) || total <= 0) return ''
|
||
const completed = Math.max(0, Number(item.processedRows) || 0)
|
||
return `关键词进度:第 ${Math.min(total, completed + 1)}/${total} 个`
|
||
}
|
||
|
||
function canDownload(item: CollectDataHistoryItem) {
|
||
const status = normalizeTaskStatus(item)
|
||
return Boolean(item.downloadUrl && (item.success || status === 'SUCCESS'))
|
||
}
|
||
|
||
async function downloadResult(item: CollectDataHistoryItem) {
|
||
if (!item.downloadUrl) {
|
||
ElMessage.warning('当前结果没有下载地址')
|
||
return
|
||
}
|
||
|
||
const filename = item.resultFilename || `${item.sourceFilename || 'collect-data-result'}.xlsx`
|
||
const downloadId = `collect-data:${item.resultId || item.taskId || Date.now()}`
|
||
const result = await saveUrlWithProgress(item.downloadUrl, filename, downloadId)
|
||
if (result.success) {
|
||
ElMessage.success(`已保存: ${result.path || filename}`)
|
||
} else if (result.error && result.error !== '用户取消') {
|
||
ElMessage.error(result.error)
|
||
}
|
||
}
|
||
|
||
async function loadDashboard() {
|
||
try {
|
||
dashboard.value = await getCollectDataDashboard()
|
||
} catch (error) {
|
||
/* 忽略:dashboard 失败不打断主流程 */
|
||
}
|
||
}
|
||
|
||
async function loadHistory() {
|
||
try {
|
||
const vo = await getCollectDataHistory(50)
|
||
const items = vo.items || []
|
||
const currentTasks = items.filter((it) => {
|
||
const s = (it.taskStatus || '').toUpperCase()
|
||
return s === 'PENDING' || s === 'RUNNING'
|
||
})
|
||
const running = currentTasks.filter((it) => (it.taskStatus || '').toUpperCase() === 'RUNNING')
|
||
historyItems.value = items.filter((it) => {
|
||
const s = (it.taskStatus || '').toUpperCase()
|
||
return s === 'SUCCESS' || s === 'FAILED'
|
||
})
|
||
// 仅轮询已入 Python 队列的 RUNNING 任务;PENDING 任务等待用户主动推送。
|
||
const seenTaskIds = new Set<number>()
|
||
for (const item of running) {
|
||
if (item.taskId) {
|
||
seenTaskIds.add(item.taskId)
|
||
progressLoop.add(item.taskId)
|
||
}
|
||
}
|
||
for (const taskId of [...progressLoop.taskIds.value]) {
|
||
const onScreen = running.some((it) => it.taskId === taskId)
|
||
if (!onScreen) {
|
||
progressLoop.remove(taskId)
|
||
}
|
||
}
|
||
// 合并:历史里 RUNNING/PENDING 的条目 + 轮询缓存里有快照但尚未在历史返回中的占位
|
||
const seen = new Set<number>(currentTasks.map((it) => it.taskId).filter((id): id is number => typeof id === 'number'))
|
||
const merged: CollectDataHistoryItem[] = [...currentTasks]
|
||
for (const taskId of progressLoop.taskIds.value) {
|
||
if (seen.has(taskId)) continue
|
||
const detail = taskSnapshots.value[taskId]
|
||
const placeholder = detail?.items?.[0]
|
||
if (placeholder) {
|
||
merged.push(placeholder)
|
||
} else if (detail?.task) {
|
||
merged.push({
|
||
taskId: detail.task.id,
|
||
taskNo: detail.task.taskNo,
|
||
taskStatus: detail.task.status,
|
||
startedAt: detail.task.createdAt,
|
||
finishedAt: detail.task.finishedAt,
|
||
sourceFilename: '采集数据',
|
||
} as CollectDataHistoryItem)
|
||
}
|
||
}
|
||
currentItems.value = merged
|
||
} catch {
|
||
/* 忽略:历史接口失败时保持空列表 */
|
||
}
|
||
}
|
||
|
||
async function deleteTaskRecord(item: CollectDataHistoryItem) {
|
||
try {
|
||
if (item.resultId) {
|
||
await deleteCollectDataHistory(item.resultId)
|
||
} else if (item.taskId) {
|
||
await deleteCollectDataTask(item.taskId)
|
||
} else {
|
||
return
|
||
}
|
||
if (item.taskId) {
|
||
progressLoop.remove(item.taskId)
|
||
}
|
||
ElMessage.success('已删除')
|
||
await Promise.all([loadDashboard(), loadHistory()])
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
void loadCountryPreference()
|
||
void loadDashboard()
|
||
void loadHistory()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (countryPrefSaveTimer != null) {
|
||
timers.clearTimer('country-pref-save', countryPrefSaveTimer)
|
||
countryPrefSaveTimer = null
|
||
}
|
||
timers.clearScope()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.module-page { min-height: 100vh; background: #1a1a1a; }
|
||
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
|
||
.left-panel { width: 400px; background: #1e1e1e; padding: 20px; overflow-y: auto; border-right: 1px solid #2a2a2a; }
|
||
.right-panel { flex: 1; min-width: 0; background: #1a1a1a; display: flex; flex-direction: column; }
|
||
.section-title, .subsection-title { font-size: 13px; color: #bbb; margin: 12px 0 10px; }
|
||
.upload-zone { border: 1px dashed #3a3a3a; border-radius: 10px; padding: 18px; background: #252525; margin-bottom: 18px; }
|
||
.hint, .loading-msg, .files, .muted { color: #888; font-size: 12px; line-height: 1.5; }
|
||
.btns, .run-row { display: flex; gap: 10px; flex-wrap: wrap; }
|
||
.opt-btn, .btn-run, .btn-delete, .download { border: none; cursor: pointer; border-radius: 7px; }
|
||
.opt-btn { padding: 8px 14px; color: #ccc; background: #2a2a2a; border: 1px solid #3a3a3a; }
|
||
.btn-run { padding: 10px 18px; color: #fff; background: #3498db; font-weight: 600; }
|
||
.btn-run.btn-queue { background: #27ae60; }
|
||
.btn-run:disabled { opacity: .55; cursor: not-allowed; }
|
||
.selected-files { margin-top: 14px; color: #999; font-size: 12px; word-break: break-all; }
|
||
.selected-files span { display: block; margin: 4px 0; }
|
||
.selected-files .more-line { color: #777; }
|
||
|
||
.filter-zone {
|
||
border: 1px solid #2a2a2a;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
background: #252525;
|
||
margin-bottom: 14px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.filter-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.filter-label {
|
||
width: 110px;
|
||
color: #bbb;
|
||
font-size: 12px;
|
||
}
|
||
.filter-input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding: 6px 10px;
|
||
background: #1e1e1e;
|
||
border: 1px solid #3a3a3a;
|
||
border-radius: 6px;
|
||
color: #ddd;
|
||
font-size: 13px;
|
||
outline: none;
|
||
}
|
||
.filter-input:focus { border-color: #3498db; }
|
||
.range-inputs { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
|
||
.range-separator { color: #777; font-size: 12px; white-space: nowrap; }
|
||
.filter-checks { display: flex; gap: 16px; }
|
||
.filter-check-row { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; color: #ccc; font-size: 12px; }
|
||
.filter-check-input { width: 16px; height: 16px; accent-color: #409eff; cursor: pointer; }
|
||
|
||
.country-pref-hint { margin-top: -4px; }
|
||
.country-section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||
.country-pref-saving { color: #3498db; font-size: 11px; }
|
||
.country-pref-radios { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 14px; }
|
||
.country-check-row { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; font-size: 12px; color: #ccc; padding: 8px 10px; border: 1px solid #333; border-radius: 8px; background: #252525; }
|
||
.country-check-row.selected { border-color: #409eff; background: rgba(64, 158, 255, .12); color: #e6f2ff; }
|
||
.country-check-input { width: 16px; height: 16px; accent-color: #409eff; cursor: pointer; flex-shrink: 0; }
|
||
.country-check-text { line-height: 1.3; }
|
||
|
||
.queue-debug-card { margin-top: 14px; border: 1px solid #2a2a2a; border-radius: 8px; padding: 10px 12px; background: #1f1f1f; }
|
||
.queue-debug-title { margin-top: 0; }
|
||
.queue-debug-line { color: #9ad; font-size: 12px; }
|
||
.queue-debug-payload { margin-top: 8px; padding: 8px 10px; background: #181818; border-radius: 6px; color: #bbb; font-size: 11px; overflow: auto; max-height: 200px; }
|
||
|
||
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; }
|
||
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
|
||
.clean-result-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: 8px; background: #1e1e1e; }
|
||
.summary-card strong { display: block; margin-top: 8px; color: #eaf4ff; font-size: 22px; }
|
||
.summary-label { color: #8d8d8d; font-size: 12px; }
|
||
.result-list-wrap { border: 1px solid #2a2a2a; border-radius: 8px; background: #1e1e1e; min-height: 180px; margin: 0 0 16px; }
|
||
.result-list-header { display: flex; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #2a2a2a; color: #ddd; font-size: 14px; }
|
||
.empty-tasks { color: #666; font-size: 13px; padding: 18px; text-align: center; }
|
||
.task-list { list-style: none; margin: 0; padding: 12px; }
|
||
.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; }
|
||
.left { flex: 1; min-width: 0; }
|
||
.id { color: #e0e0e0; font-size: 13px; font-weight: 600; }
|
||
.task-right { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; }
|
||
.status.success { background: rgba(46, 204, 113, .18); color: #2ecc71; }
|
||
.status.failed { background: rgba(231, 76, 60, .18); color: #ff6b6b; }
|
||
.status.running { background: rgba(52, 152, 219, .18); color: #3498db; }
|
||
.status.pending { background: rgba(149, 165, 166, .18); color: #bdc3c7; }
|
||
.status-filter-summary { font-weight: 400; }
|
||
.download { padding: 6px 10px; color: #d6ecff; background: rgba(52, 152, 219, .18); }
|
||
.btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); }
|
||
.task-progress { margin-top: 8px; max-width: 520px; }
|
||
.task-progress-meta { display: flex; justify-content: space-between; color: #888; font-size: 11px; margin-bottom: 4px; }
|
||
.task-progress-track { height: 5px; border-radius: 3px; background: #333; overflow: hidden; }
|
||
.task-progress-fill { height: 100%; border-radius: inherit; background: #3498db; transition: width .25s ease; }
|
||
@media (max-width: 1100px) {
|
||
.main-content { flex-direction: column; height: auto; }
|
||
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2a2a2a; }
|
||
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||
}
|
||
</style>
|