style(frontend): 全站背景图 webp 化(bg.jpg 4MB→251KB 压缩 + bg.webp)+品牌工具页默认策略调优(品牌检测默认改精确匹配 Simple、Terms 后置)+任务面板/登录页/亚马逊控制台观感微调

This commit is contained in:
2026-09-09 18:32:48 +08:00
parent 9a487ae9a7
commit 83cb3ff84e
29 changed files with 565 additions and 570 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

@@ -363,14 +363,14 @@ onMounted(async () => {
font-size: 11px;
}
/* Hero 右侧工作台卡(350 宽 / 10px 圆角,对齐 gemini-code hero-right-card */
/* Hero 右侧工作台卡(370 宽 / 10px 圆角,对齐 gemini-code hero-right-card */
.hero-card {
width: 350px;
width: 370px;
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 20px;
padding: 22px;
border: 1px solid #3e4a62;
border-radius: 10px;
background: #2b3447;
@@ -406,15 +406,26 @@ onMounted(async () => {
background: #f59e0b;
}
.hero-card-btn-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.hero-card-btn-row .hero-card-sub {
margin: 0;
}
.hero-card-caption {
color: #a0acbe;
font-size: 11px;
font-size: 13px;
}
.hero-card-title {
margin: 0 0 12px;
color: #f5f8fc;
font-size: 17px;
font-size: 20px;
font-weight: 700;
line-height: 1.4;
}
@@ -428,7 +439,7 @@ onMounted(async () => {
.hero-card-sub {
margin: 0;
color: #c8d2e2;
font-size: 11px;
font-size: 13px;
line-height: 1.5;
}
@@ -448,12 +459,12 @@ onMounted(async () => {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
padding: 9px 20px;
border: none;
border-radius: 6px;
background: linear-gradient(135deg, #f59e0b, #d97706);
color: #ffffff;
font-size: 12px;
font-size: 13px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
@@ -486,16 +497,16 @@ onMounted(async () => {
.workflow-title {
color: #f5f8fc;
font-size: 14px;
font-size: 16px;
font-weight: 700;
}
.workflow-badge {
padding: 2px 8px;
padding: 3px 10px;
border-radius: 4px;
background: #3a2a10;
color: #f59e0b;
font-size: 10px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
@@ -503,7 +514,7 @@ onMounted(async () => {
.workflow-hint {
margin-left: auto;
color: #a0acbe;
font-size: 11px;
font-size: 12px;
white-space: nowrap;
}
@@ -548,25 +559,25 @@ onMounted(async () => {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
width: 34px;
height: 34px;
border-radius: 50%;
margin-bottom: 6px;
font-size: 12px;
font-size: 13px;
font-weight: 700;
}
.step-name {
margin-bottom: 2px;
color: #c8d2e2;
font-size: 11px;
font-size: 13px;
font-weight: 700;
text-align: center;
}
.step-desc {
color: #a0acbe;
font-size: 9px;
font-size: 11px;
text-align: center;
}
@@ -600,19 +611,19 @@ onMounted(async () => {
.group-name {
color: #f5f8fc;
font-size: 16px;
font-size: 18px;
font-weight: 700;
}
.group-desc {
color: #a0acbe;
font-size: 11px;
font-size: 12px;
}
.group-count {
margin-left: auto;
color: #a0acbe;
font-size: 11px;
font-size: 12px;
}
/* 卡片墙(CARD 底 + 2px #2A3344 边框 + 10px 圆角 min-height 165,对齐 gemini-code tool-card */
@@ -107,9 +107,9 @@ const backHref = computed(() => `${resolvePageHref('/new_web_source/amazon-conso
.tool-head__desc {
min-width: 0;
max-width: 650px;
max-width: 760px;
color: #a0acbe;
font-size: 12px;
font-size: 14px;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
@@ -1,5 +1,7 @@
<template>
<header class="amazon-top">
<router-link class="home-back" :to="homeHref" title="返回首页"> 返回首页</router-link>
<router-link class="brand" :to="toolbarHref" title="返回工具台首页">
<span class="brand-logo"></span>
<span class="brand-text">
@@ -28,9 +30,7 @@
<div class="top-right">
<BrandApiSecretSettingsButton variant="topbar" />
<router-link class="help-btn" :to="workHref" title="采集工作台"></router-link>
<span class="help-btn" title="如需支持请点击底部BUG登记系统">?</span>
<span class="admin-chip"><span class="admin-badge">AD</span>{{ username }}</span>
<span class="admin-name">{{ username }}</span>
</div>
</header>
</template>
@@ -62,8 +62,8 @@ const username = ref('admin')
/** 分类 tab 点击回到工具台对应分组 */
const toolbarHref = resolvePageHref('/new_web_source/amazon-console.html')
/** ⚙ 采集工作台:直达现有真实功能页(对齐 gemini-code 顶栏 ⚙ 入口) */
const workHref = resolvePageHref('/new_web_source/collect-data.html')
/** 返回桌面入口首页(亚马逊/视频/图片入口) */
const homeHref = resolvePageHref('/new_web_source/home.html')
function onTabClick(key: 'front' | 'ops' | 'logi') {
emit('change', key)
@@ -114,6 +114,27 @@ onMounted(async () => {
text-decoration: none;
}
/* 返回首页按钮(顶栏最左,入口页见 DesktopHomePage */
.home-back {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid #3e4a62;
border-radius: 6px;
color: #c8d2e2;
font-size: 13px;
text-decoration: none;
white-space: nowrap;
transition: all 0.2s;
}
.home-back:hover {
background: #2b3447;
color: #f5f8fc;
}
.brand-logo {
display: flex;
align-items: center;
@@ -161,11 +182,11 @@ onMounted(async () => {
display: flex;
flex-direction: column;
align-items: center;
width: 140px;
height: 42px;
width: 150px;
height: 44px;
background: transparent;
color: #c8d2e2;
font-size: 13px;
font-size: 15px;
font-weight: 700;
text-decoration: none;
transition: all 0.2s;
@@ -175,7 +196,7 @@ onMounted(async () => {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
padding: 7px 14px;
border-radius: 8px;
transition: background 0.2s;
}
@@ -194,8 +215,8 @@ onMounted(async () => {
}
.cat-dot {
width: 8px;
height: 8px;
width: 9px;
height: 9px;
border-radius: 50%;
flex-shrink: 0;
}
@@ -204,11 +225,11 @@ onMounted(async () => {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 1px 7px;
padding: 1px 8px;
border-radius: 12px;
background: #343f55;
color: #a0acbe;
font-size: 10px;
font-size: 11px;
font-weight: 700;
}
@@ -230,47 +251,13 @@ onMounted(async () => {
flex-shrink: 0;
}
.help-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
.admin-name {
color: #c8d2e2;
font-size: 16px;
font-weight: 700;
text-decoration: none;
cursor: pointer;
padding: 4px 8px;
}
.help-btn:hover {
color: #f5f8fc;
}
.admin-chip {
display: inline-flex;
align-items: center;
gap: 8px;
color: #c8d2e2;
font-size: 12px;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
}
.admin-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 20px;
padding: 0 4px;
background: #2a6b4f;
color: #ffffff;
font-size: 10px;
font-weight: 700;
}
@media (max-width: 1100px) {
.amazon-top {
flex-wrap: wrap;
@@ -391,12 +391,12 @@ loadStates()
/* topbar 变体:纯文字「🔑 密钥设置」,hover 整块变 CARD2(对齐主程序顶栏) */
.secret-settings-trigger.variant-topbar {
height: auto;
padding: 4px 10px;
padding: 6px 12px;
border: 0;
border-radius: 0;
background: transparent;
color: #c8d2e2;
font-size: 11px;
font-size: 14px;
font-weight: 400;
transition: background 0.12s ease, color 0.12s ease;
}
@@ -408,7 +408,7 @@ loadStates()
.secret-settings-trigger.variant-topbar .secret-settings-icon {
color: #c8d2e2;
font-size: 12px;
font-size: 15px;
}
.secret-settings-icon {
@@ -6,7 +6,7 @@
<aside class="left-panel left-panel--with-template">
<div class="section-title">上传文件</div>
<div class="upload-zone">
<div class="hint">选择 Excel 后点击解析后端会提取 idASIN国家并保留整数 id 与子数据的第一</div>
<div class="hint">选择 Excel 后点击解析系统会识别表格里的 IDASIN国家信息同一 ID 有多行时只保留第一</div>
<div class="btns">
<button type="button" class="opt-btn" @click="selectFiles">选择 Excel</button>
<button type="button" class="opt-btn" @click="selectFolder">选择文件夹</button>
@@ -19,7 +19,7 @@
<div class="prompt-card">
<div class="section-title">新增条件要求</div>
<textarea v-model="aiPrompt" class="prompt-input" rows="8" placeholder="可选,留空则不向 LLM 传 prompt 字段;填写后作为 prompt 透传" />
<textarea v-model="aiPrompt" class="prompt-input" rows="8" placeholder="选填:填写后作为额外要求一并提交给检测" />
</div>
<div class="run-row">
@@ -30,7 +30,7 @@
{{ pushing ? '启动中...' : '启动任务' }}
</button>
</div>
<p class="loading-msg">Python 回传字段按 asin国家url标题提交后端接收分片后累计 50 条调用 LLM当前任务区展示 LLM 回流和结果组装进度</p>
<p class="loading-msg">提交后系统自动分批检测进度显示在右侧任务区完成后可下载结果</p>
<div v-if="visibleTaskSummary" class="parse-card">
<div>任务 ID{{ visibleTaskSummary.taskId }}</div>
@@ -477,7 +477,7 @@ async function pushToPythonQueue() {
})),
},
}
queuePayloadText.value = `任务 ${taskId} 已准备推送,共 ${groups.length}`
queuePayloadText.value = `任务 ${taskId} 已准备启动,共 ${groups.length}`
// 后端返回的 groups 为空时推送出去,Python 端没有可执行批次会一直停在执行中
const guard = checkQueuePayload(payload, {
expectedType: 'appearance-patent-run',
@@ -488,7 +488,7 @@ async function pushToPythonQueue() {
await activateAppearancePatentTask(taskId)
const result = await api.enqueue_json(payload)
if (!result?.success) {
ElMessage.error(result?.error || '推送失败')
ElMessage.error(result?.error || '启动失败')
return
}
queuedTaskSummary.value = toTaskSummary(currentParseResult)
@@ -772,7 +772,7 @@ function hasMeaningfulProgressValue(value: unknown) {
function statusText(item: AppearancePatentHistoryItem) {
const status = normalizeTaskStatus(item)
if (status === 'RUNNING') return '执行中'
if (status === 'PENDING') return '已解析待推送'
if (status === 'PENDING') return '已解析待启动'
if (isResultPreparing(item)) return '结果生成中'
if (isResultBuildFailed(item)) return '结果生成失败'
if (status === 'FAILED') return '失败'
@@ -817,10 +817,10 @@ function pendingResultHint(item: AppearancePatentHistoryItem) {
if (!isResultPreparing(item)) return ''
const fileStatus = (item.fileStatus || '').toUpperCase()
if (fileStatus === 'RUNNING') {
return '任务已完成,正在等待 LLM 回流并组装结果文件,下载按钮稍后出现'
return '任务已完成,正在生成结果文件,下载按钮稍后出现'
}
if (fileStatus === 'PENDING') {
return '任务已完成,结果文件已入队,正在等待后端开始处理'
return '任务已完成,结果文件排队中,请稍候'
}
return '任务已完成,正在生成结果文件,下载按钮稍后出现'
}
@@ -853,19 +853,19 @@ function displayFileProgressMessage(item: AppearancePatentHistoryItem, fallback:
function translateFileProgressMessage(message: string) {
const submittedMatch = message.match(/^LLM submitted (\d+) batches, completed (\d+), waiting for results$/)
if (submittedMatch) {
return `已提交 LLM ${submittedMatch[1]} 批,已完成 ${submittedMatch[2]} 批,等待结果回流`
return `已提交 ${submittedMatch[1]} 批,已完成 ${submittedMatch[2]} 批,等待结果`
}
const completedMatch = message.match(/^LLM completed (\d+) batches, waiting for remaining Python data$/)
if (completedMatch) {
return `LLM 已完成 ${completedMatch[1]} 批,等待 Python 继续回传数据`
return `已完成 ${completedMatch[1]} 批,等待后续数据`
}
const translations: Record<string, string> = {
'Receiving Python data, waiting for 50 rows before LLM': '正在接收 Python 数据,累计 50 条后提交 LLM',
'Submitting LLM': '正在提交 LLM',
'LLM submitted, waiting for result': 'LLM 已提交,等待结果回流',
'Waiting for Python upload': '等待 Python 继续回传数据',
'LLM results ready, assembling xlsx': 'LLM 结果已回,正在组装 xlsx',
'Assembling xlsx': '正在组装 xlsx',
'Receiving Python data, waiting for 50 rows before LLM': '正在接收检测数据,累计 50 条后提交检测',
'Submitting LLM': '正在提交检测',
'LLM submitted, waiting for result': '已提交检测,等待结果',
'Waiting for Python upload': '等待后续数据',
'LLM results ready, assembling xlsx': '检测结果已回,正在生成结果文件',
'Assembling xlsx': '正在生成结果文件',
'Uploading result file': '正在上传结果文件',
'Result file generated': '结果文件已生成',
}
@@ -967,18 +967,18 @@ async function batchDeleteHistory(views: TaskItemView[]) {
</script>
<style scoped>
.module-page { min-height: 100vh; background: #151a25; }
.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: #1c2333; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; min-width: 0; background: #151a25; display: flex; flex-direction: column; }
.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, .subsection-title { font-size: 13px; color: #a0acbe; margin-bottom: 10px; }
.upload-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 18px; background: #2e3a52; margin-bottom: 18px; }
.upload-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 18px; background: #242424; margin-bottom: 18px; }
.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: #2e3a52; border: 1px solid #3e4a62; }
.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; }
@@ -991,7 +991,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.prompt-input:focus, .secret-input:focus { border-color: #3498db; }
.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-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.result-table { --el-table-bg-color: #242424; --el-table-tr-bg-color: #242424; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.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); }
@@ -27,16 +27,16 @@
<div class="section-title">匹配策略</div>
<div class="radio-cards">
<label class="radio-card" :class="{ selected: strategy === 'Terms' }">
<input type="radio" name="brand-strategy" value="Terms" v-model="strategy" />
<span class="radio-card-name">词条 / 嵌入匹配Terms</span>
<span class="radio-card-desc">按品牌词条做宽范围比对误杀率低推荐第一遍使用</span>
</label>
<label class="radio-card" :class="{ selected: strategy === 'Simple' }">
<input type="radio" name="brand-strategy" value="Simple" v-model="strategy" />
<span class="radio-card-name">精确匹配Simple</span>
<span class="radio-card-desc">仅精确命中才判为疑似误杀最低</span>
</label>
<label class="radio-card" :class="{ selected: strategy === 'Terms' }">
<input type="radio" name="brand-strategy" value="Terms" v-model="strategy" />
<span class="radio-card-name">词条 / 嵌入匹配Terms</span>
<span class="radio-card-desc">按品牌词条做宽范围比对误杀率低推荐第一遍使用</span>
</label>
</div>
<div class="section-title">运行方式</div>
@@ -128,7 +128,7 @@ import type { UploadedFileRef } from '@/shared/api/types/upload.ts'
import type { BrandExpandFolderItem } from '@/shared/api/types/modules/brand'
const uploadedFiles = ref<UploadFileVo[]>([])
const strategy = ref<'Terms' | 'Simple'>('Terms')
const strategy = ref<'Terms' | 'Simple'>('Simple')
const submitting = ref(false)
const tasks = ref<BrandTaskItem[]>([])
@@ -580,17 +580,17 @@ async function batchDeleteHistory(views: TaskItemView[]) {
</script>
<style scoped>
.module-page { min-height: 100vh; background: #151a25; }
.module-page { min-height: 100vh; background: #242424; }
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
.left-panel { width: 420px; background: #1c2333; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; min-width: 0; background: #151a25; display: flex; flex-direction: column; }
.left-panel { width: 420px; 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: 12px 0 10px; }
.hint, .loading-msg, .files, .muted { color: #5e6878; font-size: 12px; line-height: 1.5; }
.hint { margin-bottom: 12px; }
.upload-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 16px; background: #2e3a52; margin-bottom: 6px; }
.upload-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 16px; background: #242424; margin-bottom: 6px; }
.btns, .run-row { display: flex; gap: 10px; flex-wrap: wrap; }
.run-row { margin-top: 14px; }
.opt-btn { padding: 8px 14px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 7px; cursor: pointer; font-size: 12px; }
.opt-btn { padding: 8px 14px; color: #c8d2e2; background: #242424; border: 1px solid #3e4a62; border-radius: 7px; cursor: pointer; font-size: 12px; }
.opt-btn:hover:not(:disabled) { color: #3498db; border-color: #3498db; }
.opt-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.selected-files { margin-top: 12px; color: #a0acbe; font-size: 12px; word-break: break-all; }
@@ -599,19 +599,19 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.bridge-tip { margin-top: 10px; color: #e6a23c; font-size: 11px; }
.env-tip { margin-top: 10px; padding: 8px 10px; border-radius: 8px; background: rgba(230, 162, 60, 0.1); color: #e6a23c; font-size: 12px; line-height: 1.6; }
.radio-cards { display: flex; flex-direction: column; gap: 8px; margin-bottom: 6px; }
.radio-card { display: flex; flex-direction: column; gap: 3px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; background: #2e3a52; cursor: pointer; }
.radio-card { display: flex; flex-direction: column; gap: 3px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; background: #242424; cursor: pointer; }
.radio-card.selected { border-color: #409eff; background: rgba(64, 158, 255, 0.1); }
.radio-card input { position: absolute; opacity: 0; pointer-events: none; }
.radio-card-name { color: #c8d2e2; font-size: 13px; }
.radio-card-desc { color: #5e6878; font-size: 11px; }
.run-mode-zone { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; background: #2e3a52; }
.run-mode-zone { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; background: #242424; }
.run-mode-row { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; color: #c8d2e2; font-size: 12px; }
.run-mode-row input { width: 15px; height: 15px; accent-color: #409eff; }
.btn-run { padding: 10px 18px; color: #f5f8fc; background: #3498db; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
.btn-run:disabled { opacity: 0.5; cursor: not-allowed; }
.loading-msg { margin-top: 10px; }
.panel-actions-row { display: flex; justify-content: flex-end; padding: 10px 20px 0; }
.btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; }
.btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #242424; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; }
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
.download:hover { background: rgba(52, 152, 219, 0.28); }
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; }
@@ -6,7 +6,7 @@
<aside class="left-panel left-panel--with-template">
<div class="section-title">上传文件</div>
<div class="upload-zone">
<div class="hint">选择需要采集的 Excel 文件或文件夹默认根目录采集前数据.xlsx后续将按下方筛选条件交给 Python 端进行数据采集</div>
<div class="hint">选择需要采集的 Excel 文件或文件夹默认根目录采集前数据.xlsx系统将按下方筛选条件自动采集数据</div>
<div class="btns">
<button type="button" class="opt-btn" @click="selectFiles">选择 Excel</button>
<button type="button" class="opt-btn" @click="selectFolder">选择文件夹</button>
@@ -105,10 +105,10 @@
{{ pushing ? '启动中...' : '启动任务' }}
</button>
</div>
<p class="loading-msg">提交后 Java 解析 Excel 并落库为待采集任务PENDING启动任务将激活任务RUNNINGPython 端按 50 /页拉取明细执行采集</p>
<p class="loading-msg">提交后自动识别 Excel 内容再点启动任务即开始采集进度和结果在右侧查看</p>
<div v-if="queuePushResult" class="queue-debug-card">
<div class="section-title queue-debug-title">推送结果</div>
<div class="section-title queue-debug-title">启动结果</div>
<div class="queue-debug-line">{{ queuePushResult }}</div>
<pre v-if="queuePayloadText" class="queue-debug-payload">{{ queuePayloadText }}</pre>
</div>
@@ -432,7 +432,7 @@ async function submitCollect() {
await Promise.all([loadDashboard(), loadHistory()])
return
}
ElMessage.success(`提交成功:任务 ${vo.taskNo || vo.taskId}落库 ${vo.acceptedRows ?? 0}`)
ElMessage.success(`提交成功:任务 ${vo.taskNo || vo.taskId}识别 ${vo.acceptedRows ?? 0}有效数据`)
await Promise.all([loadDashboard(), loadHistory()])
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '提交失败')
@@ -477,9 +477,9 @@ async function pushToPythonQueue() {
activated = true
const result = await api.enqueue_json(payload)
if (!result?.success) {
throw new Error(result?.error || '入队失败')
throw new Error(result?.error || '启动失败')
}
queuePushResult.value = `已启动任务(队列长度${result.queue_size ?? '-'}`
queuePushResult.value = `已启动任务(排队中的任务数${result.queue_size ?? '-'}`
queuePayloadText.value = JSON.stringify(payload, null, 2)
if (lastTaskId.value) {
progressLoop.add(lastTaskId.value)
@@ -494,7 +494,7 @@ async function pushToPythonQueue() {
/* 保留原始入队错误 */
}
}
ElMessage.error(error instanceof Error ? error.message : '推送失败')
ElMessage.error(error instanceof Error ? error.message : '启动失败')
await Promise.all([loadDashboard(), loadHistory()])
} finally {
pushing.value = false
@@ -769,16 +769,16 @@ async function batchDeleteHistory(views: TaskItemView[]) {
</script>
<style scoped>
.module-page { min-height: 100vh; background: #151a25; }
.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: #1c2333; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; min-width: 0; background: #151a25; display: flex; flex-direction: column; }
.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: 12px 0 10px; }
.upload-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 18px; background: #2e3a52; margin-bottom: 18px; }
.upload-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 18px; background: #242424; margin-bottom: 18px; }
.hint, .loading-msg, .files, .muted { color: #5e6878; font-size: 12px; line-height: 1.5; }
.btns, .run-row { display: flex; gap: 10px; flex-wrap: wrap; }
.opt-btn, .btn-run, .btn-delete, .download { border: none; cursor: pointer; border-radius: 7px; }
.opt-btn { padding: 8px 14px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; }
.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-run.btn-queue { background: #27ae60; }
.btn-run:disabled { opacity: .55; cursor: not-allowed; }
@@ -790,7 +790,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
border: 1px solid #2e3a52;
border-radius: 8px;
padding: 12px;
background: #2e3a52;
background: #242424;
margin-bottom: 14px;
display: flex;
flex-direction: column;
@@ -810,7 +810,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
flex: 1;
min-width: 0;
padding: 6px 10px;
background: #1c2333;
background: #242424;
border: 1px solid #3e4a62;
border-radius: 6px;
color: #c8d2e2;
@@ -828,7 +828,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.country-section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.country-pref-saving { color: #3498db; font-size: 11px; }
.country-pref-radios { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 14px; }
.country-check-row { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; font-size: 12px; color: #c8d2e2; padding: 8px 10px; border: 1px solid #333; border-radius: 8px; background: #2e3a52; }
.country-check-row { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; font-size: 12px; color: #c8d2e2; padding: 8px 10px; border: 1px solid #333; border-radius: 8px; background: #242424; }
.country-check-row.selected { border-color: #409eff; background: rgba(64, 158, 255, .12); color: #e6f2ff; }
.country-check-input { width: 16px; height: 16px; accent-color: #409eff; cursor: pointer; flex-shrink: 0; }
.country-check-text { line-height: 1.3; }
@@ -462,7 +462,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
<style scoped>
.module-page {
min-height: 100vh;
background: #151a25;
background: #242424;
}
.main-content {
@@ -473,7 +473,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.left-panel {
width: 380px;
background: #1c2333;
background: #242424;
padding: 20px;
overflow-y: auto;
border-right: 1px solid #2e3a52;
@@ -484,7 +484,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
display: flex;
flex-direction: column;
min-width: 0;
background: #151a25;
background: #242424;
}
.section-title {
@@ -498,14 +498,14 @@ async function batchDeleteHistory(views: TaskItemView[]) {
border-radius: 10px;
padding: 24px;
text-align: center;
background: #2e3a52;
background: #242424;
margin-bottom: 20px;
transition: all 0.2s ease;
}
.upload-zone:hover {
border-color: #3498db;
background: #2e3a52;
background: #242424;
}
.hint {
@@ -536,7 +536,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
padding: 8px 16px;
font-size: 13px;
color: #c8d2e2;
background: #2e3a52;
background: #242424;
border: 1px solid #3e4a62;
border-radius: 6px;
}
@@ -592,7 +592,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
cursor: pointer;
padding: 10px 12px;
border-radius: 8px;
background: #2e3a52;
background: #242424;
border: 1px solid #2e3a52;
margin-bottom: 8px;
transition: all 0.2s ease;
@@ -608,7 +608,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.radio-item:hover,
.radio-item.active {
background: #2e3a52;
background: #242424;
border-color: #3e4a62;
}
@@ -492,7 +492,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
<style scoped>
.module-page {
min-height: 100vh;
background: #151a25;
background: #242424;
}
.main-content {
@@ -503,7 +503,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.left-panel {
width: 380px;
background: #1c2333;
background: #242424;
padding: 20px;
overflow-y: auto;
border-right: 1px solid #2e3a52;
@@ -514,7 +514,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
display: flex;
flex-direction: column;
min-width: 0;
background: #151a25;
background: #242424;
}
.section-title {
@@ -528,14 +528,14 @@ async function batchDeleteHistory(views: TaskItemView[]) {
border-radius: 10px;
padding: 24px;
text-align: center;
background: #2e3a52;
background: #242424;
margin-bottom: 20px;
transition: all 0.2s ease;
}
.upload-zone:hover {
border-color: #3498db;
background: #2e3a52;
background: #242424;
}
.hint {
@@ -565,7 +565,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
padding: 8px 16px;
font-size: 13px;
color: #c8d2e2;
background: #2e3a52;
background: #242424;
border: 1px solid #3e4a62;
border-radius: 6px;
}
@@ -607,7 +607,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.column-option-group {
padding: 14px;
border-radius: 10px;
background: #2e3a52;
background: #242424;
border: 1px solid #2e3a52;
}
@@ -682,7 +682,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
cursor: pointer;
padding: 10px 12px;
border-radius: 8px;
background: #2e3a52;
background: #242424;
border: 1px solid #2e3a52;
margin-bottom: 8px;
transition: all 0.2s ease;
@@ -690,7 +690,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.radio-item:hover,
.radio-item.active {
background: #2e3a52;
background: #242424;
border-color: #3e4a62;
}
@@ -6,7 +6,7 @@
<aside class="left-panel left-panel--with-template">
<div class="section-title">选择文件</div>
<div class="upload-zone">
<div class="hint">上传删除品牌 Excel无论是直接上传文件还是上传文件夹批量导入都会按每个 Excel 文件名作为店铺名</div>
<div class="hint">上传删除品牌 Excel直接上传文件上传文件夹批量导入都可以每个 Excel 文件名作为店铺名</div>
<div class="btns">
<button type="button" class="opt-btn" @click="selectFiles">选择 Excel 文件</button>
<button type="button" class="opt-btn" @click="selectFolder">选择文件夹</button>
@@ -299,7 +299,7 @@ const historySectionItems = computed(() =>
),
);
const queueRunnableItems = computed(() =>
currentSectionItems.value.filter((item) => canPushToQueue(item) && getQueueStatus(item) === '未入队'),
currentSectionItems.value.filter((item) => canPushToQueue(item) && getQueueStatus(item) === '未启动'),
)
const deleteBrandCards = computed<TaskStatCard[]>(() => {
@@ -477,7 +477,7 @@ function shouldShowProgress(item: DeleteBrandResultItem) {
const isProgressTarget = Boolean(progress?.has_progress && progress?.info?.file_name === item.sourceFilename)
// 多文件任务下,展示规则更宽松:
// 1) 当前活跃文件;2) 后端进度指向的文件;3) 已推送文件;4) 已完成文件(任务未终态时保留)。
// 1) 当前活跃文件;2) 后端进度指向的文件;3) 已启动文件;4) 已完成文件(任务未终态时保留)。
if (isActiveItem || isProgressTarget || sessionItem?._pushed) {
return true
}
@@ -497,7 +497,7 @@ function shouldShowProgress(item: DeleteBrandResultItem) {
return true
}
return qs === '已入队' || qs === '处理中'
return qs === '已提交' || qs === '处理中'
}
@@ -550,7 +550,7 @@ function formatDateTime(value?: string) {
function formatMatchResult(item: DeleteBrandResultItem) {
if (item.matchStatus === 'INDEX_STALE' && isUsableMatchedItem(item)) {
return `已匹配 ${item.shopId || ''}索引过期,可继续推送`.trim()
return `已匹配 ${item.shopId || ''}匹配信息已过期,可继续启动`.trim()
}
if (item.matchStatus === 'MATCHED' || item.matched) {
return `已匹配 ${item.shopId || ''}`.trim()
@@ -562,7 +562,7 @@ function formatMatchResult(item: DeleteBrandResultItem) {
return '存在多个同名店铺,请人工确认'
}
if (item.matchStatus === 'PENDING') {
return '店铺索引暂未就绪'
return '店铺尚未匹配完成'
}
if (item.matchStatus) {
return item.matchStatus
@@ -774,9 +774,9 @@ function getQueueStatus(item: DeleteBrandResultItem) {
return '处理中'
}
// 多文件:未推送且未完成,才视为未入队
// 多文件:未推送且未完成,才视为未启动
if (multiFileTask && !sessionItem?._pushed && !sessionItem?._completed) {
return '未入队'
return '未启动'
}
if (multiFileTask && status === 'SUCCESS') {
@@ -794,10 +794,10 @@ function getQueueStatus(item: DeleteBrandResultItem) {
}
if (sessionItem?._pushed) {
return '已入队'
return '已提交'
}
return '未入队'
return '未启动'
}
function canDownloadTaskResult(item: DeleteBrandResultItem) {
@@ -902,7 +902,7 @@ async function refreshTaskDetails(taskIds?: number[]) {
try {
const batch = await getDeleteBrandTaskProgress(ids)
if (waitingForBackendRecovery.value && chainStarted.value) {
queuePushResult.value = '后端已恢复,继续执行当前任务并自动衔接后续任务...'
queuePushResult.value = '服务已恢复,继续执行当前任务并自动衔接后续任务...'
}
waitingForBackendRecovery.value = false
let changed = false
@@ -923,10 +923,10 @@ async function refreshTaskDetails(taskIds?: number[]) {
}
} catch (error) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
const transient = ['无法连接到服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
if (transient && chainStarted.value) {
waitingForBackendRecovery.value = true
queuePushResult.value = '当前任务运行中,正在等待后端服务恢复...'
queuePushResult.value = '当前任务运行中,正在等待服务恢复...'
}
}
}
@@ -1123,8 +1123,8 @@ async function submitRun() {
const hasBlockedItems = normalizedItems.some((item) => !isUsableMatchedItem(item))
queuePushResult.value = hasRunnableItems
? '解析完成,可在左侧点击“启动任务”开始串行处理'
: '解析完成,当前没有可推送的文件'
? '解析完成,可在左侧点击“启动任务”开始依次处理'
: '解析完成,当前没有可启动的文件'
queuePayloadText.value = ''
if (normalizedItems.length && normalizedItems[0].taskId) {
@@ -1133,13 +1133,13 @@ async function submitRun() {
syncResultState()
if (hasBlockedItems) {
ElMessage.warning('部分文件尚未命中可用店铺索引,已保留状态信息,请等待后台刷新后重试。')
ElMessage.warning('部分文件尚未匹配到店铺,已保留状态信息,请后重试。')
} else if (hasStaleMatchedItems) {
ElMessage.warning('部分文件命中的是过期索引,仍可启动任务;后台刷新后会自动重新匹配。')
ElMessage.warning('部分文件匹配到的店铺信息已过期,仍可启动任务;后台会自动重新匹配。')
} else if (hasRunnableItems) {
ElMessage.success('删除品牌解析完成,请在左侧启动任务')
} else {
ElMessage.warning('当前没有可推送的文件,请先等待店铺索引刷新。')
ElMessage.warning('当前没有可启动的文件,请先等待店铺匹配完成。')
}
// 更新历史记录,以展示那些未进入队列的失败项
@@ -1183,7 +1183,7 @@ async function loadHistory() {
ensurePolling(true)
} catch (err) {
const message = (err instanceof Error ? err.message : String(err || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
const transient = ['无法连接到服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
if (!transient) {
console.error('loadHistory error:', err)
}
@@ -1263,7 +1263,7 @@ function isPythonQueueBusy() {
for (const task of sessionTasks.value) {
for (const item of task.items) {
const qs = getQueueStatus(item)
if (qs === '已入队' || qs === '处理中') {
if (qs === '已提交' || qs === '处理中') {
return true
}
}
@@ -1302,7 +1302,7 @@ function findNextAutoRunnableItem() {
if (getItemKey(item) === activeItemKey.value) return false
const sessionItem = findSessionItem(item.taskId, item)
if (sessionItem?._completed) return false
return getQueueStatus(item) === '未入队'
return getQueueStatus(item) === '未启动'
})
}
@@ -1325,7 +1325,7 @@ async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }
if (!sessionTask) return { success: false, retryable: false }
if (!api?.enqueue_json) {
ElMessage.error('当前环境未启用 pywebview enqueue_json(浏览器环境不会推送')
ElMessage.error('当前环境未启用任务队列能力(浏览器预览环境不会启动')
return { success: false, retryable: false }
}
@@ -1346,14 +1346,14 @@ async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }
nonEmptyArrayKeys: ['items'],
})
if (!(await passGuard(guard))) {
queuePushResult.value = `文件 ${item.sourceFilename || taskId} 数据校验未通过,已阻止推送`
queuePushResult.value = `文件 ${item.sourceFilename || taskId} 数据校验未通过,已阻止启动`
saveSessionTask(taskId, sessionTask.items, queuePushResult.value, queuePayloadText.value)
return { success: false, retryable: false }
}
const pushResult = await api.enqueue_json(payload)
let qpr = ''
if (pushResult?.success) {
qpr = `推送文件 ${item.sourceFilename}当前队列长度${pushResult.queue_size ?? '-'}`
qpr = `启动文件 ${item.sourceFilename}排队中的任务数${pushResult.queue_size ?? '-'}`
const sessionItem = findSessionItem(taskId, item)
if (sessionItem) {
@@ -1381,7 +1381,7 @@ async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }
async function pushToPythonQueue() {
const nextItem = findNextAutoRunnableItem()
if (!nextItem) {
ElMessage.warning('当前没有可推送的文件')
ElMessage.warning('当前没有可启动的文件')
return
}
@@ -1426,7 +1426,7 @@ async function maybeAutoAdvance() {
})
}
const status = getQueueStatus(currentItem)
if (status === '已入队' || status === '处理中') {
if (status === '已提交' || status === '处理中') {
debugAutoAdvance('skip: current item still running', {
currentKey,
status,
@@ -1581,7 +1581,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.module-page {
min-height: 100vh;
background: #151a25;
background: #242424;
}
.main-content {
@@ -1592,7 +1592,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.left-panel {
width: 380px;
background: #1c2333;
background: #242424;
padding: 20px;
overflow-y: auto;
border-right: 1px solid #2e3a52;
@@ -1603,7 +1603,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
display: flex;
flex-direction: column;
min-width: 0;
background: #151a25;
background: #242424;
}
.section-title {
@@ -1617,14 +1617,14 @@ async function batchDeleteHistory(views: TaskItemView[]) {
border-radius: 10px;
padding: 24px;
text-align: center;
background: #2e3a52;
background: #242424;
margin-bottom: 20px;
transition: all 0.2s ease;
}
.upload-zone:hover {
border-color: #3498db;
background: #2e3a52;
background: #242424;
}
.hint {
@@ -1654,7 +1654,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
padding: 8px 16px;
font-size: 13px;
color: #c8d2e2;
background: #2e3a52;
background: #242424;
border: 1px solid #3e4a62;
border-radius: 6px;
}
@@ -1701,7 +1701,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
cursor: pointer;
padding: 10px 12px;
border-radius: 8px;
background: #2e3a52;
background: #242424;
border: 1px solid #2e3a52;
margin-bottom: 8px;
transition: all 0.2s ease;
@@ -1709,7 +1709,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.radio-item:hover,
.radio-item.active {
background: #2e3a52;
background: #242424;
border-color: #3e4a62;
}
@@ -7,7 +7,7 @@
<div class="section-title">店铺输入</div>
<div class="input-zone">
<div class="hint">
左侧负责录入店铺并加入备选区点击匹配店铺只会生成匹配结果确认后需手动点击启动任务才会创建并执行任务任务只会跑下方勾选的国家
左侧负责录入店铺并加入备选区点击匹配店铺得到匹配结果确认后启动任务开始执行任务只处理下方勾选的国家
</div>
<div class="input-row">
<el-input
@@ -84,7 +84,7 @@
</button>
</div>
<div v-if="!deleteConditions.length" class="empty-conditions">
暂无删除条件保存后可在推送任务时多选携带
暂无删除条件保存后可在启动任务时多选携带
</div>
<ul v-else class="condition-list">
<li
@@ -141,11 +141,11 @@
</div>
<p class="loading-msg">
当前页面会把匹配结果合并为一个巡店删除任务推送多个店铺会在同一个任务和同一个结果文件中汇总
启动后会把所有已确认的店铺汇总成一个任务处理结果合并到同一个文件中
</p>
<div v-if="queuePushResult" class="queue-debug-card">
<div class="section-title queue-debug-title">队列状态</div>
<div class="section-title queue-debug-title">任务状态</div>
<div class="queue-debug-line">{{ queuePushResult }}</div>
<pre v-if="queuePayloadText" class="queue-debug-payload">{{
queuePayloadText
@@ -476,7 +476,7 @@ function formatMatchStatus(status?: string) {
MATCHED: "已匹配",
PENDING: "待匹配",
CONFLICT: "需人工确认",
INDEX_STALE: "索引过期",
INDEX_STALE: "匹配已过期",
};
return map[value] || value || "-";
}
@@ -485,12 +485,12 @@ function formatMatchRemark(row: PatrolDeleteShopQueueItem) {
const message = (row.matchMessage || "").trim();
if (message) return message;
if (row.matched && row.matchStatus === "MATCHED") {
return "索引已命中,可启动任务";
return "已匹配成功,可启动任务";
}
if (row.matched) {
return "已命中索引,请结合状态确认";
return "已匹配成功,请查看状态确认";
}
return "未命中或未就绪,请检查店铺名";
return "未匹配成功,请检查店铺名";
}
function buildTemplateCountrySections(): PatrolDeleteCountrySection[] {
@@ -1011,7 +1011,7 @@ async function waitForTaskTerminal(taskId: number) {
}
if (activeTaskId.value !== taskId) return "DELETED";
if (transientErrorCount > 0) {
queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`;
queuePushResult.value = `任务 ${taskId} 服务已恢复,继续等待执行结果...`;
}
transientErrorCount = 0;
const item = findHistoryItemByTaskId(taskId);
@@ -1026,10 +1026,10 @@ async function waitForTaskTerminal(taskId: number) {
transientErrorCount += 1;
if (transientErrorCount >= MAX_TRANSIENT_ERRORS) {
throw new Error(
`任务 ${taskId} 等待后端恢复超时,请稍后刷新查看状态`,
`任务 ${taskId} 等待服务恢复超时,请稍后刷新查看状态`,
);
}
queuePushResult.value = `任务 ${taskId} 执行中,正在等待后端服务恢复(${transientErrorCount}/${MAX_TRANSIENT_ERRORS}...`;
queuePushResult.value = `任务 ${taskId} 执行中,正在等待服务恢复(${transientErrorCount}/${MAX_TRANSIENT_ERRORS}...`;
}
await sleep(getTaskPollIntervalMs());
}
@@ -1065,7 +1065,7 @@ async function processQueue() {
const created = await withTransientRetry(
() => createPatrolDeleteTask(runnable.map(buildTaskItem)),
(attempt, maxAttempts) => {
queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...`;
queuePushResult.value = `服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...`;
},
);
const createdItems = created.items || [];
@@ -1092,7 +1092,7 @@ async function processQueue() {
await submitPatrolDeleteTaskResult(created.taskId, {
shops: createdItems.map((createdItem) => ({
shopName: createdItem.shopName || "",
error: `任务 ${created.taskId} 数据校验未通过,已阻止推送`,
error: `任务 ${created.taskId} 数据校验未通过,已阻止启动`,
countrySections: createdItem.countrySections || [],
cartRatios: createdItem.cartRatios || [],
shopDone: true,
@@ -1102,7 +1102,7 @@ async function processQueue() {
})),
});
await refreshTaskViews();
queuePushResult.value = `任务 ${created.taskId} 数据校验未通过,已阻止推送`;
queuePushResult.value = `任务 ${created.taskId} 数据校验未通过,已阻止启动`;
clearActiveQueueTask();
return;
}
@@ -1112,7 +1112,7 @@ async function processQueue() {
await submitPatrolDeleteTaskResult(created.taskId, {
shops: createdItems.map((createdItem) => ({
shopName: createdItem.shopName || "",
error: pushResult?.error || `任务 ${created.taskId} 推送失败`,
error: pushResult?.error || `任务 ${created.taskId} 启动失败`,
countrySections: createdItem.countrySections || [],
cartRatios: createdItem.cartRatios || [],
shopDone: true,
@@ -1126,13 +1126,13 @@ async function processQueue() {
});
for (const item of runnable) removeMatchedRowLocally(item);
await refreshTaskViews();
queuePushResult.value = `任务 ${created.taskId} 推送失败`;
queuePushResult.value = `任务 ${created.taskId} 启动失败`;
clearActiveQueueTask();
return;
}
for (const item of runnable) removeMatchedRowLocally(item);
queuePushResult.value = `任务 ${created.taskId}入队,共 ${createdItems.length} 个店铺、${selectedCountryNames.value.length} 个国家,等待执行完成`;
queuePushResult.value = `任务 ${created.taskId}提交,共 ${createdItems.length} 个店铺、${selectedCountryNames.value.length} 个国家,等待执行完成`;
const finalStatus = await waitForTaskTerminal(created.taskId);
queuePushResult.value = `任务 ${created.taskId} ${finalStatus === "SUCCESS" ? "已完成" : finalStatus === "DELETED" ? "已删除" : "执行失败"}`;
@@ -1205,7 +1205,7 @@ async function deleteTaskRecord(item: PatrolDeleteHistoryItem) {
}
removeHistoryItemLocally(item);
await loadCandidates();
ElMessage.success("后端记录已不存在,已同步移除本地记录");
ElMessage.success("记录已不存在,已同步移除本地记录");
return;
}
ElMessage.error(error instanceof Error ? error.message : "删除失败");
@@ -1257,24 +1257,24 @@ async function batchDeleteHistory(views: TaskItemView[]) {
</script>
<style scoped>
.module-page { min-height: 100vh; background: #151a25; }
.module-page { min-height: 100vh; background: #242424; }
.main-content { display: flex; min-height: calc(100vh - 56px); height: calc(100vh - 56px); }
.left-panel { width: 400px; background: #1c2333; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #151a25; }
.left-panel { width: 400px; background: #242424; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #242424; }
.section-title { font-size: 13px; color: #a0acbe; margin-bottom: 10px; }
.condition-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.condition-title-hint { flex: 1; min-width: 0; color: #d6a95a; font-size: 12px; text-align: right; line-height: 1.35; }
.input-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 16px; background: #2e3a52; margin-bottom: 20px; }
.input-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 16px; background: #242424; margin-bottom: 20px; }
.hint { color: #5e6878; font-size: 12px; margin-bottom: 12px; line-height: 1.5; text-align: left; }
.input-row { display: flex; gap: 10px; align-items: center; }
.input-row :deep(.el-input) { flex: 1; }
.opt-btn { padding: 8px 12px; font-size: 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; white-space: nowrap; }
.opt-btn { padding: 8px 12px; font-size: 12px; color: #c8d2e2; background: #242424; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; white-space: nowrap; }
.opt-btn:hover:not(:disabled) { color: #3498db; border-color: #3498db; }
.opt-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.empty-candidates { color: #5e6878; font-size: 13px; padding: 16px; border: 1px dashed #333; border-radius: 8px; margin-bottom: 16px; }
.candidate-table-scroll { max-height: 320px; overflow: auto; margin-bottom: 18px; border-radius: 8px; border: 1px solid #2e3a52; }
.candidate-table { --el-table-bg-color: #2e3a52; --el-table-tr-bg-color: #2e3a52; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.condition-panel { margin-bottom: 18px; border: 1px solid #2e3a52; border-radius: 8px; background: #222; padding: 12px; }
.candidate-table { --el-table-bg-color: #242424; --el-table-tr-bg-color: #242424; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.condition-panel { margin-bottom: 18px; border: 1px solid #2e3a52; border-radius: 8px; background: #242424; padding: 12px; }
.condition-input-row { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
.condition-input-row :deep(.el-input) { flex: 1; }
.empty-conditions { color: #5e6878; font-size: 12px; padding: 10px 4px; }
@@ -1300,8 +1300,8 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; }
.match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 12px; text-align: center; }
.match-table { margin-bottom: 18px; }
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #242424) !important; }
.result-table { --el-table-bg-color: #242424; --el-table-tr-bg-color: #242424; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.ok { color: #27ae60; }
.fail { color: #e67e22; }
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
@@ -143,7 +143,7 @@
</div>
<div v-if="queuePushResult" class="queue-debug-card">
<div class="section-title queue-debug-title">推送结果</div>
<div class="section-title queue-debug-title">启动结果</div>
<div class="queue-debug-line">{{ queuePushResult }}</div>
<pre v-if="queuePayloadText" class="queue-debug-payload">{{ queuePayloadText }}</pre>
</div>
@@ -163,7 +163,7 @@
>
<template #cards-extra>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="match-empty">完成匹配店铺在此展示紫鸟索引匹配结果供核对后再推送队列</div>
<div v-if="!matchedItems.length" class="match-empty">完成匹配店铺在此展示匹配结果确认无误后再启动任务</div>
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false"
class="result-table match-table">
<el-table-column prop="shopName" label="店铺名" min-width="100" />
@@ -812,16 +812,16 @@ async function runMatch() {
function formatMatchStatus(status: string | undefined) {
const s = (status || '').trim()
const map: Record<string, string> = { MATCHED: '已匹配', PENDING: '待匹配', CONFLICT: '需人工确认', INDEX_STALE: '索引过期' }
const map: Record<string, string> = { MATCHED: '已匹配', PENDING: '待匹配', CONFLICT: '需人工确认', INDEX_STALE: '匹配已过期' }
return map[s] || (s ? s : '—')
}
function formatMatchRemark(row: PriceTrackShopQueueItem) {
const msg = (row.matchMessage || '').trim()
if (msg) return msg
if (row.matched && row.matchStatus === 'MATCHED') return '紫鸟索引已命中,可推送队列'
if (row.matched) return '已关联索引,请结合状态列查看是否可推送'
return '未命中或未就绪,请检查店铺名与索引刷新'
if (row.matched && row.matchStatus === 'MATCHED') return '已匹配成功,可启动任务'
if (row.matched) return '已关联店铺,请查看状态确认'
return '未匹配成功,请检查店铺名'
}
function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | null }) {
@@ -934,7 +934,7 @@ async function removeMatchedRow(row: PriceTrackShopQueueItem) {
await loadHistory()
await loadDashboard()
syncPollingIdsWithHistory()
ElMessage.success('后端记录已不存在,已同步移除本地记录')
ElMessage.success('记录已不存在,已同步移除本地记录')
return
}
matchedItems.value = backup
@@ -1027,7 +1027,7 @@ async function waitForTaskTerminal(taskId: number) {
try {
const batch = await getPriceTrackTaskProgressBatch([taskId])
if (transientErrorCount > 0) {
queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`
queuePushResult.value = `任务 ${taskId} 服务已恢复,继续等待执行结果...`
}
transientErrorCount = 0
if ((batch.missingTaskIds || []).includes(taskId)) {
@@ -1057,15 +1057,15 @@ async function waitForTaskTerminal(taskId: number) {
}
} catch (error) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
const transient = ['无法连接到服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
if (!transient) throw error
transientErrorCount += 1
if (transientErrorCount >= maxTransientErrors) {
throw new Error(`任务 ${taskId} 等待后端恢复超时,请稍后手动刷新查看状态`)
throw new Error(`任务 ${taskId} 等待服务恢复超时,请稍后手动刷新查看状态`)
}
queuePushResult.value = `任务 ${taskId} 运行中,正在等待后端服务恢复(${transientErrorCount}/${maxTransientErrors}...`
queuePushResult.value = `任务 ${taskId} 运行中,正在等待服务恢复(${transientErrorCount}/${maxTransientErrors}...`
if (transientErrorCount === 1 || transientErrorCount % 5 === 0) {
ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`)
ElMessage.warning(`任务 ${taskId} 运行中,服务暂时不可用,正在自动重试`)
}
await sleep(getPollIntervalMs())
continue
@@ -1130,10 +1130,10 @@ async function processMatchedQueue() {
})
} catch (error) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
const transient = ['无法连接到服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
attempt += 1
if (!transient || attempt >= 10) throw error
queuePushResult.value = '后端服务暂时不可用,正在重试创建任务(' + attempt + '/10...'
queuePushResult.value = '服务暂时不可用,正在重试创建任务(' + attempt + '/10...'
await sleep(getPollIntervalMs())
}
}
@@ -1157,7 +1157,7 @@ async function processMatchedQueue() {
addPollingTask(taskVo.taskId)
scheduleNextPoll(true)
removeMatchedRowsLocally([row])
queuePushResult.value = '任务 ' + taskVo.taskId + ' 已入队,等待执行完成'
queuePushResult.value = '任务 ' + taskVo.taskId + ' 已提交,等待执行完成'
const finalStatus = await waitForTaskTerminal(taskVo.taskId)
if (finalStatus !== 'SUCCESS') {
failedCount += 1
@@ -1177,11 +1177,11 @@ async function processMatchedQueue() {
}
}
if (successCount > 0 || failedCount > 0) {
ElMessage.success('店铺任务推送已完成:成功 ' + successCount + ' 条,失败 ' + failedCount + ' 条')
ElMessage.success('店铺任务启动已完成:成功 ' + successCount + ' 条,失败 ' + failedCount + ' 条')
}
} catch (e) {
if (disposed) return
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
queuePushResult.value = e instanceof Error ? e.message : '启动异常'
ElMessage.error(queuePushResult.value)
} finally {
queueWorkerRunning.value = false
@@ -1214,7 +1214,7 @@ async function syncActiveLoopRun() {
return activeLoopRun.value
} catch (error) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
const transient = ['无法连接到服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
if (transient) return activeLoopRun.value
activeLoopRun.value = null
activeLoopRunId.value = null
@@ -1285,10 +1285,10 @@ async function runLoopExecution(loopId?: number) {
return await completePriceTrackLoopChild(loop.id, activeTaskId)
} catch (error) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
const transient = ['无法连接到服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
completeAttempt += 1
if (!transient || completeAttempt >= 10) throw error
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后确认子任务完成(${completeAttempt}/10...`
queuePushResult.value = `循环任务 ${loop.id} 等待服务恢复后确认子任务完成(${completeAttempt}/10...`
await sleep(getPollIntervalMs())
}
}
@@ -1312,10 +1312,10 @@ async function runLoopExecution(loopId?: number) {
return await dispatchNextPriceTrackLoopRun(loop.id)
} catch (error) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
const transient = ['无法连接到服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
dispatchAttempt += 1
if (!transient || dispatchAttempt >= 10) throw error
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后继续派发${dispatchAttempt}/10...`
queuePushResult.value = `循环任务 ${loop.id} 等待服务恢复后继续执行${dispatchAttempt}/10...`
await sleep(getPollIntervalMs())
}
}
@@ -1336,10 +1336,10 @@ async function runLoopExecution(loopId?: number) {
return await createPriceTrackTask(childTaskRequest)
} catch (error) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
const transient = ['无法连接到服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
createAttempt += 1
if (!transient || createAttempt >= 10) throw error
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后重试创建子任务(${createAttempt}/10...`
queuePushResult.value = `循环任务 ${loop.id} 等待服务恢复后重试创建子任务(${createAttempt}/10...`
await sleep(getPollIntervalMs())
}
}
@@ -1351,7 +1351,7 @@ async function runLoopExecution(loopId?: number) {
addPollingTask(taskVo.taskId)
scheduleNextPoll(true)
removeMatchedRowsLocally([row])
queuePushResult.value = `循环任务 ${loop.id}${dispatch.childTaskRequest.roundIndex || 1} 轮已推送${row.shopName || taskVo.taskId}`
queuePushResult.value = `循环任务 ${loop.id}${dispatch.childTaskRequest.roundIndex || 1} 轮已启动${row.shopName || taskVo.taskId}`
}
} finally {
loopDispatching.value = false
@@ -1637,7 +1637,7 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
removeHistoryItemLocally(item)
await loadDashboard()
syncPollingIdsWithHistory()
ElMessage.success('后端记录已不存在,已同步移除本地记录')
ElMessage.success('记录已不存在,已同步移除本地记录')
return
}
ElMessage.error(e instanceof Error ? e.message : '删除失败')
@@ -1707,7 +1707,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
<style scoped>
.module-page {
min-height: 100vh;
background: #151a25;
background: #242424;
}
.main-content {
@@ -1718,7 +1718,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.left-panel {
width: 400px;
background: #1c2333;
background: #242424;
padding: 20px;
overflow-y: auto;
border-right: 1px solid #2e3a52;
@@ -1733,7 +1733,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
display: flex;
flex-direction: column;
min-width: 0;
background: #151a25;
background: #242424;
}
.section-title {
@@ -1746,7 +1746,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
border: 1px dashed #3e4a62;
border-radius: 10px;
padding: 16px;
background: #2e3a52;
background: #242424;
margin-bottom: 20px;
}
@@ -1798,7 +1798,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
border: 1px dashed #3e4a62;
border-radius: 10px;
padding: 14px 16px;
background: #2e3a52;
background: #242424;
margin-bottom: 20px;
display: flex;
flex-direction: column;
@@ -1845,7 +1845,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
}
.loop-round-input :deep(.el-input__wrapper) {
background-color: #2e3a52;
background-color: #242424;
box-shadow: 0 0 0 1px #3e4a62 inset;
}
@@ -1893,7 +1893,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
border: 1px dashed #3e4a62;
border-radius: 10px;
padding: 16px;
background: #2e3a52;
background: #242424;
margin-bottom: 20px;
}
@@ -1911,7 +1911,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
padding: 8px 14px;
font-size: 13px;
color: #c8d2e2;
background: #2e3a52;
background: #242424;
border: 1px solid #3e4a62;
border-radius: 6px;
cursor: pointer;
@@ -1965,7 +1965,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
border: 1px solid #333;
border-radius: 8px;
padding: 10px 12px;
background: #2e3a52;
background: #242424;
margin-bottom: 14px;
}
@@ -2028,7 +2028,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
}
.listing-filter-row :deep(.el-select__wrapper) {
background-color: #2e3a52;
background-color: #242424;
box-shadow: 0 0 0 1px #3e4a62 inset;
}
@@ -2062,8 +2062,8 @@ async function batchDeleteHistory(views: TaskItemView[]) {
}
.candidate-table {
--el-table-bg-color: #2e3a52;
--el-table-tr-bg-color: #2e3a52;
--el-table-bg-color: #242424;
--el-table-tr-bg-color: #242424;
--el-table-header-bg-color: #2e3a52;
--el-table-text-color: #c8d2e2;
--el-table-border-color: #333;
@@ -2205,8 +2205,8 @@ async function batchDeleteHistory(views: TaskItemView[]) {
}
.result-table {
--el-table-bg-color: #222;
--el-table-tr-bg-color: #222;
--el-table-bg-color: #242424;
--el-table-tr-bg-color: #242424;
--el-table-header-bg-color: #2e3a52;
--el-table-text-color: #c8d2e2;
--el-table-border-color: #333;
@@ -6,7 +6,7 @@
<aside class="left-panel">
<div class="section-title">店铺名称</div>
<div class="input-zone">
<div class="hint">输入店铺名后点确认加入下方备选区按当前登录用户保存仅索引可命中的店铺可入库勾选备选后点匹配店铺启动任务</div>
<div class="hint">输入店铺名后点确认加入下方备选区按当前登录用户保存勾选备选后点匹配店铺确认匹配结果后启动任务</div>
<div class="input-row">
<el-input v-model="shopInput" clearable placeholder="请输入店铺名称" @keyup.enter="confirmAdd" />
<button type="button" class="opt-btn" :disabled="adding" @click="confirmAdd">
@@ -56,7 +56,7 @@
<div v-if="countryPrefSaving" class="country-pref-status">保存中</div>
<div class="section-title">商品列表筛选</div>
<p class="hint listing-filter-hint">启动任务并下发 Python 区分处理场景</p>
<p class="hint listing-filter-hint">下方选择的筛选类型会随启动任务起执行</p>
<div class="listing-filter-row">
<el-select v-model="productRiskListingFilter" class="listing-filter-select" teleported placeholder="选择筛选类型">
<el-option v-for="opt in PRODUCT_RISK_LISTING_FILTER_OPTIONS" :key="opt.value" :label="opt.label"
@@ -75,12 +75,10 @@
{{ pushing ? '启动中' : '启动任务' }}
</button>
</div>
<p class="loading-msg">在搜索结果中禁止显示详情页面已删除会把匹配店铺合并一个任务入队避免同时打开多个紫鸟窗口其它筛选按店铺逐条入队Python
每处理完一店可 POST <code>/api/product-risk-resolve/tasks/{taskId}/result</code>可多次每次可只含已完成的店铺全部完成后任务结束前端轮询任务状态
</p>
<p class="loading-msg">在搜索结果中禁止显示详情页面已删除会合并一个任务一起处理避免同时打开多个浏览器窗口其它筛选按店铺逐个处理系统每处理完一家店铺就会更新进度全部完成后任务结束结果可在右侧查看</p>
<div v-if="queuePushResult" class="queue-debug-card">
<div class="section-title queue-debug-title">推送结果</div>
<div class="section-title queue-debug-title">启动结果</div>
<div class="queue-debug-line">{{ queuePushResult }}</div>
<pre v-if="queuePayloadText" class="queue-debug-payload">{{ queuePayloadText }}</pre>
</div>
@@ -98,7 +96,7 @@
>
<template #cards-extra>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="match-empty">完成匹配店铺在此展示紫鸟索引匹配结果供核对后再推送队列</div>
<div v-if="!matchedItems.length" class="match-empty">完成匹配店铺在此展示匹配结果确认无误后再启动任务</div>
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false"
class="result-table match-table">
<el-table-column prop="shopName" label="店铺名" min-width="100" />
@@ -495,7 +493,7 @@ async function removeMatchedRow(row: ProductRiskShopQueueItem) {
await loadHistory()
await loadDashboard()
syncPollingIdsWithHistory()
ElMessage.success('后端记录已不存在,已同步移除本地记录')
ElMessage.success('记录已不存在,已同步移除本地记录')
return
}
matchedItems.value = backup
@@ -541,7 +539,7 @@ async function deleteTaskRecord(item: ProductRiskHistoryItem) {
removeHistoryItemLocally(item)
await loadDashboard()
syncPollingIdsWithHistory()
ElMessage.success('后端记录已不存在,已同步移除本地记录')
ElMessage.success('记录已不存在,已同步移除本地记录')
return
}
ElMessage.error(e instanceof Error ? e.message : '删除失败')
@@ -703,7 +701,7 @@ function toRiskTaskView(item: ProductRiskHistoryItem): TaskItemView {
}
const TRANSIENT_BACKEND_ERROR_PATTERNS = [
'无法连接到后端服务',
'无法连接到服务',
'bad gateway',
'gateway timeout',
'network error',
@@ -738,7 +736,7 @@ async function createProductRiskTaskWithRetry(items: ProductRiskShopQueueItem[],
if (!isTransientBackendError(error) || attempt >= maxAttempts) {
throw error
}
queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...`
queuePushResult.value = `服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...`
await sleep(getPollIntervalMs())
}
}
@@ -833,7 +831,7 @@ async function waitForTaskTerminal(taskId: number) {
try {
const batch = await getProductRiskTaskProgressBatch([taskId])
if (transientErrorCount > 0) {
queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`
queuePushResult.value = `任务 ${taskId} 服务已恢复,继续等待执行结果...`
}
transientErrorCount = 0
const detail = (batch.items || []).find((d) => d.task?.id === taskId)
@@ -864,11 +862,11 @@ async function waitForTaskTerminal(taskId: number) {
}
transientErrorCount += 1
if (transientErrorCount >= maxTransientErrors) {
throw new Error(`任务 ${taskId} 等待后端恢复超时,请稍后手动刷新查看状态`)
throw new Error(`任务 ${taskId} 等待服务恢复超时,请稍后手动刷新查看状态`)
}
queuePushResult.value = `任务 ${taskId} 运行中,正在等待后端服务恢复(${transientErrorCount}/${maxTransientErrors}...`
queuePushResult.value = `任务 ${taskId} 运行中,正在等待服务恢复(${transientErrorCount}/${maxTransientErrors}...`
if (transientErrorCount === 1 || transientErrorCount % 5 === 0) {
ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`)
ElMessage.warning(`任务 ${taskId} 运行中,服务暂时不可用,正在自动重试`)
}
await sleep(getPollIntervalMs())
continue
@@ -1029,7 +1027,7 @@ function formatMatchStatus(status: string | undefined) {
MATCHED: '已匹配',
PENDING: '待匹配',
CONFLICT: '需人工确认',
INDEX_STALE: '索引过期',
INDEX_STALE: '匹配已过期',
}
return map[s] || (s ? s : '—')
}
@@ -1040,12 +1038,12 @@ function formatMatchRemark(row: ProductRiskShopQueueItem) {
return msg
}
if (row.matched && row.matchStatus === 'MATCHED') {
return '紫鸟索引已命中,可推送队列;打开店铺地址由 Python 侧处理'
return '已匹配成功,可启动任务'
}
if (row.matched) {
return '已关联索引,请结合状态列查看是否可推送'
return '已关联店铺,请查看状态确认'
}
return '未命中或未就绪,请检查店铺名与索引刷新'
return '未匹配成功,请检查店铺名'
}
async function pushToPythonQueue() {
@@ -1102,15 +1100,15 @@ async function processMatchedBatchQueue(toPush: ProductRiskShopQueueItem[]) {
queuePayloadText.value = JSON.stringify(payload, null, 2)
if (!(await passGuard(productRiskPayloadGuard(payload)))) {
removePollingTask(taskId)
throw new Error('任务 ' + taskId + ' 数据校验未通过,已阻止推送')
throw new Error('任务 ' + taskId + ' 数据校验未通过,已阻止启动')
}
const pushResult = await api.enqueue_json(payload)
if (!pushResult?.success) {
removePollingTask(taskId)
throw new Error('任务 ' + taskId + ' 推送失败:' + (pushResult?.error || '未知错误'))
throw new Error('任务 ' + taskId + ' 启动失败:' + (pushResult?.error || '未知错误'))
}
removeMatchedRowsLocally(toPush)
queuePushResult.value = '任务 ' + taskId + ' 已入队,共 ' + toPush.length + ' 个店铺,等待执行完成...'
queuePushResult.value = '任务 ' + taskId + ' 已提交,共 ' + toPush.length + ' 个店铺,等待执行完成...'
const finalStatus = await waitForTaskTerminal(taskId)
queuePushResult.value = '任务 ' + taskId + (finalStatus === 'SUCCESS' ? ' 已完成' : ' 执行失败')
if (finalStatus !== 'SUCCESS') {
@@ -1133,7 +1131,7 @@ async function processMatchedQueue() {
}
const toPush = matchedItems.value.filter((i) => i.matched)
if (!toPush.length) {
ElMessage.warning('没有已匹配的店铺可推送')
ElMessage.warning('没有已匹配的店铺可启动')
return
}
// 一个国家都没选时 Python 端没有可遍历的站点,任务会停在执行中;在建任务之前就拦掉
@@ -1141,7 +1139,7 @@ async function processMatchedQueue() {
await passGuard(
guardBlocked(
'还没有选择国家',
'当前一个国家都没有勾选,推送后 Python 端没有可遍历的站点,任务会一直停在执行中。\n请先在左侧勾选至少 1 个国家。',
'当前一个国家都没有勾选,启动后任务会一直停在执行中。\n请先在左侧勾选至少 1 个国家。',
'product-risk.no-country',
),
)
@@ -1155,7 +1153,7 @@ async function processMatchedQueue() {
try {
if (shouldBatchProductRiskQueue()) {
await processMatchedBatchQueue(toPush)
ElMessage.success('店铺推送已完成:成功 ' + toPush.length + ' 条,失败 0 条')
ElMessage.success('店铺启动已完成:成功 ' + toPush.length + ' 条,失败 0 条')
await loadHistory()
ensurePolling(true)
return
@@ -1195,19 +1193,19 @@ async function processMatchedQueue() {
queuePayloadText.value = JSON.stringify(payload, null, 2)
if (!(await passGuard(productRiskPayloadGuard(payload)))) {
removePollingTask(taskId)
throw new Error('任务 ' + taskId + ' 数据校验未通过,已阻止推送')
throw new Error('任务 ' + taskId + ' 数据校验未通过,已阻止启动')
}
const pushResult = await api.enqueue_json(payload)
if (!pushResult?.success) {
removePollingTask(taskId)
failedCount += 1
queuePushResult.value = '第 ' + index + ' 条推送失败:' + (pushResult?.error || '未知错误') + ',已自动继续下一条'
queuePushResult.value = '第 ' + index + ' 条启动失败:' + (pushResult?.error || '未知错误') + ',已自动继续下一条'
ElMessage.error(queuePushResult.value)
removeMatchedRowsLocally([item])
continue
}
removeMatchedRowsLocally([item])
queuePushResult.value = '任务 ' + taskId + ' 已入队,等待执行完成...'
queuePushResult.value = '任务 ' + taskId + ' 已提交,等待执行完成...'
const finalStatus = await waitForTaskTerminal(taskId)
if (finalStatus !== 'SUCCESS') {
failedCount += 1
@@ -1227,13 +1225,13 @@ async function processMatchedQueue() {
}
}
if (successCount > 0 || failedCount > 0) {
ElMessage.success('店铺推送已完成:成功 ' + successCount + ' 条,失败 ' + failedCount + ' 条')
ElMessage.success('店铺启动已完成:成功 ' + successCount + ' 条,失败 ' + failedCount + ' 条')
}
await loadHistory()
ensurePolling(true)
} catch (e) {
if (disposed) return
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
queuePushResult.value = e instanceof Error ? e.message : '启动异常'
ElMessage.error(queuePushResult.value)
} finally {
queueWorkerRunning.value = false
@@ -1290,7 +1288,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
<style scoped>
.module-page {
min-height: 100vh;
background: #151a25;
background: #242424;
}
.main-content {
@@ -1301,7 +1299,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.left-panel {
width: 400px;
background: #1c2333;
background: #242424;
padding: 20px;
overflow-y: auto;
border-right: 1px solid #2e3a52;
@@ -1312,7 +1310,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
display: flex;
flex-direction: column;
min-width: 0;
background: #151a25;
background: #242424;
}
.section-title {
@@ -1325,7 +1323,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
border: 1px dashed #3e4a62;
border-radius: 10px;
padding: 16px;
background: #2e3a52;
background: #242424;
margin-bottom: 20px;
}
@@ -1374,7 +1372,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
border: 1px solid #333;
border-radius: 8px;
padding: 10px 12px;
background: #2e3a52;
background: #242424;
margin-bottom: 14px;
}
@@ -1437,7 +1435,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
}
.listing-filter-row :deep(.el-select__wrapper) {
background-color: #2e3a52;
background-color: #242424;
box-shadow: 0 0 0 1px #3e4a62 inset;
}
@@ -1460,7 +1458,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
padding: 8px 14px;
font-size: 13px;
color: #c8d2e2;
background: #2e3a52;
background: #242424;
border: 1px solid #3e4a62;
border-radius: 6px;
cursor: pointer;
@@ -1495,8 +1493,8 @@ async function batchDeleteHistory(views: TaskItemView[]) {
}
.candidate-table {
--el-table-bg-color: #2e3a52;
--el-table-tr-bg-color: #2e3a52;
--el-table-bg-color: #242424;
--el-table-tr-bg-color: #242424;
--el-table-header-bg-color: #2e3a52;
--el-table-text-color: #c8d2e2;
--el-table-border-color: #333;
@@ -1618,12 +1616,12 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.match-table :deep(.el-table__body tr:hover > td.el-table__cell),
.match-table :deep(.el-table__body tr.hover-row > td.el-table__cell),
.match-table :deep(.el-table__body tr.current-row > td.el-table__cell) {
background-color: var(--el-table-tr-bg-color, #222) !important;
background-color: var(--el-table-tr-bg-color, #242424) !important;
}
.result-table {
--el-table-bg-color: #222;
--el-table-tr-bg-color: #222;
--el-table-bg-color: #242424;
--el-table-tr-bg-color: #242424;
--el-table-header-bg-color: #2e3a52;
--el-table-text-color: #c8d2e2;
--el-table-border-color: #333;
@@ -361,7 +361,7 @@ const progressLoop = useTaskProgressLoop<PublishTaskDetailVo>({
await Promise.all([loadDashboard(), loadHistory()])
},
onError: () => {
if (hasQueueWork.value) queueMessage.value = '任务仍在执行,正在等待后端状态恢复...'
if (hasQueueWork.value) queueMessage.value = '任务仍在执行,正在等待服务恢复...'
},
})
@@ -453,10 +453,10 @@ function toPublishTaskView(detail: PublishTaskDetailVo): TaskItemView {
}
const operationHint = computed(() => {
if (uploading.value) return '正在逐个上传文件到后端...'
if (parsing.value) return '正在解析 Excel、匹配店铺并创建批次...'
if (queueWorkerRunning.value) return '当前文件完成后会自动派发下一个文件。'
return '创建批次后会按文件严格串行处理。'
if (uploading.value) return '正在逐个上传文件,请稍候...'
if (parsing.value) return '正在解析 Excel、匹配店铺并准备上架...'
if (queueWorkerRunning.value) return '当前文件上架完成后会自动开始下一个文件。'
return '提交后会按文件逐个依次上架。'
})
watch(publishCountry, (code, previousCode) => {
@@ -618,7 +618,7 @@ async function submitRun() {
await Promise.all([loadDashboard(), loadHistory()])
if (!batch.pendingFileIds.length) {
queueMessage.value = '解析完成,当前没有匹配成功且可执行的文件。'
queueMessage.value = '解析完成,当前没有匹配成功且可上架的文件。'
ElMessage.warning(queueMessage.value)
return
}
@@ -627,7 +627,7 @@ async function submitRun() {
if (shouldWait) {
queuedBatches.value.push(batch)
saveQueueState()
queueMessage.value = `批次 ${parsed.taskNo || parsed.taskId} 已加入等待队列,前面还有 ${queuedBatches.value.length} 个批次`
queueMessage.value = `任务 ${parsed.taskNo || parsed.taskId} 已加入等待队列,前面还有 ${queuedBatches.value.length} 个批次`
ElMessage.success(queueMessage.value)
if (!queueWorkerRunning.value) void processQueue()
return
@@ -635,7 +635,7 @@ async function submitRun() {
setActiveBatch(batch)
saveQueueState()
queueMessage.value = `批次 ${parsed.taskNo || parsed.taskId} 已创建,开始串行处理 ${batch.pendingFileIds.length} 个文件。`
queueMessage.value = `任务 ${parsed.taskNo || parsed.taskId} 已创建,开始依次上架 ${batch.pendingFileIds.length} 个文件。`
ElMessage.success(queueMessage.value)
void processQueue()
} catch (error) {
@@ -909,13 +909,13 @@ async function processQueue() {
positiveNumberKeys: ['totalRows', 'totalPages'],
})
if (!(await passGuard(guard))) {
throw new Error(`文件 ${file.sourceFilename || nextFileId} 数据校验未通过,已阻止推送`)
throw new Error(`文件 ${file.sourceFilename || nextFileId} 数据校验未通过,已阻止启动`)
}
const result = await api.enqueue_json(payload)
if (!result?.success) throw new Error(result?.error || '任务队列拒绝接收任务')
queueMessage.value = pendingFileIds.value.length
? `文件 ${file.sourceFilename || nextFileId}入队,完成后继续剩余 ${pendingFileIds.value.length} 个文件。`
: `文件 ${file.sourceFilename || nextFileId}入队,等待执行完成。`
? `文件 ${file.sourceFilename || nextFileId}提交,完成后继续剩余 ${pendingFileIds.value.length} 个文件。`
: `文件 ${file.sourceFilename || nextFileId}提交,等待执行完成。`
const finalStatus = await waitForFileTerminal(taskId, nextFileId)
if (finalStatus === 'STOPPED') return
queueMessage.value = `文件 ${file.sourceFilename || nextFileId} ${['SUCCESS', 'COMPLETED'].includes(finalStatus) ? '已完成' : '执行失败'},继续下一个文件。`
@@ -927,11 +927,11 @@ async function processQueue() {
await submitDispatchFailure(taskId, file, reason)
} catch (compensationError) {
const message = compensationError instanceof Error ? compensationError.message : '失败状态提交失败'
queueMessage.value = `文件 ${file.sourceFilename || nextFileId} 派发失败,后端未确认失败状态:${message}串行队列已暂停。`
queueMessage.value = `文件 ${file.sourceFilename || nextFileId} 启动失败,后端未确认失败状态:${message}上架已暂停。`
saveQueueState()
throw new Error(queueMessage.value)
}
queueMessage.value = `文件 ${file.sourceFilename || nextFileId} 派发失败,已记录并继续下一个文件。`
queueMessage.value = `文件 ${file.sourceFilename || nextFileId} 启动失败,已记录并继续下一个文件。`
activeFileId.value = null
saveQueueState()
}
@@ -941,16 +941,16 @@ async function processQueue() {
await progressLoop.refreshOnce()
await Promise.all([loadDashboard(), loadHistory()])
if (queuedBatches.value.length) {
queueMessage.value = `当前批次已完成派发,继续处理后续 ${queuedBatches.value.length} 个等待批次。`
queueMessage.value = `当前批次已完成,继续处理后续 ${queuedBatches.value.length} 个等待批次。`
continue
}
queueMessage.value = '所有上架批次已按顺序派发完成,正在生成结果文件。'
ElMessage.success('上架等待队列已完成派发')
queueMessage.value = '所有上架批次已按顺序完成,正在生成结果文件。'
ElMessage.success('上架等待队列已完成')
break
}
} catch (error) {
if (disposed) return
const message = error instanceof Error ? error.message : '串行队列执行失败'
const message = error instanceof Error ? error.message : '任务执行失败'
queueMessage.value = message
ElMessage.error(message)
} finally {
@@ -1033,7 +1033,7 @@ function reconcileStoredQueue() {
const activeId = active.activeFileId
const activeStatus = normalizeStatus(active.files.find((file) => file.fileId === activeId)?.status)
if (!activeStatus || activeStatus === 'PENDING') {
queueMessage.value = `文件 ${activeId} 尚未激活,已恢复到待派发队列。`
queueMessage.value = `文件 ${activeId} 尚未开始,已恢复到待队列。`
}
}
saveQueueState()
@@ -1179,7 +1179,7 @@ onMounted(async () => {
if (hasQueueWork.value) {
queueMessage.value = activeFileId.value != null
? `检测到未完成文件 ${activeFileId.value},继续等待完成后衔接后续文件。`
: `检测到未完成队列,继续处理当前文件及后续 ${queuedBatches.value.length} 个批次。`
: `检测到未完成的任务,继续处理当前文件及后续 ${queuedBatches.value.length} 个批次。`
void processQueue()
}
})
@@ -1211,23 +1211,23 @@ async function batchDeleteHistory(views: TaskItemView[]) {
</script>
<style scoped>
.module-page { min-height: 100vh; background: #151a25; }
.module-page { min-height: 100vh; background: #242424; }
.main-content { display: flex; min-height: calc(100vh - 88px); height: calc(100vh - 88px); }
.left-panel { width: 400px; background: #1c2333; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #151a25; }
.left-panel { width: 400px; background: #242424; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #242424; }
.section-title { margin-bottom: 10px; color: #a0acbe; font-size: 13px; }
.upload-zone { margin-bottom: 20px; padding: 20px; border: 1px dashed #3e4a62; border-radius: 8px; background: #2e3a52; text-align: center; }
.upload-zone { margin-bottom: 20px; padding: 20px; border: 1px dashed #3e4a62; border-radius: 8px; background: #242424; text-align: center; }
.hint, .loading-msg { color: #5e6878; font-size: 12px; line-height: 1.5; }
.btns, .run-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.btns { justify-content: center; }
.opt-btn, .btn-run, .download, .btn-delete { border: 0; border-radius: 6px; cursor: pointer; transition: background-color .15s ease, color .15s ease, opacity .15s ease; }
.opt-btn { padding: 8px 16px; border: 1px solid #3e4a62; background: #2e3a52; color: #c8d2e2; font-size: 13px; }
.opt-btn { padding: 8px 16px; border: 1px solid #3e4a62; background: #242424; color: #c8d2e2; font-size: 13px; }
.opt-btn:hover:not(:disabled) { border-color: #3498db; color: #67b7ef; }
.opt-btn:disabled, .btn-run:disabled { opacity: .55; cursor: not-allowed; }
.selected-files { max-height: 120px; min-height: 72px; margin-top: 14px; overflow-y: auto; color: #5e6878; font-size: 12px; text-align: left; }
.selected-files span { display: block; margin: 4px 0; word-break: break-all; }
.more-line { color: #a0acbe; }
.country-group { margin-bottom: 12px; padding: 14px; border: 1px solid #323e58; border-radius: 8px; background: #2e3a52; }
.country-group { margin-bottom: 12px; padding: 14px; border: 1px solid #323e58; border-radius: 8px; background: #242424; }
.country-group-title { margin-bottom: 10px; color: #a0acbe; font-size: 12px; }
.country-checks { display: flex; flex-wrap: wrap; gap: 10px 16px; }
.country-check-row { display: inline-flex; align-items: center; gap: 8px; color: #c8d2e2; cursor: pointer; font-size: 12px; user-select: none; }
@@ -1238,7 +1238,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.run-row { margin-top: 16px; }
.btn-run { min-height: 38px; padding: 9px 20px; background: #3498db; color: #f5f8fc; font-size: 14px; }
.btn-run:hover:not(:disabled) { background: #2980b9; }
.queue-status { margin-top: 16px; padding: 12px; border-left: 3px solid #409eff; background: #2e3a52; color: #b8d8ef; font-size: 12px; line-height: 1.6; }
.queue-status { margin-top: 16px; padding: 12px; border-left: 3px solid #409eff; background: #242424; color: #b8d8ef; font-size: 12px; line-height: 1.6; }
.queue-status-title { margin-bottom: 4px; }
.download { padding: 7px 12px; background: #2d6b46; color: #dff7e8; font-size: 12px; white-space: nowrap; }
.download:hover { background: #367d54; }
@@ -7,7 +7,7 @@
<div class="section-title">店铺输入</div>
<div class="input-zone">
<div class="hint">
左侧负责录入店铺并加入备选区确认命中后启动任务查询ASIN按店铺串行执行上一个任务完成后会自动开始下一个失败也会继续下一个
左侧负责录入店铺并加入备选区确认匹配结果后启动任务查询按店铺逐个执行上一个店铺查完会自动开始下一个失败也会继续
</div>
<div class="input-row">
<el-input
@@ -77,16 +77,16 @@
:disabled="isQueueBusy || !matchedRunnableItems.length"
@click="pushToPythonQueue"
>
{{ isQueueBusy ? "串行执行中..." : "启动任务" }}
{{ isQueueBusy ? "按顺序执行中..." : "启动任务" }}
</button>
</div>
<p class="loading-msg">
当前页面会按店铺串行推送任务并在任务成功或失败后自动接续下一个任务服务端临时重启时会自动重试查询状态恢复继续后续店铺
店铺会按顺序逐个查询上一个店铺查完无论成功或失败都会自动开始下一个如果中途网络异常系统会自动恢复继续处理
</p>
<div v-if="queuePushResult" class="queue-debug-card">
<div class="section-title queue-debug-title">队列状态</div>
<div class="section-title queue-debug-title">任务状态</div>
<div class="queue-debug-line">{{ queuePushResult }}</div>
<pre v-if="queuePayloadText" class="queue-debug-payload">{{
queuePayloadText
@@ -394,7 +394,7 @@ function formatMatchStatus(status?: string) {
MATCHED: "已匹配",
PENDING: "待匹配",
CONFLICT: "需人工确认",
INDEX_STALE: "索引过期",
INDEX_STALE: "匹配已过期",
};
return map[value] || value || "-";
}
@@ -403,12 +403,12 @@ function formatMatchRemark(row: QueryAsinShopQueueItem) {
const message = (row.matchMessage || "").trim();
if (message) return message;
if (row.matched && row.matchStatus === "MATCHED") {
return "索引已命中,可启动任务";
return "已匹配成功,可启动任务";
}
if (row.matched) {
return "已命中索引,请结合状态确认";
return "已匹配成功,请查看状态确认";
}
return "未命中或未就绪,请检查店铺名";
return "未匹配成功,请检查店铺名";
}
function flattenQueryAsinRows(record: Pick<QueryAsinHistoryItem, "shopName" | "queryAsins">) {
@@ -665,8 +665,8 @@ function reconcileActiveQueueTaskWithHistory() {
const missingTaskId = activeTaskId.value;
clearActiveQueueTask();
queuePushResult.value = pendingQueue.value.length
? `任务 ${missingTaskId} 已不存在,已跳过并继续剩余队列`
: "当前任务已不存在,已释放本地队列状态";
? `任务 ${missingTaskId} 已不存在,已跳过并继续剩余任务`
: "当前任务已不存在,已重置本地状态";
resetQueueWorkerIfIdle();
}
@@ -812,7 +812,7 @@ async function runMatch() {
function removeMatchedRow(row: QueryAsinShopQueueItem) {
if (hasQueueWork.value) {
ElMessage.warning("队列执行中,请等待当前串行任务结束后再调整");
ElMessage.warning("任务执行中,请等待当前任务结束后再调整");
return;
}
matchedItems.value = matchedItems.value.filter(
@@ -878,7 +878,7 @@ async function waitForTaskTerminal(taskId: number) {
return "FAILED";
}
if (transientErrorCount > 0) {
queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`;
queuePushResult.value = `任务 ${taskId} 服务已恢复,继续等待执行结果...`;
}
transientErrorCount = 0;
const item = findHistoryItemByTaskId(taskId);
@@ -893,10 +893,10 @@ async function waitForTaskTerminal(taskId: number) {
transientErrorCount += 1;
if (transientErrorCount >= MAX_TRANSIENT_ERRORS) {
throw new Error(
`任务 ${taskId} 等待后端恢复超时,请稍后刷新查看状态`,
`任务 ${taskId} 等待服务恢复超时,请稍后刷新查看状态`,
);
}
queuePushResult.value = `任务 ${taskId} 执行中,正在等待后端服务恢复(${transientErrorCount}/${MAX_TRANSIENT_ERRORS}...`;
queuePushResult.value = `任务 ${taskId} 执行中,正在等待服务恢复(${transientErrorCount}/${MAX_TRANSIENT_ERRORS}...`;
}
await sleep(getTaskPollIntervalMs());
}
@@ -932,7 +932,7 @@ async function processQueue() {
const created = await withTransientRetry(
() => createQueryAsinTask([buildTaskItem(nextItem)]),
(attempt, maxAttempts) => {
queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...`;
queuePushResult.value = `服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...`;
},
);
const createdItem = created.items?.[0]
@@ -961,7 +961,7 @@ async function processQueue() {
shops: [
{
shopName: createdItem.shopName || nextItem.shopName || "",
error: `任务 ${created.taskId} 数据校验未通过,已阻止推送`,
error: `任务 ${created.taskId} 数据校验未通过,已阻止启动`,
shopDone: true,
submissionId: createSubmissionId(
created.taskId,
@@ -971,7 +971,7 @@ async function processQueue() {
],
});
await refreshTaskViews();
queuePushResult.value = `任务 ${created.taskId} 数据校验未通过,已阻止推送并继续下一个任务`;
queuePushResult.value = `任务 ${created.taskId} 数据校验未通过,已阻止启动并继续下一个任务`;
clearActiveQueueTask();
continue;
}
@@ -982,7 +982,7 @@ async function processQueue() {
shops: [
{
shopName: createdItem.shopName || nextItem.shopName || "",
error: pushResult?.error || `任务 ${created.taskId} 推送失败`,
error: pushResult?.error || `任务 ${created.taskId} 启动失败`,
shopDone: true,
submissionId: createSubmissionId(
created.taskId,
@@ -992,7 +992,7 @@ async function processQueue() {
],
});
await refreshTaskViews();
queuePushResult.value = `任务 ${created.taskId} 推送失败,已自动继续下一个任务`;
queuePushResult.value = `任务 ${created.taskId} 启动失败,已自动继续下一个任务`;
clearActiveQueueTask();
continue;
}
@@ -1000,11 +1000,11 @@ async function processQueue() {
setTaskStartTime(created.taskId);
queuePushResult.value =
pendingQueue.value.length > 0
? `任务 ${created.taskId}入队,等待完成后自动继续下一个(剩余 ${pendingQueue.value.length} 条)`
: `任务 ${created.taskId}入队,等待执行完成`;
? `任务 ${created.taskId}提交,等待完成后自动继续下一个(剩余 ${pendingQueue.value.length} 条)`
: `任务 ${created.taskId}提交,等待执行完成`;
}
queuePushResult.value = "串行队列已执行完成";
queuePushResult.value = "任务已按顺序执行完成";
await refreshTaskViews();
ElMessage.success("查询ASIN队列已按顺序执行完成");
} catch (error) {
@@ -1031,7 +1031,7 @@ async function pushToPythonQueue() {
}
pendingQueue.value = mergeQueueItems(pendingQueue.value, runnable);
saveQueueState();
queuePushResult.value = `已加入 ${runnable.length} 条店铺,开始串行执行`;
queuePushResult.value = `已加入 ${runnable.length} 条店铺,开始按顺序执行`;
await processQueue();
}
@@ -1073,7 +1073,7 @@ async function deleteTaskRecord(item: QueryAsinHistoryItem) {
removeHistoryItemLocally(item);
await loadCandidates();
resetQueueWorkerIfIdle();
ElMessage.success("后端记录已不存在,已同步移除本地记录");
ElMessage.success("记录已不存在,已同步移除本地记录");
return;
}
ElMessage.error(error instanceof Error ? error.message : "删除失败");
@@ -1126,21 +1126,21 @@ async function batchDeleteHistory(views: TaskItemView[]) {
</script>
<style scoped>
.module-page { min-height: 100vh; background: #151a25; }
.module-page { min-height: 100vh; background: #242424; }
.main-content { display: flex; min-height: calc(100vh - 56px); height: calc(100vh - 56px); }
.left-panel { width: 400px; background: #1c2333; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #151a25; }
.left-panel { width: 400px; background: #242424; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #242424; }
.section-title { font-size: 13px; color: #a0acbe; margin-bottom: 10px; }
.input-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 16px; background: #2e3a52; margin-bottom: 20px; }
.input-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 16px; background: #242424; margin-bottom: 20px; }
.hint { color: #5e6878; font-size: 12px; margin-bottom: 12px; line-height: 1.5; text-align: left; }
.input-row { display: flex; gap: 10px; align-items: center; }
.input-row :deep(.el-input) { flex: 1; }
.opt-btn { padding: 8px 12px; font-size: 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; white-space: nowrap; }
.opt-btn { padding: 8px 12px; font-size: 12px; color: #c8d2e2; background: #242424; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; white-space: nowrap; }
.opt-btn:hover:not(:disabled) { color: #3498db; border-color: #3498db; }
.opt-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.empty-candidates { color: #5e6878; font-size: 13px; padding: 16px; border: 1px dashed #333; border-radius: 8px; margin-bottom: 16px; }
.candidate-table-scroll { max-height: 320px; overflow: auto; margin-bottom: 18px; border-radius: 8px; border: 1px solid #2e3a52; }
.candidate-table { --el-table-bg-color: #2e3a52; --el-table-tr-bg-color: #2e3a52; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.candidate-table { --el-table-bg-color: #242424; --el-table-tr-bg-color: #242424; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.link-danger { background: none; border: none; color: #e74c3c; cursor: pointer; font-size: 12px; padding: 0; }
.link-danger:hover { text-decoration: underline; }
.run-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; }
@@ -1157,8 +1157,8 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; }
.match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 12px; text-align: center; }
.match-table { margin-bottom: 18px; }
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #242424) !important; }
.result-table { --el-table-bg-color: #242424; --el-table-tr-bg-color: #242424; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.ok { color: #27ae60; }
.fail { color: #e67e22; }
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
@@ -59,7 +59,7 @@
</button>
<button type="button" class="btn-run btn-queue" :disabled="isQueueBusy || !matchedRunnableItems.length"
@click="startQueue">
{{ isQueueBusy ? '串行抓取中...' : '开始串行抓取' }}
{{ isQueueBusy ? '抓取中...' : '开始抓取' }}
</button>
</div>
<p v-if="queueStatus" class="queue-status">{{ queueStatus }}</p>
@@ -247,12 +247,12 @@ function statusText(item: ShopDataCrawlHistoryItem) {
if (status === 'SUCCESS' || status === 'COMPLETED') return '已完成'
if (status === 'FAILED') return '失败'
if (item.success === true) return '结果文件生成中'
return 'Python 处理中'
return '处理中'
}
function statusClass(status?: string) { return status === 'SUCCESS' || status === 'COMPLETED' ? 'success' : status === 'FAILED' ? 'failed' : 'running' }
function canDownload(item: ShopDataCrawlHistoryItem) { return Boolean(item.resultId && (item.fileReady || item.downloadUrl)) }
function formatDateTime(value?: string) { if (!value) return '-'; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false }) }
function formatMatchStatus(status?: string) { return ({ MATCHED: '已匹配', PENDING: '待匹配', CONFLICT: '需确认', INDEX_STALE: '索引过期' } as Record<string, string>)[status || ''] || status || '-' }
function formatMatchStatus(status?: string) { return ({ MATCHED: '已匹配', PENDING: '待匹配', CONFLICT: '需确认', INDEX_STALE: '匹配已过期' } as Record<string, string>)[status || ''] || status || '-' }
function setStorage(name: string, value: unknown) {
if (typeof window !== 'undefined') window.localStorage.setItem(storageKey(name), JSON.stringify(value))
@@ -396,7 +396,7 @@ function activeTaskStatus(taskId: number) {
return taskSnapshots.value[taskId]?.task?.status || historyItems.value.find((item) => item.taskId === taskId)?.taskStatus || ''
}
function sleep(ms: number) { return disposed ? Promise.resolve() : timers.sleep('queue-wait', ms) }
function isTransientError(error: unknown) { return /network|fetch|timeout|50[0234]|load failed|connection refused|econnrefused|无法连接.*后端|后端服务.*(?:不可用|连接)|网络错误/i.test(error instanceof Error ? error.message : String(error || '')) }
function isTransientError(error: unknown) { return /network|fetch|timeout|50[0234]|load failed|connection refused|econnrefused|无法连接.*后端|服务.*(?:不可用|连接)|网络错误/i.test(error instanceof Error ? error.message : String(error || '')) }
async function retry<T>(action: () => Promise<T>) {
let attempt = 0
while (!disposed) {
@@ -451,10 +451,10 @@ async function dispatchActiveTask(api: NonNullable<ReturnType<typeof getPywebvie
nonEmptyArrayKeys: ['items', 'country_codes'],
})
if (!(await passGuard(guard))) {
throw new Error(`任务 ${activeTaskId.value} 数据校验未通过,已阻止推送`)
throw new Error(`任务 ${activeTaskId.value} 数据校验未通过,已阻止启动`)
}
const pushed = await api.enqueue_json(payload)
if (!pushed?.success) throw new Error(pushed?.error || `任务 ${activeTaskId.value} 入队失败`)
if (!pushed?.success) throw new Error(pushed?.error || `任务 ${activeTaskId.value} 启动失败`)
activeDispatched.value = true
saveQueueState()
return true
@@ -478,7 +478,7 @@ async function processQueue() {
await deleteShopDataCrawlTask(taskId).catch(() => undefined)
if (item) recordLocalFailure(item, error)
clearActiveTask()
queueStatus.value = `任务 ${taskId} 入队失败,继续下一店`
queueStatus.value = `任务 ${taskId} 启动失败,继续下一店`
continue
}
}
@@ -517,12 +517,12 @@ async function processQueue() {
if (!disposed) {
autoQueueEnabled.value = false
saveQueueState()
queueStatus.value = '串行抓取已完成'
queueStatus.value = '抓取已完成'
await refreshViews()
ElMessage.success('店铺数据抓取队列已完成')
}
} catch (error) {
if (!disposed) { queueStatus.value = error instanceof Error ? error.message : '队列执行失败'; ElMessage.error(queueStatus.value) }
if (!disposed) { queueStatus.value = error instanceof Error ? error.message : '任务执行失败'; ElMessage.error(queueStatus.value) }
} finally { queueWorkerRunning.value = false; saveQueueState() }
}
function startQueue() {
@@ -601,19 +601,19 @@ async function batchDeleteHistory(views: TaskItemView[]) {
</script>
<style scoped>
.module-page { min-height: 100vh; background: #151a25; }
.module-page { min-height: 100vh; background: #242424; }
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
.left-panel { width: 400px; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; background: #1c2333; }
.right-panel { flex: 1; min-width: 0; background: #151a25; display: flex; flex-direction: column; }
.left-panel { width: 400px; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; background: #242424; }
.right-panel { flex: 1; min-width: 0; background: #242424; display: flex; flex-direction: column; }
.section-title { margin-bottom: 10px; color: #a0acbe; font-size: 13px; }
.input-zone { margin-bottom: 18px; padding: 14px; border: 1px dashed #3e4a62; border-radius: 8px; background: #2e3a52; }
.input-zone { margin-bottom: 18px; padding: 14px; border: 1px dashed #3e4a62; border-radius: 8px; background: #242424; }
.input-row { display: flex; gap: 10px; }
.opt-btn, .btn-run { min-height: 36px; padding: 0 16px; border: 0; border-radius: 5px; background: #409eff; color: #f5f8fc; white-space: nowrap; cursor: pointer; }
.opt-btn { flex: 0 0 auto; }
.opt-btn:disabled, .btn-run:disabled { opacity: .5; cursor: not-allowed; }
.empty-candidates { padding: 16px; border: 1px dashed #333; border-radius: 6px; color: #5e6878; font-size: 13px; }
.candidate-table-scroll { margin-bottom: 18px; border: 1px solid #2e3a52; border-radius: 6px; overflow: hidden; }
.candidate-table { --el-table-bg-color: #2e3a52; --el-table-tr-bg-color: #2e3a52; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.candidate-table { --el-table-bg-color: #242424; --el-table-tr-bg-color: #242424; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.link-danger { border: 0; background: transparent; color: #f56c6c; cursor: pointer; }
.country-pref-checks { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; }
.country-check-row { display: flex; align-items: center; gap: 7px; min-height: 32px; color: #c8d2e2; font-size: 13px; }
@@ -629,7 +629,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.btn-queue { background: #67c23a; }
.subsection-title { margin: 18px 0 10px; color: #a0acbe; font-size: 13px; font-weight: 700; }
.match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 16px; text-align: center; }
.match-table { margin-bottom: 20px; --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #292929; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.match-table { margin-bottom: 20px; --el-table-bg-color: #242424; --el-table-tr-bg-color: #242424; --el-table-header-bg-color: #292929; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.ok { color: #67c23a; }
.files { font-size: 12px; color: #5e6878; }
.fail, .error-text { color: #f56c6c; }
@@ -5,7 +5,7 @@
<aside class="left-panel">
<div class="section-title">店铺候选</div>
<div class="input-zone">
<div class="hint">输入店铺名后点添加加入下方备选区勾选备选店铺后先做匹配店铺确认命中紫鸟索引后再启动任务</div>
<div class="hint">输入店铺名后点添加加入下方备选区勾选备选店铺后先做匹配店铺确认匹配结果后再启动任务</div>
<div class="input-row">
<el-input v-model="shopInput" clearable placeholder="请输入店铺名后回车或点击添加" @keyup.enter="confirmAdd" />
<button type="button" class="opt-btn" :disabled="adding" @click="confirmAdd">{{ adding ? '添加中...' : '添加' }}</button>
@@ -39,7 +39,7 @@
</div>
<div v-if="countryPrefSaving" class="country-pref-status">保存中...</div>
<div class="section-title">商品列表筛选</div>
<p class="hint listing-filter-hint">启动任务并下发 Python 区分处理场景</p>
<p class="hint listing-filter-hint">下方选择的筛选类型会随启动任务起执行</p>
<div class="listing-filter-row">
<el-select v-model="shopMatchListingFilter" class="listing-filter-select" teleported placeholder="选择筛选类型">
<el-option v-for="opt in LISTING_FILTER_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
@@ -53,7 +53,7 @@
<button type="button" class="schedule-remove" :disabled="pushing || schedulePickerValues.length <= 1" @click="removeScheduleValue(index)">删除</button>
</div>
<button type="button" class="schedule-add" :disabled="pushing" @click="addScheduleValue">添加时间点</button>
<p class="hint schedule-hint">默认开启定时支持 `MM-DD HH:mm` 配置多个时间点前端不再限制选择更早时间实际是否可执行以后端校验为准</p>
<p class="hint schedule-hint">启用后可 `MM-DD HH:mm` 添加多个执行时间点</p>
</div>
<ZiniaoVersionSetting v-model="ziniaoVersion" />
@@ -61,8 +61,8 @@
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">{{ matching ? '匹配中...' : '匹配店铺' }}</button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !matchedItems.length" @click="pushToPythonQueue">{{ pushing ? '启动中...' : scheduleEnabled ? '创建定时任务' : '启动任务' }}</button>
</div>
<p class="loading-msg">支持多阶段定时执行Python 每处理完一店可回传 <code>/api/shop-match/tasks/{taskId}/result</code>后端会按阶段汇总并在超时场景自动收尾</p>
<div v-if="queuePushResult" class="queue-debug-card"><div class="section-title queue-debug-title">推送结果</div><div class="queue-debug-line">{{ queuePushResult }}</div><pre v-if="queuePayloadText" class="queue-debug-payload">{{ queuePayloadText }}</pre></div>
<p class="loading-msg">支持按多个时间点定时执行系统每处理完一家店铺就会更新进度全部完成后任务结束结果可在右侧查看</p>
<div v-if="queuePushResult" class="queue-debug-card"><div class="section-title queue-debug-title">启动结果</div><div class="queue-debug-line">{{ queuePushResult }}</div><pre v-if="queuePayloadText" class="queue-debug-payload">{{ queuePayloadText }}</pre></div>
</aside>
<section class="right-panel">
<TaskCenterPanel
@@ -76,7 +76,7 @@
>
<template #cards-extra>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="match-empty">完成匹配店铺在此展示紫鸟索引匹配结果供核对后再推送队列</div>
<div v-if="!matchedItems.length" class="match-empty">完成匹配店铺在此展示匹配结果确认无误后再启动任务</div>
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false" class="result-table match-table">
<el-table-column prop="shopName" label="店铺名" min-width="100" />
<el-table-column label="匹配" width="72" align="center"><template #default="{ row }"><span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span></template></el-table-column>
@@ -393,7 +393,7 @@ function buildQueuePayload(taskId: number, item: Pick<ShopMatchHistoryItem, 'sho
}
}
function nextScheduleStage(taskId: number) { const task = taskSnapshots.value[taskId]?.task; const stages = task?.scheduleStages || []; const currentStageIndex = task?.currentStageIndex; if (typeof currentStageIndex !== 'number') return null; const stage = stages.find((item) => item.stageIndex === currentStageIndex); if (!stage?.scheduledAt) return null; return { stageIndex: currentStageIndex, scheduledAt: stage.scheduledAt, finalStage: currentStageIndex === stages.length - 1 } }
async function dispatchScheduledTask(taskId: number, item: ShopMatchHistoryItem) { const api = getPywebviewApi(); if (!api?.enqueue_json) throw new Error('当前客户端未提供 enqueue_json'); const stage = nextScheduleStage(taskId); const snapshot = taskSnapshots.value[taskId]; const countryCodes = snapshot?.task?.countryCodes || orderedCountryCodes.value; if (!stage) throw new Error(`任务 ${taskId} 缺少待执行阶段`); clearDispatchTimer(taskId); await withTransientRetry(() => activateShopMatchTask(taskId, stage.stageIndex), (attempt, maxAttempts) => { queuePushResult.value = `任务 ${taskId} 等待后端恢复后再激活执行(${attempt}/${maxAttempts}...` }); taskDetails.value = { ...taskDetails.value, [taskId]: 'RUNNING' }; if (snapshot?.task) taskSnapshots.value = { ...taskSnapshots.value, [taskId]: { ...snapshot, task: { ...snapshot.task, status: 'RUNNING', activeStageIndex: stage.stageIndex } } }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const payload = buildQueuePayload(taskId, item, countryCodes, stage.stageIndex, stage.finalStage); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); queuePushResult.value = `任务 ${taskId}推送${stage.stageIndex + 1}`; ensurePolling(true) }
async function dispatchScheduledTask(taskId: number, item: ShopMatchHistoryItem) { const api = getPywebviewApi(); if (!api?.enqueue_json) throw new Error('当前客户端未提供 enqueue_json'); const stage = nextScheduleStage(taskId); const snapshot = taskSnapshots.value[taskId]; const countryCodes = snapshot?.task?.countryCodes || orderedCountryCodes.value; if (!stage) throw new Error(`任务 ${taskId} 缺少待执行阶段`); clearDispatchTimer(taskId); await withTransientRetry(() => activateShopMatchTask(taskId, stage.stageIndex), (attempt, maxAttempts) => { queuePushResult.value = `任务 ${taskId} 等待服务恢复后再激活执行(${attempt}/${maxAttempts}...` }); taskDetails.value = { ...taskDetails.value, [taskId]: 'RUNNING' }; if (snapshot?.task) taskSnapshots.value = { ...taskSnapshots.value, [taskId]: { ...snapshot, task: { ...snapshot.task, status: 'RUNNING', activeStageIndex: stage.stageIndex } } }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const payload = buildQueuePayload(taskId, item, countryCodes, stage.stageIndex, stage.finalStage); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 启动失败`); addPollingTask(taskId); queuePushResult.value = `任务 ${taskId}启动${stage.stageIndex + 1}`; ensurePolling(true) }
function scheduledTimestamp(value?: string) { const date = parseScheduleDateTime(value); return date ? date.getTime() : Number.NaN }
function findReadyScheduledTasks() { const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } const ready = Array.from(taskIds).map((taskId) => { const snapshot = taskSnapshots.value[taskId]; const taskStatus = taskDetails.value[taskId] || snapshot?.task?.status; const item = snapshot?.items?.[0] || historyItems.value.find((row) => normalizeTaskId(row.taskId) === taskId); const stage = nextScheduleStage(taskId); if (taskStatus !== 'SCHEDULED' || !item || !stage?.scheduledAt) return null; const scheduledTime = scheduledTimestamp(stage.scheduledAt); if (!Number.isFinite(scheduledTime) || scheduledTime > Date.now()) return null; return { taskId, item, scheduledTime } }).filter((item): item is { taskId: number; item: ShopMatchHistoryItem; scheduledTime: number } => !!item); ready.sort((a, b) => a.scheduledTime - b.scheduledTime || a.taskId - b.taskId); return ready }
async function pumpScheduledQueue() {
@@ -428,7 +428,7 @@ function restoreScheduledDispatches() { if (disposed) return; const taskIds = ne
function startScheduleHeartbeat() { if (disposed || scheduleHeartbeatTimer || !dispatchTimers.size) return; scheduleHeartbeatTimer = timers.setInterval('schedule-heartbeat', () => { if (!disposed) void pumpScheduledQueue() }, 30000) }
function stopScheduleHeartbeat() { if (!scheduleHeartbeatTimer) return; timers.clearTimer('schedule-heartbeat', scheduleHeartbeatTimer); scheduleHeartbeatTimer = null }
function handleScheduleRecovery() { restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) }
const TRANSIENT_BACKEND_ERROR_PATTERNS = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'] as const
const TRANSIENT_BACKEND_ERROR_PATTERNS = ['无法连接到服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'] as const
function sleep(ms: number) { if (disposed) return Promise.resolve(); return timers.sleep('queue-wait', ms) }
function clearSleepTimers() { timers.clearCategory('queue-wait') }
function isTransientBackendError(error: unknown) { const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase(); return TRANSIENT_BACKEND_ERROR_PATTERNS.some((pattern) => message.includes(pattern.toLowerCase())) }
@@ -438,7 +438,7 @@ function getPollIntervalMs() { return getTaskPollIntervalMs() }
function scheduleNextPoll(immediate = false) { if (disposed) return; if (pollTimer.value) { if (!immediate) return; timers.clearTimer('task-poll', pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (disposed) return; if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingTaskIds.value.length) scheduleNextPoll(); return } pollingInFlight.value = true; try { const { settledTaskIds } = await refreshTaskBatch(); if (settledTaskIds.length) await Promise.allSettled([loadHistory(), loadDashboard()]) } finally { pollingInFlight.value = false } if (!disposed && pollingTaskIds.value.length) pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs()) }
function ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
function stopPolling() { if (pollTimer.value) { timers.clearTimer('task-poll', pollTimer.value); pollTimer.value = null } }
async function waitForTaskTerminal(taskId: number) { let transientErrorCount = 0; const maxTransientErrors = 30; while (true) { if (disposed) return 'STOPPED'; try { const batch = await getShopMatchTaskProgressBatch([taskId]); if (transientErrorCount > 0) queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`; transientErrorCount = 0; if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { const prev = taskSnapshots.value[taskId]; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } } catch (error) { if (!isTransientBackendError(error)) throw error; transientErrorCount += 1; if (transientErrorCount >= maxTransientErrors) throw new Error(`任务 ${taskId} 等待后端恢复超时,请稍后手动刷新查看状态`); queuePushResult.value = `任务 ${taskId} 运行中,正在等待后端服务恢复(${transientErrorCount}/${maxTransientErrors}...`; if (transientErrorCount === 1 || transientErrorCount % 5 === 0) ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`); await sleep(getPollIntervalMs()); continue } await sleep(getPollIntervalMs()) } }
async function waitForTaskTerminal(taskId: number) { let transientErrorCount = 0; const maxTransientErrors = 30; while (true) { if (disposed) return 'STOPPED'; try { const batch = await getShopMatchTaskProgressBatch([taskId]); if (transientErrorCount > 0) queuePushResult.value = `任务 ${taskId} 服务已恢复,继续等待执行结果...`; transientErrorCount = 0; if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { const prev = taskSnapshots.value[taskId]; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } } catch (error) { if (!isTransientBackendError(error)) throw error; transientErrorCount += 1; if (transientErrorCount >= maxTransientErrors) throw new Error(`任务 ${taskId} 等待服务恢复超时,请稍后手动刷新查看状态`); queuePushResult.value = `任务 ${taskId} 运行中,正在等待服务恢复(${transientErrorCount}/${maxTransientErrors}...`; if (transientErrorCount === 1 || transientErrorCount % 5 === 0) ElMessage.warning(`任务 ${taskId} 运行中,服务暂时不可用,正在自动重试`); await sleep(getPollIntervalMs()); continue } await sleep(getPollIntervalMs()) } }
async function waitForScheduledTaskStageExit(taskId: number) {
let transientErrorCount = 0
const maxTransientErrors = 30
@@ -446,7 +446,7 @@ async function waitForScheduledTaskStageExit(taskId: number) {
if (disposed) return 'STOPPED'
try {
const batch = await getShopMatchTaskProgressBatch([taskId])
if (transientErrorCount > 0) queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待当前轮次结束...`
if (transientErrorCount > 0) queuePushResult.value = `任务 ${taskId} 服务已恢复,继续等待当前轮次结束...`
transientErrorCount = 0
if ((batch.missingTaskIds || []).includes(taskId)) {
removeTaskLocally(taskId)
@@ -486,7 +486,7 @@ async function waitForScheduledTaskStageExit(taskId: number) {
transientErrorCount += 1
if (transientErrorCount >= maxTransientErrors) throw new Error(`任务 ${taskId} 等待当前轮次结束超时,请稍后手动刷新查看状态`)
queuePushResult.value = `任务 ${taskId} 运行中,正在等待当前轮次结束(${transientErrorCount}/${maxTransientErrors}...`
if (transientErrorCount === 1 || transientErrorCount % 5 === 0) ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`)
if (transientErrorCount === 1 || transientErrorCount % 5 === 0) ElMessage.warning(`任务 ${taskId} 运行中,服务暂时不可用,正在自动重试`)
await sleep(getPollIntervalMs())
}
}
@@ -535,17 +535,17 @@ async function deleteTaskRecord(item: ShopMatchHistoryItem) {
}
removeMatchedRowsLocally([item])
await Promise.allSettled([loadHistory(), loadCandidates(), loadDashboard()])
ElMessage.success('后端记录已不存在,本地记录已同步清理')
ElMessage.success('记录已不存在,本地记录已同步清理')
return
}
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
function formatMatchStatus(status?: string) { const value = (status || '').trim(); return { MATCHED: '已匹配', PENDING: '待匹配', CONFLICT: '需人工确认', INDEX_STALE: '索引过期' }[value] || value || '—' }
function formatMatchRemark(row: ShopMatchShopQueueItem) { const message = (row.matchMessage || '').trim(); if (message) return message; if (row.matched && row.matchStatus === 'MATCHED') return '紫鸟索引已命中,可推送队列'; if (row.matched) return '已关联索引,请结合状态列查看是否可推送'; return '未命中或未就绪,请检查店铺名与索引刷新' }
function formatMatchStatus(status?: string) { const value = (status || '').trim(); return { MATCHED: '已匹配', PENDING: '待匹配', CONFLICT: '需人工确认', INDEX_STALE: '匹配已过期' }[value] || value || '—' }
function formatMatchRemark(row: ShopMatchShopQueueItem) { const message = (row.matchMessage || '').trim(); if (message) return message; if (row.matched && row.matchStatus === 'MATCHED') return '已匹配成功,可启动任务'; if (row.matched) return '已关联店铺,请查看状态确认'; return '未匹配成功,请检查店铺名' }
async function pushToPythonQueue() { autoQueueEnabled.value = true; await processMatchedQueue() }
function nextMatchedQueueItem() { return matchedItems.value.find((item) => item.matched) }
async function processMatchedQueue() { if (disposed || queueWorkerRunning.value) return; const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (!disposed && autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { const created = await withTransientRetry(() => createShopMatchTask([buildTaskCreateItem(item)], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 推送失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId}入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { if (disposed) break; failedCount += 1; queuePushResult.value = `${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (!disposed && (successCount > 0 || failedCount > 0)) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount}` : `店铺推送已完成:成功 ${successCount} 条,失败 ${failedCount}`) } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } }
async function processMatchedQueue() { if (disposed || queueWorkerRunning.value) return; const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (!disposed && autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { const created = await withTransientRetry(() => createShopMatchTask([buildTaskCreateItem(item)], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 启动失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId}提交,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { if (disposed) break; failedCount += 1; queuePushResult.value = `${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (!disposed && (successCount > 0 || failedCount > 0)) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount}` : `店铺启动已完成:成功 ${successCount} 条,失败 ${failedCount}`) } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : '启动失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } }
onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); schedulePickerValues.value = sanitizeSchedulePickerValues(schedulePickerValues.value, scheduleEnabled.value); syncPollingIdsWithTaskState(); await Promise.allSettled([loadCandidates(), loadCountryPreference(), loadHistory(), loadDashboard()]); window.addEventListener('focus', handleScheduleRecovery); document.addEventListener('visibilitychange', handleScheduleRecovery); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) })
onUnmounted(() => { disposed = true; stopPolling(); stopScheduleHeartbeat(); clearSleepTimers(); window.removeEventListener('focus', handleScheduleRecovery); document.removeEventListener('visibilitychange', handleScheduleRecovery); if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer); for (const timer of dispatchTimers.values()) timers.clearTimer('scheduled-dispatch', timer); dispatchTimers.clear(); timers.clearScope() })
@@ -571,19 +571,19 @@ async function batchDeleteHistory(views: TaskItemView[]) {
</script>
<style scoped>
.module-page { min-height: 100vh; background: #151a25; }
.module-page { min-height: 100vh; background: #242424; }
.main-content { display: flex; min-height: calc(100vh - 56px); height: calc(100vh - 56px); }
.left-panel { width: 400px; background: #1c2333; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #151a25; }
.left-panel { width: 400px; background: #242424; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #242424; }
.section-title { font-size: 13px; color: #a0acbe; margin-bottom: 10px; }
.input-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 16px; background: #2e3a52; margin-bottom: 20px; }
.input-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 16px; background: #242424; margin-bottom: 20px; }
.hint { color: #5e6878; font-size: 12px; margin-bottom: 12px; line-height: 1.5; text-align: left; }
.country-pref-hint { margin-top: -4px; }
.country-pref-checks { display: flex; flex-wrap: wrap; gap: 10px 16px; margin-bottom: 12px; }
.country-check-row { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; font-size: 12px; color: #c8d2e2; }
.country-check-input { width: 16px; height: 16px; accent-color: #409eff; cursor: pointer; flex-shrink: 0; }
.country-check-text { line-height: 1.3; }
.country-order-panel { border: 1px solid #333; border-radius: 8px; padding: 10px 12px; background: #2e3a52; margin-bottom: 14px; }
.country-order-panel { border: 1px solid #333; border-radius: 8px; padding: 10px 12px; background: #242424; margin-bottom: 14px; }
.country-order-caption { font-size: 11px; color: #5e6878; margin-bottom: 8px; }
.country-order-list { display: flex; flex-direction: column; gap: 6px; }
.country-drag-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: #2c2c2c; border-radius: 6px; border: 1px solid #3e4a62; cursor: grab; user-select: none; }
@@ -593,17 +593,17 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.country-pref-status { font-size: 11px; color: #5e6878; margin-bottom: 10px; }
.input-row { display: flex; gap: 10px; align-items: center; }
.input-row :deep(.el-input) { flex: 1; }
.opt-btn,.schedule-remove,.schedule-add { padding: 8px 12px; font-size: 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; white-space: nowrap; }
.opt-btn,.schedule-remove,.schedule-add { padding: 8px 12px; font-size: 12px; color: #c8d2e2; background: #242424; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; white-space: nowrap; }
.opt-btn:hover:not(:disabled),.schedule-remove:hover:not(:disabled),.schedule-add:hover:not(:disabled) { color: #3498db; border-color: #3498db; }
.opt-btn:disabled,.schedule-remove:disabled,.schedule-add:disabled { opacity: 0.6; cursor: not-allowed; }
.empty-candidates { color: #5e6878; font-size: 13px; padding: 16px; border: 1px dashed #333; border-radius: 8px; margin-bottom: 16px; }
.candidate-table-scroll { max-height: 280px; overflow: auto; margin-bottom: 16px; border-radius: 8px; border: 1px solid #2e3a52; }
.candidate-table { --el-table-bg-color: #2e3a52; --el-table-tr-bg-color: #2e3a52; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.candidate-table { --el-table-bg-color: #242424; --el-table-tr-bg-color: #242424; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.schedule-switch { display: flex; align-items: center; gap: 8px; color: #c8d2e2; font-size: 12px; margin-bottom: 12px; }
.schedule-config { border: 1px solid #333; border-radius: 8px; padding: 12px; background: #2e3a52; margin-bottom: 16px; }
.schedule-config { border: 1px solid #333; border-radius: 8px; padding: 12px; background: #242424; margin-bottom: 16px; }
.schedule-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.schedule-picker { flex: 1; }
.schedule-picker :deep(.el-input__wrapper) { background-color: #2e3a52; box-shadow: 0 0 0 1px #3e4a62 inset; }
.schedule-picker :deep(.el-input__wrapper) { background-color: #242424; box-shadow: 0 0 0 1px #3e4a62 inset; }
.schedule-picker :deep(.el-input__inner) { color: #c8d2e2; }
.schedule-add { margin-top: 2px; }
.schedule-hint { margin-top: 10px; margin-bottom: 0; }
@@ -624,8 +624,8 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.subsection-title { font-size: 13px; color: #a0acbe; margin: 8px 0 10px; }
.match-empty { color: #5e6878; font-size: 13px; padding: 12px 8px; margin-bottom: 12px; text-align: center; }
.match-table { margin-bottom: 18px; }
.match-table :deep(.el-table__body tr:hover > td.el-table__cell),.match-table :deep(.el-table__body tr.hover-row > td.el-table__cell),.match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.match-table :deep(.el-table__body tr:hover > td.el-table__cell),.match-table :deep(.el-table__body tr.hover-row > td.el-table__cell),.match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #242424) !important; }
.result-table { --el-table-bg-color: #242424; --el-table-tr-bg-color: #242424; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.ok { color: #27ae60; }
.fail { color: #e67e22; }
.files { font-size: 12px; color: #5e6878; }
@@ -6,7 +6,7 @@
<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="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>
@@ -68,7 +68,7 @@
{{ pushing ? '启动中...' : '启动任务' }}
</button>
</div>
<p class="loading-msg">Python 回传字段按 idasin国家价格标题图片链接数组提交后端接收分片后按 10 条一批送检结果区展示真实 LLM 批次进度</p>
<p class="loading-msg">提交后系统自动分批检测进度显示在右侧任务区完成后可下载结果</p>
<div v-if="visibleTaskSummary" class="parse-card">
<div>任务 ID{{ visibleTaskSummary.taskId }}</div>
@@ -562,7 +562,7 @@ async function pushToPythonQueue() {
await activateSimilarAsinTask(taskId)
const result = await api.enqueue_json(payload)
if (!result?.success) {
ElMessage.error(result?.error || '推送失败')
ElMessage.error(result?.error || '启动失败')
return
}
queuedTaskSummary.value = toTaskSummary(currentParseResult)
@@ -889,7 +889,7 @@ function hasMeaningfulProgressValue(value: unknown) {
function statusText(item: SimilarAsinHistoryItem) {
const status = normalizeTaskStatus(item)
if (status === 'RUNNING') return '执行中'
if (status === 'PENDING') return '已解析待推送'
if (status === 'PENDING') return '已解析待启动'
if (isResultPreparing(item)) return '结果生成中'
if (isResultBuildFailed(item)) return '结果生成失败'
if (status === 'SUCCESS' || item.success) return '已完成'
@@ -934,10 +934,10 @@ function pendingResultHint(item: SimilarAsinHistoryItem) {
if (!isResultPreparing(item)) return ''
const fileStatus = (item.fileStatus || '').toUpperCase()
if (fileStatus === 'RUNNING') {
return '任务已完成,正在等待 LLM 回流并组装结果文件,下载按钮稍后出现'
return '任务已完成,正在生成结果文件,下载按钮稍后出现'
}
if (fileStatus === 'PENDING') {
return '任务已完成,结果文件已入队,正在等待后端开始处理'
return '任务已完成,结果文件排队中,请稍候'
}
return '任务已完成,正在生成结果文件,下载按钮稍后出现'
}
@@ -970,14 +970,14 @@ 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 '正在提交 LLM 批次'
if (/生成结果|结果文件|组装/i.test(message)) return 'LLM 已回流,正在生成结果文件'
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 'LLM 回流中,等待结果回'
return '检测中,等待结果回'
}
return 'LLM 处理中'
return '检测中'
}
if (/assembling|xlsx|组装|生成结果/i.test(message)) return '正在组装结果文件'
if (/assembling|xlsx|组装|生成结果/i.test(message)) return '正在生成结果文件'
return message
}
@@ -988,9 +988,9 @@ function fileProgressCountLabel(item: SimilarAsinHistoryItem) {
const total = parsed?.total ?? item.fileProgressTotal ?? 0
if (/llm/i.test(message) || /LLM|回流/.test(message)) {
if (/submitting/i.test(message) || /提交/.test(message)) {
return `LLM 提交批次 ${current}/${total}`
return `检测批次 ${current}/${total}`
}
return `LLM 回流批次 ${current}/${total}`
return `检测完成批次 ${current}/${total}`
}
return `当前阶段 ${current}/${total}`
}
@@ -1118,7 +1118,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
<style scoped>
.module-page {
min-height: 100vh;
background: #151a25;
background: #242424;
}
.main-content {
@@ -1129,7 +1129,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.left-panel {
width: 400px;
background: #1c2333;
background: #242424;
padding: 20px;
overflow-y: auto;
border-right: 1px solid #2e3a52;
@@ -1138,7 +1138,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.right-panel {
flex: 1;
min-width: 0;
background: #151a25;
background: #242424;
display: flex;
flex-direction: column;
}
@@ -1153,7 +1153,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
border: 1px dashed #3e4a62;
border-radius: 10px;
padding: 18px;
background: #2e3a52;
background: #242424;
margin-bottom: 18px;
}
@@ -1308,7 +1308,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.opt-btn {
padding: 8px 14px;
color: #c8d2e2;
background: #2e3a52;
background: #242424;
border: 1px solid #3e4a62;
}
@@ -448,7 +448,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
<style scoped>
.module-page {
min-height: 100vh;
background: #151a25;
background: #242424;
}
.main-content {
@@ -459,7 +459,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.left-panel {
width: 380px;
background: #1c2333;
background: #242424;
padding: 20px;
overflow-y: auto;
border-right: 1px solid #2e3a52;
@@ -470,7 +470,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
display: flex;
flex-direction: column;
min-width: 0;
background: #151a25;
background: #242424;
}
.section-title {
@@ -484,14 +484,14 @@ async function batchDeleteHistory(views: TaskItemView[]) {
border-radius: 10px;
padding: 24px;
text-align: center;
background: #2e3a52;
background: #242424;
margin-bottom: 20px;
transition: all 0.2s ease;
}
.upload-zone:hover {
border-color: #3498db;
background: #2e3a52;
background: #242424;
}
.hint {
@@ -521,7 +521,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
padding: 8px 16px;
font-size: 13px;
color: #c8d2e2;
background: #2e3a52;
background: #242424;
border: 1px solid #3e4a62;
border-radius: 6px;
}
@@ -569,7 +569,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.column-option-group {
padding: 14px;
border-radius: 10px;
background: #2e3a52;
background: #242424;
border: 1px solid #2e3a52;
}
@@ -644,7 +644,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
cursor: pointer;
padding: 10px 12px;
border-radius: 8px;
background: #2e3a52;
background: #242424;
border: 1px solid #2e3a52;
margin-bottom: 8px;
transition: all 0.2s ease;
@@ -652,7 +652,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
.radio-item:hover,
.radio-item.active {
background: #2e3a52;
background: #242424;
border-color: #3e4a62;
}
@@ -681,7 +681,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
padding: 14px 16px;
border-radius: 10px;
border: 1px solid #323e58;
background: #2e3a52;
background: #242424;
}
.split-number-input {
@@ -11,22 +11,22 @@
class="input-tab"
:class="{ active: inputMode === 'asin' }"
@click="inputMode = 'asin'"
>粘贴 ASIN</button>
>输入ASIN</button>
<button
type="button"
class="input-tab"
:class="{ active: inputMode === 'file' }"
@click="inputMode = 'file'"
>Excel 文件</button>
>上传文件</button>
</div>
<div v-if="inputMode === 'asin'" class="input-panel">
<div class="hint">每行一个 ASIN支持批量粘贴</div>
<div class="hint">请输入ASIN一行一个</div>
<textarea
v-model="asinText"
class="asin-textarea"
rows="9"
placeholder="每行一个 ASIN,例如:&#10;B0xxxxxxxx"
placeholder="请输入ASIN,一行一个"
></textarea>
<div class="asin-count">已输入 <b>{{ asinLines.length }}</b> ASIN</div>
</div>
@@ -46,24 +46,25 @@
</div>
</div>
<div class="section-title">采集国家可多</div>
<div class="section-title">采集国家</div>
<div class="country-group">
<label
v-for="country in COUNTRY_OPTIONS"
:key="country.code"
class="country-chip"
:class="{ selected: selectedCountries.includes(country.code) }"
:class="{ selected: selectedCountryCode === country.code }"
>
<input
type="checkbox"
type="radio"
name="variant-country"
:value="country.code"
v-model="selectedCountries"
v-model="selectedCountryCode"
/>
<span>{{ country.label }}{{ country.code }}</span>
</label>
</div>
<p class="country-hint" :class="{ error: selectedCountries.length === 0 }">
{{ selectedCountries.length === 0 ? '至少选择一个国家' : `已选 ${selectedCountries.length} 个国家` }}
<p class="country-hint" :class="{ error: !selectedCountryCode }">
{{ selectedCountryCode ? `当前选择:${countryLabel(selectedCountryCode)}${selectedCountryCode}` : '请选择一个采集国家' }}
</p>
<div v-if="!hasBridge" class="bridge-tip">
@@ -77,57 +78,54 @@
:disabled="!hasBridge || submitting || !canSubmit"
@click="submitTask"
>
{{ submitting ? '提交中...' : '开始采集(添加任务队列)' }}
{{ submitting ? '提交中...' : '开始添加任务' }}
</button>
</div>
<p class="loading-msg">
任务在远程批处理队列执行提交后到右侧查看进度并下载结果文件
提交后任务开始执行进度和结果在右侧查看完成后可下载结果文件
</p>
</aside>
<section class="right-panel">
<div class="panel-toolbar">
<div class="queue-header">
<span class="queue-title">任务队列</span>
<button type="button" class="btn-refresh" @click="refreshList">刷新任务状态</button>
<div v-if="totalCount" class="toolbar-pagination">
</div>
<div class="queue-body">
<div v-if="!currentTasks.length" class="empty-tasks">暂无变体采集任务</div>
<div v-else class="queue-grid">
<div v-for="item in currentTasks" :key="`vc-${item.task_id}`" class="task-card">
<div class="task-card-head">
<span class="task-card-id">任务 {{ item.task_id }}</span>
<span class="status" :class="statusClass(item.status)">{{ statusText(item.status) }}</span>
</div>
<div class="task-card-meta">
<div class="files">国家{{ countryText(item) || '-' }}</div>
<div class="files">任务创建时间{{ formatDateTime(item.create_time || item.updated_at) }}</div>
<div class="files">最后更新时间{{ formatDateTime(item.update_time || item.updated_at) }}</div>
<div v-if="taskMeta(item).source" class="files">来源{{ taskMeta(item).source }}</div>
</div>
<div class="task-card-msg">{{ latestMessage(item) }}</div>
<div class="task-card-actions">
<button type="button" class="act-btn" @click="showDetail(item)">查看任务详情</button>
<button v-if="hasResultFiles(item)" type="button" class="act-btn ok"
@click="downloadResult(item)">下载结果</button>
<button v-if="hasSourceFile(item)" type="button" class="act-btn"
@click="downloadSource(item)">下载源文件</button>
<button type="button" class="act-btn warn" @click="reexportTask(item)">重新导出数据</button>
<button type="button" class="act-btn danger" @click="deleteTask(item)">删除任务</button>
</div>
</div>
</div>
<div v-if="currentTasks.length" class="pagination">
<span class="page-info"> {{ totalCount }} · {{ currentPage }} / {{ totalPages }} </span>
<div class="page-btns">
<button type="button" class="page-btn" :disabled="currentPage <= 1" @click="goPage(currentPage - 1)">上一页</button>
<span class="page-now">{{ currentPage }}</span>
<button type="button" class="page-btn" :disabled="currentPage >= totalPages" @click="goPage(currentPage + 1)">下一页</button>
</div>
</div>
</div>
<TaskCenterPanel
title="变体采集任务"
:cards="variantCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-title="当前任务"
current-empty-text="暂无运行中的变体采集任务"
history-empty-text="暂无历史记录"
>
<template #item-actions="{ item }">
<template v-if="itemSource(item)">
<button type="button" class="act-btn" @click="refreshTask(itemSource(item))">更新</button>
<button type="button" class="act-btn" @click="showDetail(itemSource(item))">详情</button>
<button v-if="hasResultFiles(itemSource(item))" type="button" class="act-btn ok"
@click="downloadResult(itemSource(item))">下载结果</button>
<button v-if="hasSourceFile(itemSource(item))" type="button" class="act-btn"
@click="downloadSource(itemSource(item))">下载源文件</button>
<button type="button" class="act-btn warn" @click="reexportTask(itemSource(item))">重新导出</button>
</template>
</template>
<template #history-item-actions="{ item }">
<template v-if="itemSource(item)">
<button type="button" class="act-btn" @click="refreshTask(itemSource(item))">更新</button>
<button type="button" class="act-btn" @click="showDetail(itemSource(item))">详情</button>
<button v-if="hasResultFiles(itemSource(item))" type="button" class="act-btn ok"
@click="downloadResult(itemSource(item))">下载结果</button>
<button v-if="hasSourceFile(itemSource(item))" type="button" class="act-btn"
@click="downloadSource(itemSource(item))">下载源文件</button>
<button type="button" class="act-btn warn" @click="reexportTask(itemSource(item))">重新导出</button>
</template>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
@@ -162,8 +160,6 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import {
getPywebviewApi,
type VariantTaskListItem,
@@ -190,7 +186,7 @@ const PAGE_SIZE = 8
const inputMode = ref<'asin' | 'file'>('asin')
const asinText = ref('')
const selectedFiles = ref<string[]>([])
const selectedCountries = ref<string[]>([])
const selectedCountryCode = ref<string>('DE')
const submitting = ref(false)
const tasks = reactive<Record<string, VariantEntry>>({})
@@ -212,29 +208,15 @@ const canSubmit = computed(() =>
inputMode.value === 'asin' ? asinLines.value.length > 0 : selectedFiles.value.length > 0,
)
// ---- 右侧任务面板统一视图(TaskCenterPanel----
/** 当前任务:排队中(0) / 执行中(1) 的任务(当前分页内) */
const currentTaskViews = computed<TaskItemView[]>(() =>
currentTasks.value.filter((item) => isBusy(item.status)).map(toVariantTaskView),
)
/** 历史任务:已完成(2) / 失败(3) 的任务(当前分页内) */
const historyTaskViews = computed<TaskItemView[]>(() =>
currentTasks.value.filter((item) => !isBusy(item.status)).map(toVariantTaskView),
)
const variantCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: historyTaskViews.value.length },
{ label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
{ label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
])
function itemSource(item: TaskItemView): VariantEntry {
return item.source as VariantEntry
function countryLabel(code: string) {
return COUNTRY_OPTIONS.find((row) => row.code === code)?.label || code
}
function variantStatusClass(status?: number | string) {
function taskMeta(item: VariantEntry) {
return item.meta || {}
}
function statusClass(status?: number | string) {
const numeric = Number(status)
if (numeric === 1 || /running|processing/i.test(String(status))) return 'running'
if (numeric === 2 || /success|completed|done/i.test(String(status))) return 'success'
@@ -242,29 +224,6 @@ function variantStatusClass(status?: number | string) {
return 'pending'
}
function toVariantTaskView(item: VariantEntry): TaskItemView {
const extraLines: string[] = []
const source = item.meta?.source || ''
if (source) extraLines.push(`来源:${source}`)
extraLines.push(`采集国家:${countryText(item) || '-'}`)
const updateText = item.update_time || formatDateTime(item.updated_at)
if (updateText) extraLines.push(`更新时间:${updateText}`)
extraLines.push(latestMessage(item))
return {
key: `vc-${item.task_id}`,
title: `任务 ${item.task_id}`,
taskId: item.task_id,
// 桥接列表未提供任务开始时间,开始时间占位为 '-'
startedAt: '-',
// 桥接仅提供最近更新时间,作为结束时间的近似展示
finishedAt: formatDateTime(item.update_time),
statusText: statusText(item.status),
statusClass: variantStatusClass(item.status),
extraLines,
source: item,
}
}
let autoTimer: number | null = null
let refreshTimer: number | null = null
@@ -369,8 +328,8 @@ async function submitTask() {
ElMessage.warning('请在桌面客户端中打开本页面执行变体采集')
return
}
if (!selectedCountries.value.length) {
ElMessage.warning('请至少选择一个采集国家')
if (!selectedCountryCode.value) {
ElMessage.warning('请选择采集国家')
return
}
if (!uid()) {
@@ -386,7 +345,7 @@ async function submitTask() {
files?: string[]
} = {
mode: inputMode.value,
countries: selectedCountries.value,
countries: selectedCountryCode.value ? [selectedCountryCode.value] : [],
uid: uid(),
}
if (inputMode.value === 'asin') {
@@ -473,6 +432,7 @@ async function loadTasks(page?: number) {
const entry = tasks[item.task_id]
entry.status = item.status
if (item.update_time) entry.update_time = item.update_time
if (item.create_time) entry.create_time = item.create_time
entry.res_type = item.res_type
entry.res_mes = item.res_mes
entry.file_url = item.file_url
@@ -542,6 +502,26 @@ async function downloadSource(item: VariantTaskListItem) {
}
}
/**
* 删除任务:当前客户端/后端未提供删除接口(batch 仅 add/query/list/export),
* 先提示用户操作不可用,避免出现"已删除"假象
*/
async function deleteTask(item: VariantTaskListItem) {
const api = getPywebviewApi()
const hasVcDelete = Boolean((api as Record<string, unknown> | null)?.['vc_delete_task'])
if (!hasVcDelete) {
ElMessage.warning('当前客户端不支持删除任务,可在后台保存或稍后清理')
return
}
try {
await (api as { vc_delete_task: (taskId: string) => Promise<{ success?: boolean; error?: string }> })
.vc_delete_task(item.task_id)
void loadTasks(currentPage.value || 1)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
async function reexportTask(item: VariantTaskListItem) {
const bridge = getPywebviewApi()
if (!bridge?.vc_export_task) {
@@ -657,48 +637,66 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.module-page { min-height: 100vh; background: #151a25; }
.module-page { min-height: 100vh; background: #242424; }
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
.left-panel { width: 420px; background: #1c2333; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; min-width: 0; background: #151a25; display: flex; flex-direction: column; }
.left-panel { width: 420px; 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: 14px 0 10px; }
.hint, .loading-msg, .files, .country-hint { color: #5e6878; font-size: 12px; line-height: 1.5; }
.hint { margin-bottom: 8px; }
.input-tabs { display: flex; gap: 6px; margin-bottom: 12px; }
.input-tab { flex: 1; padding: 9px 0; border: 1px solid #3e4a62; border-radius: 8px; background: #2e3a52; color: #a0acbe; font-size: 13px; cursor: pointer; }
.input-tab.active { background: rgba(64, 158, 255, 0.15); color: #409eff; border-color: #409eff; }
.input-tab { flex: 1; padding: 9px 0; border: 1px solid #3e4a62; border-radius: 8px; background: #242424; color: #a0acbe; font-size: 13px; cursor: pointer; }
.input-tab.active { background: #409eff; color: #ffffff; border-color: #409eff; }
.input-panel { margin-bottom: 4px; }
.asin-textarea { width: 100%; box-sizing: border-box; min-height: 150px; padding: 10px; background: #151a25; color: #c8d2e2; border: 1px solid #3e4a62; border-radius: 8px; font-size: 13px; line-height: 1.6; resize: vertical; outline: none; }
.asin-textarea { width: 100%; box-sizing: border-box; min-height: 150px; padding: 10px; background: #242424; color: #c8d2e2; border: 1px solid #3e4a62; border-radius: 8px; font-size: 13px; line-height: 1.6; resize: vertical; outline: none; }
.asin-textarea:focus { border-color: #409eff; }
.asin-count { margin-top: 8px; color: #5e6878; font-size: 12px; }
.asin-count b { color: #c8d2e2; }
.asin-count b { color: #f5f8fc; }
.btns, .run-row { display: flex; gap: 10px; flex-wrap: wrap; }
.opt-btn { padding: 8px 14px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 7px; cursor: pointer; font-size: 12px; }
.opt-btn:hover:not(:disabled) { color: #3498db; border-color: #3498db; }
.opt-btn { padding: 8px 14px; color: #f5f8fc; background: #409eff; border: none; border-radius: 7px; cursor: pointer; font-size: 12px; }
.opt-btn:hover:not(:disabled) { background: #66b1ff; }
.opt-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.selected-files { margin-top: 10px; color: #a0acbe; font-size: 12px; word-break: break-all; }
.selected-files span { display: block; margin: 3px 0; }
.country-group { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.country-chip { display: inline-flex; align-items: center; gap: 7px; padding: 9px 11px; border: 1px solid #333; border-radius: 8px; background: #2e3a52; color: #c8d2e2; font-size: 12px; cursor: pointer; user-select: none; }
.country-chip.selected { border-color: #409eff; background: rgba(64, 158, 255, 0.12); color: #e6f2ff; }
.country-chip input { width: 15px; height: 15px; accent-color: #409eff; cursor: pointer; }
.country-chip { display: inline-flex; align-items: center; gap: 7px; padding: 9px 11px; border: 1px solid #3e4a62; border-radius: 8px; background: #242424; color: #f5f8fc; font-size: 12px; cursor: pointer; user-select: none; }
.country-chip.selected { border-color: #409eff; background: #409eff; color: #ffffff; }
.country-chip input { display: none; }
.country-hint { margin: 10px 0 4px; }
.country-hint.error { color: #e6a23c; }
.country-hint b { color: #c8d2e2; }
.bridge-tip { margin: 12px 0; padding: 9px 12px; border-radius: 8px; background: rgba(230, 162, 60, 0.1); color: #e6a23c; font-size: 11px; line-height: 1.6; }
.btn-run { padding: 10px 18px; color: #f5f8fc; background: #27ae60; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
.btn-run { padding: 10px 18px; color: #ffffff; background: #409eff; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; width: 100%; }
.btn-run:disabled { opacity: 0.5; cursor: not-allowed; }
.loading-msg { margin-top: 10px; }
.panel-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; padding: 12px 20px 0; }
.btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; }
.toolbar-pagination { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
.act-btn { padding: 4px 9px; border-radius: 6px; font-size: 12px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; }
.act-btn.ok { background: rgba(52, 152, 219, 0.18); color: #69b6ff; border-color: transparent; }
.act-btn.warn { background: rgba(230, 162, 60, 0.14); color: #e6a23c; border-color: transparent; }
.page-info { color: #5e6878; font-size: 12px; }
.page-btns { display: flex; gap: 8px; }
.page-btn { padding: 5px 12px; border-radius: 6px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; font-size: 12px; }
.queue-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 20px 0; }
.queue-title { font-size: 15px; font-weight: 700; color: #f5f8fc; }
.btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #242424; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; }
.queue-body { flex: 1; min-height: 0; overflow: auto; padding: 16px 20px; }
.queue-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.task-card { display: flex; flex-direction: column; gap: 10px; padding: 14px 16px; background: #232a3b; border: 1px solid #333f55; border-radius: 10px; }
.task-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.task-card-id { color: #f5f8fc; font-size: 14px; font-weight: 700; word-break: break-all; }
.task-card-meta { display: flex; flex-direction: column; gap: 2px; }
.task-card-meta .files { color: #a0acbe; font-size: 12px; line-height: 1.5; }
.task-card-msg { padding: 8px 10px; background: #2b3447; border-radius: 6px; color: #c8d2e2; font-size: 12px; line-height: 1.5; min-height: 20px; }
.task-card-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 2px; }
.act-btn { padding: 5px 12px; border: none; border-radius: 6px; font-size: 12px; cursor: pointer; background: #242424; color: #c8d2e2; }
.act-btn:hover { filter: brightness(1.2); }
.act-btn.ok { background: #2496e0; color: #ffffff; }
.act-btn.warn { background: #f59e0b; color: #ffffff; }
.act-btn.danger { background: rgba(231, 76, 60, 0.16); color: #ff6b6b; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; }
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
.status.pending { background: rgba(149, 165, 166, 0.18); color: #a0acbe; }
.pagination { display: flex; align-items: center; justify-content: flex-end; gap: 12px; margin-top: 16px; }
.page-info { color: #a0acbe; font-size: 12px; }
.page-btns { display: flex; align-items: center; gap: 8px; }
.page-btn { padding: 5px 12px; border-radius: 6px; background: #242424; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; font-size: 12px; }
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.page-now { min-width: 22px; text-align: center; color: #c8d2e2; font-size: 12px; }
.empty-tasks { color: #5e6878; font-size: 13px; padding: 12px 0; text-align: center; }
.detail-body { max-height: 60vh; overflow: auto; }
.kv-row { display: flex; gap: 12px; padding: 6px 0; font-size: 13px; }
.kv-key { flex-shrink: 0; width: 110px; color: #5e6878; }
@@ -7,7 +7,7 @@
<div class="section-title">店铺输入</div>
<div class="input-zone">
<div class="hint">
左侧负责录入店铺并加入备选区确认命中后启动任务每次推送会把本次选中的所有店铺合并为一条取款任务任务之间串行执行
左侧负责录入店铺并加入备选区确认匹配结果后启动任务每次启动会把本次选中的所有店铺合并为一条取款任务任务按顺序逐个执行
</div>
<div class="input-row">
<el-input
@@ -88,16 +88,16 @@
:disabled="isQueueBusy || !matchedRunnableItems.length"
@click="pushToPythonQueue"
>
{{ isQueueBusy ? "串行执行中..." : "启动任务" }}
{{ isQueueBusy ? "按顺序执行中..." : "启动任务" }}
</button>
</div>
<p class="loading-msg">
当前页面会按任务串行推送一个任务可包含多个店铺并在任务成功或失败后自动接续下一个任务服务端临时重启时会自动重试查询状态恢复继续后续任务
任务会按顺序逐个执行一个任务完成无论成功或失败都会自动开始下一个如果中途网络异常系统会自动恢复继续处理
</p>
<div v-if="queuePushResult" class="queue-debug-card">
<div class="section-title queue-debug-title">队列状态</div>
<div class="section-title queue-debug-title">任务状态</div>
<div class="queue-debug-line">{{ queuePushResult }}</div>
<pre v-if="queuePayloadText" class="queue-debug-payload">{{
queuePayloadText
@@ -525,7 +525,7 @@ function formatMatchStatus(status?: string) {
MATCHED: "已匹配",
PENDING: "待匹配",
CONFLICT: "需人工确认",
INDEX_STALE: "索引过期",
INDEX_STALE: "匹配已过期",
};
return map[value] || value || "-";
}
@@ -534,12 +534,12 @@ function formatMatchRemark(row: WithdrawShopQueueItem) {
const message = (row.matchMessage || "").trim();
if (message) return message;
if (row.matched && row.matchStatus === "MATCHED") {
return "索引已命中,可启动任务";
return "已匹配成功,可启动任务";
}
if (row.matched) {
return "已命中索引,请结合状态确认";
return "已匹配成功,请查看状态确认";
}
return "未命中或未就绪,请检查店铺名";
return "未匹配成功,请检查店铺名";
}
function withWithdrawFallback<T extends WithdrawHistoryItem | WithdrawShopQueueItem>(
@@ -612,7 +612,7 @@ function formatTemplateSummary(record: Pick<WithdrawTaskGroupItem, "shops" | "ro
: record.rows?.length || 0;
const amount = record.reservedAmount ?? reservedAmount.value;
const shopCount = shops.length || 1;
return `保留金额 ${amount || 0},共 ${shopCount} 个店铺,已回 ${rowCount}`;
return `保留金额 ${amount || 0},共 ${shopCount} 个店铺,已${rowCount}`;
}
function isTaskTerminal(status?: string) {
@@ -799,8 +799,8 @@ function reconcileActiveQueueTaskWithHistory() {
const missingTaskId = activeTaskId.value;
clearActiveQueueTask();
queuePushResult.value = pendingQueue.value.length
? `任务 ${missingTaskId} 已不存在,已跳过并继续剩余队列`
: "当前任务已不存在,已释放本地队列状态";
? `任务 ${missingTaskId} 已不存在,已跳过并继续剩余任务`
: "当前任务已不存在,已重置本地状态";
resetQueueWorkerIfIdle();
}
@@ -940,7 +940,7 @@ async function runMatch() {
function removeMatchedRow(row: WithdrawShopQueueItem) {
if (hasQueueWork.value) {
ElMessage.warning("队列执行中,请等待当前串行任务结束后再调整");
ElMessage.warning("任务执行中,请等待当前任务结束后再调整");
return;
}
matchedItems.value = matchedItems.value.filter(
@@ -1017,7 +1017,7 @@ async function waitForTaskTerminal(taskId: number) {
return "FAILED";
}
if (transientErrorCount > 0) {
queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`;
queuePushResult.value = `任务 ${taskId} 服务已恢复,继续等待执行结果...`;
}
transientErrorCount = 0;
const item = findHistoryItemByTaskId(taskId);
@@ -1032,10 +1032,10 @@ async function waitForTaskTerminal(taskId: number) {
transientErrorCount += 1;
if (transientErrorCount >= MAX_TRANSIENT_ERRORS) {
throw new Error(
`任务 ${taskId} 等待后端恢复超时,请稍后刷新查看状态`,
`任务 ${taskId} 等待服务恢复超时,请稍后刷新查看状态`,
);
}
queuePushResult.value = `任务 ${taskId} 执行中,正在等待后端服务恢复(${transientErrorCount}/${MAX_TRANSIENT_ERRORS}...`;
queuePushResult.value = `任务 ${taskId} 执行中,正在等待服务恢复(${transientErrorCount}/${MAX_TRANSIENT_ERRORS}...`;
}
await sleep(getTaskPollIntervalMs());
}
@@ -1071,7 +1071,7 @@ async function processQueue() {
const created = await withTransientRetry(
() => createWithdrawTask(nextBatch.items.map(buildTaskItem), nextBatch.reservedAmount),
(attempt, maxAttempts) => {
queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...`;
queuePushResult.value = `服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...`;
},
);
const createdItem = created.items?.[0];
@@ -1096,14 +1096,14 @@ async function processQueue() {
await submitWithdrawTaskResult(created.taskId, {
shops: nextBatch.items.map((item) => ({
shopName: item.shopName || "",
error: `任务 ${created.taskId} 数据校验未通过,已阻止推送`,
error: `任务 ${created.taskId} 数据校验未通过,已阻止启动`,
shopDone: true,
submissionId: createSubmissionId(created.taskId, item.shopName),
rows: [],
})),
});
await refreshTaskViews();
queuePushResult.value = `任务 ${created.taskId} 数据校验未通过,已阻止推送并继续下一个任务`;
queuePushResult.value = `任务 ${created.taskId} 数据校验未通过,已阻止启动并继续下一个任务`;
clearActiveQueueTask();
continue;
}
@@ -1113,14 +1113,14 @@ async function processQueue() {
await submitWithdrawTaskResult(created.taskId, {
shops: nextBatch.items.map((item) => ({
shopName: item.shopName || "",
error: pushResult?.error || `任务 ${created.taskId} 推送失败`,
error: pushResult?.error || `任务 ${created.taskId} 启动失败`,
shopDone: true,
submissionId: createSubmissionId(created.taskId, item.shopName),
rows: [],
})),
});
await refreshTaskViews();
queuePushResult.value = `任务 ${created.taskId} 推送失败,已自动继续下一个任务`;
queuePushResult.value = `任务 ${created.taskId} 启动失败,已自动继续下一个任务`;
clearActiveQueueTask();
continue;
}
@@ -1132,11 +1132,11 @@ async function processQueue() {
await Promise.all([loadCandidates(), loadDashboard()]);
queuePushResult.value =
pendingQueue.value.length > 0
? `任务 ${created.taskId}入队,包含 ${nextBatch.items.length} 个店铺,等待完成后自动继续下一个任务(剩余 ${pendingQueue.value.length} 个任务)`
: `任务 ${created.taskId}入队,包含 ${nextBatch.items.length} 个店铺,等待执行完成`;
? `任务 ${created.taskId}提交,包含 ${nextBatch.items.length} 个店铺,等待完成后自动继续下一个任务(剩余 ${pendingQueue.value.length} 个任务)`
: `任务 ${created.taskId}提交,包含 ${nextBatch.items.length} 个店铺,等待执行完成`;
}
queuePushResult.value = "串行队列已执行完成";
queuePushResult.value = "任务已按顺序执行完成";
await refreshTaskViews();
ElMessage.success("取款队列已按顺序执行完成");
} catch (error) {
@@ -1164,7 +1164,7 @@ async function pushToPythonQueue() {
const batch = createQueueBatch(runnable);
pendingQueue.value = mergeQueueBatches(pendingQueue.value, [batch]);
saveQueueState();
queuePushResult.value = `已加入 1 个取款任务,包含 ${batch.items.length} 个店铺,开始串行执行`;
queuePushResult.value = `已加入 1 个取款任务,包含 ${batch.items.length} 个店铺,开始按顺序执行`;
await processQueue();
}
@@ -1206,7 +1206,7 @@ async function deleteTaskRecord(item: WithdrawTaskGroupItem) {
removeHistoryItemLocally(item);
await loadCandidates();
resetQueueWorkerIfIdle();
ElMessage.success("后端记录已不存在,已同步移除本地记录");
ElMessage.success("记录已不存在,已同步移除本地记录");
return;
}
ElMessage.error(error instanceof Error ? error.message : "删除失败");
@@ -1259,24 +1259,24 @@ async function batchDeleteHistory(views: TaskItemView[]) {
</script>
<style scoped>
.module-page { min-height: 100vh; background: #151a25; }
.module-page { min-height: 100vh; background: #242424; }
.main-content { display: flex; min-height: calc(100vh - 56px); height: calc(100vh - 56px); }
.left-panel { width: 400px; background: #1c2333; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #151a25; }
.left-panel { width: 400px; background: #242424; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #242424; }
.section-title { font-size: 13px; color: #a0acbe; margin-bottom: 10px; }
.input-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 16px; background: #2e3a52; margin-bottom: 20px; }
.input-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 16px; background: #242424; margin-bottom: 20px; }
.hint { color: #5e6878; font-size: 12px; margin-bottom: 12px; line-height: 1.5; text-align: left; }
.input-row { display: flex; gap: 10px; align-items: center; }
.input-row :deep(.el-input) { flex: 1; }
.filter-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
.filter-label { color: #a0acbe; font-size: 12px; white-space: nowrap; }
.reserved-input { width: 180px; }
.opt-btn { padding: 8px 12px; font-size: 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; white-space: nowrap; }
.opt-btn { padding: 8px 12px; font-size: 12px; color: #c8d2e2; background: #242424; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; white-space: nowrap; }
.opt-btn:hover:not(:disabled) { color: #3498db; border-color: #3498db; }
.opt-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.empty-candidates { color: #5e6878; font-size: 13px; padding: 16px; border: 1px dashed #333; border-radius: 8px; margin-bottom: 16px; }
.candidate-table-scroll { max-height: 320px; overflow: auto; margin-bottom: 18px; border-radius: 8px; border: 1px solid #2e3a52; }
.candidate-table { --el-table-bg-color: #2e3a52; --el-table-tr-bg-color: #2e3a52; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.candidate-table { --el-table-bg-color: #242424; --el-table-tr-bg-color: #242424; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.link-danger { background: none; border: none; color: #e74c3c; cursor: pointer; font-size: 12px; padding: 0; }
.link-danger:hover { text-decoration: underline; }
.run-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; }
@@ -1295,7 +1295,7 @@ async function batchDeleteHistory(views: TaskItemView[]) {
margin: 16px 20px 0;
border: 1px solid #2e3a52;
border-radius: 10px;
background: #1c2333;
background: #242424;
overflow: hidden;
}
@@ -1318,8 +1318,8 @@ async function batchDeleteHistory(views: TaskItemView[]) {
overflow: auto;
}
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #242424) !important; }
.result-table { --el-table-bg-color: #242424; --el-table-tr-bg-color: #242424; --el-table-header-bg-color: #2e3a52; --el-table-text-color: #c8d2e2; --el-table-border-color: #333; }
.ok { color: #27ae60; }
.fail { color: #e67e22; }
@@ -268,7 +268,7 @@ async function launchDesktop() {
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
background:
linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5)),
url('/bg.jpg') center/cover no-repeat,
image-set(url('/bg.webp') 1x, url('/bg.jpg') 1x) center/cover no-repeat,
#eef3f7;
}
@@ -390,12 +390,13 @@ watch(rememberPassword, (checkedValue) => {
body {
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
background: #d8e4ec;
min-height: 100vh;
}
.legacy-login-root {
min-height: 100vh;
/* 页面背景放根容器,不动全局 body:避免被首页等页面的 body 背景规则覆盖导致残留 */
background: #d8e4ec;
display: flex;
flex-direction: column;
align-items: center;
@@ -123,7 +123,7 @@ async function confirmBatchDelete() {
padding: 5px 12px;
border: 1px solid #3e4a62;
border-radius: 6px;
background: #222;
background: #242424;
color: #c8d2e2;
font-size: 12px;
cursor: pointer;
@@ -111,7 +111,7 @@ withDefaults(defineProps<{
.result-list-wrap {
border: 1px solid #2e3a52;
border-radius: 8px;
background: #1c2333;
background: #242424;
min-height: 180px;
}
@@ -43,7 +43,7 @@ defineProps<{
border: 1px solid #2e3a52;
border-radius: 8px;
margin-bottom: 8px;
background: #222;
background: #242424;
}
.left {
@@ -27,7 +27,7 @@ defineProps<{
padding: 14px 16px;
border: 1px solid #2e3a52;
border-radius: 8px;
background: #1c2333;
background: #242424;
}
.summary-card strong {