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

This commit is contained in:
2026-09-09 18:32:48 +08:00
parent 9a487ae9a7
commit 83cb3ff84e
29 changed files with 565 additions and 570 deletions
@@ -11,22 +11,22 @@
class="input-tab"
:class="{ active: inputMode === 'asin' }"
@click="inputMode = 'asin'"
>粘贴 ASIN</button>
>输入ASIN</button>
<button
type="button"
class="input-tab"
:class="{ active: inputMode === 'file' }"
@click="inputMode = 'file'"
>Excel 文件</button>
>上传文件</button>
</div>
<div v-if="inputMode === 'asin'" class="input-panel">
<div class="hint">每行一个 ASIN支持批量粘贴</div>
<div class="hint">请输入ASIN一行一个</div>
<textarea
v-model="asinText"
class="asin-textarea"
rows="9"
placeholder="每行一个 ASIN,例如:&#10;B0xxxxxxxx"
placeholder="请输入ASIN,一行一个"
></textarea>
<div class="asin-count">已输入 <b>{{ asinLines.length }}</b> ASIN</div>
</div>
@@ -46,24 +46,25 @@
</div>
</div>
<div class="section-title">采集国家可多</div>
<div class="section-title">采集国家</div>
<div class="country-group">
<label
v-for="country in COUNTRY_OPTIONS"
:key="country.code"
class="country-chip"
:class="{ selected: selectedCountries.includes(country.code) }"
:class="{ selected: selectedCountryCode === country.code }"
>
<input
type="checkbox"
type="radio"
name="variant-country"
:value="country.code"
v-model="selectedCountries"
v-model="selectedCountryCode"
/>
<span>{{ country.label }}{{ country.code }}</span>
</label>
</div>
<p class="country-hint" :class="{ error: selectedCountries.length === 0 }">
{{ selectedCountries.length === 0 ? '至少选择一个国家' : `已选 ${selectedCountries.length} 个国家` }}
<p class="country-hint" :class="{ error: !selectedCountryCode }">
{{ selectedCountryCode ? `当前选择:${countryLabel(selectedCountryCode)}${selectedCountryCode}` : '请选择一个采集国家' }}
</p>
<div v-if="!hasBridge" class="bridge-tip">
@@ -77,57 +78,54 @@
:disabled="!hasBridge || submitting || !canSubmit"
@click="submitTask"
>
{{ submitting ? '提交中...' : '开始采集(添加任务队列)' }}
{{ submitting ? '提交中...' : '开始添加任务' }}
</button>
</div>
<p class="loading-msg">
任务在远程批处理队列执行提交后到右侧查看进度并下载结果文件
提交后任务开始执行进度和结果在右侧查看完成后可下载结果文件
</p>
</aside>
<section class="right-panel">
<div class="panel-toolbar">
<div class="queue-header">
<span class="queue-title">任务队列</span>
<button type="button" class="btn-refresh" @click="refreshList">刷新任务状态</button>
<div v-if="totalCount" class="toolbar-pagination">
</div>
<div class="queue-body">
<div v-if="!currentTasks.length" class="empty-tasks">暂无变体采集任务</div>
<div v-else class="queue-grid">
<div v-for="item in currentTasks" :key="`vc-${item.task_id}`" class="task-card">
<div class="task-card-head">
<span class="task-card-id">任务 {{ item.task_id }}</span>
<span class="status" :class="statusClass(item.status)">{{ statusText(item.status) }}</span>
</div>
<div class="task-card-meta">
<div class="files">国家{{ countryText(item) || '-' }}</div>
<div class="files">任务创建时间{{ formatDateTime(item.create_time || item.updated_at) }}</div>
<div class="files">最后更新时间{{ formatDateTime(item.update_time || item.updated_at) }}</div>
<div v-if="taskMeta(item).source" class="files">来源{{ taskMeta(item).source }}</div>
</div>
<div class="task-card-msg">{{ latestMessage(item) }}</div>
<div class="task-card-actions">
<button type="button" class="act-btn" @click="showDetail(item)">查看任务详情</button>
<button v-if="hasResultFiles(item)" type="button" class="act-btn ok"
@click="downloadResult(item)">下载结果</button>
<button v-if="hasSourceFile(item)" type="button" class="act-btn"
@click="downloadSource(item)">下载源文件</button>
<button type="button" class="act-btn warn" @click="reexportTask(item)">重新导出数据</button>
<button type="button" class="act-btn danger" @click="deleteTask(item)">删除任务</button>
</div>
</div>
</div>
<div v-if="currentTasks.length" class="pagination">
<span class="page-info"> {{ totalCount }} · {{ currentPage }} / {{ totalPages }} </span>
<div class="page-btns">
<button type="button" class="page-btn" :disabled="currentPage <= 1" @click="goPage(currentPage - 1)">上一页</button>
<span class="page-now">{{ currentPage }}</span>
<button type="button" class="page-btn" :disabled="currentPage >= totalPages" @click="goPage(currentPage + 1)">下一页</button>
</div>
</div>
</div>
<TaskCenterPanel
title="变体采集任务"
:cards="variantCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-title="当前任务"
current-empty-text="暂无运行中的变体采集任务"
history-empty-text="暂无历史记录"
>
<template #item-actions="{ item }">
<template v-if="itemSource(item)">
<button type="button" class="act-btn" @click="refreshTask(itemSource(item))">更新</button>
<button type="button" class="act-btn" @click="showDetail(itemSource(item))">详情</button>
<button v-if="hasResultFiles(itemSource(item))" type="button" class="act-btn ok"
@click="downloadResult(itemSource(item))">下载结果</button>
<button v-if="hasSourceFile(itemSource(item))" type="button" class="act-btn"
@click="downloadSource(itemSource(item))">下载源文件</button>
<button type="button" class="act-btn warn" @click="reexportTask(itemSource(item))">重新导出</button>
</template>
</template>
<template #history-item-actions="{ item }">
<template v-if="itemSource(item)">
<button type="button" class="act-btn" @click="refreshTask(itemSource(item))">更新</button>
<button type="button" class="act-btn" @click="showDetail(itemSource(item))">详情</button>
<button v-if="hasResultFiles(itemSource(item))" type="button" class="act-btn ok"
@click="downloadResult(itemSource(item))">下载结果</button>
<button v-if="hasSourceFile(itemSource(item))" type="button" class="act-btn"
@click="downloadSource(itemSource(item))">下载源文件</button>
<button type="button" class="act-btn warn" @click="reexportTask(itemSource(item))">重新导出</button>
</template>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
@@ -162,8 +160,6 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import {
getPywebviewApi,
type VariantTaskListItem,
@@ -190,7 +186,7 @@ const PAGE_SIZE = 8
const inputMode = ref<'asin' | 'file'>('asin')
const asinText = ref('')
const selectedFiles = ref<string[]>([])
const selectedCountries = ref<string[]>([])
const selectedCountryCode = ref<string>('DE')
const submitting = ref(false)
const tasks = reactive<Record<string, VariantEntry>>({})
@@ -212,29 +208,15 @@ const canSubmit = computed(() =>
inputMode.value === 'asin' ? asinLines.value.length > 0 : selectedFiles.value.length > 0,
)
// ---- 右侧任务面板统一视图(TaskCenterPanel----
/** 当前任务:排队中(0) / 执行中(1) 的任务(当前分页内) */
const currentTaskViews = computed<TaskItemView[]>(() =>
currentTasks.value.filter((item) => isBusy(item.status)).map(toVariantTaskView),
)
/** 历史任务:已完成(2) / 失败(3) 的任务(当前分页内) */
const historyTaskViews = computed<TaskItemView[]>(() =>
currentTasks.value.filter((item) => !isBusy(item.status)).map(toVariantTaskView),
)
const variantCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: historyTaskViews.value.length },
{ label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
{ label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
])
function itemSource(item: TaskItemView): VariantEntry {
return item.source as VariantEntry
function countryLabel(code: string) {
return COUNTRY_OPTIONS.find((row) => row.code === code)?.label || code
}
function variantStatusClass(status?: number | string) {
function taskMeta(item: VariantEntry) {
return item.meta || {}
}
function statusClass(status?: number | string) {
const numeric = Number(status)
if (numeric === 1 || /running|processing/i.test(String(status))) return 'running'
if (numeric === 2 || /success|completed|done/i.test(String(status))) return 'success'
@@ -242,29 +224,6 @@ function variantStatusClass(status?: number | string) {
return 'pending'
}
function toVariantTaskView(item: VariantEntry): TaskItemView {
const extraLines: string[] = []
const source = item.meta?.source || ''
if (source) extraLines.push(`来源:${source}`)
extraLines.push(`采集国家:${countryText(item) || '-'}`)
const updateText = item.update_time || formatDateTime(item.updated_at)
if (updateText) extraLines.push(`更新时间:${updateText}`)
extraLines.push(latestMessage(item))
return {
key: `vc-${item.task_id}`,
title: `任务 ${item.task_id}`,
taskId: item.task_id,
// 桥接列表未提供任务开始时间,开始时间占位为 '-'
startedAt: '-',
// 桥接仅提供最近更新时间,作为结束时间的近似展示
finishedAt: formatDateTime(item.update_time),
statusText: statusText(item.status),
statusClass: variantStatusClass(item.status),
extraLines,
source: item,
}
}
let autoTimer: number | null = null
let refreshTimer: number | null = null
@@ -369,8 +328,8 @@ async function submitTask() {
ElMessage.warning('请在桌面客户端中打开本页面执行变体采集')
return
}
if (!selectedCountries.value.length) {
ElMessage.warning('请至少选择一个采集国家')
if (!selectedCountryCode.value) {
ElMessage.warning('请选择采集国家')
return
}
if (!uid()) {
@@ -386,7 +345,7 @@ async function submitTask() {
files?: string[]
} = {
mode: inputMode.value,
countries: selectedCountries.value,
countries: selectedCountryCode.value ? [selectedCountryCode.value] : [],
uid: uid(),
}
if (inputMode.value === 'asin') {
@@ -473,6 +432,7 @@ async function loadTasks(page?: number) {
const entry = tasks[item.task_id]
entry.status = item.status
if (item.update_time) entry.update_time = item.update_time
if (item.create_time) entry.create_time = item.create_time
entry.res_type = item.res_type
entry.res_mes = item.res_mes
entry.file_url = item.file_url
@@ -542,6 +502,26 @@ async function downloadSource(item: VariantTaskListItem) {
}
}
/**
* 删除任务:当前客户端/后端未提供删除接口(batch 仅 add/query/list/export),
* 先提示用户操作不可用,避免出现"已删除"假象
*/
async function deleteTask(item: VariantTaskListItem) {
const api = getPywebviewApi()
const hasVcDelete = Boolean((api as Record<string, unknown> | null)?.['vc_delete_task'])
if (!hasVcDelete) {
ElMessage.warning('当前客户端不支持删除任务,可在后台保存或稍后清理')
return
}
try {
await (api as { vc_delete_task: (taskId: string) => Promise<{ success?: boolean; error?: string }> })
.vc_delete_task(item.task_id)
void loadTasks(currentPage.value || 1)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
async function reexportTask(item: VariantTaskListItem) {
const bridge = getPywebviewApi()
if (!bridge?.vc_export_task) {
@@ -657,48 +637,66 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.module-page { min-height: 100vh; background: #151a25; }
.module-page { min-height: 100vh; background: #242424; }
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
.left-panel { width: 420px; background: #1c2333; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; min-width: 0; background: #151a25; display: flex; flex-direction: column; }
.left-panel { width: 420px; background: #242424; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; min-width: 0; background: #242424; display: flex; flex-direction: column; }
.section-title { font-size: 13px; color: #a0acbe; margin: 14px 0 10px; }
.hint, .loading-msg, .files, .country-hint { color: #5e6878; font-size: 12px; line-height: 1.5; }
.hint { margin-bottom: 8px; }
.input-tabs { display: flex; gap: 6px; margin-bottom: 12px; }
.input-tab { flex: 1; padding: 9px 0; border: 1px solid #3e4a62; border-radius: 8px; background: #2e3a52; color: #a0acbe; font-size: 13px; cursor: pointer; }
.input-tab.active { background: rgba(64, 158, 255, 0.15); color: #409eff; border-color: #409eff; }
.input-tab { flex: 1; padding: 9px 0; border: 1px solid #3e4a62; border-radius: 8px; background: #242424; color: #a0acbe; font-size: 13px; cursor: pointer; }
.input-tab.active { background: #409eff; color: #ffffff; border-color: #409eff; }
.input-panel { margin-bottom: 4px; }
.asin-textarea { width: 100%; box-sizing: border-box; min-height: 150px; padding: 10px; background: #151a25; color: #c8d2e2; border: 1px solid #3e4a62; border-radius: 8px; font-size: 13px; line-height: 1.6; resize: vertical; outline: none; }
.asin-textarea { width: 100%; box-sizing: border-box; min-height: 150px; padding: 10px; background: #242424; color: #c8d2e2; border: 1px solid #3e4a62; border-radius: 8px; font-size: 13px; line-height: 1.6; resize: vertical; outline: none; }
.asin-textarea:focus { border-color: #409eff; }
.asin-count { margin-top: 8px; color: #5e6878; font-size: 12px; }
.asin-count b { color: #c8d2e2; }
.asin-count b { color: #f5f8fc; }
.btns, .run-row { display: flex; gap: 10px; flex-wrap: wrap; }
.opt-btn { padding: 8px 14px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 7px; cursor: pointer; font-size: 12px; }
.opt-btn:hover:not(:disabled) { color: #3498db; border-color: #3498db; }
.opt-btn { padding: 8px 14px; color: #f5f8fc; background: #409eff; border: none; border-radius: 7px; cursor: pointer; font-size: 12px; }
.opt-btn:hover:not(:disabled) { background: #66b1ff; }
.opt-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.selected-files { margin-top: 10px; color: #a0acbe; font-size: 12px; word-break: break-all; }
.selected-files span { display: block; margin: 3px 0; }
.country-group { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.country-chip { display: inline-flex; align-items: center; gap: 7px; padding: 9px 11px; border: 1px solid #333; border-radius: 8px; background: #2e3a52; color: #c8d2e2; font-size: 12px; cursor: pointer; user-select: none; }
.country-chip.selected { border-color: #409eff; background: rgba(64, 158, 255, 0.12); color: #e6f2ff; }
.country-chip input { width: 15px; height: 15px; accent-color: #409eff; cursor: pointer; }
.country-chip { display: inline-flex; align-items: center; gap: 7px; padding: 9px 11px; border: 1px solid #3e4a62; border-radius: 8px; background: #242424; color: #f5f8fc; font-size: 12px; cursor: pointer; user-select: none; }
.country-chip.selected { border-color: #409eff; background: #409eff; color: #ffffff; }
.country-chip input { display: none; }
.country-hint { margin: 10px 0 4px; }
.country-hint.error { color: #e6a23c; }
.country-hint b { color: #c8d2e2; }
.bridge-tip { margin: 12px 0; padding: 9px 12px; border-radius: 8px; background: rgba(230, 162, 60, 0.1); color: #e6a23c; font-size: 11px; line-height: 1.6; }
.btn-run { padding: 10px 18px; color: #f5f8fc; background: #27ae60; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
.btn-run { padding: 10px 18px; color: #ffffff; background: #409eff; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; width: 100%; }
.btn-run:disabled { opacity: 0.5; cursor: not-allowed; }
.loading-msg { margin-top: 10px; }
.panel-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; padding: 12px 20px 0; }
.btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; }
.toolbar-pagination { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
.act-btn { padding: 4px 9px; border-radius: 6px; font-size: 12px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; }
.act-btn.ok { background: rgba(52, 152, 219, 0.18); color: #69b6ff; border-color: transparent; }
.act-btn.warn { background: rgba(230, 162, 60, 0.14); color: #e6a23c; border-color: transparent; }
.page-info { color: #5e6878; font-size: 12px; }
.page-btns { display: flex; gap: 8px; }
.page-btn { padding: 5px 12px; border-radius: 6px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; font-size: 12px; }
.queue-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 20px 0; }
.queue-title { font-size: 15px; font-weight: 700; color: #f5f8fc; }
.btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #242424; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; }
.queue-body { flex: 1; min-height: 0; overflow: auto; padding: 16px 20px; }
.queue-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.task-card { display: flex; flex-direction: column; gap: 10px; padding: 14px 16px; background: #232a3b; border: 1px solid #333f55; border-radius: 10px; }
.task-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.task-card-id { color: #f5f8fc; font-size: 14px; font-weight: 700; word-break: break-all; }
.task-card-meta { display: flex; flex-direction: column; gap: 2px; }
.task-card-meta .files { color: #a0acbe; font-size: 12px; line-height: 1.5; }
.task-card-msg { padding: 8px 10px; background: #2b3447; border-radius: 6px; color: #c8d2e2; font-size: 12px; line-height: 1.5; min-height: 20px; }
.task-card-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 2px; }
.act-btn { padding: 5px 12px; border: none; border-radius: 6px; font-size: 12px; cursor: pointer; background: #242424; color: #c8d2e2; }
.act-btn:hover { filter: brightness(1.2); }
.act-btn.ok { background: #2496e0; color: #ffffff; }
.act-btn.warn { background: #f59e0b; color: #ffffff; }
.act-btn.danger { background: rgba(231, 76, 60, 0.16); color: #ff6b6b; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; }
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
.status.pending { background: rgba(149, 165, 166, 0.18); color: #a0acbe; }
.pagination { display: flex; align-items: center; justify-content: flex-end; gap: 12px; margin-top: 16px; }
.page-info { color: #a0acbe; font-size: 12px; }
.page-btns { display: flex; align-items: center; gap: 8px; }
.page-btn { padding: 5px 12px; border-radius: 6px; background: #242424; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; font-size: 12px; }
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.page-now { min-width: 22px; text-align: center; color: #c8d2e2; font-size: 12px; }
.empty-tasks { color: #5e6878; font-size: 13px; padding: 12px 0; text-align: center; }
.detail-body { max-height: 60vh; overflow: auto; }
.kv-row { display: flex; gap: 12px; padding: 6px 0; font-size: 13px; }
.kv-key { flex-shrink: 0; width: 110px; color: #5e6878; }