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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user