6d46506726
安全 - /api/ziniao/** 五个匿名接口加管理员鉴权(此前可匿名换取任意员工店铺登录令牌) - 删除 Flask 遗留后门:默认密码建超管 + 每次启动写生产 users 表(服务端与客户端各一份) - 进度/详情接口归属过滤:新增 TaskProgressOwnershipSupport,11 模块 progress/light 与 /tasks/batch 接入,DTO 补 userId,前端 13 个查询封装补传(未传时后端不过滤,兼容旧端) - 代理提取链接(含账密)不再明文入日志(新增 common/util/SecretMasking) - 全局异常兜底不再回传原始异常信息;内部令牌比较改常量时间 - 登录加失败计数与锁定(10 次锁 15 分钟);品牌源文件下载加 SSRF 防护 - AdminApiGuardFilter 覆盖前缀从 2 扩到 15(开关默认 false,行为不变,为收紧做准备) - 生产关闭 springdoc/knife4j(/doc.html 匿名可读全部接口定义) 正确性 - 40901/40902 拆分:锁竞争不再被伪装成 success=true(此前客户端停止重试、分片静默丢失) - 假成功收敛:集采明细批量写失败改为抛出、去重 worker 异常标失败、4 个 worker 改判 success 字段、publish 空 ASIN 行参与批次 flush、巡店删除全失败带 error 上报 - 客户端心跳 discard 移入 finally(7 模块,失败路径不再留僵尸 RUNNING 任务) - 状态机条件更新:跟价停止循环、集采 activate/fail、imagevideo 归档回填、店铺匹配提交 性能 - 前端入口包 JS 1.05MB→204KB、CSS 355KB→10.7KB(Element Plus 改按需 + el-config-provider) - 载荷引用计数按指针里的 taskId 收敛(原 JSON 列 IN 全表扫且逐行调用) - 店铺明细多值批量 INSERT;快照 upsert 预载缓存;结果文件列改单条 UPDATE - 新增迁移 V120(补 3 个缺失索引)/V121(删 4 个被覆盖的冗余索引)/V122(URL 前缀索引) 稳定性 - 新增 common/util/ThreadPools 有界线程池替换 5 处无界队列(防堆积 OOM) - Redis 锁释放改 Lua 原子校验(原裸 delete 会误删他人已过期的锁) - imagevideo 加死节点接管;锁续期失败重试;调度池 4→16;openStream 全部加超时 - 事务内远程对象删除移到提交后;启动恢复锁按实例命名 客户端 - 不再 taskkill /f /im chrome.exe(改为按调试端口精准回收,不杀用户自己的浏览器) - 密码检测不再无条件杀紫鸟进程;品牌检测加全局互斥(代理池不再互相覆盖) - base_dir 统一到 exe 目录(原被 os.getcwd() 覆盖,日志/缓存会分裂两个目录) - 缓存加定时清理;图片下载加超时;mkstemp 句柄托管 测试 - 同步更新受影响的契约测试(构造器签名/条件更新/方法改名/新增接口方法等) - 修复 FaultInjectionTest 等 3 处 mock 未 stub 流式 read 导致的读循环 OOM - mvn test 2795 个测试全绿
728 lines
29 KiB
Vue
728 lines
29 KiB
Vue
<template>
|
||
<div class="page-shell module-page">
|
||
<AmazonToolPageShell tool-id="variant">
|
||
|
||
<div class="main-content">
|
||
<aside class="left-panel">
|
||
<div class="section-title">输入方式</div>
|
||
<div class="input-tabs">
|
||
<button
|
||
type="button"
|
||
class="input-tab"
|
||
:class="{ active: inputMode === 'asin' }"
|
||
@click="inputMode = 'asin'"
|
||
>输入ASIN</button>
|
||
<button
|
||
type="button"
|
||
class="input-tab"
|
||
:class="{ active: inputMode === 'file' }"
|
||
@click="inputMode = 'file'"
|
||
>上传文件</button>
|
||
</div>
|
||
|
||
<div v-if="inputMode === 'asin'" class="input-panel">
|
||
<div class="hint">请输入ASIN,一行一个</div>
|
||
<textarea
|
||
v-model="asinText"
|
||
class="asin-textarea"
|
||
rows="9"
|
||
placeholder="请输入ASIN,一行一个"
|
||
></textarea>
|
||
<div class="asin-count">已输入 <b>{{ asinLines.length }}</b> 个 ASIN</div>
|
||
</div>
|
||
|
||
<div v-else class="input-panel">
|
||
<div class="hint">选择含 ASIN 的 Excel(需含表头行)或多个文件。</div>
|
||
<div class="btns">
|
||
<button type="button" class="opt-btn" :disabled="!hasBridge" @click="selectFiles">
|
||
选择 Excel
|
||
</button>
|
||
</div>
|
||
<div class="selected-files">
|
||
<template v-if="selectedFiles.length">
|
||
<span v-for="file in selectedFiles" :key="file" :title="file">{{ baseName(file) }}</span>
|
||
</template>
|
||
<span v-else>暂未选择文件</span>
|
||
</div>
|
||
</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: selectedCountryCode === country.code }"
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="variant-country"
|
||
:value="country.code"
|
||
v-model="selectedCountryCode"
|
||
/>
|
||
<span>{{ country.label }}({{ country.code }})</span>
|
||
</label>
|
||
</div>
|
||
<p class="country-hint" :class="{ error: !selectedCountryCode }">
|
||
{{ selectedCountryCode ? `当前选择:${countryLabel(selectedCountryCode)}(${selectedCountryCode})` : '请选择一个采集国家' }}
|
||
</p>
|
||
|
||
<div v-if="!hasBridge" class="bridge-tip">
|
||
变体采集在本机客户端执行,当前浏览器环境无桌面桥接,仅可预览页面。
|
||
</div>
|
||
|
||
<div class="run-row">
|
||
<button
|
||
type="button"
|
||
class="btn-run"
|
||
:disabled="!hasBridge || submitting || !canSubmit"
|
||
@click="submitTask"
|
||
>
|
||
{{ submitting ? '提交中...' : '开始添加任务' }}
|
||
</button>
|
||
</div>
|
||
<p class="loading-msg">
|
||
提交后任务开始执行,进度和结果在右侧查看,完成后可下载结果文件。
|
||
</p>
|
||
</aside>
|
||
|
||
<section class="right-panel">
|
||
<div class="queue-header">
|
||
<span class="queue-title">任务队列</span>
|
||
<button type="button" class="btn-refresh" @click="refreshList">刷新任务状态</button>
|
||
</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>
|
||
</section>
|
||
</div>
|
||
</AmazonToolPageShell>
|
||
|
||
<el-dialog v-model="detailVisible" title="任务详情" width="620px">
|
||
<div v-if="detailItem" class="detail-body">
|
||
<div class="kv-row"><span class="kv-key">任务 ID</span><span class="kv-val">{{ detailItem.task_id }}</span></div>
|
||
<div class="kv-row"><span class="kv-key">状态</span><span class="kv-val">{{ statusText(detailItem.status) }}</span></div>
|
||
<div class="kv-row"><span class="kv-key">采集国家</span><span class="kv-val">{{ countryText(detailItem) || '-' }}</span></div>
|
||
<div class="kv-row"><span class="kv-key">更新时间</span><span class="kv-val">{{ detailItem.update_time || formatDateTime(detailItem.updated_at) }}</span></div>
|
||
<div class="kv-row"><span class="kv-key">进度信息</span><span class="kv-val">{{ latestMessage(detailItem) || '-' }}</span></div>
|
||
<template v-if="resultFiles(detailItem).length">
|
||
<div class="kv-section">结果文件</div>
|
||
<div v-for="(url, index) in resultFiles(detailItem)" :key="url" class="kv-row">
|
||
<span class="kv-key">文件 {{ index + 1 }}</span>
|
||
<a class="kv-val link" :href="url" target="_blank" rel="noopener">{{ url }}</a>
|
||
</div>
|
||
</template>
|
||
<template v-if="detailItem.task_data?.file_url">
|
||
<div class="kv-section">源文件</div>
|
||
<div class="kv-row">
|
||
<span class="kv-key">源文件</span>
|
||
<a class="kv-val link" :href="detailItem.task_data.file_url" target="_blank" rel="noopener">{{ detailItem.task_data.file_url }}</a>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'
|
||
import {
|
||
getPywebviewApi,
|
||
type VariantTaskListItem,
|
||
} from '@/shared/bridges/pywebview'
|
||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||
|
||
/** 本地任务记录:桥接返回的列表项 + 前端维护字段 */
|
||
type VariantEntry = VariantTaskListItem & {
|
||
updated_at?: number
|
||
message?: string
|
||
meta?: { source?: string; execute_country?: string[] }
|
||
}
|
||
|
||
const COUNTRY_OPTIONS = [
|
||
{ code: 'DE', label: '德国' },
|
||
{ code: 'UK', label: '英国' },
|
||
{ code: 'FR', label: '法国' },
|
||
{ code: 'IT', label: '意大利' },
|
||
{ code: 'ES', label: '西班牙' },
|
||
] as const
|
||
|
||
const PAGE_SIZE = 8
|
||
|
||
const inputMode = ref<'asin' | 'file'>('asin')
|
||
const asinText = ref('')
|
||
const selectedFiles = ref<string[]>([])
|
||
const selectedCountryCode = ref<string>('DE')
|
||
const submitting = ref(false)
|
||
|
||
const tasks = reactive<Record<string, VariantEntry>>({})
|
||
const currentTaskIds = ref<string[]>([])
|
||
const currentPage = ref(1)
|
||
const totalPages = ref(1)
|
||
const totalCount = ref(0)
|
||
const isLoading = ref(false)
|
||
|
||
const detailVisible = ref(false)
|
||
const detailItem = ref<VariantEntry | null>(null)
|
||
|
||
const hasBridge = computed(() => Boolean(getPywebviewApi()?.vc_start_collect))
|
||
const asinLines = computed(() => asinText.value.split(/\r?\n/).map((s) => s.trim()).filter(Boolean))
|
||
const currentTasks = computed(() =>
|
||
currentTaskIds.value.map((id) => tasks[id]).filter((item): item is VariantEntry => Boolean(item)),
|
||
)
|
||
const canSubmit = computed(() =>
|
||
inputMode.value === 'asin' ? asinLines.value.length > 0 : selectedFiles.value.length > 0,
|
||
)
|
||
|
||
function countryLabel(code: string) {
|
||
return COUNTRY_OPTIONS.find((row) => row.code === code)?.label || code
|
||
}
|
||
|
||
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'
|
||
if (numeric === 3 || /failed|error/i.test(String(status))) return 'failed'
|
||
return 'pending'
|
||
}
|
||
|
||
let autoTimer: number | null = null
|
||
let refreshTimer: number | null = null
|
||
// 导出轮询用独立计时器:此前与自动刷新共用 autoTimer,两条链互相覆盖,必有一条无法被 cleanup 清掉
|
||
let exportTimer: number | null = null
|
||
// 组件已卸载标记:轮询链的异步回调在卸载后仍会续链(切页后继续打接口)
|
||
let disposed = false
|
||
|
||
function uid() {
|
||
const raw = typeof window === 'undefined' ? '' : window.localStorage.getItem('uid') || ''
|
||
return raw.trim()
|
||
}
|
||
|
||
function baseName(path: string) {
|
||
return path.split(/[\\/]/).pop() || path
|
||
}
|
||
|
||
function statusText(status?: number | string) {
|
||
const numeric = Number(status)
|
||
if (numeric === 0 || String(status).toLowerCase() === 'pending' || String(status).toLowerCase() === 'queued') return '排队中'
|
||
if (numeric === 1 || String(status).toLowerCase() === 'running' || String(status).toLowerCase() === 'processing') return '执行中'
|
||
if (numeric === 2 || ['success', 'completed', 'done'].includes(String(status).toLowerCase())) return '已完成'
|
||
if (numeric === 3 || ['failed', 'error'].includes(String(status).toLowerCase())) return '失败'
|
||
if (['cancelled', 'stopped'].includes(String(status).toLowerCase())) return '已取消'
|
||
if (status === 0 || status === 1 || status === 2 || status === 3) return statusText(String(status))
|
||
return String(status ?? '未知')
|
||
}
|
||
|
||
function isBusy(status?: number | string) {
|
||
const numeric = Number(status)
|
||
return numeric === 0 || numeric === 1 || String(status).toLowerCase() === 'pending' || String(status).toLowerCase() === 'running'
|
||
}
|
||
|
||
function toArray(value?: string[] | string) {
|
||
if (!value) return []
|
||
return Array.isArray(value) ? value.filter(Boolean) : [value]
|
||
}
|
||
|
||
function countriesOf(item?: VariantEntry) {
|
||
return item?.meta?.execute_country || item?.task_data?.execute_country || []
|
||
}
|
||
|
||
function countryText(item?: VariantEntry) {
|
||
return countriesOf(item).join('、') || '-'
|
||
}
|
||
|
||
function latestMessage(item: VariantEntry) {
|
||
const raw = item.res_mes || item.message
|
||
return (raw || '').trim() || '等待后台处理…'
|
||
}
|
||
|
||
function resultFiles(item?: VariantEntry) {
|
||
return toArray(item?.file_url)
|
||
}
|
||
|
||
function hasResultFiles(item: VariantTaskListItem) {
|
||
const resType = Number(item.res_type)
|
||
return (resType === 1 || String(item.res_type) === '1') && resultFiles(item).length > 0
|
||
}
|
||
|
||
function hasSourceFile(item: VariantTaskListItem) {
|
||
return Boolean(item.task_data?.file_url)
|
||
}
|
||
|
||
function formatDateTime(value?: number | string) {
|
||
if (!value) return '-'
|
||
const ms = typeof value === 'number' ? value : Number(new Date(String(value)))
|
||
if (!Number.isFinite(ms)) return String(value)
|
||
const date = new Date(ms)
|
||
const year = date.getFullYear()
|
||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||
const day = String(date.getDate()).padStart(2, '0')
|
||
const hours = String(date.getHours()).padStart(2, '0')
|
||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||
}
|
||
|
||
async function selectFiles() {
|
||
const bridge = getPywebviewApi()
|
||
if (!bridge?.vc_select_input_files) {
|
||
ElMessage.warning('当前环境不支持文件选择,请在本机客户端中打开')
|
||
return
|
||
}
|
||
try {
|
||
const files = await bridge.vc_select_input_files()
|
||
selectedFiles.value = files || []
|
||
if (selectedFiles.value.length) {
|
||
ElMessage.success(`已选择 ${selectedFiles.value.length} 个文件`)
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '选择文件失败')
|
||
}
|
||
}
|
||
|
||
function registerTask(taskId: string, meta?: { source?: string; execute_country?: string[] }) {
|
||
if (!taskId) return
|
||
if (!tasks[taskId]) {
|
||
tasks[taskId] = { task_id: taskId, status: 0, updated_at: Date.now(), meta: meta || {} }
|
||
} else if (meta) {
|
||
tasks[taskId].meta = { ...tasks[taskId].meta, ...meta }
|
||
}
|
||
}
|
||
|
||
async function submitTask() {
|
||
if (!hasBridge.value) {
|
||
ElMessage.warning('请在桌面客户端中打开本页面执行变体采集')
|
||
return
|
||
}
|
||
if (!selectedCountryCode.value) {
|
||
ElMessage.warning('请选择采集国家')
|
||
return
|
||
}
|
||
if (!uid()) {
|
||
ElMessage.warning('未获取到用户信息,请重新登录后再试')
|
||
return
|
||
}
|
||
const bridge = getPywebviewApi()!
|
||
const params: {
|
||
mode: 'asin' | 'file'
|
||
countries: string[]
|
||
uid: string
|
||
asins?: string[]
|
||
files?: string[]
|
||
} = {
|
||
mode: inputMode.value,
|
||
countries: selectedCountryCode.value ? [selectedCountryCode.value] : [],
|
||
uid: uid(),
|
||
}
|
||
if (inputMode.value === 'asin') {
|
||
params.asins = asinLines.value
|
||
} else {
|
||
params.files = selectedFiles.value
|
||
}
|
||
submitting.value = true
|
||
try {
|
||
const res = await bridge.vc_start_collect!(params)
|
||
if (res?.success) {
|
||
const added = res.tasks || []
|
||
ElMessage.success(`已添加 ${added.length} 个任务`)
|
||
added.forEach((task) => {
|
||
registerTask(task.task_id, { source: task.source, execute_country: task.execute_country })
|
||
})
|
||
if (inputMode.value === 'asin') {
|
||
asinText.value = ''
|
||
} else {
|
||
selectedFiles.value = []
|
||
}
|
||
await loadTasks(1)
|
||
} else {
|
||
throw new Error(res?.error || '添加任务失败')
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '添加任务失败')
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
async function refreshTask(item: VariantTaskListItem) {
|
||
const bridge = getPywebviewApi()
|
||
if (!bridge?.vc_query_task) return
|
||
try {
|
||
const res = await bridge.vc_query_task(item.task_id)
|
||
if (!res?.success) {
|
||
ElMessage.error(res?.error || '查询失败')
|
||
return
|
||
}
|
||
const entry = tasks[item.task_id] || { task_id: item.task_id, meta: {} }
|
||
if (res.status !== undefined && res.status !== null) {
|
||
entry.status = res.status
|
||
}
|
||
const data = res.data
|
||
if (data && typeof data === 'object') {
|
||
entry.message = (data as { message?: string }).message || (data as { msg?: string }).msg || ''
|
||
if (data.res_mes) {
|
||
entry.res_mes = data.res_mes
|
||
entry.message = data.res_mes
|
||
}
|
||
if (data.res_type !== undefined) entry.res_type = data.res_type
|
||
if (data.file_url) entry.file_url = data.file_url
|
||
if (data.task_data) entry.task_data = data.task_data
|
||
} else if (typeof data === 'string') {
|
||
entry.message = data
|
||
}
|
||
entry.updated_at = Date.now()
|
||
tasks[item.task_id] = entry
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '查询失败')
|
||
}
|
||
}
|
||
|
||
async function loadTasks(page?: number) {
|
||
const bridge = getPywebviewApi()
|
||
if (!bridge?.vc_list_tasks) return
|
||
if (isLoading.value) return
|
||
if (!uid()) return
|
||
isLoading.value = true
|
||
try {
|
||
const targetPage = page || currentPage.value || 1
|
||
const res = await bridge.vc_list_tasks({ uid: uid(), page: targetPage, page_size: PAGE_SIZE })
|
||
if (!res?.success) return
|
||
currentPage.value = res.page || targetPage || 1
|
||
totalPages.value = Math.max(1, res.total_page || 1)
|
||
totalCount.value = res.total || (res.tasks || []).length
|
||
|
||
const ids: string[] = []
|
||
;(res.tasks || []).forEach((item) => {
|
||
if (!item.task_id) return
|
||
registerTask(item.task_id, {})
|
||
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
|
||
entry.task_data = item.task_data
|
||
if (item.res_mes) entry.message = item.res_mes
|
||
entry.updated_at = Date.now()
|
||
ids.push(item.task_id)
|
||
})
|
||
currentTaskIds.value = ids
|
||
} catch {
|
||
/* 静默:列表加载失败时保留现状 */
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
|
||
function goPage(page: number) {
|
||
if (page < 1 || page > totalPages.value) return
|
||
void loadTasks(page)
|
||
}
|
||
|
||
function refreshList() {
|
||
ElMessage.info('正在刷新任务状态…')
|
||
void loadTasks(currentPage.value || 1)
|
||
}
|
||
|
||
async function downloadResult(item: VariantTaskListItem) {
|
||
const bridge = getPywebviewApi()
|
||
const urls = resultFiles(item)
|
||
if (!bridge?.vc_download_files_as_zip) {
|
||
ElMessage.warning('当前环境不支持下载,请在桌面客户端中打开')
|
||
return
|
||
}
|
||
if (!urls.length) {
|
||
ElMessage.warning('没有可下载的结果文件')
|
||
return
|
||
}
|
||
try {
|
||
const downloadId = `res_${item.task_id}_${Date.now()}`
|
||
const res = await bridge.vc_download_files_as_zip({
|
||
urls,
|
||
zip_name: `variant_collection_${item.task_id}.zip`,
|
||
download_id: downloadId,
|
||
})
|
||
if (res?.success) {
|
||
ElMessage.success('结果已打包保存')
|
||
} else {
|
||
throw new Error(res?.error || '下载失败')
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '下载失败')
|
||
}
|
||
}
|
||
|
||
async function downloadSource(item: VariantTaskListItem) {
|
||
const srcUrl = item.task_data?.file_url
|
||
if (!srcUrl) {
|
||
ElMessage.warning('没有可下载的源文件')
|
||
return
|
||
}
|
||
const name = srcUrl.split(/[\\/?]/).filter(Boolean).pop() || 'source.xlsx'
|
||
const result = await saveUrlWithProgress(srcUrl, name, `src_${item.task_id}_${Date.now()}`)
|
||
if (result.success) {
|
||
ElMessage.success(`源文件已保存:${result.path || name}`)
|
||
} else if (result.error && result.error !== '用户取消') {
|
||
ElMessage.error(result.error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除任务:当前客户端/后端未提供删除接口(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) {
|
||
ElMessage.warning('当前环境不支持导出,请在桌面客户端中打开')
|
||
return
|
||
}
|
||
const numeric = Number(item.status)
|
||
if (numeric === 1) {
|
||
try {
|
||
await ElMessageBox.confirm('任务正在执行中,导出会终止任务,是否继续导出?', '重新导出', { type: 'warning' })
|
||
} catch {
|
||
return
|
||
}
|
||
}
|
||
try {
|
||
const startRes = await bridge.vc_export_task(item.task_id)
|
||
if (!startRes?.success || !startRes.file_id) {
|
||
throw new Error(startRes?.error || '发起导出失败')
|
||
}
|
||
ElMessage.info('已发起导出,正在轮询结果…')
|
||
const result = await pollExport(bridge, startRes.file_id)
|
||
if (result && Number(result.status) === 2 && result.file_url) {
|
||
const name = result.file_name || result.file_url.split(/[\\/?]/).filter(Boolean).pop() || 'export.xlsx'
|
||
const dlRes = await saveUrlWithProgress(result.file_url, name, `exp_${startRes.file_id}`)
|
||
if (dlRes.success) {
|
||
ElMessage.success(`导出文件已保存:${dlRes.path || name}`)
|
||
} else if (dlRes.error && dlRes.error !== '用户取消') {
|
||
ElMessage.error(dlRes.error)
|
||
}
|
||
void loadTasks(currentPage.value || 1)
|
||
} else if (result && Number(result.status) === 3) {
|
||
ElMessage.error(result.error || '后台导出失败')
|
||
} else {
|
||
ElMessage.warning('导出超时或状态未知,请稍后重试')
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '导出失败')
|
||
}
|
||
}
|
||
|
||
function pollExport(bridge: NonNullable<ReturnType<typeof getPywebviewApi>>, fileId: string) {
|
||
return new Promise<{ status?: number | string; file_url?: string; file_name?: string; error?: string } | null>((resolve) => {
|
||
let count = 0
|
||
const maxAttempts = 60
|
||
const tick = async () => {
|
||
if (disposed) {
|
||
resolve(null)
|
||
return
|
||
}
|
||
count += 1
|
||
try {
|
||
const res = await bridge.vc_query_export!(fileId)
|
||
if (res?.success) {
|
||
const st = Number(res.status)
|
||
if (st === 2 || st === 3) {
|
||
resolve(res)
|
||
return
|
||
}
|
||
}
|
||
} catch {
|
||
/* 网络异常继续轮询 */
|
||
}
|
||
if (count >= maxAttempts) {
|
||
resolve(null)
|
||
return
|
||
}
|
||
exportTimer = window.setTimeout(tick, 2000)
|
||
}
|
||
void tick()
|
||
})
|
||
}
|
||
|
||
function showDetail(item: VariantTaskListItem) {
|
||
detailItem.value = { ...item, task_data: item.task_data ? { ...item.task_data } : undefined }
|
||
detailVisible.value = true
|
||
}
|
||
|
||
function scheduleAutoRefresh() {
|
||
if (autoTimer) {
|
||
window.clearTimeout(autoTimer)
|
||
autoTimer = null
|
||
}
|
||
autoTimer = window.setTimeout(async () => {
|
||
if (disposed) {
|
||
return
|
||
}
|
||
await loadTasks(currentPage.value || 1)
|
||
// 卸载后不得再续链:异步回调返回时组件可能已销毁,续链会持续打接口
|
||
if (!disposed && currentTasks.value.some((item) => isBusy(item.status))) {
|
||
scheduleAutoRefresh()
|
||
}
|
||
}, 3000)
|
||
}
|
||
|
||
function startPolling() {
|
||
void loadTasks(1)
|
||
refreshTimer = window.setInterval(() => {
|
||
void loadTasks(currentPage.value || 1)
|
||
}, 60 * 1000)
|
||
}
|
||
|
||
function cleanup() {
|
||
disposed = true
|
||
if (autoTimer) {
|
||
window.clearTimeout(autoTimer)
|
||
autoTimer = null
|
||
}
|
||
if (exportTimer) {
|
||
window.clearTimeout(exportTimer)
|
||
exportTimer = null
|
||
}
|
||
if (refreshTimer) {
|
||
window.clearInterval(refreshTimer)
|
||
refreshTimer = null
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
startPolling()
|
||
scheduleAutoRefresh()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
cleanup()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.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: #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: #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: #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: #f5f8fc; }
|
||
.btns, .run-row { display: flex; gap: 10px; flex-wrap: wrap; }
|
||
.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 #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: #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; }
|
||
.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; }
|
||
.kv-val { color: #c8d2e2; word-break: break-all; }
|
||
.kv-val.link { color: #69b6ff; }
|
||
.kv-section { margin: 10px 0 4px; font-size: 12px; color: #a0acbe; border-bottom: 1px solid #333; padding-bottom: 4px; }
|
||
@media (max-width: 1100px) {
|
||
.main-content { flex-direction: column; height: auto; }
|
||
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
|
||
}
|
||
</style>
|