1008 lines
40 KiB
Vue
1008 lines
40 KiB
Vue
<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 后点击解析,后端会提取 id、ASIN、国家、价格,每一行都会独立送检。</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 回传字段按 id、asin、国家、价格、标题、图片链接数组提交;后端接收分片后按 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>
|