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 import Blueprint, current_app, jsonify, request, send_file
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func, or_
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.audit import AuditLog
|
from app.models.audit import AuditLog
|
||||||
from app.models.system import SysUser
|
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
|
from app.utils.decorators import get_current_company_filter, permission_required
|
||||||
|
|
||||||
audit_bp = Blueprint('audit', __name__)
|
audit_bp = Blueprint('audit', __name__)
|
||||||
@ -30,8 +35,13 @@ from app.utils.audit_labels import ( # noqa: E402
|
|||||||
canon_action,
|
canon_action,
|
||||||
expand_modules,
|
expand_modules,
|
||||||
labels_payload,
|
labels_payload,
|
||||||
|
module_options,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 列表页「变更摘要」列的截断长度。太长会把表格撑变形且扫读性差 ——
|
||||||
|
# 完整逐字段对比在详情抽屉里,摘要只负责"一眼看出改了啥"。
|
||||||
|
SUMMARY_LIMIT = 120
|
||||||
|
|
||||||
# 单次导出的记录数上限。
|
# 单次导出的记录数上限。
|
||||||
#
|
#
|
||||||
# ★ 这是**熔断**,不是"为了让报表好看"的截断:
|
# ★ 这是**熔断**,不是"为了让报表好看"的截断:
|
||||||
@ -102,6 +112,20 @@ def _build_audit_query(args):
|
|||||||
if target_id:
|
if target_id:
|
||||||
query = query.filter(AuditLog.target_id == 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 的数据也要包含进来,
|
# 语义是**闭区间**:end_date 当天 23:59:59 的数据也要包含进来,
|
||||||
# 故上界取次日 00:00 的严格小于。写成 <= end_date 会静默丢掉当天。
|
# 故上界取次日 00:00 的严格小于。写成 <= end_date 会静默丢掉当天。
|
||||||
@ -154,6 +178,7 @@ def _filter_summary_rows(args):
|
|||||||
('module', '模块'),
|
('module', '模块'),
|
||||||
('action', '操作类型'),
|
('action', '操作类型'),
|
||||||
('target_id', '目标ID'),
|
('target_id', '目标ID'),
|
||||||
|
('target_keyword', '操作对象(模糊匹配)'),
|
||||||
]
|
]
|
||||||
for key, label in labels:
|
for key, label in labels:
|
||||||
val = (args.get(key) or '').strip()
|
val = (args.get(key) or '').strip()
|
||||||
@ -168,6 +193,36 @@ def _filter_summary_rows(args):
|
|||||||
return rows
|
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):
|
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)
|
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)
|
actions_raw = _distinct_with_company(AuditLog.action, company_limit)
|
||||||
|
|
||||||
# ★ action 下拉项归一化:把 CREATE/create/新增 等别名合并为一个规范值,
|
# ★ action 下拉项归一化:把 CREATE/create/新增 等别名合并为一个规范值,
|
||||||
@ -220,7 +275,9 @@ def get_audit_logs():
|
|||||||
'total': pagination.total,
|
'total': pagination.total,
|
||||||
'page': page,
|
'page': page,
|
||||||
'pageSize': page_size,
|
'pageSize': page_size,
|
||||||
'modules': modules,
|
# ★ [{value,label}],历史命名已折叠进聚合项且不再单独列出 ——
|
||||||
|
# 规则由后端 module_options() 统一决定,前端零硬编。
|
||||||
|
'modules': module_options(raw_modules),
|
||||||
'actions': actions
|
'actions': actions
|
||||||
}
|
}
|
||||||
}), 200
|
}), 200
|
||||||
@ -316,7 +373,8 @@ def get_audit_log_detail(log_id):
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'code': 200,
|
'code': 200,
|
||||||
'msg': '获取成功',
|
'msg': '获取成功',
|
||||||
'data': log.to_dict()
|
# 与列表同一套序列化:action 归一 + 摘要(详情页也用得上同一条摘要)
|
||||||
|
'data': _serialize_logs([log])[0]
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -327,11 +385,20 @@ def get_audit_log_detail(log_id):
|
|||||||
@audit_bp.route('/modules', methods=['GET'])
|
@audit_bp.route('/modules', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def get_modules():
|
def get_modules():
|
||||||
"""获取所有模块列表(用于筛选)"""
|
"""
|
||||||
|
获取模块下拉选项(用于筛选)。
|
||||||
|
|
||||||
|
返回 [{value, label}] 而不是裸的 module 字符串 —— 历史命名已被折叠进
|
||||||
|
「入库」聚合项,规则由 audit_labels.module_options() 统一决定。
|
||||||
|
|
||||||
|
★ 只挂 @jwt_required()、不加权限码:这里返回的是模块**名称**(不含任何
|
||||||
|
业务记录),且已做公司隔离;审计页本身由 /logs 的权限码把关,
|
||||||
|
与 /labels 的处理一致。
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
company_limit = get_current_company_filter()
|
company_limit = get_current_company_filter()
|
||||||
modules = _distinct_with_company(AuditLog.module, company_limit)
|
raw_modules = _distinct_with_company(AuditLog.module, company_limit)
|
||||||
return jsonify({'code': 200, 'data': modules}), 200
|
return jsonify({'code': 200, 'data': module_options(raw_modules)}), 200
|
||||||
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
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
记错键名不会报错,只会得到一张空表。
|
记错键名不会报错,只会得到一张空表。
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
|
|
||||||
from app.models.base import MaterialBase
|
from app.models.base import MaterialBase
|
||||||
@ -117,6 +118,28 @@ def as_int(value):
|
|||||||
return None
|
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):
|
def text_of(val):
|
||||||
"""
|
"""
|
||||||
任意值 → 文本。
|
任意值 → 文本。
|
||||||
@ -131,6 +154,8 @@ def text_of(val):
|
|||||||
return json.dumps(val, ensure_ascii=False, separators=(',', ':'))
|
return json.dumps(val, ensure_ascii=False, separators=(',', ':'))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return str(val)
|
return str(val)
|
||||||
|
if is_object_repr(val):
|
||||||
|
return OBJECT_REPR_TEXT
|
||||||
return str(val)
|
return str(val)
|
||||||
|
|
||||||
|
|
||||||
@ -458,6 +483,35 @@ def resolved_changes(module, changes, ref_maps):
|
|||||||
return out
|
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):
|
def changes_summary(row, ref_maps, limit=200, resolved=None):
|
||||||
"""
|
"""
|
||||||
一行日志的变更摘要(一句话),供台账 sheet 用。
|
一行日志的变更摘要(一句话),供台账 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)
|
resolved = resolved_changes(row.module, changes_of(row), ref_maps)
|
||||||
if not resolved:
|
if not resolved:
|
||||||
return '(仅变更了图片/链接/更新时间等噪声字段)'
|
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]
|
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, '删除前快照')):
|
for which, verb in ((SNAPSHOT_CREATED, '新增'), (SNAPSHOT_DELETED, '删除前快照')):
|
||||||
snap = snapshot_of(row, which)
|
snap = snapshot_of(row, which)
|
||||||
if snap:
|
if snap:
|
||||||
names = [field_label(k) for k in list(snap)[:8]]
|
# ★ 跳过 id:它是主键、每条都有,排在最前面却零信息量,
|
||||||
more = '' if len(snap) <= 8 else f" 等 {len(snap)} 个字段"
|
# 白白挤掉一个真正有内容的字段(摘要只列前 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 truncate(f"{verb}:{'、'.join(names)}{more}", limit)
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|||||||
@ -389,9 +389,106 @@ def person_name_label(value):
|
|||||||
# 已经是手工同步的副本,不能再塞第二份进去)。
|
# 已经是手工同步的副本,不能再塞第二份进去)。
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
MODULE_GROUPS = {
|
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):
|
def expand_modules(values):
|
||||||
"""
|
"""
|
||||||
@ -448,7 +545,11 @@ def labels_payload():
|
|||||||
'defaultStatus': _stringify_keys(DEFAULT_STATUS_LABELS),
|
'defaultStatus': _stringify_keys(DEFAULT_STATUS_LABELS),
|
||||||
'booleanFields': sorted(BOOLEAN_FIELDS),
|
'booleanFields': sorted(BOOLEAN_FIELDS),
|
||||||
'enumFields': sorted(ENUM_FIELDS),
|
'enumFields': sorted(ENUM_FIELDS),
|
||||||
# 模块聚合项及其成员 —— 前端把键注入「模块」下拉,选中后原样回传给
|
# 模块聚合规则及其成员。前端**不需要**它来渲染下拉(下拉的选项由
|
||||||
# 后端,由 expand_modules() 展开。前端不需要知道成员是谁。
|
# /audit/modules 下发),保留是为了让"规则"本身可被查看/调试。
|
||||||
'moduleGroups': {k: list(v) for k, v in MODULE_GROUPS.items()},
|
'moduleGroups': {k: list(v) for k, v in MODULE_GROUPS.items()},
|
||||||
|
# {原始 module 值: 展示名} —— 前端用它把表格/详情里的原始值翻成中文,
|
||||||
|
# 否则会「下拉显示『入库』、表格显示『库存管理』」。
|
||||||
|
# ★ 含英文历史值,前端不再自带一份 moduleMap。
|
||||||
|
'moduleDisplay': module_display_map(),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,34 +18,29 @@
|
|||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="操作人">
|
<el-form-item label="操作人">
|
||||||
<el-input v-model="queryParams.username" placeholder="请输入操作人账号" clearable @keyup.enter="handleQuery" style="width: 150px" />
|
<el-input
|
||||||
|
v-model="queryParams.username"
|
||||||
|
placeholder="输入姓名或账号"
|
||||||
|
clearable
|
||||||
|
style="width: 150px"
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="模块">
|
<el-form-item label="模块">
|
||||||
<!--
|
<!--
|
||||||
★ 分两组:上面是业务聚合项,下面是具体的 module 值。
|
★ 平铺列表,选项由后端下发 [{value,label}]。
|
||||||
聚合项是必须的 —— 审计的 module 在 2026-09-10 换过一次口径
|
历史命名(入库管理/采购入库/成品入库/库存管理)已被后端折叠进
|
||||||
(旧的「入库管理」→ 新的「库存管理」),只按单个 module 值筛
|
「入库」这一项且不再单独出现 —— 分组规则是后端 module_options()
|
||||||
「入库」会正好在那天断掉。聚合项由后端展开成全部成员值
|
的事,前端不硬编,否则将来后端加成员会**静默失效**。
|
||||||
(见后端 audit_labels.MODULE_GROUPS),前端不需要知道成员是谁。
|
详见后端 audit_labels.MODULE_GROUPS 的说明。
|
||||||
-->
|
-->
|
||||||
<el-select v-model="queryParams.module" placeholder="请选择模块" clearable style="width: 200px">
|
<el-select v-model="queryParams.module" placeholder="全部模块" clearable style="width: 170px">
|
||||||
<el-option-group v-if="moduleGroupKeys.length" label="业务聚合">
|
<el-option
|
||||||
<el-option
|
v-for="o in moduleOptions"
|
||||||
v-for="g in moduleGroupKeys"
|
:key="o.value"
|
||||||
:key="g"
|
:label="o.label"
|
||||||
:label="g"
|
:value="o.value"
|
||||||
:value="g"
|
/>
|
||||||
/>
|
|
||||||
</el-option-group>
|
|
||||||
<el-option-group label="具体模块">
|
|
||||||
<!-- ★ label 走中文映射,value 保留原始值以保证筛选命中 -->
|
|
||||||
<el-option
|
|
||||||
v-for="item in moduleOptions"
|
|
||||||
:key="item"
|
|
||||||
:label="moduleLabel(item)"
|
|
||||||
:value="item"
|
|
||||||
/>
|
|
||||||
</el-option-group>
|
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="操作类型">
|
<el-form-item label="操作类型">
|
||||||
@ -59,7 +54,6 @@
|
|||||||
clearable
|
clearable
|
||||||
style="width: 190px"
|
style="width: 190px"
|
||||||
>
|
>
|
||||||
<!-- ★ 下拉只列规范值,显示为中文;后端会把历史别名一并匹配 -->
|
|
||||||
<el-option
|
<el-option
|
||||||
v-for="item in actionOptions"
|
v-for="item in actionOptions"
|
||||||
:key="item"
|
:key="item"
|
||||||
@ -68,6 +62,16 @@
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="操作对象">
|
||||||
|
<!-- 同时模糊匹配单号/编码/名称(后端 target_keyword 走 target_id + target_name) -->
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.target_keyword"
|
||||||
|
placeholder="输入单号、编码或名称"
|
||||||
|
clearable
|
||||||
|
style="width: 180px"
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="时间范围">
|
<el-form-item label="时间范围">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="dateRange"
|
v-model="dateRange"
|
||||||
@ -100,44 +104,71 @@
|
|||||||
|
|
||||||
<!-- 表格 -->
|
<!-- 表格 -->
|
||||||
<el-table v-loading="tableLoading" :data="tableData" border stripe>
|
<el-table v-loading="tableLoading" :data="tableData" border stripe>
|
||||||
<el-table-column prop="id" label="ID" width="70" />
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
<el-table-column prop="username" label="操作人" width="140">
|
|
||||||
<template #default="scope">
|
<!-- ★ 合并原「操作人」+「姓名」两列:它们本来就是同一个人 -->
|
||||||
<!-- ★ 系统日志加灰色标签,便于在"全部"视图下快速识别 -->
|
<el-table-column label="操作人" width="170">
|
||||||
<el-tag v-if="scope.row.username === 'system'" type="info" size="small">系统</el-tag>
|
<template #default="{ row }">
|
||||||
<span v-else>{{ scope.row.username }}</span>
|
<!-- ★ 判据必须是 username === 'system'。
|
||||||
|
后端响应里**没有** operator_type 字段 —— 那是请求参数名,
|
||||||
|
写成 row.operator_type === 'system' 会永远为假、标签永不显示。 -->
|
||||||
|
<template v-if="row.username === 'system'">
|
||||||
|
<el-tag type="info" size="small">系统</el-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ row.display_name || row.username }}
|
||||||
|
<span v-if="row.display_name && row.username" class="muted">({{ row.username }})</span>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="display_name" label="姓名" width="100" />
|
|
||||||
<el-table-column prop="module" label="模块" width="120">
|
<el-table-column prop="module" label="模块" width="120">
|
||||||
<template #default="scope">
|
<template #default="{ row }">
|
||||||
<el-tag>{{ moduleLabel(scope.row.module) }}</el-tag>
|
<el-tag>{{ moduleLabel(row.module) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column prop="action" label="操作类型" width="100">
|
<el-table-column prop="action" label="操作类型" width="100">
|
||||||
<template #default="scope">
|
<template #default="{ row }">
|
||||||
<el-tag :type="getActionType(scope.row.action)">{{ actionMap[scope.row.action] || scope.row.action }}</el-tag>
|
<el-tag :type="getActionType(row.action)">{{ actionLabel(row.action) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="target_name" label="操作对象" min-width="150" show-overflow-tooltip />
|
|
||||||
|
<!-- 强化操作对象:名称 + 主键,便于按 ID 追单条记录 -->
|
||||||
|
<el-table-column label="操作对象" min-width="180" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.target_name || '-' }}</span>
|
||||||
|
<span v-if="row.target_id" class="muted">#{{ row.target_id }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<!-- ★ 摘要由后端生成(含值翻译:状态 待审批→已通过、ID→人名)。
|
||||||
|
前端不要自己解析 details 树:那会让列表页加载庞大的 JSON,
|
||||||
|
且翻译规则会与后端/导出各存一份。 -->
|
||||||
|
<el-table-column prop="summary" label="变更摘要" min-width="220" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="summary-text">{{ row.summary || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column prop="ip_address" label="IP地址" width="130" />
|
<el-table-column prop="ip_address" label="IP地址" width="130" />
|
||||||
<el-table-column prop="created_at" label="操作时间" width="170">
|
|
||||||
<template #default="scope">
|
<!-- ★ 直接显示后端的北京时间字符串,不做时区换算:
|
||||||
{{ formatLocalTime(scope.row.created_at) }}
|
与数据库、日报、导出保持同一口径,换台电脑看也是同一时刻 -->
|
||||||
</template>
|
<el-table-column prop="created_at" label="操作时间" width="170" />
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="120" fixed="right">
|
<el-table-column label="操作" width="90" fixed="right" align="center">
|
||||||
<template #default="scope">
|
<template #default="{ row }">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="hasDetailContent(scope.row.details)"
|
v-if="hasDetailContent(row.details)"
|
||||||
link
|
link
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@click="handleViewDetails(scope.row)"
|
@click="openDetail(row)"
|
||||||
>
|
>
|
||||||
查看详情
|
查看详情
|
||||||
</el-button>
|
</el-button>
|
||||||
<span v-else style="color: #909399; font-size: 12px;">无变更明细</span>
|
<span v-else class="muted">无明细</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@ -155,109 +186,116 @@
|
|||||||
/>
|
/>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- ★ 重写的详情弹窗:支持三种高级结构 ★ -->
|
<!-- 详情抽屉:比弹窗宽,字段对比更好读 -->
|
||||||
<el-dialog v-model="detailDialogVisible" title="操作详情" width="750px" destroy-on-close :close-on-click-modal="false">
|
<el-drawer v-model="detailVisible" title="操作详情" size="46%" destroy-on-close>
|
||||||
<!-- 基本信息 -->
|
<template v-if="currentDetail.id">
|
||||||
<el-descriptions :column="2" border class="base-info">
|
<!--
|
||||||
<el-descriptions-item label="ID">{{ currentLog.id }}</el-descriptions-item>
|
★ 触发来源置顶高亮。
|
||||||
<el-descriptions-item label="操作人">{{ currentLog.username }} ({{ currentLog.display_name }})</el-descriptions-item>
|
业务方看到「入库/库存管理」里出现大量非库管人员的 UPDATE,
|
||||||
<el-descriptions-item label="模块">
|
会误以为"越权修改库存" —— 实际多数是出库、借还、盘点等**单据流转**
|
||||||
<el-tag>{{ moduleLabel(currentLog.module) }}</el-tag>
|
触发的自动扣减,操作人只是执行那笔单据的人。
|
||||||
</el-descriptions-item>
|
光看"谁改的"永远解释不清,必须把"哪个接口触发的"摆到最显眼处。
|
||||||
<el-descriptions-item label="操作类型">
|
-->
|
||||||
<el-tag :type="getActionType(currentLog.action)">{{ actionMap[currentLog.action] || currentLog.action }}</el-tag>
|
<el-alert
|
||||||
</el-descriptions-item>
|
v-if="currentDetail.url"
|
||||||
<el-descriptions-item label="操作对象" :span="2">{{ currentLog.target_name || '-' }}</el-descriptions-item>
|
type="info"
|
||||||
<el-descriptions-item label="IP地址">{{ currentLog.ip_address }}</el-descriptions-item>
|
:closable="false"
|
||||||
<el-descriptions-item label="请求方式">{{ currentLog.method }}</el-descriptions-item>
|
show-icon
|
||||||
<el-descriptions-item label="操作时间" :span="2">{{ formatLocalTime(currentLog.created_at) }}</el-descriptions-item>
|
class="source-alert"
|
||||||
</el-descriptions>
|
>
|
||||||
|
<template #title>
|
||||||
|
<span class="source-method">{{ currentDetail.method || '—' }}</span>
|
||||||
|
<span class="source-url">{{ currentDetail.url }}</span>
|
||||||
|
</template>
|
||||||
|
<template #default>
|
||||||
|
<span class="source-hint">
|
||||||
|
触发来源(该记录由这个接口产生)。若是出库/借还/盘点等单据流转,
|
||||||
|
操作人是执行单据的人,并非直接在库存上改数。
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
<!-- ★ 变更明细区域(支持同时展示多种结构)★ -->
|
<el-descriptions :column="2" border size="small" class="base-info">
|
||||||
<div class="details-section">
|
<el-descriptions-item label="操作人">
|
||||||
|
{{ currentDetail.display_name || currentDetail.username }}
|
||||||
|
<span v-if="currentDetail.username" class="muted">({{ currentDetail.username }})</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="模块">{{ moduleLabel(currentDetail.module) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="操作类型">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<el-descriptions-item label="请求方式">{{ currentDetail.method || '-' }}</el-descriptions-item>
|
||||||
|
<!-- 请求地址移到顶部 alert 里高亮展示(见上),此处不再重复 -->
|
||||||
|
<el-descriptions-item label="变更摘要" :span="2">
|
||||||
|
<span class="summary-text">{{ currentDetail.summary || '—' }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
<!-- 情况1:UPDATE - 变更对比表 -->
|
<!-- 情况1:UPDATE —— 新旧值对比表 -->
|
||||||
<div v-if="hasChanges" class="changes-box">
|
<div v-if="diffRows.length" class="details-section">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<el-icon><EditPen /></el-icon>
|
<el-icon><EditPen /></el-icon>
|
||||||
字段变更详情(共 {{ changesList.length }} 处变更)
|
字段变更(共 {{ diffRows.length }} 处)
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="changesList" border stripe size="small" max-height="350">
|
<el-table :data="diffRows" border stripe size="small">
|
||||||
<el-table-column label="字段名" width="150">
|
<el-table-column label="变更字段" width="150">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="field-name">{{ fieldLabel(row.field) }}</span>
|
<span class="field-name">{{ fieldLabel(row.key) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="修改前" min-width="200">
|
<el-table-column label="修改前">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="old-value">{{ valueLabel(row.field, row.old) }}</span>
|
<span class="old-value">{{ formatValue(row.old, row.key) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="修改后" min-width="200">
|
<el-table-column label="修改后">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="new-value">{{ valueLabel(row.field, row.new) }}</span>
|
<span class="new-value">{{ formatValue(row.new, row.key) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 情况2:DELETE - 删除快照 -->
|
<!-- 情况2/3:CREATE / DELETE —— 快照属性列表 -->
|
||||||
<div v-if="hasDeletedSnapshot" class="snapshot-box">
|
<div v-else-if="snapshotRows.length" class="details-section">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<el-icon><Delete /></el-icon>
|
<el-icon><Document /></el-icon>
|
||||||
删除前的数据快照
|
{{ isDelete ? '删除前数据快照' : '新增数据快照' }}
|
||||||
</div>
|
</div>
|
||||||
<el-descriptions :column="2" border size="small">
|
<el-descriptions :column="1" border size="small">
|
||||||
<el-descriptions-item
|
<el-descriptions-item
|
||||||
v-for="(value, key) in deletedSnapshot"
|
v-for="item in snapshotRows"
|
||||||
:key="String(key)"
|
:key="String(item.key)"
|
||||||
:label="fieldLabel(key)"
|
:label="fieldLabel(item.key)"
|
||||||
:span="isLongValue(value) ? 2 : 1"
|
|
||||||
>
|
>
|
||||||
<span class="snapshot-value">{{ formatValue(value, String(key)) }}</span>
|
<span class="snapshot-value">{{ formatValue(item.value, String(item.key)) }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 情况3:CREATE - 新增详情 -->
|
<!-- 兜底:没有可识别的结构时显示原始 JSON -->
|
||||||
<div v-if="hasCreated" class="snapshot-box">
|
<div v-else class="details-section">
|
||||||
<div class="section-title">
|
|
||||||
<el-icon><Plus /></el-icon>
|
|
||||||
新增的数据详情
|
|
||||||
</div>
|
|
||||||
<el-descriptions :column="2" border size="small">
|
|
||||||
<el-descriptions-item
|
|
||||||
v-for="(value, key) in createdData"
|
|
||||||
:key="String(key)"
|
|
||||||
:label="fieldLabel(key)"
|
|
||||||
:span="isLongValue(value) ? 2 : 1"
|
|
||||||
>
|
|
||||||
<span class="snapshot-value">{{ formatValue(value, String(key)) }}</span>
|
|
||||||
</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 兜底:原始 JSON(仅在没有任何高级结构时显示) -->
|
|
||||||
<div v-if="showRawJson" class="raw-json-box">
|
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<el-icon><Document /></el-icon>
|
<el-icon><Document /></el-icon>
|
||||||
原始数据
|
原始数据
|
||||||
</div>
|
</div>
|
||||||
<pre class="raw-json">{{ rawJson }}</pre>
|
<pre class="raw-json">{{ rawJson }}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="detailDialogVisible = false">关闭</el-button>
|
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-drawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted, computed } from 'vue'
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Search, Refresh, Download, EditPen, Delete, Plus, Document } from '@element-plus/icons-vue'
|
import { Search, Refresh, Download, EditPen, Document } from '@element-plus/icons-vue'
|
||||||
import { getAuditLogs, getAuditModules, getAuditLabels, exportAuditLogs } from '@/api/audit'
|
import { getAuditLogs, getAuditModules, getAuditLabels, exportAuditLogs } from '@/api/audit'
|
||||||
|
|
||||||
// 表格数据
|
// 表格数据
|
||||||
@ -279,17 +317,17 @@ const queryParams = reactive({
|
|||||||
module: '',
|
module: '',
|
||||||
// ★ 多选,提交时 join(',')(后端 split(',') 接收)
|
// ★ 多选,提交时 join(',')(后端 split(',') 接收)
|
||||||
action: [] as string[],
|
action: [] as string[],
|
||||||
target_id: '',
|
target_keyword: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
// 日期范围
|
// 日期范围
|
||||||
const dateRange = ref<[string, string] | null>(null)
|
const dateRange = ref<[string, string] | null>(null)
|
||||||
|
|
||||||
// 选项数据
|
// 选项数据
|
||||||
const moduleOptions = ref<string[]>([])
|
// ★ moduleOptions 是 [{value,label}] 而不是裸字符串 —— 历史命名的折叠规则
|
||||||
const actionOptions = ref<string[]>(['create', 'update', 'delete', 'export', 'import'])
|
// 在后端,前端只负责渲染。见模板里模块下拉的说明。
|
||||||
// 业务聚合项(如「入库(全部)」)—— 由后端 /audit/labels 下发,前端不硬编
|
const moduleOptions = ref<Array<{ value: string; label: string }>>([])
|
||||||
const moduleGroupKeys = ref<string[]>([])
|
const actionOptions = ref<string[]>(['CREATE', 'UPDATE', 'DELETE'])
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 中文化映射 —— ★ 后端为唯一来源,前端不再自带副本
|
// 中文化映射 —— ★ 后端为唯一来源,前端不再自带副本
|
||||||
@ -299,12 +337,10 @@ const moduleGroupKeys = ref<string[]>([])
|
|||||||
// 页面上就会冒出英文列名、或出现对不上的中文 —— 这是已经踩过的坑。
|
// 页面上就会冒出英文列名、或出现对不上的中文 —— 这是已经踩过的坑。
|
||||||
//
|
//
|
||||||
// 现在统一由 GET /audit/labels 下发(后端 app/utils/audit_labels.py 是唯一来源)。
|
// 现在统一由 GET /audit/labels 下发(后端 app/utils/audit_labels.py 是唯一来源)。
|
||||||
// 拉取失败时两个 map 为空,fieldLabel() / actionMap 对未命中项**原样返回**,
|
// 拉取失败时两个 map 为空,fieldLabel() / actionLabel() 对未命中项**原样返回**,
|
||||||
// 页面退化成显示原始字段名,功能不受影响 —— 可控降级,不是崩溃。
|
// 页面退化成显示原始字段名,功能不受影响 —— 可控降级,不是崩溃。
|
||||||
// ============================================================
|
// ============================================================
|
||||||
const actionMap = ref<Record<string, string>>({})
|
const actionMap = ref<Record<string, string>>({})
|
||||||
|
|
||||||
// 字段名中文化映射 —— 与 actionMap 同源,同样由后端下发
|
|
||||||
const fieldMap = ref<Record<string, string>>({})
|
const fieldMap = ref<Record<string, string>>({})
|
||||||
|
|
||||||
// 码值中文化映射。
|
// 码值中文化映射。
|
||||||
@ -315,6 +351,7 @@ const statusMapByModule = ref<Record<string, Record<string, string>>>({})
|
|||||||
const defaultStatusMap = ref<Record<string, string>>({})
|
const defaultStatusMap = ref<Record<string, string>>({})
|
||||||
const enumFields = ref<string[]>(['status'])
|
const enumFields = ref<string[]>(['status'])
|
||||||
const booleanFields = ref<string[]>([])
|
const booleanFields = ref<string[]>([])
|
||||||
|
// 模块展示映射 {原始值: 展示名} 定义在下方「模块名展示」一节(后端下发)
|
||||||
|
|
||||||
// 字段中文名解析:命中映射取中文,否则原样返回
|
// 字段中文名解析:命中映射取中文,否则原样返回
|
||||||
const fieldLabel = (key: string | number): string => {
|
const fieldLabel = (key: string | number): string => {
|
||||||
@ -322,6 +359,13 @@ const fieldLabel = (key: string | number): string => {
|
|||||||
return fieldMap.value[k] || k
|
return fieldMap.value[k] || k
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 操作类型中文名。★ 后端已把历史别名(CREATE/create/新增)归一成规范值,
|
||||||
|
// 这里直接查表即可,不需要再兜底历史写法。
|
||||||
|
const actionLabel = (action: string): string => {
|
||||||
|
const k = String(action || '')
|
||||||
|
return actionMap.value[k] || k
|
||||||
|
}
|
||||||
|
|
||||||
// 变更值中文化:布尔 → 是/否;枚举(status)→ 按模块查表;其余原样返回。
|
// 变更值中文化:布尔 → 是/否;枚举(status)→ 按模块查表;其余原样返回。
|
||||||
// 查不到就显示原值 —— 宁可看到裸数字,也不要显示一个可能错的中文。
|
// 查不到就显示原值 —— 宁可看到裸数字,也不要显示一个可能错的中文。
|
||||||
const valueLabel = (field: string, value: any): string => {
|
const valueLabel = (field: string, value: any): string => {
|
||||||
@ -331,7 +375,7 @@ const valueLabel = (field: string, value: any): string => {
|
|||||||
if (booleanFields.value.includes(field)) return value ? '是' : '否'
|
if (booleanFields.value.includes(field)) return value ? '是' : '否'
|
||||||
|
|
||||||
if (enumFields.value.includes(field)) {
|
if (enumFields.value.includes(field)) {
|
||||||
const mod = String(currentLog.value?.module || '')
|
const mod = String(currentDetail.value?.module || '')
|
||||||
const map = statusMapByModule.value[mod] || defaultStatusMap.value || {}
|
const map = statusMapByModule.value[mod] || defaultStatusMap.value || {}
|
||||||
const hit = map[String(value)]
|
const hit = map[String(value)]
|
||||||
if (hit) return hit
|
if (hit) return hit
|
||||||
@ -350,9 +394,7 @@ const loadLabels = async () => {
|
|||||||
defaultStatusMap.value = res.data.defaultStatus || {}
|
defaultStatusMap.value = res.data.defaultStatus || {}
|
||||||
enumFields.value = res.data.enumFields || ['status']
|
enumFields.value = res.data.enumFields || ['status']
|
||||||
booleanFields.value = res.data.booleanFields || []
|
booleanFields.value = res.data.booleanFields || []
|
||||||
// 聚合项只取**键**(如「入库(全部)」)—— 它展开成哪些 module 值是
|
moduleDisplay.value = res.data.moduleDisplay || {}
|
||||||
// 后端的事,前端原样回传即可,不复制一份成员表过来。
|
|
||||||
moduleGroupKeys.value = Object.keys(res.data.moduleGroups || {})
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[AuditLog] 审计中文映射拉取失败,字段名与码值将原样显示', e)
|
console.warn('[AuditLog] 审计中文映射拉取失败,字段名与码值将原样显示', e)
|
||||||
@ -360,76 +402,37 @@ const loadLabels = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 模块名中文化
|
// 模块名展示
|
||||||
//
|
//
|
||||||
// 现行监听器写出的 module 已是中文(出库管理/BOM管理…),但历史数据里残留了
|
// ★ 映射表**在后端**(audit_labels.module_display_map),前端只查表。
|
||||||
// 一批英文模块名 —— 它们来自早期全局监听器:那时无白名单,系统表、草稿表、
|
|
||||||
// 向量表都会被审计,而 _infer_module_name 对未登记的类名直接回退成表名。
|
|
||||||
// 下拉框取 DISTINCT module,于是这些英文值就混了进来。
|
|
||||||
//
|
//
|
||||||
// 处理:仅在前端做展示翻译,value 仍用原始字符串,筛选行为完全不变。
|
// 这里原本硬编着一份 moduleMap(英文表名 → 中文),历史上已经漂移过一次:
|
||||||
|
// 前端加了表名后端不知道、后端加了前端不显示,界面就会裸露英文。
|
||||||
|
// 现在由 GET /audit/labels 的 moduleDisplay 下发,与字段名/状态码同源。
|
||||||
|
//
|
||||||
|
// 映射同时覆盖两类:
|
||||||
|
// · 聚合成员(入库管理/采购入库/成品入库/库存管理 →「入库」)
|
||||||
|
// · 英文历史值(image_embeddings →「图像特征(历史)」)
|
||||||
|
// 缺了前者会出现「下拉显示『入库』、表格显示『库存管理』」,用户会以为
|
||||||
|
// 筛选没生效 —— 所以两者必须都在同一张表里。
|
||||||
|
//
|
||||||
|
// 拉不到映射时退化为显示原始值(可控降级,不是崩溃)。
|
||||||
// ============================================================
|
// ============================================================
|
||||||
const moduleMap: Record<string, string> = {
|
const moduleDisplay = ref<Record<string, string>>({})
|
||||||
'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': '盘点草稿',
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模块名解析:命中映射取中文,否则原样返回
|
|
||||||
const moduleLabel = (m: string | number): string => {
|
const moduleLabel = (m: string | number): string => {
|
||||||
const k = String(m)
|
const k = String(m)
|
||||||
return moduleMap[k] || k
|
return moduleDisplay.value[k] || k
|
||||||
}
|
}
|
||||||
|
|
||||||
// 时间格式化
|
|
||||||
//
|
|
||||||
// ★ 改造说明:后端 audit_listener 现在显式写入**北京时间**(beijing_time),
|
|
||||||
// 返回的字符串已经是 UTC+8 的钟面值。此处**不能**再补 'Z' 当 UTC 解析,
|
|
||||||
// 否则会被浏览器二次加 8 小时(原实现即存在该问题)。
|
|
||||||
// 策略:把后端字符串按原样格式化输出;仅当浏览器不在 UTC+8 时,
|
|
||||||
// 才需要按「字符串即北京时间」做等值换算。
|
|
||||||
const formatLocalTime = (timeStr: string) => {
|
|
||||||
if (!timeStr) return '-'
|
|
||||||
const iso = timeStr.replace(' ', 'T')
|
|
||||||
// 按北京时间(UTC+8)理解该字符串,转为本地时间展示
|
|
||||||
const date = new Date(iso + '+08:00')
|
|
||||||
if (isNaN(date.getTime())) return timeStr
|
|
||||||
|
|
||||||
const y = date.getFullYear()
|
|
||||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
const d = String(date.getDate()).padStart(2, '0')
|
|
||||||
const h = String(date.getHours()).padStart(2, '0')
|
|
||||||
const min = String(date.getMinutes()).padStart(2, '0')
|
|
||||||
const s = String(date.getSeconds()).padStart(2, '0')
|
|
||||||
return `${y}-${m}-${d} ${h}:${min}:${s}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 详情弹窗
|
|
||||||
const detailDialogVisible = ref(false)
|
|
||||||
const currentLog = ref<any>({})
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 详情解析逻辑
|
// 详情解析
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const currentDetail = ref<any>({})
|
||||||
|
const isDelete = computed(() => String(currentDetail.value?.action || '').toUpperCase() === 'DELETE')
|
||||||
|
|
||||||
// 辅助函数:判断 details 是否有可显示内容
|
// 辅助函数:判断 details 是否有可显示内容
|
||||||
const hasDetailContent = (details: any): boolean => {
|
const hasDetailContent = (details: any): boolean => {
|
||||||
if (!details || typeof details !== 'object') return false
|
if (!details || typeof details !== 'object') return false
|
||||||
@ -442,87 +445,68 @@ const hasDetailContent = (details: any): boolean => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断是否存在各高级结构
|
// ★ 对象 repr 的识别 —— 形如 '<MaterialBase 3030>'。
|
||||||
const hasChanges = computed(() => {
|
//
|
||||||
const details = currentLog.value.details
|
// 来源:快照收集时本该跳过 ORM 关系属性,早期版本漏过去了,库里留了 1500+ 条
|
||||||
return !!(details?.changes && typeof details.changes === 'object')
|
// 这种「被 str() 的对象」。它不是业务数据(真正有用的值在对应的 xxx_id 字段里),
|
||||||
})
|
// 原样显示只会让人以为数据坏了。
|
||||||
|
//
|
||||||
|
// 判据要**窄**:只认「<类名 空格 内容>」,不能见 '<'/'>' 就滤,
|
||||||
|
// 否则会把正常业务值误伤(备注里写「<急件>」是很正常的)。
|
||||||
|
// 后端的 audit_export_service.is_object_repr 是同一个规则(用于 Excel 导出)。
|
||||||
|
const OBJECT_REPR_RE = /^<[A-Za-z_][\w.]*(\s+[^<>]*)?>$/
|
||||||
|
const isObjectRepr = (v: any): boolean =>
|
||||||
|
typeof v === 'string' && OBJECT_REPR_RE.test(v.trim())
|
||||||
|
|
||||||
const hasDeletedSnapshot = computed(() => {
|
// UPDATE:字段对比行
|
||||||
const details = currentLog.value.details
|
const diffRows = computed(() => {
|
||||||
return !!(details?.deleted_snapshot && typeof details.deleted_snapshot === 'object')
|
const changes = currentDetail.value?.details?.changes
|
||||||
})
|
if (!changes || typeof changes !== 'object') return []
|
||||||
|
return Object.entries(changes).map(([key, v]: [string, any]) => ({
|
||||||
const hasCreated = computed(() => {
|
key,
|
||||||
const details = currentLog.value.details
|
old: v?.old,
|
||||||
return !!(details?.created && typeof details.created === 'object')
|
new: v?.new,
|
||||||
})
|
|
||||||
|
|
||||||
const showRawJson = computed(() => {
|
|
||||||
return !hasChanges.value && !hasDeletedSnapshot.value && !hasCreated.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// 解析 changes 为表格数据
|
|
||||||
const changesList = computed(() => {
|
|
||||||
const details = currentLog.value.details
|
|
||||||
if (!details?.changes) return []
|
|
||||||
|
|
||||||
return Object.entries(details.changes).map(([field, values]: [string, any]) => ({
|
|
||||||
field,
|
|
||||||
old: values?.old,
|
|
||||||
new: values?.new
|
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
// 解析 deleted_snapshot
|
// CREATE / DELETE:快照属性行(跳过对象 repr 脏值)
|
||||||
const deletedSnapshot = computed(() => {
|
const snapshotRows = computed(() => {
|
||||||
const details = currentLog.value.details
|
const d = currentDetail.value?.details || {}
|
||||||
return details?.deleted_snapshot || {}
|
const snap = d.deleted_snapshot || d.created
|
||||||
|
if (!snap || typeof snap !== 'object') return []
|
||||||
|
return Object.entries(snap)
|
||||||
|
.filter(([, v]) => !isObjectRepr(v))
|
||||||
|
.map(([key, value]) => ({ key, value }))
|
||||||
})
|
})
|
||||||
|
|
||||||
// 解析 created
|
|
||||||
const createdData = computed(() => {
|
|
||||||
const details = currentLog.value.details
|
|
||||||
return details?.created || {}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 原始 JSON
|
|
||||||
const rawJson = computed(() => {
|
const rawJson = computed(() => {
|
||||||
const details = currentLog.value.details
|
const d = currentDetail.value?.details
|
||||||
if (!details) return ''
|
return d ? JSON.stringify(d, null, 2) : ''
|
||||||
return JSON.stringify(details, null, 2)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 辅助函数:格式化值
|
// 辅助函数:格式化值
|
||||||
// field 传入时走码值中文化(快照里的 status 同样是裸码值)
|
// field 传入时走码值中文化(快照里的 status 同样是裸码值)
|
||||||
const formatValue = (value: any, field?: string): string => {
|
const formatValue = (value: any, field?: string): string => {
|
||||||
if (value === null || value === undefined) return '-'
|
if (value === null || value === undefined || value === '') return '-'
|
||||||
|
if (isObjectRepr(value)) return '(对象引用)'
|
||||||
if (typeof value === 'object') return JSON.stringify(value)
|
if (typeof value === 'object') return JSON.stringify(value)
|
||||||
return field ? valueLabel(field, value) : String(value)
|
return field ? valueLabel(field, value) : String(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 辅助函数:判断是否是长值
|
|
||||||
const isLongValue = (value: any): boolean => {
|
|
||||||
if (value === null || value === undefined) return false
|
|
||||||
const str = typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
||||||
return str.length > 50
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 其他方法
|
// 其他方法
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
// ★ 后端已归一化 action(canon_action),这里只需按规范值给颜色
|
||||||
const getActionType = (action: string) => {
|
const getActionType = (action: string) => {
|
||||||
const typeMap: Record<string, string> = {
|
const typeMap: Record<string, string> = {
|
||||||
'create': 'success',
|
'CREATE': 'success',
|
||||||
'add': 'success',
|
'UPDATE': 'warning',
|
||||||
'update': 'warning',
|
'DELETE': 'danger',
|
||||||
'edit': 'warning',
|
'EXPORT': 'info',
|
||||||
'delete': 'danger',
|
'IMPORT': 'info',
|
||||||
'export': 'info',
|
|
||||||
'import': 'info'
|
|
||||||
}
|
}
|
||||||
return typeMap[action?.toLowerCase()] || 'info'
|
return typeMap[String(action || '').toUpperCase()] || 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组装请求参数(列表与导出**共用同一份**)
|
// 组装请求参数(列表与导出**共用同一份**)
|
||||||
@ -533,10 +517,12 @@ const buildFilterParams = () => {
|
|||||||
const params: any = {
|
const params: any = {
|
||||||
operator_type: queryParams.operator_type,
|
operator_type: queryParams.operator_type,
|
||||||
username: queryParams.username,
|
username: queryParams.username,
|
||||||
|
// ★ 原样回传,不做任何展开 —— 聚合项(如「入库」)由后端
|
||||||
|
// expand_modules() 展开成成员值。前端硬编成员表 = 第二份副本 = 会漂移。
|
||||||
module: queryParams.module,
|
module: queryParams.module,
|
||||||
// ★ 多选数组 → 逗号串(仓库惯例,后端 split(',') 接收)
|
// ★ 多选数组 → 逗号串(仓库惯例,后端 split(',') 接收)
|
||||||
action: (queryParams.action || []).join(','),
|
action: (queryParams.action || []).join(','),
|
||||||
target_id: queryParams.target_id,
|
target_keyword: queryParams.target_keyword,
|
||||||
start_date: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
}
|
}
|
||||||
@ -584,11 +570,16 @@ const handleReset = () => {
|
|||||||
queryParams.username = ''
|
queryParams.username = ''
|
||||||
queryParams.module = ''
|
queryParams.module = ''
|
||||||
queryParams.action = []
|
queryParams.action = []
|
||||||
queryParams.target_id = ''
|
queryParams.target_keyword = ''
|
||||||
dateRange.value = null
|
dateRange.value = null
|
||||||
getList()
|
getList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openDetail = (row: any) => {
|
||||||
|
currentDetail.value = row || {}
|
||||||
|
detailVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 导出 Excel ----
|
// ---- 导出 Excel ----
|
||||||
// 与列表**同一套筛选条件**(buildFilterParams),但导的是全部匹配结果 ——
|
// 与列表**同一套筛选条件**(buildFilterParams),但导的是全部匹配结果 ——
|
||||||
// 后端忽略分页参数。下载是浏览器的静默行为,页面不跳转,所以要给一条
|
// 后端忽略分页参数。下载是浏览器的静默行为,页面不跳转,所以要给一条
|
||||||
@ -623,14 +614,11 @@ const handleExport = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleViewDetails = (row: any) => {
|
|
||||||
currentLog.value = row
|
|
||||||
detailDialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getList()
|
getList()
|
||||||
loadLabels()
|
loadLabels()
|
||||||
|
// 模块选项:优先用 /audit/modules(不依赖列表结果),
|
||||||
|
// 列表接口也会顺带返回一份,两者结构一致
|
||||||
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
|
||||||
@ -654,6 +642,49 @@ onMounted(() => {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 次要信息(账号、主键、URL)统一弱化,让主体内容更突出 */
|
||||||
|
.muted {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-text {
|
||||||
|
color: #606266;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 触发来源告警条 —— 抽屉里的第一眼信息 */
|
||||||
|
.source-alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-method {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 52px;
|
||||||
|
margin-right: 8px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #1f4e79;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-url {
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-hint {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
.details-section {
|
.details-section {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
@ -670,14 +701,6 @@ onMounted(() => {
|
|||||||
border-bottom: 1px solid #ebeef5;
|
border-bottom: 1px solid #ebeef5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 变更表格样式 */
|
|
||||||
.changes-box {
|
|
||||||
background: #fef0f0;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid #fbc4c4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-name {
|
.field-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #303133;
|
color: #303133;
|
||||||
@ -693,25 +716,10 @@ onMounted(() => {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 快照样式 */
|
|
||||||
.snapshot-box {
|
|
||||||
background: #f5f7fa;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid #e4e7ed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snapshot-value {
|
.snapshot-value {
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 原始 JSON */
|
|
||||||
.raw-json-box {
|
|
||||||
background: #f5f7fa;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.raw-json {
|
.raw-json {
|
||||||
background: #2d2d2d;
|
background: #2d2d2d;
|
||||||
color: #67c23a;
|
color: #67c23a;
|
||||||
@ -719,7 +727,7 @@ onMounted(() => {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
max-height: 300px;
|
max-height: 400px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|||||||
Reference in New Issue
Block a user