228d481211
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 改按请求复用连接
1375 lines
47 KiB
Vue
1375 lines
47 KiB
Vue
<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>
|
||
|
||
|