style(frontend): 全站背景图 webp 化(bg.jpg 4MB→251KB 压缩 + bg.webp)+品牌工具页默认策略调优(品牌检测默认改精确匹配 Simple、Terms 后置)+任务面板/登录页/亚马逊控制台观感微调
This commit is contained in:
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 后点击解析,后端会提取 id、ASIN、国家,并保留整数 id 与子数据的第一条。</div>
|
||||
<div class="hint">选择 Excel 后点击解析,系统会识别表格里的 ID、ASIN、国家信息;同一 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);启动任务将激活任务(RUNNING),Python 端按 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 后点击解析,后端会提取 id、ASIN、国家、价格,每一行都会独立送检。</div>
|
||||
<div class="hint">选择 Excel 后点击解析,系统会识别表格里的 ID、ASIN、国家、价格,每一行都会自动检测。</div>
|
||||
<div class="btns">
|
||||
<button type="button" class="opt-btn" @click="selectFiles">选择 Excel</button>
|
||||
<button type="button" class="opt-btn" @click="selectFolder">选择文件夹</button>
|
||||
@@ -68,7 +68,7 @@
|
||||
{{ pushing ? '启动中...' : '启动任务' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="loading-msg">Python 回传字段按 id、asin、国家、价格、标题、图片链接数组提交;后端接收分片后按 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,例如: 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 {
|
||||
|
||||
Reference in New Issue
Block a user