1323 lines
47 KiB
Vue
1323 lines
47 KiB
Vue
<template>
|
||
<div class="page-shell module-page">
|
||
<BrandTopBar active="patrol-delete" />
|
||
|
||
<div class="main-content">
|
||
<aside class="left-panel">
|
||
<div class="section-title">店铺输入</div>
|
||
<div class="input-zone">
|
||
<div class="hint">
|
||
左侧负责录入店铺并加入备选区,点击“匹配店铺”只会生成匹配结果;确认后需手动点击“推送到 Python 队列”才会创建并执行任务。
|
||
</div>
|
||
<div class="input-row">
|
||
<el-input
|
||
v-model="shopInput"
|
||
clearable
|
||
placeholder="请输入店铺名后回车或点击添加"
|
||
@keyup.enter="confirmAdd"
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="opt-btn"
|
||
:disabled="adding"
|
||
@click="confirmAdd"
|
||
>
|
||
{{ adding ? "添加中..." : "添加" }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section-title">备选区</div>
|
||
<div v-if="!candidates.length" class="empty-candidates">
|
||
暂无备选店铺,请先输入并添加
|
||
</div>
|
||
<div v-else class="candidate-table-scroll">
|
||
<el-table
|
||
:data="candidates"
|
||
row-key="id"
|
||
height="260"
|
||
class="candidate-table"
|
||
@selection-change="onSelectionChange"
|
||
>
|
||
<el-table-column type="selection" width="42" />
|
||
<el-table-column
|
||
prop="shop_name"
|
||
label="店铺名"
|
||
min-width="140"
|
||
show-overflow-tooltip
|
||
/>
|
||
<el-table-column label="操作" width="72" align="center">
|
||
<template #default="{ row }">
|
||
<button
|
||
type="button"
|
||
class="link-danger"
|
||
@click="removeCandidate(row.id)"
|
||
>
|
||
删除
|
||
</button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
|
||
<div class="section-title condition-title">
|
||
<span>删除条件</span>
|
||
<span class="condition-title-hint">
|
||
注意删除条件的描述需要和页面描述一致
|
||
</span>
|
||
</div>
|
||
<div class="condition-panel">
|
||
<div class="condition-input-row">
|
||
<el-input
|
||
v-model="conditionInput"
|
||
clearable
|
||
placeholder="请输入删除条件"
|
||
@keyup.enter="confirmAddCondition"
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="opt-btn"
|
||
:disabled="addingCondition"
|
||
@click="confirmAddCondition"
|
||
>
|
||
{{ addingCondition ? "保存中..." : "保存" }}
|
||
</button>
|
||
</div>
|
||
<div v-if="!deleteConditions.length" class="empty-conditions">
|
||
暂无删除条件,保存后可在推送任务时多选携带。
|
||
</div>
|
||
<ul v-else class="condition-list">
|
||
<li
|
||
v-for="condition in deleteConditions"
|
||
:key="condition.id"
|
||
class="condition-item"
|
||
>
|
||
<label class="condition-check">
|
||
<input
|
||
v-model="selectedConditionIds"
|
||
type="checkbox"
|
||
:value="condition.id"
|
||
/>
|
||
<span :title="condition.conditionText">
|
||
{{ condition.conditionText }}
|
||
</span>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
class="link-danger"
|
||
@click="removeCondition(condition.id)"
|
||
>
|
||
删除
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="run-row">
|
||
<button
|
||
type="button"
|
||
class="btn-run"
|
||
:disabled="matching || hasQueueWork"
|
||
@click="runMatch"
|
||
>
|
||
{{ matching ? "匹配中..." : "匹配店铺" }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn-run btn-queue"
|
||
:disabled="pushing || !matchedRunnableItems.length"
|
||
@click="pushToPythonQueue"
|
||
>
|
||
{{ pushing ? "任务执行中..." : "推送到 Python 队列" }}
|
||
</button>
|
||
</div>
|
||
|
||
<p class="loading-msg">
|
||
当前页面会把匹配结果合并为一个巡店删除任务推送;多个店铺会在同一个任务和同一个结果文件中汇总。
|
||
</p>
|
||
|
||
<div v-if="queuePushResult" class="queue-debug-card">
|
||
<div class="section-title queue-debug-title">队列状态</div>
|
||
<div class="queue-debug-line">{{ queuePushResult }}</div>
|
||
<pre v-if="queuePayloadText" class="queue-debug-payload">{{
|
||
queuePayloadText
|
||
}}</pre>
|
||
</div>
|
||
</aside>
|
||
|
||
<section class="right-panel">
|
||
<div class="panel-header">匹配与任务</div>
|
||
<div class="task-list-wrap">
|
||
<div class="clean-result-summary">
|
||
<div class="summary-card">
|
||
<span class="summary-label">备选店铺</span>
|
||
<strong>{{ dashboard.candidateCount }}</strong>
|
||
</div>
|
||
<div class="summary-card">
|
||
<span class="summary-label">已处理任务</span>
|
||
<strong>{{ dashboard.processedTaskCount }}</strong>
|
||
</div>
|
||
<div class="summary-card">
|
||
<span class="summary-label">成功任务</span>
|
||
<strong>{{ dashboard.successTaskCount }}</strong>
|
||
</div>
|
||
<div class="summary-card">
|
||
<span class="summary-label">失败任务</span>
|
||
<strong>{{ dashboard.failedTaskCount }}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="subsection-title">匹配结果</div>
|
||
<div v-if="!matchedItems.length" class="empty-tasks narrow">
|
||
完成“匹配店铺”后,这里会展示匹配结果。
|
||
</div>
|
||
<el-table
|
||
v-else
|
||
:data="matchedItems"
|
||
:row-key="rowKeyForMatch"
|
||
:highlight-current-row="false"
|
||
class="result-table match-table"
|
||
>
|
||
<el-table-column prop="shopName" label="店铺名" min-width="120" />
|
||
<el-table-column label="匹配" width="72" align="center">
|
||
<template #default="{ row }">
|
||
<span :class="row.matched ? 'ok' : 'fail'">
|
||
{{ row.matched ? "是" : "否" }}
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
prop="shopId"
|
||
label="店铺 ID"
|
||
min-width="120"
|
||
show-overflow-tooltip
|
||
/>
|
||
<el-table-column
|
||
prop="platform"
|
||
label="平台"
|
||
width="88"
|
||
show-overflow-tooltip
|
||
/>
|
||
<el-table-column
|
||
prop="companyName"
|
||
label="公司"
|
||
min-width="120"
|
||
show-overflow-tooltip
|
||
/>
|
||
<el-table-column label="状态" width="110" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
{{ formatMatchStatus(row.matchStatus) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
{{ formatMatchRemark(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="72" align="center">
|
||
<template #default="{ row }">
|
||
<button
|
||
type="button"
|
||
class="link-danger"
|
||
@click="removeMatchedRow(row)"
|
||
>
|
||
删除
|
||
</button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<div class="result-list-wrap">
|
||
<div class="result-list-header">
|
||
<span>任务记录</span>
|
||
</div>
|
||
|
||
<div
|
||
v-if="!currentSectionItems.length && !historySectionItems.length"
|
||
class="empty-tasks"
|
||
>
|
||
暂无任务记录。推送到 Python 队列后,这里会展示当前任务和历史任务。
|
||
</div>
|
||
|
||
<template v-else>
|
||
<div v-if="currentSectionItems.length" class="result-subsection">
|
||
<div class="result-subsection-title">当前任务</div>
|
||
<ul class="task-list">
|
||
<li
|
||
v-for="item in currentSectionItems"
|
||
:key="taskGroupKey(item)"
|
||
class="task-item"
|
||
>
|
||
<div class="left split-result-main">
|
||
<span class="id" :title="formatTaskGroupShopNames(item)">
|
||
{{ formatTaskGroupShopNames(item) }}
|
||
</span>
|
||
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
|
||
<div v-if="formatTaskGroupResultIds(item)" class="files">
|
||
结果 ID: {{ formatTaskGroupResultIds(item) }}
|
||
</div>
|
||
<div v-if="formatTaskGroupShopIds(item)" class="files">
|
||
店铺 ID: {{ formatTaskGroupShopIds(item) }}
|
||
</div>
|
||
<div v-if="item.platform" class="files">
|
||
平台: {{ item.platform }}
|
||
</div>
|
||
<div class="files">
|
||
创建时间: {{ formatDateTime(item.createdAt) }}
|
||
</div>
|
||
<div class="files">
|
||
模板: {{ formatTemplateSummary(item) }}
|
||
</div>
|
||
<div v-if="formatTaskGroupErrors(item)" class="files">
|
||
错误: {{ formatTaskGroupErrors(item) }}
|
||
</div>
|
||
</div>
|
||
<div class="task-right">
|
||
<span
|
||
class="status"
|
||
:class="statusClass(item.taskStatus)"
|
||
>
|
||
{{ statusText(item.taskStatus) }}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
class="btn-delete"
|
||
@click="deleteTaskRecord(item)"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div v-if="historySectionItems.length" class="result-subsection">
|
||
<div class="result-subsection-title">历史任务</div>
|
||
<ul class="task-list">
|
||
<li
|
||
v-for="item in historySectionItems"
|
||
:key="taskGroupKey(item)"
|
||
class="task-item"
|
||
>
|
||
<div class="left split-result-main">
|
||
<span class="id" :title="formatTaskGroupShopNames(item)">
|
||
{{ formatTaskGroupShopNames(item) }}
|
||
</span>
|
||
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
|
||
<div v-if="formatTaskGroupResultIds(item)" class="files">
|
||
结果 ID: {{ formatTaskGroupResultIds(item) }}
|
||
</div>
|
||
<div v-if="formatTaskGroupShopIds(item)" class="files">
|
||
店铺 ID: {{ formatTaskGroupShopIds(item) }}
|
||
</div>
|
||
<div v-if="item.platform" class="files">
|
||
平台: {{ item.platform }}
|
||
</div>
|
||
<div class="files">
|
||
创建时间: {{ formatDateTime(item.createdAt) }}
|
||
</div>
|
||
<div v-if="item.finishedAt" class="files">
|
||
完成时间: {{ formatDateTime(item.finishedAt) }}
|
||
</div>
|
||
<div class="files">
|
||
模板: {{ formatTemplateSummary(item) }}
|
||
</div>
|
||
<div v-if="formatTaskGroupErrors(item)" class="files">
|
||
错误: {{ formatTaskGroupErrors(item) }}
|
||
</div>
|
||
</div>
|
||
<div class="task-right">
|
||
<span
|
||
class="status"
|
||
:class="statusClass(item.taskStatus)"
|
||
>
|
||
{{ statusText(item.taskStatus) }}
|
||
</span>
|
||
<button
|
||
v-if="canDownload(item)"
|
||
type="button"
|
||
class="archive"
|
||
@click="downloadResult(item)"
|
||
>
|
||
下载结果
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn-delete"
|
||
@click="deleteTaskRecord(item)"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||
import { ElMessage } from "element-plus";
|
||
import BrandTopBar from "@/pages/brand/components/BrandTopBar.vue";
|
||
import {
|
||
addPatrolDeleteCandidate,
|
||
addPatrolDeleteCondition,
|
||
createPatrolDeleteTask,
|
||
deletePatrolDeleteCandidate,
|
||
deletePatrolDeleteCondition,
|
||
deletePatrolDeleteHistory,
|
||
deletePatrolDeleteTask,
|
||
getPatrolDeleteDashboard,
|
||
getPatrolDeleteHistory,
|
||
getPatrolDeleteTaskProgressBatch,
|
||
getPatrolDeleteResultDownloadUrl,
|
||
listPatrolDeleteConditions,
|
||
listPatrolDeleteCandidates,
|
||
matchPatrolDeleteShops,
|
||
submitPatrolDeleteTaskResult,
|
||
type PatrolDeleteCandidateVo,
|
||
type PatrolDeleteConditionVo,
|
||
type PatrolDeleteCartRatio,
|
||
type PatrolDeleteCountrySection,
|
||
type PatrolDeleteDashboardVo,
|
||
type PatrolDeleteHistoryItem,
|
||
type PatrolDeleteShopQueueItem,
|
||
type PatrolDeleteTaskItem,
|
||
} from "@/shared/api/java-modules";
|
||
import { getPywebviewApi } from "@/shared/bridges/pywebview";
|
||
import { getTaskPollIntervalMs } from "@/shared/task-progress-config";
|
||
import { createCategorizedTimers } from "@/shared/utils/categorized-timers";
|
||
|
||
const COUNTRY_TEMPLATE = ["德国", "英国", "法国", "意大利", "西班牙"] as const;
|
||
const MAX_TRANSIENT_ERRORS = 30;
|
||
|
||
const shopInput = ref("");
|
||
const conditionInput = ref("");
|
||
const candidates = ref<PatrolDeleteCandidateVo[]>([]);
|
||
const selectedCandidates = ref<PatrolDeleteCandidateVo[]>([]);
|
||
const deleteConditions = ref<PatrolDeleteConditionVo[]>([]);
|
||
const selectedConditionIds = ref<number[]>([]);
|
||
const matchedItems = ref<PatrolDeleteShopQueueItem[]>([]);
|
||
const historyItems = ref<PatrolDeleteHistoryItem[]>([]);
|
||
const dashboard = ref<PatrolDeleteDashboardVo>({
|
||
candidateCount: 0,
|
||
processedTaskCount: 0,
|
||
successTaskCount: 0,
|
||
failedTaskCount: 0,
|
||
});
|
||
const adding = ref(false);
|
||
const addingCondition = ref(false);
|
||
const matching = ref(false);
|
||
const pushing = ref(false);
|
||
const queuePushResult = ref("");
|
||
const queuePayloadText = ref("");
|
||
const activeTaskId = ref<number | null>(null);
|
||
const queueWorkerRunning = ref(false);
|
||
let historyPollTimer: number | null = null;
|
||
const timers = createCategorizedTimers("patrol-delete");
|
||
|
||
const matchedRunnableItems = computed(() =>
|
||
matchedItems.value.filter((item) => item.matched),
|
||
);
|
||
const taskRecordItems = computed(() => groupHistoryItemsByTask(historyItems.value));
|
||
const currentSectionItems = computed(() =>
|
||
taskRecordItems.value.filter((item) => !isTaskTerminal(item.taskStatus)),
|
||
);
|
||
const historySectionItems = computed(() =>
|
||
taskRecordItems.value.filter((item) => isTaskTerminal(item.taskStatus)),
|
||
);
|
||
const hasQueueWork = computed(
|
||
() =>
|
||
queueWorkerRunning.value ||
|
||
!!activeTaskId.value,
|
||
);
|
||
|
||
function uidForStorage() {
|
||
return typeof window !== "undefined"
|
||
? window.localStorage.getItem("uid") || "0"
|
||
: "0";
|
||
}
|
||
|
||
function matchedStorageKey() {
|
||
return `patrol-delete:matched:${uidForStorage()}`;
|
||
}
|
||
|
||
function queueStateStorageKey() {
|
||
return `patrol-delete:queue-state:${uidForStorage()}`;
|
||
}
|
||
|
||
function rowKeyForMatch(row: PatrolDeleteShopQueueItem) {
|
||
return `${(row.shopName || "").trim()}::${row.shopId || ""}`;
|
||
}
|
||
|
||
function historyItemKey(item: PatrolDeleteHistoryItem) {
|
||
return `${item.taskId ?? 0}:${item.resultId ?? 0}`;
|
||
}
|
||
|
||
function taskGroupKey(item: PatrolDeleteHistoryItem) {
|
||
return `task:${item.taskId ?? item.resultId ?? 0}`;
|
||
}
|
||
|
||
let disposed = false;
|
||
|
||
function sleep(ms: number) {
|
||
if (disposed) return Promise.resolve();
|
||
return timers.sleep("queue-wait", ms);
|
||
}
|
||
|
||
function clearSleepTimers() {
|
||
timers.clearCategory("queue-wait");
|
||
}
|
||
|
||
function isTransientBackendError(error: unknown) {
|
||
const message =
|
||
error instanceof Error ? error.message.toLowerCase() : String(error || "").toLowerCase();
|
||
return (
|
||
message.includes("network error") ||
|
||
message.includes("failed to fetch") ||
|
||
message.includes("timeout") ||
|
||
message.includes("503") ||
|
||
message.includes("502") ||
|
||
message.includes("504") ||
|
||
message.includes("500") ||
|
||
message.includes("load failed")
|
||
);
|
||
}
|
||
|
||
async function withTransientRetry<T>(
|
||
action: () => Promise<T>,
|
||
onRetry?: (attempt: number, maxAttempts: number) => void,
|
||
maxAttempts = 20,
|
||
) {
|
||
let attempt = 0;
|
||
while (true) {
|
||
if (disposed) throw new Error("component disposed");
|
||
try {
|
||
return await action();
|
||
} catch (error) {
|
||
if (!isTransientBackendError(error) || attempt >= maxAttempts - 1) {
|
||
throw error;
|
||
}
|
||
attempt += 1;
|
||
onRetry?.(attempt, maxAttempts);
|
||
await sleep(getTaskPollIntervalMs());
|
||
}
|
||
}
|
||
}
|
||
|
||
function createSubmissionId(taskId: number, shopName?: string) {
|
||
return `patrol-delete:${taskId}:${shopName || "shop"}:${Date.now()}`;
|
||
}
|
||
|
||
function formatDateTime(value?: string) {
|
||
if (!value) return "-";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return value;
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||
const day = String(date.getDate()).padStart(2, "0");
|
||
const hours = String(date.getHours()).padStart(2, "0");
|
||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||
}
|
||
|
||
function formatMatchStatus(status?: string) {
|
||
const value = (status || "").trim();
|
||
const map: Record<string, string> = {
|
||
MATCHED: "已匹配",
|
||
PENDING: "待匹配",
|
||
CONFLICT: "需人工确认",
|
||
INDEX_STALE: "索引过期",
|
||
};
|
||
return map[value] || value || "-";
|
||
}
|
||
|
||
function formatMatchRemark(row: PatrolDeleteShopQueueItem) {
|
||
const message = (row.matchMessage || "").trim();
|
||
if (message) return message;
|
||
if (row.matched && row.matchStatus === "MATCHED") {
|
||
return "索引已命中,可推送到 Python 队列";
|
||
}
|
||
if (row.matched) {
|
||
return "已命中索引,请结合状态列确认";
|
||
}
|
||
return "未命中或未就绪,请检查店铺名";
|
||
}
|
||
|
||
function buildTemplateCountrySections(): PatrolDeleteCountrySection[] {
|
||
return COUNTRY_TEMPLATE.map((country) => ({
|
||
country,
|
||
rows: [
|
||
{
|
||
status: "全部",
|
||
quantity: "",
|
||
deleteQuantity: "",
|
||
processStatus: "",
|
||
},
|
||
],
|
||
}));
|
||
}
|
||
|
||
function buildTemplateCartRatios(): PatrolDeleteCartRatio[] {
|
||
return COUNTRY_TEMPLATE.map((country) => ({
|
||
country,
|
||
ratio: "",
|
||
}));
|
||
}
|
||
|
||
function flattenTemplateRows(
|
||
record: Pick<
|
||
PatrolDeleteHistoryItem,
|
||
"shopName" | "countrySections" | "cartRatios"
|
||
>,
|
||
) {
|
||
const sections = record.countrySections || [];
|
||
const ratios = record.cartRatios || [];
|
||
const rowCount = Math.max(...sections.map((section) => section.rows.length), 1);
|
||
return Array.from({ length: rowCount }, (_, rowIndex) => ({
|
||
shopName: rowIndex === 0 ? record.shopName : "",
|
||
countries: sections.map((section) => {
|
||
const row = section.rows[rowIndex];
|
||
return {
|
||
country: section.country,
|
||
status: row?.status || "",
|
||
quantity: row?.quantity || "",
|
||
deleteQuantity: row?.deleteQuantity || "",
|
||
processStatus: row?.processStatus || "",
|
||
};
|
||
}),
|
||
cartRatios:
|
||
rowIndex === 0
|
||
? ratios.map((item) => ({
|
||
country: item.country,
|
||
ratio: item.ratio,
|
||
}))
|
||
: [],
|
||
}));
|
||
}
|
||
|
||
function formatTemplateSummary(
|
||
record: Pick<
|
||
PatrolDeleteHistoryItem,
|
||
"shopName" | "countrySections" | "cartRatios"
|
||
>,
|
||
) {
|
||
const countries = new Set([
|
||
...(record.countrySections || []).map((item) => item.country).filter(Boolean),
|
||
...(record.cartRatios || []).map((item) => item.country).filter(Boolean),
|
||
]);
|
||
return `${countries.size || 0} 个国家,含状态数据和购物车比例`;
|
||
}
|
||
|
||
function uniqueJoined(values: Array<string | number | undefined | null>) {
|
||
return Array.from(
|
||
new Set(
|
||
values
|
||
.map((value) => (value == null ? "" : String(value).trim()))
|
||
.filter(Boolean),
|
||
),
|
||
).join("、");
|
||
}
|
||
|
||
function groupHistoryItemsByTask(items: PatrolDeleteHistoryItem[]) {
|
||
const groups = new Map<string, PatrolDeleteHistoryItem[]>();
|
||
for (const item of items || []) {
|
||
const key = String(item.taskId ?? `result:${item.resultId ?? Math.random()}`);
|
||
const group = groups.get(key) || [];
|
||
group.push(item);
|
||
groups.set(key, group);
|
||
}
|
||
return Array.from(groups.values()).map((group) => {
|
||
const base =
|
||
group.find((item) => canDownload(item)) ||
|
||
group.find((item) => item.taskStatus) ||
|
||
group[0];
|
||
const errors = uniqueJoined(group.map((item) => item.error));
|
||
return {
|
||
...base,
|
||
shopName: uniqueJoined(group.map((item) => item.shopName)) || base.shopName,
|
||
shopId: uniqueJoined(group.map((item) => item.shopId)) || base.shopId,
|
||
error: errors || base.error,
|
||
__groupItems: group,
|
||
} as PatrolDeleteHistoryItem & { __groupItems?: PatrolDeleteHistoryItem[] };
|
||
});
|
||
}
|
||
|
||
function taskGroupItems(item: PatrolDeleteHistoryItem) {
|
||
return ((item as PatrolDeleteHistoryItem & { __groupItems?: PatrolDeleteHistoryItem[] }).__groupItems || [item]);
|
||
}
|
||
|
||
function formatTaskGroupShopNames(item: PatrolDeleteHistoryItem) {
|
||
return uniqueJoined(taskGroupItems(item).map((row) => row.shopName)) || "-";
|
||
}
|
||
|
||
function formatTaskGroupResultIds(item: PatrolDeleteHistoryItem) {
|
||
return uniqueJoined(taskGroupItems(item).map((row) => row.resultId));
|
||
}
|
||
|
||
function formatTaskGroupShopIds(item: PatrolDeleteHistoryItem) {
|
||
return uniqueJoined(taskGroupItems(item).map((row) => row.shopId));
|
||
}
|
||
|
||
function formatTaskGroupErrors(item: PatrolDeleteHistoryItem) {
|
||
return uniqueJoined(taskGroupItems(item).map((row) => row.error));
|
||
}
|
||
|
||
function isTaskTerminal(status?: string) {
|
||
return status === "SUCCESS" || status === "FAILED" || status === "COMPLETED";
|
||
}
|
||
|
||
function statusText(status?: string) {
|
||
if (status === "SUCCESS" || status === "COMPLETED") return "已完成";
|
||
if (status === "FAILED") return "失败";
|
||
return "执行中";
|
||
}
|
||
|
||
function statusClass(status?: string) {
|
||
return status === "SUCCESS" || status === "COMPLETED"
|
||
? "success"
|
||
: status === "FAILED"
|
||
? "failed"
|
||
: "running";
|
||
}
|
||
|
||
function canDownload(item: PatrolDeleteHistoryItem) {
|
||
return taskGroupItems(item).some((row) =>
|
||
Boolean(row.resultId && (row.fileReady || row.downloadUrl)),
|
||
);
|
||
}
|
||
|
||
function saveMatchedItems() {
|
||
if (typeof window === "undefined") return;
|
||
window.localStorage.setItem(
|
||
matchedStorageKey(),
|
||
JSON.stringify(matchedItems.value),
|
||
);
|
||
}
|
||
|
||
function loadMatchedItems() {
|
||
try {
|
||
const raw =
|
||
typeof window !== "undefined"
|
||
? window.localStorage.getItem(matchedStorageKey())
|
||
: null;
|
||
matchedItems.value = raw ? JSON.parse(raw) : [];
|
||
} catch {
|
||
matchedItems.value = [];
|
||
}
|
||
}
|
||
|
||
function saveQueueState() {
|
||
if (typeof window === "undefined") return;
|
||
const payload = {
|
||
activeTaskId: activeTaskId.value,
|
||
};
|
||
window.localStorage.setItem(queueStateStorageKey(), JSON.stringify(payload));
|
||
}
|
||
|
||
function loadQueueState() {
|
||
try {
|
||
const raw =
|
||
typeof window !== "undefined"
|
||
? window.localStorage.getItem(queueStateStorageKey())
|
||
: null;
|
||
if (!raw) return;
|
||
const parsed = JSON.parse(raw) as {
|
||
activeTaskId?: number | null;
|
||
};
|
||
activeTaskId.value = parsed.activeTaskId ?? null;
|
||
} catch {
|
||
activeTaskId.value = null;
|
||
}
|
||
}
|
||
|
||
function clearActiveQueueTask() {
|
||
activeTaskId.value = null;
|
||
saveQueueState();
|
||
}
|
||
|
||
function isRecordMissingError(error: unknown) {
|
||
const message = error instanceof Error ? error.message : String(error || "");
|
||
return /记录不存在|任务不存在|不存在|已删除|not\s*found|404/i.test(message);
|
||
}
|
||
|
||
function removeMatchedRowLocally(row: PatrolDeleteShopQueueItem) {
|
||
const key = rowKeyForMatch(row);
|
||
matchedItems.value = matchedItems.value.filter((item) => rowKeyForMatch(item) !== key);
|
||
saveMatchedItems();
|
||
}
|
||
|
||
function removeHistoryItemLocally(item: PatrolDeleteHistoryItem) {
|
||
historyItems.value = historyItems.value.filter((row) => {
|
||
if (item.resultId != null && row.resultId === item.resultId) return false;
|
||
if (item.taskId != null && row.taskId === item.taskId) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
async function loadCandidates() {
|
||
candidates.value = await listPatrolDeleteCandidates();
|
||
}
|
||
|
||
async function loadConditions() {
|
||
deleteConditions.value = await listPatrolDeleteConditions();
|
||
const existingIds = new Set(deleteConditions.value.map((item) => item.id));
|
||
selectedConditionIds.value = selectedConditionIds.value.filter((id) =>
|
||
existingIds.has(id),
|
||
);
|
||
}
|
||
|
||
async function loadDashboard() {
|
||
dashboard.value = await getPatrolDeleteDashboard();
|
||
}
|
||
|
||
async function loadHistory() {
|
||
const data = await getPatrolDeleteHistory();
|
||
historyItems.value = data.items || [];
|
||
}
|
||
|
||
async function refreshTaskViews() {
|
||
await Promise.all([loadDashboard(), loadHistory()]);
|
||
}
|
||
|
||
function mergeHistoryProgressItems(incoming: PatrolDeleteHistoryItem[]) {
|
||
if (!incoming.length) {
|
||
return;
|
||
}
|
||
const incomingMap = new Map(
|
||
incoming.map((item) => [historyItemKey(item), item] as const),
|
||
);
|
||
const merged = historyItems.value.map((item) => {
|
||
const next = incomingMap.get(historyItemKey(item));
|
||
if (!next) {
|
||
return item;
|
||
}
|
||
return {
|
||
...item,
|
||
...next,
|
||
countrySections: next.countrySections || item.countrySections || [],
|
||
cartRatios: next.cartRatios || item.cartRatios || [],
|
||
};
|
||
});
|
||
for (const item of incoming) {
|
||
const key = historyItemKey(item);
|
||
if (!merged.some((current) => historyItemKey(current) === key)) {
|
||
merged.push(item);
|
||
}
|
||
}
|
||
historyItems.value = merged;
|
||
}
|
||
|
||
function upsertHistoryItems(incoming: PatrolDeleteHistoryItem[]) {
|
||
mergeHistoryProgressItems(incoming);
|
||
}
|
||
|
||
async function refreshActiveTaskProgress(taskIds?: number[]) {
|
||
const ids = Array.from(
|
||
new Set(
|
||
(taskIds && taskIds.length
|
||
? taskIds
|
||
: currentSectionItems.value.map((item) => item.taskId ?? 0)
|
||
).filter((taskId) => Number.isFinite(taskId) && taskId > 0),
|
||
),
|
||
);
|
||
if (!ids.length) {
|
||
return [];
|
||
}
|
||
const batch = await getPatrolDeleteTaskProgressBatch(ids);
|
||
mergeHistoryProgressItems(batch.items || []);
|
||
if ((batch.missingTaskIds || []).length) {
|
||
await loadHistory();
|
||
}
|
||
return batch.missingTaskIds || [];
|
||
}
|
||
|
||
function startHistoryPolling() {
|
||
stopHistoryPolling();
|
||
if (disposed || !hasQueueWork.value) return;
|
||
const run = async () => {
|
||
historyPollTimer = null;
|
||
if (disposed || !hasQueueWork.value) return;
|
||
await refreshActiveTaskProgress();
|
||
if (!disposed && hasQueueWork.value) {
|
||
historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
|
||
}
|
||
};
|
||
historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
|
||
}
|
||
|
||
function stopHistoryPolling() {
|
||
if (historyPollTimer) {
|
||
timers.clearTimer("history-poll", historyPollTimer);
|
||
historyPollTimer = null;
|
||
}
|
||
}
|
||
|
||
function onSelectionChange(rows: PatrolDeleteCandidateVo[]) {
|
||
selectedCandidates.value = rows;
|
||
}
|
||
|
||
async function confirmAdd() {
|
||
const name = shopInput.value.trim();
|
||
if (!name) {
|
||
ElMessage.warning("请输入店铺名");
|
||
return;
|
||
}
|
||
adding.value = true;
|
||
try {
|
||
await addPatrolDeleteCandidate(name);
|
||
shopInput.value = "";
|
||
await Promise.all([loadCandidates(), loadDashboard()]);
|
||
ElMessage.success("已加入备选区");
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : "添加失败");
|
||
} finally {
|
||
adding.value = false;
|
||
}
|
||
}
|
||
|
||
async function confirmAddCondition() {
|
||
const text = conditionInput.value.trim();
|
||
if (!text) {
|
||
ElMessage.warning("请输入删除条件");
|
||
return;
|
||
}
|
||
addingCondition.value = true;
|
||
try {
|
||
const saved = await addPatrolDeleteCondition(text);
|
||
conditionInput.value = "";
|
||
await loadConditions();
|
||
if (saved?.id && !selectedConditionIds.value.includes(saved.id)) {
|
||
selectedConditionIds.value = [...selectedConditionIds.value, saved.id];
|
||
}
|
||
ElMessage.success("删除条件已保存");
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : "保存失败");
|
||
} finally {
|
||
addingCondition.value = false;
|
||
}
|
||
}
|
||
|
||
async function removeCondition(id: number) {
|
||
try {
|
||
await deletePatrolDeleteCondition(id);
|
||
selectedConditionIds.value = selectedConditionIds.value.filter(
|
||
(item) => item !== id,
|
||
);
|
||
await loadConditions();
|
||
ElMessage.success("删除条件已删除");
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : "删除失败");
|
||
}
|
||
}
|
||
|
||
async function removeCandidate(id: number) {
|
||
try {
|
||
await deletePatrolDeleteCandidate(id);
|
||
selectedCandidates.value = selectedCandidates.value.filter(
|
||
(item) => item.id !== id,
|
||
);
|
||
await Promise.all([loadCandidates(), loadDashboard()]);
|
||
ElMessage.success("已删除");
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : "删除失败");
|
||
}
|
||
}
|
||
|
||
function mergeMatchedItems(
|
||
base: PatrolDeleteShopQueueItem[],
|
||
incoming: PatrolDeleteShopQueueItem[],
|
||
) {
|
||
const map = new Map<string, PatrolDeleteShopQueueItem>();
|
||
for (const item of base) map.set(rowKeyForMatch(item), item);
|
||
for (const item of incoming) map.set(rowKeyForMatch(item), item);
|
||
return Array.from(map.values());
|
||
}
|
||
|
||
async function runMatch() {
|
||
const names = selectedCandidates.value
|
||
.map((item) => item.shop_name)
|
||
.filter(Boolean);
|
||
if (!names.length) {
|
||
ElMessage.warning("请先勾选备选区店铺");
|
||
return;
|
||
}
|
||
matching.value = true;
|
||
try {
|
||
const data = await matchPatrolDeleteShops(names);
|
||
matchedItems.value = mergeMatchedItems(matchedItems.value, data.items || []);
|
||
saveMatchedItems();
|
||
ElMessage.success(`本次返回 ${data.items?.length || 0} 条匹配结果`);
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : "匹配失败");
|
||
} finally {
|
||
matching.value = false;
|
||
}
|
||
}
|
||
|
||
function removeMatchedRow(row: PatrolDeleteShopQueueItem) {
|
||
removeMatchedRowLocally(row);
|
||
ElMessage.success("已从匹配结果移除");
|
||
}
|
||
|
||
function buildTaskItem(item: PatrolDeleteShopQueueItem): PatrolDeleteTaskItem {
|
||
return {
|
||
shopName: item.shopName,
|
||
matched: item.matched,
|
||
shopId: item.shopId,
|
||
platform: item.platform,
|
||
companyName: item.companyName,
|
||
matchStatus: item.matchStatus,
|
||
matchMessage: item.matchMessage,
|
||
countrySections: buildTemplateCountrySections(),
|
||
cartRatios: buildTemplateCartRatios(),
|
||
};
|
||
}
|
||
|
||
function selectedDeleteConditions() {
|
||
const selected = new Set(selectedConditionIds.value);
|
||
return deleteConditions.value
|
||
.filter((condition) => selected.has(condition.id))
|
||
.map((condition) => ({
|
||
id: condition.id,
|
||
conditionText: condition.conditionText,
|
||
}));
|
||
}
|
||
|
||
function buildQueuePayload(taskId: number, items: PatrolDeleteHistoryItem[]) {
|
||
const firstItem = items[0];
|
||
const deleteConditionsForTask = selectedDeleteConditions();
|
||
return {
|
||
type: "patrol-delete-run",
|
||
ts: Date.now(),
|
||
data: {
|
||
taskId,
|
||
user_id: Number(uidForStorage()) || 0,
|
||
source: "frontend-vue-patrol-delete",
|
||
delete_conditions: deleteConditionsForTask,
|
||
deleteConditions: deleteConditionsForTask,
|
||
items: items.map((item) => ({
|
||
shopName: item.shopName,
|
||
shopId: item.shopId,
|
||
platform: item.platform,
|
||
companyName: item.companyName,
|
||
matched: item.matched,
|
||
matchStatus: item.matchStatus,
|
||
matchMessage: item.matchMessage,
|
||
countrySections: item.countrySections || [],
|
||
cartRatios: item.cartRatios || [],
|
||
})),
|
||
template_rows: firstItem ? flattenTemplateRows(firstItem) : [],
|
||
shop_template_rows: items.map((item) => ({
|
||
shopName: item.shopName,
|
||
shopId: item.shopId,
|
||
templateRows: flattenTemplateRows(item),
|
||
})),
|
||
country_sections: firstItem?.countrySections || [],
|
||
cart_ratios: firstItem?.cartRatios || [],
|
||
},
|
||
};
|
||
}
|
||
|
||
function findHistoryItemByTaskId(taskId: number) {
|
||
return historyItems.value.find((item) => item.taskId === taskId);
|
||
}
|
||
|
||
async function waitForTaskTerminal(taskId: number) {
|
||
let transientErrorCount = 0;
|
||
while (true) {
|
||
if (disposed) return "STOPPED";
|
||
if (activeTaskId.value !== taskId) return "DELETED";
|
||
try {
|
||
const missingTaskIds = await refreshActiveTaskProgress([taskId]);
|
||
if (missingTaskIds.includes(taskId)) {
|
||
clearActiveQueueTask();
|
||
return "DELETED";
|
||
}
|
||
if (activeTaskId.value !== taskId) return "DELETED";
|
||
if (transientErrorCount > 0) {
|
||
queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`;
|
||
}
|
||
transientErrorCount = 0;
|
||
const item = findHistoryItemByTaskId(taskId);
|
||
const status = item?.taskStatus || "";
|
||
if (status === "SUCCESS" || status === "FAILED" || status === "COMPLETED") {
|
||
return status;
|
||
}
|
||
} catch (error) {
|
||
if (!isTransientBackendError(error)) {
|
||
throw error;
|
||
}
|
||
transientErrorCount += 1;
|
||
if (transientErrorCount >= MAX_TRANSIENT_ERRORS) {
|
||
throw new Error(
|
||
`任务 ${taskId} 等待后端恢复超时,请稍后刷新查看状态`,
|
||
);
|
||
}
|
||
queuePushResult.value = `任务 ${taskId} 执行中,正在等待后端服务恢复(${transientErrorCount}/${MAX_TRANSIENT_ERRORS})...`;
|
||
}
|
||
await sleep(getTaskPollIntervalMs());
|
||
}
|
||
}
|
||
|
||
async function processQueue() {
|
||
if (disposed || queueWorkerRunning.value) return;
|
||
queueWorkerRunning.value = true;
|
||
pushing.value = true;
|
||
startHistoryPolling();
|
||
|
||
try {
|
||
const api = getPywebviewApi();
|
||
if (!api?.enqueue_json) {
|
||
throw new Error("当前客户端未提供 enqueue_json");
|
||
}
|
||
|
||
if (activeTaskId.value) {
|
||
const finalStatus = await waitForTaskTerminal(activeTaskId.value);
|
||
queuePushResult.value =
|
||
finalStatus === "DELETED"
|
||
? "任务已删除"
|
||
: `任务 ${activeTaskId.value} ${finalStatus === "SUCCESS" ? "已完成" : "执行失败"}`;
|
||
clearActiveQueueTask();
|
||
return;
|
||
}
|
||
|
||
const runnable = matchedRunnableItems.value;
|
||
if (!runnable.length) {
|
||
ElMessage.warning("请先匹配可用店铺");
|
||
return;
|
||
}
|
||
const created = await withTransientRetry(
|
||
() => createPatrolDeleteTask(runnable.map(buildTaskItem)),
|
||
(attempt, maxAttempts) => {
|
||
queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts})...`;
|
||
},
|
||
);
|
||
const createdItems = created.items || [];
|
||
if (!created.taskId || !createdItems.length || createdItems.some((item) => !item.taskId || !item.resultId)) {
|
||
throw new Error("后端未返回有效任务标识");
|
||
}
|
||
|
||
activeTaskId.value = created.taskId;
|
||
saveQueueState();
|
||
upsertHistoryItems(createdItems);
|
||
void loadDashboard();
|
||
|
||
const payload = buildQueuePayload(created.taskId, createdItems);
|
||
queuePayloadText.value = JSON.stringify(payload, null, 2);
|
||
|
||
const pushResult = await api.enqueue_json(payload);
|
||
if (!pushResult?.success) {
|
||
await submitPatrolDeleteTaskResult(created.taskId, {
|
||
shops: createdItems.map((createdItem) => ({
|
||
shopName: createdItem.shopName || "",
|
||
error: pushResult?.error || `任务 ${created.taskId} 推送失败`,
|
||
countrySections: createdItem.countrySections || [],
|
||
cartRatios: createdItem.cartRatios || [],
|
||
shopDone: true,
|
||
submissionId: createSubmissionId(
|
||
created.taskId,
|
||
createdItem.shopName,
|
||
),
|
||
chunkIndex: 1,
|
||
chunkTotal: 1,
|
||
})),
|
||
});
|
||
for (const item of runnable) removeMatchedRowLocally(item);
|
||
await refreshTaskViews();
|
||
queuePushResult.value = `任务 ${created.taskId} 推送失败`;
|
||
clearActiveQueueTask();
|
||
return;
|
||
}
|
||
|
||
for (const item of runnable) removeMatchedRowLocally(item);
|
||
queuePushResult.value = `任务 ${created.taskId} 已入队,共 ${createdItems.length} 个店铺,等待执行完成`;
|
||
|
||
const finalStatus = await waitForTaskTerminal(created.taskId);
|
||
queuePushResult.value = `任务 ${created.taskId} ${finalStatus === "SUCCESS" ? "已完成" : finalStatus === "DELETED" ? "已删除" : "执行失败"}`;
|
||
clearActiveQueueTask();
|
||
await refreshTaskViews();
|
||
ElMessage.success("巡店删除任务已完成");
|
||
} catch (error) {
|
||
if (disposed) return;
|
||
const message = error instanceof Error ? error.message : "队列执行失败";
|
||
queuePushResult.value = message;
|
||
ElMessage.error(message);
|
||
await refreshTaskViews();
|
||
} finally {
|
||
queueWorkerRunning.value = false;
|
||
pushing.value = false;
|
||
stopHistoryPolling();
|
||
saveQueueState();
|
||
}
|
||
}
|
||
|
||
async function pushToPythonQueue() {
|
||
const runnable = matchedRunnableItems.value;
|
||
if (!runnable.length) {
|
||
ElMessage.warning("请先匹配可用店铺");
|
||
return;
|
||
}
|
||
queuePushResult.value = `开始创建任务,共 ${runnable.length} 个店铺`;
|
||
await processQueue();
|
||
}
|
||
|
||
async function downloadResult(item: PatrolDeleteHistoryItem) {
|
||
const downloadable = taskGroupItems(item).find((row) =>
|
||
Boolean(row.resultId && (row.fileReady || row.downloadUrl)),
|
||
);
|
||
if (!downloadable?.resultId) return;
|
||
const api = getPywebviewApi();
|
||
if (!api?.save_file_from_url_new) {
|
||
ElMessage.error("当前客户端未提供下载能力");
|
||
return;
|
||
}
|
||
const url = getPatrolDeleteResultDownloadUrl(downloadable.resultId);
|
||
const filename =
|
||
downloadable.outputFilename || `${item.shopName || "patrol-delete-result"}.xlsx`;
|
||
const result = await api.save_file_from_url_new(url, filename);
|
||
if (result.success) {
|
||
ElMessage.success(`已保存: ${result.path || filename}`);
|
||
} else if (result.error && result.error !== "用户取消") {
|
||
ElMessage.error(result.error);
|
||
}
|
||
}
|
||
|
||
async function deleteTaskRecord(item: PatrolDeleteHistoryItem) {
|
||
try {
|
||
if (item.taskId) {
|
||
await deletePatrolDeleteTask(item.taskId);
|
||
if (activeTaskId.value === item.taskId) {
|
||
clearActiveQueueTask();
|
||
}
|
||
} else if (item.resultId) {
|
||
await deletePatrolDeleteHistory(item.resultId);
|
||
} else {
|
||
throw new Error("缺少可删除的任务标识");
|
||
}
|
||
removeHistoryItemLocally(item);
|
||
await Promise.allSettled([loadCandidates(), refreshTaskViews()]);
|
||
ElMessage.success("已删除");
|
||
} catch (error) {
|
||
if (isRecordMissingError(error)) {
|
||
if (item.taskId && activeTaskId.value === item.taskId) {
|
||
clearActiveQueueTask();
|
||
}
|
||
removeHistoryItemLocally(item);
|
||
await loadCandidates();
|
||
ElMessage.success("后端记录已不存在,已同步移除本地记录");
|
||
return;
|
||
}
|
||
ElMessage.error(error instanceof Error ? error.message : "删除失败");
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
loadMatchedItems();
|
||
loadQueueState();
|
||
await Promise.all([loadCandidates(), loadConditions(), loadDashboard(), loadHistory()]);
|
||
|
||
if (activeTaskId.value) {
|
||
queuePushResult.value =
|
||
activeTaskId.value != null
|
||
? `检测到未完成任务,继续等待任务 ${activeTaskId.value} 完成`
|
||
: "";
|
||
void processQueue();
|
||
}
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
disposed = true;
|
||
stopHistoryPolling();
|
||
clearSleepTimers();
|
||
timers.clearScope();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.module-page { min-height: 100vh; background: #1a1a1a; }
|
||
.main-content { display: flex; min-height: calc(100vh - 56px); height: calc(100vh - 56px); }
|
||
.left-panel { width: 400px; background: #1e1e1e; padding: 20px; overflow-y: auto; border-right: 1px solid #2a2a2a; }
|
||
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #1a1a1a; }
|
||
.section-title { font-size: 13px; color: #bbb; margin-bottom: 10px; }
|
||
.condition-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||
.condition-title-hint { flex: 1; min-width: 0; color: #d6a95a; font-size: 12px; text-align: right; line-height: 1.35; }
|
||
.input-zone { border: 1px dashed #3a3a3a; border-radius: 10px; padding: 16px; background: #252525; margin-bottom: 20px; }
|
||
.hint { color: #888; font-size: 12px; margin-bottom: 12px; line-height: 1.5; text-align: left; }
|
||
.input-row { display: flex; gap: 10px; align-items: center; }
|
||
.input-row :deep(.el-input) { flex: 1; }
|
||
.opt-btn { padding: 8px 12px; font-size: 12px; color: #ccc; background: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 6px; cursor: pointer; white-space: nowrap; }
|
||
.opt-btn:hover:not(:disabled) { color: #3498db; border-color: #3498db; }
|
||
.opt-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||
.empty-candidates { color: #666; font-size: 13px; padding: 16px; border: 1px dashed #333; border-radius: 8px; margin-bottom: 16px; }
|
||
.candidate-table-scroll { max-height: 320px; overflow: auto; margin-bottom: 18px; border-radius: 8px; border: 1px solid #2a2a2a; }
|
||
.candidate-table { --el-table-bg-color: #252525; --el-table-tr-bg-color: #252525; --el-table-header-bg-color: #2a2a2a; --el-table-text-color: #ccc; --el-table-border-color: #333; }
|
||
.condition-panel { margin-bottom: 18px; border: 1px solid #2a2a2a; border-radius: 8px; background: #222; padding: 12px; }
|
||
.condition-input-row { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
|
||
.condition-input-row :deep(.el-input) { flex: 1; }
|
||
.empty-conditions { color: #666; font-size: 12px; padding: 10px 4px; }
|
||
.condition-list { list-style: none; margin: 0; padding: 0; max-height: 150px; overflow: auto; display: flex; flex-direction: column; gap: 8px; }
|
||
.condition-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 6px; border-bottom: 1px solid #303030; }
|
||
.condition-item:last-child { border-bottom: none; }
|
||
.condition-check { display: flex; align-items: center; gap: 8px; min-width: 0; color: #cfd6df; font-size: 12px; cursor: pointer; }
|
||
.condition-check input { width: 16px; height: 16px; margin: 0; flex: 0 0 auto; }
|
||
.condition-check span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.link-danger { background: none; border: none; color: #e74c3c; cursor: pointer; font-size: 12px; padding: 0; }
|
||
.link-danger:hover { text-decoration: underline; }
|
||
.run-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; }
|
||
.btn-run { padding: 10px 18px; font-size: 14px; font-weight: 600; color: #fff; background: #3498db; border: none; border-radius: 8px; cursor: pointer; }
|
||
.btn-run:hover:not(:disabled) { background: #2980b9; }
|
||
.btn-run:disabled { opacity: 0.55; cursor: not-allowed; }
|
||
.btn-queue { background: #27ae60; }
|
||
.btn-queue:hover:not(:disabled) { background: #219a52; }
|
||
.loading-msg { margin-top: 12px; font-size: 12px; color: #777; line-height: 1.5; }
|
||
.queue-debug-card { margin-top: 18px; padding: 14px; border: 1px solid #2a2a2a; border-radius: 10px; background: #202020; }
|
||
.queue-debug-title { margin-bottom: 8px; }
|
||
.queue-debug-line { color: #b8c1cc; font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
|
||
.queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #141414; color: #8fd3ff; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
|
||
.panel-header { padding: 16px 20px; font-size: 15px; font-weight: 600; color: #e0e0e0; border-bottom: 1px solid #2a2a2a; }
|
||
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
|
||
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||
.summary-card { padding: 14px 16px; border: 1px solid #2a2a2a; border-radius: 10px; background: #1e1e1e; }
|
||
.summary-card strong { display: block; margin-top: 8px; font-size: 22px; color: #eaf4ff; }
|
||
.summary-label { font-size: 12px; color: #8d8d8d; }
|
||
.subsection-title { font-size: 13px; color: #999; margin: 8px 0 10px; }
|
||
.empty-tasks { color: #666; font-size: 13px; padding: 16px; text-align: center; }
|
||
.empty-tasks.narrow { padding: 12px 8px; }
|
||
.match-table { margin-bottom: 18px; }
|
||
.match-table :deep(.el-table__body tr:hover > td.el-table__cell), .match-table :deep(.el-table__body tr.hover-row > td.el-table__cell), .match-table :deep(.el-table__body tr.current-row > td.el-table__cell) { background-color: var(--el-table-tr-bg-color, #222) !important; }
|
||
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2a2a2a; --el-table-text-color: #ccc; --el-table-border-color: #333; }
|
||
.ok { color: #27ae60; }
|
||
.fail { color: #e67e22; }
|
||
.result-list-wrap { border: 1px solid #2a2a2a; border-radius: 10px; background: #1e1e1e; min-height: 220px; margin-top: 8px; }
|
||
.result-list-header { padding: 12px 16px; border-bottom: 1px solid #2a2a2a; font-size: 14px; color: #ddd; }
|
||
.result-subsection { padding: 12px 12px 4px; }
|
||
.result-subsection-title { font-size: 12px; color: #888; margin-bottom: 8px; }
|
||
.task-list { list-style: none; margin: 0; padding: 0; }
|
||
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2a2a2a; border-radius: 8px; margin-bottom: 8px; background: #222; }
|
||
.left { flex: 1; min-width: 0; }
|
||
.split-result-main { display: flex; flex-direction: column; gap: 4px; }
|
||
.id { font-weight: 600; color: #e0e0e0; font-size: 13px; }
|
||
.files { font-size: 12px; color: #888; }
|
||
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
||
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
|
||
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
|
||
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
|
||
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; }
|
||
.archive { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
|
||
.archive:hover { background: rgba(52, 152, 219, 0.28); }
|
||
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; }
|
||
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
|
||
@media (max-width: 1100px) {
|
||
.main-content { flex-direction: column; height: auto; }
|
||
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2a2a2a; }
|
||
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||
}
|
||
</style>
|