审计导出功能--初版
This commit is contained in:
+22
-20
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>CTMS</title>
|
<title>CTMS</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DZqfrNxW.js"></script>
|
<script type="module" crossorigin src="/assets/index-CdXH1-ll.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CrvflH-M.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Dh_vTCuZ.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -57,4 +57,14 @@ export const auditDict: Record<
|
|||||||
actionText: "更新了中心信息",
|
actionText: "更新了中心信息",
|
||||||
targetLabel: "中心",
|
targetLabel: "中心",
|
||||||
},
|
},
|
||||||
|
AUDIT_EXPORT_SYSTEM: {
|
||||||
|
label: "导出系统审计",
|
||||||
|
actionText: "导出了系统审计日志",
|
||||||
|
targetLabel: "审计日志",
|
||||||
|
},
|
||||||
|
AUDIT_EXPORT_PROJECT: {
|
||||||
|
label: "导出项目审计",
|
||||||
|
actionText: "导出了项目审计日志",
|
||||||
|
targetLabel: "审计日志",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export interface AuditExportColumn {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auditExportColumns: AuditExportColumn[] = [
|
||||||
|
{ key: "timestamp", label: "审计时间" },
|
||||||
|
{ key: "actorName", label: "操作人姓名" },
|
||||||
|
{ key: "actorId", label: "操作人账号" },
|
||||||
|
{ key: "actorRoleLabel", label: "操作人角色" },
|
||||||
|
{ key: "eventLabel", label: "操作类型" },
|
||||||
|
{ key: "targetTypeLabel", label: "操作对象类型" },
|
||||||
|
{ key: "targetIdentifier", label: "操作对象标识" },
|
||||||
|
{ key: "description", label: "操作描述" },
|
||||||
|
{ key: "beforeStatus", label: "操作前状态" },
|
||||||
|
{ key: "afterStatus", label: "操作后状态" },
|
||||||
|
{ key: "resultLabel", label: "操作结果" },
|
||||||
|
{ key: "reason", label: "拒绝原因" },
|
||||||
|
{ key: "ip", label: "IP 地址" },
|
||||||
|
{ key: "remark", label: "备注" },
|
||||||
|
];
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { getDictLabel, statusDict } from "../../dictionaries";
|
||||||
|
import type { AuditEvent } from "..";
|
||||||
|
import { auditExportColumns } from "./auditExportColumns";
|
||||||
|
|
||||||
|
export interface AuditExportRow {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractStatusLabel = (obj?: Record<string, any>) => {
|
||||||
|
if (!obj) return "";
|
||||||
|
if (obj.status) return getDictLabel(statusDict, obj.status) || obj.status;
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatAuditRow = (event: AuditEvent): AuditExportRow => {
|
||||||
|
const beforeStatus = extractStatusLabel(event.before);
|
||||||
|
const afterStatus = extractStatusLabel(event.after);
|
||||||
|
const descriptionParts: string[] = [];
|
||||||
|
descriptionParts.push(`${event.actorName} ${event.actionText}`);
|
||||||
|
if (event.targetTypeLabel || event.targetName || event.targetId) {
|
||||||
|
descriptionParts.push(
|
||||||
|
`对象:${event.targetTypeLabel || ""}${event.targetName ? `(${event.targetName})` : `(${event.targetId}`})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (event.diffText?.length) {
|
||||||
|
descriptionParts.push(`变更:${event.diffText.join(";")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: event.timestamp || "",
|
||||||
|
actorName: event.actorName || "",
|
||||||
|
actorId: event.actorId || "",
|
||||||
|
actorRoleLabel: event.actorRoleLabel || "",
|
||||||
|
eventLabel: event.eventLabel || "",
|
||||||
|
targetTypeLabel: event.targetTypeLabel || "",
|
||||||
|
targetIdentifier: event.targetName || event.targetId || "",
|
||||||
|
description: descriptionParts.join(";"),
|
||||||
|
beforeStatus,
|
||||||
|
afterStatus,
|
||||||
|
resultLabel: event.resultLabel || "",
|
||||||
|
reason: event.reason || "",
|
||||||
|
ip: "",
|
||||||
|
remark: event.diffText?.join(";") || "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatAuditRows = (events: AuditEvent[]): AuditExportRow[] => {
|
||||||
|
return events.map((ev) => formatAuditRow(ev)).map((row) => {
|
||||||
|
const ordered: AuditExportRow = {};
|
||||||
|
auditExportColumns.forEach((col) => {
|
||||||
|
ordered[col.key] = row[col.key] ?? "";
|
||||||
|
});
|
||||||
|
return ordered;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { auditExportColumns } from "./auditExportColumns";
|
||||||
|
import { formatAuditRows } from "./auditExportFormatter";
|
||||||
|
import type { AuditEvent } from "..";
|
||||||
|
|
||||||
|
const BOM = "\ufeff";
|
||||||
|
|
||||||
|
const escapeCsv = (val: string) => {
|
||||||
|
const safe = (val ?? "").replace(/"/g, '""');
|
||||||
|
return `"${safe}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCsv = (rows: Record<string, string>[]) => {
|
||||||
|
const header = auditExportColumns.map((c) => escapeCsv(c.label)).join(",");
|
||||||
|
const body = rows.map((row) => auditExportColumns.map((c) => escapeCsv(row[c.key] || "")).join(",")).join("\n");
|
||||||
|
return `${BOM}${header}\n${body}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AuditExportOptions {
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const exportAuditCsv = (events: AuditEvent[], options: AuditExportOptions) => {
|
||||||
|
const rows = formatAuditRows(events);
|
||||||
|
const csv = buildCsv(rows);
|
||||||
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(link.href);
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@ export interface AuditEvent {
|
|||||||
before?: Record<string, any>;
|
before?: Record<string, any>;
|
||||||
after?: Record<string, any>;
|
after?: Record<string, any>;
|
||||||
diffText?: string[];
|
diffText?: string[];
|
||||||
|
reason?: string;
|
||||||
result: "SUCCESS" | "FAIL";
|
result: "SUCCESS" | "FAIL";
|
||||||
resultLabel: string;
|
resultLabel: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -71,6 +72,7 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
|
|||||||
before,
|
before,
|
||||||
after,
|
after,
|
||||||
diffText: Array.isArray(diffText) ? diffText : diffText ? [diffText] : [],
|
diffText: Array.isArray(diffText) ? diffText : diffText ? [diffText] : [],
|
||||||
|
reason: detailObj.reason,
|
||||||
result: detailObj.result || "SUCCESS",
|
result: detailObj.result || "SUCCESS",
|
||||||
resultLabel: detailObj.result === "FAIL" ? "失败" : "成功",
|
resultLabel: detailObj.result === "FAIL" ? "失败" : "成功",
|
||||||
timestamp: raw.created_at,
|
timestamp: raw.created_at,
|
||||||
|
|||||||
@@ -24,6 +24,24 @@
|
|||||||
/>
|
/>
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
<el-button type="primary" @click="loadLogs">查询</el-button>
|
<el-button type="primary" @click="loadLogs">查询</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="isAdmin"
|
||||||
|
type="warning"
|
||||||
|
plain
|
||||||
|
:loading="exportLoading"
|
||||||
|
@click="confirmExport('system')"
|
||||||
|
>
|
||||||
|
导出系统审计
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="canProjectExport"
|
||||||
|
type="warning"
|
||||||
|
plain
|
||||||
|
:loading="exportLoading"
|
||||||
|
@click="confirmExport('project')"
|
||||||
|
>
|
||||||
|
导出项目审计
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="logs" v-loading="loading" style="width: 100%">
|
<el-table :data="logs" v-loading="loading" style="width: 100%">
|
||||||
<el-table-column prop="timestamp" label="时间" width="180" />
|
<el-table-column prop="timestamp" label="时间" width="180" />
|
||||||
@@ -67,19 +85,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { fetchAuditLogs } from "../../api/auditLogs";
|
import { fetchAuditLogs } from "../../api/auditLogs";
|
||||||
import { fetchUsers } from "../../api/users";
|
import { fetchUsers } from "../../api/users";
|
||||||
import { auditDict, normalizeAuditEvent } from "../../audit";
|
import { auditDict, normalizeAuditEvent } from "../../audit";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { roleDict, getDictLabel } from "../../dictionaries";
|
import { roleDict, getDictLabel } from "../../dictionaries";
|
||||||
|
import { exportAuditCsv } from "../../audit/export/auditExportService";
|
||||||
|
import { logAudit } from "../../audit";
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const exportLoading = ref(false);
|
||||||
const rawLogs = ref<any[]>([]);
|
const rawLogs = ref<any[]>([]);
|
||||||
const logs = ref<any[]>([]);
|
const logs = ref<any[]>([]);
|
||||||
const users = ref<any[]>([]);
|
const users = ref<any[]>([]);
|
||||||
@@ -99,6 +120,8 @@ const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
|
|||||||
label: cfg.label,
|
label: cfg.label,
|
||||||
}));
|
}));
|
||||||
const userOptions = computed(() => users.value.map((u: any) => ({ label: u.username, value: u.id })));
|
const userOptions = computed(() => users.value.map((u: any) => ({ label: u.username, value: u.id })));
|
||||||
|
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||||
|
const canProjectExport = computed(() => !!study.currentStudy && (auth.user?.role === "ADMIN" || auth.user?.role === "PM" || study.currentStudyRole === "PM"));
|
||||||
|
|
||||||
const ensureAccess = () => {
|
const ensureAccess = () => {
|
||||||
const role = auth.user?.role;
|
const role = auth.user?.role;
|
||||||
@@ -191,6 +214,66 @@ const onPageChange = (p: number) => {
|
|||||||
loadLogs();
|
loadLogs();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchAllForExport = async () => {
|
||||||
|
if (!study.currentStudy) return [];
|
||||||
|
exportLoading.value = true;
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = {
|
||||||
|
skip: 0,
|
||||||
|
limit: 2000,
|
||||||
|
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 || [];
|
||||||
|
const local = loadLocalLogs();
|
||||||
|
const merged = [...items, ...local];
|
||||||
|
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
|
||||||
|
acc[cur.id] = cur.username;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const filtered = merged.filter((log) => {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
return filtered.map((log) => normalizeAuditEvent(log, userMap));
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || "导出数据加载失败");
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
exportLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmExport = async (scope: "system" | "project") => {
|
||||||
|
const ok = await ElMessageBox.confirm(
|
||||||
|
"该文件包含敏感审计信息,仅限授权人员使用。确认导出吗?",
|
||||||
|
"审计导出确认",
|
||||||
|
{ type: "warning", confirmButtonText: "确认", cancelButtonText: "取消" }
|
||||||
|
).catch(() => null);
|
||||||
|
if (!ok) return;
|
||||||
|
const events = await fetchAllForExport();
|
||||||
|
if (!events.length) {
|
||||||
|
ElMessage.warning("无可导出的审计记录");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fileName =
|
||||||
|
scope === "system"
|
||||||
|
? `系统审计_${new Date().toISOString().slice(0, 10)}`
|
||||||
|
: `项目审计_${study.currentStudy?.code || ""}_${new Date().toISOString().slice(0, 10)}`;
|
||||||
|
exportAuditCsv(events, { fileName });
|
||||||
|
logAudit(scope === "system" ? "AUDIT_EXPORT_SYSTEM" : "AUDIT_EXPORT_PROJECT", {
|
||||||
|
targetId: study.currentStudy?.id,
|
||||||
|
targetName: study.currentStudy?.name,
|
||||||
|
severity: "normal",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!auth.user && auth.token) {
|
if (!auth.user && auth.token) {
|
||||||
await auth.fetchMe().catch(() => {});
|
await auth.fetchMe().catch(() => {});
|
||||||
|
|||||||
Reference in New Issue
Block a user