From 6a9e41f53c9c9624ebdc5ecb56c3455ba50604b6 Mon Sep 17 00:00:00 2001 From: yueli Date: Mon, 28 Sep 2026 10:10:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(audit-ui):=20=E5=AE=A1=E8=AE=A1=E9=A1=B5?= =?UTF-8?q?=20UI=20=E9=87=8D=E6=9E=84=20=E2=80=94=E2=80=94=20=E5=B9=B2?= =?UTF-8?q?=E5=87=80=E6=A8=A1=E5=9D=97=E4=B8=8B=E6=8B=89=E3=80=81=E5=8F=98?= =?UTF-8?q?=E6=9B=B4=E6=91=98=E8=A6=81=E3=80=81=E6=8A=BD=E5=B1=89=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E3=80=81=E8=A7=A6=E5=8F=91=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: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 脏值()。快照收集早期漏跳关系属性, 库里留了 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 列结构与改动前一致。 --- inventory-backend/app/api/v1/audit.py | 85 ++- .../app/services/audit_export_service.py | 72 ++- inventory-backend/app/utils/audit_labels.py | 107 +++- inventory-web/src/views/system/AuditLog.vue | 560 +++++++++--------- 4 files changed, 532 insertions(+), 292 deletions(-) diff --git a/inventory-backend/app/api/v1/audit.py b/inventory-backend/app/api/v1/audit.py index a1b34e4..4cc931b 100644 --- a/inventory-backend/app/api/v1/audit.py +++ b/inventory-backend/app/api/v1/audit.py @@ -4,12 +4,17 @@ from datetime import datetime, timedelta from flask import Blueprint, current_app, jsonify, request, send_file from flask_jwt_extended import jwt_required -from sqlalchemy import func +from sqlalchemy import func, or_ from app.extensions import db from app.models.audit import AuditLog from app.models.system import SysUser -from app.services.audit_export_service import SYSTEM_USERNAME, build_audit_workbook +from app.services.audit_export_service import ( + SYSTEM_USERNAME, + build_audit_workbook, + changes_summary, + load_ref_maps, +) from app.utils.decorators import get_current_company_filter, permission_required audit_bp = Blueprint('audit', __name__) @@ -30,8 +35,13 @@ from app.utils.audit_labels import ( # noqa: E402 canon_action, expand_modules, labels_payload, + module_options, ) +# 列表页「变更摘要」列的截断长度。太长会把表格撑变形且扫读性差 —— +# 完整逐字段对比在详情抽屉里,摘要只负责"一眼看出改了啥"。 +SUMMARY_LIMIT = 120 + # 单次导出的记录数上限。 # # ★ 这是**熔断**,不是"为了让报表好看"的截断: @@ -102,6 +112,20 @@ def _build_audit_query(args): if target_id: query = query.filter(AuditLog.target_id == target_id) + # ---------- 操作对象模糊搜索 ---------- + # 与 target_id 的分工:target_id 是**精确**匹配(程序化调用、追单条记录用), + # target_keyword 是给人在搜索框里用的模糊匹配,同时命中单号/编码/名称。 + # + # ★ 用 ILIKE '%kw%' 走不了 btree 索引,会顺序扫描。当前全库 5.8 万条实测 + # 毫秒级,可接受;若将来量级上去了,再加 pg_trgm 的 GIN 索引即可 + # (不需要改这里的写法)。 + target_keyword = (args.get('target_keyword') or '').strip() + if target_keyword: + query = query.filter(or_( + AuditLog.target_id.ilike(f'%{target_keyword}%'), + AuditLog.target_name.ilike(f'%{target_keyword}%'), + )) + # ---------- 日期区间 ---------- # 语义是**闭区间**:end_date 当天 23:59:59 的数据也要包含进来, # 故上界取次日 00:00 的严格小于。写成 <= end_date 会静默丢掉当天。 @@ -154,6 +178,7 @@ def _filter_summary_rows(args): ('module', '模块'), ('action', '操作类型'), ('target_id', '目标ID'), + ('target_keyword', '操作对象(模糊匹配)'), ] for key, label in labels: val = (args.get(key) or '').strip() @@ -168,6 +193,36 @@ def _filter_summary_rows(args): return rows +def _serialize_logs(rows): + """ + 审计记录 → 响应 dict(列表与详情共用)。 + + 在 to_dict() 之上补两件事: + + ★ **action 归一化**。历史数据里 CREATE 与「新增」混用(早期装饰器写中文, + 现行监听器写英文)。归一后前端不必再为每种历史写法兜底 —— 否则每个 + 新消费者都要记得再兜一次,漏一个就静默显示英文/中文原值。 + 归一用 audit_labels.canon_action(**唯一来源**,覆盖全部历史别名), + 不在模型上另建一份映射:那份只认识 3 个中文词,`批量删除`/`分配`/`归还` + 这类别名会漏掉。 + + ★ **变更摘要**。这里算而不是做成模型 @property:摘要要把 user_id / base_id + 翻成人名 / 物料名,需要查库;模型属性里发查询就是 N+1。 + 改为整页一次性批量解析(load_ref_maps 按 ref 类型各查一次), + 50 条的页面对应 2~3 次查询,与逐行查库是两回事。 + """ + if not rows: + return [] + ref_maps = load_ref_maps(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) + out.append(d) + return out + + def _distinct_with_company(column, company_limit): """ 取某列的去重值(带公司隔离)。 @@ -202,10 +257,10 @@ def get_audit_logs(): # 分页 pagination = query.paginate(page=page, per_page=page_size, error_out=False) - data = [log.to_dict() for log in pagination.items] + data = _serialize_logs(pagination.items) # 获取可用的模块和操作类型(同公司范围内) - modules = _distinct_with_company(AuditLog.module, company_limit) + raw_modules = _distinct_with_company(AuditLog.module, company_limit) actions_raw = _distinct_with_company(AuditLog.action, company_limit) # ★ action 下拉项归一化:把 CREATE/create/新增 等别名合并为一个规范值, @@ -220,7 +275,9 @@ def get_audit_logs(): 'total': pagination.total, 'page': page, 'pageSize': page_size, - 'modules': modules, + # ★ [{value,label}],历史命名已折叠进聚合项且不再单独列出 —— + # 规则由后端 module_options() 统一决定,前端零硬编。 + 'modules': module_options(raw_modules), 'actions': actions } }), 200 @@ -316,7 +373,8 @@ def get_audit_log_detail(log_id): return jsonify({ 'code': 200, 'msg': '获取成功', - 'data': log.to_dict() + # 与列表同一套序列化:action 归一 + 摘要(详情页也用得上同一条摘要) + 'data': _serialize_logs([log])[0] }), 200 except Exception as e: @@ -327,11 +385,20 @@ def get_audit_log_detail(log_id): @audit_bp.route('/modules', methods=['GET']) @jwt_required() def get_modules(): - """获取所有模块列表(用于筛选)""" + """ + 获取模块下拉选项(用于筛选)。 + + 返回 [{value, label}] 而不是裸的 module 字符串 —— 历史命名已被折叠进 + 「入库」聚合项,规则由 audit_labels.module_options() 统一决定。 + + ★ 只挂 @jwt_required()、不加权限码:这里返回的是模块**名称**(不含任何 + 业务记录),且已做公司隔离;审计页本身由 /logs 的权限码把关, + 与 /labels 的处理一致。 + """ try: company_limit = get_current_company_filter() - modules = _distinct_with_company(AuditLog.module, company_limit) - return jsonify({'code': 200, 'data': modules}), 200 + raw_modules = _distinct_with_company(AuditLog.module, company_limit) + return jsonify({'code': 200, 'data': module_options(raw_modules)}), 200 except Exception as e: current_app.logger.error(f"获取模块列表失败: {str(e)}") return jsonify({'code': 500, 'msg': str(e)}), 500 diff --git a/inventory-backend/app/services/audit_export_service.py b/inventory-backend/app/services/audit_export_service.py index 24fea99..6220c5a 100644 --- a/inventory-backend/app/services/audit_export_service.py +++ b/inventory-backend/app/services/audit_export_service.py @@ -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 的识别 —— 形如 ''。 +# +# ★ 来源:快照收集时(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 '' diff --git a/inventory-backend/app/utils/audit_labels.py b/inventory-backend/app/utils/audit_labels.py index fb39ae0..346949c 100644 --- a/inventory-backend/app/utils/audit_labels.py +++ b/inventory-backend/app/utils/audit_labels.py @@ -389,9 +389,106 @@ def person_name_label(value): # 已经是手工同步的副本,不能再塞第二份进去)。 # ============================================================================= MODULE_GROUPS = { - '入库(全部)': ('入库管理', '采购入库', '成品入库', '库存管理'), + '入库': ('入库管理', '采购入库', '成品入库', '库存管理'), } +# 历史遗留的**英文** module 值 → 中文展示名。 +# +# ★ 来源:早期全局监听器(app/utils/audit_events.py,现已停用)没有表白名单, +# 系统表、草稿表、向量表都会被审计,而 _infer_module_name 对未登记的类名 +# 直接回退成**表名**,于是这些英文值混进了 module 列。 +# 实测存量:image_embeddings 2719 条、purchase_request 75 条、sys_element 6 条; +# 表里其余的(stock_buy 等)当前没有数据,但换个环境可能出现,一并留着。 +# +# ★ 这份表原本硬编在前端 AuditLog.vue 的 moduleMap 里,**已经漂移过一次**: +# 前端加了表名后端不知道、后端加了前端不显示。收进本文件后,前端经 +# GET /audit/labels 的 moduleDisplay 取,和字段名/状态码一样只有一份。 +MODULE_LABELS = { + 'image_embeddings': '图像特征(历史)', + 'purchase_request': '采购申请', + 'sys_element': '系统元素', + 'sys_menu': '系统菜单', + 'sys_user': '系统用户', + 'sys_role': '系统角色', + 'sys_role_permission': '角色权限', + 'sys_warehouse_location': '库位设置', + 'material_base': '物料主数据', + 'material_warning_settings': '物料预警设置', + 'stock_buy': '采购库存', + 'stock_semi': '半成品库存', + 'stock_product': '成品库存', + 'stock_adjustment': '库存调整', + 'trans_outbound': '出库流水', + 'trans_borrow': '借还流水', + 'trans_scrap': '报废流水', + 'trans_repair': '维修单', + 'bom_table': 'BOM配方', + 'bom_draft_table': 'BOM草稿', + 'stocktake_draft': '盘点草稿', +} + + +def module_display(module): + """ + 单个 module 值 → 展示名。 + + 三级解析,顺序不能换: + 1. 聚合成员 → 聚合名(「入库管理」→「入库」) + 2. 英文历史值 → 中文(「image_embeddings」→「图像特征(历史)」) + 3. 其余原样(现行监听器写的就是中文,无需翻译) + + ★ 第 1 级必须在第 2 级之前:聚合名优先,否则「库存管理」会被 + MODULE_LABELS 之类的表抢先命中而显示成别的。 + """ + m = (module or '').strip() + for group, members in MODULE_GROUPS.items(): + if m in members: + return group + return MODULE_LABELS.get(m, m) + + +def module_display_map(): + """ + 完整展示映射 {原始值: 展示名},下发给前端。 + + ★ 必须把**聚合成员**也算进来(不只是 MODULE_LABELS):表格与详情里拿到 + 的是原始 module 值,不映射的话会出现「下拉显示『入库』、表格显示 + 『库存管理』」——用户会以为筛选没生效。 + """ + out = dict(MODULE_LABELS) + for group, members in MODULE_GROUPS.items(): + for m in members: + out[m] = group + return out + + +def module_options(raw_modules): + """ + 库里的原始 module 值 → 前端「模块」下拉的最终选项 [{value, label}]。 + + ★ 为什么由后端算,而不是前端拉原始值自己拼: + 分组规则(哪些历史值属于同一个业务概念)由 MODULE_GROUPS 管。前端再算 + 一遍就是第二份副本 —— 将来往组里加成员,前端会**静默失效且没有任何 + 报错**。这是本项目反复踩过的坑(见本文件开头、以及前端 AuditLog.vue + 那个已经漂移过一次的 moduleMap)。 + + ★ 已归入聚合项的历史值**不再单独列出**:`入库管理`/`采购入库`/`成品入库` + 与 `库存管理` 是被改名的同一批数据。同时列出会让用户以为它们是不同的 + 东西,选了历史值又只看得到断裂前的一半 —— 那正是 2026-09-10 的口径 + 断裂在界面上的表现。 + + 代价:无法只查 `库存管理`(09-10 之后的 1371 条)。这是有意取舍 —— + 那几个名字本身就是历史包袱,把选择权从用户手里拿走比让他选错更安全。 + """ + grouped = {m for members in MODULE_GROUPS.values() for m in members} + # 聚合项置顶,其余按名称排序(DISTINCT 返回顺序不保证,排序后位置稳定) + opts = [{'value': name, 'label': name} for name in MODULE_GROUPS] + # ★ label 走 module_display,英文历史值在这里就变成中文 —— + # value 保持原始字符串,否则筛选匹配不上(后端按 module 列的原值过滤) + opts += [{'value': m, 'label': module_display(m)} + for m in sorted(set(raw_modules or [])) if m and m not in grouped] + return opts + def expand_modules(values): """ @@ -448,7 +545,11 @@ def labels_payload(): 'defaultStatus': _stringify_keys(DEFAULT_STATUS_LABELS), 'booleanFields': sorted(BOOLEAN_FIELDS), 'enumFields': sorted(ENUM_FIELDS), - # 模块聚合项及其成员 —— 前端把键注入「模块」下拉,选中后原样回传给 - # 后端,由 expand_modules() 展开。前端不需要知道成员是谁。 + # 模块聚合规则及其成员。前端**不需要**它来渲染下拉(下拉的选项由 + # /audit/modules 下发),保留是为了让"规则"本身可被查看/调试。 'moduleGroups': {k: list(v) for k, v in MODULE_GROUPS.items()}, + # {原始 module 值: 展示名} —— 前端用它把表格/详情里的原始值翻成中文, + # 否则会「下拉显示『入库』、表格显示『库存管理』」。 + # ★ 含英文历史值,前端不再自带一份 moduleMap。 + 'moduleDisplay': module_display_map(), } diff --git a/inventory-web/src/views/system/AuditLog.vue b/inventory-web/src/views/system/AuditLog.vue index 54b3a96..927206a 100644 --- a/inventory-web/src/views/system/AuditLog.vue +++ b/inventory-web/src/views/system/AuditLog.vue @@ -18,34 +18,29 @@ - + - - - - - - - - + + @@ -59,7 +54,6 @@ clearable style="width: 190px" > - + + + + - - -