perf(frontend): 工具页分页/历史截断/轻量轮询/共享纯逻辑(审查 F5/F6/F9/F12/G3)
- F9 新增 useTablePaging composable 并接入 7 个工具页"匹配结果"表(只切渲染窗口, 不加选择语义;每页 100 条)+ 深色分页样式 - F6 历史任务抽屉渲染截断(默认 50 条 + "显示全部/收起"),全选口径改为当前可见条目 - F5 新增 task-progress-polling 适配器:等待任务终态的紧循环走 /tasks/progress/light, 异常或空响应回退重型 batch;已接入跟价/定时匹配的 waitForTaskTerminal - F12 背景图 bg.jpg 251KB → 166KB(1920 宽 + quality 80,image-set 仍优先 webp) - G3 抽出共享纯逻辑 task-queue-state(开始时间表序列化校验 + 记录缺失错误判定), 5 个工具页删除逐字重复实现 - 新增 4 个单测文件(分页切片/历史截断/轻量轮询归一/任务队列纯逻辑)
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 166 KiB |
@@ -162,7 +162,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<el-table
|
<el-table
|
||||||
v-else
|
v-else
|
||||||
:data="matchedItems"
|
:data="pagedMatchedItems"
|
||||||
:row-key="rowKeyForMatch"
|
:row-key="rowKeyForMatch"
|
||||||
:highlight-current-row="false"
|
:highlight-current-row="false"
|
||||||
class="result-table match-table"
|
class="result-table match-table"
|
||||||
@@ -215,6 +215,14 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #item-actions="{ item }">
|
<template #item-actions="{ item }">
|
||||||
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
@@ -275,6 +283,8 @@ import {
|
|||||||
import { formatDateTime } from '@/shared/utils/datetime'
|
import { formatDateTime } from '@/shared/utils/datetime'
|
||||||
import { useHistoryPolling } from '@/shared/composables/useHistoryPolling'
|
import { useHistoryPolling } from '@/shared/composables/useHistoryPolling'
|
||||||
import { runBatchDelete } from '@/shared/utils/batch-delete'
|
import { runBatchDelete } from '@/shared/utils/batch-delete'
|
||||||
|
import { isRecordMissingError } from '@/shared/utils/task-queue-state.ts'
|
||||||
|
import { useTablePaging } from '@/shared/composables/useTablePaging.ts'
|
||||||
|
|
||||||
const MAX_TRANSIENT_ERRORS = 30;
|
const MAX_TRANSIENT_ERRORS = 30;
|
||||||
/** 任务终态后等待结果文件(Java 侧异步生成)的最大轮次,12 × 10s ≈ 2 分钟 */
|
/** 任务终态后等待结果文件(Java 侧异步生成)的最大轮次,12 × 10s ≈ 2 分钟 */
|
||||||
@@ -290,6 +300,14 @@ const taskConditionTexts = ref<string[]>([]);
|
|||||||
const conditionSelectValue = ref("");
|
const conditionSelectValue = ref("");
|
||||||
const selectedCountryCodes = ref<string[]>([...EU_COUNTRY_CODES]);
|
const selectedCountryCodes = ref<string[]>([...EU_COUNTRY_CODES]);
|
||||||
const matchedItems = ref<PatrolDeleteShopQueueItem[]>([]);
|
const matchedItems = ref<PatrolDeleteShopQueueItem[]>([]);
|
||||||
|
|
||||||
|
// F9:匹配结果表分页(只切渲染窗口,不加选择语义)
|
||||||
|
const {
|
||||||
|
page: matchedPage,
|
||||||
|
pageSize: matchedPageSize,
|
||||||
|
total: matchedTotal,
|
||||||
|
paged: pagedMatchedItems,
|
||||||
|
} = useTablePaging(matchedItems)
|
||||||
const historyItems = ref<PatrolDeleteHistoryItem[]>([]);
|
const historyItems = ref<PatrolDeleteHistoryItem[]>([]);
|
||||||
const dashboard = ref<PatrolDeleteDashboardVo>({
|
const dashboard = ref<PatrolDeleteDashboardVo>({
|
||||||
candidateCount: 0,
|
candidateCount: 0,
|
||||||
@@ -713,11 +731,6 @@ function clearActiveQueueTask() {
|
|||||||
saveQueueState();
|
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) {
|
function removeMatchedRowLocally(row: PatrolDeleteShopQueueItem) {
|
||||||
const key = rowKeyForMatch(row);
|
const key = rowKeyForMatch(row);
|
||||||
matchedItems.value = matchedItems.value.filter((item) => rowKeyForMatch(item) !== key);
|
matchedItems.value = matchedItems.value.filter((item) => rowKeyForMatch(item) !== key);
|
||||||
|
|||||||
@@ -164,7 +164,7 @@
|
|||||||
<template #cards-extra>
|
<template #cards-extra>
|
||||||
<div class="subsection-title">匹配结果</div>
|
<div class="subsection-title">匹配结果</div>
|
||||||
<div v-if="!matchedItems.length" class="match-empty">完成「匹配店铺」后,在此展示匹配结果,确认无误后再启动任务。</div>
|
<div v-if="!matchedItems.length" class="match-empty">完成「匹配店铺」后,在此展示匹配结果,确认无误后再启动任务。</div>
|
||||||
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false"
|
<el-table v-else :data="pagedMatchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false"
|
||||||
class="result-table match-table">
|
class="result-table match-table">
|
||||||
<el-table-column prop="shopName" label="店铺名" min-width="100" />
|
<el-table-column prop="shopName" label="店铺名" min-width="100" />
|
||||||
<el-table-column label="匹配" width="72" align="center">
|
<el-table-column label="匹配" width="72" align="center">
|
||||||
@@ -187,6 +187,14 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #item-actions="{ item }">
|
<template #item-actions="{ item }">
|
||||||
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||||||
@@ -257,9 +265,12 @@ import {
|
|||||||
type PriceTrackShopQueueItem,
|
type PriceTrackShopQueueItem,
|
||||||
type PriceTrackTaskDetailVo,
|
type PriceTrackTaskDetailVo,
|
||||||
} from '@/shared/api/java-modules'
|
} from '@/shared/api/java-modules'
|
||||||
|
import { getPollingProgressBatch } from '@/shared/api/task-progress-polling.ts'
|
||||||
import { formatDateTime } from '@/shared/utils/datetime'
|
import { formatDateTime } from '@/shared/utils/datetime'
|
||||||
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
|
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
|
||||||
import { runBatchDelete } from '@/shared/utils/batch-delete'
|
import { runBatchDelete } from '@/shared/utils/batch-delete'
|
||||||
|
import { isRecordMissingError } from '@/shared/utils/task-queue-state.ts'
|
||||||
|
import { useTablePaging } from '@/shared/composables/useTablePaging.ts'
|
||||||
|
|
||||||
const ziniaoVersion = useZiniaoVersion()
|
const ziniaoVersion = useZiniaoVersion()
|
||||||
|
|
||||||
@@ -277,6 +288,14 @@ const shopInput = ref('')
|
|||||||
const candidates = ref<PriceTrackCandidateVo[]>([])
|
const candidates = ref<PriceTrackCandidateVo[]>([])
|
||||||
const selectedCandidates = ref<PriceTrackCandidateVo[]>([])
|
const selectedCandidates = ref<PriceTrackCandidateVo[]>([])
|
||||||
const matchedItems = ref<PriceTrackShopQueueItem[]>([])
|
const matchedItems = ref<PriceTrackShopQueueItem[]>([])
|
||||||
|
|
||||||
|
// F9:匹配结果表分页(只切渲染窗口,不加选择语义)
|
||||||
|
const {
|
||||||
|
page: matchedPage,
|
||||||
|
pageSize: matchedPageSize,
|
||||||
|
total: matchedTotal,
|
||||||
|
paged: pagedMatchedItems,
|
||||||
|
} = useTablePaging(matchedItems)
|
||||||
const adding = ref(false)
|
const adding = ref(false)
|
||||||
const matching = ref(false)
|
const matching = ref(false)
|
||||||
const pushing = ref(false)
|
const pushing = ref(false)
|
||||||
@@ -809,11 +828,6 @@ function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | nul
|
|||||||
return `${(row.shopName || '').trim()}\u0001${row.shopId ?? ''}`
|
return `${(row.shopName || '').trim()}\u0001${row.shopId ?? ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecordMissingError(error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error || '')
|
|
||||||
return /记录不存在|不存在|已删除|not\s*found|404/i.test(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
function priceTrackModeForAppClient() {
|
function priceTrackModeForAppClient() {
|
||||||
return statusModeEnabled.value ? 'status' : 'asin'
|
return statusModeEnabled.value ? 'status' : 'asin'
|
||||||
}
|
}
|
||||||
@@ -1006,7 +1020,10 @@ async function waitForTaskTerminal(taskId: number) {
|
|||||||
while (true) {
|
while (true) {
|
||||||
if (disposed) return 'STOPPED'
|
if (disposed) return 'STOPPED'
|
||||||
try {
|
try {
|
||||||
const batch = await getPriceTrackTaskProgressBatch([taskId])
|
// F5:等待终态的紧循环只读 status(轻量白名单覆盖),走 light 端点减小轮询响应体,异常回退重型 batch
|
||||||
|
const batch = await getPollingProgressBatch('priceTrack', [taskId], {
|
||||||
|
fallback: () => getPriceTrackTaskProgressBatch([taskId]),
|
||||||
|
})
|
||||||
if (transientErrorCount > 0) {
|
if (transientErrorCount > 0) {
|
||||||
queuePushResult.value = `任务 ${taskId} 服务已恢复,继续等待执行结果...`
|
queuePushResult.value = `任务 ${taskId} 服务已恢复,继续等待执行结果...`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
<template #cards-extra>
|
<template #cards-extra>
|
||||||
<div class="subsection-title">匹配结果</div>
|
<div class="subsection-title">匹配结果</div>
|
||||||
<div v-if="!matchedItems.length" class="match-empty">完成「匹配店铺」后,在此展示匹配结果,确认无误后再启动任务。</div>
|
<div v-if="!matchedItems.length" class="match-empty">完成「匹配店铺」后,在此展示匹配结果,确认无误后再启动任务。</div>
|
||||||
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false"
|
<el-table v-else :data="pagedMatchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false"
|
||||||
class="result-table match-table">
|
class="result-table match-table">
|
||||||
<el-table-column prop="shopName" label="店铺名" min-width="100" />
|
<el-table-column prop="shopName" label="店铺名" min-width="100" />
|
||||||
<el-table-column label="匹配" width="72" align="center">
|
<el-table-column label="匹配" width="72" align="center">
|
||||||
@@ -120,6 +120,14 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #item-actions="{ item }">
|
<template #item-actions="{ item }">
|
||||||
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||||||
@@ -176,11 +184,21 @@ import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
|
|||||||
import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
|
import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
|
||||||
import { formatDateTime } from '@/shared/utils/datetime'
|
import { formatDateTime } from '@/shared/utils/datetime'
|
||||||
import { runBatchDelete } from '@/shared/utils/batch-delete'
|
import { runBatchDelete } from '@/shared/utils/batch-delete'
|
||||||
|
import { isRecordMissingError } from '@/shared/utils/task-queue-state.ts'
|
||||||
|
import { useTablePaging } from '@/shared/composables/useTablePaging.ts'
|
||||||
|
|
||||||
const shopInput = ref('')
|
const shopInput = ref('')
|
||||||
const candidates = ref<ProductRiskCandidateVo[]>([])
|
const candidates = ref<ProductRiskCandidateVo[]>([])
|
||||||
const selectedCandidates = ref<ProductRiskCandidateVo[]>([])
|
const selectedCandidates = ref<ProductRiskCandidateVo[]>([])
|
||||||
const matchedItems = ref<ProductRiskShopQueueItem[]>([])
|
const matchedItems = ref<ProductRiskShopQueueItem[]>([])
|
||||||
|
|
||||||
|
// F9:匹配结果表分页(只切渲染窗口,不加选择语义)
|
||||||
|
const {
|
||||||
|
page: matchedPage,
|
||||||
|
pageSize: matchedPageSize,
|
||||||
|
total: matchedTotal,
|
||||||
|
paged: pagedMatchedItems,
|
||||||
|
} = useTablePaging(matchedItems)
|
||||||
const adding = ref(false)
|
const adding = ref(false)
|
||||||
const matching = ref(false)
|
const matching = ref(false)
|
||||||
const pushing = ref(false)
|
const pushing = ref(false)
|
||||||
@@ -443,11 +461,6 @@ function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | nul
|
|||||||
return `${name}\u0001${id}`
|
return `${name}\u0001${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecordMissingError(error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error || '')
|
|
||||||
return /记录不存在|任务不存在|不存在|已删除|not\s*found|404/i.test(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: number | string | null }>) {
|
function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: number | string | null }>) {
|
||||||
if (!rows.length) return
|
if (!rows.length) return
|
||||||
const keys = new Set(rows.map((row) => rowKeyForMatch(row)))
|
const keys = new Set(rows.map((row) => rowKeyForMatch(row)))
|
||||||
|
|||||||
@@ -111,7 +111,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<el-table
|
<el-table
|
||||||
v-else
|
v-else
|
||||||
:data="matchedItems"
|
:data="pagedMatchedItems"
|
||||||
:row-key="rowKeyForMatch"
|
:row-key="rowKeyForMatch"
|
||||||
:highlight-current-row="false"
|
:highlight-current-row="false"
|
||||||
class="result-table match-table"
|
class="result-table match-table"
|
||||||
@@ -164,6 +164,14 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #item-actions="{ item }">
|
<template #item-actions="{ item }">
|
||||||
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||||||
@@ -217,6 +225,8 @@ import { useZiniaoVersion } from "@/shared/utils/ziniao-version";
|
|||||||
import { formatDateTime } from '@/shared/utils/datetime'
|
import { formatDateTime } from '@/shared/utils/datetime'
|
||||||
import { useHistoryPolling } from '@/shared/composables/useHistoryPolling'
|
import { useHistoryPolling } from '@/shared/composables/useHistoryPolling'
|
||||||
import { runBatchDelete } from '@/shared/utils/batch-delete'
|
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'
|
||||||
|
|
||||||
const MAX_TRANSIENT_ERRORS = 30;
|
const MAX_TRANSIENT_ERRORS = 30;
|
||||||
const ziniaoVersion = useZiniaoVersion();
|
const ziniaoVersion = useZiniaoVersion();
|
||||||
@@ -225,6 +235,14 @@ const shopInput = ref("");
|
|||||||
const candidates = ref<QueryAsinCandidateVo[]>([]);
|
const candidates = ref<QueryAsinCandidateVo[]>([]);
|
||||||
const selectedCandidates = ref<QueryAsinCandidateVo[]>([]);
|
const selectedCandidates = ref<QueryAsinCandidateVo[]>([]);
|
||||||
const matchedItems = ref<QueryAsinShopQueueItem[]>([]);
|
const matchedItems = ref<QueryAsinShopQueueItem[]>([]);
|
||||||
|
|
||||||
|
// F9:匹配结果表分页(只切渲染窗口,不加选择语义)
|
||||||
|
const {
|
||||||
|
page: matchedPage,
|
||||||
|
pageSize: matchedPageSize,
|
||||||
|
total: matchedTotal,
|
||||||
|
paged: pagedMatchedItems,
|
||||||
|
} = useTablePaging(matchedItems)
|
||||||
const historyItems = ref<QueryAsinHistoryItem[]>([]);
|
const historyItems = ref<QueryAsinHistoryItem[]>([]);
|
||||||
const dashboard = ref<QueryAsinDashboardVo>({
|
const dashboard = ref<QueryAsinDashboardVo>({
|
||||||
candidateCount: 0,
|
candidateCount: 0,
|
||||||
@@ -560,15 +578,7 @@ function loadTaskStartTimes() {
|
|||||||
typeof window !== "undefined"
|
typeof window !== "undefined"
|
||||||
? window.localStorage.getItem(taskStartTimeStorageKey())
|
? window.localStorage.getItem(taskStartTimeStorageKey())
|
||||||
: null;
|
: null;
|
||||||
const parsed = raw ? JSON.parse(raw) : {};
|
taskStartTimes.value = parseTaskStartTimes(raw);
|
||||||
const next: Record<number, string> = {};
|
|
||||||
for (const [taskId, value] of Object.entries(parsed || {})) {
|
|
||||||
const numericTaskId = Number(taskId);
|
|
||||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) continue;
|
|
||||||
if (typeof value !== "string" || !value.trim()) continue;
|
|
||||||
next[numericTaskId] = value;
|
|
||||||
}
|
|
||||||
taskStartTimes.value = next;
|
|
||||||
} catch {
|
} catch {
|
||||||
taskStartTimes.value = {};
|
taskStartTimes.value = {};
|
||||||
}
|
}
|
||||||
@@ -576,18 +586,13 @@ function loadTaskStartTimes() {
|
|||||||
|
|
||||||
function setTaskStartTime(taskId: number, startedAt = new Date().toISOString()) {
|
function setTaskStartTime(taskId: number, startedAt = new Date().toISOString()) {
|
||||||
if (!Number.isFinite(taskId) || taskId <= 0) return;
|
if (!Number.isFinite(taskId) || taskId <= 0) return;
|
||||||
taskStartTimes.value = {
|
taskStartTimes.value = upsertTaskStartTime(taskStartTimes.value, taskId, startedAt);
|
||||||
...taskStartTimes.value,
|
|
||||||
[taskId]: startedAt,
|
|
||||||
};
|
|
||||||
saveTaskStartTimes();
|
saveTaskStartTimes();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearTaskStartTime(taskId?: number | null) {
|
function clearTaskStartTime(taskId?: number | null) {
|
||||||
if (!taskId || !(taskId in taskStartTimes.value)) return;
|
if (!taskId || !(taskId in taskStartTimes.value)) return;
|
||||||
const next = { ...taskStartTimes.value };
|
taskStartTimes.value = removeTaskStartTime(taskStartTimes.value, taskId);
|
||||||
delete next[taskId];
|
|
||||||
taskStartTimes.value = next;
|
|
||||||
saveTaskStartTimes();
|
saveTaskStartTimes();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,11 +618,6 @@ function resetQueueWorkerIfIdle() {
|
|||||||
saveQueueState();
|
saveQueueState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecordMissingError(error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error || "");
|
|
||||||
return /记录不存在|任务不存在|不存在|已删除|not\s*found|404/i.test(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeHistoryItemLocally(item: QueryAsinHistoryItem) {
|
function removeHistoryItemLocally(item: QueryAsinHistoryItem) {
|
||||||
historyItems.value = historyItems.value.filter((row) => {
|
historyItems.value = historyItems.value.filter((row) => {
|
||||||
if (item.resultId != null && row.resultId === item.resultId) return false;
|
if (item.resultId != null && row.resultId === item.resultId) return false;
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
<template #cards-extra>
|
<template #cards-extra>
|
||||||
<div class="subsection-title">匹配结果</div>
|
<div class="subsection-title">匹配结果</div>
|
||||||
<div v-if="!matchedItems.length" class="match-empty">匹配后将在这里显示结果</div>
|
<div v-if="!matchedItems.length" class="match-empty">匹配后将在这里显示结果</div>
|
||||||
<el-table v-else :data="matchedItems" :row-key="rowKey" :highlight-current-row="false"
|
<el-table v-else :data="pagedMatchedItems" :row-key="rowKey" :highlight-current-row="false"
|
||||||
class="result-table match-table">
|
class="result-table match-table">
|
||||||
<el-table-column prop="shopName" label="店铺名" min-width="120" />
|
<el-table-column prop="shopName" label="店铺名" min-width="120" />
|
||||||
<el-table-column label="匹配" width="64" align="center">
|
<el-table-column label="匹配" width="64" align="center">
|
||||||
@@ -93,6 +93,14 @@
|
|||||||
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
|
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
|
||||||
</template></el-table-column>
|
</template></el-table-column>
|
||||||
</el-table>
|
</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"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #item-extra="{ item }">
|
<template #item-extra="{ item }">
|
||||||
<div v-if="itemSource(item).error" class="files error-text">错误:{{ itemSource(item).error }}</div>
|
<div v-if="itemSource(item).error" class="files error-text">错误:{{ itemSource(item).error }}</div>
|
||||||
@@ -153,6 +161,7 @@ import {
|
|||||||
type ShopDataCrawlTaskDetailVo,
|
type ShopDataCrawlTaskDetailVo,
|
||||||
} from '@/shared/api/java-modules'
|
} from '@/shared/api/java-modules'
|
||||||
import { runBatchDelete } from '@/shared/utils/batch-delete'
|
import { runBatchDelete } from '@/shared/utils/batch-delete'
|
||||||
|
import { useTablePaging } from '@/shared/composables/useTablePaging.ts'
|
||||||
|
|
||||||
const COUNTRY_OPTIONS = [
|
const COUNTRY_OPTIONS = [
|
||||||
{ code: 'UK', label: '英国' },
|
{ code: 'UK', label: '英国' },
|
||||||
@@ -168,6 +177,14 @@ const shopInput = ref('')
|
|||||||
const candidates = ref<ShopDataCrawlCandidateVo[]>([])
|
const candidates = ref<ShopDataCrawlCandidateVo[]>([])
|
||||||
const selectedCandidates = ref<ShopDataCrawlCandidateVo[]>([])
|
const selectedCandidates = ref<ShopDataCrawlCandidateVo[]>([])
|
||||||
const matchedItems = ref<ShopDataCrawlShopItem[]>([])
|
const matchedItems = ref<ShopDataCrawlShopItem[]>([])
|
||||||
|
|
||||||
|
// F9:匹配结果表分页(只切渲染窗口,不加选择语义)
|
||||||
|
const {
|
||||||
|
page: matchedPage,
|
||||||
|
pageSize: matchedPageSize,
|
||||||
|
total: matchedTotal,
|
||||||
|
paged: pagedMatchedItems,
|
||||||
|
} = useTablePaging(matchedItems)
|
||||||
const historyItems = ref<ShopDataCrawlHistoryItem[]>([])
|
const historyItems = ref<ShopDataCrawlHistoryItem[]>([])
|
||||||
const dashboard = ref<ShopDataCrawlDashboardVo>({ candidateCount: 0, processedTaskCount: 0, successTaskCount: 0, failedTaskCount: 0 })
|
const dashboard = ref<ShopDataCrawlDashboardVo>({ candidateCount: 0, processedTaskCount: 0, successTaskCount: 0, failedTaskCount: 0 })
|
||||||
const orderedCountryCodes = ref<string[]>(COUNTRY_OPTIONS.map((row) => row.code))
|
const orderedCountryCodes = ref<string[]>(COUNTRY_OPTIONS.map((row) => row.code))
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
<template #cards-extra>
|
<template #cards-extra>
|
||||||
<div class="subsection-title">匹配结果</div>
|
<div class="subsection-title">匹配结果</div>
|
||||||
<div v-if="!matchedItems.length" class="match-empty">完成“匹配店铺”后,在此展示匹配结果,确认无误后再启动任务。</div>
|
<div v-if="!matchedItems.length" class="match-empty">完成“匹配店铺”后,在此展示匹配结果,确认无误后再启动任务。</div>
|
||||||
<el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false" class="result-table match-table">
|
<el-table v-else :data="pagedMatchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false" class="result-table match-table">
|
||||||
<el-table-column prop="shopName" label="店铺名" min-width="100" />
|
<el-table-column prop="shopName" label="店铺名" min-width="100" />
|
||||||
<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 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="shopId" label="店铺ID" min-width="120" show-overflow-tooltip />
|
||||||
@@ -87,6 +87,14 @@
|
|||||||
<el-table-column label="说明" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ formatMatchRemark(row) }}</template></el-table-column>
|
<el-table-column label="说明" min-width="140" 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-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-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"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #item-extra="{ item }">
|
<template #item-extra="{ item }">
|
||||||
<div v-if="currentTaskStageText(itemSource(item))" class="files">{{ currentTaskStageText(itemSource(item)) }}</div>
|
<div v-if="currentTaskStageText(itemSource(item))" class="files">{{ currentTaskStageText(itemSource(item)) }}</div>
|
||||||
@@ -116,18 +124,28 @@ import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types
|
|||||||
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
|
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
|
||||||
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
|
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
|
||||||
import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTaskProgressBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchCreateTaskItem, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules'
|
import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTaskProgressBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchCreateTaskItem, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules'
|
||||||
|
import { getPollingProgressBatch } from '@/shared/api/task-progress-polling.ts'
|
||||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||||
import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
|
import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
|
||||||
import { runBatchDelete } from '@/shared/utils/batch-delete'
|
import { runBatchDelete } from '@/shared/utils/batch-delete'
|
||||||
|
import { useTablePaging } from '@/shared/composables/useTablePaging.ts'
|
||||||
|
|
||||||
const COUNTRY_OPTIONS = [{ code: 'DE', label: '德国' }, { code: 'UK', label: '英国' }, { code: 'FR', label: '法国' }, { code: 'IT', label: '意大利' }, { code: 'ES', label: '西班牙' }] as const
|
const COUNTRY_OPTIONS = [{ code: 'DE', label: '德国' }, { code: 'UK', label: '英国' }, { code: 'FR', label: '法国' }, { code: 'IT', label: '意大利' }, { code: 'ES', label: '西班牙' }] as const
|
||||||
const shopInput = ref('')
|
const shopInput = ref('')
|
||||||
const candidates = ref<ShopMatchCandidateVo[]>([])
|
const candidates = ref<ShopMatchCandidateVo[]>([])
|
||||||
const selectedCandidates = ref<ShopMatchCandidateVo[]>([])
|
const selectedCandidates = ref<ShopMatchCandidateVo[]>([])
|
||||||
const matchedItems = ref<ShopMatchShopQueueItem[]>([])
|
const matchedItems = ref<ShopMatchShopQueueItem[]>([])
|
||||||
|
|
||||||
|
// F9:匹配结果表分页(只切渲染窗口,不加选择语义)
|
||||||
|
const {
|
||||||
|
page: matchedPage,
|
||||||
|
pageSize: matchedPageSize,
|
||||||
|
total: matchedTotal,
|
||||||
|
paged: pagedMatchedItems,
|
||||||
|
} = useTablePaging(matchedItems)
|
||||||
const shopMatchListingFilter = ref<ListingFilterValue>('Active')
|
const shopMatchListingFilter = ref<ListingFilterValue>('Active')
|
||||||
const adding = ref(false)
|
const adding = ref(false)
|
||||||
const matching = ref(false)
|
const matching = ref(false)
|
||||||
@@ -439,7 +457,10 @@ function getPollIntervalMs() { return getTaskPollIntervalMs() }
|
|||||||
function scheduleNextPoll(immediate = false) { if (disposed) return; if (pollTimer.value) { if (!immediate) return; timers.clearTimer('task-poll', pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (disposed) return; if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingTaskIds.value.length) scheduleNextPoll(); return } pollingInFlight.value = true; try { const { settledTaskIds } = await refreshTaskBatch(); if (settledTaskIds.length) await Promise.allSettled([loadHistory(), loadDashboard()]) } finally { pollingInFlight.value = false } if (!disposed && pollingTaskIds.value.length) pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs()) }
|
function scheduleNextPoll(immediate = false) { if (disposed) return; if (pollTimer.value) { if (!immediate) return; timers.clearTimer('task-poll', pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (disposed) return; if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingTaskIds.value.length) scheduleNextPoll(); return } pollingInFlight.value = true; try { const { settledTaskIds } = await refreshTaskBatch(); if (settledTaskIds.length) await Promise.allSettled([loadHistory(), loadDashboard()]) } finally { pollingInFlight.value = false } if (!disposed && pollingTaskIds.value.length) pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs()) }
|
||||||
function ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
|
function ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
|
||||||
function stopPolling() { if (pollTimer.value) { timers.clearTimer('task-poll', pollTimer.value); pollTimer.value = null } }
|
function stopPolling() { if (pollTimer.value) { timers.clearTimer('task-poll', pollTimer.value); pollTimer.value = null } }
|
||||||
async function waitForTaskTerminal(taskId: number) { let transientErrorCount = 0; const maxTransientErrors = 30; while (true) { if (disposed) return 'STOPPED'; try { const batch = await getShopMatchTaskProgressBatch([taskId]); if (transientErrorCount > 0) queuePushResult.value = `任务 ${taskId} 服务已恢复,继续等待执行结果...`; transientErrorCount = 0; if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { const prev = taskSnapshots.value[taskId]; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } } catch (error) { if (!isTransientBackendError(error)) throw error; transientErrorCount += 1; if (transientErrorCount >= maxTransientErrors) throw new Error(`任务 ${taskId} 等待服务恢复超时,请稍后手动刷新查看状态`); queuePushResult.value = `任务 ${taskId} 运行中,正在等待服务恢复(${transientErrorCount}/${maxTransientErrors})...`; if (transientErrorCount === 1 || transientErrorCount % 5 === 0) ElMessage.warning(`任务 ${taskId} 运行中,服务暂时不可用,正在自动重试`); await sleep(getPollIntervalMs()); continue } await sleep(getPollIntervalMs()) } }
|
async function waitForTaskTerminal(taskId: number) { let transientErrorCount = 0; const maxTransientErrors = 30; while (true) { if (disposed) return 'STOPPED'; try { // F5:等待终态的紧循环只读 status,走 light 端点减小轮询响应体,异常回退重型 batch
|
||||||
|
const batch = await getPollingProgressBatch('shopMatch', [taskId], {
|
||||||
|
fallback: () => getShopMatchTaskProgressBatch([taskId]),
|
||||||
|
}); if (transientErrorCount > 0) queuePushResult.value = `任务 ${taskId} 服务已恢复,继续等待执行结果...`; transientErrorCount = 0; if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { const prev = taskSnapshots.value[taskId]; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } } catch (error) { if (!isTransientBackendError(error)) throw error; transientErrorCount += 1; if (transientErrorCount >= maxTransientErrors) throw new Error(`任务 ${taskId} 等待服务恢复超时,请稍后手动刷新查看状态`); queuePushResult.value = `任务 ${taskId} 运行中,正在等待服务恢复(${transientErrorCount}/${maxTransientErrors})...`; if (transientErrorCount === 1 || transientErrorCount % 5 === 0) ElMessage.warning(`任务 ${taskId} 运行中,服务暂时不可用,正在自动重试`); await sleep(getPollIntervalMs()); continue } await sleep(getPollIntervalMs()) } }
|
||||||
async function waitForScheduledTaskStageExit(taskId: number) {
|
async function waitForScheduledTaskStageExit(taskId: number) {
|
||||||
let transientErrorCount = 0
|
let transientErrorCount = 0
|
||||||
const maxTransientErrors = 30
|
const maxTransientErrors = 30
|
||||||
|
|||||||
@@ -115,7 +115,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="match-zone-scroll">
|
<div v-else class="match-zone-scroll">
|
||||||
<el-table
|
<el-table
|
||||||
:data="matchedItems"
|
:data="pagedMatchedItems"
|
||||||
:row-key="rowKeyForMatch"
|
:row-key="rowKeyForMatch"
|
||||||
:highlight-current-row="false"
|
:highlight-current-row="false"
|
||||||
class="result-table match-table"
|
class="result-table match-table"
|
||||||
@@ -168,6 +168,14 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -257,6 +265,8 @@ import { useZiniaoVersion } from "@/shared/utils/ziniao-version";
|
|||||||
import { formatDateTime } from '@/shared/utils/datetime'
|
import { formatDateTime } from '@/shared/utils/datetime'
|
||||||
import { useHistoryPolling } from '@/shared/composables/useHistoryPolling'
|
import { useHistoryPolling } from '@/shared/composables/useHistoryPolling'
|
||||||
import { runBatchDelete } from '@/shared/utils/batch-delete'
|
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'
|
||||||
|
|
||||||
const MAX_TRANSIENT_ERRORS = 30;
|
const MAX_TRANSIENT_ERRORS = 30;
|
||||||
const ziniaoVersion = useZiniaoVersion();
|
const ziniaoVersion = useZiniaoVersion();
|
||||||
@@ -276,6 +286,14 @@ const reservedAmount = ref(0);
|
|||||||
const candidates = ref<WithdrawCandidateVo[]>([]);
|
const candidates = ref<WithdrawCandidateVo[]>([]);
|
||||||
const selectedCandidates = ref<WithdrawCandidateVo[]>([]);
|
const selectedCandidates = ref<WithdrawCandidateVo[]>([]);
|
||||||
const matchedItems = ref<WithdrawShopQueueItem[]>([]);
|
const matchedItems = ref<WithdrawShopQueueItem[]>([]);
|
||||||
|
|
||||||
|
// F9:匹配结果表分页(只切渲染窗口,不加选择语义)
|
||||||
|
const {
|
||||||
|
page: matchedPage,
|
||||||
|
pageSize: matchedPageSize,
|
||||||
|
total: matchedTotal,
|
||||||
|
paged: pagedMatchedItems,
|
||||||
|
} = useTablePaging(matchedItems)
|
||||||
const historyItems = ref<WithdrawHistoryItem[]>([]);
|
const historyItems = ref<WithdrawHistoryItem[]>([]);
|
||||||
const dashboard = ref<WithdrawDashboardVo>({
|
const dashboard = ref<WithdrawDashboardVo>({
|
||||||
candidateCount: 0,
|
candidateCount: 0,
|
||||||
@@ -700,15 +718,7 @@ function loadTaskStartTimes() {
|
|||||||
typeof window !== "undefined"
|
typeof window !== "undefined"
|
||||||
? window.localStorage.getItem(taskStartTimeStorageKey())
|
? window.localStorage.getItem(taskStartTimeStorageKey())
|
||||||
: null;
|
: null;
|
||||||
const parsed = raw ? JSON.parse(raw) : {};
|
taskStartTimes.value = parseTaskStartTimes(raw);
|
||||||
const next: Record<number, string> = {};
|
|
||||||
for (const [taskId, value] of Object.entries(parsed || {})) {
|
|
||||||
const numericTaskId = Number(taskId);
|
|
||||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) continue;
|
|
||||||
if (typeof value !== "string" || !value.trim()) continue;
|
|
||||||
next[numericTaskId] = value;
|
|
||||||
}
|
|
||||||
taskStartTimes.value = next;
|
|
||||||
} catch {
|
} catch {
|
||||||
taskStartTimes.value = {};
|
taskStartTimes.value = {};
|
||||||
}
|
}
|
||||||
@@ -716,18 +726,13 @@ function loadTaskStartTimes() {
|
|||||||
|
|
||||||
function setTaskStartTime(taskId: number, startedAt = new Date().toISOString()) {
|
function setTaskStartTime(taskId: number, startedAt = new Date().toISOString()) {
|
||||||
if (!Number.isFinite(taskId) || taskId <= 0) return;
|
if (!Number.isFinite(taskId) || taskId <= 0) return;
|
||||||
taskStartTimes.value = {
|
taskStartTimes.value = upsertTaskStartTime(taskStartTimes.value, taskId, startedAt);
|
||||||
...taskStartTimes.value,
|
|
||||||
[taskId]: startedAt,
|
|
||||||
};
|
|
||||||
saveTaskStartTimes();
|
saveTaskStartTimes();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearTaskStartTime(taskId?: number | null) {
|
function clearTaskStartTime(taskId?: number | null) {
|
||||||
if (!taskId || !(taskId in taskStartTimes.value)) return;
|
if (!taskId || !(taskId in taskStartTimes.value)) return;
|
||||||
const next = { ...taskStartTimes.value };
|
taskStartTimes.value = removeTaskStartTime(taskStartTimes.value, taskId);
|
||||||
delete next[taskId];
|
|
||||||
taskStartTimes.value = next;
|
|
||||||
saveTaskStartTimes();
|
saveTaskStartTimes();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -753,11 +758,6 @@ function resetQueueWorkerIfIdle() {
|
|||||||
saveQueueState();
|
saveQueueState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecordMissingError(error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error || "");
|
|
||||||
return /记录不存在|任务不存在|不存在|已删除|not\s*found|404/i.test(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeHistoryItemLocally(item: WithdrawHistoryItem) {
|
function removeHistoryItemLocally(item: WithdrawHistoryItem) {
|
||||||
historyItems.value = historyItems.value.filter((row) => {
|
historyItems.value = historyItems.value.filter((row) => {
|
||||||
if (item.resultId != null && row.resultId === item.resultId) return false;
|
if (item.resultId != null && row.resultId === item.resultId) return false;
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { getModuleProgressLight, type ProgressLightModule } from './progress-light.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询用进度适配器(2026-09 全维度审查 F5)。
|
||||||
|
*
|
||||||
|
* 后端 12 个模块都提供了 /tasks/progress/light 轻量端点(只回 status/fileReady 等白名单字段),
|
||||||
|
* 但此前全站仍在轮询 /tasks/progress/batch(回全量任务详情)。等待任务终态的紧循环
|
||||||
|
* (每几秒一次)只需要 status,用轻量端点可显著减小响应体。
|
||||||
|
*
|
||||||
|
* 安全前提(务必确认后再接入):
|
||||||
|
* 1. 调用方只用 task.id / task.status / missingTaskIds(轻量白名单覆盖的字段);
|
||||||
|
* 2. 调用方把结果**部分合并**到已有完整快照上(`{...prev.task, ...detail.task}`),
|
||||||
|
* 这样缺失字段由旧快照兜底,不会把页面数据抹空。
|
||||||
|
*
|
||||||
|
* 不满足上述前提的路径(如历史列表合并、首屏加载)请继续用重型 batch 端点。
|
||||||
|
*/
|
||||||
|
export interface LightPollingItem {
|
||||||
|
task?: {
|
||||||
|
id?: number
|
||||||
|
status?: string | null
|
||||||
|
statusCode?: string | null
|
||||||
|
fileStatus?: string | null
|
||||||
|
fileError?: string | null
|
||||||
|
fileReady?: boolean | null
|
||||||
|
updatedAt?: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toBatchItems(lightItems: unknown[]): LightPollingItem[] {
|
||||||
|
const items: LightPollingItem[] = []
|
||||||
|
for (const raw of lightItems || []) {
|
||||||
|
const item = raw as { taskId?: number; status?: string | null; statusCode?: string | null
|
||||||
|
fileStatus?: string | null; fileError?: string | null; fileReady?: boolean | null
|
||||||
|
updatedAt?: string | null } | null
|
||||||
|
if (!item || typeof item.taskId !== 'number' || item.taskId <= 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 归一为重型端点的嵌套形状(各模块 batch 项都是 { task: {...} }),
|
||||||
|
// 保证调用方的 detail.task?.xxx 读取与部分合并逻辑不变
|
||||||
|
items.push({
|
||||||
|
task: {
|
||||||
|
id: item.taskId,
|
||||||
|
status: item.status ?? null,
|
||||||
|
statusCode: item.statusCode ?? null,
|
||||||
|
fileStatus: item.fileStatus ?? null,
|
||||||
|
fileError: item.fileError ?? null,
|
||||||
|
fileReady: item.fileReady ?? null,
|
||||||
|
updatedAt: item.updatedAt ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 各模块 batch 返回类型形状不同(`missingTaskIds` 可选/必填、明细类型各异),
|
||||||
|
* 这里按"带 items 与 missingTaskIds 的批次对象"约束,避免为每个模块写一份适配器。
|
||||||
|
*/
|
||||||
|
export interface PollingBatchLike<T> {
|
||||||
|
items?: T[]
|
||||||
|
missingTaskIds?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PollingProgressOptions<TBatch> {
|
||||||
|
/** 重型端点兜底(轻量端点异常或返回空时调用),不传则不兜底 */
|
||||||
|
fallback?: () => Promise<TBatch>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 纯函数:把轻量响应判定为可用的策略结果。
|
||||||
|
* 既没有可用条目也没有 missing 列表时返回 null(调用方据此走重型兜底,
|
||||||
|
* 避免后端未上线该模块 light 路由时页面静默拿不到状态)。
|
||||||
|
*/
|
||||||
|
export function pickPollingBatch<T>(
|
||||||
|
lightItems: unknown[] | null | undefined,
|
||||||
|
missingTaskIds: number[] | null | undefined,
|
||||||
|
): PollingBatchLike<T> | null {
|
||||||
|
const items = toBatchItems(lightItems || [])
|
||||||
|
const missing = missingTaskIds || []
|
||||||
|
if (!items.length && !missing.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return { items: items as unknown as T[], missingTaskIds: missing }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取任务进度:轻量端点优先,异常/空结果时回退重型端点。
|
||||||
|
* 轻量端点返回 empty(既没有 items 也没有 missingTaskIds)时也走兜底——
|
||||||
|
* 避免后端未上线该模块的 light 路由时页面静默拿不到状态。
|
||||||
|
*/
|
||||||
|
export async function getPollingProgressBatch<TBatch extends PollingBatchLike<unknown>>(
|
||||||
|
module: ProgressLightModule,
|
||||||
|
taskIds: number[],
|
||||||
|
options: PollingProgressOptions<TBatch> = {},
|
||||||
|
): Promise<TBatch> {
|
||||||
|
const fallback = options.fallback
|
||||||
|
try {
|
||||||
|
const light = await getModuleProgressLight(module, taskIds)
|
||||||
|
const picked = pickPollingBatch<unknown>(light.items, light.missingTaskIds)
|
||||||
|
if (picked) {
|
||||||
|
return picked as TBatch
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 落回重型端点(下面统一处理)
|
||||||
|
}
|
||||||
|
if (fallback) {
|
||||||
|
return fallback()
|
||||||
|
}
|
||||||
|
return { items: [], missingTaskIds: [] } as unknown as TBatch
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
<div v-if="!items.length" class="empty-tasks">{{ emptyText }}</div>
|
<div v-if="!items.length" class="empty-tasks">{{ emptyText }}</div>
|
||||||
<ul v-else class="history-list">
|
<ul v-else class="history-list">
|
||||||
<li v-for="item in items" :key="item.key" class="history-item-row" :class="{ 'is-selected': isSelected(item.key) }">
|
<li v-for="item in visibleHistoryItems" :key="item.key" class="history-item-row" :class="{ 'is-selected': isSelected(item.key) }">
|
||||||
<label v-if="onBatchDelete" class="row-check" @click.stop>
|
<label v-if="onBatchDelete" class="row-check" @click.stop>
|
||||||
<input v-model="selectedSet" type="checkbox" :value="item.key" :disabled="batchDeleting" />
|
<input v-model="selectedSet" type="checkbox" :value="item.key" :disabled="batchDeleting" />
|
||||||
</label>
|
</label>
|
||||||
@@ -39,6 +39,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<!-- 渲染截断(F6):超上限时只渲染前 N 条,避免历史很多时打开抽屉即产生大量 DOM -->
|
||||||
|
<div v-if="historySlice.capped || showAll" class="history-paging">
|
||||||
|
<button v-if="historySlice.capped" type="button" class="btn-more" @click="showAll = true">
|
||||||
|
显示全部(还有 {{ historySlice.hiddenCount }} 条)
|
||||||
|
</button>
|
||||||
|
<button v-else type="button" class="btn-more" @click="showAll = false">收起</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,6 +57,7 @@ import { ElMessageBox } from 'element-plus'
|
|||||||
|
|
||||||
import TaskItemCard from './TaskItemCard.vue'
|
import TaskItemCard from './TaskItemCard.vue'
|
||||||
import type { TaskItemView } from './types'
|
import type { TaskItemView } from './types'
|
||||||
|
import { sliceHistoryItems } from '../../utils/history-paging.ts'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
/** 历史任务数量(按钮角标) */
|
/** 历史任务数量(按钮角标) */
|
||||||
@@ -71,14 +79,21 @@ const open = ref(false)
|
|||||||
const batchDeleting = ref(false)
|
const batchDeleting = ref(false)
|
||||||
/** 勾选集合:以 TaskItemView.key 为维度 */
|
/** 勾选集合:以 TaskItemView.key 为维度 */
|
||||||
const selectedSet = ref<Set<string>>(new Set())
|
const selectedSet = ref<Set<string>>(new Set())
|
||||||
|
/** 是否展开全部历史(默认只渲染前 HISTORY_VISIBLE_LIMIT 条) */
|
||||||
|
const showAll = ref(false)
|
||||||
|
|
||||||
|
const historySlice = computed(() => sliceHistoryItems(props.items, showAll.value))
|
||||||
|
const visibleHistoryItems = computed(() => historySlice.value.visible)
|
||||||
|
|
||||||
const selectedKeys = computed(() => selectedSet.value)
|
const selectedKeys = computed(() => selectedSet.value)
|
||||||
const allSelected = computed({
|
const allSelected = computed({
|
||||||
get: () => Boolean(props.items.length) && selectedSet.value.size === props.items.length,
|
// 全选口径=当前可见条目(截断状态下就是屏幕上这几条),避免"全选"选中看不见的条目
|
||||||
|
get: () => Boolean(visibleHistoryItems.value.length)
|
||||||
|
&& visibleHistoryItems.value.every((item) => selectedSet.value.has(item.key)),
|
||||||
set: (checked: boolean) => {
|
set: (checked: boolean) => {
|
||||||
const next = new Set<string>()
|
const next = new Set<string>()
|
||||||
if (checked) {
|
if (checked) {
|
||||||
props.items.forEach((item) => next.add(item.key))
|
visibleHistoryItems.value.forEach((item) => next.add(item.key))
|
||||||
}
|
}
|
||||||
selectedSet.value = next
|
selectedSet.value = next
|
||||||
},
|
},
|
||||||
@@ -87,6 +102,8 @@ const allSelected = computed({
|
|||||||
watch(open, (value) => {
|
watch(open, (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
selectedSet.value = new Set()
|
selectedSet.value = new Set()
|
||||||
|
// 关闭后回到"只渲染前 N 条",下次打开不会因为上次展开过而全量渲染
|
||||||
|
showAll.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -133,6 +150,27 @@ async function confirmBatchDelete() {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-paging {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-more {
|
||||||
|
padding: 5px 14px;
|
||||||
|
border: 1px solid #3e4a62;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #242424;
|
||||||
|
color: #9fb0c8;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-more:hover {
|
||||||
|
background: #2b3447;
|
||||||
|
color: #f5f8fc;
|
||||||
|
}
|
||||||
|
|
||||||
.history-btn:hover {
|
.history-btn:hover {
|
||||||
background: #2b3447;
|
background: #2b3447;
|
||||||
color: #f5f8fc;
|
color: #f5f8fc;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { computed, ref, watch, type ComputedRef, type Ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表格分页(2026-09 全维度审查 F9)。
|
||||||
|
*
|
||||||
|
* 工具页的"匹配结果"表此前把整份数据绑到 el-table 的 :data 上,全量行常驻 DOM
|
||||||
|
* (每行 8+ 节点,几百行就会明显拖慢打开/滚动)。这里提供共用的分页切片:
|
||||||
|
* 只改变渲染的数据窗口,不改动数据源本身,因此挑选/删除/提交等逻辑不受影响。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 默认每页条数:足够一屏展示,又不会让 DOM 随列表规模线性膨胀。 */
|
||||||
|
export const DEFAULT_TABLE_PAGE_SIZE = 100
|
||||||
|
|
||||||
|
/** 纯函数切片:page 从 1 开始,越界自动收敛到有效范围。 */
|
||||||
|
export function paginateSlice<T>(items: T[] | null | undefined, page: number, pageSize: number): T[] {
|
||||||
|
const list = Array.isArray(items) ? items : []
|
||||||
|
const size = Number.isFinite(pageSize) && pageSize > 0 ? Math.floor(pageSize) : DEFAULT_TABLE_PAGE_SIZE
|
||||||
|
const pageCount = Math.max(1, Math.ceil(list.length / size))
|
||||||
|
const safePage = Math.min(Math.max(1, Math.floor(Number.isFinite(page) ? page : 1)), pageCount)
|
||||||
|
const start = (safePage - 1) * size
|
||||||
|
return list.slice(start, start + size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 有效页码(把越界页码收敛到 [1, pageCount])。 */
|
||||||
|
export function clampPage(page: number, total: number, pageSize: number): number {
|
||||||
|
const size = Number.isFinite(pageSize) && pageSize > 0 ? Math.floor(pageSize) : DEFAULT_TABLE_PAGE_SIZE
|
||||||
|
const pageCount = Math.max(1, Math.ceil((Number.isFinite(total) ? total : 0) / size))
|
||||||
|
return Math.min(Math.max(1, Math.floor(Number.isFinite(page) ? page : 1)), pageCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TablePaging<T> {
|
||||||
|
/** 当前页码(可 v-model:current-page 绑定) */
|
||||||
|
page: Ref<number>
|
||||||
|
/** 每页条数 */
|
||||||
|
pageSize: number
|
||||||
|
/** 数据总条数 */
|
||||||
|
total: ComputedRef<number>
|
||||||
|
/** 当前页数据(绑定到 el-table 的 :data) */
|
||||||
|
paged: ComputedRef<T[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于响应式数据源的分页状态。数据源变化(新增/删除行)时自动收敛页码,
|
||||||
|
* 避免删到空页后表格显示空白且无法回到有效页。
|
||||||
|
*/
|
||||||
|
export function useTablePaging<T>(
|
||||||
|
source: Ref<T[]> | ComputedRef<T[]>,
|
||||||
|
pageSize: number = DEFAULT_TABLE_PAGE_SIZE,
|
||||||
|
): TablePaging<T> {
|
||||||
|
const page = ref(1)
|
||||||
|
const total = computed(() => (Array.isArray(source.value) ? source.value.length : 0))
|
||||||
|
const paged = computed(() => paginateSlice(source.value, page.value, pageSize))
|
||||||
|
|
||||||
|
watch([total, () => pageSize], () => {
|
||||||
|
const next = clampPage(page.value, total.value, pageSize)
|
||||||
|
if (next !== page.value) {
|
||||||
|
page.value = next
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { page, pageSize, total, paged }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 历史任务抽屉的渲染截断(2026-09 全维度审查 F6)。
|
||||||
|
*
|
||||||
|
* 历史条目由页面从后端/本地缓存整批传入,抽屉此前全量渲染:条目多时(每卡 8+ 节点)
|
||||||
|
* 打开即产生大量 DOM,关闭后虽然 destroy-on-close 会销毁,但打开期间的首屏渲染成本
|
||||||
|
* 与内存占用仍随条数线性增长。这里统一按上限截断渲染,由 UI 提供「显示全部」入口。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 首屏渲染条数上限 */
|
||||||
|
export const HISTORY_VISIBLE_LIMIT = 50
|
||||||
|
|
||||||
|
export interface HistorySlice<T> {
|
||||||
|
/** 实际渲染的条目 */
|
||||||
|
visible: T[]
|
||||||
|
/** 被截断的条数(0 表示未截断) */
|
||||||
|
hiddenCount: number
|
||||||
|
/** 是否处于截断状态 */
|
||||||
|
capped: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sliceHistoryItems<T>(
|
||||||
|
items: T[] | null | undefined,
|
||||||
|
showAll: boolean,
|
||||||
|
limit: number = HISTORY_VISIBLE_LIMIT,
|
||||||
|
): HistorySlice<T> {
|
||||||
|
const list = Array.isArray(items) ? items : []
|
||||||
|
const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : HISTORY_VISIBLE_LIMIT
|
||||||
|
if (showAll || list.length <= safeLimit) {
|
||||||
|
return { visible: list, hiddenCount: 0, capped: false }
|
||||||
|
}
|
||||||
|
return { visible: list.slice(0, safeLimit), hiddenCount: list.length - safeLimit, capped: true }
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* 任务队列在工具页之间的共享纯逻辑(2026-09 全维度审查 G3)。
|
||||||
|
*
|
||||||
|
* 7 个工具页(query-asin / withdraw / price-track / product-risk / patrol-delete / shop-match /
|
||||||
|
* shop-data-crawl)各有一份逐字重复的「任务开始时间 localStorage 持久化 + 记录缺失错误判定」实现。
|
||||||
|
* 这里先收敛**无副作用、无页面状态依赖**的部分:序列化/反序列化校验、开始时间表增删、
|
||||||
|
* 记录缺失错误判定。队列 worker 的状态机(谁在跑、防抖、轮询句柄)仍留在各页,
|
||||||
|
* 那部分抽取需要真机走一遍队列流程,另行处理。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 反序列化:只保留正整数 taskId 与非空字符串时间戳,形状不符的条目丢弃。 */
|
||||||
|
export function parseTaskStartTimes(raw: string | null | undefined): Record<number, string> {
|
||||||
|
if (!raw) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
const next: Record<number, string> = {}
|
||||||
|
for (const [taskId, value] of Object.entries((parsed || {}) as Record<string, unknown>)) {
|
||||||
|
const numericTaskId = Number(taskId)
|
||||||
|
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) continue
|
||||||
|
if (typeof value !== 'string' || !value.trim()) continue
|
||||||
|
next[numericTaskId] = value
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入/覆盖某个任务的开始时间(返回新对象,不修改入参)。 */
|
||||||
|
export function upsertTaskStartTime(
|
||||||
|
current: Record<number, string> | null | undefined,
|
||||||
|
taskId: number,
|
||||||
|
startedAt: string,
|
||||||
|
): Record<number, string> {
|
||||||
|
if (!Number.isFinite(taskId) || taskId <= 0) {
|
||||||
|
return { ...(current || {}) }
|
||||||
|
}
|
||||||
|
return { ...(current || {}), [taskId]: startedAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移除某个任务的开始时间(不存在时返回原对象的浅拷贝)。 */
|
||||||
|
export function removeTaskStartTime(
|
||||||
|
current: Record<number, string> | null | undefined,
|
||||||
|
taskId?: number | null,
|
||||||
|
): Record<number, string> {
|
||||||
|
const base = { ...(current || {}) }
|
||||||
|
if (!taskId || !(taskId in base)) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
delete base[taskId]
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端「记录不存在」类错误的判定:历史/任务已被删除时,各页据此清理本地缓存并停止轮询。
|
||||||
|
* 匹配口径与各页原实现一致(含中文关键词与 HTTP 404 / not found)。
|
||||||
|
*/
|
||||||
|
export function isRecordMissingError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || '')
|
||||||
|
return /记录不存在|任务不存在|不存在|已删除|not\s*found|404/i.test(message)
|
||||||
|
}
|
||||||
@@ -143,3 +143,22 @@ html {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* 匹配结果表分页(F9):风格与深色工具页一致,避免 el-pagination 默认浅色冲突 */
|
||||||
|
.matched-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 8px 4px 0;
|
||||||
|
--el-pagination-bg-color: transparent;
|
||||||
|
--el-pagination-text-color: #c8d2e2;
|
||||||
|
--el-pagination-button-color: #c8d2e2;
|
||||||
|
--el-pagination-button-bg-color: #242424;
|
||||||
|
--el-pagination-button-disabled-color: #6b7789;
|
||||||
|
--el-pagination-button-disabled-bg-color: #1e1e1e;
|
||||||
|
--el-pagination-hover-color: #4da3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matched-pagination .el-pagination__total {
|
||||||
|
color: #9fb0c8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { HISTORY_VISIBLE_LIMIT, sliceHistoryItems } from '../src/shared/utils/history-paging.ts'
|
||||||
|
|
||||||
|
test('未超上限时全量渲染且不显示展开入口', () => {
|
||||||
|
const items = Array.from({ length: 10 }, (_, i) => ({ key: `k${i}` }))
|
||||||
|
|
||||||
|
const slice = sliceHistoryItems(items, false)
|
||||||
|
|
||||||
|
assert.equal(slice.visible.length, 10)
|
||||||
|
assert.equal(slice.hiddenCount, 0)
|
||||||
|
assert.equal(slice.capped, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('超过上限时只渲染前 N 条并给出剩余条数', () => {
|
||||||
|
const items = Array.from({ length: HISTORY_VISIBLE_LIMIT + 7 }, (_, i) => ({ key: `k${i}` }))
|
||||||
|
|
||||||
|
const slice = sliceHistoryItems(items, false)
|
||||||
|
|
||||||
|
assert.equal(slice.visible.length, HISTORY_VISIBLE_LIMIT)
|
||||||
|
assert.equal(slice.hiddenCount, 7)
|
||||||
|
assert.equal(slice.capped, true)
|
||||||
|
assert.equal(slice.visible[0].key, 'k0')
|
||||||
|
assert.equal(slice.visible.at(-1)?.key, `k${HISTORY_VISIBLE_LIMIT - 1}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('展开全部后不再截断', () => {
|
||||||
|
const items = Array.from({ length: HISTORY_VISIBLE_LIMIT + 3 }, (_, i) => ({ key: `k${i}` }))
|
||||||
|
|
||||||
|
const slice = sliceHistoryItems(items, true)
|
||||||
|
|
||||||
|
assert.equal(slice.visible.length, items.length)
|
||||||
|
assert.equal(slice.hiddenCount, 0)
|
||||||
|
assert.equal(slice.capped, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('恰好等于上限时不算截断', () => {
|
||||||
|
const items = Array.from({ length: HISTORY_VISIBLE_LIMIT }, (_, i) => ({ key: `k${i}` }))
|
||||||
|
|
||||||
|
const slice = sliceHistoryItems(items, false)
|
||||||
|
|
||||||
|
assert.equal(slice.capped, false)
|
||||||
|
assert.equal(slice.visible.length, HISTORY_VISIBLE_LIMIT)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('空值与非法上限安全降级', () => {
|
||||||
|
assert.deepEqual(sliceHistoryItems(null, false), { visible: [], hiddenCount: 0, capped: false })
|
||||||
|
assert.deepEqual(sliceHistoryItems(undefined, false), { visible: [], hiddenCount: 0, capped: false })
|
||||||
|
assert.deepEqual(sliceHistoryItems([], false), { visible: [], hiddenCount: 0, capped: false })
|
||||||
|
|
||||||
|
const items = [{ key: 'a' }, { key: 'b' }]
|
||||||
|
const slice = sliceHistoryItems(items, false, 0)
|
||||||
|
assert.equal(slice.visible.length, 2, '非法上限回退到默认上限')
|
||||||
|
assert.equal(slice.capped, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('自定义上限生效', () => {
|
||||||
|
const items = Array.from({ length: 5 }, (_, i) => i)
|
||||||
|
|
||||||
|
const slice = sliceHistoryItems(items, false, 2)
|
||||||
|
|
||||||
|
assert.deepEqual(slice.visible, [0, 1])
|
||||||
|
assert.equal(slice.hiddenCount, 3)
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { DEFAULT_TABLE_PAGE_SIZE, clampPage, paginateSlice } from '../src/shared/composables/useTablePaging.ts'
|
||||||
|
|
||||||
|
const items = (n) => Array.from({ length: n }, (_, i) => i + 1)
|
||||||
|
|
||||||
|
test('切片返回当前页窗口', () => {
|
||||||
|
assert.deepEqual(paginateSlice(items(250), 1, 100), items(100))
|
||||||
|
assert.deepEqual(paginateSlice(items(250), 3, 100), items(250).slice(200, 250), '末页只到最后一行为止')
|
||||||
|
assert.deepEqual(paginateSlice(items(5), 2, 100), items(5), '只有一页时任何页码都落在首页')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('越界页码自动收敛到有效范围', () => {
|
||||||
|
assert.deepEqual(paginateSlice(items(250), 99, 100), items(250).slice(200, 250), '超出末页回到末页')
|
||||||
|
assert.deepEqual(paginateSlice(items(250), 0, 100), items(100), '小于 1 回到首页')
|
||||||
|
assert.deepEqual(paginateSlice(items(250), -3, 100), items(100))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('非法分页参数安全降级', () => {
|
||||||
|
assert.deepEqual(paginateSlice(null, 1, 100), [])
|
||||||
|
assert.deepEqual(paginateSlice(undefined, 1, 100), [])
|
||||||
|
assert.equal(paginateSlice(items(10), 1, 0).length, 10, '非法 pageSize 回退默认值')
|
||||||
|
assert.equal(paginateSlice(items(DEFAULT_TABLE_PAGE_SIZE + 1), 1, Number.NaN).length, DEFAULT_TABLE_PAGE_SIZE)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clampPage 与切片口径一致', () => {
|
||||||
|
assert.equal(clampPage(1, 250, 100), 1)
|
||||||
|
assert.equal(clampPage(3, 250, 100), 3)
|
||||||
|
assert.equal(clampPage(9, 250, 100), 3)
|
||||||
|
assert.equal(clampPage(0, 0, 100), 1, '空列表仍有 1 页')
|
||||||
|
assert.equal(clampPage(5, 0, 100), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('恰好整除时不多出空页', () => {
|
||||||
|
assert.equal(clampPage(2, 200, 100), 2)
|
||||||
|
assert.equal(clampPage(3, 200, 100), 2)
|
||||||
|
assert.deepEqual(paginateSlice(items(200), 2, 100).length, 100)
|
||||||
|
})
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { pickPollingBatch, toBatchItems } from '../src/shared/api/task-progress-polling.ts'
|
||||||
|
|
||||||
|
test('轻量项归一为重型 batch 的嵌套形状', () => {
|
||||||
|
const items = toBatchItems([
|
||||||
|
{ taskId: 7, status: 'RUNNING', fileStatus: 'PENDING', fileReady: false, updatedAt: '2026-09-14T06:00:00' },
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.equal(items.length, 1)
|
||||||
|
assert.deepEqual(items[0].task, {
|
||||||
|
id: 7,
|
||||||
|
status: 'RUNNING',
|
||||||
|
statusCode: null,
|
||||||
|
fileStatus: 'PENDING',
|
||||||
|
fileError: null,
|
||||||
|
fileReady: false,
|
||||||
|
updatedAt: '2026-09-14T06:00:00',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('缺少 taskId 的项被丢弃,非法 taskId 同样丢弃', () => {
|
||||||
|
const items = toBatchItems([{ status: 'RUNNING' }, { taskId: 0, status: 'RUNNING' }, { taskId: 3 }])
|
||||||
|
|
||||||
|
assert.equal(items.length, 1)
|
||||||
|
assert.equal(items[0].task?.id, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('有条目时返回可用结果', () => {
|
||||||
|
const picked = pickPollingBatch([{ taskId: 9, status: 'SUCCESS' }], [])
|
||||||
|
|
||||||
|
assert.notEqual(picked, null)
|
||||||
|
assert.equal(picked?.items.length, 1)
|
||||||
|
assert.deepEqual(picked?.missingTaskIds, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('只有 missing 列表也算可用结果(任务已删除场景)', () => {
|
||||||
|
const picked = pickPollingBatch([], [11, 12])
|
||||||
|
|
||||||
|
assert.notEqual(picked, null)
|
||||||
|
assert.equal(picked?.items.length, 0)
|
||||||
|
assert.deepEqual(picked?.missingTaskIds, [11, 12])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('空响应返回 null,调用方据此回退重型端点', () => {
|
||||||
|
assert.equal(pickPollingBatch([], []), null)
|
||||||
|
assert.equal(pickPollingBatch(null, null), null)
|
||||||
|
assert.equal(pickPollingBatch(undefined, undefined), null)
|
||||||
|
assert.equal(pickPollingBatch([{ status: 'RUNNING' }], []), null, '全是非法项时同样回退')
|
||||||
|
})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import {
|
||||||
|
isRecordMissingError,
|
||||||
|
parseTaskStartTimes,
|
||||||
|
removeTaskStartTime,
|
||||||
|
upsertTaskStartTime,
|
||||||
|
} from '../src/shared/utils/task-queue-state.ts'
|
||||||
|
|
||||||
|
test('解析开始时间表:保留正整数 id 与非空时间戳', () => {
|
||||||
|
const parsed = parseTaskStartTimes(JSON.stringify({ '12': '2026-09-14T00:00:00Z', '0': 'x', '-3': 'y', 'abc': 'z', '15': ' ' }))
|
||||||
|
|
||||||
|
assert.deepEqual(parsed, { 12: '2026-09-14T00:00:00Z' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('解析开始时间表:坏 JSON 与非字符串值安全降级', () => {
|
||||||
|
assert.deepEqual(parseTaskStartTimes('{bad json'), {})
|
||||||
|
assert.deepEqual(parseTaskStartTimes(null), {})
|
||||||
|
assert.deepEqual(parseTaskStartTimes(undefined), {})
|
||||||
|
assert.deepEqual(parseTaskStartTimes('{"7": 123, "8": null}'), {})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('写入开始时间:非法 taskId 不改动内容且不共享引用', () => {
|
||||||
|
const base = { 1: 'a' }
|
||||||
|
const next = upsertTaskStartTime(base, 0, 'b')
|
||||||
|
|
||||||
|
assert.deepEqual(next, { 1: 'a' })
|
||||||
|
assert.notEqual(next, base)
|
||||||
|
|
||||||
|
assert.deepEqual(upsertTaskStartTime(base, 2, 'b'), { 1: 'a', 2: 'b' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('移除开始时间:不存在时返回内容相同的新对象', () => {
|
||||||
|
const base = { 1: 'a' }
|
||||||
|
const next = removeTaskStartTime(base, 9)
|
||||||
|
|
||||||
|
assert.deepEqual(next, { 1: 'a' })
|
||||||
|
assert.notEqual(next, base)
|
||||||
|
assert.deepEqual(removeTaskStartTime(base, 1), {})
|
||||||
|
assert.deepEqual(removeTaskStartTime(base, null), { 1: 'a' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('记录缺失错误判定:中文关键词与 404 命中', () => {
|
||||||
|
assert.equal(isRecordMissingError(new Error('记录不存在')), true)
|
||||||
|
assert.equal(isRecordMissingError(new Error('任务不存在')), true)
|
||||||
|
assert.equal(isRecordMissingError(new Error('该记录已删除')), true)
|
||||||
|
assert.equal(isRecordMissingError(new Error('request failed with 404')), true)
|
||||||
|
assert.equal(isRecordMissingError(new Error('Not Found')), true)
|
||||||
|
assert.equal(isRecordMissingError('不存在'), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('记录缺失错误判定:其它错误不误判', () => {
|
||||||
|
assert.equal(isRecordMissingError(new Error('服务器繁忙,请稍后重试')), false)
|
||||||
|
assert.equal(isRecordMissingError(new Error('网络超时')), false)
|
||||||
|
assert.equal(isRecordMissingError(null), false)
|
||||||
|
assert.equal(isRecordMissingError(undefined), false)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user