Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue

1323 lines
47 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">
<BrandTopBar active="patrol-delete" />
<div class="main-content">
<aside class="left-panel">
<div class="section-title">店铺输入</div>
<div class="input-zone">
<div class="hint">
左侧负责录入店铺并加入备选区点击匹配店铺只会生成匹配结果确认后需手动点击推送到 Python 队列才会创建并执行任务
</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="conditionInput"
clearable
placeholder="请输入删除条件"
@keyup.enter="confirmAddCondition"
/>
<button
type="button"
class="opt-btn"
:disabled="addingCondition"
@click="confirmAddCondition"
>
{{ addingCondition ? "保存中..." : "保存" }}
</button>
</div>
<div v-if="!deleteConditions.length" class="empty-conditions">
暂无删除条件保存后可在推送任务时多选携带
</div>
<ul v-else class="condition-list">
<li
v-for="condition in deleteConditions"
:key="condition.id"
class="condition-item"
>
<label class="condition-check">
<input
v-model="selectedConditionIds"
type="checkbox"
:value="condition.id"
/>
<span :title="condition.conditionText">
{{ condition.conditionText }}
</span>
</label>
<button
type="button"
class="link-danger"
@click="removeCondition(condition.id)"
>
删除
</button>
</li>
</ul>
</div>
<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 ? "任务执行中..." : "推送到 Python 队列" }}
</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">
<div class="panel-header">匹配与任务</div>
<div class="task-list-wrap">
<div class="clean-result-summary">
<div class="summary-card">
<span class="summary-label">备选店铺</span>
<strong>{{ dashboard.candidateCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">已处理任务</span>
<strong>{{ dashboard.processedTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功任务</span>
<strong>{{ dashboard.successTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败任务</span>
<strong>{{ dashboard.failedTaskCount }}</strong>
</div>
</div>
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="empty-tasks narrow">
完成匹配店铺这里会展示匹配结果
</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>
<div class="result-list-wrap">
<div class="result-list-header">
<span>任务记录</span>
</div>
<div
v-if="!currentSectionItems.length && !historySectionItems.length"
class="empty-tasks"
>
暂无任务记录推送到 Python 队列后这里会展示当前任务和历史任务
</div>
<template v-else>
<div v-if="currentSectionItems.length" class="result-subsection">
<div class="result-subsection-title">当前任务</div>
<ul class="task-list">
<li
v-for="item in currentSectionItems"
:key="taskGroupKey(item)"
class="task-item"
>
<div class="left split-result-main">
<span class="id" :title="formatTaskGroupShopNames(item)">
{{ formatTaskGroupShopNames(item) }}
</span>
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
<div v-if="formatTaskGroupResultIds(item)" class="files">
结果 ID: {{ formatTaskGroupResultIds(item) }}
</div>
<div v-if="formatTaskGroupShopIds(item)" class="files">
店铺 ID: {{ formatTaskGroupShopIds(item) }}
</div>
<div v-if="item.platform" class="files">
平台: {{ item.platform }}
</div>
<div class="files">
创建时间: {{ formatDateTime(item.createdAt) }}
</div>
<div class="files">
模板: {{ formatTemplateSummary(item) }}
</div>
<div v-if="formatTaskGroupErrors(item)" class="files">
错误: {{ formatTaskGroupErrors(item) }}
</div>
</div>
<div class="task-right">
<span
class="status"
:class="statusClass(item.taskStatus)"
>
{{ statusText(item.taskStatus) }}
</span>
<button
type="button"
class="btn-delete"
@click="deleteTaskRecord(item)"
>
删除
</button>
</div>
</li>
</ul>
</div>
<div v-if="historySectionItems.length" class="result-subsection">
<div class="result-subsection-title">历史任务</div>
<ul class="task-list">
<li
v-for="item in historySectionItems"
:key="taskGroupKey(item)"
class="task-item"
>
<div class="left split-result-main">
<span class="id" :title="formatTaskGroupShopNames(item)">
{{ formatTaskGroupShopNames(item) }}
</span>
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
<div v-if="formatTaskGroupResultIds(item)" class="files">
结果 ID: {{ formatTaskGroupResultIds(item) }}
</div>
<div v-if="formatTaskGroupShopIds(item)" class="files">
店铺 ID: {{ formatTaskGroupShopIds(item) }}
</div>
<div v-if="item.platform" class="files">
平台: {{ item.platform }}
</div>
<div class="files">
创建时间: {{ formatDateTime(item.createdAt) }}
</div>
<div v-if="item.finishedAt" class="files">
完成时间: {{ formatDateTime(item.finishedAt) }}
</div>
<div class="files">
模板: {{ formatTemplateSummary(item) }}
</div>
<div v-if="formatTaskGroupErrors(item)" class="files">
错误: {{ formatTaskGroupErrors(item) }}
</div>
</div>
<div class="task-right">
<span
class="status"
:class="statusClass(item.taskStatus)"
>
{{ statusText(item.taskStatus) }}
</span>
<button
v-if="canDownload(item)"
type="button"
class="archive"
@click="downloadResult(item)"
>
下载结果
</button>
<button
type="button"
class="btn-delete"
@click="deleteTaskRecord(item)"
>
删除
</button>
</div>
</li>
</ul>
</div>
</template>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import { ElMessage } from "element-plus";
import BrandTopBar from "@/pages/brand/components/BrandTopBar.vue";
import {
addPatrolDeleteCandidate,
addPatrolDeleteCondition,
createPatrolDeleteTask,
deletePatrolDeleteCandidate,
deletePatrolDeleteCondition,
deletePatrolDeleteHistory,
deletePatrolDeleteTask,
getPatrolDeleteDashboard,
getPatrolDeleteHistory,
getPatrolDeleteTaskProgressBatch,
getPatrolDeleteResultDownloadUrl,
listPatrolDeleteConditions,
listPatrolDeleteCandidates,
matchPatrolDeleteShops,
submitPatrolDeleteTaskResult,
type PatrolDeleteCandidateVo,
type PatrolDeleteConditionVo,
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";
const COUNTRY_TEMPLATE = ["德国", "英国", "法国", "意大利", "西班牙"] as const;
const MAX_TRANSIENT_ERRORS = 30;
const shopInput = ref("");
const conditionInput = ref("");
const candidates = ref<PatrolDeleteCandidateVo[]>([]);
const selectedCandidates = ref<PatrolDeleteCandidateVo[]>([]);
const deleteConditions = ref<PatrolDeleteConditionVo[]>([]);
const selectedConditionIds = ref<number[]>([]);
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 addingCondition = 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);
let historyPollTimer: number | null = null;
const timers = createCategorizedTimers("patrol-delete");
const matchedRunnableItems = computed(() =>
matchedItems.value.filter((item) => item.matched),
);
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)),
);
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 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;
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 formatDateTime(value?: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
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}`;
}
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 "索引已命中,可推送到 Python 队列";
}
if (row.matched) {
return "已命中索引,请结合状态列确认";
}
return "未命中或未就绪,请检查店铺名";
}
function buildTemplateCountrySections(): PatrolDeleteCountrySection[] {
return COUNTRY_TEMPLATE.map((country) => ({
country,
rows: [
{
status: "全部",
quantity: "",
deleteQuantity: "",
processStatus: "",
},
],
}));
}
function buildTemplateCartRatios(): PatrolDeleteCartRatio[] {
return COUNTRY_TEMPLATE.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)),
);
}
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 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 loadConditions() {
deleteConditions.value = await listPatrolDeleteConditions();
const existingIds = new Set(deleteConditions.value.map((item) => item.id));
selectedConditionIds.value = selectedConditionIds.value.filter((id) =>
existingIds.has(id),
);
}
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 startHistoryPolling() {
stopHistoryPolling();
if (disposed || !hasQueueWork.value) return;
const run = async () => {
historyPollTimer = null;
if (disposed || !hasQueueWork.value) return;
await refreshActiveTaskProgress();
if (!disposed && hasQueueWork.value) {
historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
}
};
historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
}
function stopHistoryPolling() {
if (historyPollTimer) {
timers.clearTimer("history-poll", historyPollTimer);
historyPollTimer = null;
}
}
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;
}
}
async function confirmAddCondition() {
const text = conditionInput.value.trim();
if (!text) {
ElMessage.warning("请输入删除条件");
return;
}
addingCondition.value = true;
try {
const saved = await addPatrolDeleteCondition(text);
conditionInput.value = "";
await loadConditions();
if (saved?.id && !selectedConditionIds.value.includes(saved.id)) {
selectedConditionIds.value = [...selectedConditionIds.value, saved.id];
}
ElMessage.success("删除条件已保存");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "保存失败");
} finally {
addingCondition.value = false;
}
}
async function removeCondition(id: number) {
try {
await deletePatrolDeleteCondition(id);
selectedConditionIds.value = selectedConditionIds.value.filter(
(item) => item !== id,
);
await loadConditions();
ElMessage.success("删除条件已删除");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "删除失败");
}
}
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() {
const selected = new Set(selectedConditionIds.value);
return deleteConditions.value
.filter((condition) => selected.has(condition.id))
.map((condition) => ({
id: condition.id,
conditionText: condition.conditionText,
}));
}
function buildQueuePayload(taskId: number, items: PatrolDeleteHistoryItem[]) {
const firstItem = items[0];
const deleteConditionsForTask = selectedDeleteConditions();
return {
type: "patrol-delete-run",
ts: Date.now(),
data: {
taskId,
user_id: Number(uidForStorage()) || 0,
source: "frontend-vue-patrol-delete",
delete_conditions: deleteConditionsForTask,
deleteConditions: deleteConditionsForTask,
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;
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") {
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;
startHistoryPolling();
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;
}
const created = await withTransientRetry(
() => createPatrolDeleteTask(runnable.map(buildTaskItem)),
(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);
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} 个店铺,等待执行完成`;
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;
stopHistoryPolling();
saveQueueState();
}
}
async function pushToPythonQueue() {
const runnable = matchedRunnableItems.value;
if (!runnable.length) {
ElMessage.warning("请先匹配可用店铺");
return;
}
queuePushResult.value = `开始创建任务,共 ${runnable.length} 个店铺`;
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 api = getPywebviewApi();
if (!api?.save_file_from_url_new) {
ElMessage.error("当前客户端未提供下载能力");
return;
}
const url = getPatrolDeleteResultDownloadUrl(downloadable.resultId);
const filename =
downloadable.outputFilename || `${item.shopName || "patrol-delete-result"}.xlsx`;
const result = await api.save_file_from_url_new(url, filename);
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();
await Promise.all([loadCandidates(), loadConditions(), loadDashboard(), loadHistory()]);
if (activeTaskId.value) {
queuePushResult.value =
activeTaskId.value != null
? `检测到未完成任务,继续等待任务 ${activeTaskId.value} 完成`
: "";
void processQueue();
}
});
onUnmounted(() => {
disposed = true;
stopHistoryPolling();
clearSleepTimers();
timers.clearScope();
});
</script>
<style scoped>
.module-page { min-height: 100vh; background: #1a1a1a; }
.main-content { display: flex; min-height: calc(100vh - 56px); height: calc(100vh - 56px); }
.left-panel { width: 400px; background: #1e1e1e; padding: 20px; overflow-y: auto; border-right: 1px solid #2a2a2a; }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #1a1a1a; }
.section-title { font-size: 13px; color: #bbb; 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 #3a3a3a; border-radius: 10px; padding: 16px; background: #252525; margin-bottom: 20px; }
.hint { color: #888; 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: #ccc; background: #2a2a2a; border: 1px solid #3a3a3a; 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: #666; 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 #2a2a2a; }
.candidate-table { --el-table-bg-color: #252525; --el-table-tr-bg-color: #252525; --el-table-header-bg-color: #2a2a2a; --el-table-text-color: #ccc; --el-table-border-color: #333; }
.condition-panel { margin-bottom: 18px; border: 1px solid #2a2a2a; border-radius: 8px; background: #222; 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: #666; 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-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 6px; border-bottom: 1px solid #303030; }
.condition-item:last-child { border-bottom: none; }
.condition-check { display: flex; align-items: center; gap: 8px; min-width: 0; color: #cfd6df; font-size: 12px; cursor: pointer; }
.condition-check input { width: 16px; height: 16px; margin: 0; flex: 0 0 auto; }
.condition-check span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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: #fff; 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: #777; line-height: 1.5; }
.queue-debug-card { margin-top: 18px; padding: 14px; border: 1px solid #2a2a2a; border-radius: 10px; background: #202020; }
.queue-debug-title { margin-bottom: 8px; }
.queue-debug-line { color: #b8c1cc; 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: #141414; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
.panel-header { padding: 16px 20px; font-size: 15px; font-weight: 600; color: #e0e0e0; border-bottom: 1px solid #2a2a2a; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.summary-card { padding: 14px 16px; border: 1px solid #2a2a2a; border-radius: 10px; background: #1e1e1e; }
.summary-card strong { display: block; margin-top: 8px; font-size: 22px; color: #eaf4ff; }
.summary-label { font-size: 12px; color: #8d8d8d; }
.subsection-title { font-size: 13px; color: #999; margin: 8px 0 10px; }
.empty-tasks { color: #666; font-size: 13px; padding: 16px; text-align: center; }
.empty-tasks.narrow { padding: 12px 8px; }
.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, #222) !important; }
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2a2a2a; --el-table-text-color: #ccc; --el-table-border-color: #333; }
.ok { color: #27ae60; }
.fail { color: #e67e22; }
.result-list-wrap { border: 1px solid #2a2a2a; border-radius: 10px; background: #1e1e1e; min-height: 220px; margin-top: 8px; }
.result-list-header { padding: 12px 16px; border-bottom: 1px solid #2a2a2a; font-size: 14px; color: #ddd; }
.result-subsection { padding: 12px 12px 4px; }
.result-subsection-title { font-size: 12px; color: #888; margin-bottom: 8px; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2a2a2a; border-radius: 8px; margin-bottom: 8px; background: #222; }
.left { flex: 1; min-width: 0; }
.split-result-main { display: flex; flex-direction: column; gap: 4px; }
.id { font-weight: 600; color: #e0e0e0; font-size: 13px; }
.files { font-size: 12px; color: #888; }
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; }
.archive { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
.archive: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 #2a2a2a; }
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>