feat(audit-ui): 审计页 UI 重构 —— 干净模块下拉、变更摘要、抽屉详情、触发来源

后端:GET /audit/modules 与 /logs 的 modules 改为下发最终选项 [{value,label}]
  · 历史命名(入库管理/采购入库/成品入库/库存管理)折叠进「入库」一项且
    **不再单独列出**,下拉里看不到历史包袱。展开仍只由后端 expand_modules
    负责 —— 前端若自己再拼一份成员表,将来后端加成员会静默失效(本项目
    已经踩过两次这种漂移)。
  · 英文历史 module(image_embeddings 2719 条 / purchase_request 75 条 /
    sys_element 6 条)在这里翻成中文 label,value 保留英文原值否则筛选
    匹配不上。映射表 MODULE_LABELS 从**前端** AuditLog.vue 的 moduleMap
    收进 audit_labels.py —— 那份硬编副本历史上已经漂移过一次。
  · /audit/labels 新增 moduleDisplay {原始值 → 展示名},同时覆盖聚合成员与
    英文历史值:只覆盖后者会出现「下拉显示『入库』、表格显示『库存管理』」,
    用户会以为筛选没生效。

后端:新增「触发来源」
  · 摘要追加 (来源:/outbound/request) 形式的 URL 尾段(去掉 /api/vN 前缀)。
    解决一个具体误解:业务方看到「入库/库存管理」里有大量非库管人员的
    UPDATE,以为越权改库存,实际是出库/借还/盘点等单据流转触发的自动扣减。
    实测该类 UPDATE 的来源:outbound 1582、outbound/request 424、
    borrow/dispatch 102、stocktake/update-quantity 95 —— 光看"谁改的"永远
    解释不清,必须能看出"哪个流程触发的"。
  · 来源拼在**截断之后**,保证这条关键信息永远不会被截掉。
  · 列表与导出共用 changes_summary,故 Excel 台账同样带来源。

后端:其余
  · 新增 target_keyword(ilike 同时匹配 target_id / target_name),
    放进共用的 _build_audit_query,导出自动支持。
  · action 在响应里归一化为 CREATE/UPDATE/DELETE(复用 audit_labels
    .canon_action,覆盖批量删除/分配/归还等全部历史别名)。库里还有 1573 条
    中文 action,归一后前端不必再为每种历史写法兜底。
  · 新增 summary 字段。不算在模型 @property 上:摘要要把 user_id/base_id
    翻成人名/物料名,需要查库,模型属性里发查询就是 N+1;改为整页一次
    load_ref_maps 批量解析。
  · 过滤对象 repr 脏值(<MaterialBase 3030>)。快照收集早期漏跳关系属性,
    库里留了 1529 条这种值 —— 不是业务数据,真正的值在对应 _id 字段里。
    判据收窄到「<类名 空格 内容>」,避免误伤备注里的「<急件>」。

前端 AuditLog.vue
  · 模块下拉改平铺 [{value,label}],删除硬编的 moduleMap
  · 新增「操作对象」模糊搜索
  · 合并「操作人」「姓名」两列(判据 username==='system' —— 响应里没有
    operator_type 字段,那是请求参数名,照搬会永远不显示系统标签)
  · 新增「变更摘要」列;操作对象显示为「名称 #id」
  · 操作时间改为恒显示北京时间,不再按浏览器时区换算,与数据库/日报/导出一致
  · 详情弹窗改 el-drawer,按 action 分支渲染:UPDATE 出对比表,
    CREATE/DELETE 出快照属性表,并跳过对象 repr
  · 抽屉顶部高亮展示触发来源(METHOD + URL 告警条)

验证:后端 18 + 22 + 10 项断言全过(模块选项无历史名且「入库」=四值之和、
英文 value 仍可筛选、url_source 边界、来源不被截断、导出口径与列表一致、
权限 401/403/200 矩阵);vue-tsc --noEmit 与 vite build 均 exit=0。
日报回归:三天附件 490.5K/193.5K/10.3K,6 列结构与改动前一致。
This commit is contained in:
yueli
2026-09-28 10:10:05 +08:00
parent 7d203b20c1
commit 6a9e41f53c
4 changed files with 532 additions and 292 deletions

View File

@ -27,6 +27,7 @@
记错键名不会报错,只会得到一张空表。
"""
import json
import re
from collections import Counter, defaultdict
from app.models.base import MaterialBase
@ -117,6 +118,28 @@ def as_int(value):
return None
# 对象 repr 的识别 —— 形如 '<MaterialBase 3030>'。
#
# ★ 来源:快照收集时(audit_listener._collect_snapshot)本应跳过关系属性,
# 但早期版本漏过去了,于是一批历史快照里留下了这种「被 str() 的对象」。
# 它**不是业务数据**:真正有用的值在对应的 `xxx_id` 字段里(parent_id=3030),
# 原样显示只会让人以为数据坏了。
#
# ★ 判据要**窄**:只认「<类名 空格 内容>」这一种形态,不能见 '<'/'>' 就滤,
# 否则会把正常的业务值也误伤(备注里写「<急件>」是很正常的)。
_OBJECT_REPR = re.compile(r'^<[A-Za-z_][\w.]*(\s+[^<>]*)?>$')
def is_object_repr(s):
"""该字符串是否是「被 str() 的 ORM 对象」而非业务数据"""
return isinstance(s, str) and bool(_OBJECT_REPR.match(s.strip()))
# 对象 repr 的替代显示。明确指向 _id 字段,而不是留空 —— 留空会让人以为
# "这个字段本来就没值",而实际上值在别处。
OBJECT_REPR_TEXT = '(对象引用,见对应 _id 字段)'
def text_of(val):
"""
任意值 → 文本。
@ -131,6 +154,8 @@ def text_of(val):
return json.dumps(val, ensure_ascii=False, separators=(',', ':'))
except (TypeError, ValueError):
return str(val)
if is_object_repr(val):
return OBJECT_REPR_TEXT
return str(val)
@ -458,6 +483,35 @@ def resolved_changes(module, changes, ref_maps):
return out
# 请求 URL 的 /api 或 /api/vN 前缀 —— 对用户零信息量,还占摘要的字符预算
_API_VERSION_RE = re.compile(r'^/?api(?:/v\d+)?/?', re.IGNORECASE)
def url_source(url):
"""
请求 URL → 简短的「触发来源」。去掉 /api/vN 前缀,保留剩余路径。
★ 这个信号解决的是一个**具体的误解**:业务方看到「入库/库存管理」里有
大量非库管人员的 UPDATE,以为是越权改库存 —— 实际是出库、借还、盘点
等单据流转触发的自动扣减。实测该类 UPDATE 的来源分布:
1582 /api/v1/outbound 出库触发扣减
424 /api/v1/outbound/request
108 /api/v1/inbound/stock/draft/add
102 /api/v1/transactions/borrow/dispatch 借出
95 /api/v1/inbound/stock/stocktake/update-quantity
光看"谁改的"永远解释不清,必须能看出"哪个流程触发的"。
★ 去掉版本前缀是因为它零信息量;剩下的路径段正是要的东西。
长度封顶 40 字,避免个别超长 URL 把摘要挤没。
"""
s = (url or '').strip()
if not s:
return ''
return truncate(_API_VERSION_RE.sub('', s).strip('/'), 40)
def changes_summary(row, ref_maps, limit=200, resolved=None):
"""
一行日志的变更摘要(一句话),供台账 sheet 用。
@ -474,15 +528,25 @@ def changes_summary(row, ref_maps, limit=200, resolved=None):
resolved = resolved_changes(row.module, changes_of(row), ref_maps)
if not resolved:
return '(仅变更了图片/链接/更新时间等噪声字段)'
parts = [f"{label}: {fmt_value(old)}→{fmt_value(new)}"
# ★ 分隔符统一用全角(:和;)—— 摘要是一句中文,半角冒号混在全角分号
# 里很难看,也和日报正文的排版不一致。
parts = [f"{label}:{fmt_value(old)}→{fmt_value(new)}"
for label, old, new in resolved]
return truncate(';'.join(parts), limit)
# ★ 来源**拼在截断之后**:先截变更内容、再追加来源,保证来源这条关键
# 信息永远不会被截掉。反过来(整个串一起截)会把来源切没,
# 而"哪触发的"正是这条摘要存在的理由。
body = truncate(';'.join(parts), limit)
source = url_source(row.url)
return f"{body}(来源:/{source})" if source else body
for which, verb in ((SNAPSHOT_CREATED, '新增'), (SNAPSHOT_DELETED, '删除前快照')):
snap = snapshot_of(row, which)
if snap:
names = [field_label(k) for k in list(snap)[:8]]
more = '' if len(snap) <= 8 else f" 等 {len(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)
return ''