操作审计(Audit Log)
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user