Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandSimilarAsinTab.vue
T
huangzd1997 82a782550e feat(密钥): 用户 API 密钥服务端化——V115 按账号绑定存储 + 后台密钥管理页 + 桌面端全站拦截与配置引导
- 新增 usersecret 模块:外观专利/货源查询密钥从本地 localStorage 迁移至 biz_user_api_secret(AES 加密、按 uid 绑定)
- 后台「密钥管理」页:脱敏展示、立即检测、清空;每日 04:30 分布式锁定时巡检
- 桌面端:密钥设置面板走服务端、未配齐引导 /setup-secrets、代理余量展示
- 删除专利汇令牌全链路与密钥保留时长选择器
2026-09-13 10:03:59 +08:00

1391 lines
44 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">
<AmazonToolPageShell tool-id="source">
<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 后点击解析系统会识别表格里的 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="condition-card">
<div class="section-title">检测条件</div>
<label class="switch-row">
<span>
<strong>图片检测</strong>
</span>
<input v-model="imgSwitch" type="checkbox" />
<i></i>
</label>
<label class="switch-row">
<span>
<strong>类目检测</strong>
</span>
<input v-model="categorySwitch" type="checkbox" />
<i></i>
</label>
</div>
<div class="aliprice-card">
<div class="section-title">货源账号</div>
<label class="aliprice-field">
<span>账号</span>
<input
v-model="alipriceUsername"
type="text"
autocomplete="username"
placeholder="请输入 Aliprice 账号"
required
/>
</label>
<label class="aliprice-field">
<span>密码</span>
<input
v-model="alipricePassword"
type="password"
autocomplete="current-password"
placeholder="请输入 Aliprice 密码"
required
/>
</label>
</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 ? '启动中...' : '启动任务' }}
</button>
</div>
<p class="loading-msg">提交后系统自动分批检测进度显示在右侧任务区完成后可下载结果</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>
<ModuleTemplateDownload module-code="similar-asin" filename="货源查询 文档格式.xlsx" />
</aside>
<section class="right-panel">
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="货源查询"
:cards="asinCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-title="当前任务"
current-empty-text="暂无当前任务"
history-empty-text="暂无历史记录"
>
<template #item-extra="{ item }">
<div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</div>
</template>
<template #history-item-extra="{ item }">
<div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</div>
</template>
<template #item-actions="{ item }">
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
<template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载</button>
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
@click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import {
activateSimilarAsinTask,
deleteSimilarAsinHistory,
deleteSimilarAsinTask,
getSimilarAsinDashboard,
getSimilarAsinHistory,
getSimilarAsinResultDownloadUrl,
getSimilarAsinTaskProgressBatch,
parseSimilarAsin,
type SimilarAsinDashboardVo,
type SimilarAsinHistoryItem,
type SimilarAsinParseVo,
type UploadedFileRef,
type UploadFileVo,
} from '@/shared/api/java-modules'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { getPywebviewApi, type ProxyMode } 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'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import { createAsinForceThrottle } from '@/shared/asin-force-throttle'
import { toParsePreview, type ParsePreviewOptions } from '@/shared/parse-preview'
import {
EXCEL_EXTENSIONS,
checkParseResult,
checkQueuePayload,
checkSelectedFiles,
} from '@/shared/dispatch-guard'
import { passGuard } from '@/shared/dispatch-guard-ui'
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 imgSwitch = ref(false)
const categorySwitch = ref(false)
const alipriceUsername = ref('')
const alipricePassword = ref('')
const queuePayloadText = ref('')
const pollingTaskIds = ref<number[]>([])
const pendingFileTaskIds = ref<number[]>([])
const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false)
// force 请求节流:文件生成中不重复 force(TTL 窗口内只发一次),终态后 clear 释放
const forceThrottle = createAsinForceThrottle({ ttlMs: 30_000 })
const HISTORY_CACHE_TTL_MS = 3000
let historyInFlight: Promise<void> | null = null
let lastHistoryLoadedAt = 0
let disposed = false
let pywebviewReadyHandler: (() => void) | null = null
const timers = createCategorizedTimers('similar-asin')
const dashboard = ref<SimilarAsinDashboardVo>({
pendingTaskCount: 0,
processedTaskCount: 0,
successTaskCount: 0,
failedTaskCount: 0,
})
const historyItems = ref<SimilarAsinHistoryItem[]>([])
// 前端实时进度态:保存最近一次 progress/batch 接口拿到的进度,作为 history 接口的兜底,
// 避免历史接口暂时返回不到该任务、或字段不全时,当前任务区出现进度回退/丢失。
const liveProgressItems = ref<Record<number, 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')
})
.map((item) => mergeCurrentTaskItem(item))
// 兜底:history 暂未返回该任务,但实时缓存里仍在跑,保留它在当前任务区
for (const [taskIdText, liveItem] of Object.entries(liveProgressItems.value)) {
const taskId = Number(taskIdText)
if (!Number.isFinite(taskId) || taskId <= 0) continue
if (!activeIds.has(taskId) && normalizeTaskStatus(liveItem) !== 'RUNNING' && normalizeTaskStatus(liveItem) !== 'PENDING') {
continue
}
if (activeItems.some((item) => item.taskId === taskId)) continue
activeItems.push(mergeCurrentTaskItem(liveItem))
}
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'
}),
)
const asinCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: dashboard.value.pendingTaskCount },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
])
const currentTaskViews = computed<TaskItemView[]>(() => currentItems.value.map(toAsinTaskView))
const historyTaskViews = computed<TaskItemView[]>(() => historyOnlyItems.value.map(toAsinTaskView))
function itemSource(item: TaskItemView): SimilarAsinHistoryItem {
return item.source as SimilarAsinHistoryItem
}
function toAsinTaskView(item: SimilarAsinHistoryItem): TaskItemView {
const showProgress = showFileProgress(item)
const isRunning = normalizeTaskStatus(item) === 'RUNNING'
return {
key: `asin-${item.taskId ?? item.resultId ?? item.sourceFilename}`,
title: item.sourceFilename || '货源查询',
taskId: item.taskId ?? '-',
startedAt: formatDateTime(item.startedAt || item.createdAt),
finishedAt: formatDateTime(item.finishedAt),
statusText: statusText(item),
statusClass: statusClass(item),
extraLines: [
...(item.rowCount != null ? [`行数:${item.rowCount}`] : []),
...(item.resultFilename && !showProgress ? [item.resultFilename] : []),
...(item.error ? [`错误:${item.error}`] : []),
],
progress: showProgress
? {
percent: fileProgressPercent(item),
stage: displayFileProgressStage(item, isRunning ? '处理中' : '结果生成中'),
countLabel: hasFileProgressCount(item) ? fileProgressCountLabel(item) : undefined,
}
: null,
source: item,
}
}
function mergeCurrentTaskItem(item: SimilarAsinHistoryItem) {
if (item.taskId == null) return item
const liveItem = liveProgressItems.value[item.taskId]
if (!liveItem) return item
return {
...item,
...liveItem,
sourceFilename: liveItem.sourceFilename || item.sourceFilename,
resultFilename: liveItem.resultFilename || item.resultFilename,
rowCount: liveItem.rowCount ?? item.rowCount,
createdAt: liveItem.createdAt || item.createdAt,
}
}
function effectiveLlmApiKey() {
return getStoredApiSecret('similar-asin').trim()
}
function getRequiredAlipriceCredentials() {
const username = alipriceUsername.value.trim()
const password = alipricePassword.value
if (!username || !password.trim()) {
ElMessage.warning('请填写 Aliprice 账号和密码')
return null
}
return { username, password }
}
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 || '')),
aliprice_pwd: maskSecret(String(payload.data.aliprice_pwd || '')),
}
: 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
if (!(await passGuard(checkSelectedFiles(paths, { allowedExtensions: EXCEL_EXTENSIONS })))) return
try {
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 = ''
} catch (error) {
// 上传失败时清空选择,避免残留上一批文件让用户误以为新文件已就绪
uploadedFiles.value = []
selectedFileNames.value = []
parseResult.value = null
queuedTaskSummary.value = null
queuePayloadText.value = ''
ElMessage.error(error instanceof Error ? error.message : '文件上传失败')
}
}
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
}
if (!(await passGuard(checkSelectedFiles(result.items, { allowedExtensions: EXCEL_EXTENSIONS })))) 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) {
uploadedFiles.value = []
selectedFileNames.value = []
parseResult.value = null
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function parseFiles() {
if (!uploadedFiles.value.length) {
ElMessage.warning('请先选择 Excel')
return
}
if (!effectiveLlmApiKey()) {
// 本地无明文不再阻断:密钥已服务端化,任务执行按用户 uid 兜底读取
console.log('[similar-asin] 本地无密钥明文,提交时由服务端按用户密钥兜底')
}
if (!getRequiredAlipriceCredentials()) 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, effectiveLlmApiKey(), imgSwitch.value, categorySwitch.value)
// 0 有效行的任务推给 Python 只会一直停在 RUNNING,这里拦下来不写入 parseResult
const guard = checkParseResult(res, { requiredColumnsHint: 'id / ASIN / 国家' })
if (!(await passGuard(guard))) {
parseResult.value = null
queuedTaskSummary.value = null
queuePayloadText.value = ''
return
}
// 只保留摘要字段与有界预览行,避免数千行 items/groups 进入响应式对象
const previewOptions: ParsePreviewOptions = { previewRowLimit: 200 }
parseResult.value = toParsePreview(res, previewOptions) as unknown as SimilarAsinParseVo
categorySwitch.value = Boolean(res.categorySwitch)
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 (!effectiveLlmApiKey()) {
// 本地无明文不再阻断:服务端已保存该用户密钥,任务执行时按 uid 兜底读取
console.log('[similar-asin] 本地无密钥明文,启动任务由服务端兜底')
}
const alipriceCredentials = getRequiredAlipriceCredentials()
if (!alipriceCredentials) return
const alipriceUsename = alipriceCredentials.username
const alipricePwd = alipriceCredentials.password
pushing.value = true
try {
let proxyData: { proxy_url: string; proxy_mode: ProxyMode } | undefined
if (api.read_config) {
try {
const config = await api.read_config()
const proxyUrl = typeof config?.proxy_url === 'string' ? config.proxy_url.trim() : ''
if (proxyUrl) {
proxyData = {
proxy_url: proxyUrl,
proxy_mode: Number(config?.proxy_mode) === 2 ? 2 : 1,
}
}
} catch {
// Keep the existing queue behavior when proxy configuration is unavailable.
}
}
if (api.save_config) {
try {
await api.save_config({
aliprice_usename: alipriceUsename,
aliprice_pwd: alipricePwd,
})
} catch {
ElMessage.warning('Aliprice 账号配置保存失败,本次任务仍会继续')
}
}
const payload = {
type: 'similar-asin-run',
ts: Date.now(),
data: {
taskId,
user_id: currentUserId(),
api_key: effectiveLlmApiKey(),
sourceFileCount: currentParseResult.sourceFileCount || 0,
totalRows: currentParseResult.totalRows || 0,
acceptedRows: currentParseResult.acceptedRows || 0,
groupCount: currentParseResult.groupCount || 0,
aliprice_usename: alipriceUsename,
aliprice_pwd: alipricePwd,
...proxyData,
},
}
queuePayloadText.value = JSON.stringify(payloadForDisplay(payload), null, 2)
// 入队前最后一道闸:缺字段或含 NaN 的 payload 到 Python 侧会变成 None,任务卡住
const guard = checkQueuePayload(payload, {
expectedType: 'similar-asin-run',
requiredDataKeys: ['taskId', 'api_key', 'aliprice_usename', 'aliprice_pwd'],
})
if (!(await passGuard(guard))) return
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('已启动任务')
} finally {
pushing.value = false
}
}
async function loadAlipriceConfig() {
const api = getPywebviewApi()
if (!api?.read_config) return
try {
const config = await api.read_config()
if (disposed) return
alipriceUsername.value = typeof config?.aliprice_usename === 'string' ? config.aliprice_usename : ''
alipricePassword.value = typeof config?.aliprice_pwd === 'string' ? config.aliprice_pwd : ''
} catch {
ElMessage.warning('Aliprice 账号配置读取失败')
}
}
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)
if (!pendingFileTaskIds.value.includes(taskId)) {
const next = { ...liveProgressItems.value }
delete next[taskId]
liveProgressItems.value = next
}
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)
forceThrottle.clear(taskId)
if (!pollingTaskIds.value.includes(taskId)) {
const next = { ...liveProgressItems.value }
delete next[taskId]
liveProgressItems.value = next
}
savePollingIds()
}
function removeTaskFromLocalState(taskId?: number) {
if (!taskId) return
removePollingTask(taskId)
removePendingFileTask(taskId)
if (parseResult.value?.taskId === taskId) {
clearParsedTask()
}
if (queuedTaskSummary.value?.taskId === taskId) {
queuedTaskSummary.value = null
}
historyItems.value = historyItems.value.filter((row) => row.taskId !== taskId)
if (liveProgressItems.value[taskId]) {
const next = { ...liveProgressItems.value }
delete next[taskId]
liveProgressItems.value = next
}
}
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
try {
await refreshTaskProgress()
} catch {
// A transient request failure must not stop progress polling permanently.
} finally {
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 force = pendingFileTaskIds.value.some((taskId) => forceThrottle.shouldForce(taskId))
const batch = await getSimilarAsinTaskProgressBatch(taskIds, { force })
if (force) {
// force 请求已发出,进入冷却窗口;TTL 内不再重复 force,避免重复触发文件生成
pendingFileTaskIds.value.forEach((taskId) => forceThrottle.markForce(taskId))
}
for (const detail of batch.items || []) {
const task = detail.task
if (!task?.id) continue
const item = detail.items?.[0]
if (item) {
// 实时缓存:以最新一次 progress 为准,避免 history 接口字段缺失/延迟时丢失进度
liveProgressItems.value = {
...liveProgressItems.value,
[task.id]: 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) {
const taskStatus = (item.taskStatus || '').toUpperCase()
if (taskStatus !== 'FAILED' && isResultFileComplete(item)) return 'SUCCESS'
return taskStatus
}
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 '任务已完成,正在生成结果文件,下载按钮稍后出现'
}
if (fileStatus === 'PENDING') {
return '任务已完成,结果文件排队中,请稍候'
}
return '任务已完成,正在生成结果文件,下载按钮稍后出现'
}
function canDownload(item: SimilarAsinHistoryItem) {
return Boolean(item.resultId && (item.fileReady || item.downloadUrl))
}
function isResultFileComplete(item: SimilarAsinHistoryItem) {
return Boolean(
item.fileReady
|| item.downloadUrl
|| (item.fileStatus || '').toUpperCase() === 'SUCCESS',
)
}
function fileProgressPercent(item: SimilarAsinHistoryItem) {
if (isResultFileComplete(item)) return 100
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 (/llm/i.test(message) || /LLM|回流/.test(message)) {
if (/submitting/i.test(message) || /提交/.test(message)) return '正在提交检测批次'
if (/生成结果|结果文件|组装/i.test(message)) return '检测结果已返回,正在生成结果文件'
if (/waiting/i.test(message) || /等待/.test(message) || /\d+\s*\/\s*\d+/.test(message)) {
return '检测中,等待结果返回'
}
return '检测中'
}
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 (/llm/i.test(message) || /LLM|回流/.test(message)) {
if (/submitting/i.test(message) || /提交/.test(message)) {
return `检测批次 ${current}/${total}`
}
return `检测完成批次 ${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 url = item.downloadUrl || getSimilarAsinResultDownloadUrl(item.resultId)
const filename = item.resultFilename || `${item.sourceFilename || 'similar-asin'}.xlsx`
const result = await saveUrlWithProgress(url, filename, `similar-asin:${item.resultId}`)
if (result.success) {
ElMessage.success(`已保存: ${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
}
async function deleteTaskRecord(item: SimilarAsinHistoryItem) {
try {
const status = normalizeTaskStatus(item)
const taskId = item.taskId
const shouldDeleteTask = taskId != null && (status === 'RUNNING' || status === 'PENDING' || !item.resultId)
if (shouldDeleteTask) {
await deleteSimilarAsinTask(taskId)
removeTaskFromLocalState(taskId)
} else if (item.resultId) {
await deleteSimilarAsinHistory(item.resultId)
if (item.taskId) {
historyItems.value = historyItems.value.filter((row) => row.taskId !== item.taskId)
}
}
await loadDashboard()
await loadHistory({ force: true })
ElMessage.success('已删除')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '删除失败')
}
}
onMounted(async () => {
loadPollingIds()
if (typeof window !== 'undefined') {
pywebviewReadyHandler = () => {
void loadAlipriceConfig()
}
window.addEventListener('pywebviewready', pywebviewReadyHandler)
}
await Promise.all([
loadDashboard().catch(() => undefined),
loadHistory().catch(() => undefined),
loadAlipriceConfig(),
])
seedPendingFileTasksFromHistory()
seedRunningTasksFromHistory()
if (pollingTaskIds.value.length || pendingFileTaskIds.value.length) ensurePolling()
})
onUnmounted(() => {
disposed = true
if (typeof window !== 'undefined' && pywebviewReadyHandler) {
window.removeEventListener('pywebviewready', pywebviewReadyHandler)
pywebviewReadyHandler = null
}
stopPolling()
timers.clearScope()
})
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</script>
<style scoped>
.module-page {
min-height: 100vh;
background: #242424;
}
.main-content {
display: flex;
height: calc(100vh - 56px);
min-height: calc(100vh - 56px);
}
.left-panel {
width: 400px;
background: #242424;
padding: 20px;
overflow-y: auto;
border-right: 1px solid #2e3a52;
}
.right-panel {
flex: 1;
min-width: 0;
background: #242424;
display: flex;
flex-direction: column;
}
.section-title {
font-size: 13px;
color: #a0acbe;
margin-bottom: 10px;
}
.upload-zone {
border: 1px dashed #3e4a62;
border-radius: 10px;
padding: 18px;
background: #242424;
margin-bottom: 18px;
}
.condition-card {
margin-bottom: 18px;
padding: 14px 16px;
border: 1px solid #2f3a34;
border-radius: 10px;
background: linear-gradient(135deg, #222a25, #222b3d);
}
.aliprice-card {
margin-bottom: 18px;
padding: 14px 16px;
border: 1px solid #343434;
border-radius: 8px;
background: #232323;
}
.aliprice-field {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
align-items: center;
gap: 10px;
color: #a0acbe;
font-size: 13px;
}
.aliprice-field + .aliprice-field {
margin-top: 10px;
}
.aliprice-field input {
width: 100%;
min-width: 0;
height: 34px;
padding: 0 10px;
border: 1px solid #3b3b3b;
border-radius: 6px;
outline: none;
background: #1b1b1b;
color: #c8d2e2;
font: inherit;
}
.aliprice-field input:focus {
border-color: #4f91c7;
}
.switch-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
color: #d8e4dc;
cursor: pointer;
user-select: none;
}
.switch-row span {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.switch-row strong {
font-size: 13px;
font-weight: 600;
}
.switch-row em {
color: #8d9a91;
font-size: 12px;
font-style: normal;
line-height: 1.4;
}
.switch-row input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.switch-row i {
position: relative;
width: 46px;
height: 24px;
flex: 0 0 auto;
border-radius: 999px;
background: #3e4a62;
box-shadow: inset 0 0 0 1px #4b4b4b;
transition: background .2s ease, box-shadow .2s ease;
}
.switch-row i::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #c7c7c7;
transition: transform .2s ease, background .2s ease;
}
.switch-row input:checked+i {
background: #27ae60;
box-shadow: inset 0 0 0 1px #42d17a;
}
.switch-row input:checked+i::after {
transform: translateX(22px);
background: #fff;
}
.hint,
.loading-msg,
.files,
.muted {
color: #5e6878;
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: #c8d2e2;
background: #242424;
border: 1px solid #3e4a62;
}
.btn-run {
padding: 10px 18px;
color: #f5f8fc;
background: #3498db;
font-weight: 600;
}
.btn-queue {
background: #27ae60;
}
.btn-run:disabled {
opacity: .55;
cursor: not-allowed;
}
.selected-files {
margin-top: 14px;
color: #a0acbe;
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 #2e3a52;
border-radius: 8px;
background: #222b3d;
color: #a0acbe;
font-size: 12px;
}
.queue-payload {
max-height: 220px;
overflow: auto;
color: #8fd3ff;
white-space: pre-wrap;
}
.result-hint {
margin-top: 6px;
color: #e0b96d;
}
.download {
padding: 6px 10px;
color: #c8d2e2;
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 #2e3a52;
}
}
</style>