Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue
T
huangzd1997 228d481211 perf(全链路): 连接池/事务边界/轮询与 IO 效率收口
Java:
- LLM 180s 读超时不再被全局 call-timeout 静默截断成 90s(长思考请求被掐断→重试→付费网关二次计费)
- 代理 HttpClient 缓存改有界 LRU(jikip 每次提取新 IP,无界缓存持续泄漏 selector 线程与连接池)
- 12 个 service 的 Redis 任务锁移出 @Transactional(自旋最坏 10s 白占 DB 连接,池仅 30),远端对象删/传改 afterCommit
- 结果文件 Job 闸门拒绝时不再回退内联执行(改重新入队,避免把背压转嫁给 MQ 消费线程)
- imagevideo 每秒扫描加列投影、过期清理加 LIMIT;权限页整表查询改列投影(不再拉回密码哈希)
- 哈希改 HexFormat;补 5 处"不能改"的技术依据注释(批量插入会丢回填主键、流式丢模板与图片等)

前端:4 个工具页轮询改轻量端点(带 fallback);PriceTrack 快照节流写盘;候选店铺表分页;页面隐藏时停表

客户端:HTTP 连接池按出口复用(Session 仍每请求新建,保持无跨请求状态);品牌检测 WIPO 逐请求握手;
代理配置按 mtime 缓存;串行任务改专属池;异常降级为标签页重连;紫鸟启动改端口轮询;模板编译缓存;
日志上报连接与落盘收口;Flask 版本 API 改按请求复用连接
2026-09-15 23:01:59 +08:00

1375 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">
<AmazonToolPageShell tool-id="cash">
<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 class="filter-row">
<span class="filter-label">保留金额</span>
<el-input-number
v-model="reservedAmount"
:min="0"
:precision="2"
:step="1"
controls-position="right"
class="reserved-input"
/>
</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="pagedCandidates"
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>
<el-pagination
v-if="candidateTotal > candidatePageSize"
class="candidate-pagination"
layout="total, prev, pager, next"
:total="candidateTotal"
:page-size="candidatePageSize"
v-model:current-page="candidatePage"
/>
<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">
<div class="match-zone">
<div class="match-zone-header">
<span>匹配结果</span>
</div>
<div v-if="!matchedItems.length" class="match-zone-empty">
完成匹配店铺这里会展示匹配结果
</div>
<div v-else class="match-zone-scroll">
<el-table
:data="pagedMatchedItems"
: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>
<el-pagination
v-if="matchedTotal > matchedPageSize"
class="matched-pagination"
layout="total, prev, pager, next"
:total="matchedTotal"
:page-size="matchedPageSize"
v-model:current-page="matchedPage"
/>
</div>
</div>
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="匹配与任务"
:cards="withdrawCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-title="当前任务"
current-empty-text="暂无当前任务启动任务后运行中的任务会显示在这里"
history-empty-text="暂无历史任务"
>
<template #item-extra="{ item }">
<div class="files">取款结果{{ formatTemplateSummary(itemSource(item)) }}</div>
<div v-if="itemSource(item).shops.length" class="shop-detail-list">
<div
v-for="shop in itemSource(item).shops"
:key="`cur-shop-${shop.resultId || shop.shopId || shop.shopName}`"
class="files shop-detail-line"
>
{{ shop.shopName || "-" }} · 结果 ID: {{ shop.resultId ?? "-" }} · 店铺 ID: {{ shop.shopId || "-" }} · 平台: {{ shop.platform || "-" }}
</div>
</div>
</template>
<template #history-item-extra="{ item }">
<div class="files">取款结果{{ formatTemplateSummary(itemSource(item)) }}</div>
<div v-if="itemSource(item).shops.length" class="shop-detail-list">
<div
v-for="shop in itemSource(item).shops"
:key="`his-shop-${shop.resultId || shop.shopId || shop.shopName}`"
class="files shop-detail-line"
>
{{ shop.shopName || "-" }} · 结果 ID: {{ shop.resultId ?? "-" }} · 店铺 ID: {{ shop.shopId || "-" }} · 平台: {{ shop.platform || "-" }}
</div>
</div>
</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 {
addWithdrawCandidate,
createWithdrawTask,
deleteWithdrawCandidate,
deleteWithdrawHistory,
deleteWithdrawTask,
getWithdrawDashboard,
getWithdrawHistory,
getWithdrawTaskProgressBatch,
getWithdrawResultDownloadUrl,
listWithdrawCandidates,
matchWithdrawShops,
clearWithdrawCandidates,
submitWithdrawTaskResult,
type WithdrawCandidateVo,
type WithdrawDashboardVo,
type WithdrawHistoryItem,
type WithdrawShopQueueItem,
type WithdrawTaskItem,
} 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'
import { isRecordMissingError, parseTaskStartTimes, removeTaskStartTime, upsertTaskStartTime } from '@/shared/utils/task-queue-state.ts'
import { useTablePaging } from '@/shared/composables/useTablePaging.ts'
import { getModuleProgressLight } from '@/shared/api/progress-light.ts'
import { createRowsVersionTracker } from '@/shared/api/task-progress-polling.ts'
const MAX_TRANSIENT_ERRORS = 30;
const ziniaoVersion = useZiniaoVersion();
type WithdrawTaskBatchQueueItem = {
batchId: string;
reservedAmount: number;
items: WithdrawShopQueueItem[];
};
type WithdrawTaskGroupItem = WithdrawHistoryItem & {
shops: WithdrawHistoryItem[];
};
const shopInput = ref("");
const reservedAmount = ref(0);
const candidates = ref<WithdrawCandidateVo[]>([]);
const selectedCandidates = ref<WithdrawCandidateVo[]>([]);
const matchedItems = ref<WithdrawShopQueueItem[]>([]);
// F9:匹配结果表分页(只切渲染窗口,不加选择语义)
const {
page: matchedPage,
pageSize: matchedPageSize,
total: matchedTotal,
paged: pagedMatchedItems,
} = useTablePaging(matchedItems)
// F9:备选店铺表同样只切渲染窗口(分页不改变数据源,勾选/删除语义不变)
const {
page: candidatePage,
pageSize: candidatePageSize,
total: candidateTotal,
paged: pagedCandidates,
} = useTablePaging(candidates)
const historyItems = ref<WithdrawHistoryItem[]>([]);
const dashboard = ref<WithdrawDashboardVo>({
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<WithdrawTaskBatchQueueItem[]>([]);
const activeTaskId = ref<number | null>(null);
const activeQueueItem = ref<WithdrawTaskBatchQueueItem | null>(null);
const queueWorkerRunning = ref(false);
const autoQueueEnabled = ref(false);
const taskStartTimes = ref<Record<number, string>>({});
const timers = createCategorizedTimers("withdraw");
const matchedRunnableItems = computed(() =>
matchedItems.value.filter((item) => item.matched),
);
const groupedHistoryItems = computed(() => groupHistoryItemsByTask(historyItems.value));
const currentSectionItems = computed(() =>
groupedHistoryItems.value.filter((item) => !isTaskTerminal(item.taskStatus)),
);
const historySectionItems = computed(() =>
groupedHistoryItems.value.filter((item) => isTaskTerminal(item.taskStatus)),
);
const hasQueuedTaskWork = computed(
() =>
!!activeTaskId.value ||
pendingQueue.value.length > 0,
);
const hasQueueWork = computed(() => queueWorkerRunning.value || hasQueuedTaskWork.value);
const isQueueBusy = computed(() => queueWorkerRunning.value && hasQueuedTaskWork.value);
// ---- 右侧任务面板统一视图(TaskCenterPanel----
const withdrawCards = computed<TaskStatCard[]>(() => [
{ label: '运行中任务', value: currentTaskViews.value.length },
{ label: '已结束任务', value: historyTaskViews.value.length },
{ label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
{ label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
]);
/** 当前任务:按任务分组后非终态的任务卡 */
const currentTaskViews = computed<TaskItemView[]>(() =>
currentSectionItems.value.map(toWithdrawTaskView),
);
/** 历史任务:按任务分组后已终态的任务卡 */
const historyTaskViews = computed<TaskItemView[]>(() =>
historySectionItems.value.map(toWithdrawTaskView),
);
function itemSource(item: TaskItemView): WithdrawTaskGroupItem {
return item.source as WithdrawTaskGroupItem;
}
/** 一任务一卡:标题优先店铺名(多店以“、”连接),其次结果文件名 */
function toWithdrawTaskView(item: WithdrawTaskGroupItem): TaskItemView {
return {
key: `withdraw-${item.taskId ?? item.resultId ?? item.shopName}`,
title: item.shopName || item.outputFilename || `取款任务 ${item.taskId ?? "-"}`,
taskId: item.taskId ?? "-",
startedAt: formatDateTime(taskStartTime(item.taskId) || item.createdAt),
finishedAt: formatDateTime(item.finishedAt),
statusText: statusText(item.taskStatus),
statusClass: statusClass(item.taskStatus),
extraLines: item.error ? [`错误:${item.error}`] : [],
source: item,
};
}
function uidForStorage() {
return typeof window !== "undefined"
? window.localStorage.getItem("uid") || "0"
: "0";
}
function matchedStorageKey() {
return `withdraw:matched:${uidForStorage()}`;
}
function queueStateStorageKey() {
return `withdraw:queue-state:${uidForStorage()}`;
}
function taskStartTimeStorageKey() {
return `withdraw:start-times:${uidForStorage()}`;
}
function rowKeyForMatch(row: WithdrawShopQueueItem) {
return `${(row.shopName || "").trim()}::${row.shopId || ""}`;
}
function historyItemKey(item: WithdrawHistoryItem) {
return `${item.taskId ?? 0}:${item.resultId ?? 0}`;
}
function batchKeyForItems(items: WithdrawShopQueueItem[]) {
return items.map((item) => rowKeyForMatch(item)).join("||");
}
function normalizeBatchItems(items: WithdrawShopQueueItem[]) {
const map = new Map<string, WithdrawShopQueueItem>();
for (const item of items || []) {
map.set(rowKeyForMatch(item), item);
}
return Array.from(map.values());
}
function createQueueBatch(items: WithdrawShopQueueItem[]): WithdrawTaskBatchQueueItem {
const normalizedItems = normalizeBatchItems(items);
return {
batchId: `${Date.now()}:${Math.random().toString(16).slice(2)}`,
reservedAmount: Number(reservedAmount.value || 0),
items: normalizedItems,
};
}
function isPersistedBatch(value: unknown): value is WithdrawTaskBatchQueueItem {
return Boolean(value && typeof value === "object" && Array.isArray((value as WithdrawTaskBatchQueueItem).items));
}
function normalizePersistedBatch(value: unknown): WithdrawTaskBatchQueueItem | null {
if (!value || typeof value !== "object") {
return null;
}
if (isPersistedBatch(value)) {
const items = normalizeBatchItems(value.items || []);
return items.length
? {
batchId: value.batchId || `${Date.now()}:${Math.random().toString(16).slice(2)}`,
reservedAmount: Number(value.reservedAmount ?? reservedAmount.value ?? 0),
items,
}
: null;
}
return null;
}
function normalizePersistedBatches(values: unknown[]) {
const batches: WithdrawTaskBatchQueueItem[] = [];
for (const value of values || []) {
const batch = normalizePersistedBatch(value);
if (batch) {
batches.push(batch);
}
}
return batches;
}
function mergeQueueBatches(
base: WithdrawTaskBatchQueueItem[],
incoming: WithdrawTaskBatchQueueItem[],
) {
const map = new Map<string, WithdrawTaskBatchQueueItem>();
for (const item of base || []) {
map.set(item.batchId, {
...item,
items: normalizeBatchItems(item.items),
});
}
for (const item of incoming || []) {
const key = batchKeyForItems(item.items);
const existing = map.get(item.batchId) || Array.from(map.values()).find((batch) => batchKeyForItems(batch.items) === key);
if (existing) {
existing.items = normalizeBatchItems([...existing.items, ...item.items]);
existing.reservedAmount = Number(item.reservedAmount ?? existing.reservedAmount ?? 0);
continue;
}
map.set(item.batchId, {
...item,
items: normalizeBatchItems(item.items),
});
}
return Array.from(map.values()).filter((batch) => batch.items.length > 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 `withdraw:${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: WithdrawShopQueueItem) {
const message = (row.matchMessage || "").trim();
if (message) return message;
if (row.matched && row.matchStatus === "MATCHED") {
return "已匹配成功,可启动任务";
}
if (row.matched) {
return "已匹配成功,请查看状态确认";
}
return "未匹配成功,请检查店铺名";
}
function withWithdrawFallback<T extends WithdrawHistoryItem | WithdrawShopQueueItem>(
item: T,
_fallback?: WithdrawShopQueueItem | WithdrawHistoryItem | null,
) {
return item;
}
function groupHistoryItemsByTask(items: WithdrawHistoryItem[]): WithdrawTaskGroupItem[] {
const map = new Map<string, WithdrawTaskGroupItem>();
for (const item of items || []) {
const key = item.taskId != null ? `task:${item.taskId}` : `result:${item.resultId ?? historyItemKey(item)}`;
const existing = map.get(key);
if (!existing) {
map.set(key, {
...item,
shops: [item],
});
continue;
}
existing.shops.push(item);
existing.shopName = existing.shops.map((shop) => shop.shopName).filter(Boolean).join("、");
existing.resultId = existing.resultId || item.resultId;
existing.outputFilename = existing.outputFilename || item.outputFilename;
existing.fileReady = Boolean(existing.fileReady || item.fileReady);
existing.downloadUrl = existing.downloadUrl || item.downloadUrl;
existing.fileStatus = existing.fileStatus || item.fileStatus;
existing.fileError = existing.fileError || item.fileError;
existing.error = [existing.error, item.error].filter(Boolean).join("; ") || undefined;
existing.rows = existing.shops.flatMap((shop) => shop.rows || []);
existing.createdAt = minDateTime(existing.createdAt, item.createdAt);
existing.finishedAt = maxDateTime(existing.finishedAt, item.finishedAt);
existing.taskStatus = mergeTaskStatus(existing.shops);
}
return Array.from(map.values()).map((item) => ({
...item,
shopName: item.shops.map((shop) => shop.shopName).filter(Boolean).join("、") || item.shopName,
rows: item.shops.flatMap((shop) => shop.rows || []),
taskStatus: mergeTaskStatus(item.shops),
}));
}
function mergeTaskStatus(items: WithdrawHistoryItem[]) {
const statuses = items.map((item) => item.taskStatus || "");
if (statuses.some((status) => !isTaskTerminal(status))) {
return statuses.find((status) => !isTaskTerminal(status)) || "RUNNING";
}
if (statuses.some((status) => status === "FAILED")) return "FAILED";
if (statuses.some((status) => status === "SUCCESS" || status === "COMPLETED")) return "SUCCESS";
return statuses[0] || "";
}
function minDateTime(first?: string, second?: string) {
if (!first) return second;
if (!second) return first;
return new Date(first).getTime() <= new Date(second).getTime() ? first : second;
}
function maxDateTime(first?: string, second?: string) {
if (!first) return second;
if (!second) return first;
return new Date(first).getTime() >= new Date(second).getTime() ? first : second;
}
function formatTemplateSummary(record: Pick<WithdrawTaskGroupItem, "shops" | "rows" | "reservedAmount">) {
const shops = "shops" in record ? record.shops || [] : [];
const rowCount = shops.length
? shops.reduce((total, shop) => total + (shop.rows?.length || 0), 0)
: record.rows?.length || 0;
const amount = record.reservedAmount ?? reservedAmount.value;
const shopCount = shops.length || 1;
return `保留金额 ${amount || 0},共 ${shopCount} 个店铺,已返回 ${rowCount} 行`;
}
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: WithdrawTaskGroupItem) {
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?: WithdrawTaskBatchQueueItem[];
activeTaskId?: number | null;
activeQueueItem?: WithdrawTaskBatchQueueItem | null;
};
pendingQueue.value = normalizePersistedBatches(parsed.pendingQueue || []);
activeTaskId.value = parsed.activeTaskId ?? null;
activeQueueItem.value = normalizePersistedBatch(parsed.activeQueueItem);
} catch {
pendingQueue.value = [];
activeTaskId.value = null;
activeQueueItem.value = null;
}
}
function loadTaskStartTimes() {
try {
const raw =
typeof window !== "undefined"
? window.localStorage.getItem(taskStartTimeStorageKey())
: null;
taskStartTimes.value = parseTaskStartTimes(raw);
} catch {
taskStartTimes.value = {};
}
}
function setTaskStartTime(taskId: number, startedAt = new Date().toISOString()) {
if (!Number.isFinite(taskId) || taskId <= 0) return;
taskStartTimes.value = upsertTaskStartTime(taskStartTimes.value, taskId, startedAt);
saveTaskStartTimes();
}
function clearTaskStartTime(taskId?: number | null) {
if (!taskId || !(taskId in taskStartTimes.value)) return;
taskStartTimes.value = removeTaskStartTime(taskStartTimes.value, taskId);
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 removeHistoryItemLocally(item: WithdrawHistoryItem) {
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 listWithdrawCandidates();
}
async function loadDashboard() {
dashboard.value = await getWithdrawDashboard();
}
async function loadHistory() {
const data = await getWithdrawHistory();
historyItems.value = (data.items || []).map((item) => withWithdrawFallback(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: WithdrawHistoryItem[]) {
if (!incoming.length) {
return;
}
const incomingMap = new Map(
incoming.map((item) => [historyItemKey(item), withWithdrawFallback(item)] as const),
);
const merged = historyItems.value.map((item) => {
const next = incomingMap.get(historyItemKey(item));
if (!next) {
return withWithdrawFallback(item);
}
return {
...item,
...next,
};
});
// 判重改 Set:此前 merged.some(...) 是 O(n×m),历史条目越多每轮轮询越慢
const mergedKeys = new Set(merged.map(historyItemKey));
for (const item of incoming) {
const key = historyItemKey(item);
if (!mergedKeys.has(key)) {
merged.push(withWithdrawFallback(item));
mergedKeys.add(key);
}
}
historyItems.value = merged;
}
/** 结果行版本跟踪:版本未变的任务不再拉取行明细(F5 后续) */
const rowsVersionTracker = createRowsVersionTracker()
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;
}
// 行数据按需拉取(审查 F5 后续):先问轻量端点拿"结果行版本",版本未变的任务跳过重型 batch
let heavyIds = ids;
let missingIds: number[] = [];
try {
const light = await getModuleProgressLight('withdraw', ids);
heavyIds = rowsVersionTracker.selectTasksNeedingRows(light.items || [], ids);
missingIds = light.missingTaskIds || [];
if (!heavyIds.length) {
if (missingIds.length) {
await loadHistory();
reconcileActiveQueueTaskWithHistory();
}
return;
}
} catch {
// 轻量端点不可用(老后端/网络)→ 照旧全量拉取,行为与原来一致
heavyIds = ids;
}
const batch = await getWithdrawTaskProgressBatch(heavyIds);
missingIds = Array.from(new Set([...missingIds, ...(batch.missingTaskIds || [])]));
mergeHistoryProgressItems(batch.items || []);
if (missingIds.length) {
await loadHistory();
reconcileActiveQueueTaskWithHistory();
}
}
function onSelectionChange(rows: WithdrawCandidateVo[]) {
selectedCandidates.value = rows;
}
async function confirmAdd() {
const name = shopInput.value.trim();
if (!name) {
ElMessage.warning("请输入店铺名");
return;
}
adding.value = true;
try {
await addWithdrawCandidate(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 deleteWithdrawCandidate(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: WithdrawShopQueueItem[],
incoming: WithdrawShopQueueItem[],
) {
const map = new Map<string, WithdrawShopQueueItem>();
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 matchWithdrawShops(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: WithdrawShopQueueItem) {
if (hasQueueWork.value) {
ElMessage.warning("任务执行中,请等待当前任务结束后再调整");
return;
}
matchedItems.value = matchedItems.value.filter(
(item) => rowKeyForMatch(item) !== rowKeyForMatch(row),
);
saveMatchedItems();
}
function buildTaskItem(item: WithdrawShopQueueItem): WithdrawTaskItem {
return {
shopName: item.shopName,
matched: item.matched,
shopId: item.shopId,
platform: item.platform,
companyName: item.companyName,
matchStatus: item.matchStatus,
matchMessage: item.matchMessage,
};
}
function buildQueuePayload(taskId: number, batch: WithdrawTaskBatchQueueItem) {
const submissionId = createSubmissionId(taskId, "batch");
const items = batch.items.map((item) => ({
shop_name: item.shopName,
submission_id: createSubmissionId(taskId, item.shopName),
shop_id: item.shopId,
platform: item.platform,
company_name: item.companyName,
matched: item.matched,
match_status: item.matchStatus,
match_message: item.matchMessage,
reserved_amount: batch.reservedAmount,
}));
return {
type: "withdraw-run",
ts: Date.now(),
data: {
task_id: taskId,
ziniao_version: ziniaoVersion.value,
user_id: Number(uidForStorage()) || 0,
source: "frontend-vue-withdraw",
reserved_amount: batch.reservedAmount,
submission_id: submissionId,
items,
},
};
}
async function clearMatchedCandidates(rows: WithdrawShopQueueItem[]) {
const shopNames = Array.from(
new Set(rows.map((row) => (row.shopName || "").trim()).filter(Boolean)),
);
if (!shopNames.length) return;
await clearWithdrawCandidates(shopNames);
}
function removeMatchedRowsLocally(rows: WithdrawShopQueueItem[]) {
const keys = new Set(rows.map((row) => rowKeyForMatch(row)));
matchedItems.value = matchedItems.value.filter((item) => !keys.has(rowKeyForMatch(item)));
saveMatchedItems();
}
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 nextBatch = pendingQueue.value.shift();
saveQueueState();
if (!nextBatch || !nextBatch.items.length) break;
const created = await withTransientRetry(
() => createWithdrawTask(nextBatch.items.map(buildTaskItem), nextBatch.reservedAmount),
(attempt, maxAttempts) => {
queuePushResult.value = `服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...`;
},
);
const createdItem = created.items?.[0];
if (!created.taskId || !createdItem?.resultId) {
throw new Error("后端未返回有效任务标识");
}
activeTaskId.value = created.taskId;
activeQueueItem.value = nextBatch;
saveQueueState();
await refreshTaskViews();
const payload = buildQueuePayload(created.taskId, nextBatch);
queuePayloadText.value = JSON.stringify(payload, null, 2);
const guard = checkQueuePayload(payload, {
expectedType: "withdraw-run",
requiredDataKeys: ["task_id"],
nonEmptyArrayKeys: ["items"],
});
if (!(await passGuard(guard))) {
await submitWithdrawTaskResult(created.taskId, {
shops: nextBatch.items.map((item) => ({
shopName: item.shopName || "",
error: `任务 ${created.taskId} 数据校验未通过,已阻止启动`,
shopDone: true,
submissionId: createSubmissionId(created.taskId, item.shopName),
rows: [],
})),
});
await refreshTaskViews();
queuePushResult.value = `任务 ${created.taskId} 数据校验未通过,已阻止启动并继续下一个任务`;
clearActiveQueueTask();
continue;
}
const pushResult = await api.enqueue_json(payload);
if (!pushResult?.success) {
await submitWithdrawTaskResult(created.taskId, {
shops: nextBatch.items.map((item) => ({
shopName: item.shopName || "",
error: pushResult?.error || `任务 ${created.taskId} 启动失败`,
shopDone: true,
submissionId: createSubmissionId(created.taskId, item.shopName),
rows: [],
})),
});
await refreshTaskViews();
queuePushResult.value = `任务 ${created.taskId} 启动失败,已自动继续下一个任务`;
clearActiveQueueTask();
continue;
}
setTaskStartTime(created.taskId);
removeMatchedRowsLocally(nextBatch.items);
await clearMatchedCandidates(nextBatch.items);
selectedCandidates.value = [];
await Promise.all([loadCandidates(), loadDashboard()]);
queuePushResult.value =
pendingQueue.value.length > 0
? `任务 ${created.taskId} 已提交,包含 ${nextBatch.items.length} 个店铺,等待完成后自动继续下一个任务(剩余 ${pendingQueue.value.length} 个任务)`
: `任务 ${created.taskId} 已提交,包含 ${nextBatch.items.length} 个店铺,等待执行完成`;
}
queuePushResult.value = "任务已按顺序执行完成";
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() {
autoQueueEnabled.value = true;
resetQueueWorkerIfIdle();
const runnable = matchedRunnableItems.value;
if (!runnable.length) {
ElMessage.warning("请先匹配可用店铺");
return;
}
const batch = createQueueBatch(runnable);
pendingQueue.value = mergeQueueBatches(pendingQueue.value, [batch]);
saveQueueState();
queuePushResult.value = `已加入 1 个取款任务,包含 ${batch.items.length} 个店铺,开始按顺序执行`;
await processQueue();
}
async function downloadResult(item: WithdrawTaskGroupItem) {
if (!item.resultId) return;
const url = getWithdrawResultDownloadUrl(item.resultId);
const filename = item.outputFilename || `取款-${item.taskId || item.resultId}.xlsx`;
const result = await saveUrlWithProgress(url, filename, `withdraw:${item.resultId}`);
if (result.success) {
ElMessage.success(`已保存: ${result.path || filename}`);
} else if (result.error && result.error !== "用户取消") {
ElMessage.error(result.error);
}
}
async function deleteTaskRecord(item: WithdrawTaskGroupItem) {
try {
if (item.taskId) {
await deleteWithdrawTask(item.taskId);
if (activeTaskId.value === item.taskId) {
clearActiveQueueTask();
}
clearTaskStartTime(item.taskId);
} else if (item.resultId) {
await deleteWithdrawHistory(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; }
.filter-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
.filter-label { color: #a0acbe; font-size: 12px; white-space: nowrap; }
.reserved-input { width: 180px; }
.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; }
.match-zone {
flex: 0 0 auto;
margin: 16px 20px 0;
border: 1px solid #2e3a52;
border-radius: 10px;
background: #242424;
overflow: hidden;
}
.match-zone-header {
padding: 10px 16px;
border-bottom: 1px solid #2e3a52;
font-size: 13px;
color: #a0acbe;
}
.match-zone-empty {
padding: 18px 16px;
color: #5e6878;
font-size: 13px;
text-align: center;
}
.match-zone-scroll {
max-height: 260px;
overflow: auto;
}
.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; }
.files { font-size: 12px; color: #5e6878; }
.shop-detail-list {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 4px;
}
.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>