task-88: 解析结果前端只接收预览数据,避免大 payload 进入响应式对象
新增纯 TS 预览裁剪 parse-preview:把解析 Vo 裁剪为摘要字段 (taskId/行数统计/开关/prompt),items/groups 整包剔除,可按 previewRowLimit/previewGroupLimit 有界保留样本行供表格展示。输入 对象永不修改,taskId 非法 fail-fast,属性读取抛错时零部分结果。 BrandSimilarAsinTab 的 parseFiles 改为只接收裁剪后的预览(保留 200 行样本),数千行 items 不再进入 Vue 响应式 ref。8 个测试覆盖 默认、批量、幂等、空、单元素、截断溢出、非法输入与 getter 故障。
This commit is contained in:
@@ -212,6 +212,7 @@ import { getStoredApiSecret } from '@/shared/utils/api-secret-store'
|
|||||||
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 { createAsinForceThrottle } from '@/shared/asin-force-throttle'
|
import { createAsinForceThrottle } from '@/shared/asin-force-throttle'
|
||||||
|
import { toParsePreview, type ParsePreviewOptions } from '@/shared/parse-preview'
|
||||||
|
|
||||||
const selectedFileNames = ref<string[]>([])
|
const selectedFileNames = ref<string[]>([])
|
||||||
const uploadedFiles = ref<UploadFileVo[]>([])
|
const uploadedFiles = ref<UploadFileVo[]>([])
|
||||||
@@ -472,7 +473,9 @@ async function parseFiles() {
|
|||||||
relativePath: f.relativePath,
|
relativePath: f.relativePath,
|
||||||
}))
|
}))
|
||||||
const res = await parseSimilarAsin(files, effectiveCozeApiKey(), imgSwitch.value, categorySwitch.value)
|
const res = await parseSimilarAsin(files, effectiveCozeApiKey(), imgSwitch.value, categorySwitch.value)
|
||||||
parseResult.value = res
|
// 只保留摘要字段与有界预览行,避免数千行 items/groups 进入响应式对象
|
||||||
|
const previewOptions: ParsePreviewOptions = { previewRowLimit: 200 }
|
||||||
|
parseResult.value = toParsePreview(res, previewOptions) as unknown as SimilarAsinParseVo
|
||||||
categorySwitch.value = Boolean(res.categorySwitch)
|
categorySwitch.value = Boolean(res.categorySwitch)
|
||||||
queuedTaskSummary.value = null
|
queuedTaskSummary.value = null
|
||||||
queuePayloadText.value = ''
|
queuePayloadText.value = ''
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* 解析结果预览裁剪(Task 88)。
|
||||||
|
*
|
||||||
|
* 解析接口(similar-asin/parse)返回完整解析 Vo,其中 items 可能达数千行,
|
||||||
|
* 直接放入 Vue 响应式 ref 会造成大量响应式代理与内存放大。本模块把 Vo
|
||||||
|
* 裁剪为预览对象:只保留页面实际使用的摘要字段(taskId、行数统计、开关、
|
||||||
|
* prompt 等),items/groups 整包剔除;可按 previewRowLimit / previewGroupLimit
|
||||||
|
* 有界保留前 N 条样本行用于表格展示(limit 省略或为 0 时不保留)。
|
||||||
|
*
|
||||||
|
* 纯 TS 无副作用模块:输入对象永不修改;taskId 非法时 fail-fast 抛错;
|
||||||
|
* 属性读取抛错(如 Proxy getter 故障)时调用失败,不产生部分结果。
|
||||||
|
*/
|
||||||
|
export interface ParsePreviewOptions {
|
||||||
|
/** 保留的样本行条数(0 或不传表示不保留),不能为负数 */
|
||||||
|
previewRowLimit?: number
|
||||||
|
/** 保留的样本分组条数(0 或不传表示不保留),不能为负数 */
|
||||||
|
previewGroupLimit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toParsePreview<T>(
|
||||||
|
result: T,
|
||||||
|
options: ParsePreviewOptions = {},
|
||||||
|
) {
|
||||||
|
if (typeof result !== 'object' || result == null) {
|
||||||
|
throw new Error('解析结果必须是对象')
|
||||||
|
}
|
||||||
|
const { previewRowLimit, previewGroupLimit } = options
|
||||||
|
if (previewRowLimit != null && !(previewRowLimit >= 0)) {
|
||||||
|
throw new Error('previewRowLimit 不能为负数: ' + previewRowLimit)
|
||||||
|
}
|
||||||
|
if (previewGroupLimit != null && !(previewGroupLimit >= 0)) {
|
||||||
|
throw new Error('previewGroupLimit 不能为负数: ' + previewGroupLimit)
|
||||||
|
}
|
||||||
|
const record = result as Record<string, unknown>
|
||||||
|
const taskId = record.taskId
|
||||||
|
if (typeof taskId !== 'number' || !Number.isFinite(taskId) || taskId <= 0) {
|
||||||
|
throw new Error('taskId 必须是正整数: ' + taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary: Record<string, unknown> = {}
|
||||||
|
for (const key of Object.keys(record)) {
|
||||||
|
if (key === 'items' || key === 'groups') continue
|
||||||
|
summary[key] = record[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewRowLimit != null && previewRowLimit > 0) {
|
||||||
|
const rows = record.items
|
||||||
|
if (Array.isArray(rows)) {
|
||||||
|
summary.previewItems = rows.slice(0, previewRowLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (previewGroupLimit != null && previewGroupLimit > 0) {
|
||||||
|
const groups = record.groups
|
||||||
|
if (Array.isArray(groups)) {
|
||||||
|
summary.previewGroups = groups.slice(0, previewGroupLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary as Omit<T, 'items' | 'groups'> & {
|
||||||
|
previewItems?: unknown[]
|
||||||
|
previewGroups?: unknown[]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { toParsePreview } from '../src/shared/parse-preview.ts'
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
rowIndex: number
|
||||||
|
sourceId: string
|
||||||
|
asin: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParseVo {
|
||||||
|
taskId: number
|
||||||
|
sourceFilename?: string
|
||||||
|
sourceFileCount?: number
|
||||||
|
totalRows: number
|
||||||
|
acceptedRows: number
|
||||||
|
droppedRows: number
|
||||||
|
groupCount?: number
|
||||||
|
aiPrompt?: string
|
||||||
|
imgSwitch?: boolean
|
||||||
|
categorySwitch?: boolean
|
||||||
|
items: Row[]
|
||||||
|
groups?: unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = (n: number): Row => ({ rowIndex: n, sourceId: `s${n}`, asin: `B00${n}` })
|
||||||
|
const vo = (over: Partial<ParseVo> = {}): ParseVo => ({
|
||||||
|
taskId: 1,
|
||||||
|
sourceFilename: 'a.xlsx',
|
||||||
|
sourceFileCount: 2,
|
||||||
|
totalRows: 100,
|
||||||
|
acceptedRows: 90,
|
||||||
|
droppedRows: 10,
|
||||||
|
groupCount: 5,
|
||||||
|
aiPrompt: 'prompt',
|
||||||
|
imgSwitch: true,
|
||||||
|
categorySwitch: false,
|
||||||
|
items: [row(1), row(2), row(3)],
|
||||||
|
groups: [{ g: 1 }, { g: 2 }],
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_088_payload_preview_frontend_normal_default_path', () => {
|
||||||
|
const input = vo()
|
||||||
|
const preview = toParsePreview(input)
|
||||||
|
assert.equal(preview.taskId, 1)
|
||||||
|
assert.equal(preview.sourceFilename, 'a.xlsx')
|
||||||
|
assert.equal(preview.sourceFileCount, 2)
|
||||||
|
assert.equal(preview.totalRows, 100)
|
||||||
|
assert.equal(preview.acceptedRows, 90)
|
||||||
|
assert.equal(preview.droppedRows, 10)
|
||||||
|
assert.equal(preview.groupCount, 5)
|
||||||
|
assert.equal(preview.aiPrompt, 'prompt')
|
||||||
|
assert.equal(preview.imgSwitch, true)
|
||||||
|
assert.equal(preview.categorySwitch, false)
|
||||||
|
assert.equal('items' in preview, false, 'items 不得进入预览')
|
||||||
|
assert.equal('groups' in preview, false, 'groups 不得进入预览')
|
||||||
|
assert.equal('previewItems' in preview, false, '默认不保留预览行')
|
||||||
|
// 输入对象不被修改
|
||||||
|
assert.equal(input.items.length, 3)
|
||||||
|
assert.ok(Array.isArray(input.groups) && input.groups.length === 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_088_payload_preview_frontend_normal_multiple_items', () => {
|
||||||
|
const input = vo({ items: [row(1), row(2), row(3), row(4), row(5)], groups: [{ g: 1 }, { g: 2 }, { g: 3 }] })
|
||||||
|
const preview = toParsePreview(input, { previewRowLimit: 3, previewGroupLimit: 2 })
|
||||||
|
assert.equal(preview.totalRows, 100)
|
||||||
|
assert.equal(preview.acceptedRows, 90)
|
||||||
|
assert.ok(preview.previewItems, '有界保留预览行')
|
||||||
|
assert.equal(preview.previewItems.length, 3)
|
||||||
|
assert.deepEqual(
|
||||||
|
preview.previewItems.map((r) => r.rowIndex),
|
||||||
|
[1, 2, 3],
|
||||||
|
)
|
||||||
|
assert.equal(preview.previewGroups?.length, 2)
|
||||||
|
// 摘要字段不受截断影响,顺序稳定
|
||||||
|
assert.equal(preview.previewItems[0].asin, 'B001')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_088_payload_preview_frontend_normal_repeated_operation_is_idempotent', () => {
|
||||||
|
const input = vo()
|
||||||
|
const once = toParsePreview(input)
|
||||||
|
const twice = toParsePreview(input)
|
||||||
|
assert.deepEqual(once, twice)
|
||||||
|
assert.deepEqual(toParsePreview(once), twice, '对预览再裁剪结果不变')
|
||||||
|
assert.deepEqual(vo(), input, '输入对象不被修改')
|
||||||
|
const withLimit = toParsePreview(input, { previewRowLimit: 2 })
|
||||||
|
assert.deepEqual(toParsePreview(input, { previewRowLimit: 2 }), withLimit)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_088_payload_preview_frontend_boundary_empty_input', () => {
|
||||||
|
const empty = toParsePreview(vo({ items: [], groups: [] }))
|
||||||
|
assert.equal(empty.totalRows, 100)
|
||||||
|
assert.equal('items' in empty, false)
|
||||||
|
assert.equal('groups' in empty, false)
|
||||||
|
assert.equal('previewItems' in empty, false)
|
||||||
|
const limited = toParsePreview(vo({ items: [] }), { previewRowLimit: 5 })
|
||||||
|
assert.deepEqual(limited.previewItems, [])
|
||||||
|
assert.equal(limited.acceptedRows, 90)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_088_payload_preview_frontend_boundary_single_item', () => {
|
||||||
|
const input = vo({ items: [row(7)] })
|
||||||
|
const preview = toParsePreview(input, { previewRowLimit: 1 })
|
||||||
|
assert.equal(preview.previewItems?.length, 1)
|
||||||
|
assert.equal(preview.previewItems?.[0].rowIndex, 7)
|
||||||
|
assert.equal(preview.taskId, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_088_payload_preview_frontend_boundary_limit_and_overflow', () => {
|
||||||
|
const input = vo({ items: Array.from({ length: 10 }, (_, i) => row(i + 1)) })
|
||||||
|
// limit=0 不保留任何行
|
||||||
|
const none = toParsePreview(input, { previewRowLimit: 0 })
|
||||||
|
assert.equal('previewItems' in none, false)
|
||||||
|
// 行数超 limit 截断前 N
|
||||||
|
const some = toParsePreview(input, { previewRowLimit: 3 })
|
||||||
|
assert.equal(some.previewItems?.length, 3)
|
||||||
|
assert.deepEqual(
|
||||||
|
some.previewItems?.map((r) => r.rowIndex),
|
||||||
|
[1, 2, 3],
|
||||||
|
)
|
||||||
|
// 非法 limit 抛错
|
||||||
|
assert.throws(() => toParsePreview(input, { previewRowLimit: -1 }), /previewRowLimit 不能为负数/)
|
||||||
|
assert.throws(() => toParsePreview(input, { previewGroupLimit: -1 }), /previewGroupLimit 不能为负数/)
|
||||||
|
assert.throws(() => toParsePreview(input, { previewRowLimit: NaN }), /previewRowLimit 不能为负数/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_088_payload_preview_frontend_invalid_input_rejected', () => {
|
||||||
|
assert.throws(() => toParsePreview(null as never), /解析结果必须是对象/)
|
||||||
|
assert.throws(() => toParsePreview(undefined as never), /解析结果必须是对象/)
|
||||||
|
assert.throws(() => toParsePreview('x' as never), /解析结果必须是对象/)
|
||||||
|
assert.throws(() => toParsePreview(vo({ taskId: 0 })), /taskId 必须是正整数/)
|
||||||
|
assert.throws(() => toParsePreview(vo({ taskId: -1 })), /taskId 必须是正整数/)
|
||||||
|
assert.throws(() => toParsePreview(vo({ taskId: NaN })), /taskId 必须是正整数/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_088_payload_preview_frontend_dependency_failure_releases_resources', () => {
|
||||||
|
const input = vo()
|
||||||
|
let broken = true
|
||||||
|
const poisoned = new Proxy(input, {
|
||||||
|
get(target, prop, receiver) {
|
||||||
|
if (broken && prop === 'totalRows') throw new Error('getter down')
|
||||||
|
return Reflect.get(target, prop, receiver)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.throws(() => toParsePreview(poisoned), /getter down/)
|
||||||
|
// 输入未被修改
|
||||||
|
assert.deepEqual(input, vo())
|
||||||
|
// 错误可恢复:修复后同一输入成功
|
||||||
|
broken = false
|
||||||
|
const preview = toParsePreview(poisoned)
|
||||||
|
assert.equal(preview.totalRows, 100)
|
||||||
|
assert.equal(preview.acceptedRows, 90)
|
||||||
|
// 提取行数据失败(items 读取抛错)不影响其余摘要字段的读取路径
|
||||||
|
const badItems = new Proxy([row(1)], {
|
||||||
|
get(target, prop) {
|
||||||
|
if (prop === 'length') throw new Error('items getter down')
|
||||||
|
return Reflect.get(target, prop)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.throws(() => toParsePreview(vo({ items: badItems }), { previewRowLimit: 3 }), /items getter down/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user