操作审计(Audit Log)

This commit is contained in:
Cheng Zhou
2025-12-17 21:19:05 +08:00
parent b0bffbc75e
commit 13efb0a3a9
10 changed files with 420 additions and 74 deletions
+206
View File
@@ -0,0 +1,206 @@
<template>
<div class="page">
<el-card>
<div class="filters">
<el-select v-model="filters.eventType" clearable placeholder="操作类型" @change="loadLogs">
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-select v-model="filters.operatorId" clearable placeholder="操作人" filterable @change="loadLogs">
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
</el-select>
<el-select v-model="filters.result" clearable placeholder="结果" @change="loadLogs">
<el-option label="成功" value="SUCCESS" />
<el-option label="失败" value="FAIL" />
</el-select>
<el-date-picker
v-model="filters.range"
type="daterange"
unlink-panels
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
@change="loadLogs"
/>
<div class="spacer" />
<el-button type="primary" @click="loadLogs">查询</el-button>
</div>
<el-table :data="logs" v-loading="loading" style="width: 100%">
<el-table-column prop="timestamp" label="时间" width="180" />
<el-table-column prop="actorName" label="操作人" width="140" />
<el-table-column prop="actorRoleLabel" label="角色" width="120" />
<el-table-column prop="eventLabel" label="操作类型" min-width="160" />
<el-table-column prop="actionText" label="操作内容" min-width="180" />
<el-table-column label="操作对象" min-width="180">
<template #default="scope">
{{ scope.row.targetTypeLabel }}{{ scope.row.targetId }} {{ scope.row.targetName || "" }}
</template>
</el-table-column>
<el-table-column label="变更详情" min-width="220">
<template #default="scope">
<div v-if="scope.row.diffText?.length">
<div v-for="(line, idx) in scope.row.diffText" :key="idx">{{ line }}</div>
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="结果" width="120">
<template #default="scope">
<el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'">
{{ scope.row.resultLabel }}
</el-tag>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
:total="total"
@current-change="onPageChange"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { fetchAuditLogs } from "../../api/auditLogs";
import { fetchUsers } from "../../api/users";
import { auditDict, normalizeAuditEvent } from "../../audit";
import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth";
import { roleDict, getDictLabel } from "../../dictionaries";
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const loading = ref(false);
const rawLogs = ref<any[]>([]);
const logs = ref<any[]>([]);
const users = ref<any[]>([]);
const page = ref(1);
const pageSize = 20;
const total = ref(0);
const filters = ref({
eventType: "",
operatorId: "",
result: "",
range: [],
});
const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
value,
label: cfg.label,
}));
const userOptions = computed(() => users.value.map((u: any) => ({ label: u.username, value: u.id })));
const ensureAccess = () => {
const role = auth.user?.role;
const studyRole = study.currentStudyRole;
if (!(role === "ADMIN" || role === "PM" || studyRole === "PM")) {
router.replace("/study/home");
}
};
const loadUsers = async () => {
if (auth.user?.role !== "ADMIN") {
users.value = [];
return;
}
try {
const { data } = await fetchUsers({ limit: 500 });
users.value = (data as any).items || data || [];
} catch {
users.value = [];
}
};
const loadLogs = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
const params: Record<string, any> = {
skip: (page.value - 1) * pageSize,
limit: pageSize,
action: filters.value.eventType || undefined,
operator_id: filters.value.operatorId || undefined,
};
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
const items = Array.isArray(data) ? data : (data as any).items || [];
rawLogs.value = items;
total.value = (data as any).total || items.length;
enrichLogs();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "审计日志加载失败");
} finally {
loading.value = false;
}
};
const enrichLogs = () => {
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = cur.username;
return acc;
}, {});
const filtered = rawLogs.value.filter((log) => {
if (filters.value.result && (log.result || "SUCCESS") !== filters.value.result) return false;
if (filters.value.range?.length === 2) {
const ts = new Date(log.created_at);
const start = new Date(filters.value.range[0]);
const end = new Date(filters.value.range[1]);
if (ts < start || ts > end) return false;
}
return true;
});
logs.value = filtered.map((log) => normalizeAuditEvent(log, userMap));
if (users.value.length === 0) {
const byActor = new Map<string, string>();
logs.value.forEach((log) => {
if (!byActor.has(log.actorId)) {
byActor.set(log.actorId, log.actorName || log.actorId);
}
});
users.value = Array.from(byActor.entries()).map(([id, username]) => ({ id, username }));
}
};
const onPageChange = (p: number) => {
page.value = p;
loadLogs();
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
ensureAccess();
await loadUsers();
await loadLogs();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.filters {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.spacer {
flex: 1;
}
.pagination {
margin-top: 12px;
text-align: right;
}
</style>