Files
ctms/frontend/src/views/admin/AuditLogs.vue
T
2026-02-24 16:53:22 +08:00

395 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page">
<el-card shadow="never" class="main-content-card unified-shell">
<div class="filter-container unified-action-bar">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="" class="filter-item-form">
<el-select v-model="filters.entityType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEntityType" @change="loadLogs" class="filter-select-comp">
<el-option v-for="opt in entityTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item-form">
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="loadLogs" class="filter-select-comp">
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item-form">
<el-select v-model="filters.operatorId" clearable :placeholder="TEXT.modules.adminAuditLogs.filterOperator" filterable @change="loadLogs" class="filter-select-comp">
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item-form">
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="loadLogs" style="width: 120px">
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
<el-option :label="TEXT.audit.resultFail" value="FAIL" />
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item-form">
<el-date-picker
v-model="filters.range"
type="daterange"
unlink-panels
:start-placeholder="TEXT.modules.adminAuditLogs.rangeStart"
:end-placeholder="TEXT.modules.adminAuditLogs.rangeEnd"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
class="date-range-picker-comp"
@change="loadLogs"
/>
</el-form-item>
<div class="filter-spacer"></div>
<div class="ctms-action-group">
<el-button v-if="isAdmin" type="warning" plain size="small" :loading="exportLoading" @click="confirmExport('system')">
{{ TEXT.modules.adminAuditLogs.exportSystem }}
</el-button>
<el-button v-if="canProjectExport" type="warning" plain size="small" :loading="exportLoading" @click="confirmExport('project')">
{{ TEXT.modules.adminAuditLogs.exportProject }}
</el-button>
</div>
</el-form>
</div>
<div class="unified-section table-section">
<el-table :data="logs" v-loading="loading" style="width: 100%" stripe>
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="180" align="center">
<template #default="scope">
<span class="text-gray-500">{{ displayDateTime(scope.row.timestamp) }}</span>
</template>
</el-table-column>
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="140" />
<el-table-column prop="actorRoleLabel" :label="TEXT.common.labels.role" width="120" />
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="160">
<template #default="scope">
<el-tag effect="plain">{{ scope.row.eventLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="actionText" :label="TEXT.modules.adminAuditLogs.columns.action" min-width="180" />
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="180">
<template #default="scope">
<span v-if="scope.row.targetTypeLabel">
<strong>{{ scope.row.targetTypeLabel }}</strong>
<span class="text-gray-500" v-if="scope.row.targetName">{{ scope.row.targetName }}</span>
</span>
<span v-else>{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="260">
<template #default="scope">
<div v-if="scope.row.diffText?.length" class="diff-container">
<div v-for="(line, idx) in scope.row.diffText" :key="idx" class="diff-line">{{ line }}</div>
</div>
<span v-else class="text-gray-400">{{ TEXT.audit.emptyValue }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="100" align="center">
<template #default="scope">
<el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'" effect="dark" size="small">
{{ 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"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { fetchAuditLogs } from "../../api/auditLogs";
import { fetchUsers } from "../../api/users";
import { listMembers } from "../../api/members";
import { auditDict, normalizeAuditEvent } from "../../audit";
import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth";
import { roleDict, getDictLabel } from "../../dictionaries";
import { exportAuditCsv } from "../../audit/export/auditExportService";
import { logAudit } from "../../audit";
import { displayDateTime } from "../../utils/display";
import { TEXT } from "../../locales";
const buildLogKey = (log: any) => {
if (log.id) return `id:${log.id}`;
return `${log.action || log.eventType || "event"}-${log.entity_id || log.entityId || ""}-${log.created_at || log.timestamp || ""}`;
};
const dedupeLogs = (list: any[]) => {
const seen = new Set<string>();
const result: any[] = [];
list.forEach((log) => {
const key = buildLogKey(log);
if (seen.has(key)) return;
seen.add(key);
result.push(log);
});
return result;
};
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const loading = ref(false);
const exportLoading = 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({
entityType: "",
eventType: "",
operatorId: "",
result: "",
range: [],
});
const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
value,
label: cfg.label,
}));
const entityTypeOptions = [
{ value: "study_setup_config", label: "立项配置" },
{ value: "study", label: "项目" },
{ value: "subject", label: "参与者" },
{ value: "site", label: "中心" },
{ value: "finance", label: "费用" },
{ value: "drug_shipment", label: "药品流向" },
{ value: "audit_log", label: "审计日志" },
];
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");
const ensureAccess = () => {
const role = auth.user?.role;
if (role !== "ADMIN") {
router.replace("/admin/users");
}
};
const loadUsers = async () => {
try {
if (auth.user?.role === "ADMIN") {
const { data } = await fetchUsers({ limit: 500 });
users.value = (data as any).items || data || [];
} else if (study.currentStudy) {
// 非管理员,尝试加载项目成员作为备选
const { data } = await listMembers(study.currentStudy.id, { limit: 500 });
const items = Array.isArray(data) ? data : (data as any).items || [];
users.value = items.map((m: any) => ({
id: m.user_id,
username: m.user?.display_name || m.user?.full_name || m.user?.username || m.username,
}));
}
} 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,
entity_type: filters.value.entityType || undefined,
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 = dedupeLogs([...items, ...local]);
rawLogs.value = merged;
total.value = (data as any).total || merged.length;
enrichLogs();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.loadFailed);
} finally {
loading.value = false;
}
};
const loadLocalLogs = () => {
try {
const key = `audit_local_${study.currentStudy?.id || "global"}`;
const raw = localStorage.getItem(key);
if (!raw) return [];
const list = JSON.parse(raw);
return Array.isArray(list) ? list : [];
} catch {
return [];
}
};
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))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
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();
};
const fetchAllForExport = async () => {
if (!study.currentStudy) return [];
exportLoading.value = true;
try {
const params: Record<string, any> = {
skip: 0,
limit: 2000,
entity_type: filters.value.entityType || undefined,
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 = dedupeLogs([...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 || TEXT.modules.adminAuditLogs.exportLoadFailed);
return [];
} finally {
exportLoading.value = false;
}
};
const confirmExport = async (scope: "system" | "project") => {
const ok = await ElMessageBox.confirm(
TEXT.modules.adminAuditLogs.exportConfirm,
TEXT.modules.adminAuditLogs.exportConfirmTitle,
{ type: "warning", confirmButtonText: TEXT.common.actions.confirm, cancelButtonText: TEXT.common.actions.cancel }
).catch(() => null);
if (!ok) return;
const events = await fetchAllForExport();
if (!events.length) {
ElMessage.warning(TEXT.modules.adminAuditLogs.exportEmpty);
return;
}
const fileName =
scope === "system"
? `${TEXT.modules.adminAuditLogs.exportSystemName}_${new Date().toISOString().slice(0, 10)}`
: `${TEXT.modules.adminAuditLogs.exportProjectName}_${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 () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
ensureAccess();
await loadUsers();
await loadLogs();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
}
.table-section {
padding-top: 12px;
padding-bottom: 12px;
}
.filter-form {
display: flex;
width: 100%;
gap: 12px;
align-items: center;
}
.filter-item-form {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-select-comp {
width: 150px;
}
.date-range-picker-comp {
width: 280px !important;
}
.filter-spacer {
flex: 1;
}
.diff-container {
font-size: 13px;
color: #4b5563;
line-height: 1.5;
}
.diff-line {
margin-bottom: 2px;
word-break: break-all;
}
.pagination {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
</style>