From 956d4acb5f6e76806195a701ddf616921aa0d584 Mon Sep 17 00:00:00 2001 From: yueli Date: Mon, 28 Sep 2026 10:40:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(audit):=20=E5=BF=AB=E7=85=A7=E5=B5=8C?= =?UTF-8?q?=E5=A5=97=E5=88=86=E5=B1=82=E6=B8=B2=E6=9F=93=20+=20=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E5=AD=97=E6=AE=B5=E4=BB=8E=E6=8E=A5=E5=8F=A3=E5=89=A5?= =?UTF-8?q?=E7=A6=BB=EF=BC=88=E5=AE=89=E5=85=A8=E4=BF=AE=E5=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 起因:借库管理等单据的详情页仍显示原始 JSON 代码块。 根因:details 有**第三种**存法 {'payload': {...}},且内部嵌着对象数组 (items 明细行),而旧实现只认平铺的 created/deleted_snapshot, 遇到非平铺结构就退化成原始 JSON。 1. 快照键收敛(后端) created / deleted_snapshot / payload 三种键与展示标题收进 audit_labels.SNAPSHOT_VIEWS,经 /audit/labels 下发 snapshotViews, 前端不再硬编键名 —— 将来加第四种只改一处。 audit_export_service 与 daily_report_service 改为引用同一份。 changes_summary 也改用 snapshot_of_any:payload 型记录的新增摘要 此前整列为空,现在有内容了(如「新增(借库管理):物料明细 1 项、 备注、借用人、签名、预计归还时间」)。 2. 分层渲染(前端) · 标量字段 → el-descriptions(保留字典翻译与空值/技术字段过滤) · 数组字段 → el-table:列取**所有元素键的并集**(同一数组内元素键 并不完全一致,只取首个元素会漏字段),跳过技术字段,列头走 fieldLabel · 标量数组(arrival_photo 等图片 URL 列表)→ 单列表格 · details / payload 本身是数组或标量的情况也一并处理 · 只有确实无可识别结构时才回落到原始 JSON 实测全库 58728 条:**0 条**会退化到原始 JSON 兜底 (payload 1495 条全部拆出结构)。 3. ★ 安全修复:审计快照里存有**明文密码**,本次从所有出口剥离 实测 `用户管理/新增` 的 payload 快照记着哈希前的原始密码(27 条, 形如 '123456'、'shili0823'),因为写入路径把请求体整包记进了审计。 · sanitize_details():递归剥掉凭据类字段,应用于列表与详情接口 · changes_of / snapshot_of 在**源头**滤掉凭据:逐个出口去补必然漏掉 某一个,而漏掉的那个就是泄漏点(日报附件、导出、摘要都走这两个函数) · 凭据判定用**关键词**(password/passwd/secret/token/private_key) 而非逐个登记 —— 今天漏的是 payload 里的 password,明天可能是 reset_token。新表加凭据字段也自动被挡住。 ★ 第一版我只做了前端隐藏(hiddenFields),被测试抓出来:原始响应里 密码照样在,打开 devtools 就读得到。**要挡的数据必须在接口出口剥掉, 前端隐藏不是防线。** 验证:14 + 15 项断言全过,含「列表/详情响应无密码原文与 password 键」 「全量导出的表头与单元格均无凭据」「日报三天附件无凭据」 「sanitize_details 递归进嵌套数组且不原地改原对象」。 6 列结构与附件大小未变(490.5K / 193.4K / 10.3K);vue-tsc 与 vite build exit=0。 ⚠️ 数据库中仍存有明文密码(16 条非空),本次只挡展示与导出,**未改数据** —— 改数据不可逆,需单独立项决定。 --- inventory-backend/app/api/v1/audit.py | 5 + .../app/services/audit_export_service.py | 118 +++++++++--- .../app/services/daily_report_service.py | 8 +- inventory-backend/app/utils/audit_labels.py | 127 ++++++++++++- inventory-web/src/views/system/AuditLog.vue | 177 +++++++++++++++--- 5 files changed, 377 insertions(+), 58 deletions(-) diff --git a/inventory-backend/app/api/v1/audit.py b/inventory-backend/app/api/v1/audit.py index b12f59e..34d4f5d 100644 --- a/inventory-backend/app/api/v1/audit.py +++ b/inventory-backend/app/api/v1/audit.py @@ -16,6 +16,7 @@ from app.services.audit_export_service import ( format_target_display, load_material_context, load_ref_maps, + sanitize_details, ) from app.utils.decorators import get_current_company_filter, permission_required @@ -224,6 +225,10 @@ def _serialize_logs(rows): out = [] for r in rows: d = r.to_dict() + # ★ 在**接口出口**剥掉凭据类字段。只靠前端隐藏不够 —— + # 原始响应里照样有明文密码,打开 devtools 就读得到。 + # 见 audit_export_service.sanitize_details。 + d['details'] = sanitize_details(d.get('details')) d['action'] = canon_action(d.get('action')) mat = materials.get(r.id) # 保留原始 target_name(搜索仍按它匹配),展示用 target_display diff --git a/inventory-backend/app/services/audit_export_service.py b/inventory-backend/app/services/audit_export_service.py index a380891..b757015 100644 --- a/inventory-backend/app/services/audit_export_service.py +++ b/inventory-backend/app/services/audit_export_service.py @@ -36,7 +36,10 @@ from app.extensions import db from app.models.base import MaterialBase from app.utils.audit_labels import ( BOOLEAN_FIELDS, + CHANGES_KEY, PERSON_NAME_FIELDS, + SNAPSHOT_CREATED, + SNAPSHOT_DELETED, USER_ID_FIELDS, action_label, bool_label, @@ -44,6 +47,7 @@ from app.utils.audit_labels import ( enum_label, field_label, id_ref_of, + is_hidden_credential_field, module_display, person_name_label, ) @@ -54,10 +58,8 @@ SYSTEM_USERNAME = 'system' # 未解析出实体时的占位 UNKNOWN = '-' -# details 里的三种结构各自的键名 —— 集中在此,避免调用方记错 -SNAPSHOT_CREATED = 'created' -SNAPSHOT_DELETED = 'deleted_snapshot' -CHANGES_KEY = 'changes' +# details 的键名统一在 audit_labels.py 定义(那里还要下发给前端), +# 此处只做引用 —— 两个模块各存一份字符串就是在等着它们漂移。 # 变更字段的噪声过滤。 # @@ -695,6 +697,11 @@ def changes_of(row): for key, val in changes.items(): if key in IGNORED_CHANGE_FIELDS: continue + # ★ 凭据类字段一律不出现在任何出口(详情、摘要、Excel 导出)。 + # 在这里滤是源头做法 —— 逐个出口去补必然漏掉某一个, + # 而漏掉的那个就是泄漏点。 + if is_hidden_credential_field(key): + continue if isinstance(val, dict): old, new = val.get('old'), val.get('new') else: @@ -708,6 +715,25 @@ def changes_of(row): return out +def snapshot_of_any(row): + """ + 该行的业务快照 + 它的展示标题,按 SNAPSHOT_VIEWS 的优先级取第一个可用的。 + + 返回 (title, data) 或 (None, None)。data 可能是 dict(常规)也可能是 + list(`payload` 实测有整包就是数组的),由调用方按类型处置。 + """ + from app.utils.audit_labels import SNAPSHOT_VIEWS + + details = getattr(row, 'details', None) + if not isinstance(details, dict): + return None, None + for key, title in SNAPSHOT_VIEWS: + data = details.get(key) + if data: + return title, data + return None, None + + def snapshot_of(row, which): """ CREATE / DELETE 记录的整字段快照 → {字段: 值};结构异常时返回 {}。 @@ -723,16 +749,53 @@ def snapshot_of(row, which): snap = details.get(which) if not isinstance(snap, dict): return {} - return {k: v for k, v in snap.items() if k not in IGNORED_CHANGE_FIELDS} + return { + k: v for k, v in snap.items() + if k not in IGNORED_CHANGE_FIELDS + # 凭据类同样在源头滤掉(理由见 changes_of) + and not is_hidden_credential_field(k) + } + + +def sanitize_details(details): + """ + details(含嵌套快照与数组)→ 剔除凭据类字段后的**新**结构。 + + ★★ 必须在这层做,不能只靠前端隐藏:前端隐藏只是"不显示",原始响应里 + 照样有明文 —— 任何人打开 devtools 就读得到。 + **凡是要挡的数据,都要在接口出口剥掉。** 前端那份只是为了不把 + DOM 也渲染出来(双保险),不是防线本身。 + + ★ 实测来源:`用户管理/新增` 的 payload 快照里存着明文密码(27 条), + 写入路径把请求体整包记进了审计 —— 请求体里是哈希前的原始密码。 + + ★ 递归进 dict 与 list:payload 里嵌着 items 数组,数组元素对象上同样 + 可能有凭据字段,只看第一层会漏。 + + ★ 返回新对象,**不原地改** ORM 对象持有的 dict —— 那会污染 session + 里的实体,同一个请求后续若还要用到原始数据就会拿到被改过的版本。 + """ + if isinstance(details, dict): + return { + k: sanitize_details(v) + for k, v in details.items() + if not is_hidden_credential_field(k) + } + if isinstance(details, list): + return [sanitize_details(v) for v in details] + return details def has_detail_content(details): """details 里是否有可展示的结构(与前端 hasDetailContent 同判据)""" if not isinstance(details, dict) or not details: return False - return any( - details.get(k) for k in (CHANGES_KEY, SNAPSHOT_DELETED, SNAPSHOT_CREATED, 'payload') - ) + # 键名取自 SNAPSHOT_VIEWS + changes —— 与前端渲染用的是同一份, + # 不在这里另列一遍(漏一个就会出现"有详情按钮但点开是空的") + from app.utils.audit_labels import SNAPSHOT_VIEWS + + keys = [k for k, _ in SNAPSHOT_VIEWS] + [CHANGES_KEY] + return any(details.get(k) for k in keys) # ============================================================================= @@ -932,21 +995,30 @@ def changes_summary(row, ref_maps, limit=200, resolved=None, material=None): mod = module_display(row.module) prefix = f"{verb}({mod}):" if mod else f"{verb}:" - for which in (SNAPSHOT_CREATED, SNAPSHOT_DELETED): - snap = snapshot_of(row, which) - if not snap: - continue - # 优先:核心属性的**值**(多少数量、放哪)—— 这才是用户想知道的 - core = _core_attrs(snap, material) - if core: - return truncate(prefix + '、'.join(core), limit) - # 回落:没有核心属性可提(如 BOM 记录),退化成字段名清单。 - # 跳过 id:主键每条都有,排最前却零信息量,白占位置。 - keys = [k for k in snap if k != 'id'] - names = [field_label(k) for k in keys[:8]] - more = '' if len(keys) <= 8 else f" 等 {len(keys)} 个字段" - return truncate(f"{prefix}{'、'.join(names)}{more}", limit) - return '' + # ★ 用 snapshot_of_any:`payload` 是与 `created` 平级的第三种快照存法 + # (借库/出库/采购入库走这条),只看 created/deleted_snapshot 会让 + # 这些记录的新增摘要整列为空。 + _title, snap = snapshot_of_any(row) + if not isinstance(snap, dict): + return '' + # 优先:核心属性的**值**(多少数量、放哪)—— 这才是用户想知道的 + core = _core_attrs(snap, material) + if core: + return truncate(prefix + '、'.join(core), limit) + # 回落:没有核心属性可提(如 BOM 记录、payload 型的借库单), + # 退化成字段名清单。跳过 id(主键每条都有、零信息量)与凭据类字段 + # (摘要里出现「password」这种字样本身就是多余的暴露)。 + keys = [k for k in snap + if k != 'id' and not is_hidden_credential_field(k)] + names = [] + for k in keys[:8]: + v = snap[k] + # 数组字段带上条数 —— 只写「物料明细」看不出有几行, + # 「物料明细 1 项」才是有用的信息 + names.append(f"{field_label(k)} {len(v)} 项" if isinstance(v, list) + else field_label(k)) + more = '' if len(keys) <= 8 else f" 等 {len(keys)} 个字段" + return truncate(f"{prefix}{'、'.join(names)}{more}", limit) # ============================================================================= diff --git a/inventory-backend/app/services/daily_report_service.py b/inventory-backend/app/services/daily_report_service.py index ca6e4d1..4135659 100644 --- a/inventory-backend/app/services/daily_report_service.py +++ b/inventory-backend/app/services/daily_report_service.py @@ -55,8 +55,6 @@ from app.models.transaction import TransBorrow from app.services.audit_export_service import ( DAILY_REPORT_HEAD, EXCEL_SNAPSHOT_COL_LIMIT, - SNAPSHOT_CREATED, - SNAPSHOT_DELETED, SYSTEM_USERNAME, UNKNOWN, cell, @@ -71,7 +69,11 @@ from app.services.audit_export_service import ( snapshot_headers, snapshot_of, ) -from app.utils.audit_labels import canon_action +from app.utils.audit_labels import ( + SNAPSHOT_CREATED, + SNAPSHOT_DELETED, + canon_action, +) from app.utils.constants import OutboundType logger = logging.getLogger(__name__) diff --git a/inventory-backend/app/utils/audit_labels.py b/inventory-backend/app/utils/audit_labels.py index fe37999..9fffc23 100644 --- a/inventory-backend/app/utils/audit_labels.py +++ b/inventory-backend/app/utils/audit_labels.py @@ -282,6 +282,57 @@ FIELD_LABELS = { 'image_url': '图片地址', 'user_id': '用户ID', 'type': '类型', + + # --- 快照里的数组字段 --- + # ★ 这些在详情抽屉里会**直接当表格标题**用(见前端的分层渲染), + # 漏一个就是在表头上裸露英文。 + 'items': '物料明细', + 'rules': '规则', + 'children': '子项', + 'arrival_photo': '到货照片', + 'generalImage': '通用图片', + 'generalManual': '通用手册', + 'permissions': '权限', + 'signature_path': '签名', + + # --- payload 型快照的字段 --- + # ★ 来源:接口层手工记录的请求体整包(借库/出库/采购入库/用户管理/预警设置)。 + # 它的命名与 ORM 快照**不一致**,同一概念有两种写法: + # qty_stock / stock_quantity —— 都是"库存数量" + # commonName / common_name —— 都是"通用名" + # companyName / company_name —— 都是"所属公司" + # 这是历史遗留,改数据不可逆,故两种写法都登记。 + 'qty_stock': '库存数量', + 'qty_available': '可用数量', + 'qty_inbound': '入库数量', + 'inventoryCount': '库存数量', + 'availableCount': '可用数量', + 'pending_quantity': '待处理数量', + 'inbound_date': '入库日期', + 'cn_name': '中文名', + 'commonName': '通用名', + 'companyName': '所属公司', + 'spec': '规格型号', + 'price': '价格', + 'purchaser': '采购员', + 'purchaser_email': '采购邮箱', + 'print_copies': '打印份数', + 'global_print_id_str': '全局打印ID(文本)', + 'source_link': '来源链接', + 'unit_total_cost': '单位总成本', + 'current_location': '当前位置', + 'isEnabled': '是否启用', + 'isInspectionRequired': '是否需质检', + 'visibilityLevel': '可见级别', + 'warningEnabled': '是否启用预警', + 'warningStatus': '预警状态', + 'warningRed': '红色预警', + 'warningYellow': '黄色预警', + 'manual_link': '说明书链接', + 'manual_link_remark': '说明书链接备注', + 'product_image': '产品图片', + 'product_image_remark': '产品图片备注', + 'purchase_link': '采购链接', } # ============================================================================= @@ -310,19 +361,81 @@ HIDDEN_SNAPSHOT_FIELDS = frozenset({ 'arrival_image_embedding', 'qc_report_image_embedding', 'image_embedding', + 'password', # ★ 见下方凭据关键词的说明 + 'password_hash', }) +# 凭据类字段的**关键词**拦截。 +# +# ★★ 这不是洁癖,是实测出来的洞:`用户管理/新增` 的 payload 快照里存着 +# **明文密码**(27 条,实测 pw_len 6/8/11,形如 '1234…'、以用户名开头…)—— +# 写入路径把请求体整包记进了审计,而请求体里是哈希前的原始密码。 +# 这些内容会直接显示在详情抽屉里,并随导出进 Excel。 +# +# ★ 用关键词而不是逐个登记:今天漏的是 payload 里的 password, +# 明天可能是 reset_token、api_secret。凡是键名沾这些词的都不展示 —— +# 新表加了凭据字段也自动被挡住。 +_CREDENTIAL_KEYWORDS = ('password', 'passwd', 'secret', 'token', 'private_key') + + +# ============================================================================= +# 7. 快照(details)的键与展示标题 +# +# ★ 历史上有**三种**互不相同的存法,由不同时期的写入路径产生: +# {'created': {...}} —— ORM 监听器的 INSERT 快照 +# {'deleted_snapshot': {...}} —— ORM 监听器的 DELETE 快照 +# {'payload': {...}} —— 接口层手工记录的业务数据整包 +# (借库/出库/采购入库等,内部还嵌 items 数组) +# `changes` 是第四种,但语义是"变更对比"而非快照,不走这套渲染。 +# +# ★ 键名与标题都收在这里、由 GET /audit/labels 下发:前端不该硬编这三行 —— +# 将来再加第四种存法,只改这里即可(本项目已多次栽在"前端存一份副本"上)。 +# +# ★ 顺序即优先级:一条记录同时有多个键时取第一个能用的。 +# ============================================================================= +SNAPSHOT_CREATED = 'created' +SNAPSHOT_DELETED = 'deleted_snapshot' +SNAPSHOT_PAYLOAD = 'payload' +CHANGES_KEY = 'changes' + +SNAPSHOT_VIEWS = ( + (SNAPSHOT_CREATED, '新增数据快照'), + (SNAPSHOT_DELETED, '删除前数据快照'), + (SNAPSHOT_PAYLOAD, '业务数据'), +) + + +def is_hidden_credential_field(key): + """ + 该字段是否属于**凭据类**(密码/令牌/密钥)—— 这类字段不仅要"不显示", + 还要在接口出口**整键剥掉**(见 audit_export_service.sanitize_details)。 + + ★ 单列一个函数而不是复用 is_hidden_snapshot_field:两者处置方式不同。 + 技术字段(id/embedding)只是不该展示;凭据字段是**泄漏**, + 必须连响应体里都不能有。混在一起会让"要不要剥掉"的语义变含糊。 + """ + k = str(key or '').strip().lower() + return any(w in k for w in _CREDENTIAL_KEYWORDS) + def is_hidden_snapshot_field(key): """ - 该字段是否属于「不该在详情快照里展示」的纯技术字段。 + 该字段是否属于「不该在详情快照里展示」的字段。 - ★ 除名单本身,还按**后缀**拦截 `*embedding`:向量列在各表命名不一 - (img_embedding / arrival_image_embedding / qc_report_image_embedding…), - 逐个登记必然漏。新表加向量列时不必再改这里。 + 三类: + · 名单内的纯技术字段(id / 时间戳 / 审计元数据) + · `*embedding` 向量列 —— 按**后缀**拦截:各表命名不一 + (img_embedding / arrival_image_embedding / qc_report_image_embedding…), + 逐个登记必然漏。新表加向量列不必再改这里。 + · 凭据类字段 —— 见 is_hidden_credential_field + + ★ 前端同一套规则再判一次(AuditLog.vue 的 isHiddenField): + 后端名单管"点名"的,关键词两端各自判,语义一致。 """ k = str(key or '').strip().lower() - return k in HIDDEN_SNAPSHOT_FIELDS or k.endswith('embedding') + if k in HIDDEN_SNAPSHOT_FIELDS or k.endswith('embedding'): + return True + return is_hidden_credential_field(k) def field_label(key): @@ -676,4 +789,8 @@ def labels_payload(): 'moduleDisplay': module_display_map(), # 详情快照里不展示的纯技术字段(前端过滤用,见 HIDDEN_SNAPSHOT_FIELDS) 'hiddenFields': sorted(HIDDEN_SNAPSHOT_FIELDS), + # 快照键 → 展示标题,按优先级排列(见 SNAPSHOT_VIEWS)。 + # 前端据此把 created / deleted_snapshot / payload 三种存法抹平, + # 不硬编键名。 + 'snapshotViews': [{'key': k, 'title': t} for k, t in SNAPSHOT_VIEWS], } diff --git a/inventory-web/src/views/system/AuditLog.vue b/inventory-web/src/views/system/AuditLog.vue index 6aa7fe1..0257df1 100644 --- a/inventory-web/src/views/system/AuditLog.vue +++ b/inventory-web/src/views/system/AuditLog.vue @@ -264,24 +264,50 @@ - -
-
- - {{ isDelete ? '删除前数据快照' : '新增数据快照' }} + + + +
@@ -355,6 +381,8 @@ const enumFields = ref(['status']) const booleanFields = ref([]) // 详情快照里不展示的纯技术字段(后端下发,见「详情降噪」一节) const hiddenFields = ref([]) +// 快照键 → 标题(后端下发,见「快照的分层渲染」一节) +const snapshotViews = ref>([]) // 字段中文名解析:命中映射取中文,否则原样返回 const fieldLabel = (key: string | number): string => { @@ -399,6 +427,7 @@ const loadLabels = async () => { booleanFields.value = res.data.booleanFields || [] moduleDisplay.value = res.data.moduleDisplay || {} hiddenFields.value = res.data.hiddenFields || [] + snapshotViews.value = res.data.snapshotViews || [] } } catch (e) { console.warn('[AuditLog] 审计中文映射拉取失败,字段名与码值将原样显示', e) @@ -485,9 +514,16 @@ const isBlankValue = (v: any): boolean => { return false } +// ★ 凭据类字段按**关键词**拦截,与后端 audit_labels._CREDENTIAL_KEYWORDS 同规则。 +// 实测 `用户管理/新增` 的 payload 快照里存着明文密码(写入路径把请求体整包 +// 记进了审计),必须在详情与导出里都挡住。 +// 名单用关键词而不是后端下发:新表加了凭据字段也自动被挡,不会漏。 +const CREDENTIAL_KEYWORDS = ['password', 'passwd', 'secret', 'token', 'private_key'] + const isHiddenField = (key: string): boolean => { const k = String(key || '').trim().toLowerCase() - return hiddenFields.value.includes(k) || k.endsWith('embedding') + if (hiddenFields.value.includes(k) || k.endsWith('embedding')) return true + return CREDENTIAL_KEYWORDS.some(w => k.includes(w)) } // UPDATE:字段对比行 @@ -502,16 +538,103 @@ const diffRows = computed(() => { .map(([key, v]: [string, any]) => ({ key, old: v?.old, new: v?.new })) }) -// CREATE / DELETE:快照属性行 -// 过滤:空值、纯技术字段、对象 repr 脏值(见 isObjectRepr) -const snapshotRows = computed(() => { - const d = currentDetail.value?.details || {} - const snap = d.deleted_snapshot || d.created - if (!snap || typeof snap !== 'object') return [] - return Object.entries(snap) - .filter(([key, v]) => - !isBlankValue(v) && !isHiddenField(key) && !isObjectRepr(v)) - .map(([key, value]) => ({ key, value })) +// ============================================================ +// CREATE / DELETE / payload:快照的分层渲染 +// +// 历史上有三种快照存法(created / deleted_snapshot / payload), +// 键名与标题由**后端**下发(snapshotViews),前端不硬编 —— 见 +// 后端 audit_labels.SNAPSHOT_VIEWS。 +// +// ★ payload 型快照内部还嵌着数组(items 明细行、arrival_photo 图片 URL 列表), +// 旧实现遇到非平铺结构就退化成原始 JSON 代码块 —— 业务人员看不懂。 +// 现在拆成两层: +// 标量字段 → el-descriptions(保留字典翻译 + 空值/技术字段过滤) +// 数组字段 → el-table(列取元素键的并集,同样过滤与翻译) +// ============================================================ + +// 从 details 里挑出第一个可用的快照,抹平三种存法的入口差异 +const pickedSnapshot = computed(() => { + const details = currentDetail.value?.details + if (!details || typeof details !== 'object') return null + for (const v of snapshotViews.value) { + const data = (details as any)[v.key] + // ★ 用真值判断会漏掉空数组/空对象,但那本来就没什么可渲染的, + // 继续试下一个键反而是对的行为 + if (data) return { title: v.title, data } + } + return null +}) + +// 数组 → 表格块。元素可能是对象(items)也可能是标量(图片 URL 列表)。 +const buildListBlock = (key: string, arr: any[]) => { + const head = arr[0] + // 标量数组:单列表格。图片 URL / 权限码都是这种,给个统一去处比拼成 + // 长字符串更好读(长 URL 会撑爆整行)。 + if (head === null || typeof head !== 'object') { + return { + key, + label: fieldLabel(key), + count: arr.length, + columns: [{ prop: 'value', label: fieldLabel(key) }], + rows: arr.map(v => ({ value: formatValue(v, key) })), + } + } + // 对象数组:列取**所有元素键的并集** —— 只取第一个元素的键会漏掉 + // 后续元素才有的字段(实测同一数组内的元素键并不完全一致)。 + const cols: string[] = [] + for (const el of arr) { + for (const k of Object.keys(el || {})) { + if (!cols.includes(k) && !isHiddenField(k)) cols.push(k) + } + } + return { + key, + label: fieldLabel(key), + count: arr.length, + columns: cols.map(c => ({ prop: c, label: fieldLabel(c) })), + rows: arr.map(el => { + const row: Record = {} + for (const c of cols) row[c] = formatValue((el || {})[c], c) + return row + }), + } +} + +// 快照 → { 标量字段, 数组块 } +const snapshotView = computed(() => { + const picked = pickedSnapshot.value + if (!picked) return null + + // payload 整包可能就是数组(实测有这种),此时全按列表渲染 + if (Array.isArray(picked.data)) { + const block = buildListBlock('payload', picked.data) + return { title: picked.title, scalars: [], lists: block ? [block] : [] } + } + if (typeof picked.data !== 'object') { + return { title: picked.title, scalars: [], lists: [] } + } + + const scalars: Array<{ key: string; value: any }> = [] + const lists: any[] = [] + for (const [key, v] of Object.entries(picked.data)) { + if (isHiddenField(key)) continue + if (Array.isArray(v)) { + if (!v.length) continue + const block = buildListBlock(key, v) + if (block) lists.push(block) + } else if (!isBlankValue(v) && !isObjectRepr(v)) { + // 嵌套对象(非数组)原样转 JSON 显示 —— 实测快照里没有这种, + // 但留着比把它静默丢掉安全 + scalars.push({ key, value: v }) + } + } + return { title: picked.title, scalars, lists } +}) + +// 有东西可渲染就不走原始 JSON 兜底 +const hasStructuredDetail = computed(() => { + const v = snapshotView.value + return !!(v && (v.scalars.length || v.lists.length)) }) const rawJson = computed(() => {