refactor(audit): 审计中文映射收敛为后端唯一来源

问题:同一套「字段名 → 中文」映射存了三份手工同步的副本 ——
前端 AuditLog.vue 的 fieldMap(约 95 个字段)、后端 api/v1/audit.py 的
ACTION_ALIASES,另有若干内联的 status 映射散落在 service 层。
改一边漏一边就会漂移,页面上冒出英文列名或对不上的中文。

改动:
  · 新增 app/utils/audit_labels.py 作为**唯一来源**,统一提供
    操作类型归一化(canon_action)、字段名中文化(field_label)、
    码值中文化(enum_label / bool_label / person_name_label)、
    ID 指向登记(id_ref_of)。
  · api/v1/audit.py 改为从该模块 import,删掉本地副本。
  · 新增 GET /audit/labels 下发映射(静态标签,免权限码;审计页本身
    已由 system_audit 把关)。
  · 前端 AuditLog.vue 删除本地 95 行 fieldMap,改从接口拉取;
    拉取失败时未命中项原样显示,是可控降级。

★ 码值必须**按模块**翻译:同名 status 在出库/借还/报废里含义完全不同
  (出库的 3 是「已出库」,报废的 3 是「已执行(已报废)」),
  不区分模块会把报废单显示成已出库。借还模块下还混着字符串状态
  (borrowed/returned),因其两张表共用 status 列名。
  故映射按 (模块, 字段) 匹配,未登记再退回字段名。

★ ID 字段同理:parent_id 在「系统管理」里是菜单的上级菜单,在「BOM管理」
  里是物料节点 —— 指向完全不同的表。

验证:前端 vue-tsc 改前改后均为 565 个错误、去除行号后**报错集合完全相同**,
未引入新错误;GET /audit/labels 实测返回 200。
This commit is contained in:
yueli
2026-09-23 17:16:20 +08:00
parent 69d9f010a9
commit ad2d27cd72
4 changed files with 498 additions and 158 deletions

View File

@ -12,35 +12,20 @@ audit_bp = Blueprint('audit', __name__)
# ============================================================================= # =============================================================================
# 操作类型归一化 # 操作类型归一化 / 中文化标签
# #
# 问题背景:历史数据里 action 有两套写法 —— 早期装饰器(已废弃)写入中文 # ★ 映射表已统一收敛到 app/utils/audit_labels.py(**唯一来源**)。
# (新增/修改/删除/批量删除…),现行监听器写入大写英文(CREATE/UPDATE/DELETE)。 # 本模块与日报服务共用同一份,前端经 GET /audit/labels 拉取同一份 ——
# 前端下拉框直接取 DISTINCT action,于是同时出现「CREATE」和「新增」两个选项, # 改一处即可,不会再出现"两边手工同步、改一边漏一边"的漂移。
# 而表格里二者又都显示为「新增」(actionMap 做了映射),用户无法分辨。
# #
# 后果:用户选了看得懂的中文项,只能搜到 3-4 月的历史数据,误以为"没有最近的内容"。 # 历史问题(迁移前):前端 AuditLog.vue 自带一份 fieldMap,后端这里自带一份
# # ACTION_ALIASES,两份副本各自演化。
# 处理:对外只暴露规范值(CREATE/UPDATE/DELETE),筛选时自动展开到全部别名,
# 历史数据无需迁移即可被正确检索。
# ============================================================================= # =============================================================================
ACTION_ALIASES = { from app.utils.audit_labels import ( # noqa: E402
'CREATE': ('CREATE', 'create', 'INSERT', 'insert', '新增', '批量生成'), ACTION_ALIASES,
'UPDATE': ('UPDATE', 'update', '修改', '分配', '归还'), canon_action,
'DELETE': ('DELETE', 'delete', '删除', '批量删除'), labels_payload,
} )
# 反向索引:任意别名 → 规范值
_ALIAS_TO_CANON = {
alias: canon
for canon, aliases in ACTION_ALIASES.items()
for alias in aliases
}
def canon_action(action):
"""把任意写法的 action 归一化为规范值;无法识别时原样返回"""
return _ALIAS_TO_CANON.get((action or '').strip(), (action or '').strip())
@audit_bp.route('/logs', methods=['GET']) @audit_bp.route('/logs', methods=['GET'])
@ -193,3 +178,19 @@ def get_modules():
except Exception as e: except Exception as e:
current_app.logger.error(f"获取模块列表失败: {str(e)}") current_app.logger.error(f"获取模块列表失败: {str(e)}")
return jsonify({'code': 500, 'msg': str(e)}), 500 return jsonify({'code': 500, 'msg': str(e)}), 500
@audit_bp.route('/labels', methods=['GET'])
@jwt_required()
def get_labels():
"""
下发操作类型与字段名的中文映射。
★ 后端是**唯一来源**,前端不再自带副本 —— 否则两份手工同步的映射必然漂移。
前端拉到之前(或拉取失败时)对未命中字段原样显示字段名,
是可控的降级,不会让页面崩掉。
免权限码:纯静态标签,不含任何业务数据;审计页本身已由
@permission_required('system_audit') 把关(见 /logs)。
"""
return jsonify({'code': 200, 'data': labels_payload()}), 200

View File

@ -0,0 +1,399 @@
"""
审计日志的中文化标签 —— **唯一来源**。
为什么要有这个模块
------------------
同一套「字段名 → 中文」映射原先在两个地方各存一份:前端
`views/system/AuditLog.vue` 的 fieldMap,以及后端 `api/v1/audit.py` 里的
ACTION_ALIASES。两份手工同步的副本必然漂移 —— 改一边漏一边,页面上就会
冒出英文列名或对不上的中文。
现在统一收在这里:
· 前端经 `GET /api/v1/audit/labels` 拉取,不再自带副本;
· 后端 API 层与日报服务直接 import,不重复定义。
★ 加新字段时**只改本文件**。
三层映射
--------
1. ACTION_ALIASES / canon_action —— 操作类型归一化。
历史数据里 action 有两套写法:早期装饰器写中文(新增/修改/批量删除…),
现行监听器写大写英文(CREATE/UPDATE/DELETE)。归一到规范值后,
筛选与统计才不会漏掉历史数据。
2. ACTION_LABELS —— 规范值 → 中文显示名。
3. FIELD_LABELS —— 数据库列名 → 中文列名。未命中时由 field_label()
兜底为原字段名(宁可显示英文,也不要显示空白)。
"""
# =============================================================================
# 1. 操作类型归一化
# =============================================================================
# 问题背景(沿用 api/v1/audit.py 的原始注释):
# 前端下拉框直接取 DISTINCT action,于是同时出现「CREATE」和「新增」两个
# 选项,而表格里二者又都显示为「新增」,用户无法分辨。用户选了看得懂的中文项,
# 只能搜到 3-4 月的历史数据,误以为"没有最近的内容"。
#
# 处理:对外只暴露规范值(CREATE/UPDATE/DELETE),筛选时自动展开到全部别名,
# 历史数据无需迁移即可被正确检索。
ACTION_ALIASES = {
'CREATE': ('CREATE', 'create', 'INSERT', 'insert', '新增', '批量生成'),
'UPDATE': ('UPDATE', 'update', '修改', '分配', '归还'),
'DELETE': ('DELETE', 'delete', '删除', '批量删除'),
}
# 反向索引:任意别名 → 规范值
_ALIAS_TO_CANON = {
alias: canon
for canon, aliases in ACTION_ALIASES.items()
for alias in aliases
}
def canon_action(action):
"""把任意写法的 action 归一化为规范值;无法识别时原样返回"""
return _ALIAS_TO_CANON.get((action or '').strip(), (action or '').strip())
# =============================================================================
# 2. 操作类型显示名
# =============================================================================
ACTION_LABELS = {
'CREATE': '新增',
'UPDATE': '修改',
'DELETE': '删除',
'EXPORT': '导出',
'IMPORT': '导入',
'LOGIN': '登录',
'LOGOUT': '登出',
}
def action_label(action):
"""操作类型 → 中文;先归一化再查表,未命中时返回归一化后的原值"""
canon = canon_action(action)
return ACTION_LABELS.get(canon, canon)
# =============================================================================
# 3. 字段名中文化映射
#
# ★ 覆盖范围:审批单 / 流水 / 库存 / 主数据 / 系统管理 五类核心业务表。
# 未命中的字段由 field_label() 兜底为「原字段名」,不会再出现大面积英文列名。
# ★ 用法:变更对比、新增详情、删除快照三个区块统一经 field_label() 取值。
# (改造前仅"变更对比"区用了映射,另外两区直接渲染原始 key,
# 这是"详情里一堆英文列名"的直接原因。)
# =============================================================================
FIELD_LABELS = {
# --- 通用 ---
'id': 'ID',
'name': '名称',
'title': '标题',
'remark': '备注',
'reason': '原因',
'reason_category': '原因分类',
'status': '状态',
'created_at': '创建时间',
'updated_at': '更新时间',
'is_active': '是否启用',
'is_enabled': '是否启用',
'company_name': '所属公司',
'operator_name': '操作人',
'operator': '操作人',
# --- 物料主数据 ---
'material_name': '物料名称',
'spec_model': '规格型号',
'category': '类别',
'material_type': '物料类型',
'unit': '单位',
'base_id': '物料ID',
'sku': 'SKU',
'batch_number': '批次号',
'serial_number': '序列号',
'barcode': '条码',
'warehouse_location': '库位',
'warehouse_loc': '库位',
'reference_price': '参考价格',
'is_approval_required': '是否需审批',
'is_inspection_required': '是否需质检',
# --- 库存数量 ---
'in_quantity': '入库数量',
'stock_quantity': '总库存',
'available_quantity': '可用库存',
'out_quantity': '出库数量',
'quantity': '数量',
'pre_tax_unit_price': '不含税单价',
'post_tax_unit_price': '含税单价',
'unit_price': '单价',
'total_price': '总价',
'tax_rate': '税率',
'supplier_name': '供应商',
'buyer_name': '采购员',
# --- 审批单 ---
'request_no': '申请单号',
'applicant_id': '申请人ID',
'allowed_approvers': '允许审批人',
'actual_approver_id': '实际审批人ID',
'approved_at': '审批时间',
'reject_reason': '驳回原因',
'items_json': '物料明细',
'outbound_type': '出库类型',
'consumer_name': '领用人/客户',
'borrower_name': '借用人',
'executed_at': '执行时间',
'executor_name': '执行人',
# --- 流水 ---
'outbound_no': '出库单号',
'outbound_time': '出库时间',
'borrow_no': '借出单号',
'borrow_time': '借出时间',
'expected_return_time': '预计归还时间',
'return_time': '归还时间',
'return_operator': '归还操作人',
'returned_quantity': '已归还数量',
'is_returned': '是否已归还',
'scrap_request_no': '报废申请单号',
'operation_time': '操作时间',
'source_table': '来源表',
'source_ref': '外部单号',
'stock_id': '库存ID',
'cost_at_scrap': '报废成本',
'total_loss': '损失金额',
# --- 逆向物流(退不良/在管不良品)---
'remaining_qty': '在管数量',
'restocked_qty': '累计已回库',
'scrapped_qty': '累计已报废',
'return_qty': '退回数量',
'return_type': '退回类型',
'return_id': '退回流水ID',
'outbound_id': '出库记录ID',
'signature_path': '签名',
'reissue_qty': '补发数量',
# --- 采购 / BOM ---
'purchase_date': '采购日期',
'requester_id': '申请人ID',
'approver_id': '审批人ID',
'bom_no': 'BOM编号',
'bom_version': 'BOM版本',
'parent_id': '父级ID',
'child_id': '子级ID',
# --- 系统管理 ---
'username': '用户名',
'display_name': '显示名',
'email': '邮箱',
'role': '角色',
'department': '部门',
'password_hash': '密码哈希',
'code': '编码',
'path': '路径',
'sort_order': '排序',
'is_visible': '是否可见',
'menu_code': '菜单编码',
'element_type': '元素类型',
'role_code': '角色编码',
'target_code': '目标编码',
'user_agent': '浏览器标识',
'ip_address': 'IP地址',
}
def field_label(key):
"""字段名 → 中文;未命中时原样返回字段名"""
k = str(key)
return FIELD_LABELS.get(k, k)
# =============================================================================
# 4. 枚举值中文化 —— ★ 必须按模块区分
#
# 同一个字段名在不同模块里是**不同的东西**:
# · 出库管理.status 的 3 表示「已完成(已出库)」
# · 借还管理.status 的 3 表示「已完成(已借出)」
# · 报废管理.status 的 3 表示「已执行(已报废)」
# · 借还管理.status 还可能是字符串 'borrowed'/'returned'(那是 trans_borrow,
# 不是审批单 —— 这个模块下两张表共用了 status 这个列名)
# · 退回管理.status 本来就是中文(待处理/已报废),无需翻译
#
# 不区分模块就会出现「报废单显示成已出库」这种错。
# =============================================================================
# 审批单通用取值(出库/借还/报废/采购四条审批流共用同一套编码)
_APPROVAL_STATUS = {
0: '待审批',
1: '已通过',
2: '已驳回',
3: '已完成',
4: '已撤回',
}
STATUS_LABELS_BY_MODULE = {
# 出库审批流(OutboundApproval)
'出库管理': {**_APPROVAL_STATUS, 3: '已完成(已出库)', 4: '已完结'},
# 借还模块下有两张表共用 status:审批单(数字)与 trans_borrow(字符串)
'借还管理': {
**_APPROVAL_STATUS,
3: '已完成(已借出)',
4: '已撤回',
'borrowed': '借出中',
'returned': '已归还',
'scrapped': '已报废',
},
# 报废审批流(ScrapApproval):4 是「已撤回」,不是「已完结」
'报废管理': {**_APPROVAL_STATUS, 3: '已执行(已报废)'},
# 采购申请 / 采购单(PurchaseRequest)
'采购管理': {**_APPROVAL_STATUS, 3: '已完成', 4: '已完结'},
'purchase_request': {**_APPROVAL_STATUS, 3: '已完成', 4: '已完结'},
}
# 未登记的模块退回通用编码 —— 比显示裸数字强,也比乱猜安全
DEFAULT_STATUS_LABELS = _APPROVAL_STATUS
# 走枚举翻译的字段(目前只有 status;将来有别的码值列在此追加)
ENUM_FIELDS = frozenset({'status'})
# 布尔列 → 是/否
BOOLEAN_FIELDS = frozenset({
'is_enabled', 'is_active', 'is_visible', 'is_archived',
'is_returned', 'is_approval_required', 'is_inspection_required',
})
# 引用 sys_user.id 的列 —— 值需解析成姓名才有意义
USER_ID_FIELDS = frozenset({
'actual_approver_id', 'approver_id', 'applicant_id',
'requester_id', 'user_id', 'returner_id',
'from_user_id', 'to_user_id',
})
# 引用 material_base.id 的列 —— 值需解析成物料名才有意义
MATERIAL_ID_FIELDS = frozenset({'base_id'})
# ---------------------------------------------------------------------------
# ID 字段指向什么实体
#
# ★ 同名 ID 在不同模块指向**完全不同的表** —— 这是必须按模块区分的原因:
# parent_id 在「系统管理」里是菜单的上级菜单(sys_menu),
# 在「BOM管理」里却是材质/物料节点(material_base)。
# 故 (模块, 字段) 优先,未命中再退回按字段名匹配。
#
# ★ 登记原则:**只登记能真正查到实体的**。查不到的 ID 宁可显示原值,
# 也不要硬编一个可能错的中文。
# ---------------------------------------------------------------------------
ID_REF_BY_MODULE = {
('系统管理', 'parent_id'): 'menu',
('系统管理', 'menu_id'): 'menu',
}
ID_REF_BY_FIELD = {
'base_id': 'material',
'parent_id': 'material', # BOM 结构里的父节点(非「系统管理」模块时)
'child_id': 'material',
'return_id': 'return_ledger',
}
def id_ref_of(module, field):
"""该 (模块, 字段) 的 ID 指向什么实体;未登记返回 None。"""
mod = (module or '').strip()
hit = ID_REF_BY_MODULE.get((mod, field))
if hit:
return hit
return ID_REF_BY_FIELD.get(field)
def enum_label(module, field, value):
"""
把枚举码值翻成中文。返回 None 表示「不适用/未登记」,由调用方原样显示。
★ 只对数值型/已登记的值做翻译,**不猜** —— 未登记的模块用通用表兜底,
通用表也查不到就返回 None(显示原值),绝不硬编一个可能错的中文。
"""
if field not in ENUM_FIELDS:
return None
mapping = STATUS_LABELS_BY_MODULE.get((module or '').strip(), DEFAULT_STATUS_LABELS)
if isinstance(value, bool): # bool 是 int 的子类,先挡掉
return None
if isinstance(value, (int, float)) and float(value).is_integer():
return mapping.get(int(value))
if isinstance(value, str):
return mapping.get(value.strip())
return None
def bool_label(value):
"""布尔值 → 是/否;非布尔返回 None"""
if isinstance(value, bool):
return '是' if value else '否'
return None
# 人名类**字符串**字段:库里存法不统一('杜邢宸/duxingchen'),需规整成「名(账号)」。
#
# ★★ 必须按字段名限定,**绝不能**对所有含 '/' 的字符串做规整:
# 规格型号的值就长这样('Det0001/Det0001'),一刀切会把它变成
# 'Det0001(Det0001)' —— 那是静默篡改业务数据,比不翻译危险得多。
PERSON_NAME_FIELDS = frozenset({
'executor_name', 'operator_name', 'operator',
'return_operator', 'approver_name', 'applicant_name',
'borrower_name', 'dispatch_operator', 'purchaser',
'production_manager', 'from_user_name', 'to_user_name',
})
def person_name_label(value):
"""
'杜邢宸/duxingchen' → '杜邢宸(duxingchen)'。
不是字符串、空串、或不含 '/' 时返回 None(由调用方原样显示)——
只做规整不做兜底,避免把空值变成 '-' 之类的占位符。
"""
if not isinstance(value, str):
return None
s = value.strip()
if not s or '/' not in s:
return None
name, _, acct = s.partition('/')
name, acct = name.strip(), acct.strip()
if name and acct:
return f"{name}({acct})"
return None
def _stringify_keys(mapping):
"""
把映射的键统一转成字符串。
★ 两重必要性:
1. JSON 对象的键本来就只能是字符串;
2. Flask 的 jsonify 默认开启 sort_keys,而「借还管理」的状态表里
int 键(0-4)与 str 键('borrowed')混用 —— 排序时会直接抛
TypeError: '<' not supported between instances of 'str' and 'int'。
实测这个错会让接口 500,且被全局错误处理器吞掉、日志里看不到堆栈。
前端取值时用 String(value) 归一化,正好对上字符串键。
"""
return {str(k): v for k, v in mapping.items()}
def labels_payload():
"""
下发给前端的合并映射。
前端替换数据来源即可,取用方式不用改。除字段名外还带上**码值**映射,
这样审计页的详情弹窗也不必再显示「状态: 1 → 3」。
"""
return {
'action': ACTION_LABELS,
'field': FIELD_LABELS,
'status': {
mod: _stringify_keys(m) for mod, m in STATUS_LABELS_BY_MODULE.items()
},
'defaultStatus': _stringify_keys(DEFAULT_STATUS_LABELS),
'booleanFields': sorted(BOOLEAN_FIELDS),
'enumFields': sorted(ENUM_FIELDS),
}

View File

@ -24,3 +24,12 @@ export function getAuditModules() {
method: 'get' method: 'get'
}) })
} }
// 获取操作类型 / 字段名的中文映射
// ★ 后端为唯一来源,前端不再自带副本(见 views/system/AuditLog.vue 的说明)
export function getAuditLabels() {
return request({
url: '/audit/labels',
method: 'get'
})
}

View File

@ -155,12 +155,12 @@
</el-table-column> </el-table-column>
<el-table-column label="修改前" min-width="200"> <el-table-column label="修改前" min-width="200">
<template #default="{ row }"> <template #default="{ row }">
<span class="old-value">{{ row.old ?? '空' }}</span> <span class="old-value">{{ valueLabel(row.field, row.old) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="修改后" min-width="200"> <el-table-column label="修改后" min-width="200">
<template #default="{ row }"> <template #default="{ row }">
<span class="new-value">{{ row.new ?? '空' }}</span> <span class="new-value">{{ valueLabel(row.field, row.new) }}</span>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -179,7 +179,7 @@
:label="fieldLabel(key)" :label="fieldLabel(key)"
:span="isLongValue(value) ? 2 : 1" :span="isLongValue(value) ? 2 : 1"
> >
<span class="snapshot-value">{{ formatValue(value) }}</span> <span class="snapshot-value">{{ formatValue(value, String(key)) }}</span>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
</div> </div>
@ -197,7 +197,7 @@
:label="fieldLabel(key)" :label="fieldLabel(key)"
:span="isLongValue(value) ? 2 : 1" :span="isLongValue(value) ? 2 : 1"
> >
<span class="snapshot-value">{{ formatValue(value) }}</span> <span class="snapshot-value">{{ formatValue(value, String(key)) }}</span>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
</div> </div>
@ -222,7 +222,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue' import { ref, reactive, onMounted, computed } from 'vue'
import { Search, Refresh, EditPen, Delete, Plus, Document } from '@element-plus/icons-vue' import { Search, Refresh, EditPen, Delete, Plus, Document } from '@element-plus/icons-vue'
import { getAuditLogs, getAuditModules } from '@/api/audit' import { getAuditLogs, getAuditModules, getAuditLabels } from '@/api/audit'
// 表格数据 // 表格数据
const tableLoading = ref(false) const tableLoading = ref(false)
@ -251,139 +251,68 @@ const moduleOptions = ref<string[]>([])
const actionOptions = ref<string[]>(['create', 'update', 'delete', 'export', 'import']) const actionOptions = ref<string[]>(['create', 'update', 'delete', 'export', 'import'])
// ============================================================ // ============================================================
// 中文化映射 // 中文化映射 —— ★ 后端为唯一来源,前端不再自带副本
// ============================================================
// 操作类型中文化映射
// 同时兼容两种大小写:当前监听器写入 UPDATE/CREATE/DELETE(大写),
// 历史数据中存在小写与零星中文(如 '修改' / '批量删除'),均需可读。
const actionMap: Record<string, string> = {
'UPDATE': '修改', 'update': '修改',
'CREATE': '新增', 'create': '新增', 'INSERT': '新增', 'insert': '新增',
'DELETE': '删除', 'delete': '删除',
'LOGIN': '登录', 'login': '登录',
'LOGOUT': '登出', 'logout': '登出'
};
// ============================================================
// 字段名中文化映射
// //
// ★ 覆盖范围:审批单 / 流水 / 库存 / 主数据 / 系统管理 五类核心业务表。 // 改造前这里硬编码了 actionMap 与 fieldMap 两份映射,后端 api/v1/audit.py
// 未命中的字段由 fieldLabel() 兜底为「原字段名」,不会再出现大面积英文列名。 // 里又有一份 ACTION_ALIASES。三份手工同步的副本必然漂移:改一边漏一边,
// ★ 用法:变更对比、新增详情、删除快照三个区块统一经 fieldLabel() 取值。 // 页面上就会冒出英文列名、或出现对不上的中文 —— 这是已经踩过的坑。
// (改造前仅"变更对比"区用了 fieldMap,另外两区直接渲染原始 key, //
// 这是"详情里一堆英文列名"的直接原因。) // 现在统一由 GET /audit/labels 下发(后端 app/utils/audit_labels.py 是唯一来源)。
// 拉取失败时两个 map 为空,fieldLabel() / actionMap 对未命中项**原样返回**,
// 页面退化成显示原始字段名,功能不受影响 —— 可控降级,不是崩溃。
// ============================================================ // ============================================================
const fieldMap: Record<string, string> = { const actionMap = ref<Record<string, string>>({})
// --- 通用 ---
'id': 'ID',
'name': '名称',
'title': '标题',
'remark': '备注',
'reason': '原因',
'status': '状态',
'created_at': '创建时间',
'updated_at': '更新时间',
'is_active': '是否启用',
'is_enabled': '是否启用',
'company_name': '所属公司',
'operator_name': '操作人',
'operator': '操作人',
// --- 物料主数据 --- // 字段名中文化映射 —— 与 actionMap 同源,同样由后端下发
'material_name': '物料名称', const fieldMap = ref<Record<string, string>>({})
'spec_model': '规格型号',
'category': '类别',
'material_type': '物料类型',
'unit': '单位',
'base_id': '物料ID',
'sku': 'SKU',
'batch_number': '批次号',
'serial_number': '序列号',
'barcode': '条码',
'warehouse_location': '库位',
'warehouse_loc': '库位',
'reference_price': '参考价格',
'is_approval_required': '是否需审批',
'is_inspection_required': '是否需质检',
// --- 库存数量 --- // 码值中文化映射。
'in_quantity': '入库数量', // ★ status 必须**按模块**查表:同名 status 在出库/借还/报废里含义不同
'stock_quantity': '总库存', // (出库的 3 是「已出库」,报废的 3 是「已执行(已报废)」),
'available_quantity': '可用库存', // 不区分模块就会把报废单显示成已出库。
'out_quantity': '出库数量', const statusMapByModule = ref<Record<string, Record<string, string>>>({})
'quantity': '数量', const defaultStatusMap = ref<Record<string, string>>({})
'pre_tax_unit_price': '不含税单价', const enumFields = ref<string[]>(['status'])
'post_tax_unit_price': '含税单价', const booleanFields = ref<string[]>([])
'unit_price': '单价',
'total_price': '总价',
'tax_rate': '税率',
'supplier_name': '供应商',
'buyer_name': '采购员',
// --- 审批单 ---
'request_no': '申请单号',
'applicant_id': '申请人ID',
'allowed_approvers': '允许审批人',
'actual_approver_id': '实际审批人ID',
'approved_at': '审批时间',
'reject_reason': '驳回原因',
'items_json': '物料明细',
'outbound_type': '出库类型',
'consumer_name': '领用人/客户',
'borrower_name': '借用人',
'executed_at': '执行时间',
'executor_name': '执行人',
// --- 流水 ---
'outbound_no': '出库单号',
'outbound_time': '出库时间',
'borrow_no': '借出单号',
'borrow_time': '借出时间',
'expected_return_time': '预计归还时间',
'return_time': '归还时间',
'return_operator': '归还操作人',
'is_returned': '是否已归还',
'scrap_request_no': '报废申请单号',
'operation_time': '操作时间',
'source_table': '来源表',
'stock_id': '库存ID',
'cost_at_scrap': '报废成本',
'total_loss': '损失金额',
'signature_path': '签名',
// --- 采购 / BOM ---
'purchase_date': '采购日期',
'requester_id': '申请人ID',
'approver_id': '审批人ID',
'bom_no': 'BOM编号',
'bom_version': 'BOM版本',
'parent_id': '父级ID',
'child_id': '子级ID',
// --- 系统管理 ---
'username': '用户名',
'display_name': '显示名',
'email': '邮箱',
'role': '角色',
'department': '部门',
'password_hash': '密码哈希',
'code': '编码',
'path': '路径',
'sort_order': '排序',
'is_visible': '是否可见',
'menu_code': '菜单编码',
'element_type': '元素类型',
'role_code': '角色编码',
'target_code': '目标编码',
'user_agent': '浏览器标识',
'ip_address': 'IP地址'
};
// 字段中文名解析:命中映射取中文,否则原样返回 // 字段中文名解析:命中映射取中文,否则原样返回
const fieldLabel = (key: string | number): string => { const fieldLabel = (key: string | number): string => {
const k = String(key) const k = String(key)
return fieldMap[k] || k return fieldMap.value[k] || k
}
// 变更值中文化:布尔 → 是/否;枚举(status)→ 按模块查表;其余原样返回。
// 查不到就显示原值 —— 宁可看到裸数字,也不要显示一个可能错的中文。
const valueLabel = (field: string, value: any): string => {
if (value === null || value === undefined || value === '') return '空'
if (typeof value === 'object') return JSON.stringify(value)
if (booleanFields.value.includes(field)) return value ? '是' : '否'
if (enumFields.value.includes(field)) {
const mod = String(currentLog.value?.module || '')
const map = statusMapByModule.value[mod] || defaultStatusMap.value || {}
const hit = map[String(value)]
if (hit) return hit
}
return String(value)
}
// 拉取中文映射(后端唯一来源)。失败不阻断页面 —— 退化为显示原始字段名与原值。
const loadLabels = async () => {
try {
const res = await getAuditLabels()
if (res.code === 200 && res.data) {
actionMap.value = res.data.action || {}
fieldMap.value = res.data.field || {}
statusMapByModule.value = res.data.status || {}
defaultStatusMap.value = res.data.defaultStatus || {}
enumFields.value = res.data.enumFields || ['status']
booleanFields.value = res.data.booleanFields || []
}
} catch (e) {
console.warn('[AuditLog] 审计中文映射拉取失败,字段名与码值将原样显示', e)
}
} }
// ============================================================ // ============================================================
@ -521,10 +450,11 @@ const rawJson = computed(() => {
}) })
// 辅助函数:格式化值 // 辅助函数:格式化值
const formatValue = (value: any): string => { // field 传入时走码值中文化(快照里的 status 同样是裸码值)
const formatValue = (value: any, field?: string): string => {
if (value === null || value === undefined) return '-' if (value === null || value === undefined) return '-'
if (typeof value === 'object') return JSON.stringify(value) if (typeof value === 'object') return JSON.stringify(value)
return String(value) return field ? valueLabel(field, value) : String(value)
} }
// 辅助函数:判断是否是长值 // 辅助函数:判断是否是长值
@ -606,6 +536,7 @@ const handleViewDetails = (row: any) => {
onMounted(() => { onMounted(() => {
getList() getList()
loadLabels()
getAuditModules().then(res => { getAuditModules().then(res => {
if (res.code === 200 && res.data) { if (res.code === 200 && res.data) {
moduleOptions.value = res.data moduleOptions.value = res.data