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:
2026-09-14 06:23:46 +08:00
parent e76714c32e
commit 67223f8950
18 changed files with 684 additions and 69 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 166 KiB

@@ -162,7 +162,7 @@
</div>
<el-table
v-else
:data="matchedItems"
:data="pagedMatchedItems"
:row-key="rowKeyForMatch"
:highlight-current-row="false"
class="result-table match-table"
@@ -215,6 +215,14 @@
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="matchedTotal > matchedPageSize"
class="matched-pagination"
layout="total, prev, pager, next"
:total="matchedTotal"
:page-size="matchedPageSize"
v-model:current-page="matchedPage"
/>
</template>
<template #item-actions="{ item }">
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
@@ -275,6 +283,8 @@ import {
import { formatDateTime } from '@/shared/utils/datetime'
import { useHistoryPolling } from '@/shared/composables/useHistoryPolling'
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;
/** 任务终态后等待结果文件(Java 侧异步生成)的最大轮次,12 × 10s ≈ 2 分钟 */
@@ -290,6 +300,14 @@ const taskConditionTexts = ref<string[]>([]);
const conditionSelectValue = ref("");
const selectedCountryCodes = ref<string[]>([...EU_COUNTRY_CODES]);
const matchedItems = ref<PatrolDeleteShopQueueItem[]>([]);
// F9:匹配结果表分页(只切渲染窗口,不加选择语义)
const {
page: matchedPage,
pageSize: matchedPageSize,
total: matchedTotal,
paged: pagedMatchedItems,
} = useTablePaging(matchedItems)
const historyItems = ref<PatrolDeleteHistoryItem[]>([]);
const dashboard = ref<PatrolDeleteDashboardVo>({
candidateCount: 0,
@@ -713,11 +731,6 @@ function clearActiveQueueTask() {
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);
@@ -164,7 +164,7 @@
<template #cards-extra>
<div class="subsection-title">匹配结果</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">
<el-table-column prop="shopName" label="店铺名" min-width="100" />
<el-table-column label="匹配" width="72" align="center">
@@ -187,6 +187,14 @@
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="matchedTotal > matchedPageSize"
class="matched-pagination"
layout="total, prev, pager, next"
:total="matchedTotal"
:page-size="matchedPageSize"
v-model:current-page="matchedPage"
/>
</template>
<template #item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@@ -257,9 +265,12 @@ import {
type PriceTrackShopQueueItem,
type PriceTrackTaskDetailVo,
} from '@/shared/api/java-modules'
import { getPollingProgressBatch } from '@/shared/api/task-progress-polling.ts'
import { formatDateTime } from '@/shared/utils/datetime'
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
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()
@@ -277,6 +288,14 @@ const shopInput = ref('')
const candidates = ref<PriceTrackCandidateVo[]>([])
const selectedCandidates = ref<PriceTrackCandidateVo[]>([])
const matchedItems = ref<PriceTrackShopQueueItem[]>([])
// F9:匹配结果表分页(只切渲染窗口,不加选择语义)
const {
page: matchedPage,
pageSize: matchedPageSize,
total: matchedTotal,
paged: pagedMatchedItems,
} = useTablePaging(matchedItems)
const adding = ref(false)
const matching = 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 ?? ''}`
}
function isRecordMissingError(error: unknown) {
const message = error instanceof Error ? error.message : String(error || '')
return /记录不存在|不存在|已删除|not\s*found|404/i.test(message)
}
function priceTrackModeForAppClient() {
return statusModeEnabled.value ? 'status' : 'asin'
}
@@ -1006,7 +1020,10 @@ async function waitForTaskTerminal(taskId: number) {
while (true) {
if (disposed) return 'STOPPED'
try {
const batch = await getPriceTrackTaskProgressBatch([taskId])
// F5:等待终态的紧循环只读 status(轻量白名单覆盖),走 light 端点减小轮询响应体,异常回退重型 batch
const batch = await getPollingProgressBatch('priceTrack', [taskId], {
fallback: () => getPriceTrackTaskProgressBatch([taskId]),
})
if (transientErrorCount > 0) {
queuePushResult.value = `任务 ${taskId} 服务已恢复,继续等待执行结果...`
}
@@ -97,7 +97,7 @@
<template #cards-extra>
<div class="subsection-title">匹配结果</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">
<el-table-column prop="shopName" label="店铺名" min-width="100" />
<el-table-column label="匹配" width="72" align="center">
@@ -120,6 +120,14 @@
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="matchedTotal > matchedPageSize"
class="matched-pagination"
layout="total, prev, pager, next"
:total="matchedTotal"
:page-size="matchedPageSize"
v-model:current-page="matchedPage"
/>
</template>
<template #item-actions="{ item }">
<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 { formatDateTime } from '@/shared/utils/datetime'
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 candidates = ref<ProductRiskCandidateVo[]>([])
const selectedCandidates = ref<ProductRiskCandidateVo[]>([])
const matchedItems = ref<ProductRiskShopQueueItem[]>([])
// F9:匹配结果表分页(只切渲染窗口,不加选择语义)
const {
page: matchedPage,
pageSize: matchedPageSize,
total: matchedTotal,
paged: pagedMatchedItems,
} = useTablePaging(matchedItems)
const adding = ref(false)
const matching = ref(false)
const pushing = ref(false)
@@ -443,11 +461,6 @@ function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | nul
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 }>) {
if (!rows.length) return
const keys = new Set(rows.map((row) => rowKeyForMatch(row)))
@@ -111,7 +111,7 @@
</div>
<el-table
v-else
:data="matchedItems"
:data="pagedMatchedItems"
:row-key="rowKeyForMatch"
:highlight-current-row="false"
class="result-table match-table"
@@ -164,6 +164,14 @@
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="matchedTotal > matchedPageSize"
class="matched-pagination"
layout="total, prev, pager, next"
:total="matchedTotal"
:page-size="matchedPageSize"
v-model:current-page="matchedPage"
/>
</template>
<template #item-actions="{ item }">
<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 { useHistoryPolling } from '@/shared/composables/useHistoryPolling'
import { runBatchDelete } from '@/shared/utils/batch-delete'
import { isRecordMissingError, parseTaskStartTimes, removeTaskStartTime, upsertTaskStartTime } from '@/shared/utils/task-queue-state.ts'
import { useTablePaging } from '@/shared/composables/useTablePaging.ts'
const MAX_TRANSIENT_ERRORS = 30;
const ziniaoVersion = useZiniaoVersion();
@@ -225,6 +235,14 @@ const shopInput = ref("");
const candidates = ref<QueryAsinCandidateVo[]>([]);
const selectedCandidates = ref<QueryAsinCandidateVo[]>([]);
const matchedItems = ref<QueryAsinShopQueueItem[]>([]);
// F9:匹配结果表分页(只切渲染窗口,不加选择语义)
const {
page: matchedPage,
pageSize: matchedPageSize,
total: matchedTotal,
paged: pagedMatchedItems,
} = useTablePaging(matchedItems)
const historyItems = ref<QueryAsinHistoryItem[]>([]);
const dashboard = ref<QueryAsinDashboardVo>({
candidateCount: 0,
@@ -560,15 +578,7 @@ function loadTaskStartTimes() {
typeof window !== "undefined"
? window.localStorage.getItem(taskStartTimeStorageKey())
: null;
const parsed = raw ? JSON.parse(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;
taskStartTimes.value = parseTaskStartTimes(raw);
} catch {
taskStartTimes.value = {};
}
@@ -576,18 +586,13 @@ function loadTaskStartTimes() {
function setTaskStartTime(taskId: number, startedAt = new Date().toISOString()) {
if (!Number.isFinite(taskId) || taskId <= 0) return;
taskStartTimes.value = {
...taskStartTimes.value,
[taskId]: startedAt,
};
taskStartTimes.value = upsertTaskStartTime(taskStartTimes.value, taskId, startedAt);
saveTaskStartTimes();
}
function clearTaskStartTime(taskId?: number | null) {
if (!taskId || !(taskId in taskStartTimes.value)) return;
const next = { ...taskStartTimes.value };
delete next[taskId];
taskStartTimes.value = next;
taskStartTimes.value = removeTaskStartTime(taskStartTimes.value, taskId);
saveTaskStartTimes();
}
@@ -613,11 +618,6 @@ function resetQueueWorkerIfIdle() {
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) {
historyItems.value = historyItems.value.filter((row) => {
if (item.resultId != null && row.resultId === item.resultId) return false;
@@ -78,7 +78,7 @@
<template #cards-extra>
<div class="subsection-title">匹配结果</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">
<el-table-column prop="shopName" label="店铺名" min-width="120" />
<el-table-column label="匹配" width="64" align="center">
@@ -93,6 +93,14 @@
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
</template></el-table-column>
</el-table>
<el-pagination
v-if="matchedTotal > matchedPageSize"
class="matched-pagination"
layout="total, prev, pager, next"
:total="matchedTotal"
:page-size="matchedPageSize"
v-model:current-page="matchedPage"
/>
</template>
<template #item-extra="{ item }">
<div v-if="itemSource(item).error" class="files error-text">错误{{ itemSource(item).error }}</div>
@@ -153,6 +161,7 @@ import {
type ShopDataCrawlTaskDetailVo,
} from '@/shared/api/java-modules'
import { runBatchDelete } from '@/shared/utils/batch-delete'
import { useTablePaging } from '@/shared/composables/useTablePaging.ts'
const COUNTRY_OPTIONS = [
{ code: 'UK', label: '英国' },
@@ -168,6 +177,14 @@ const shopInput = ref('')
const candidates = ref<ShopDataCrawlCandidateVo[]>([])
const selectedCandidates = ref<ShopDataCrawlCandidateVo[]>([])
const matchedItems = ref<ShopDataCrawlShopItem[]>([])
// F9:匹配结果表分页(只切渲染窗口,不加选择语义)
const {
page: matchedPage,
pageSize: matchedPageSize,
total: matchedTotal,
paged: pagedMatchedItems,
} = useTablePaging(matchedItems)
const historyItems = ref<ShopDataCrawlHistoryItem[]>([])
const dashboard = ref<ShopDataCrawlDashboardVo>({ candidateCount: 0, processedTaskCount: 0, successTaskCount: 0, failedTaskCount: 0 })
const orderedCountryCodes = ref<string[]>(COUNTRY_OPTIONS.map((row) => row.code))
@@ -77,7 +77,7 @@
<template #cards-extra>
<div class="subsection-title">匹配结果</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 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 />
@@ -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="操作" width="72" align="center"><template #default="{ row }"><button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button></template></el-table-column>
</el-table>
<el-pagination
v-if="matchedTotal > matchedPageSize"
class="matched-pagination"
layout="total, prev, pager, next"
:total="matchedTotal"
:page-size="matchedPageSize"
v-model:current-page="matchedPage"
/>
</template>
<template #item-extra="{ item }">
<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 { 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 { getPollingProgressBatch } from '@/shared/api/task-progress-polling.ts'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
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 shopInput = ref('')
const candidates = ref<ShopMatchCandidateVo[]>([])
const selectedCandidates = ref<ShopMatchCandidateVo[]>([])
const matchedItems = ref<ShopMatchShopQueueItem[]>([])
// F9:匹配结果表分页(只切渲染窗口,不加选择语义)
const {
page: matchedPage,
pageSize: matchedPageSize,
total: matchedTotal,
paged: pagedMatchedItems,
} = useTablePaging(matchedItems)
const shopMatchListingFilter = ref<ListingFilterValue>('Active')
const adding = 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 ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
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) {
let transientErrorCount = 0
const maxTransientErrors = 30
@@ -115,7 +115,7 @@
</div>
<div v-else class="match-zone-scroll">
<el-table
:data="matchedItems"
:data="pagedMatchedItems"
:row-key="rowKeyForMatch"
:highlight-current-row="false"
class="result-table match-table"
@@ -168,6 +168,14 @@
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="matchedTotal > matchedPageSize"
class="matched-pagination"
layout="total, prev, pager, next"
:total="matchedTotal"
:page-size="matchedPageSize"
v-model:current-page="matchedPage"
/>
</div>
</div>
@@ -257,6 +265,8 @@ import { useZiniaoVersion } from "@/shared/utils/ziniao-version";
import { formatDateTime } from '@/shared/utils/datetime'
import { useHistoryPolling } from '@/shared/composables/useHistoryPolling'
import { runBatchDelete } from '@/shared/utils/batch-delete'
import { isRecordMissingError, parseTaskStartTimes, removeTaskStartTime, upsertTaskStartTime } from '@/shared/utils/task-queue-state.ts'
import { useTablePaging } from '@/shared/composables/useTablePaging.ts'
const MAX_TRANSIENT_ERRORS = 30;
const ziniaoVersion = useZiniaoVersion();
@@ -276,6 +286,14 @@ const reservedAmount = ref(0);
const candidates = ref<WithdrawCandidateVo[]>([]);
const selectedCandidates = ref<WithdrawCandidateVo[]>([]);
const matchedItems = ref<WithdrawShopQueueItem[]>([]);
// F9:匹配结果表分页(只切渲染窗口,不加选择语义)
const {
page: matchedPage,
pageSize: matchedPageSize,
total: matchedTotal,
paged: pagedMatchedItems,
} = useTablePaging(matchedItems)
const historyItems = ref<WithdrawHistoryItem[]>([]);
const dashboard = ref<WithdrawDashboardVo>({
candidateCount: 0,
@@ -700,15 +718,7 @@ function loadTaskStartTimes() {
typeof window !== "undefined"
? window.localStorage.getItem(taskStartTimeStorageKey())
: null;
const parsed = raw ? JSON.parse(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;
taskStartTimes.value = parseTaskStartTimes(raw);
} catch {
taskStartTimes.value = {};
}
@@ -716,18 +726,13 @@ function loadTaskStartTimes() {
function setTaskStartTime(taskId: number, startedAt = new Date().toISOString()) {
if (!Number.isFinite(taskId) || taskId <= 0) return;
taskStartTimes.value = {
...taskStartTimes.value,
[taskId]: startedAt,
};
taskStartTimes.value = upsertTaskStartTime(taskStartTimes.value, taskId, startedAt);
saveTaskStartTimes();
}
function clearTaskStartTime(taskId?: number | null) {
if (!taskId || !(taskId in taskStartTimes.value)) return;
const next = { ...taskStartTimes.value };
delete next[taskId];
taskStartTimes.value = next;
taskStartTimes.value = removeTaskStartTime(taskStartTimes.value, taskId);
saveTaskStartTimes();
}
@@ -753,11 +758,6 @@ function resetQueueWorkerIfIdle() {
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) {
historyItems.value = historyItems.value.filter((row) => {
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>
<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>
<input v-model="selectedSet" type="checkbox" :value="item.key" :disabled="batchDeleting" />
</label>
@@ -39,6 +39,13 @@
</div>
</li>
</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>
</el-drawer>
</div>
@@ -50,6 +57,7 @@ import { ElMessageBox } from 'element-plus'
import TaskItemCard from './TaskItemCard.vue'
import type { TaskItemView } from './types'
import { sliceHistoryItems } from '../../utils/history-paging.ts'
const props = withDefaults(defineProps<{
/** 历史任务数量(按钮角标) */
@@ -71,14 +79,21 @@ const open = ref(false)
const batchDeleting = ref(false)
/** 勾选集合:以 TaskItemView.key 为维度 */
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 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) => {
const next = new Set<string>()
if (checked) {
props.items.forEach((item) => next.add(item.key))
visibleHistoryItems.value.forEach((item) => next.add(item.key))
}
selectedSet.value = next
},
@@ -87,6 +102,8 @@ const allSelected = computed({
watch(open, (value) => {
if (!value) {
selectedSet.value = new Set()
// " N "
showAll.value = false
}
})
@@ -133,6 +150,27 @@ async function confirmBatchDelete() {
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 {
background: #2b3447;
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)
}
+19
View File
@@ -143,3 +143,22 @@ html {
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;
}
+64
View File
@@ -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)
})
+38
View File
@@ -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)
})