feat(ops): 操作日志留痕、字典管理、名册改删(V9 / V10)
PRD FR-506/FR-1202 的 P0 项:V9 给 sys_operation_log 补 target_type/target_id/ before_val/after_val,OperationLogService 显式留痕并统一强制脱敏; 已接入的敏感动作:名册改证件号/改名/删名册项、任务发布/暂停/恢复/截止/归档。 F-32 的教训落地:动作名由调用方显式给,不从 (原状态, 新状态) 反推—— 「发布」与「恢复」都是 →收集中,反推必然把 resume 记成 PUBLISH_TASK。 字典:内置字典写在代码里(DictCatalog),库里只存客户自定义项与内置项停用开关, 两表分开才能「一键恢复默认」;表单下拉与字典共用同一份口径(BuiltinFields 加访问器)。 验证:e2e-m5-logs 44/44、e2e-m1-dict 53/53。
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<GiPageLayout>
|
||||
<a-card :bordered="false">
|
||||
<template #title>操作日志</template>
|
||||
<template #extra>
|
||||
<span class="log__hint">
|
||||
按「对象」查是这个页面最重要的用法:填对象类型 + ID,就能看到它被谁改过
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<a-alert type="normal" class="log__tip">
|
||||
<template #title>
|
||||
证件号在这里**只有脱敏值与指纹**(日志泄露不该比业务库泄露更严重)。
|
||||
IP 取代理链第一跳、**可以被伪造** —— 用于排查,不作合规证据;
|
||||
真正的证据是「哪个账号在什么时候做了什么」。
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<div class="g-row-tool">
|
||||
<a-space wrap>
|
||||
<a-input-number v-model="query.targetId" placeholder="对象 ID" size="small"
|
||||
class="log__num" :min="1" hide-button @change="search" />
|
||||
<a-select v-model="query.targetType" placeholder="对象类型" allow-clear size="small"
|
||||
class="log__sel" @change="search">
|
||||
<a-option value="ROSTER">名册项</a-option>
|
||||
<a-option value="TASK">任务</a-option>
|
||||
<a-option value="SUBMISSION">填报单</a-option>
|
||||
<a-option value="EXPORT">导出</a-option>
|
||||
<a-option value="USER">账号</a-option>
|
||||
</a-select>
|
||||
<a-select v-model="query.action" placeholder="动作" allow-clear size="small"
|
||||
class="log__sel2" @change="search">
|
||||
<a-option v-for="a in OPERATION_ACTIONS" :key="a.value" :value="a.value">
|
||||
{{ a.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<a-select v-model="query.module" placeholder="模块" allow-clear size="small"
|
||||
class="log__sel3" @change="search">
|
||||
<a-option value="ROSTER">名册</a-option>
|
||||
<a-option value="TASK">任务</a-option>
|
||||
<a-option value="AUDIT">审核</a-option>
|
||||
<a-option value="EXPORT">导出</a-option>
|
||||
<a-option value="JOIN">加入申请</a-option>
|
||||
</a-select>
|
||||
<!-- 起止日期用两个独立日期框,**不用 range 模式**:
|
||||
Arco 的 range 模式实际值是数组、但类型声明只写了单值,
|
||||
改哪边都要么 as any(掩盖问题)、要么被类型挡住。
|
||||
而且两个框各自能单独清空,核查时更好用 -->
|
||||
<a-date-picker v-model="fromDate" size="small" mode="date"
|
||||
placeholder="开始日期" class="log__date" @change="search" />
|
||||
<a-date-picker v-model="toDate" size="small" mode="date"
|
||||
placeholder="结束日期" class="log__date" @change="search" />
|
||||
<a-button size="small" @click="reset">重置</a-button>
|
||||
<a-button size="small" @click="load">
|
||||
<template #icon><IconRefresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
|
||||
:scroll="{ x: 1200 }" @page-change="onPageChange">
|
||||
<template #columns>
|
||||
<a-table-column title="时间" :width="150">
|
||||
<template #cell="{ record }">
|
||||
<span class="log__muted">{{ fmt(record.createTime) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作人" :width="120">
|
||||
<template #cell="{ record }">
|
||||
<div>{{ record.operatorName || '—' }}</div>
|
||||
<div class="log__muted">ID {{ record.operatorId ?? '—' }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="动作" :width="150">
|
||||
<template #cell="{ record }">
|
||||
<a-tag size="small" :color="isSensitive(record.action) ? 'red' : 'arcoblue'">
|
||||
{{ actionText(record.action) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="对象" :width="130">
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.targetType" class="log__muted">
|
||||
{{ record.targetType }} #{{ record.targetId }}
|
||||
</span>
|
||||
<span v-else class="log__muted">—</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="变更前 → 变更后" :width="280">
|
||||
<template #cell="{ record }">
|
||||
<div v-if="record.beforeVal || record.afterVal" class="log__diff">
|
||||
<span class="log__before">{{ record.beforeVal || '(空)' }}</span>
|
||||
<span class="log__arrow">→</span>
|
||||
<span class="log__after">{{ record.afterVal || '(空)' }}</span>
|
||||
</div>
|
||||
<span v-else class="log__muted">—</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="来源" :width="200">
|
||||
<template #cell="{ record }">
|
||||
<div class="log__muted">{{ record.ip || '—' }}</div>
|
||||
<div class="log__ua" :title="record.userAgent || ''">{{ record.userAgent || '' }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OperationLog } from '@/apis/xt/log'
|
||||
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import {
|
||||
actionText,
|
||||
getOperationLogs,
|
||||
OPERATION_ACTIONS,
|
||||
SENSITIVE_ACTIONS
|
||||
} from '@/apis/xt/log'
|
||||
|
||||
/**
|
||||
* 操作日志(《03-接口设计》§13.4)。
|
||||
*
|
||||
* ## 为什么把「对象 ID」放在筛选栏最左边
|
||||
*
|
||||
* 因为这是这一屏**唯一不可替代**的查询方式。合规问题问的是
|
||||
* 「这张名册项的证件号被谁从什么改成了什么」,答案只能靠
|
||||
* `target_type + target_id` 定位;而"搜关键字"会把
|
||||
* 「改了它」和「顺带提到它」混在一起 —— 那种匹配撑不起合规结论。
|
||||
*
|
||||
* ## 「变更前 → 变更后」单独一列
|
||||
*
|
||||
* 看这一屏的人 90% 就是为了看这两列。塞进"详情"弹窗里等于让每次查看多一次点击,
|
||||
* 而合规核查往往是几十条连着看的。
|
||||
*/
|
||||
defineOptions({ name: 'OperationLogs' })
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<OperationLog[]>([])
|
||||
const total = ref(0)
|
||||
const fromDate = ref<string | undefined>(undefined)
|
||||
const toDate = ref<string | undefined>(undefined)
|
||||
|
||||
const query = reactive<{
|
||||
targetId?: number
|
||||
targetType?: string
|
||||
action?: string
|
||||
module?: string
|
||||
from?: string
|
||||
to?: string
|
||||
pageNum: number
|
||||
pageSize: number
|
||||
}>({
|
||||
targetId: undefined,
|
||||
targetType: undefined,
|
||||
action: undefined,
|
||||
module: undefined,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
pageNum: 1,
|
||||
pageSize: 20
|
||||
})
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
total: total.value,
|
||||
showTotal: true,
|
||||
showPageSize: true
|
||||
}))
|
||||
|
||||
function fmt(v?: string) {
|
||||
return v ? v.replace('T', ' ').slice(0, 19) : '—'
|
||||
}
|
||||
|
||||
function isSensitive(action?: string) {
|
||||
return !!action && SENSITIVE_ACTIONS.includes(action)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getOperationLogs({
|
||||
targetId: query.targetId,
|
||||
targetType: query.targetType,
|
||||
action: query.action,
|
||||
module: query.module,
|
||||
// 后端 `to` 会补到当天 23:59:59,所以这里只给日期就够
|
||||
from: fromDate.value || undefined,
|
||||
to: toDate.value || undefined,
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize
|
||||
})
|
||||
list.value = res.data?.list ?? []
|
||||
total.value = res.data?.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
query.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
query.targetId = undefined
|
||||
query.targetType = undefined
|
||||
query.action = undefined
|
||||
query.module = undefined
|
||||
fromDate.value = undefined
|
||||
toDate.value = undefined
|
||||
search()
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
query.pageNum = page
|
||||
load()
|
||||
}
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.log {
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__tip {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__num {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
&__sel {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
&__sel2 {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
&__sel3 {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
&__date {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
&__muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__diff {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__before {
|
||||
color: var(--color-text-3);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
margin: 0 6px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__after {
|
||||
color: rgb(var(--red-6));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__ua {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user