(light.items, light.missingTaskIds)
+ if (picked) {
+ return picked as TBatch
+ }
+ } catch {
+ // 落回重型端点(下面统一处理)
+ }
+ if (fallback) {
+ return fallback()
+ }
+ return { items: [], missingTaskIds: [] } as unknown as TBatch
+}
diff --git a/frontend-vue/src/shared/components/tasks/HistoryTaskLayer.vue b/frontend-vue/src/shared/components/tasks/HistoryTaskLayer.vue
index e238ab56..99de3ccc 100644
--- a/frontend-vue/src/shared/components/tasks/HistoryTaskLayer.vue
+++ b/frontend-vue/src/shared/components/tasks/HistoryTaskLayer.vue
@@ -23,7 +23,7 @@
{{ emptyText }}
+
+
+
+
+
@@ -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>(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()
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;
diff --git a/frontend-vue/src/shared/composables/useTablePaging.ts b/frontend-vue/src/shared/composables/useTablePaging.ts
new file mode 100644
index 00000000..26ec6bf9
--- /dev/null
+++ b/frontend-vue/src/shared/composables/useTablePaging.ts
@@ -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(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 {
+ /** 当前页码(可 v-model:current-page 绑定) */
+ page: Ref
+ /** 每页条数 */
+ pageSize: number
+ /** 数据总条数 */
+ total: ComputedRef
+ /** 当前页数据(绑定到 el-table 的 :data) */
+ paged: ComputedRef
+}
+
+/**
+ * 基于响应式数据源的分页状态。数据源变化(新增/删除行)时自动收敛页码,
+ * 避免删到空页后表格显示空白且无法回到有效页。
+ */
+export function useTablePaging(
+ source: Ref | ComputedRef,
+ pageSize: number = DEFAULT_TABLE_PAGE_SIZE,
+): TablePaging {
+ 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 }
+}
diff --git a/frontend-vue/src/shared/utils/history-paging.ts b/frontend-vue/src/shared/utils/history-paging.ts
new file mode 100644
index 00000000..af9a1d63
--- /dev/null
+++ b/frontend-vue/src/shared/utils/history-paging.ts
@@ -0,0 +1,32 @@
+/**
+ * 历史任务抽屉的渲染截断(2026-09 全维度审查 F6)。
+ *
+ * 历史条目由页面从后端/本地缓存整批传入,抽屉此前全量渲染:条目多时(每卡 8+ 节点)
+ * 打开即产生大量 DOM,关闭后虽然 destroy-on-close 会销毁,但打开期间的首屏渲染成本
+ * 与内存占用仍随条数线性增长。这里统一按上限截断渲染,由 UI 提供「显示全部」入口。
+ */
+
+/** 首屏渲染条数上限 */
+export const HISTORY_VISIBLE_LIMIT = 50
+
+export interface HistorySlice {
+ /** 实际渲染的条目 */
+ visible: T[]
+ /** 被截断的条数(0 表示未截断) */
+ hiddenCount: number
+ /** 是否处于截断状态 */
+ capped: boolean
+}
+
+export function sliceHistoryItems(
+ items: T[] | null | undefined,
+ showAll: boolean,
+ limit: number = HISTORY_VISIBLE_LIMIT,
+): HistorySlice {
+ 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 }
+}
diff --git a/frontend-vue/src/shared/utils/task-queue-state.ts b/frontend-vue/src/shared/utils/task-queue-state.ts
new file mode 100644
index 00000000..b2992113
--- /dev/null
+++ b/frontend-vue/src/shared/utils/task-queue-state.ts
@@ -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 {
+ if (!raw) {
+ return {}
+ }
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(raw)
+ } catch {
+ return {}
+ }
+ const next: Record = {}
+ for (const [taskId, value] of Object.entries((parsed || {}) as Record)) {
+ 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 | null | undefined,
+ taskId: number,
+ startedAt: string,
+): Record {
+ if (!Number.isFinite(taskId) || taskId <= 0) {
+ return { ...(current || {}) }
+ }
+ return { ...(current || {}), [taskId]: startedAt }
+}
+
+/** 移除某个任务的开始时间(不存在时返回原对象的浅拷贝)。 */
+export function removeTaskStartTime(
+ current: Record | null | undefined,
+ taskId?: number | null,
+): Record {
+ 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)
+}
diff --git a/frontend-vue/src/styles/main.css b/frontend-vue/src/styles/main.css
index e4326260..c28f523c 100644
--- a/frontend-vue/src/styles/main.css
+++ b/frontend-vue/src/styles/main.css
@@ -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;
+}
diff --git a/frontend-vue/tests/history-paging.test.ts b/frontend-vue/tests/history-paging.test.ts
new file mode 100644
index 00000000..806fe9a5
--- /dev/null
+++ b/frontend-vue/tests/history-paging.test.ts
@@ -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)
+})
diff --git a/frontend-vue/tests/table-paging.test.ts b/frontend-vue/tests/table-paging.test.ts
new file mode 100644
index 00000000..8d604e75
--- /dev/null
+++ b/frontend-vue/tests/table-paging.test.ts
@@ -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)
+})
diff --git a/frontend-vue/tests/task-progress-polling.test.ts b/frontend-vue/tests/task-progress-polling.test.ts
new file mode 100644
index 00000000..fb0bfcf3
--- /dev/null
+++ b/frontend-vue/tests/task-progress-polling.test.ts
@@ -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, '全是非法项时同样回退')
+})
diff --git a/frontend-vue/tests/task-queue-state.test.ts b/frontend-vue/tests/task-queue-state.test.ts
new file mode 100644
index 00000000..aea35d55
--- /dev/null
+++ b/frontend-vue/tests/task-queue-state.test.ts
@@ -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)
+})