Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.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

1149 lines
40 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="qasin">
<div class="main-content">
<aside class="left-panel">
<div class="section-title">店铺输入</div>
<div class="input-zone">
<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>
</div>
</div>
<div class="section-title">备选区</div>
<div v-if="!candidates.length" class="empty-candidates">
暂无备选店铺请先输入并添加
</div>
<div v-else class="candidate-table-scroll">
<el-table
:data="candidates"
row-key="id"
height="260"
class="candidate-table"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="42" />
<el-table-column
prop="shop_name"
label="店铺名"
min-width="140"
show-overflow-tooltip
/>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<button
type="button"
class="link-danger"
@click="removeCandidate(row.id)"
>
删除
</button>
</template>
</el-table-column>
</el-table>
</div>
<ZiniaoVersionSetting v-model="ziniaoVersion" />
<div class="run-row">
<button
type="button"
class="btn-run"
:disabled="matching"
@click="runMatch"
>
{{ matching ? "匹配中..." : "匹配店铺" }}
</button>
<button
type="button"
class="btn-run btn-queue"
:disabled="isQueueBusy || !matchedRunnableItems.length"
@click="pushToPythonQueue"
>
{{ 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="queue-debug-line">{{ queuePushResult }}</div>
<pre v-if="queuePayloadText" class="queue-debug-payload">{{
queuePayloadText
}}</pre>
</div>
</aside>
<section class="right-panel">
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="匹配与任务"
:cards="queryAsinCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-empty-text="暂无当前任务启动任务后正在执行的任务会展示在这里历史任务可在右上角历史任务中查看"
history-empty-text="暂无历史记录"
>
<template #cards-extra>
<div class="subsection-title">匹配结果</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="120" />
<el-table-column label="匹配" width="72" align="center">
<template #default="{ row }">
<span :class="row.matched ? 'ok' : 'fail'">
{{ row.matched ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column
prop="shopId"
label="店铺 ID"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
prop="platform"
label="平台"
width="88"
show-overflow-tooltip
/>
<el-table-column
prop="companyName"
label="公司"
min-width="120"
show-overflow-tooltip
/>
<el-table-column label="状态" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchStatus(row.matchStatus) }}
</template>
</el-table-column>
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ formatMatchRemark(row) }}
</template>
</el-table-column>
<el-table-column label="操作" width="72" align="center">
<template #default="{ row }">
<button
type="button"
class="link-danger"
@click="removeMatchedRow(row)"
>
删除
</button>
</template>
</el-table-column>
</el-table>
</template>
<template #item-actions="{ item }">
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
<template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载结果</button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import { ElMessage } 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 {
addQueryAsinCandidate,
createQueryAsinTask,
deleteQueryAsinCandidate,
deleteQueryAsinHistory,
deleteQueryAsinTask,
getQueryAsinDashboard,
getQueryAsinHistory,
getQueryAsinTaskProgressBatch,
getQueryAsinResultDownloadUrl,
listQueryAsinCandidates,
matchQueryAsinShops,
submitQueryAsinTaskResult,
type QueryAsinCandidateVo,
type QueryAsinCountryAsins,
type QueryAsinDashboardVo,
type QueryAsinHistoryItem,
type QueryAsinShopQueueItem,
type QueryAsinTaskItem,
} from "@/shared/api/java-modules";
import { getPywebviewApi } from "@/shared/bridges/pywebview";
import { getTaskPollIntervalMs } from "@/shared/task-progress-config";
import { createCategorizedTimers } from "@/shared/utils/categorized-timers";
import { saveUrlWithProgress } from "@/shared/utils/download-progress";
import { checkQueuePayload } from "@/shared/dispatch-guard";
import { passGuard } from "@/shared/dispatch-guard-ui";
import ZiniaoVersionSetting from "@/shared/components/ZiniaoVersionSetting.vue";
import { useZiniaoVersion } from "@/shared/utils/ziniao-version";
import { formatDateTime } from '@/shared/utils/datetime'
import { useHistoryPolling } from '@/shared/composables/useHistoryPolling'
import { runBatchDelete } from '@/shared/utils/batch-delete'
const MAX_TRANSIENT_ERRORS = 30;
const ziniaoVersion = useZiniaoVersion();
const shopInput = ref("");
const candidates = ref<QueryAsinCandidateVo[]>([]);
const selectedCandidates = ref<QueryAsinCandidateVo[]>([]);
const matchedItems = ref<QueryAsinShopQueueItem[]>([]);
const historyItems = ref<QueryAsinHistoryItem[]>([]);
const dashboard = ref<QueryAsinDashboardVo>({
candidateCount: 0,
processedTaskCount: 0,
successTaskCount: 0,
failedTaskCount: 0,
});
const adding = ref(false);
const matching = ref(false);
const pushing = ref(false);
const queuePushResult = ref("");
const queuePayloadText = ref("");
const pendingQueue = ref<QueryAsinShopQueueItem[]>([]);
const activeTaskId = ref<number | null>(null);
const activeQueueItem = ref<QueryAsinShopQueueItem | null>(null);
const queueWorkerRunning = ref(false);
const autoQueueEnabled = ref(false);
const taskStartTimes = ref<Record<number, string>>({});
const timers = createCategorizedTimers("query-asin");
const matchedRunnableItems = computed(() =>
matchedItems.value.filter((item) => item.matched && (item.queryAsins || []).some((row) => row.asins?.length)),
);
const currentSectionItems = computed(() =>
historyItems.value.filter((item) => !isTaskTerminal(item.taskStatus)),
);
const historySectionItems = computed(() =>
historyItems.value.filter((item) => isTaskTerminal(item.taskStatus)),
);
/** 统一统计卡:运行中任务按页面当前列表实时计算,其余沿用后端 dashboard */
const queryAsinCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentSectionItems.value.length },
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
{ label: '成功任务', value: dashboard.value.successTaskCount },
{ label: '失败任务', value: dashboard.value.failedTaskCount },
]);
const currentTaskViews = computed<TaskItemView[]>(() => currentSectionItems.value.map(toQueryAsinTaskView));
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toQueryAsinTaskView));
function itemSource(item: TaskItemView) {
return item.source as QueryAsinHistoryItem;
}
function toQueryAsinTaskView(item: QueryAsinHistoryItem): TaskItemView {
const startedRaw = taskStartTime(item.taskId);
return {
key: `qasin-${item.taskId ?? "r"}-${item.resultId ?? ""}-${item.shopName ?? ""}`,
title: item.shopName || "-",
taskId: item.taskId ?? "-",
startedAt: formatDateTime(startedRaw || item.createdAt),
finishedAt: item.finishedAt ? formatDateTime(item.finishedAt) : "进行中",
statusText: statusText(item.taskStatus),
statusClass: statusClass(item.taskStatus),
extraLines: [
...(item.resultId ? [`结果 ID: ${item.resultId}`] : []),
...(item.shopId ? [`店铺 ID: ${item.shopId}`] : []),
...(item.platform ? [`平台: ${item.platform}`] : []),
...(startedRaw && item.createdAt && startedRaw !== item.createdAt ? [`创建时间: ${formatDateTime(item.createdAt)}`] : []),
`ASIN结构: ${formatTemplateSummary(item)}`,
...(item.error ? [`错误: ${item.error}`] : []),
],
source: item,
};
}
const hasQueuedTaskWork = computed(
() =>
!!activeTaskId.value ||
pendingQueue.value.length > 0,
);
const hasQueueWork = computed(() => queueWorkerRunning.value || hasQueuedTaskWork.value);
const isQueueBusy = computed(() => queueWorkerRunning.value && hasQueuedTaskWork.value);
function uidForStorage() {
return typeof window !== "undefined"
? window.localStorage.getItem("uid") || "0"
: "0";
}
function matchedStorageKey() {
return `query-asin:matched:${uidForStorage()}`;
}
function queueStateStorageKey() {
return `query-asin:queue-state:${uidForStorage()}`;
}
function taskStartTimeStorageKey() {
return `query-asin:start-times:${uidForStorage()}`;
}
function rowKeyForMatch(row: QueryAsinShopQueueItem) {
return `${(row.shopName || "").trim()}::${row.shopId || ""}`;
}
function historyItemKey(item: QueryAsinHistoryItem) {
return `${item.taskId ?? 0}:${item.resultId ?? 0}`;
}
let disposed = false;
const historyPolling = useHistoryPolling({
timers,
isDisposed: () => disposed,
shouldPoll: () => hasQueueWork.value,
refresh: () => refreshActiveTaskProgress(),
})
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.toLowerCase() : String(error || "").toLowerCase();
return (
message.includes("network error") ||
message.includes("failed to fetch") ||
message.includes("timeout") ||
message.includes("503") ||
message.includes("502") ||
message.includes("504") ||
message.includes("500") ||
message.includes("load failed")
);
}
async function withTransientRetry<T>(
action: () => Promise<T>,
onRetry?: (attempt: number, maxAttempts: number) => void,
maxAttempts = 20,
) {
let attempt = 0;
while (true) {
if (disposed) throw new Error("component disposed");
try {
return await action();
} catch (error) {
if (!isTransientBackendError(error) || attempt >= maxAttempts - 1) {
throw error;
}
attempt += 1;
onRetry?.(attempt, maxAttempts);
await sleep(getTaskPollIntervalMs());
}
}
}
function createSubmissionId(taskId: number, shopName?: string) {
return `query-asin:${taskId}:${shopName || "shop"}:${Date.now()}`;
}
function formatMatchStatus(status?: string) {
const value = (status || "").trim();
const map: Record<string, string> = {
MATCHED: "已匹配",
PENDING: "待匹配",
CONFLICT: "需人工确认",
INDEX_STALE: "匹配已过期",
};
return map[value] || value || "-";
}
function formatMatchRemark(row: QueryAsinShopQueueItem) {
const message = (row.matchMessage || "").trim();
if (message) return message;
if (row.matched && row.matchStatus === "MATCHED") {
return "已匹配成功,可启动任务";
}
if (row.matched) {
return "已匹配成功,请查看状态确认";
}
return "未匹配成功,请检查店铺名";
}
function flattenQueryAsinRows(record: Pick<QueryAsinHistoryItem, "shopName" | "queryAsins">) {
const sections = readQueryAsins(record);
const rowCount = Math.max(...sections.map((section) => section.asins?.length || 0), 1);
return Array.from({ length: rowCount }, (_, rowIndex) => ({
shopName: rowIndex === 0 ? record.shopName : "",
countries: sections.map((section) => ({
country: section.country,
asin: section.asins?.[rowIndex] || "",
})),
}));
}
function formatTemplateSummary(record: Pick<QueryAsinHistoryItem, "shopName" | "queryAsins">) {
const sections = readQueryAsins(record);
const total = sections.reduce((sum, section) => sum + (section.asins?.length || 0), 0);
const countries = sections.filter((section) => section.asins?.length).map((section) => section.country).join("、");
return `${countries || "无国家"},共 ${total} 个 ASIN`;
}
function copyQueryAsins(items?: QueryAsinCountryAsins[]) {
return (items || [])
.map((item) => ({
country: (item.country || "").trim().toUpperCase(),
asins: Array.from(new Set((item.asins || []).map((asin) => (asin || "").trim().toUpperCase()).filter(Boolean))),
}))
.filter((item) => item.country && item.asins.length);
}
function readQueryAsins(record?: { queryAsins?: QueryAsinCountryAsins[]; query_asins?: QueryAsinCountryAsins[] } | null) {
return copyQueryAsins(record?.queryAsins?.length ? record.queryAsins : record?.query_asins);
}
function hasQueryAsins(item?: { queryAsins?: QueryAsinCountryAsins[]; query_asins?: QueryAsinCountryAsins[] } | null) {
return readQueryAsins(item).some((section) => section.asins.length);
}
function fallbackQueryAsinsForShop(shopName?: string) {
const normalizedShopName = (shopName || "").trim();
const candidates = [
activeQueueItem.value,
...pendingQueue.value,
...matchedItems.value,
];
const exact = candidates.find((item) => (item?.shopName || "").trim() === normalizedShopName && hasQueryAsins(item));
return readQueryAsins(exact);
}
function withQueryAsinFallback<T extends QueryAsinHistoryItem | QueryAsinShopQueueItem>(
item: T,
fallback?: QueryAsinShopQueueItem | QueryAsinHistoryItem | null,
) {
// 已有 queryAsins 时直接返回原对象:本函数在每轮轮询里对全量历史调用,
// 无条件 { ...item } 会让下游历史抽屉的全部卡片因 props 变化而每轮重渲染
if (hasQueryAsins(item)) {
return item;
}
const fallbackAsins = readQueryAsins(fallback || undefined);
const resolved = fallbackAsins.length ? fallbackAsins : fallbackQueryAsinsForShop(item.shopName);
return {
...item,
queryAsins: resolved,
};
}
function isTaskTerminal(status?: string) {
return status === "SUCCESS" || status === "FAILED" || status === "COMPLETED";
}
function statusText(status?: string) {
if (status === "SUCCESS" || status === "COMPLETED") return "已完成";
if (status === "FAILED") return "失败";
return "执行中";
}
function statusClass(status?: string) {
return status === "SUCCESS" || status === "COMPLETED"
? "success"
: status === "FAILED"
? "failed"
: "running";
}
function canDownload(item: QueryAsinHistoryItem) {
return Boolean(item.resultId && (item.fileReady || item.downloadUrl));
}
function saveMatchedItems() {
if (typeof window === "undefined") return;
window.localStorage.setItem(
matchedStorageKey(),
JSON.stringify(matchedItems.value),
);
}
function loadMatchedItems() {
try {
const raw =
typeof window !== "undefined"
? window.localStorage.getItem(matchedStorageKey())
: null;
matchedItems.value = raw ? JSON.parse(raw) : [];
} catch {
matchedItems.value = [];
}
}
function saveQueueState() {
if (typeof window === "undefined") return;
const payload = {
pendingQueue: pendingQueue.value,
activeTaskId: activeTaskId.value,
activeQueueItem: activeQueueItem.value,
};
window.localStorage.setItem(queueStateStorageKey(), JSON.stringify(payload));
}
function saveTaskStartTimes() {
if (typeof window === "undefined") return;
if (!Object.keys(taskStartTimes.value).length) {
window.localStorage.removeItem(taskStartTimeStorageKey());
return;
}
window.localStorage.setItem(taskStartTimeStorageKey(), JSON.stringify(taskStartTimes.value));
}
function loadQueueState() {
try {
const raw =
typeof window !== "undefined"
? window.localStorage.getItem(queueStateStorageKey())
: null;
if (!raw) return;
const parsed = JSON.parse(raw) as {
pendingQueue?: QueryAsinShopQueueItem[];
activeTaskId?: number | null;
activeQueueItem?: QueryAsinShopQueueItem | null;
};
pendingQueue.value = parsed.pendingQueue || [];
activeTaskId.value = parsed.activeTaskId ?? null;
activeQueueItem.value = parsed.activeQueueItem ?? null;
} catch {
pendingQueue.value = [];
activeTaskId.value = null;
activeQueueItem.value = null;
}
}
function loadTaskStartTimes() {
try {
const raw =
typeof window !== "undefined"
? window.localStorage.getItem(taskStartTimeStorageKey())
: null;
const parsed = raw ? JSON.parse(raw) : {};
const next: Record<number, string> = {};
for (const [taskId, value] of Object.entries(parsed || {})) {
const numericTaskId = Number(taskId);
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) continue;
if (typeof value !== "string" || !value.trim()) continue;
next[numericTaskId] = value;
}
taskStartTimes.value = next;
} catch {
taskStartTimes.value = {};
}
}
function setTaskStartTime(taskId: number, startedAt = new Date().toISOString()) {
if (!Number.isFinite(taskId) || taskId <= 0) return;
taskStartTimes.value = {
...taskStartTimes.value,
[taskId]: startedAt,
};
saveTaskStartTimes();
}
function clearTaskStartTime(taskId?: number | null) {
if (!taskId || !(taskId in taskStartTimes.value)) return;
const next = { ...taskStartTimes.value };
delete next[taskId];
taskStartTimes.value = next;
saveTaskStartTimes();
}
function taskStartTime(taskId?: number | null) {
if (!taskId) return "";
return taskStartTimes.value[taskId] || "";
}
function clearActiveQueueTask() {
activeTaskId.value = null;
activeQueueItem.value = null;
saveQueueState();
}
function resetQueueWorkerIfIdle() {
if (hasQueuedTaskWork.value) {
return;
}
queueWorkerRunning.value = false;
pushing.value = false;
autoQueueEnabled.value = false;
historyPolling.stop();
saveQueueState();
}
function isRecordMissingError(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
return /记录不存在|任务不存在|不存在|已删除|not\s*found|404/i.test(message);
}
function removeHistoryItemLocally(item: QueryAsinHistoryItem) {
historyItems.value = historyItems.value.filter((row) => {
if (item.resultId != null && row.resultId === item.resultId) return false;
if (item.taskId != null && row.taskId === item.taskId) return false;
return true;
});
}
function mergeQueueItems(
base: QueryAsinShopQueueItem[],
incoming: QueryAsinShopQueueItem[],
) {
const map = new Map<string, QueryAsinShopQueueItem>();
for (const item of base) map.set(rowKeyForMatch(item), item);
for (const item of incoming) map.set(rowKeyForMatch(item), item);
return Array.from(map.values());
}
async function loadCandidates() {
candidates.value = await listQueryAsinCandidates();
}
async function loadDashboard() {
dashboard.value = await getQueryAsinDashboard();
}
async function loadHistory() {
const data = await getQueryAsinHistory();
historyItems.value = (data.items || []).map((item) => withQueryAsinFallback(item));
}
async function refreshTaskViews() {
await Promise.all([loadDashboard(), loadHistory()]);
}
function reconcileActiveQueueTaskWithHistory() {
if (!activeTaskId.value) {
resetQueueWorkerIfIdle();
return;
}
const exists = historyItems.value.some((item) => item.taskId === activeTaskId.value);
if (exists) {
return;
}
const missingTaskId = activeTaskId.value;
clearActiveQueueTask();
queuePushResult.value = pendingQueue.value.length
? `任务 ${missingTaskId} 已不存在,已跳过并继续剩余任务`
: "当前任务已不存在,已重置本地状态";
resetQueueWorkerIfIdle();
}
function mergeHistoryProgressItems(incoming: QueryAsinHistoryItem[]) {
if (!incoming.length) {
return;
}
const incomingMap = new Map(
incoming.map((item) => [historyItemKey(item), withQueryAsinFallback(item)] as const),
);
const merged = historyItems.value.map((item) => {
const next = incomingMap.get(historyItemKey(item));
if (!next) {
return withQueryAsinFallback(item);
}
const queryAsins = hasQueryAsins(next)
? readQueryAsins(next)
: readQueryAsins(item).length
? readQueryAsins(item)
: fallbackQueryAsinsForShop(next.shopName || item.shopName);
return {
...item,
...next,
queryAsins,
};
});
// 判重改 Set:此前 merged.some(...) 是 O(n×m),历史 500 条 × 每轮 20 条即 1 万次比较
const mergedKeys = new Set(merged.map(historyItemKey));
for (const item of incoming) {
const key = historyItemKey(item);
if (!mergedKeys.has(key)) {
merged.push(withQueryAsinFallback(item));
mergedKeys.add(key);
}
}
historyItems.value = merged;
}
async function refreshActiveTaskProgress(taskIds?: number[]) {
const ids = Array.from(
new Set(
(taskIds && taskIds.length
? taskIds
: currentSectionItems.value.map((item) => item.taskId ?? 0)
).filter((taskId) => Number.isFinite(taskId) && taskId > 0),
),
);
if (!ids.length) {
return;
}
const batch = await getQueryAsinTaskProgressBatch(ids);
mergeHistoryProgressItems(batch.items || []);
if ((batch.missingTaskIds || []).length) {
await loadHistory();
reconcileActiveQueueTaskWithHistory();
}
}
function onSelectionChange(rows: QueryAsinCandidateVo[]) {
selectedCandidates.value = rows;
}
async function confirmAdd() {
const name = shopInput.value.trim();
if (!name) {
ElMessage.warning("请输入店铺名");
return;
}
adding.value = true;
try {
await addQueryAsinCandidate(name);
shopInput.value = "";
await Promise.all([loadCandidates(), loadDashboard()]);
ElMessage.success("已加入备选区");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "添加失败");
} finally {
adding.value = false;
}
}
async function removeCandidate(id: number) {
try {
await deleteQueryAsinCandidate(id);
selectedCandidates.value = selectedCandidates.value.filter(
(item) => item.id !== id,
);
await Promise.all([loadCandidates(), loadDashboard()]);
ElMessage.success("已删除");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "删除失败");
}
}
function mergeMatchedItems(
base: QueryAsinShopQueueItem[],
incoming: QueryAsinShopQueueItem[],
) {
const map = new Map<string, QueryAsinShopQueueItem>();
for (const item of base) map.set(rowKeyForMatch(item), item);
for (const item of incoming) map.set(rowKeyForMatch(item), item);
return Array.from(map.values());
}
async function runMatch() {
const names = selectedCandidates.value
.map((item) => item.shop_name)
.filter(Boolean);
if (!names.length) {
ElMessage.warning("请先勾选备选区店铺");
return;
}
matching.value = true;
try {
const data = await matchQueryAsinShops(names);
matchedItems.value = mergeMatchedItems(matchedItems.value, data.items || []);
saveMatchedItems();
ElMessage.success(`本次返回 ${data.items?.length || 0} 条匹配结果`);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "匹配失败");
} finally {
matching.value = false;
}
}
function removeMatchedRow(row: QueryAsinShopQueueItem) {
if (hasQueueWork.value) {
ElMessage.warning("任务执行中,请等待当前任务结束后再调整");
return;
}
matchedItems.value = matchedItems.value.filter(
(item) => rowKeyForMatch(item) !== rowKeyForMatch(row),
);
saveMatchedItems();
}
function buildTaskItem(item: QueryAsinShopQueueItem): QueryAsinTaskItem {
return {
shopName: item.shopName,
matched: item.matched,
shopId: item.shopId,
platform: item.platform,
companyName: item.companyName,
matchStatus: item.matchStatus,
matchMessage: item.matchMessage,
queryAsins: copyQueryAsins(item.queryAsins),
};
}
function buildQueuePayload(taskId: number, item: QueryAsinHistoryItem) {
const submissionId = createSubmissionId(taskId, item.shopName);
return {
type: "query-asin-run",
ts: Date.now(),
data: {
taskId,
ziniao_version: ziniaoVersion.value,
user_id: Number(uidForStorage()) || 0,
source: "frontend-vue-query-asin",
submissionId,
items: [
{
shopName: item.shopName,
submissionId,
shopId: item.shopId,
platform: item.platform,
companyName: item.companyName,
matched: item.matched,
matchStatus: item.matchStatus,
matchMessage: item.matchMessage,
queryAsins: copyQueryAsins(item.queryAsins),
},
],
query_asins: copyQueryAsins(item.queryAsins),
query_asin_rows: flattenQueryAsinRows(item),
},
};
}
function findHistoryItemByTaskId(taskId: number) {
return historyItems.value.find((item) => item.taskId === taskId);
}
async function waitForTaskTerminal(taskId: number) {
let transientErrorCount = 0;
while (true) {
if (disposed) return "STOPPED";
try {
await refreshActiveTaskProgress([taskId]);
if (activeTaskId.value !== taskId) {
return "FAILED";
}
if (transientErrorCount > 0) {
queuePushResult.value = `任务 ${taskId} 服务已恢复,继续等待执行结果...`;
}
transientErrorCount = 0;
const item = findHistoryItemByTaskId(taskId);
const status = item?.taskStatus || "";
if (status === "SUCCESS" || status === "FAILED" || status === "COMPLETED") {
return status;
}
} catch (error) {
if (!isTransientBackendError(error)) {
throw error;
}
transientErrorCount += 1;
if (transientErrorCount >= MAX_TRANSIENT_ERRORS) {
throw new Error(
`任务 ${taskId} 等待服务恢复超时,请稍后刷新查看状态`,
);
}
queuePushResult.value = `任务 ${taskId} 执行中,正在等待服务恢复(${transientErrorCount}/${MAX_TRANSIENT_ERRORS}...`;
}
await sleep(getTaskPollIntervalMs());
}
}
async function processQueue() {
if (disposed || queueWorkerRunning.value) return;
queueWorkerRunning.value = true;
pushing.value = true;
historyPolling.start();
try {
const api = getPywebviewApi();
if (!api?.enqueue_json) {
throw new Error("当前客户端未提供 enqueue_json");
}
while (!disposed && (activeTaskId.value || pendingQueue.value.length)) {
if (activeTaskId.value) {
const finalStatus = await waitForTaskTerminal(activeTaskId.value);
queuePushResult.value =
pendingQueue.value.length > 0
? `任务 ${activeTaskId.value} ${finalStatus === "SUCCESS" ? "已完成" : "执行失败"},自动继续下一个(剩余 ${pendingQueue.value.length} 条)`
: `任务 ${activeTaskId.value} ${finalStatus === "SUCCESS" ? "已完成" : "执行失败"}`;
clearActiveQueueTask();
continue;
}
const nextItem = pendingQueue.value.shift();
saveQueueState();
if (!nextItem) break;
const created = await withTransientRetry(
() => createQueryAsinTask([buildTaskItem(nextItem)]),
(attempt, maxAttempts) => {
queuePushResult.value = `服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...`;
},
);
const createdItem = created.items?.[0]
? withQueryAsinFallback(created.items[0], nextItem)
: undefined;
if (!createdItem?.taskId || !createdItem.resultId) {
throw new Error("后端未返回有效任务标识");
}
activeTaskId.value = created.taskId;
activeQueueItem.value = withQueryAsinFallback(nextItem, createdItem);
saveQueueState();
await refreshTaskViews();
const payload = buildQueuePayload(created.taskId, createdItem);
queuePayloadText.value = JSON.stringify(payload, null, 2);
// 模板里一个有效 ASIN 都没解析出来时,Python 端拿到空列表会空转,任务停在执行中
const guard = checkQueuePayload(payload, {
expectedType: "query-asin-run",
requiredDataKeys: ["taskId"],
nonEmptyArrayKeys: ["items", "query_asins"],
});
if (!(await passGuard(guard))) {
await submitQueryAsinTaskResult(created.taskId, {
shops: [
{
shopName: createdItem.shopName || nextItem.shopName || "",
error: `任务 ${created.taskId} 数据校验未通过,已阻止启动`,
shopDone: true,
submissionId: createSubmissionId(
created.taskId,
createdItem.shopName || nextItem.shopName,
),
},
],
});
await refreshTaskViews();
queuePushResult.value = `任务 ${created.taskId} 数据校验未通过,已阻止启动并继续下一个任务`;
clearActiveQueueTask();
continue;
}
const pushResult = await api.enqueue_json(payload);
if (!pushResult?.success) {
await submitQueryAsinTaskResult(created.taskId, {
shops: [
{
shopName: createdItem.shopName || nextItem.shopName || "",
error: pushResult?.error || `任务 ${created.taskId} 启动失败`,
shopDone: true,
submissionId: createSubmissionId(
created.taskId,
createdItem.shopName || nextItem.shopName,
),
},
],
});
await refreshTaskViews();
queuePushResult.value = `任务 ${created.taskId} 启动失败,已自动继续下一个任务`;
clearActiveQueueTask();
continue;
}
setTaskStartTime(created.taskId);
queuePushResult.value =
pendingQueue.value.length > 0
? `任务 ${created.taskId} 已提交,等待完成后自动继续下一个(剩余 ${pendingQueue.value.length} 条)`
: `任务 ${created.taskId} 已提交,等待执行完成`;
}
queuePushResult.value = "任务已按顺序执行完成";
await refreshTaskViews();
ElMessage.success("查询ASIN队列已按顺序执行完成");
} catch (error) {
if (disposed) return;
const message = error instanceof Error ? error.message : "队列执行失败";
queuePushResult.value = message;
ElMessage.error(message);
await refreshTaskViews();
} finally {
queueWorkerRunning.value = false;
pushing.value = false;
historyPolling.stop();
saveQueueState();
}
}
async function pushToPythonQueue() {
autoQueueEnabled.value = true;
resetQueueWorkerIfIdle();
const runnable = matchedRunnableItems.value;
if (!runnable.length) {
ElMessage.warning("请先匹配可用店铺");
return;
}
pendingQueue.value = mergeQueueItems(pendingQueue.value, runnable);
saveQueueState();
queuePushResult.value = `已加入 ${runnable.length} 条店铺,开始按顺序执行`;
await processQueue();
}
async function downloadResult(item: QueryAsinHistoryItem) {
if (!item.resultId) return;
const url = getQueryAsinResultDownloadUrl(item.resultId);
const filename = item.outputFilename || `${item.shopName || "result"}.xlsx`;
const result = await saveUrlWithProgress(url, filename, `query-asin:${item.resultId}`);
if (result.success) {
ElMessage.success(`已保存: ${result.path || filename}`);
} else if (result.error && result.error !== "用户取消") {
ElMessage.error(result.error);
}
}
async function deleteTaskRecord(item: QueryAsinHistoryItem) {
try {
if (item.taskId) {
await deleteQueryAsinTask(item.taskId);
if (activeTaskId.value === item.taskId) {
clearActiveQueueTask();
}
clearTaskStartTime(item.taskId);
} else if (item.resultId) {
await deleteQueryAsinHistory(item.resultId);
clearTaskStartTime(item.taskId);
} else {
throw new Error("缺少可删除的任务标识");
}
await Promise.all([loadCandidates(), refreshTaskViews()]);
resetQueueWorkerIfIdle();
ElMessage.success("已删除");
} catch (error) {
if (isRecordMissingError(error)) {
if (item.taskId && activeTaskId.value === item.taskId) {
clearActiveQueueTask();
}
clearTaskStartTime(item.taskId);
removeHistoryItemLocally(item);
await loadCandidates();
resetQueueWorkerIfIdle();
ElMessage.success("记录已不存在,已同步移除本地记录");
return;
}
ElMessage.error(error instanceof Error ? error.message : "删除失败");
}
}
onMounted(async () => {
loadMatchedItems();
loadQueueState();
loadTaskStartTimes();
await Promise.all([loadCandidates(), loadDashboard(), loadHistory()]);
reconcileActiveQueueTaskWithHistory();
if (activeTaskId.value || pendingQueue.value.length) {
autoQueueEnabled.value = true;
queuePushResult.value =
activeTaskId.value != null
? `检测到未完成队列,继续等待任务 ${activeTaskId.value} 完成并自动接续后续店铺`
: `检测到未完成队列,继续执行剩余 ${pendingQueue.value.length} 条店铺`;
void processQueue();
}
});
onUnmounted(() => {
disposed = true;
historyPolling.stop();
clearSleepTimers();
timers.clearScope();
});
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
const { total, failed } = await runBatchDelete(views, (view) => deleteTaskRecord(itemSource(view)))
if (!total) return
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${total} 条历史记录`)
}
}
</script>
<style scoped>
.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: #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: #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: #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: #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; }
.btn-run { padding: 10px 18px; font-size: 14px; font-weight: 600; color: #f5f8fc; background: #3498db; border: none; border-radius: 8px; cursor: pointer; }
.btn-run:hover:not(:disabled) { background: #2980b9; }
.btn-run:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-queue { background: #27ae60; }
.btn-queue:hover:not(:disabled) { background: #219a52; }
.loading-msg { margin-top: 12px; font-size: 12px; color: #5e6878; line-height: 1.5; }
.queue-debug-card { margin-top: 18px; padding: 14px; border: 1px solid #2e3a52; border-radius: 10px; background: #222b3d; }
.queue-debug-title { margin-bottom: 8px; }
.queue-debug-line { color: #a0acbe; font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
.queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #10141f; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
.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, #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; }
.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; }
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
@media (max-width: 1100px) {
.main-content { flex-direction: column; height: auto; }
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
}
</style>