Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandCollectDataTab.vue
T

832 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-shell module-page">
<BrandTopBar active="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>