操作审计(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
+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>