Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandVariantTab.vue
T
huangzd1997 6d46506726 fix: 全维度审查修复(安全/正确性/性能/稳定性/客户端/前端)
安全
- /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 个测试全绿
2026-09-14 04:15:36 +08:00

728 lines
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>