diff --git a/frontend-vue/src/pages/brand/components/BrandSimilarAsinTab.vue b/frontend-vue/src/pages/brand/components/BrandSimilarAsinTab.vue index c23fd90f..6129f31b 100644 --- a/frontend-vue/src/pages/brand/components/BrandSimilarAsinTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandSimilarAsinTab.vue @@ -212,6 +212,7 @@ import { getStoredApiSecret } from '@/shared/utils/api-secret-store' import { createCategorizedTimers } from '@/shared/utils/categorized-timers' import { saveUrlWithProgress } from '@/shared/utils/download-progress' import { createAsinForceThrottle } from '@/shared/asin-force-throttle' +import { toParsePreview, type ParsePreviewOptions } from '@/shared/parse-preview' const selectedFileNames = ref([]) const uploadedFiles = ref([]) @@ -472,7 +473,9 @@ async function parseFiles() { relativePath: f.relativePath, })) 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) queuedTaskSummary.value = null queuePayloadText.value = '' diff --git a/frontend-vue/src/shared/parse-preview.ts b/frontend-vue/src/shared/parse-preview.ts new file mode 100644 index 00000000..3f6ca8a3 --- /dev/null +++ b/frontend-vue/src/shared/parse-preview.ts @@ -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( + 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 + const taskId = record.taskId + if (typeof taskId !== 'number' || !Number.isFinite(taskId) || taskId <= 0) { + throw new Error('taskId 必须是正整数: ' + taskId) + } + + const summary: Record = {} + 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 & { + previewItems?: unknown[] + previewGroups?: unknown[] + } +} diff --git a/frontend-vue/tests/parse-preview.test.ts b/frontend-vue/tests/parse-preview.test.ts new file mode 100644 index 00000000..1a615c8c --- /dev/null +++ b/frontend-vue/tests/parse-preview.test.ts @@ -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 => ({ + 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/) +})