操作审计(Audit Log)
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
+70
File diff suppressed because one or more lines are too long
-70
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
@@ -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: "风险/问题",
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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