完善多店铺开启多窗口、外观进度条等
This commit is contained in:
BIN
frontend-vue/new_web_source.zip
Normal file
BIN
frontend-vue/new_web_source.zip
Normal file
Binary file not shown.
@@ -113,6 +113,18 @@
|
||||
{{ item.resultFilename || '下载结果' }}
|
||||
</div>
|
||||
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
|
||||
<div v-if="showFileProgress(item)" class="file-progress">
|
||||
<div class="file-progress-meta">
|
||||
<span>{{ item.fileProgressMessage || '结果生成中' }}</span>
|
||||
<span>{{ fileProgressPercent(item) }}%</span>
|
||||
</div>
|
||||
<div class="file-progress-track">
|
||||
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
|
||||
</div>
|
||||
<div class="file-progress-count">
|
||||
{{ item.fileProgressCurrent || 0 }}/{{ item.fileProgressTotal || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="item.error" class="files">错误:{{ item.error }}</div>
|
||||
</div>
|
||||
<div class="task-right">
|
||||
@@ -633,6 +645,16 @@ function canDownload(item: AppearancePatentHistoryItem) {
|
||||
return Boolean(item.resultId && (item.fileReady || item.downloadUrl))
|
||||
}
|
||||
|
||||
function fileProgressPercent(item: AppearancePatentHistoryItem) {
|
||||
const percent = Number(item.fileProgressPercent || 0)
|
||||
if (!Number.isFinite(percent)) return 0
|
||||
return Math.max(0, Math.min(100, Math.round(percent)))
|
||||
}
|
||||
|
||||
function showFileProgress(item: AppearancePatentHistoryItem) {
|
||||
return isResultPreparing(item) && (item.fileProgressTotal || 0) > 0
|
||||
}
|
||||
|
||||
async function downloadResult(item: AppearancePatentHistoryItem) {
|
||||
if (!item.resultId) return
|
||||
const api = getPywebviewApi()
|
||||
@@ -729,6 +751,11 @@ onUnmounted(() => {
|
||||
.status.running { background: rgba(52, 152, 219, .18); color: #3498db; }
|
||||
.status.pending { background: rgba(149, 165, 166, .18); color: #bdc3c7; }
|
||||
.result-hint { margin-top: 6px; color: #e0b96d; }
|
||||
.file-progress { margin-top: 8px; max-width: 520px; }
|
||||
.file-progress-meta { display: flex; justify-content: space-between; gap: 12px; color: #d8c278; font-size: 12px; }
|
||||
.file-progress-track { margin-top: 5px; height: 8px; border-radius: 999px; overflow: hidden; background: #303030; border: 1px solid #3b3b3b; }
|
||||
.file-progress-bar { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #4aa3ff, #f0c75e); transition: width .25s ease; }
|
||||
.file-progress-count { margin-top: 4px; color: #858585; font-size: 11px; }
|
||||
.download { padding: 6px 10px; color: #d6ecff; background: rgba(52, 152, 219, .18); }
|
||||
.btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); }
|
||||
@media (max-width: 1100px) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="section-title">店铺输入</div>
|
||||
<div class="input-zone">
|
||||
<div class="hint">
|
||||
左侧负责录入店铺并加入备选区,确认命中后推送到 Python 队列。巡店删除按店铺串行执行:上一个任务完成后会自动开始下一个,失败也会继续下一个。
|
||||
左侧负责录入店铺并加入备选区,点击“匹配店铺”只会生成匹配结果;确认后需手动点击“推送到 Python 队列”才会创建并执行任务。
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<el-input
|
||||
@@ -60,6 +60,54 @@
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="section-title">删除条件</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"
|
||||
@@ -75,12 +123,12 @@
|
||||
:disabled="pushing || !matchedRunnableItems.length"
|
||||
@click="pushToPythonQueue"
|
||||
>
|
||||
{{ pushing ? "串行执行中..." : "推送到 Python 队列" }}
|
||||
{{ pushing ? "任务执行中..." : "推送到 Python 队列" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="loading-msg">
|
||||
当前页面会按店铺串行推送任务,并在任务成功或失败后自动接续下一个任务。服务端临时重启时会自动重试查询状态,恢复后继续后续店铺。
|
||||
当前页面会把匹配结果合并为一个巡店删除任务推送;多个店铺会在同一个任务和同一个结果文件中汇总。
|
||||
</p>
|
||||
|
||||
<div v-if="queuePushResult" class="queue-debug-card">
|
||||
@@ -192,19 +240,19 @@
|
||||
<ul class="task-list">
|
||||
<li
|
||||
v-for="item in currentSectionItems"
|
||||
:key="`${item.resultId}-${item.taskId}`"
|
||||
:key="taskGroupKey(item)"
|
||||
class="task-item"
|
||||
>
|
||||
<div class="left split-result-main">
|
||||
<span class="id" :title="item.shopName || ''">
|
||||
{{ item.shopName || "-" }}
|
||||
<span class="id" :title="formatTaskGroupShopNames(item)">
|
||||
{{ formatTaskGroupShopNames(item) }}
|
||||
</span>
|
||||
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
|
||||
<div v-if="item.resultId" class="files">
|
||||
结果 ID: {{ item.resultId }}
|
||||
<div v-if="formatTaskGroupResultIds(item)" class="files">
|
||||
结果 ID: {{ formatTaskGroupResultIds(item) }}
|
||||
</div>
|
||||
<div v-if="item.shopId" class="files">
|
||||
店铺 ID: {{ item.shopId }}
|
||||
<div v-if="formatTaskGroupShopIds(item)" class="files">
|
||||
店铺 ID: {{ formatTaskGroupShopIds(item) }}
|
||||
</div>
|
||||
<div v-if="item.platform" class="files">
|
||||
平台: {{ item.platform }}
|
||||
@@ -213,10 +261,10 @@
|
||||
创建时间: {{ formatDateTime(item.createdAt) }}
|
||||
</div>
|
||||
<div class="files">
|
||||
模板结构: {{ formatTemplateSummary(item) }}
|
||||
模板: {{ formatTemplateSummary(item) }}
|
||||
</div>
|
||||
<div v-if="item.error" class="files">
|
||||
错误: {{ item.error }}
|
||||
<div v-if="formatTaskGroupErrors(item)" class="files">
|
||||
错误: {{ formatTaskGroupErrors(item) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-right">
|
||||
@@ -243,19 +291,19 @@
|
||||
<ul class="task-list">
|
||||
<li
|
||||
v-for="item in historySectionItems"
|
||||
:key="`${item.resultId}-${item.taskId}`"
|
||||
:key="taskGroupKey(item)"
|
||||
class="task-item"
|
||||
>
|
||||
<div class="left split-result-main">
|
||||
<span class="id" :title="item.shopName || ''">
|
||||
{{ item.shopName || "-" }}
|
||||
<span class="id" :title="formatTaskGroupShopNames(item)">
|
||||
{{ formatTaskGroupShopNames(item) }}
|
||||
</span>
|
||||
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
|
||||
<div v-if="item.resultId" class="files">
|
||||
结果 ID: {{ item.resultId }}
|
||||
<div v-if="formatTaskGroupResultIds(item)" class="files">
|
||||
结果 ID: {{ formatTaskGroupResultIds(item) }}
|
||||
</div>
|
||||
<div v-if="item.shopId" class="files">
|
||||
店铺 ID: {{ item.shopId }}
|
||||
<div v-if="formatTaskGroupShopIds(item)" class="files">
|
||||
店铺 ID: {{ formatTaskGroupShopIds(item) }}
|
||||
</div>
|
||||
<div v-if="item.platform" class="files">
|
||||
平台: {{ item.platform }}
|
||||
@@ -267,10 +315,10 @@
|
||||
完成时间: {{ formatDateTime(item.finishedAt) }}
|
||||
</div>
|
||||
<div class="files">
|
||||
模板结构: {{ formatTemplateSummary(item) }}
|
||||
模板: {{ formatTemplateSummary(item) }}
|
||||
</div>
|
||||
<div v-if="item.error" class="files">
|
||||
错误: {{ item.error }}
|
||||
<div v-if="formatTaskGroupErrors(item)" class="files">
|
||||
错误: {{ formatTaskGroupErrors(item) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-right">
|
||||
@@ -313,18 +361,22 @@ 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,
|
||||
@@ -340,8 +392,11 @@ const COUNTRY_TEMPLATE = ["德国", "英国", "法国", "意大利", "西班牙"
|
||||
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>({
|
||||
@@ -351,32 +406,30 @@ const dashboard = ref<PatrolDeleteDashboardVo>({
|
||||
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 pendingQueue = ref<PatrolDeleteShopQueueItem[]>([]);
|
||||
const activeTaskId = ref<number | null>(null);
|
||||
const activeQueueItem = ref<PatrolDeleteShopQueueItem | null>(null);
|
||||
const queueWorkerRunning = ref(false);
|
||||
const autoQueueEnabled = 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(() =>
|
||||
historyItems.value.filter((item) => !isTaskTerminal(item.taskStatus)),
|
||||
taskRecordItems.value.filter((item) => !isTaskTerminal(item.taskStatus)),
|
||||
);
|
||||
const historySectionItems = computed(() =>
|
||||
historyItems.value.filter((item) => isTaskTerminal(item.taskStatus)),
|
||||
taskRecordItems.value.filter((item) => isTaskTerminal(item.taskStatus)),
|
||||
);
|
||||
const hasQueueWork = computed(
|
||||
() =>
|
||||
queueWorkerRunning.value ||
|
||||
!!activeTaskId.value ||
|
||||
pendingQueue.value.length > 0,
|
||||
!!activeTaskId.value,
|
||||
);
|
||||
|
||||
function uidForStorage() {
|
||||
@@ -401,6 +454,10 @@ 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) {
|
||||
@@ -546,8 +603,65 @@ function formatTemplateSummary(
|
||||
"shopName" | "countrySections" | "cartRatios"
|
||||
>,
|
||||
) {
|
||||
const rows = flattenTemplateRows(record);
|
||||
return `${rows.length} 行状态数据,首行含 ${record.cartRatios?.length || 0} 个购物车比例列`;
|
||||
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) {
|
||||
@@ -569,7 +683,9 @@ function statusClass(status?: string) {
|
||||
}
|
||||
|
||||
function canDownload(item: PatrolDeleteHistoryItem) {
|
||||
return Boolean(item.resultId && (item.fileReady || item.downloadUrl));
|
||||
return taskGroupItems(item).some((row) =>
|
||||
Boolean(row.resultId && (row.fileReady || row.downloadUrl)),
|
||||
);
|
||||
}
|
||||
|
||||
function saveMatchedItems() {
|
||||
@@ -595,9 +711,7 @@ function loadMatchedItems() {
|
||||
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));
|
||||
}
|
||||
@@ -610,23 +724,16 @@ function loadQueueState() {
|
||||
: null;
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as {
|
||||
pendingQueue?: PatrolDeleteShopQueueItem[];
|
||||
activeTaskId?: number | null;
|
||||
activeQueueItem?: PatrolDeleteShopQueueItem | null;
|
||||
};
|
||||
pendingQueue.value = parsed.pendingQueue || [];
|
||||
activeTaskId.value = parsed.activeTaskId ?? null;
|
||||
activeQueueItem.value = parsed.activeQueueItem ?? null;
|
||||
} catch {
|
||||
pendingQueue.value = [];
|
||||
activeTaskId.value = null;
|
||||
activeQueueItem.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearActiveQueueTask() {
|
||||
activeTaskId.value = null;
|
||||
activeQueueItem.value = null;
|
||||
saveQueueState();
|
||||
}
|
||||
|
||||
@@ -636,9 +743,8 @@ function isRecordMissingError(error: unknown) {
|
||||
}
|
||||
|
||||
function removeMatchedRowLocally(row: PatrolDeleteShopQueueItem) {
|
||||
matchedItems.value = matchedItems.value.filter(
|
||||
(item) => rowKeyForMatch(item) !== rowKeyForMatch(row),
|
||||
);
|
||||
const key = rowKeyForMatch(row);
|
||||
matchedItems.value = matchedItems.value.filter((item) => rowKeyForMatch(item) !== key);
|
||||
saveMatchedItems();
|
||||
}
|
||||
|
||||
@@ -650,20 +756,18 @@ function removeHistoryItemLocally(item: PatrolDeleteHistoryItem) {
|
||||
});
|
||||
}
|
||||
|
||||
function mergeQueueItems(
|
||||
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 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();
|
||||
}
|
||||
@@ -705,6 +809,10 @@ function mergeHistoryProgressItems(incoming: PatrolDeleteHistoryItem[]) {
|
||||
historyItems.value = merged;
|
||||
}
|
||||
|
||||
function upsertHistoryItems(incoming: PatrolDeleteHistoryItem[]) {
|
||||
mergeHistoryProgressItems(incoming);
|
||||
}
|
||||
|
||||
async function refreshActiveTaskProgress(taskIds?: number[]) {
|
||||
const ids = Array.from(
|
||||
new Set(
|
||||
@@ -768,6 +876,41 @@ async function confirmAdd() {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -804,11 +947,6 @@ async function runMatch() {
|
||||
const data = await matchPatrolDeleteShops(names);
|
||||
matchedItems.value = mergeMatchedItems(matchedItems.value, data.items || []);
|
||||
saveMatchedItems();
|
||||
if (autoQueueEnabled.value) {
|
||||
pendingQueue.value = mergeQueueItems(pendingQueue.value, matchedRunnableItems.value);
|
||||
saveQueueState();
|
||||
void processQueue();
|
||||
}
|
||||
ElMessage.success(`本次返回 ${data.items?.length || 0} 条匹配结果`);
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "匹配失败");
|
||||
@@ -818,14 +956,8 @@ async function runMatch() {
|
||||
}
|
||||
|
||||
function removeMatchedRow(row: PatrolDeleteShopQueueItem) {
|
||||
if (hasQueueWork.value) {
|
||||
ElMessage.warning("队列执行中,请等待当前串行任务结束后再调整");
|
||||
return;
|
||||
}
|
||||
matchedItems.value = matchedItems.value.filter(
|
||||
(item) => rowKeyForMatch(item) !== rowKeyForMatch(row),
|
||||
);
|
||||
saveMatchedItems();
|
||||
removeMatchedRowLocally(row);
|
||||
ElMessage.success("已从匹配结果移除");
|
||||
}
|
||||
|
||||
function buildTaskItem(item: PatrolDeleteShopQueueItem): PatrolDeleteTaskItem {
|
||||
@@ -842,7 +974,19 @@ function buildTaskItem(item: PatrolDeleteShopQueueItem): PatrolDeleteTaskItem {
|
||||
};
|
||||
}
|
||||
|
||||
function buildQueuePayload(taskId: number, item: PatrolDeleteHistoryItem) {
|
||||
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(),
|
||||
@@ -850,8 +994,9 @@ function buildQueuePayload(taskId: number, item: PatrolDeleteHistoryItem) {
|
||||
taskId,
|
||||
user_id: Number(uidForStorage()) || 0,
|
||||
source: "frontend-vue-patrol-delete",
|
||||
items: [
|
||||
{
|
||||
delete_conditions: deleteConditionsForTask,
|
||||
deleteConditions: deleteConditionsForTask,
|
||||
items: items.map((item) => ({
|
||||
shopName: item.shopName,
|
||||
shopId: item.shopId,
|
||||
platform: item.platform,
|
||||
@@ -859,11 +1004,17 @@ function buildQueuePayload(taskId: number, item: PatrolDeleteHistoryItem) {
|
||||
matched: item.matched,
|
||||
matchStatus: item.matchStatus,
|
||||
matchMessage: item.matchMessage,
|
||||
},
|
||||
],
|
||||
template_rows: flattenTemplateRows(item),
|
||||
country_sections: item.countrySections || [],
|
||||
cart_ratios: item.cartRatios || [],
|
||||
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 || [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -876,8 +1027,10 @@ async function waitForTaskTerminal(taskId: number) {
|
||||
let transientErrorCount = 0;
|
||||
while (true) {
|
||||
if (disposed) return "STOPPED";
|
||||
if (activeTaskId.value !== taskId) return "DELETED";
|
||||
try {
|
||||
await refreshActiveTaskProgress([taskId]);
|
||||
if (activeTaskId.value !== taskId) return "DELETED";
|
||||
if (transientErrorCount > 0) {
|
||||
queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`;
|
||||
}
|
||||
@@ -915,76 +1068,72 @@ async function processQueue() {
|
||||
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;
|
||||
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 nextItem = pendingQueue.value.shift();
|
||||
saveQueueState();
|
||||
if (!nextItem) break;
|
||||
|
||||
const created = await withTransientRetry(
|
||||
() => createPatrolDeleteTask([buildTaskItem(nextItem)]),
|
||||
() => createPatrolDeleteTask(runnable.map(buildTaskItem)),
|
||||
(attempt, maxAttempts) => {
|
||||
queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts})...`;
|
||||
},
|
||||
);
|
||||
const createdItem = created.items?.[0];
|
||||
if (!createdItem?.taskId || !createdItem.resultId) {
|
||||
const createdItems = created.items || [];
|
||||
if (!created.taskId || !createdItems.length || createdItems.some((item) => !item.taskId || !item.resultId)) {
|
||||
throw new Error("后端未返回有效任务标识");
|
||||
}
|
||||
|
||||
activeTaskId.value = created.taskId;
|
||||
activeQueueItem.value = nextItem;
|
||||
saveQueueState();
|
||||
await refreshTaskViews();
|
||||
upsertHistoryItems(createdItems);
|
||||
void loadDashboard();
|
||||
|
||||
const payload = buildQueuePayload(created.taskId, createdItem);
|
||||
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: [
|
||||
{
|
||||
shopName: createdItem.shopName || nextItem.shopName || "",
|
||||
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 || nextItem.shopName,
|
||||
createdItem.shopName,
|
||||
),
|
||||
chunkIndex: 1,
|
||||
chunkTotal: 1,
|
||||
},
|
||||
],
|
||||
})),
|
||||
});
|
||||
removeMatchedRowLocally(nextItem);
|
||||
for (const item of runnable) removeMatchedRowLocally(item);
|
||||
await refreshTaskViews();
|
||||
queuePushResult.value = `任务 ${created.taskId} 推送失败,已自动继续下一个任务`;
|
||||
queuePushResult.value = `任务 ${created.taskId} 推送失败`;
|
||||
clearActiveQueueTask();
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
removeMatchedRowLocally(nextItem);
|
||||
queuePushResult.value =
|
||||
pendingQueue.value.length > 0
|
||||
? `任务 ${created.taskId} 已入队,等待完成后自动继续下一个(剩余 ${pendingQueue.value.length} 条)`
|
||||
: `任务 ${created.taskId} 已入队,等待执行完成`;
|
||||
}
|
||||
for (const item of runnable) removeMatchedRowLocally(item);
|
||||
queuePushResult.value = `任务 ${created.taskId} 已入队,共 ${createdItems.length} 个店铺,等待执行完成`;
|
||||
|
||||
queuePushResult.value = "串行队列已执行完成";
|
||||
const finalStatus = await waitForTaskTerminal(created.taskId);
|
||||
queuePushResult.value = `任务 ${created.taskId} ${finalStatus === "SUCCESS" ? "已完成" : finalStatus === "DELETED" ? "已删除" : "执行失败"}`;
|
||||
clearActiveQueueTask();
|
||||
await refreshTaskViews();
|
||||
ElMessage.success("巡店删除队列已按顺序执行完成");
|
||||
ElMessage.success("巡店删除任务已完成");
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
const message = error instanceof Error ? error.message : "队列执行失败";
|
||||
@@ -1000,27 +1149,28 @@ async function processQueue() {
|
||||
}
|
||||
|
||||
async function pushToPythonQueue() {
|
||||
autoQueueEnabled.value = true;
|
||||
const runnable = matchedRunnableItems.value;
|
||||
if (!runnable.length) {
|
||||
ElMessage.warning("请先匹配可用店铺");
|
||||
return;
|
||||
}
|
||||
pendingQueue.value = mergeQueueItems(pendingQueue.value, runnable);
|
||||
saveQueueState();
|
||||
queuePushResult.value = `已加入 ${runnable.length} 条店铺,开始串行执行`;
|
||||
queuePushResult.value = `开始创建任务,共 ${runnable.length} 个店铺`;
|
||||
await processQueue();
|
||||
}
|
||||
|
||||
async function downloadResult(item: PatrolDeleteHistoryItem) {
|
||||
if (!item.resultId) return;
|
||||
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(item.resultId);
|
||||
const filename = item.outputFilename || `${item.shopName || "result"}.xlsx`;
|
||||
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}`);
|
||||
@@ -1041,7 +1191,8 @@ async function deleteTaskRecord(item: PatrolDeleteHistoryItem) {
|
||||
} else {
|
||||
throw new Error("缺少可删除的任务标识");
|
||||
}
|
||||
await Promise.all([loadCandidates(), refreshTaskViews()]);
|
||||
removeHistoryItemLocally(item);
|
||||
await Promise.allSettled([loadCandidates(), refreshTaskViews()]);
|
||||
ElMessage.success("已删除");
|
||||
} catch (error) {
|
||||
if (isRecordMissingError(error)) {
|
||||
@@ -1060,14 +1211,13 @@ async function deleteTaskRecord(item: PatrolDeleteHistoryItem) {
|
||||
onMounted(async () => {
|
||||
loadMatchedItems();
|
||||
loadQueueState();
|
||||
await Promise.all([loadCandidates(), loadDashboard(), loadHistory()]);
|
||||
await Promise.all([loadCandidates(), loadConditions(), loadDashboard(), loadHistory()]);
|
||||
|
||||
if (activeTaskId.value || pendingQueue.value.length) {
|
||||
autoQueueEnabled.value = true;
|
||||
if (activeTaskId.value) {
|
||||
queuePushResult.value =
|
||||
activeTaskId.value != null
|
||||
? `检测到未完成队列,继续等待任务 ${activeTaskId.value} 完成并自动接续后续店铺`
|
||||
: `检测到未完成队列,继续执行剩余 ${pendingQueue.value.length} 条店铺`;
|
||||
? `检测到未完成任务,继续等待任务 ${activeTaskId.value} 完成`
|
||||
: "";
|
||||
void processQueue();
|
||||
}
|
||||
});
|
||||
@@ -1096,6 +1246,16 @@ onUnmounted(() => {
|
||||
.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; }
|
||||
|
||||
@@ -73,8 +73,7 @@
|
||||
{{ pushing ? '推送中…' : '推送到 Python 队列' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="loading-msg">与删除 ASIN 品牌相同:先创建任务,再按店铺<strong>逐条</strong>入队(每条 <code>data.items</code>
|
||||
仅含一个店铺)。队列串行执行;Python
|
||||
<p class="loading-msg">在搜索结果中禁止显示、详情页面已删除会把匹配店铺合并为一个任务入队,避免同时打开多个紫鸟窗口;其它筛选仍按店铺逐条入队。Python
|
||||
每处理完一店可 POST <code>/api/product-risk-resolve/tasks/{taskId}/result</code>(可多次,每次可只含已完成的店铺);全部完成后任务结束。前端轮询任务状态。
|
||||
</p>
|
||||
|
||||
@@ -1033,6 +1032,53 @@ function nextMatchedQueueItem() {
|
||||
return matchedItems.value.find((item) => item.matched)
|
||||
}
|
||||
|
||||
function shouldBatchProductRiskQueue() {
|
||||
return productRiskListingFilter.value === 'SearchSuppressed' || productRiskListingFilter.value === 'DetailPageRemoved'
|
||||
}
|
||||
|
||||
async function processMatchedBatchQueue(toPush: ProductRiskShopQueueItem[]) {
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.enqueue_json) {
|
||||
ElMessage.error('当前环境未启用 pywebview enqueue_json')
|
||||
return
|
||||
}
|
||||
const created = await createProductRiskTaskWithRetry(toPush)
|
||||
const taskId = created.taskId
|
||||
taskSnapshots.value = {
|
||||
...taskSnapshots.value,
|
||||
[taskId]: {
|
||||
task: { id: taskId, status: 'RUNNING' },
|
||||
items: created.items,
|
||||
},
|
||||
}
|
||||
saveTaskSnapshotsToStorage()
|
||||
addPollingTask(taskId)
|
||||
ensurePolling(true)
|
||||
const payload = {
|
||||
type: 'product-risk-resolve-run',
|
||||
ts: Date.now(),
|
||||
data: {
|
||||
taskId,
|
||||
items: toPush,
|
||||
country_codes: [...orderedCountryCodes.value],
|
||||
risk_listing_filter: productRiskListingFilter.value,
|
||||
},
|
||||
}
|
||||
queuePayloadText.value = JSON.stringify(payload, null, 2)
|
||||
const pushResult = await api.enqueue_json(payload)
|
||||
if (!pushResult?.success) {
|
||||
removePollingTask(taskId)
|
||||
throw new Error('任务 ' + taskId + ' 推送失败:' + (pushResult?.error || '未知错误'))
|
||||
}
|
||||
removeMatchedRowsLocally(toPush)
|
||||
queuePushResult.value = '任务 ' + taskId + ' 已入队,共 ' + toPush.length + ' 个店铺,等待执行完成...'
|
||||
const finalStatus = await waitForTaskTerminal(taskId)
|
||||
queuePushResult.value = '任务 ' + taskId + (finalStatus === 'SUCCESS' ? ' 已完成' : ' 执行失败')
|
||||
if (finalStatus !== 'SUCCESS') {
|
||||
throw new Error(queuePushResult.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function processMatchedQueue() {
|
||||
if (disposed || queueWorkerRunning.value) {
|
||||
return
|
||||
@@ -1057,6 +1103,14 @@ async function processMatchedQueue() {
|
||||
let successCount = 0
|
||||
let failedCount = 0
|
||||
try {
|
||||
if (shouldBatchProductRiskQueue()) {
|
||||
await processMatchedBatchQueue(toPush)
|
||||
ElMessage.success('店铺推送已完成:成功 ' + toPush.length + ' 条,失败 0 条')
|
||||
await loadHistory()
|
||||
ensurePolling(true)
|
||||
return
|
||||
}
|
||||
|
||||
let index = 0
|
||||
while (!disposed && autoQueueEnabled.value) {
|
||||
const item = nextMatchedQueueItem()
|
||||
|
||||
@@ -1008,6 +1008,12 @@ export type PatrolDeleteCandidateVo = ProductRiskCandidateVo;
|
||||
export type PatrolDeleteDashboardVo = ProductRiskDashboardVo;
|
||||
export type PatrolDeleteShopQueueItem = ProductRiskShopQueueItem;
|
||||
|
||||
export interface PatrolDeleteConditionVo {
|
||||
id: number;
|
||||
conditionText: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface PatrolDeleteCountryMetricRow {
|
||||
status: string;
|
||||
quantity: string;
|
||||
@@ -1110,6 +1116,40 @@ export function deletePatrolDeleteCandidate(id: number) {
|
||||
);
|
||||
}
|
||||
|
||||
export function listPatrolDeleteConditions() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<PatrolDeleteConditionVo[]>>(
|
||||
`${JAVA_API_PREFIX}/patrol-delete/conditions`,
|
||||
{
|
||||
params: { user_id: getCurrentUserId() },
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function addPatrolDeleteCondition(conditionText: string) {
|
||||
return unwrapJavaResponse(
|
||||
post<
|
||||
JavaApiResponse<PatrolDeleteConditionVo>,
|
||||
{ user_id: number; condition_text: string }
|
||||
>(`${JAVA_API_PREFIX}/patrol-delete/conditions`, {
|
||||
user_id: getCurrentUserId(),
|
||||
condition_text: conditionText,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function deletePatrolDeleteCondition(id: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(
|
||||
`${JAVA_API_PREFIX}/patrol-delete/conditions/${id}`,
|
||||
{
|
||||
params: { user_id: getCurrentUserId() },
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function matchPatrolDeleteShops(shopNames: string[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<
|
||||
@@ -1451,6 +1491,10 @@ export interface AppearancePatentHistoryItem {
|
||||
fileStatus?: string;
|
||||
fileError?: string;
|
||||
fileReady?: boolean;
|
||||
fileProgressPercent?: number;
|
||||
fileProgressCurrent?: number;
|
||||
fileProgressTotal?: number;
|
||||
fileProgressMessage?: string;
|
||||
taskStatus?: string;
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
|
||||
Reference in New Issue
Block a user