Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandSimilarAsinTab.vue
2026-05-12 23:25:12 +08:00

1008 lines
40 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="prompt-card">
<div class="section-title">筛选条件</div>
<textarea v-model="filterConditionInput" class="prompt-input" rows="4" placeholder="请输入筛选条件" />
<div class="condition-actions">
<button type="button" class="opt-btn" :disabled="savingCondition" @click="saveFilterCondition">
{{ savingCondition ? '保存中...' : '保存' }}
</button>
</div>
<div class="section-title condition-list-title">备选区</div>
<div v-if="!filterConditions.length" class="empty-conditions">暂无筛选条件输入后保存即可加入备选区</div>
<ul v-else class="condition-list">
<li v-for="condition in filterConditions" :key="condition.id" class="condition-item">
<label class="condition-check">
<input
v-model="selectedFilterConditionId"
type="radio"
name="similar-asin-filter-condition"
:value="condition.id"
@change="applySelectedFilterCondition(condition)"
/>
<span :title="condition.conditionText">{{ condition.conditionText }}</span>
</label>
<button type="button" class="link-danger" @click="removeFilterCondition(condition.id)">删除</button>
</li>
</ul>
</div>
<div class="category-card">
<div class="category-card-head">
<div class="section-title">商品类目</div>
<div class="category-actions">
<button type="button" class="opt-btn" @click="openCategoryDialog">选择类目</button>
<button
v-if="selectedProductCategoryIds.length"
type="button"
class="link-danger"
@click="clearSelectedProductCategories"
>
清空
</button>
</div>
</div>
<div v-if="!selectedProductCategories.length" class="empty-conditions">暂未选择商品类目</div>
<div v-else class="selected-category-list">
<span v-for="category in selectedProductCategories" :key="category.id" class="category-chip">
{{ category.path || category.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">行数{{ item.rowCount ?? '-' }}</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 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>{{ item.fileProgressMessage || '结果生成中' }}</span>
<span>{{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
<div class="file-progress-count">
{{ item.fileProgressCurrent || 0 }}/{{ item.fileProgressTotal || 0 }}
</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>
<el-dialog
v-model="categoryDialogVisible"
title="选择商品类目"
width="560px"
class="category-dialog"
>
<div class="category-dialog-toolbar">
<span class="muted">可多选确认后会追加到筛选条件 prompt 后面</span>
<button type="button" class="link-danger" @click="clearCategoryTreeSelection">清空选择</button>
</div>
<div v-if="loadingProductCategories" class="empty-tasks">正在加载商品类目...</div>
<div v-else-if="!productCategoryTree.length" class="empty-tasks">暂无商品类目</div>
<el-tree
v-else
ref="categoryTreeRef"
class="category-tree"
:data="productCategoryTree"
node-key="id"
show-checkbox
default-expand-all
:props="{ label: 'name', children: 'children' }"
>
<template #default="{ data }">
<span class="category-tree-node">
<span>{{ data.name }}</span>
<span v-if="data.description" class="category-description">{{ data.description }}</span>
</span>
</template>
</el-tree>
<template #footer>
<button type="button" class="opt-btn" @click="categoryDialogVisible = false">取消</button>
<button type="button" class="btn-run category-confirm" @click="confirmCategorySelection">确定</button>
</template>
</el-dialog>
</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,
addSimilarAsinFilterCondition,
deleteSimilarAsinFilterCondition,
deleteSimilarAsinHistory,
deleteSimilarAsinTask,
getSimilarAsinDashboard,
getSimilarAsinHistory,
getSimilarAsinResultDownloadUrl,
getSimilarAsinTaskProgressBatch,
listProductCategories,
listSimilarAsinFilterConditions,
parseSimilarAsin,
type ProductCategoryItemVo,
type SimilarAsinFilterConditionVo,
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 filterConditionInput = ref('')
const filterConditions = ref<SimilarAsinFilterConditionVo[]>([])
const selectedFilterConditionId = ref<number | null>(null)
const productCategoryTree = ref<ProductCategoryItemVo[]>([])
const productCategoryItems = ref<ProductCategoryItemVo[]>([])
const selectedProductCategoryIds = ref<number[]>([])
const categoryDialogVisible = ref(false)
const loadingProductCategories = ref(false)
const categoryTreeRef = ref<any>(null)
const parsing = ref(false)
const pushing = ref(false)
const savingCondition = 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 selectedFilterConditionText() {
const selectedId = selectedFilterConditionId.value
if (selectedId == null) return ''
return filterConditions.value.find((item) => item.id === selectedId)?.conditionText?.trim() || ''
}
function effectiveFilterCondition() {
return appendProductCategoriesToPrompt(filterConditionInput.value.trim() || selectedFilterConditionText())
}
function effectiveCozeApiKey() {
return getStoredApiSecret('similar-asin').trim()
}
const selectedProductCategories = computed(() => {
const selected = new Set(selectedProductCategoryIds.value)
return productCategoryItems.value.filter((item) => selected.has(item.id))
})
function appendProductCategoriesToPrompt(prompt: string) {
const lines = selectedProductCategories.value
.map((item) => {
const path = item.path || item.name
const description = item.description?.trim()
return description ? `${path}${description}` : path
})
.filter(Boolean)
if (!lines.length) return prompt
const categoryBlock = [
'商品类目要求:',
...lines.map((line) => `- ${line}`),
].join('\n')
return prompt ? `${prompt}\n\n${categoryBlock}` : categoryBlock
}
async function loadProductCategories() {
if (loadingProductCategories.value) return
loadingProductCategories.value = true
try {
const res = await listProductCategories()
productCategoryTree.value = res.tree || []
productCategoryItems.value = res.items || []
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '加载商品类目失败')
} finally {
loadingProductCategories.value = false
}
}
async function openCategoryDialog() {
categoryDialogVisible.value = true
if (!productCategoryTree.value.length) {
await loadProductCategories()
}
requestAnimationFrame(() => {
categoryTreeRef.value?.setCheckedKeys?.(selectedProductCategoryIds.value)
})
}
function hasCheckedDescendant(item: ProductCategoryItemVo, checkedIds: Set<number>): boolean {
return Boolean(item.children?.some((child) => checkedIds.has(child.id) || hasCheckedDescendant(child, checkedIds)))
}
function confirmCategorySelection() {
const checkedNodes = (categoryTreeRef.value?.getCheckedNodes?.(false, false) || []) as ProductCategoryItemVo[]
const checkedIds = new Set(checkedNodes.map((item) => item.id))
selectedProductCategoryIds.value = checkedNodes
.filter((item) => !hasCheckedDescendant(item, checkedIds))
.map((item) => item.id)
categoryDialogVisible.value = false
}
function clearCategoryTreeSelection() {
categoryTreeRef.value?.setCheckedKeys?.([])
}
function clearSelectedProductCategories() {
selectedProductCategoryIds.value = []
categoryTreeRef.value?.setCheckedKeys?.([])
}
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 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 loadFilterConditions() {
try {
filterConditions.value = await listSimilarAsinFilterConditions()
const ids = new Set(filterConditions.value.map((item) => item.id))
if (selectedFilterConditionId.value != null && !ids.has(selectedFilterConditionId.value)) {
selectedFilterConditionId.value = null
}
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '加载筛选条件失败')
}
}
async function saveFilterCondition() {
const text = filterConditionInput.value.trim()
if (!text) {
ElMessage.warning('请输入筛选条件')
return
}
savingCondition.value = true
try {
const saved = await addSimilarAsinFilterCondition(text)
await loadFilterConditions()
if (saved?.id) selectedFilterConditionId.value = saved.id
ElMessage.success('筛选条件已保存')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '保存筛选条件失败')
} finally {
savingCondition.value = false
}
}
function applySelectedFilterCondition(condition: SimilarAsinFilterConditionVo) {
filterConditionInput.value = condition.conditionText || ''
}
async function removeFilterCondition(id: number) {
try {
await deleteSimilarAsinFilterCondition(id)
if (selectedFilterConditionId.value === id) selectedFilterConditionId.value = null
await loadFilterConditions()
ElMessage.success('筛选条件已删除')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '删除筛选条件失败')
}
}
async function parseFiles() {
if (!uploadedFiles.value.length) {
ElMessage.warning('请先选择 Excel')
return
}
const filterCondition = effectiveFilterCondition()
if (!filterCondition) {
ElMessage.warning('请输入筛选条件,或在备选区选择一个筛选条件')
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, filterCondition, 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,
prompt: currentParseResult.aiPrompt || effectiveFilterCondition(),
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()
}
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 = res.items || []
lastHistoryLoadedAt = Date.now()
})
.finally(() => {
historyInFlight = null
})
return historyInFlight
}
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 (!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)) return 0
return Math.max(0, Math.min(100, Math.round(percent)))
}
function showFileProgress(item: SimilarAsinHistoryItem) {
return isResultPreparing(item) && (item.fileProgressTotal || 0) > 0
}
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([
loadFilterConditions().catch(() => undefined),
loadProductCategories().catch(() => undefined),
loadDashboard().catch(() => undefined),
loadHistory().catch(() => undefined),
])
seedPendingFileTasksFromHistory()
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; }
.prompt-card { margin-bottom: 14px; }
.prompt-input { width: 100%; box-sizing: border-box; resize: vertical; min-height: 96px; max-height: 140px; padding: 8px 12px; border: 1px solid #333; border-radius: 8px; background: #202020; color: #d8d8d8; font-size: 12px; line-height: 1.5; outline: none; }
.prompt-input:focus { border-color: #3498db; }
.condition-actions { display: flex; justify-content: flex-end; margin-top: 8px; }
.condition-actions .opt-btn { padding: 6px 14px; }
.condition-list-title { margin-top: 10px; }
.empty-conditions { color: #666; font-size: 12px; padding: 10px 4px; }
.condition-list { list-style: none; margin: 0; padding: 0; max-height: 96px; overflow: auto; display: flex; flex-direction: column; gap: 6px; }
.condition-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 6px; border-bottom: 1px solid #303030; }
.condition-item:last-child { border-bottom: none; }
.condition-check { display: flex; align-items: center; gap: 8px; min-width: 0; color: #cfd6df; font-size: 12px; cursor: pointer; }
.condition-check input { width: 16px; height: 16px; margin: 0; flex: 0 0 auto; }
.condition-check span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link-danger { border: none; background: transparent; color: #ff8f8f; cursor: pointer; font-size: 12px; padding: 2px 0; white-space: nowrap; }
.category-card { margin: 0 0 14px; padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; background: #202020; }
.category-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.category-card-head .section-title { margin-bottom: 0; }
.category-actions { display: flex; align-items: center; gap: 10px; }
.category-actions .opt-btn { padding: 6px 12px; }
.selected-category-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; max-height: 96px; overflow: auto; }
.category-chip { max-width: 100%; padding: 4px 8px; border-radius: 6px; background: rgba(52, 152, 219, .16); color: #cfe7ff; font-size: 12px; line-height: 1.4; }
.category-dialog-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.category-tree { max-height: 420px; overflow: auto; padding: 8px; border: 1px solid #2f2f2f; border-radius: 8px; background: #202020; --el-tree-bg-color: #202020; --el-tree-text-color: #d8d8d8; --el-tree-node-hover-bg-color: #2a2a2a; }
.category-tree-node { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
.category-description { color: #888; font-size: 12px; }
.category-confirm { padding: 8px 16px; }
.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>