Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue
T
huangzd1997 dc6e8924a9 refactor(品牌工具页): 历史轮询抽为 useHistoryPolling
QueryAsin / Withdraw / PatrolDelete 三页逐字相同的 startHistoryPolling /
stopHistoryPolling 收敛为 shared/composables/useHistoryPolling:定时器走各页
categorized-timers(category 固定 history-poll),间隔默认主轮询的 2 倍。
categorized-timers 补 CategorizedTimers 类型导出;补注入式假定时器单测 5 例。

净减约 40 行;vue-tsc 构建与 695 个前端单测通过。
2026-09-13 23:22:49 +08:00

1332 lines
48 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="patrol">
<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>
<div class="section-title condition-title">
<span>删除条件</span>
<span class="condition-title-hint">
输入删除条件后点击添加或回车);启动时作为本次任务的删除条件携带
</span>
</div>
<div class="condition-panel">
<div class="condition-input-row">
<el-input
v-model="conditionSelectValue"
clearable
placeholder="请输入删除条件,回车或点击添加"
@keyup.enter="addCurrentCondition"
/>
<button
type="button"
class="opt-btn"
@click="addCurrentCondition"
>
添加
</button>
</div>
<div v-if="!taskConditionTexts.length" class="empty-conditions">
暂无删除条件输入后点击添加
</div>
<ul v-else class="condition-list condition-list--chips">
<li
v-for="text in taskConditionTexts"
:key="text"
class="condition-chip"
:title="text"
>
<span class="condition-chip-text">{{ text }}</span>
<button
type="button"
class="condition-chip-remove"
title="移除"
@click="removeTaskConditionText(text)"
>
×
</button>
</li>
</ul>
</div>
<CountrySelector
v-model="selectedCountryCodes"
title="巡店国家与顺序"
min-selected-warning="至少保留 1 个国家否则任务没有可巡查的站点"
/>
<ZiniaoVersionSetting v-model="ziniaoVersion" />
<div class="run-row">
<button
type="button"
class="btn-run"
:disabled="matching || hasQueueWork"
@click="runMatch"
>
{{ matching ? "匹配中..." : "匹配店铺" }}
</button>
<button
type="button"
class="btn-run btn-queue"
:disabled="pushing || !matchedRunnableItems.length"
@click="pushToPythonQueue"
>
{{ pushing ? "任务执行中..." : "启动任务" }}
</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="patrolCards"
: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, watch } 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 {
addPatrolDeleteCandidate,
createPatrolDeleteTask,
deletePatrolDeleteCandidate,
deletePatrolDeleteHistory,
deletePatrolDeleteTask,
getPatrolDeleteDashboard,
getPatrolDeleteHistory,
getPatrolDeleteTaskProgressBatch,
getPatrolDeleteResultDownloadUrl,
listPatrolDeleteCandidates,
matchPatrolDeleteShops,
submitPatrolDeleteTaskResult,
type PatrolDeleteCandidateVo,
type PatrolDeleteCartRatio,
type PatrolDeleteCountrySection,
type PatrolDeleteDashboardVo,
type PatrolDeleteHistoryItem,
type PatrolDeleteShopQueueItem,
type PatrolDeleteTaskItem,
} 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 CountrySelector from "@/shared/components/CountrySelector.vue";
import {
EU_COUNTRY_CODES,
countryLabel,
sanitizeCountryCodes,
} from "@/shared/country-options";
import { formatDateTime } from '@/shared/utils/datetime'
import { useHistoryPolling } from '@/shared/composables/useHistoryPolling'
const MAX_TRANSIENT_ERRORS = 30;
/** 任务终态后等待结果文件(Java 侧异步生成)的最大轮次,12 × 10s ≈ 2 分钟 */
const MAX_FILE_WAIT_ROUNDS = 12;
const ziniaoVersion = useZiniaoVersion();
const shopInput = ref("");
const candidates = ref<PatrolDeleteCandidateVo[]>([]);
const selectedCandidates = ref<PatrolDeleteCandidateVo[]>([]);
/** 本次任务已添加的删除条件(文本;输入后回车/点添加逐个加入,启动时随任务携带) */
const taskConditionTexts = ref<string[]>([]);
/** 删除条件输入框当前值 */
const conditionSelectValue = ref("");
const selectedCountryCodes = ref<string[]>([...EU_COUNTRY_CODES]);
const matchedItems = ref<PatrolDeleteShopQueueItem[]>([]);
const historyItems = ref<PatrolDeleteHistoryItem[]>([]);
const dashboard = ref<PatrolDeleteDashboardVo>({
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 activeTaskId = ref<number | null>(null);
const queueWorkerRunning = ref(false);
const timers = createCategorizedTimers("patrol-delete");
const matchedRunnableItems = computed(() =>
matchedItems.value.filter((item) => item.matched),
);
// 模板结构以中文国家名为 keyJava 的 Excel 列、Python 的站点切换都按中文名匹配)
const selectedCountryNames = computed(() =>
selectedCountryCodes.value.map((code) => countryLabel(code)),
);
const taskRecordItems = computed(() => groupHistoryItemsByTask(historyItems.value));
const currentSectionItems = computed(() =>
taskRecordItems.value.filter((item) => !isTaskTerminal(item.taskStatus)),
);
const historySectionItems = computed(() =>
taskRecordItems.value.filter((item) => isTaskTerminal(item.taskStatus)),
);
/** 统一统计卡:运行中/已结束按页面当前分区统计,成功/失败按已结束任务的 taskStatus 统计 */
const patrolCards = computed<TaskStatCard[]>(() => {
const ended = historySectionItems.value
return [
{ label: '运行中任务', value: currentSectionItems.value.length },
{ label: '已结束任务', value: ended.length },
{
label: '成功任务',
value: ended.filter((item) => item.taskStatus === 'SUCCESS' || item.taskStatus === 'COMPLETED').length,
},
{ label: '失败任务', value: ended.filter((item) => item.taskStatus === 'FAILED').length },
]
})
const currentTaskViews = computed<TaskItemView[]>(() => currentSectionItems.value.map(toPatrolTaskView))
const historyTaskViews = computed<TaskItemView[]>(() => historySectionItems.value.map(toPatrolTaskView))
function itemSource(item: TaskItemView) {
return item.source as PatrolDeleteHistoryItem
}
function toPatrolTaskView(item: PatrolDeleteHistoryItem): TaskItemView {
return {
key: taskGroupKey(item),
title: formatTaskGroupShopNames(item),
taskId: item.taskId ?? '-',
startedAt: formatDateTime(item.createdAt),
finishedAt: item.finishedAt ? formatDateTime(item.finishedAt) : '进行中',
statusText: statusText(item.taskStatus),
statusClass: statusClass(item.taskStatus),
extraLines: [
...(formatTaskGroupResultIds(item) ? [`结果 ID: ${formatTaskGroupResultIds(item)}`] : []),
...(formatTaskGroupShopIds(item) ? [`店铺 ID: ${formatTaskGroupShopIds(item)}`] : []),
...(item.platform ? [`平台: ${item.platform}`] : []),
`模板: ${formatTemplateSummary(item)}`,
...(formatTaskGroupErrors(item) ? [`错误: ${formatTaskGroupErrors(item)}`] : []),
],
source: item,
}
}
const hasQueueWork = computed(
() =>
queueWorkerRunning.value ||
!!activeTaskId.value,
);
function uidForStorage() {
return typeof window !== "undefined"
? window.localStorage.getItem("uid") || "0"
: "0";
}
function matchedStorageKey() {
return `patrol-delete:matched:${uidForStorage()}`;
}
function queueStateStorageKey() {
return `patrol-delete:queue-state:${uidForStorage()}`;
}
function countryCodesStorageKey() {
return `patrol-delete:country-codes:${uidForStorage()}`;
}
function rowKeyForMatch(row: PatrolDeleteShopQueueItem) {
return `${(row.shopName || "").trim()}::${row.shopId || ""}`;
}
function historyItemKey(item: PatrolDeleteHistoryItem) {
return `${item.taskId ?? 0}:${item.resultId ?? 0}`;
}
function taskGroupKey(item: PatrolDeleteHistoryItem) {
return `task:${item.taskId ?? 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 `patrol-delete:${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: PatrolDeleteShopQueueItem) {
const message = (row.matchMessage || "").trim();
if (message) return message;
if (row.matched && row.matchStatus === "MATCHED") {
return "已匹配成功,可启动任务";
}
if (row.matched) {
return "已匹配成功,请查看状态确认";
}
return "未匹配成功,请检查店铺名";
}
function buildTemplateCountrySections(): PatrolDeleteCountrySection[] {
return selectedCountryNames.value.map((country) => ({
country,
rows: [
{
status: "全部",
quantity: "",
deleteQuantity: "",
processStatus: "",
},
],
}));
}
function buildTemplateCartRatios(): PatrolDeleteCartRatio[] {
return selectedCountryNames.value.map((country) => ({
country,
ratio: "",
}));
}
function flattenTemplateRows(
record: Pick<
PatrolDeleteHistoryItem,
"shopName" | "countrySections" | "cartRatios"
>,
) {
const sections = record.countrySections || [];
const ratios = record.cartRatios || [];
const rowCount = Math.max(...sections.map((section) => section.rows.length), 1);
return Array.from({ length: rowCount }, (_, rowIndex) => ({
shopName: rowIndex === 0 ? record.shopName : "",
countries: sections.map((section) => {
const row = section.rows[rowIndex];
return {
country: section.country,
status: row?.status || "",
quantity: row?.quantity || "",
deleteQuantity: row?.deleteQuantity || "",
processStatus: row?.processStatus || "",
};
}),
cartRatios:
rowIndex === 0
? ratios.map((item) => ({
country: item.country,
ratio: item.ratio,
}))
: [],
}));
}
function formatTemplateSummary(
record: Pick<
PatrolDeleteHistoryItem,
"shopName" | "countrySections" | "cartRatios"
>,
) {
const countries = new Set([
...(record.countrySections || []).map((item) => item.country).filter(Boolean),
...(record.cartRatios || []).map((item) => item.country).filter(Boolean),
]);
return `${countries.size || 0} 个国家,含状态数据和购物车比例`;
}
function uniqueJoined(values: Array<string | number | undefined | null>) {
return Array.from(
new Set(
values
.map((value) => (value == null ? "" : String(value).trim()))
.filter(Boolean),
),
).join("、");
}
function groupHistoryItemsByTask(items: PatrolDeleteHistoryItem[]) {
const groups = new Map<string, PatrolDeleteHistoryItem[]>();
for (const item of items || []) {
const key = String(item.taskId ?? `result:${item.resultId ?? Math.random()}`);
const group = groups.get(key) || [];
group.push(item);
groups.set(key, group);
}
return Array.from(groups.values()).map((group) => {
const base =
group.find((item) => canDownload(item)) ||
group.find((item) => item.taskStatus) ||
group[0];
const errors = uniqueJoined(group.map((item) => item.error));
return {
...base,
shopName: uniqueJoined(group.map((item) => item.shopName)) || base.shopName,
shopId: uniqueJoined(group.map((item) => item.shopId)) || base.shopId,
error: errors || base.error,
__groupItems: group,
} as PatrolDeleteHistoryItem & { __groupItems?: PatrolDeleteHistoryItem[] };
});
}
function taskGroupItems(item: PatrolDeleteHistoryItem) {
return ((item as PatrolDeleteHistoryItem & { __groupItems?: PatrolDeleteHistoryItem[] }).__groupItems || [item]);
}
function formatTaskGroupShopNames(item: PatrolDeleteHistoryItem) {
return uniqueJoined(taskGroupItems(item).map((row) => row.shopName)) || "-";
}
function formatTaskGroupResultIds(item: PatrolDeleteHistoryItem) {
return uniqueJoined(taskGroupItems(item).map((row) => row.resultId));
}
function formatTaskGroupShopIds(item: PatrolDeleteHistoryItem) {
return uniqueJoined(taskGroupItems(item).map((row) => row.shopId));
}
function formatTaskGroupErrors(item: PatrolDeleteHistoryItem) {
return uniqueJoined(taskGroupItems(item).map((row) => row.error));
}
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: PatrolDeleteHistoryItem) {
return taskGroupItems(item).some((row) =>
Boolean(row.resultId && (row.fileReady || row.downloadUrl)),
);
}
/**
* 结果文件是否仍在生成中。
*
* 任务终态(taskStatus=SUCCESS)只代表 Python 端回传完毕,结果 Excel 由 Java 侧
* 收尾后经 MQ 异步生成(finalizeTaskWorkbook → enqueueAssembleResult),两步之间
* 有几秒到几十秒的间隔。只按 taskStatus 停轮询会让前端本地的 fileReady 永远停在
* false,历史记录里就不会出现「下载结果」按钮(任务 27547 即此现象)。
*
* 判定只认「确实有文件任务」的信号(fileJobId / PENDING / RUNNING):全部店铺失败
* 的任务不会生成结果文件,其 fileStatus 为空,不应继续等待。
*/
function isResultFilePending(item?: PatrolDeleteHistoryItem) {
if (!item) return false;
if (item.fileReady || item.downloadUrl) return false;
const fileStatus = String(item.fileStatus || "").toUpperCase();
if (fileStatus === "SUCCESS" || fileStatus === "FAILED") return false;
return item.fileJobId != null || fileStatus === "PENDING" || fileStatus === "RUNNING";
}
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 = {
activeTaskId: activeTaskId.value,
};
window.localStorage.setItem(queueStateStorageKey(), JSON.stringify(payload));
}
function loadQueueState() {
try {
const raw =
typeof window !== "undefined"
? window.localStorage.getItem(queueStateStorageKey())
: null;
if (!raw) return;
const parsed = JSON.parse(raw) as {
activeTaskId?: number | null;
};
activeTaskId.value = parsed.activeTaskId ?? null;
} catch {
activeTaskId.value = null;
}
}
function saveCountryCodes() {
if (typeof window === "undefined") return;
window.localStorage.setItem(
countryCodesStorageKey(),
JSON.stringify(selectedCountryCodes.value),
);
}
function loadCountryCodes() {
try {
const raw =
typeof window !== "undefined"
? window.localStorage.getItem(countryCodesStorageKey())
: null;
// 没存过(首次进入)时保持默认的五国全选,与改动前的行为一致
selectedCountryCodes.value = raw
? sanitizeCountryCodes(JSON.parse(raw))
: [...EU_COUNTRY_CODES];
} catch {
selectedCountryCodes.value = [...EU_COUNTRY_CODES];
}
}
function clearActiveQueueTask() {
activeTaskId.value = null;
saveQueueState();
}
function isRecordMissingError(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
return /记录不存在|任务不存在|不存在|已删除|not\s*found|404/i.test(message);
}
function removeMatchedRowLocally(row: PatrolDeleteShopQueueItem) {
const key = rowKeyForMatch(row);
matchedItems.value = matchedItems.value.filter((item) => rowKeyForMatch(item) !== key);
saveMatchedItems();
}
function removeHistoryItemLocally(item: PatrolDeleteHistoryItem) {
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;
});
}
async function loadCandidates() {
candidates.value = await listPatrolDeleteCandidates();
}
async function loadDashboard() {
dashboard.value = await getPatrolDeleteDashboard();
}
async function loadHistory() {
const data = await getPatrolDeleteHistory();
historyItems.value = data.items || [];
}
async function refreshTaskViews() {
await Promise.all([loadDashboard(), loadHistory()]);
}
function mergeHistoryProgressItems(incoming: PatrolDeleteHistoryItem[]) {
if (!incoming.length) {
return;
}
const incomingMap = new Map(
incoming.map((item) => [historyItemKey(item), item] as const),
);
const merged = historyItems.value.map((item) => {
const next = incomingMap.get(historyItemKey(item));
if (!next) {
return item;
}
return {
...item,
...next,
countrySections: next.countrySections || item.countrySections || [],
cartRatios: next.cartRatios || item.cartRatios || [],
};
});
for (const item of incoming) {
const key = historyItemKey(item);
if (!merged.some((current) => historyItemKey(current) === key)) {
merged.push(item);
}
}
historyItems.value = merged;
}
function upsertHistoryItems(incoming: PatrolDeleteHistoryItem[]) {
mergeHistoryProgressItems(incoming);
}
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 getPatrolDeleteTaskProgressBatch(ids);
mergeHistoryProgressItems(batch.items || []);
if ((batch.missingTaskIds || []).length) {
await loadHistory();
}
return batch.missingTaskIds || [];
}
function onSelectionChange(rows: PatrolDeleteCandidateVo[]) {
selectedCandidates.value = rows;
}
async function confirmAdd() {
const name = shopInput.value.trim();
if (!name) {
ElMessage.warning("请输入店铺名");
return;
}
adding.value = true;
try {
await addPatrolDeleteCandidate(name);
shopInput.value = "";
await Promise.all([loadCandidates(), loadDashboard()]);
ElMessage.success("已加入备选区");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "添加失败");
} finally {
adding.value = false;
}
}
function addConditionText(text: string) {
const trimmed = String(text || "").trim();
if (!trimmed) {
ElMessage.warning("请输入删除条件");
return;
}
if (taskConditionTexts.value.includes(trimmed)) {
ElMessage.warning(`删除条件「${trimmed}」已添加`);
return;
}
taskConditionTexts.value = [...taskConditionTexts.value, trimmed];
}
/** 「添加」按钮 / 回车:把输入文本加入本次任务条件列表 */
function addCurrentCondition() {
addConditionText(conditionSelectValue.value);
conditionSelectValue.value = "";
}
function removeTaskConditionText(text: string) {
taskConditionTexts.value = taskConditionTexts.value.filter((item) => item !== text);
}
async function removeCandidate(id: number) {
try {
await deletePatrolDeleteCandidate(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: PatrolDeleteShopQueueItem[],
incoming: PatrolDeleteShopQueueItem[],
) {
const map = new Map<string, PatrolDeleteShopQueueItem>();
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 matchPatrolDeleteShops(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: PatrolDeleteShopQueueItem) {
removeMatchedRowLocally(row);
ElMessage.success("已从匹配结果移除");
}
function buildTaskItem(item: PatrolDeleteShopQueueItem): PatrolDeleteTaskItem {
return {
shopName: item.shopName,
matched: item.matched,
shopId: item.shopId,
platform: item.platform,
companyName: item.companyName,
matchStatus: item.matchStatus,
matchMessage: item.matchMessage,
countrySections: buildTemplateCountrySections(),
cartRatios: buildTemplateCartRatios(),
};
}
function selectedDeleteConditions() {
return taskConditionTexts.value.map((text) => ({
id: text,
conditionText: text,
}));
}
function buildQueuePayload(taskId: number, items: PatrolDeleteHistoryItem[]) {
const firstItem = items[0];
const deleteConditionsForTask = selectedDeleteConditions();
return {
type: "patrol-delete-run",
ts: Date.now(),
data: {
taskId,
ziniao_version: ziniaoVersion.value,
user_id: Number(uidForStorage()) || 0,
source: "frontend-vue-patrol-delete",
delete_conditions: deleteConditionsForTask,
deleteConditions: deleteConditionsForTask,
// 与店铺数据抓取 / 跟价 / 商品风险等页面统一:国家一律用 country_codes 传代码
country_codes: [...selectedCountryCodes.value],
items: items.map((item) => ({
shopName: item.shopName,
shopId: item.shopId,
platform: item.platform,
companyName: item.companyName,
matched: item.matched,
matchStatus: item.matchStatus,
matchMessage: item.matchMessage,
countrySections: item.countrySections || [],
cartRatios: item.cartRatios || [],
})),
template_rows: firstItem ? flattenTemplateRows(firstItem) : [],
shop_template_rows: items.map((item) => ({
shopName: item.shopName,
shopId: item.shopId,
templateRows: flattenTemplateRows(item),
})),
country_sections: firstItem?.countrySections || [],
cart_ratios: firstItem?.cartRatios || [],
},
};
}
function findHistoryItemByTaskId(taskId: number) {
return historyItems.value.find((item) => item.taskId === taskId);
}
async function waitForTaskTerminal(taskId: number) {
let transientErrorCount = 0;
let fileWaitRounds = 0;
while (true) {
if (disposed) return "STOPPED";
if (activeTaskId.value !== taskId) return "DELETED";
try {
const missingTaskIds = await refreshActiveTaskProgress([taskId]);
if (missingTaskIds.includes(taskId)) {
clearActiveQueueTask();
return "DELETED";
}
if (activeTaskId.value !== taskId) return "DELETED";
if (transientErrorCount > 0) {
queuePushResult.value = `任务 ${taskId} 服务已恢复,继续等待执行结果...`;
}
transientErrorCount = 0;
const item = findHistoryItemByTaskId(taskId);
const status = item?.taskStatus || "";
if (status === "SUCCESS" || status === "FAILED" || status === "COMPLETED") {
// 任务终态后结果 Excel 还在异步生成,必须继续轮询到文件就绪再退出:
// 一旦退出,clearActiveQueueTask 会停掉历史轮询,fileReady 就再也刷不出来了
if (isResultFilePending(item) && fileWaitRounds < MAX_FILE_WAIT_ROUNDS) {
fileWaitRounds += 1;
console.log(
`[patrol-delete] 任务 ${taskId} 已终态(${status}),等待结果文件生成 ${fileWaitRounds}/${MAX_FILE_WAIT_ROUNDS}`,
);
queuePushResult.value = `任务 ${taskId} 已完成,正在生成结果文件(${fileWaitRounds}/${MAX_FILE_WAIT_ROUNDS}...`;
await sleep(getTaskPollIntervalMs());
continue;
}
if (isResultFilePending(item)) {
console.warn(
`[patrol-delete] 任务 ${taskId} 结果文件等待超时(${MAX_FILE_WAIT_ROUNDS} 轮),按任务终态返回,可刷新页面查看`,
);
}
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 settlePendingResultFiles() {
for (let round = 1; round <= MAX_FILE_WAIT_ROUNDS; round += 1) {
const pendingIds = Array.from(new Set(
taskRecordItems.value
.filter((item) => isTaskTerminal(item.taskStatus) && isResultFilePending(item))
.map((item) => item.taskId)
.filter((taskId): taskId is number => typeof taskId === "number" && taskId > 0),
));
if (!pendingIds.length) {
if (round > 1) {
console.log(`[patrol-delete] 结果文件已就绪(第 ${round - 1} 轮收敛完成)`);
}
return;
}
console.log(
`[patrol-delete] 任务 ${pendingIds.join(",")} 结果文件仍在生成,等待中 ${round}/${MAX_FILE_WAIT_ROUNDS}`,
);
await sleep(getTaskPollIntervalMs());
if (disposed) return;
await refreshActiveTaskProgress(pendingIds);
}
console.warn(
`[patrol-delete] 结果文件等待超时(${MAX_FILE_WAIT_ROUNDS} 轮),可稍后刷新页面查看下载按钮`,
);
}
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");
}
if (activeTaskId.value) {
const finalStatus = await waitForTaskTerminal(activeTaskId.value);
queuePushResult.value =
finalStatus === "DELETED"
? "任务已删除"
: `任务 ${activeTaskId.value} ${finalStatus === "SUCCESS" ? "已完成" : "执行失败"}`;
clearActiveQueueTask();
return;
}
const runnable = matchedRunnableItems.value;
if (!runnable.length) {
ElMessage.warning("请先匹配可用店铺");
return;
}
if (!selectedDeleteConditions().length) {
// 删除条件为空时任务没有可执行的判定规则,必须在创建任务前拦截,
// 否则 Java 已创建(RUNNING)而 Python 端无事可做,任务会一直停在执行中
ElMessage.warning("请先选择删除条件");
return;
}
const created = await withTransientRetry(
() => createPatrolDeleteTask(runnable.map(buildTaskItem), selectedDeleteConditions()),
(attempt, maxAttempts) => {
queuePushResult.value = `服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...`;
},
);
const createdItems = created.items || [];
if (!created.taskId || !createdItems.length || createdItems.some((item) => !item.taskId || !item.resultId)) {
throw new Error("后端未返回有效任务标识");
}
activeTaskId.value = created.taskId;
saveQueueState();
upsertHistoryItems(createdItems);
void loadDashboard();
const payload = buildQueuePayload(created.taskId, createdItems);
queuePayloadText.value = JSON.stringify(payload, null, 2);
// 删除条件为空时 Python 端没有可执行的判定规则,任务会一直停在执行中
// country_codes 为空时 Python 端没有可遍历的站点,同样会空转
const guard = checkQueuePayload(payload, {
expectedType: "patrol-delete-run",
requiredDataKeys: ["taskId"],
nonEmptyArrayKeys: ["items", "delete_conditions", "country_codes"],
});
if (!(await passGuard(guard))) {
await submitPatrolDeleteTaskResult(created.taskId, {
shops: createdItems.map((createdItem) => ({
shopName: createdItem.shopName || "",
error: `任务 ${created.taskId} 数据校验未通过,已阻止启动`,
countrySections: createdItem.countrySections || [],
cartRatios: createdItem.cartRatios || [],
shopDone: true,
submissionId: createSubmissionId(created.taskId, createdItem.shopName),
chunkIndex: 1,
chunkTotal: 1,
})),
});
await refreshTaskViews();
queuePushResult.value = `任务 ${created.taskId} 数据校验未通过,已阻止启动`;
clearActiveQueueTask();
return;
}
const pushResult = await api.enqueue_json(payload);
if (!pushResult?.success) {
await submitPatrolDeleteTaskResult(created.taskId, {
shops: createdItems.map((createdItem) => ({
shopName: createdItem.shopName || "",
error: pushResult?.error || `任务 ${created.taskId} 启动失败`,
countrySections: createdItem.countrySections || [],
cartRatios: createdItem.cartRatios || [],
shopDone: true,
submissionId: createSubmissionId(
created.taskId,
createdItem.shopName,
),
chunkIndex: 1,
chunkTotal: 1,
})),
});
for (const item of runnable) removeMatchedRowLocally(item);
await refreshTaskViews();
queuePushResult.value = `任务 ${created.taskId} 启动失败`;
clearActiveQueueTask();
return;
}
for (const item of runnable) removeMatchedRowLocally(item);
queuePushResult.value = `任务 ${created.taskId} 已提交,共 ${createdItems.length} 个店铺、${selectedCountryNames.value.length} 个国家,等待执行完成`;
const finalStatus = await waitForTaskTerminal(created.taskId);
queuePushResult.value = `任务 ${created.taskId} ${finalStatus === "SUCCESS" ? "已完成" : finalStatus === "DELETED" ? "已删除" : "执行失败"}`;
clearActiveQueueTask();
await refreshTaskViews();
ElMessage.success("巡店删除任务已完成");
} 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() {
const runnable = matchedRunnableItems.value;
if (!runnable.length) {
ElMessage.warning("请先匹配可用店铺");
return;
}
if (!selectedCountryCodes.value.length) {
ElMessage.warning("请至少勾选 1 个巡店国家");
return;
}
queuePushResult.value = `开始创建任务,共 ${runnable.length} 个店铺、${selectedCountryNames.value.length} 个国家(${selectedCountryNames.value.join("、")}`;
await processQueue();
}
async function downloadResult(item: PatrolDeleteHistoryItem) {
const downloadable = taskGroupItems(item).find((row) =>
Boolean(row.resultId && (row.fileReady || row.downloadUrl)),
);
if (!downloadable?.resultId) return;
const url = getPatrolDeleteResultDownloadUrl(downloadable.resultId);
const filename =
downloadable.outputFilename || `${item.shopName || "patrol-delete-result"}.xlsx`;
const result = await saveUrlWithProgress(url, filename, `patrol-delete:${downloadable.resultId}`);
if (result.success) {
ElMessage.success(`已保存: ${result.path || filename}`);
} else if (result.error && result.error !== "用户取消") {
ElMessage.error(result.error);
}
}
async function deleteTaskRecord(item: PatrolDeleteHistoryItem) {
try {
if (item.taskId) {
await deletePatrolDeleteTask(item.taskId);
if (activeTaskId.value === item.taskId) {
clearActiveQueueTask();
}
} else if (item.resultId) {
await deletePatrolDeleteHistory(item.resultId);
} else {
throw new Error("缺少可删除的任务标识");
}
removeHistoryItemLocally(item);
await Promise.allSettled([loadCandidates(), refreshTaskViews()]);
ElMessage.success("已删除");
} catch (error) {
if (isRecordMissingError(error)) {
if (item.taskId && activeTaskId.value === item.taskId) {
clearActiveQueueTask();
}
removeHistoryItemLocally(item);
await loadCandidates();
ElMessage.success("记录已不存在,已同步移除本地记录");
return;
}
ElMessage.error(error instanceof Error ? error.message : "删除失败");
}
}
onMounted(async () => {
loadMatchedItems();
loadQueueState();
loadCountryCodes();
watch(selectedCountryCodes, saveCountryCodes, { deep: true });
await Promise.all([loadCandidates(), loadDashboard(), loadHistory()]);
if (activeTaskId.value) {
queuePushResult.value =
activeTaskId.value != null
? `检测到未完成任务,继续等待任务 ${activeTaskId.value} 完成`
: "";
void processQueue();
} else {
// 未完成任务由 processQueue 负责等待;这里只收敛「任务已完成、结果文件还在生成」的历史任务
void settlePendingResultFiles();
}
});
onUnmounted(() => {
disposed = true;
historyPolling.stop();
clearSleepTimers();
timers.clearScope();
});
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
if (!views.length) return
let failed = 0
for (const view of views) {
try {
await deleteTaskRecord(itemSource(view))
} catch {
failed += 1
}
}
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${views.length} 条历史记录`)
}
}
</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; }
.condition-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.condition-title-hint { flex: 1; min-width: 0; color: #d6a95a; font-size: 12px; text-align: right; line-height: 1.35; }
.input-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 16px; background: #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; }
.condition-panel { margin-bottom: 18px; border: 1px solid #2e3a52; border-radius: 8px; background: #242424; padding: 12px; }
.condition-input-row { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
.condition-input-row :deep(.el-input) { flex: 1; }
.empty-conditions { color: #5e6878; font-size: 12px; padding: 10px 4px; }
.condition-list { list-style: none; margin: 0; padding: 0; max-height: 150px; overflow: auto; display: flex; flex-direction: column; gap: 8px; }
.condition-list--chips { flex-direction: row; flex-wrap: wrap; align-items: center; gap: 8px; max-height: none; }
.condition-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 6px 4px 10px; border: 1px solid #3e6a8f; border-radius: 999px; background: #232a3b; color: #cfd6df; font-size: 12px; }
.condition-chip-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 240px; }
.condition-chip-remove { border: none; background: transparent; color: #8ba3c0; cursor: pointer; font-size: 14px; line-height: 1; padding: 0 4px; }
.condition-chip-remove:hover { color: #f87171; }
.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>