feat(audit): 提升可读性 —— 操作对象补全物料名、详情降噪、字段字典补齐

业务方反馈两点:操作对象太干瘪、详情快照噪音太多。

1. 操作对象补全为「SKU - 物料名称 (规格型号)」

   库里 target_name 大多只存了 SKU('0000002270'),入库类甚至存的是内部
   标识('stock_buy ID:1667'),业务人员完全看不懂。

   补全走两条路径,**正确性优先**:
     · 快照里的 base_id(CREATE/DELETE 的库存行)—— 权威,零歧义
     · target_id 唯一命中一张股票表 —— 实测与上一条 1230/1230 完全一致

   ★ 撞号一律放弃:target_id 同时命中 2 张股票表时解出的 base_id
     24/27 是错的、命中 3 张时 44/44 全错。补一个**错的**物料名比不补更糟 ——
     那是"看起来完全可信的错误答案",业务方会照着它去找不相干的物料。
     实测 300 条撞号行 0 条被补,300 条唯一命中行全部补上。

   target_name 原值保留(target_keyword 搜索仍按它匹配),新增 target_display
   供展示。前端去掉灰显的 #target_id —— 业务人员不需要看数据库主键。

2. 详情快照降噪

   · 前端过滤空值:null / '' / [] / {} / '-'。判据**严格**:false 和 0
     不算空(`is_returned: false`、`quantity: 0` 是明确的业务事实,
     用真值判断会把它们一起吃掉,那是在篡改数据)。
   · 过滤纯技术字段:id / created_at / updated_at / target_id / module_name
     及 pgvector 的 `*embedding`(单条可达数 KB)。清单由后端下发
     (labels.hiddenFields),前端不硬编 —— 它会随新表增长,再存一份必然漂移。
     embedding 类按后缀拦截,新表加向量列不必改代码。
   · 变更对比表过滤「等于没改」的行(null ↔ 空串)。

3. 字段字典补齐:168 项

   实测快照里出现过但字典没有的字段全部补上(buyer_email / currency /
   in_date / exchange_rate / dosage / loss_rate / child / parent /
   production_* / return_* / *_threshold 等 60+ 个)。
   现在快照字段缺中文名的数量为 **0** —— 详情页不会再裸露英文。

4. 摘要优化

   · changes_of 丢掉无意义变更(null ↔ 空串)。库里 98 条记录带这种变更,
     写进摘要就是「备注:空→空」,纯噪音还挤占截断长度。
   · CREATE/DELETE 摘要改为核心属性**按槽位取值**:
         新增(入库):入库数量 10 件、库位 ZZTEST
     而不是「新增:SKU、base、状态… 等 29 个字段」。
   · 槽位只有数量与位置两个,**不含物料** —— 物料由「操作对象」列承担,
     同一屏里再来一遍是重复。数量字段也按槽位只取一个:
     in_quantity / stock_quantity / available_quantity 值往往相同,
     取三个会得到三个一样的数字。
   · 数字去掉无意义的 .0(10.0 → 10);单位取自物料主数据,
     纯数字的占位单位(库里有一批 unit='1')丢弃。

验证:33 + 16 项断言全过,含「补全的物料与快照 base_id 零冲突」「撞号行
一律不补」「快照字段缺中文名 0 个」「摘要无空→空」「导出口径与列表一致」;
vue-tsc --noEmit 与 vite build 均 exit=0。
日报回归:三天附件 490.5K / 193.4K / 10.3K,6 列结构未变。
This commit is contained in:
yueli
2026-09-28 10:19:58 +08:00
parent 6a9e41f53c
commit 46aec0b954
4 changed files with 436 additions and 37 deletions

View File

@ -13,6 +13,8 @@ from app.services.audit_export_service import (
SYSTEM_USERNAME,
build_audit_workbook,
changes_summary,
format_target_display,
load_material_context,
load_ref_maps,
)
from app.utils.decorators import get_current_company_filter, permission_required
@ -214,11 +216,20 @@ def _serialize_logs(rows):
if not rows:
return []
ref_maps = load_ref_maps(rows)
# ★ 操作对象补全:库里 target_name 大多只存了 SKU('0000002270')甚至内部
# 标识('stock_buy ID:1667'),业务人员看不懂。补成
# 「SKU - 物料名称 (规格型号)」;解不出物料的行不补,前端回落原始值。
# 见 load_material_context —— 撞号的 target_id 一律放弃,宁可留空不补错。
materials = load_material_context(rows)
out = []
for r in rows:
d = r.to_dict()
d['action'] = canon_action(d.get('action'))
d['summary'] = changes_summary(r, ref_maps, limit=SUMMARY_LIMIT)
mat = materials.get(r.id)
# 保留原始 target_name(搜索仍按它匹配),展示用 target_display
d['target_display'] = format_target_display(mat)
d['summary'] = changes_summary(
r, ref_maps, limit=SUMMARY_LIMIT, material=mat)
out.append(d)
return out

View File

@ -41,6 +41,7 @@ from app.utils.audit_labels import (
enum_label,
field_label,
id_ref_of,
module_display,
person_name_label,
)
@ -103,6 +104,38 @@ TEXT_VALUE_LIMIT = 40
# 基础类型转换
# =============================================================================
def is_blank(val):
"""
值是否「空」—— 用于过滤无意义的变更与快照字段。
★ 判据必须**严格**:None / '' / [] / {} / '-' 才算空。
**false 和 0 不算空** —— `is_returned: false`、`quantity: 0` 是明确的
业务事实,用 Python 的真值判断(`if not val`)会把它们一起吃掉,
那是在悄悄篡改业务数据。
"""
if val is None:
return True
if isinstance(val, str):
return val.strip() in ('', '-')
if isinstance(val, (list, dict)):
return len(val) == 0
return False
def is_noop_change(old, new):
"""
该变更是否「等于没改」。
★ 典型来源:字段从 NULL 被写成空串(或反之)。库里实测有 98 条记录
带这种变更 —— 写进摘要就是「备注:空→空」,纯噪音,还会把真正有意义
的变更挤出截断长度之外。
"""
if is_blank(old) and is_blank(new):
return True
# 空串与 NULL 在业务上是同一件事,两者之间的"变化"也不算改
return text_of(old) == text_of(new)
def as_int(value):
"""尽力转 int;转不了返回 None(bool 不算数 —— 它是 int 的子类)"""
if isinstance(value, bool):
@ -312,6 +345,129 @@ REF_LOADERS = {
}
# 「快照里带 base_id」= 这条记录就是关于某个物料的库存行
_SNAPSHOT_BASE_ID_SOURCES = (SNAPSHOT_CREATED, SNAPSHOT_DELETED)
# 库存三表 —— 入库数据分别落在采购/半成品/成品库存表里
STOCK_TABLES = ('stock_buy', 'stock_semi', 'stock_product')
def _stock_row_lookup(target_ids):
"""
{target_id: (base_id, sku)} —— **仅在 target_id 恰好命中一张股票表**时给出。
★ 为什么撞号必须放弃(这是本函数存在的全部理由):
实测在库存类记录上,target_id「唯一命中」股票表时,解出的 base_id 与
快照里的 base_id **1230/1230 完全一致**;而一旦命中 2 张表就 24/27 错、
命中 3 张表就 44/44 全错。
补一个**错的**物料名比不补更糟 —— 那是"看起来完全可信的错误答案",
业务方会照着它去找一个根本不相干的物料。
★ 为什么按 id 去查三张表而不是按 module 定位:
「入库管理」这个 module 名下混着三张表的记录,module 本身分辨不出是哪张;
只能靠 id 命中情况反推,命中多张就是无法判定,直接放弃。
"""
ids = set()
for t in target_ids:
iv = as_int(t)
if iv is not None:
ids.add(iv)
if not ids:
return {}
from app.models.inbound.buy import StockBuy
from app.models.inbound.product import StockProduct
from app.models.inbound.semi import StockSemi
hits = defaultdict(dict) # stock_id -> {表名: (base_id, sku)}
for name, model in (('buy', StockBuy), ('semi', StockSemi), ('product', StockProduct)):
for r in model.query.filter(model.id.in_(ids)).all():
hits[r.id][name] = (getattr(r, 'base_id', None), getattr(r, 'sku', '') or '')
return {sid: next(iter(tbls.values()))
for sid, tbls in hits.items() if len(tbls) == 1}
def load_material_context(rows):
"""
一批审计行 → {row.id: {'sku','name','spec','unit','base_id'}}。
只为「能无歧义定位到物料」的行给出结果:
1. 快照里有 base_id(CREATE/DELETE 的库存行)—— 权威,零歧义;
2. 否则 target_id 唯一命中一张股票表 —— 实测与 (1) 100% 一致。
定位不到就**不放进去**,由调用方回落到原始 target_name ——
宁可不补,也不能补错。
"""
# 1) 先从快照取 base_id / sku
snap_ctx = {} # row_pk -> (base_id, sku)
target_ids = []
for r in rows:
base_id, sku = None, ''
for which in _SNAPSHOT_BASE_ID_SOURCES:
snap = snapshot_of(r, which)
if not snap:
continue
base_id = as_int(snap.get('base_id')) or base_id
sku = sku or (snap.get('sku') or '')
if base_id:
snap_ctx[r.id] = (base_id, sku)
elif as_int(getattr(r, 'target_id', None)) is not None:
# 2) 没有快照 base_id 的(典型是 UPDATE),留给股票表兜底
target_ids.append(r.target_id)
stock_ctx = _stock_row_lookup(target_ids)
# 汇总所有要查的 base_id,一次查完
need_base = {b for b, _ in snap_ctx.values()}
need_base |= {b for b, _ in stock_ctx.values() if b}
if not need_base:
return {}
bases = {
m.id: m for m in MaterialBase.query.filter(MaterialBase.id.in_(need_base)).all()
}
out = {}
for r in rows:
ctx = snap_ctx.get(r.id)
if ctx is None:
ctx = stock_ctx.get(as_int(getattr(r, 'target_id', None)))
if not ctx:
continue
base_id, sku = ctx
b = bases.get(base_id)
if b is None:
continue
out[r.id] = {
'base_id': base_id,
'sku': (sku or '').strip(),
'name': (b.name or '').strip(),
'spec': (b.spec_model or '').strip(),
'unit': (b.unit or '').strip(),
}
return out
def format_target_display(ctx, fallback=''):
"""
物料上下文 → 「SKU - 物料名称 (规格型号)」。取不到就返回空串(调用方回落)。
★ 缺哪段就省哪段,不留空括号/空横杠:材料名查不到时显示
「0000001180 - 」比只显示「0000001180」更让人困惑。
"""
if not ctx:
return ''
head = ctx.get('sku') or ''
name, spec = ctx.get('name') or '', ctx.get('spec') or ''
if not (head or name):
return ''
tail = f"{name} ({spec})" if name and spec else (name or '')
if head and tail:
return f"{head} - {tail}"
return head or tail
def _collect_ref(ref_ids, user_ids, module, key, value):
"""把一个候选值按类型塞进对应的收集集合(只收集,不查库)"""
iv = as_int(value)
@ -375,10 +531,15 @@ def changes_of(row):
if key in IGNORED_CHANGE_FIELDS:
continue
if isinstance(val, dict):
out.append((key, val.get('old'), val.get('new')))
old, new = val.get('old'), val.get('new')
else:
# 兼容「非 {old,new} 结构」的历史写法:整值视为新值
out.append((key, None, val))
old, new = None, val
# ★ 等于没改的(NULL ↔ 空串)在这里就丢掉:它会污染摘要(「备注:空→空」)
# 并把真正有意义的变更挤出截断长度;在 Excel 里也白占一行。
if is_noop_change(old, new):
continue
out.append((key, old, new))
return out
@ -512,7 +673,60 @@ def url_source(url):
return truncate(_API_VERSION_RE.sub('', s).strip('/'), 40)
def changes_summary(row, ref_maps, limit=200, resolved=None):
# 摘要核心属性按**槽位**取:每个槽位只取第一个有值的字段。
#
# ★ 为什么不是简单按字段列表顺序取前 3 个:库存快照里 in_quantity /
# stock_quantity / available_quantity 三个数量字段的值往往完全相同,
# 取前 3 个会得到「入库数量 10 件、总库存 10 件、可用库存 10 件」——
# 三个数字一样,纯冗余,还把库位这类更该看到的信息挤掉了。
# 按槽位(数量 / 位置 / 物料)各取一个,信息不重复。
#
# ★ 为什么不再罗列字段名:实测一条入库快照有 29 个字段,罗列成
# 「新增:SKU、base、状态、…等 29 个字段」信息量为零 —— 用户看完还是不知道
# "到底新增了什么"。业务方真正想知道的是"多少数量、放在哪"。
# ★ 只有两个槽位:数量与位置。**不含物料** —— 物料由「操作对象」列承担
# (那一列已经是「SKU - 名称 (规格)」),摘要里再来一遍「SKU 0000002270」
# 是同一屏内的重复,白白吃掉截断长度。用户要的也正是「10 件 (仓库: ZZTEST)」
# 这种只讲数量和位置的形式。
_SUMMARY_SLOTS = (
('数量', ('in_quantity', 'quantity', 'stock_quantity', 'available_quantity',
'out_quantity', 'return_qty', 'dosage')),
('位置', ('warehouse_location', 'warehouse_loc', 'location', 'return_location')),
)
# 数量类字段 —— 摘要里带上单位(单位来自物料主数据,查不到就不带)
_QUANTITY_FIELDS = frozenset(_SUMMARY_SLOTS[0][1])
def _fmt_number(val):
"""数字去掉无意义的 .0(10.0 → 10);非数字原样返回。"""
if isinstance(val, bool):
return str(val)
if isinstance(val, float) and val.is_integer():
return str(int(val))
return text_of(val)
def _core_attrs(snap, material=None):
"""快照 → ['入库数量 10 件', '库位 ZZTEST', …],每个槽位取一个。"""
parts = []
for _slot, keys in _SUMMARY_SLOTS:
for key in keys:
if key not in snap or is_blank(snap[key]):
continue
text = truncate(_fmt_number(snap[key]), 30)
# ★ 单位只在物料主数据里确实有 unit 时才带 —— 不猜。
# 但**纯数字的单位要丢掉**:库里有一批 unit 落成了 '1',
# 显示成「入库数量 48 1」比不带单位还难读。
unit = (material or {}).get('unit') or ''
if key in _QUANTITY_FIELDS and unit and not unit.isdigit():
text = f"{text} {unit}"
parts.append(f"{field_label(key)} {text}")
break # 该槽位已取到,不再看后面的候选字段
return parts
def changes_summary(row, ref_maps, limit=200, resolved=None, material=None):
"""
一行日志的变更摘要(一句话),供台账 sheet 用。
@ -539,15 +753,24 @@ def changes_summary(row, ref_maps, limit=200, resolved=None):
source = url_source(row.url)
return f"{body}(来源:/{source})" if source else body
for which, verb in ((SNAPSHOT_CREATED, '新增'), (SNAPSHOT_DELETED, '删除前快照')):
verb = '新增' if canon_action(row.action) == 'CREATE' else '删除'
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 snap:
# ★ 跳过 id:它是主键、每条都有,排在最前面却零信息量,
# 白白挤掉一个真正有内容的字段(摘要只列前 8 个)。
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"{verb}:{'、'.join(names)}{more}", limit)
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 ''
@ -649,14 +872,19 @@ def _timestamp(row):
return row.created_at.strftime('%Y-%m-%d %H:%M:%S') if row.created_at else ''
def audit_head_values(row):
"""审计页导出的前导列取值(与 AUDIT_HEAD 一一对应)"""
def audit_head_values(row, display=None):
"""
审计页导出的前导列取值(与 AUDIT_HEAD 一一对应)。
display: 补全后的操作对象(「SKU - 名称 (规格)」)。不传则用库里的
target_name —— 后者常常只是 SKU 或 'stock_buy ID:1667'。
"""
return [
_timestamp(row),
operator_of(row),
row.module or '',
action_label(canon_action(row.action)),
row.target_name or '',
display or row.target_name or '',
row.target_id or '',
row.url or '',
]
@ -703,6 +931,7 @@ def build_audit_workbook(rows, ref_maps=None, summary_rows=None, note=None):
if ref_maps is None:
ref_maps = load_ref_maps(rows)
materials = load_material_context(rows)
# ---------- 审计日志(台账)----------
ledger = []
@ -711,24 +940,24 @@ def build_audit_workbook(rows, ref_maps=None, summary_rows=None, note=None):
# UPDATE 行的翻译只算一次,同时喂给台账摘要与明细长表(见 changes_summary)
changes = (resolved_changes(r.module, changes_of(r), ref_maps)
if canon_action(r.action) == 'UPDATE' else None)
mat = materials.get(r.id)
display = format_target_display(mat) or None
ledger.append(
audit_head_values(r)
+ [changes_summary(r, ref_maps, resolved=changes), r.ip_address or '']
audit_head_values(r, display=display)
+ [changes_summary(r, ref_maps, resolved=changes, material=mat),
r.ip_address or '']
)
if changes is None:
continue
head6 = audit_head_values(r, display=display)[:6]
if not changes:
# ★ 改的全是噪声字段的记录不能凭空消失 —— 它确实发生过。
detail.append(
audit_head_values(r)[:6]
+ ['(仅变更了图片/链接/更新时间等噪声字段)', '', '']
head6 + ['(仅变更了图片/链接/更新时间等噪声字段)', '', '']
)
continue
for label, old, new in changes:
detail.append(
audit_head_values(r)[:6]
+ [label, cell(old), cell(new)]
)
detail.append(head6 + [label, cell(old), cell(new)])
# ---------- 汇总 ----------
summary = list(summary_rows or [])

View File

@ -200,8 +200,130 @@ FIELD_LABELS = {
'target_code': '目标编码',
'user_agent': '浏览器标识',
'ip_address': 'IP地址',
'visibility_level': '可见级别',
'is_archived': '是否归档',
# --- 采购 / 入库(stock_* 表)---
# 这几个是实测缺失最多的:采购入库快照整表展开,缺一个就漏一排英文。
'buyer_email': '采购邮箱',
'in_date': '入库日期',
'currency': '币种',
'exchange_rate': '汇率',
'inspection_status': '质检状态',
'inspection_report': '质检报告',
'inspection_report_link': '质检报告链接',
'quality_status': '质检状态',
'quality_report_link': '质检报告链接',
'arrival_photo': '到货照片',
'product_photo': '成品照片',
'global_print_id': '全局打印ID',
'base': '基础物料',
'detail_link': '详情链接',
'original_link': '原始链接',
'supplier_link': '供应商链接',
'request_id': '申请ID',
'purchase_request': '采购申请',
'order_id': '订单ID',
'is_ordered': '是否已下单',
'sale_price': '销售价',
'location': '库位',
'common_name': '通用名',
'material': '物料',
'images': '图片',
# --- 生产(半成品/成品)---
'work_order_code': '工单号',
'bom_code': 'BOM编号',
'production_manager': '生产负责人',
'production_date': '生产日期',
'production_start_time': '生产开始时间',
'production_end_time': '生产结束时间',
'production_time_range': '生产时间段',
'raw_material_cost': '原材料成本',
'manual_cost': '人工成本',
# --- BOM ---
'loss_rate': '损耗率',
'dosage': '用量',
'parent': '父件',
'child': '子件',
'version': '版本',
'bom_remark': 'BOM备注',
'child_bom_no': '子BOM编号',
'child_bom_version': '子BOM版本',
# --- 退回 / 借还 ---
'return_signature': '归还签名',
'return_location': '归还库位',
'borrow_signature': '借出签名',
'current_holder_id': '当前持有人ID',
'current_holder_name': '当前持有人',
'borrower_id': '借用人ID',
'dispatch_operator': '发放操作人',
'source_return_id': '来源退回ID',
# --- 预警设置 ---
'yellow_threshold': '黄色阈值',
'red_threshold': '红色阈值',
'yellow_emails': '黄色预警邮箱',
'red_emails': '红色预警邮箱',
'last_notified_at': '上次通知时间',
# --- 审批 ---
'approver_name': '审批人',
'approval_status': '审批状态',
# --- 盘点 / 扫码(长尾,实测仅个位数条记录,但不补就会在详情里裸露英文)---
'diff_qty': '差异数量',
'stock_qty': '库存数量',
'scan_time': '扫码时间',
'session_id': '会话标识',
'uuid': '唯一标识',
'image_url': '图片地址',
'user_id': '用户ID',
'type': '类型',
}
# =============================================================================
# 6. 详情快照里**不展示**的纯技术字段
#
# ★ 判据:这些字段对业务人员零信息量,且常常极端冗长。
# 典型是 image_embeddings 表的 `embedding`(1536 维向量),一条快照里
# 塞进去就是几 KB 的乱码;`target_id` / `module_name` 则是审计层的元数据,
# 被早期快照采集误带进了业务对象。
#
# ★ 只收「纯技术」的。链接/图片**不收**:那是业务内容,用户点进去是有用的;
# 它们在"变更对比"里已经被 IGNORED_CHANGE_FIELDS 单独处理了。
#
# ★ 这份清单放在后端而不是前端硬编:它会随新表增长,前端再存一份必然漂移。
# 前端经 GET /audit/labels 的 hiddenFields 取。
# =============================================================================
HIDDEN_SNAPSHOT_FIELDS = frozenset({
'id',
'created_at',
'updated_at',
'tenant_id',
'target_id', # 审计层元数据,不是业务字段
'module_name',
'embedding', # pgvector 向量列,单条可达数 KB
'img_embedding',
'arrival_image_embedding',
'qc_report_image_embedding',
'image_embedding',
})
def is_hidden_snapshot_field(key):
"""
该字段是否属于「不该在详情快照里展示」的纯技术字段。
★ 除名单本身,还按**后缀**拦截 `*embedding`:向量列在各表命名不一
(img_embedding / arrival_image_embedding / qc_report_image_embedding…),
逐个登记必然漏。新表加向量列时不必再改这里。
"""
k = str(key or '').strip().lower()
return k in HIDDEN_SNAPSHOT_FIELDS or k.endswith('embedding')
def field_label(key):
"""字段名 → 中文;未命中时原样返回字段名"""
@ -552,4 +674,6 @@ def labels_payload():
# 否则会「下拉显示『入库』、表格显示『库存管理』」。
# ★ 含英文历史值,前端不再自带一份 moduleMap。
'moduleDisplay': module_display_map(),
# 详情快照里不展示的纯技术字段(前端过滤用,见 HIDDEN_SNAPSHOT_FIELDS)
'hiddenFields': sorted(HIDDEN_SNAPSHOT_FIELDS),
}

View File

@ -134,11 +134,14 @@
</template>
</el-table-column>
<!-- 强化操作对象:名称 + 主键,便于按 ID 追单条记录 -->
<el-table-column label="操作对象" min-width="180" show-overflow-tooltip>
<!--
★ 操作对象只显示名称,**不显示数据库主键** —— 业务人员不需要它。
★ 优先用后端补全的 target_display(「SKU - 物料名称 (规格型号)」);
后端解不出物料的记录回落原始 target_name。
-->
<el-table-column label="操作对象" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.target_name || '-' }}</span>
<span v-if="row.target_id" class="muted">#{{ row.target_id }}</span>
<span>{{ row.target_display || row.target_name || '-' }}</span>
</template>
</el-table-column>
@ -225,8 +228,7 @@
<el-tag :type="getActionType(currentDetail.action)">{{ actionLabel(currentDetail.action) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="操作对象">
{{ currentDetail.target_name || '-' }}
<span v-if="currentDetail.target_id" class="muted">#{{ currentDetail.target_id }}</span>
{{ currentDetail.target_display || currentDetail.target_name || '-' }}
</el-descriptions-item>
<el-descriptions-item label="操作时间" :span="2">{{ currentDetail.created_at }}</el-descriptions-item>
<el-descriptions-item label="IP地址">{{ currentDetail.ip_address || '-' }}</el-descriptions-item>
@ -351,7 +353,8 @@ const statusMapByModule = ref<Record<string, Record<string, string>>>({})
const defaultStatusMap = ref<Record<string, string>>({})
const enumFields = ref<string[]>(['status'])
const booleanFields = ref<string[]>([])
// 模块展示映射 {原始值: 展示名} 定义在下方「模块名展示」一节(后端下发)
// 详情快照里不展示的纯技术字段(后端下发,见「详情降噪」一节)
const hiddenFields = ref<string[]>([])
// 字段中文名解析:命中映射取中文,否则原样返回
const fieldLabel = (key: string | number): string => {
@ -395,6 +398,7 @@ const loadLabels = async () => {
enumFields.value = res.data.enumFields || ['status']
booleanFields.value = res.data.booleanFields || []
moduleDisplay.value = res.data.moduleDisplay || {}
hiddenFields.value = res.data.hiddenFields || []
}
} catch (e) {
console.warn('[AuditLog] 审计中文映射拉取失败,字段名与码值将原样显示', e)
@ -458,24 +462,55 @@ const OBJECT_REPR_RE = /^<[A-Za-z_][\w.]*(\s+[^<>]*)?>$/
const isObjectRepr = (v: any): boolean =>
typeof v === 'string' && OBJECT_REPR_RE.test(v.trim())
// ============================================================
// 详情降噪
//
// 目标:只给用户看「真正有值」的业务数据。
//
// ★ 「空」的判据必须**严格**:null / '' / [] / {} / '-' 才算空。
// **false 和 0 不算空** —— `is_returned: false`、`quantity: 0` 是明确的
// 业务事实;用 JS 的真值判断(`if (!v)`)会把它们一起吃掉,
// 那是在悄悄篡改业务数据。
//
// ★ 纯技术字段清单由**后端**下发(hiddenFields),前端不硬编:
// 它会随新表增长(尤其 pgvector 的 `*embedding` 列,单条几 KB),
// 前端再存一份必然漂移。这里只额外按后缀拦一层 embedding,
// 与后端 is_hidden_snapshot_field 的规则一致。
// ============================================================
const isBlankValue = (v: any): boolean => {
if (v === null || v === undefined) return true
if (typeof v === 'string') return ['', '-'].includes(v.trim())
if (Array.isArray(v)) return v.length === 0
if (typeof v === 'object') return Object.keys(v).length === 0
return false
}
const isHiddenField = (key: string): boolean => {
const k = String(key || '').trim().toLowerCase()
return hiddenFields.value.includes(k) || k.endsWith('embedding')
}
// UPDATE:字段对比行
// 过滤「等于没改」的行(两侧都空)—— 后端 changes_of 也会滤,但详情走的是
// 原始 details,这里必须再滤一次,否则会看到「备注:空 → 空」这种行。
const diffRows = computed(() => {
const changes = currentDetail.value?.details?.changes
if (!changes || typeof changes !== 'object') return []
return Object.entries(changes).map(([key, v]: [string, any]) => ({
key,
old: v?.old,
new: v?.new,
}))
return Object.entries(changes)
.filter(([key, v]: [string, any]) =>
!isHiddenField(key) && !(isBlankValue(v?.old) && isBlankValue(v?.new)))
.map(([key, v]: [string, any]) => ({ key, old: v?.old, new: v?.new }))
})
// CREATE / DELETE:快照属性行(跳过对象 repr 脏值)
// 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(([, v]) => !isObjectRepr(v))
.filter(([key, v]) =>
!isBlankValue(v) && !isHiddenField(key) && !isObjectRepr(v))
.map(([key, value]) => ({ key, value }))
})