操作审计(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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CTMS</title>
<script type="module" crossorigin src="/assets/index-DWloMSfp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D1uShwvc.css">
<script type="module" crossorigin src="/assets/index-CeU-aW8t.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CUEjPO3h.css">
</head>
<body>
<div id="app"></div>
+5
View File
@@ -0,0 +1,5 @@
import { apiGet } from "./axios";
import type { ApiListResponse } from "../types/api";
export const fetchAuditLogs = (studyId: string, params?: Record<string, any>) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/audit-logs/`, { params });
+35
View File
@@ -0,0 +1,35 @@
export const auditDict: Record<
string,
{ label: string; actionText: string; targetLabel: string }
> = {
AE_CLOSED: {
label: "关闭 AE(不良事件)",
actionText: "关闭了 AE",
targetLabel: "不良事件",
},
SUBJECT_STATUS_CHANGED: {
label: "受试者状态变更",
actionText: "变更了受试者状态",
targetLabel: "受试者",
},
FINANCE_STATUS_CHANGED: {
label: "费用状态变更",
actionText: "变更了费用状态",
targetLabel: "费用记录",
},
ADMIN_RESET_PASSWORD: {
label: "重置用户密码",
actionText: "重置了用户密码",
targetLabel: "用户账号",
},
TASK_STATUS_CHANGED: {
label: "任务状态变更",
actionText: "更新了任务状态",
targetLabel: "任务",
},
ISSUE_STATUS_CHANGED: {
label: "风险/问题状态变更",
actionText: "更新了风险/问题状态",
targetLabel: "风险/问题",
},
};
+81
View File
@@ -0,0 +1,81 @@
import { auditDict } from "./auditDict";
import { roleDict, getDictLabel, statusDict } from "../dictionaries";
export interface AuditEvent {
eventType: string;
eventLabel: string;
actorId: string;
actorName: string;
actorRole: string;
actorRoleLabel: string;
targetType: string;
targetTypeLabel: string;
targetId: string;
targetName?: string;
actionText: string;
before?: Record<string, any>;
after?: Record<string, any>;
diffText?: string[];
result: "SUCCESS" | "FAIL";
resultLabel: string;
timestamp: string;
}
const buildDiffText = (before?: Record<string, any>, after?: Record<string, any>) => {
if (!before || !after) return [];
const lines: string[] = [];
Object.keys({ ...before, ...after }).forEach((key) => {
const prev = before[key];
const next = after[key];
if (prev === next) return;
if (key.toLowerCase().includes("status")) {
lines.push(`状态:${getDictLabel(statusDict, prev)}${getDictLabel(statusDict, next)}`);
} else {
lines.push(`${key}${prev ?? "-"}${next ?? "-"}`);
}
});
return lines;
};
export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>): AuditEvent => {
const dict = auditDict[raw.action] || {
label: "系统操作",
actionText: "执行了操作",
targetLabel: raw.entity_type || "对象",
};
let detailObj: any = {};
if (raw.detail) {
try {
detailObj = JSON.parse(raw.detail);
} catch {
detailObj = { description: raw.detail };
}
}
const before = detailObj.before;
const after = detailObj.after;
const diffText = detailObj.diffText || buildDiffText(before, after);
return {
eventType: raw.action,
eventLabel: dict.label,
actorId: raw.operator_id,
actorName: userMap[raw.operator_id] || raw.operator_id,
actorRole: raw.operator_role,
actorRoleLabel: getDictLabel(roleDict, raw.operator_role),
targetType: raw.entity_type || "",
targetTypeLabel: dict.targetLabel,
targetId: raw.entity_id || "",
targetName: detailObj.targetName,
actionText: dict.actionText,
before,
after,
diffText: Array.isArray(diffText) ? diffText : diffText ? [diffText] : [],
result: detailObj.result || "SUCCESS",
resultLabel: detailObj.result === "FAIL" ? "失败" : "成功",
timestamp: raw.created_at,
};
};
export { auditDict };
// 前端占位的 logAudit:不修改后端逻辑,失败不影响主流程
export const logAudit = async (_eventType: string, _payload: Record<string, any>) => Promise.resolve();
+5 -1
View File
@@ -34,10 +34,13 @@
<el-menu-item index="/study/imp/inventory">库存</el-menu-item>
<el-menu-item index="/study/imp/products">产品</el-menu-item>
<el-menu-item index="/study/imp/batches">批次</el-menu-item>
<el-menu-item index="/study/imp/transactions">交易台账</el-menu-item>
<el-menu-item index="/study/imp/transactions">交易台账</el-menu-item>
</el-sub-menu>
<el-menu-item index="/study/finance">费用</el-menu-item>
<el-menu-item index="/study/faq">知识库</el-menu-item>
<el-menu-item v-if="isAdmin || isPm" index="/study/audit-logs">
审计日志
</el-menu-item>
</template>
</el-menu>
</el-aside>
@@ -59,6 +62,7 @@ const auth = useAuthStore();
const study = useStudyStore();
const router = useRouter();
const isAdmin = computed(() => auth.user?.role === "ADMIN");
const isPm = computed(() => auth.user?.role === "PM" || study.currentStudyRole === "PM");
const onLogout = () => {
auth.logout();
+15
View File
@@ -25,6 +25,7 @@ import FaqDetail from "../views/FaqDetail.vue";
import Issues from "../views/Issues.vue";
import IssueDetail from "../views/IssueDetail.vue";
import Verification from "../views/Verification.vue";
import AuditLogs from "../views/admin/AuditLogs.vue";
import AdminUsers from "../views/admin/Users.vue";
import AdminProjects from "../views/admin/Projects.vue";
import ProjectMembers from "../views/admin/ProjectMembers.vue";
@@ -168,6 +169,12 @@ const routes: RouteRecordRaw[] = [
component: Verification,
meta: { title: "核查进度", requiresStudy: true },
},
{
path: "study/audit-logs",
name: "AuditLogs",
component: AuditLogs,
meta: { title: "审计日志", requiresStudy: true },
},
],
},
{
@@ -243,6 +250,14 @@ router.beforeEach(async (to, _from, next) => {
next({ path: "/studies" });
return;
}
if (to.name === "AuditLogs") {
const role = auth.user?.role;
const studyRole = studyStore.currentStudyRole;
if (!(role === "ADMIN" || role === "PM" || studyRole === "PM")) {
next({ path: "/study/home" });
return;
}
}
if (to.path.startsWith("/study") && !studyStore.currentStudy) {
next({ path: "/studies" });
return;
+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>