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:
2026-08-30 22:55:27 +08:00
parent abb262668c
commit 6600bfaa99
3 changed files with 228 additions and 1 deletions
@@ -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<string[]>([])
const uploadedFiles = ref<UploadFileVo[]>([])
@@ -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 = ''
+62
View File
@@ -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[]
}
}