feat(audit): 操作人筛选改为下拉选择,过滤由模糊改为精确
原来的「操作人」是自由文本输入,后端按 `LIKE %值%` 模糊匹配。
后端:
· 新增 GET /audit/operators,返回 [{value: 账号, label: '名(账号)'}],
按记录数降序(最活跃的排最前),排除 system;/logs 也内联一份省一次请求。
· username 过滤由 LIKE 改**精确匹配**。选出来的是完整账号,再模糊匹配就是错的:
实测 `LIKE '%gao%'` 会多带出 2439 条非 gaoxue 的记录,将来出现
gaoxue / gaoxue2 两个账号时,选前者会连带查出后者的记录。
部分匹配的需求由下拉的 filterable(在选项里搜)承担,不落到 SQL。
★ 选项从 **audit_logs** 取,不是从 sys_user:审计记的是操作发生时的账号,
用户被删/改名后 sys_user 就查不到,而历史审计仍需能按他筛选 ——
实测 32 个操作人里有 1 个在 sys_user 中已不存在。
display_name 本来就在审计行上('杜邢宸(duxingchen)'),不必 join。
★ 排除 system:它不是人,「操作来源」那组单选(真实用户/系统操作/全部)
已经专门管它,混进下拉会让两个控件语义打架。
★ /operators 加了 system_audit 权限码,与 /modules(仅 JWT)**故意不同**:
/modules 给的是模块名,这个给的是**人员账号清单**。它唯一的消费者就是
审计页,而审计页本身要 system_audit —— 没道理让人绕开页面直接拉全员名单。
前端 AuditLog.vue:
· 操作人由 el-input 改为 el-select(clearable + filterable),
选项由后端下发,前端不硬编。
· 拉取失败(无权限 403 等)时 catch 住,下拉优雅退化为空,不抛未捕获异常。
验证(21 + 20 项断言全过):
· 32 个操作人全覆盖、排除 system、label 带中文名、按记录数降序
· 精确匹配与 SQL count 逐一核对(gaoxue 2519 / duxingchen 16863 / liuqi 1236),
且结果里不含他人
· 导出口径在与列表一致的 4 组筛选下逐一对齐(含操作人筛选)
· 权限矩阵:/logs 与 /export 无 token 401、无权限 403;/operators 无权限 403;
/labels 保持仅 JWT(纯静态标签)
· 日报三天附件 490.5K/193.4K/92.3K 与 6 列结构未变;聚合行仍可读
· vue-tsc 与 vite build exit=0
This commit is contained in:
@ -86,9 +86,14 @@ def _build_audit_query(args):
|
|||||||
elif operator_type == 'system':
|
elif operator_type == 'system':
|
||||||
query = query.filter(AuditLog.username == SYSTEM_USERNAME)
|
query = query.filter(AuditLog.username == SYSTEM_USERNAME)
|
||||||
|
|
||||||
|
# ---------- 操作人(精确匹配)----------
|
||||||
|
# ★ 由模糊(LIKE %值%)改为精确:前端的操作人已从自由输入改为**下拉选择**,
|
||||||
|
# 选出来的是完整账号,再模糊匹配就是错的 —— 将来出现 `gaoxue` 与
|
||||||
|
# `gaoxue2` 两个账号时,选前者会连带把后者的记录一起查出来。
|
||||||
|
# 部分匹配的需求由下拉的 filterable(在选项里搜)承担,不需要落到 SQL。
|
||||||
username = (args.get('username') or '').strip()
|
username = (args.get('username') or '').strip()
|
||||||
if username:
|
if username:
|
||||||
query = query.filter(AuditLog.username.like(f'%{username}%'))
|
query = query.filter(AuditLog.username == username)
|
||||||
|
|
||||||
# ---------- 模块(支持多选 + 聚合别名)----------
|
# ---------- 模块(支持多选 + 聚合别名)----------
|
||||||
# ★ 经 expand_modules 展开:「入库(全部)」会变成它名下的全部成员值。
|
# ★ 经 expand_modules 展开:「入库(全部)」会变成它名下的全部成员值。
|
||||||
@ -243,6 +248,48 @@ def _serialize_logs(rows):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _operator_options(company_limit):
|
||||||
|
"""
|
||||||
|
操作人下拉选项:[{'value': 账号, 'label': '中文名(账号)'}],按记录数降序。
|
||||||
|
|
||||||
|
★ 从 **audit_logs** 取而不是从 sys_user:审计里记的是操作发生时的账号,
|
||||||
|
用户被删/改名后 sys_user 就查不到了,而历史审计仍需要能按他筛选。
|
||||||
|
实测 32 个操作人里有 1 个在 sys_user 中已不存在。
|
||||||
|
|
||||||
|
★ display_name 本来就在审计行上('杜邢宸(duxingchen)'),不必 join。
|
||||||
|
|
||||||
|
★ 按记录数降序:最活跃的人排最前,下拉一打开就是常用的那几个。
|
||||||
|
|
||||||
|
★ 排除 system:它不是人,且「操作来源」那组单选已经专门管它
|
||||||
|
(真实用户 / 系统操作 / 全部)。混进来只会让两个控件语义打架。
|
||||||
|
"""
|
||||||
|
q = db.session.query(
|
||||||
|
AuditLog.username,
|
||||||
|
func.max(AuditLog.display_name),
|
||||||
|
func.count().label('n'),
|
||||||
|
).filter(AuditLog.username != SYSTEM_USERNAME)
|
||||||
|
if company_limit is not None:
|
||||||
|
q = q.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \
|
||||||
|
.filter(SysUser.department == company_limit)
|
||||||
|
q = q.group_by(AuditLog.username).order_by(func.count().desc())
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for name, display, _n in q.all():
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
dn = (display or '').strip()
|
||||||
|
# display_name 库里存法不统一('高雪(gaoxue)' / '高闯/gaochuang' / 空),
|
||||||
|
# 统一成「名(账号)」;拿不到名字就只显示账号。
|
||||||
|
label = name
|
||||||
|
for sep in ('(', '/'):
|
||||||
|
if sep in dn:
|
||||||
|
dn = dn.split(sep)[0].strip()
|
||||||
|
if dn and dn != name:
|
||||||
|
label = f"{dn}({name})"
|
||||||
|
out.append({'value': name, 'label': label})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _distinct_with_company(column, company_limit):
|
def _distinct_with_company(column, company_limit):
|
||||||
"""
|
"""
|
||||||
取某列的去重值(带公司隔离)。
|
取某列的去重值(带公司隔离)。
|
||||||
@ -298,7 +345,9 @@ def get_audit_logs():
|
|||||||
# ★ [{value,label}],历史命名已折叠进聚合项且不再单独列出 ——
|
# ★ [{value,label}],历史命名已折叠进聚合项且不再单独列出 ——
|
||||||
# 规则由后端 module_options() 统一决定,前端零硬编。
|
# 规则由后端 module_options() 统一决定,前端零硬编。
|
||||||
'modules': module_options(raw_modules),
|
'modules': module_options(raw_modules),
|
||||||
'actions': actions
|
'actions': actions,
|
||||||
|
# 操作人下拉选项(与 /operators 同源,顺带回一份省一次请求)
|
||||||
|
'operators': _operator_options(company_limit),
|
||||||
}
|
}
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
@ -424,6 +473,27 @@ def get_modules():
|
|||||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@audit_bp.route('/operators', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
@permission_required('system_audit')
|
||||||
|
def get_operators():
|
||||||
|
"""
|
||||||
|
获取操作人下拉选项(用于筛选)。
|
||||||
|
|
||||||
|
返回 [{value: 账号, label: '名(账号)'}],按记录数降序,已排除 system。
|
||||||
|
|
||||||
|
★ 加权限码,与 /modules(仅 JWT)**故意不同**:/modules 给的是模块名,
|
||||||
|
这个给的是**人员账号清单**。它的唯一消费者就是审计页,而审计页本身
|
||||||
|
要 system_audit —— 没道理让人绕开页面直接拉全员名单。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
company_limit = get_current_company_filter()
|
||||||
|
return jsonify({'code': 200, 'data': _operator_options(company_limit)}), 200
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"获取操作人列表失败: {str(e)}")
|
||||||
|
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@audit_bp.route('/labels', methods=['GET'])
|
@audit_bp.route('/labels', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def get_labels():
|
def get_labels():
|
||||||
|
|||||||
@ -25,6 +25,17 @@ export function getAuditModules() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取可选操作人列表
|
||||||
|
//
|
||||||
|
// ★ 返回 [{value: 账号, label: '名(账号)'}],按记录数降序,已排除 system。
|
||||||
|
// 从审计日志本身取而非用户表:用户被删/改名后仍能按历史账号筛选。
|
||||||
|
export function getAuditOperators() {
|
||||||
|
return request({
|
||||||
|
url: '/audit/operators',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 获取操作类型 / 字段名的中文映射
|
// 获取操作类型 / 字段名的中文映射
|
||||||
// ★ 后端为唯一来源,前端不再自带副本(见 views/system/AuditLog.vue 的说明)
|
// ★ 后端为唯一来源,前端不再自带副本(见 views/system/AuditLog.vue 的说明)
|
||||||
export function getAuditLabels() {
|
export function getAuditLabels() {
|
||||||
|
|||||||
@ -18,13 +18,27 @@
|
|||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="操作人">
|
<el-form-item label="操作人">
|
||||||
<el-input
|
<!--
|
||||||
|
★ 下拉选择而不是自由输入:选出来的是**完整账号**,后端按精确匹配过滤。
|
||||||
|
filterable 让用户敲几个字就能在选项里搜到人(部分匹配的需求在这里
|
||||||
|
承担,不必落到 SQL 的 LIKE —— 那会在出现 gaoxue / gaoxue2 时误命中)。
|
||||||
|
可选人员清单由后端 /audit/operators 下发,已排除 system
|
||||||
|
(它不是人,「操作来源」那组单选专门管它)。
|
||||||
|
-->
|
||||||
|
<el-select
|
||||||
v-model="queryParams.username"
|
v-model="queryParams.username"
|
||||||
placeholder="输入姓名或账号"
|
placeholder="全部操作人"
|
||||||
clearable
|
clearable
|
||||||
style="width: 150px"
|
filterable
|
||||||
@keyup.enter="handleQuery"
|
style="width: 180px"
|
||||||
/>
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="o in operatorOptions"
|
||||||
|
:key="o.value"
|
||||||
|
:label="o.label"
|
||||||
|
:value="o.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="模块">
|
<el-form-item label="模块">
|
||||||
<!--
|
<!--
|
||||||
@ -326,7 +340,13 @@
|
|||||||
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, 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,
|
||||||
|
getAuditOperators,
|
||||||
|
getAuditLabels,
|
||||||
|
exportAuditLogs,
|
||||||
|
} from '@/api/audit'
|
||||||
|
|
||||||
// 表格数据
|
// 表格数据
|
||||||
const tableLoading = ref(false)
|
const tableLoading = ref(false)
|
||||||
@ -385,6 +405,8 @@ const booleanFields = ref<string[]>([])
|
|||||||
const hiddenFields = ref<string[]>([])
|
const hiddenFields = ref<string[]>([])
|
||||||
// 快照键 → 标题(后端下发,见「快照的分层渲染」一节)
|
// 快照键 → 标题(后端下发,见「快照的分层渲染」一节)
|
||||||
const snapshotViews = ref<Array<{ key: string; title: string }>>([])
|
const snapshotViews = ref<Array<{ key: string; title: string }>>([])
|
||||||
|
// 操作人下拉选项(后端下发,按记录数降序)
|
||||||
|
const operatorOptions = ref<Array<{ value: string; label: string }>>([])
|
||||||
|
|
||||||
// 字段中文名解析:命中映射取中文,否则原样返回
|
// 字段中文名解析:命中映射取中文,否则原样返回
|
||||||
const fieldLabel = (key: string | number): string => {
|
const fieldLabel = (key: string | number): string => {
|
||||||
@ -710,6 +732,9 @@ const getList = async () => {
|
|||||||
if (res.data.actions) {
|
if (res.data.actions) {
|
||||||
actionOptions.value = res.data.actions
|
actionOptions.value = res.data.actions
|
||||||
}
|
}
|
||||||
|
if (res.data.operators) {
|
||||||
|
operatorOptions.value = res.data.operators
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取审计日志失败:', error)
|
console.error('获取审计日志失败:', error)
|
||||||
@ -784,6 +809,16 @@ onMounted(() => {
|
|||||||
moduleOptions.value = res.data
|
moduleOptions.value = res.data
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// 操作人选项:不依赖列表结果,首次进页面就能选。
|
||||||
|
// 接口带 system_audit 权限码,无权限时会 403 —— 兜住它,
|
||||||
|
// 让下拉优雅退化(列表接口也会顺带返回一份,不致命)。
|
||||||
|
getAuditOperators().then(res => {
|
||||||
|
if (res.code === 200 && res.data) {
|
||||||
|
operatorOptions.value = res.data
|
||||||
|
}
|
||||||
|
}).catch(e => {
|
||||||
|
console.warn('[AuditLog] 操作人选项拉取失败,下拉暂为空', e)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user