Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandSimilarAsinTab.vue
2026-05-20 09:05:28 +08:00

927 lines
36 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="similar-asin" />
<div class="main-content">
<aside class="left-panel">
<div class="section-title">上传文件</div>
<div class="upload-zone">
<div class="hint">选择 Excel 后点击解析后端会提取 idASIN国家价格每一行都会独立送检</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">
<span v-if="!selectedFileNames.length">暂未选择文件</span>
<span v-for="name in selectedFileNames" v-else :key="name">{{ name }}</span>
</div>
</div>
<div class="run-row">
<button type="button" class="btn-run" :disabled="parsing || !uploadedFiles.length" @click="parseFiles">
{{ parsing ? '解析中...' : '解析并创建任务' }}
</button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !parseResult?.taskId" @click="pushToPythonQueue">
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
</button>
</div>
<p class="loading-msg">Python 回传字段按 idasin国家价格标题图片链接数组提交后端接收分片后按 10 条一批送检结果区展示真实 Coze 批次进度</p>
<div v-if="visibleTaskSummary" class="parse-card">
<div>任务 ID{{ visibleTaskSummary.taskId }}</div>
<div>总行数{{ visibleTaskSummary.totalRows }}有效{{ visibleTaskSummary.acceptedRows }}过滤{{ visibleTaskSummary.droppedRows }}</div>
</div>
<pre v-if="queuePayloadText" class="queue-payload">{{ queuePayloadText }}</pre>
</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">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div class="files">行数{{ item.rowCount ?? '-' }}</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta">
<span>{{ displayFileProgressStage(item, '处理中') }}</span>
<span>总进度 {{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
<div v-if="hasFileProgressCount(item)" class="file-progress-count">
{{ fileProgressCountLabel(item) }}
</div>
</div>
</div>
<div class="task-right">
<span class="status running">{{ statusText(item) }}</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="!historyOnlyItems.length" class="empty-tasks">暂无历史记录</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in historyOnlyItems" :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 v-if="item.resultFilename" class="files">
{{ item.resultFilename || '下载结果' }}
</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta">
<span>{{ displayFileProgressStage(item, '结果生成中') }}</span>
<span>总进度 {{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
<div v-if="hasFileProgressCount(item)" class="file-progress-count">
{{ fileProgressCountLabel(item) }}
</div>
</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
</div>
<div class="task-right">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</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, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
import {
activateSimilarAsinTask,
deleteSimilarAsinHistory,
deleteSimilarAsinTask,
getSimilarAsinDashboard,
getSimilarAsinHistory,
getSimilarAsinResultDownloadUrl,
getSimilarAsinTaskProgressBatch,
parseSimilarAsin,
type SimilarAsinDashboardVo,
type SimilarAsinHistoryItem,
type SimilarAsinParseVo,
type UploadedFileRef,
type UploadFileVo,
uploadTempFileToJava,
} from '@/shared/api/java-modules'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { getStoredApiSecret } from '@/shared/utils/api-secret-store'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
const selectedFileNames = ref<string[]>([])
const uploadedFiles = ref<UploadFileVo[]>([])
const parseResult = ref<SimilarAsinParseVo | null>(null)
type TaskSummary = Pick<SimilarAsinParseVo, 'taskId' | 'totalRows' | 'acceptedRows' | 'groupCount' | 'droppedRows'>
const queuedTaskSummary = ref<TaskSummary | null>(null)
const parsing = ref(false)
const pushing = ref(false)
const queuePayloadText = ref('')
const pollingTaskIds = ref<number[]>([])
const pendingFileTaskIds = ref<number[]>([])
const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false)
const HISTORY_CACHE_TTL_MS = 3000
let historyInFlight: Promise<void> | null = null
let lastHistoryLoadedAt = 0
let disposed = false
const timers = createCategorizedTimers('similar-asin')
const dashboard = ref<SimilarAsinDashboardVo>({
pendingTaskCount: 0,
processedTaskCount: 0,
successTaskCount: 0,
failedTaskCount: 0,
})
const historyItems = ref<SimilarAsinHistoryItem[]>([])
const visibleTaskSummary = computed(() => parseResult.value || queuedTaskSummary.value)
const pendingParseItem = computed<SimilarAsinHistoryItem | null>(() => {
const result = parseResult.value
if (!result?.taskId) return null
const exists = historyItems.value.some((item) => item.taskId === result.taskId)
if (exists) return null
return {
taskId: result.taskId,
sourceFilename: result.sourceFilename,
rowCount: result.acceptedRows,
taskStatus: 'PENDING',
success: false,
}
})
const currentItems = computed(() => {
const activeIds = new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value])
const activeItems = historyItems.value.filter((item) => {
const status = normalizeTaskStatus(item)
return item.taskId != null && (activeIds.has(item.taskId) || status === 'RUNNING' || status === 'PENDING')
})
if (!pendingParseItem.value) return activeItems
const exists = activeItems.some((item) => item.taskId === pendingParseItem.value?.taskId)
return exists ? activeItems : [pendingParseItem.value, ...activeItems]
})
const historyOnlyItems = computed(() =>
historyItems.value.filter((i) => {
const status = normalizeTaskStatus(i)
return status !== 'RUNNING' && status !== 'PENDING'
}),
)
function effectiveCozeApiKey() {
return getStoredApiSecret().trim()
}
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 maskSecret(secret: string) {
if (!secret) return ''
if (secret.length <= 10) return '***'
return `${secret.slice(0, 6)}***${secret.slice(-4)}`
}
function payloadForDisplay<T extends { data?: Record<string, unknown> }>(payload: T) {
return {
...payload,
data: payload.data
? {
...payload.data,
api_key: maskSecret(String(payload.data.api_key || '')),
}
: payload.data,
}
}
function uidForStorage() {
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
}
function currentUserId() {
return Number(uidForStorage()) || 0
}
function pollingKey() {
return `similar-asin:tasks:${uidForStorage()}`
}
function toTaskSummary(result: SimilarAsinParseVo): TaskSummary {
return {
taskId: result.taskId,
totalRows: result.totalRows,
acceptedRows: result.acceptedRows,
groupCount: result.groupCount,
droppedRows: result.droppedRows,
}
}
function savePollingIds() {
if (typeof window === 'undefined') return
const ids = Array.from(new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value]))
if (!ids.length) window.localStorage.removeItem(pollingKey())
else window.localStorage.setItem(pollingKey(), JSON.stringify(ids))
}
function loadPollingIds() {
if (typeof window === 'undefined') return
try {
const raw = window.localStorage.getItem(pollingKey())
const ids = raw ? JSON.parse(raw) : []
pollingTaskIds.value = Array.isArray(ids)
? ids.filter((id): id is number => typeof id === 'number' && id > 0)
: []
} catch {
pollingTaskIds.value = []
}
}
async function uploadAppearancePathsToJava(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
}
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
const files = await uploadAppearancePathsToJava(paths)
uploadedFiles.value = files
selectedFileNames.value = files.map((f) => f.relativePath || f.originalFilename || f.fileKey)
parseResult.value = null
queuedTaskSummary.value = null
queuePayloadText.value = ''
}
async function selectFolder() {
const api = getPywebviewApi()
if (!api?.select_brand_folder || !api.upload_file_to_java) {
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 uploadAppearancePathsToJava(result.items)
uploadedFiles.value = files
selectedFileNames.value = result.items.map((item) => item.relativePath || item.absolutePath)
parseResult.value = null
queuedTaskSummary.value = null
queuePayloadText.value = ''
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 Excel 文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function parseFiles() {
if (!uploadedFiles.value.length) {
ElMessage.warning('请先选择 Excel')
return
}
if (!effectiveCozeApiKey()) {
ElMessage.warning('请先在左上角设置中填写货源查询密钥')
return
}
parsing.value = true
try {
const files: UploadedFileRef[] = uploadedFiles.value.map((f) => ({
fileKey: f.fileKey,
originalFilename: f.originalFilename,
relativePath: f.relativePath,
}))
const res = await parseSimilarAsin(files, effectiveCozeApiKey())
parseResult.value = res
queuedTaskSummary.value = null
queuePayloadText.value = ''
ElMessage.success(`解析完成,共 ${res.acceptedRows}`)
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '解析失败')
} finally {
parsing.value = false
}
}
async function pushToPythonQueue() {
const api = getPywebviewApi()
const currentParseResult = parseResult.value
const taskId = currentParseResult?.taskId
if (!taskId) {
ElMessage.warning('请先解析文件')
return
}
if (!api?.enqueue_json) {
ElMessage.error('当前环境未启用 pywebview enqueue_json')
return
}
if (!effectiveCozeApiKey()) {
ElMessage.warning('请先在左上角设置中填写货源查询密钥')
return
}
pushing.value = true
try {
const payload = {
type: 'similar-asin-run',
ts: Date.now(),
data: {
taskId,
user_id: currentUserId(),
api_key: effectiveCozeApiKey(),
sourceFileCount: currentParseResult.sourceFileCount || 0,
totalRows: currentParseResult.totalRows || 0,
acceptedRows: currentParseResult.acceptedRows || 0,
groupCount: currentParseResult.groupCount || 0,
},
}
queuePayloadText.value = JSON.stringify(payloadForDisplay(payload), null, 2)
await activateSimilarAsinTask(taskId)
const result = await api.enqueue_json(payload)
if (!result?.success) {
ElMessage.error(result?.error || '推送失败')
return
}
queuedTaskSummary.value = toTaskSummary(currentParseResult)
addPollingTask(taskId)
clearParsedTask()
await loadDashboard()
await loadHistory({ force: true })
ElMessage.success('已推送到 Python 队列')
} finally {
pushing.value = false
}
}
function clearParsedTask() {
parseResult.value = null
queuePayloadText.value = ''
}
function addPollingTask(taskId: number) {
if (!pollingTaskIds.value.includes(taskId)) {
pollingTaskIds.value = [...pollingTaskIds.value, taskId]
savePollingIds()
}
ensurePolling()
}
function removePollingTask(taskId: number) {
pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId)
savePollingIds()
}
function addPendingFileTask(taskId: number) {
if (!pendingFileTaskIds.value.includes(taskId)) {
pendingFileTaskIds.value = [...pendingFileTaskIds.value, taskId]
savePollingIds()
}
ensurePolling()
}
function removePendingFileTask(taskId: number) {
pendingFileTaskIds.value = pendingFileTaskIds.value.filter((id) => id !== taskId)
savePollingIds()
}
function ensurePolling() {
if (disposed) return
if (pollTimer.value != null) return
scheduleNextPoll(true)
}
function stopPolling() {
if (pollTimer.value != null) {
timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null
}
}
function scheduleNextPoll(immediate = false) {
if (disposed) return
if (pollTimer.value != null) {
if (!immediate) return
timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null
}
const run = async () => {
pollTimer.value = null
if (disposed) return
if (!pollingTaskIds.value.length && !pendingFileTaskIds.value.length) return
await refreshTaskProgress()
if (!disposed && pollTimer.value == null && (pollingTaskIds.value.length || pendingFileTaskIds.value.length)) {
pollTimer.value = timers.setTimeout('task-poll', run, getTaskPollIntervalMs())
}
}
if (immediate) void run()
else pollTimer.value = timers.setTimeout('task-poll', run, getTaskPollIntervalMs())
}
async function refreshTaskProgress() {
if (pollingInFlight.value || (!pollingTaskIds.value.length && !pendingFileTaskIds.value.length)) {
if (!pollingTaskIds.value.length && !pendingFileTaskIds.value.length) stopPolling()
return
}
pollingInFlight.value = true
try {
let shouldRefreshDashboard = false
let shouldRefreshHistory = false
const taskIds = Array.from(new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value]))
if (taskIds.length) {
const batch = await getSimilarAsinTaskProgressBatch(taskIds, { force: pendingFileTaskIds.value.length > 0 })
for (const detail of batch.items || []) {
const task = detail.task
if (!task?.id) continue
const item = detail.items?.[0]
if (item) mergeHistoryItem(item)
if (task.status === 'SUCCESS' || task.status === 'FAILED') {
removePollingTask(task.id)
shouldRefreshDashboard = true
if (task.status === 'SUCCESS') addPendingFileTask(task.id)
else removePendingFileTask(task.id)
}
if (item && task.status === 'SUCCESS' && pendingFileTaskIds.value.includes(task.id)) {
const fileStatus = (item.fileStatus || '').toUpperCase()
if (item.fileReady || fileStatus === 'SUCCESS' || fileStatus === 'FAILED') {
removePendingFileTask(task.id)
shouldRefreshDashboard = true
shouldRefreshHistory = true
}
}
}
if (batch.missingTaskIds?.length) {
batch.missingTaskIds.forEach((taskId) => {
removePollingTask(taskId)
removePendingFileTask(taskId)
})
shouldRefreshHistory = true
shouldRefreshDashboard = true
}
}
if (shouldRefreshDashboard) {
await loadDashboard()
}
if (shouldRefreshHistory) {
await loadHistory()
}
settlePendingFileTasks()
} finally {
pollingInFlight.value = false
}
}
function mergeHistoryItem(item: SimilarAsinHistoryItem) {
if (!item.taskId && !item.resultId) return
const index = historyItems.value.findIndex((row) =>
(item.resultId != null && row.resultId === item.resultId)
|| (item.taskId != null && row.taskId === item.taskId),
)
if (index >= 0) {
historyItems.value[index] = { ...historyItems.value[index], ...item }
} else {
historyItems.value = [item, ...historyItems.value]
}
}
function settlePendingFileTasks() {
if (!pendingFileTaskIds.value.length) return
const remaining: number[] = []
for (const taskId of pendingFileTaskIds.value) {
const item = historyItems.value.find((row) => row.taskId === taskId)
if (!item) {
remaining.push(taskId)
continue
}
const taskStatus = (item.taskStatus || '').toUpperCase()
const fileStatus = (item.fileStatus || '').toUpperCase()
if (taskStatus === 'FAILED' || item.fileReady || fileStatus === 'SUCCESS' || fileStatus === 'FAILED') {
continue
}
if (taskStatus === 'SUCCESS') {
remaining.push(taskId)
}
}
pendingFileTaskIds.value = remaining
savePollingIds()
if (pendingFileTaskIds.value.length) ensurePolling()
}
function seedPendingFileTasksFromHistory() {
const ids = historyItems.value
.filter((item) => item.taskId != null && isResultPreparing(item))
.map((item) => item.taskId as number)
pendingFileTaskIds.value = Array.from(new Set(ids))
savePollingIds()
if (pendingFileTaskIds.value.length) ensurePolling()
}
function seedRunningTasksFromHistory() {
const ids = historyItems.value
.filter((item) => item.taskId != null && normalizeTaskStatus(item) === 'RUNNING')
.map((item) => item.taskId as number)
if (!ids.length) return
pollingTaskIds.value = Array.from(new Set([...pollingTaskIds.value, ...ids]))
savePollingIds()
ensurePolling()
}
async function loadDashboard() {
dashboard.value = await getSimilarAsinDashboard()
}
async function loadHistory(options: { force?: boolean } = {}) {
const now = Date.now()
if (!options.force && historyItems.value.length && now - lastHistoryLoadedAt < HISTORY_CACHE_TTL_MS) return
if (historyInFlight) return historyInFlight
historyInFlight = getSimilarAsinHistory()
.then((res) => {
historyItems.value = mergeHistoryItemsPreservingLiveProgress(historyItems.value, res.items || [])
lastHistoryLoadedAt = Date.now()
})
.finally(() => {
historyInFlight = null
})
return historyInFlight
}
function mergeHistoryItemsPreservingLiveProgress(
previousItems: SimilarAsinHistoryItem[],
incomingItems: SimilarAsinHistoryItem[],
) {
const previousByTaskId = new Map<number, SimilarAsinHistoryItem>()
const previousByResultId = new Map<number, SimilarAsinHistoryItem>()
for (const item of previousItems || []) {
if (item.taskId != null) previousByTaskId.set(item.taskId, item)
if (item.resultId != null) previousByResultId.set(item.resultId, item)
}
return (incomingItems || []).map((incoming) => {
const previous = incoming.resultId != null
? previousByResultId.get(incoming.resultId)
: incoming.taskId != null
? previousByTaskId.get(incoming.taskId)
: undefined
return mergeHistoryItemPreservingLiveProgress(previous, incoming)
})
}
function mergeHistoryItemPreservingLiveProgress(
previous: SimilarAsinHistoryItem | undefined,
incoming: SimilarAsinHistoryItem,
) {
if (!previous) return incoming
const shouldPreserveLiveProgress =
(normalizeTaskStatus(previous) === 'RUNNING' || isResultPreparing(previous) || showFileProgress(previous))
&& !showFileProgress(incoming)
&& !canDownload(incoming)
if (!shouldPreserveLiveProgress) {
return incoming
}
return {
...incoming,
fileJobId: incoming.fileJobId ?? previous.fileJobId,
fileStatus: incoming.fileStatus || previous.fileStatus,
fileError: incoming.fileError || previous.fileError,
fileReady: incoming.fileReady || previous.fileReady,
fileProgressPercent: hasMeaningfulProgressValue(incoming.fileProgressPercent)
? incoming.fileProgressPercent
: previous.fileProgressPercent,
fileProgressCurrent: hasMeaningfulProgressValue(incoming.fileProgressCurrent)
? incoming.fileProgressCurrent
: previous.fileProgressCurrent,
fileProgressTotal: hasMeaningfulProgressValue(incoming.fileProgressTotal)
? incoming.fileProgressTotal
: previous.fileProgressTotal,
fileProgressMessage: (incoming.fileProgressMessage || '').trim()
? incoming.fileProgressMessage
: previous.fileProgressMessage,
}
}
function hasMeaningfulProgressValue(value: unknown) {
const num = Number(value)
return Number.isFinite(num) && num > 0
}
function statusText(item: SimilarAsinHistoryItem) {
const status = normalizeTaskStatus(item)
if (status === 'RUNNING') return '执行中'
if (status === 'PENDING') return '已解析待推送'
if (isResultPreparing(item)) return '结果生成中'
if (isResultBuildFailed(item)) return '结果生成失败'
if (status === 'SUCCESS' || item.success) return '已完成'
if (status === 'FAILED') return '失败'
return '等待中'
}
function statusClass(item: SimilarAsinHistoryItem) {
const status = normalizeTaskStatus(item)
if (status === 'RUNNING' || isResultPreparing(item)) return 'running'
if (isResultBuildFailed(item) || status === 'FAILED') return 'failed'
if (status === 'SUCCESS' || item.success) return 'success'
return 'pending'
}
function isResultPreparing(item: SimilarAsinHistoryItem) {
const status = normalizeTaskStatus(item)
const fileStatus = (item.fileStatus || '').toUpperCase()
if (status !== 'SUCCESS') return false
if (canDownload(item)) return false
if (fileStatus === 'FAILED' || fileStatus === 'SUCCESS') return false
return true
}
function normalizeTaskStatus(item: SimilarAsinHistoryItem) {
return (item.taskStatus || '').toUpperCase()
}
function isResultBuildFailed(item: SimilarAsinHistoryItem) {
return (item.fileStatus || '').toUpperCase() === 'FAILED'
}
function pendingResultHint(item: SimilarAsinHistoryItem) {
if (isResultBuildFailed(item)) {
return item.fileError || '结果文件生成失败,请稍后重试或检查后端日志'
}
if (showFileProgress(item)) {
return ''
}
if (!isResultPreparing(item)) return ''
const fileStatus = (item.fileStatus || '').toUpperCase()
if (fileStatus === 'RUNNING') {
return '任务已完成,正在等待 Coze 回流并组装结果文件,下载按钮稍后出现'
}
if (fileStatus === 'PENDING') {
return '任务已完成,结果文件已入队,正在等待后端开始处理'
}
return '任务已完成,正在生成结果文件,下载按钮稍后出现'
}
function canDownload(item: SimilarAsinHistoryItem) {
return Boolean(item.resultId && (item.fileReady || item.downloadUrl))
}
function fileProgressPercent(item: SimilarAsinHistoryItem) {
const percent = Number(item.fileProgressPercent || 0)
if (Number.isFinite(percent) && percent > 0) {
return Math.max(0, Math.min(100, Math.round(percent)))
}
if (shouldShowFallbackJobProgress(item)) {
return activeFileJobStatus(item) === 'PENDING' ? 6 : 10
}
return 0
}
function displayFileProgressStage(item: SimilarAsinHistoryItem, fallback: string) {
const message = (item.fileProgressMessage || '').trim()
if (!message) return fallback
if (/coze/i.test(message) || /Coze|回流/.test(message)) {
if (/submitting/i.test(message) || /提交/.test(message)) return '正在提交 Coze 批次'
if (/生成结果|结果文件|组装/i.test(message)) return 'Coze 已回流,正在生成结果文件'
if (/waiting/i.test(message) || /等待/.test(message) || /\d+\s*\/\s*\d+/.test(message)) {
return 'Coze 回流中,等待结果回传'
}
return 'Coze 处理中'
}
if (/assembling|xlsx|组装|生成结果/i.test(message)) return '正在组装结果文件'
return message
}
function fileProgressCountLabel(item: SimilarAsinHistoryItem) {
const message = (item.fileProgressMessage || '').trim()
const parsed = parseProgressCountFromMessage(message)
const current = parsed?.current ?? item.fileProgressCurrent ?? 0
const total = parsed?.total ?? item.fileProgressTotal ?? 0
if (/coze/i.test(message) || /Coze|回流/.test(message)) {
if (/submitting/i.test(message) || /提交/.test(message)) {
return `Coze 提交批次 ${current}/${total}`
}
return `Coze 回流批次 ${current}/${total}`
}
return `当前阶段 ${current}/${total}`
}
function parseProgressCountFromMessage(message: string) {
const match = message.match(/(\d+)\s*\/\s*(\d+)/)
if (!match) return null
const current = Number(match[1])
const total = Number(match[2])
if (!Number.isFinite(current) || !Number.isFinite(total)) return null
return { current, total }
}
function showFileProgress(item: SimilarAsinHistoryItem) {
const status = normalizeTaskStatus(item)
return (status === 'RUNNING' || isResultPreparing(item)) && (hasExplicitFileProgress(item) || shouldShowFallbackJobProgress(item))
}
function hasExplicitFileProgress(item: SimilarAsinHistoryItem) {
return hasFileProgressCount(item)
|| Number(item.fileProgressPercent || 0) > 0
|| Boolean((item.fileProgressMessage || '').trim())
}
function hasFileProgressCount(item: SimilarAsinHistoryItem) {
return (item.fileProgressTotal || 0) > 0
}
function activeFileJobStatus(item: SimilarAsinHistoryItem) {
return (item.fileStatus || '').toUpperCase()
}
function hasActiveFileJob(item: SimilarAsinHistoryItem) {
const fileStatus = activeFileJobStatus(item)
return item.fileJobId != null || fileStatus === 'PENDING' || fileStatus === 'RUNNING'
}
function shouldShowFallbackJobProgress(item: SimilarAsinHistoryItem) {
return hasActiveFileJob(item) && !hasExplicitFileProgress(item)
}
async function downloadResult(item: SimilarAsinHistoryItem) {
if (!item.resultId) return
const api = getPywebviewApi()
if (!api?.save_file_from_url_new) {
ElMessage.error('当前客户端未提供下载能力')
return
}
const url = item.downloadUrl || getSimilarAsinResultDownloadUrl(item.resultId)
const filename = item.resultFilename || `${item.sourceFilename || 'similar-asin'}.xlsx`
const result = await api.save_file_from_url_new(url, filename)
if (result.success) {
ElMessage.success(`已保存: ${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
}
async function deleteTaskRecord(item: SimilarAsinHistoryItem) {
try {
if (item.taskStatus === 'RUNNING' && item.taskId) {
await deleteSimilarAsinTask(item.taskId)
removePollingTask(item.taskId)
} else if (item.resultId) {
await deleteSimilarAsinHistory(item.resultId)
}
await loadDashboard()
await loadHistory({ force: true })
ElMessage.success('已删除')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '删除失败')
}
}
onMounted(async () => {
loadPollingIds()
await Promise.all([
loadDashboard().catch(() => undefined),
loadHistory().catch(() => undefined),
])
seedPendingFileTasksFromHistory()
seedRunningTasksFromHistory()
if (pollingTaskIds.value.length || pendingFileTaskIds.value.length) ensurePolling()
})
onUnmounted(() => {
disposed = true
stopPolling()
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-bottom: 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; }
.link { color: #6ea8fe; text-decoration: none; }
.link:hover { color: #9fc5ff; }
.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-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; }
.parse-card, .queue-payload { margin-top: 14px; padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; background: #202020; color: #b8c1cc; font-size: 12px; }
.queue-payload { max-height: 220px; overflow: auto; color: #8fd3ff; white-space: pre-wrap; }
.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; }
.result-hint { margin-top: 6px; color: #e0b96d; }
.file-progress { margin-top: 8px; max-width: 520px; }
.file-progress-meta { display: flex; justify-content: space-between; gap: 12px; color: #d8c278; font-size: 12px; }
.file-progress-track { margin-top: 5px; height: 8px; border-radius: 999px; overflow: hidden; background: #303030; border: 1px solid #3b3b3b; }
.file-progress-bar { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #4aa3ff, #f0c75e); transition: width .25s ease; }
.file-progress-count { margin-top: 4px; color: #858585; font-size: 11px; }
.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); }
@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>