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

@ -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 }))
})