Files
ctms/frontend/src/views/admin/AuditLogs.vue
T

592 lines
19 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="audit-toolbar">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="" class="filter-item-form">
<el-select
v-model="filters.eventType"
clearable
:placeholder="TEXT.modules.adminAuditLogs.filterEvent"
@change="onServerFilterChange"
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="onServerFilterChange"
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="onLocalFilterChange" class="filter-select-result">
<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="onLocalFilterChange"
/>
</el-form-item>
<div class="filter-spacer"></div>
<el-dropdown trigger="click" @command="handleExportCommand" class="audit-export-dropdown">
<el-button plain class="audit-export-btn" :loading="exportLoading">
导出 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="isAdmin" command="system">{{ TEXT.modules.adminAuditLogs.exportSystem }}</el-dropdown-item>
<el-dropdown-item v-if="canProjectExport" command="project">{{ TEXT.modules.adminAuditLogs.exportProject }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</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="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 getDiffPreview(scope.row)" :key="idx" class="diff-line">{{ line }}</div>
<div class="diff-more-row" v-if="scope.row.diffText.length > DIFF_PREVIEW_LIMIT">
<span class="diff-more-text">其余 {{ scope.row.diffText.length - DIFF_PREVIEW_LIMIT }} </span>
<el-button link type="primary" @click="openDetail(scope.row)">{{ TEXT.common.actions.view }}</el-button>
</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>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20]"
:total="total"
layout="prev, pager, next, sizes, total"
small
@current-change="onPageChange"
@size-change="onPageSizeChange"
/>
</div>
</div>
</el-card>
<el-drawer
v-model="detailVisible"
direction="rtl"
size="560px"
:close-on-click-modal="false"
:show-close="false"
class="audit-detail-drawer"
>
<template #header>
<div class="drawer-header">
<div class="drawer-title">审计日志详情</div>
<el-button @click="detailVisible = false">{{ TEXT.common.actions.close }}</el-button>
</div>
</template>
<template v-if="selectedLog">
<div class="detail-meta-grid">
<div class="meta-item"><span class="meta-label">时间</span><span>{{ displayDateTime(selectedLog.timestamp) }}</span></div>
<div class="meta-item"><span class="meta-label">操作人</span><span>{{ selectedLog.actorName || TEXT.common.fallback }}</span></div>
<div class="meta-item"><span class="meta-label">事件</span><span>{{ selectedLog.eventLabel || TEXT.common.fallback }}</span></div>
<div class="meta-item"><span class="meta-label">结果</span><span>{{ selectedLog.resultLabel || TEXT.common.fallback }}</span></div>
<div class="meta-item meta-item-full">
<span class="meta-label">对象</span>
<span>{{ selectedLog.targetTypeLabel || TEXT.common.fallback }}<span v-if="selectedLog.targetName">{{ selectedLog.targetName }}</span></span>
</div>
<div class="meta-item meta-item-full"><span class="meta-label">动作</span><span>{{ selectedLog.actionText || TEXT.common.fallback }}</span></div>
</div>
<div class="detail-section">
<div class="detail-section-title">变更明细</div>
<el-scrollbar max-height="58vh">
<div v-if="selectedLog.diffText?.length" class="detail-lines">
<div v-for="(line, idx) in selectedLog.diffText" :key="idx" class="detail-line-row">
<span class="detail-line-index">{{ Number(idx) + 1 }}.</span>
<span class="detail-line-text">{{ line }}</span>
</div>
</div>
<div v-else class="text-gray-400">{{ TEXT.audit.emptyValue }}</div>
</el-scrollbar>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { ArrowDown } from "@element-plus/icons-vue";
import { createAuditEvent, 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 { displayDateTime } from "../../utils/display";
import { TEXT } from "../../locales";
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const loading = ref(false);
const exportLoading = ref(false);
const logs = ref<any[]>([]);
const users = ref<any[]>([]);
const allLogs = ref<any[]>([]);
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
const detailVisible = ref(false);
const selectedLog = ref<any | null>(null);
const DIFF_PREVIEW_LIMIT = 2;
const filters = ref({
eventType: "",
operatorId: "",
result: "",
range: [],
});
const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
value,
label: cfg.label,
}));
const resolveUserDisplayName = (u: any): string => {
return u?.full_name || u?.display_name || u?.username || u?.email || u?.id || TEXT.common.fallback;
};
const userOptions = computed(() => users.value.map((u: any) => ({ label: resolveUserDisplayName(u), 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 = Array.isArray(data) ? data : (data as any).items || [];
} 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,
full_name: 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 batchLimit = 500;
let skip = 0;
const allItems: any[] = [];
const params: Record<string, any> = {
skip,
limit: batchLimit,
action: filters.value.eventType || undefined,
operator_id: filters.value.operatorId || undefined,
};
while (true) {
params.skip = skip;
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
const items = Array.isArray(data) ? data : (data as any).items || [];
allItems.push(...items);
if (items.length < batchLimit) break;
skip += items.length;
}
enrichLogs(allItems);
refreshPagedLogs();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.loadFailed);
} finally {
loading.value = false;
}
};
const enrichLogs = (items: any[]) => {
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = resolveUserDisplayName(cur);
return acc;
}, {});
allLogs.value = items
.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>();
allLogs.value.forEach((log) => {
if (!byActor.has(log.actorId)) {
byActor.set(log.actorId, log.actorName || log.actorId);
}
});
users.value = Array.from(byActor.entries()).map(([id, full_name]) => ({ id, full_name }));
}
};
const filterLogs = (items: any[]) =>
items.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.timestamp);
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;
});
const refreshPagedLogs = () => {
const filtered = filterLogs(allLogs.value);
total.value = filtered.length;
const maxPage = Math.max(1, Math.ceil(total.value / pageSize.value));
if (page.value > maxPage) page.value = maxPage;
const start = (page.value - 1) * pageSize.value;
logs.value = filtered.slice(start, start + pageSize.value);
};
const onServerFilterChange = () => {
page.value = 1;
loadLogs();
};
const onLocalFilterChange = () => {
page.value = 1;
refreshPagedLogs();
};
const onPageChange = (p: number) => {
page.value = p;
refreshPagedLogs();
};
const onPageSizeChange = (size: number) => {
pageSize.value = size;
page.value = 1;
refreshPagedLogs();
};
const getDiffPreview = (log: any) => {
const lines = Array.isArray(log?.diffText) ? log.diffText : [];
return lines.slice(0, DIFF_PREVIEW_LIMIT);
};
const openDetail = (log: any) => {
selectedLog.value = log;
detailVisible.value = true;
};
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 userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = resolveUserDisplayName(cur);
return acc;
}, {});
const filtered = items.filter((log: any) => {
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: any) => normalizeAuditEvent(log, userMap));
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.exportLoadFailed);
return [];
} finally {
exportLoading.value = false;
}
};
const handleExportCommand = (command: string) => {
confirmExport(command as "system" | "project");
};
const confirmExport = async (scope: "system" | "project") => {
const currentStudy = study.currentStudy;
if (!currentStudy) return;
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}_${currentStudy.code || ""}_${new Date().toISOString().slice(0, 10)}`;
exportAuditCsv(events, { fileName });
await createAuditEvent(currentStudy.id, {
action: scope === "system" ? "AUDIT_EXPORT_SYSTEM" : "AUDIT_EXPORT_PROJECT",
entity_type: "audit_log",
entity_id: null,
detail: JSON.stringify(
{
targetName: currentStudy.name,
scope,
exportedCount: events.length,
},
null,
0
),
}).catch(() => null);
};
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;
}
.audit-toolbar {
padding: 12px 16px;
}
.filter-form {
display: flex;
width: 100%;
gap: 10px;
align-items: center;
flex-wrap: nowrap;
}
.filter-item-form {
margin-bottom: 0 !important;
margin-right: 0 !important;
flex-shrink: 0;
}
.filter-select-comp {
width: 140px;
}
.filter-select-result {
width: 100px;
}
.date-range-picker-comp {
width: 260px !important;
}
.filter-spacer {
flex: 1;
}
.audit-export-dropdown {
flex-shrink: 0;
}
.audit-export-btn {
border-radius: 8px !important;
font-weight: 600;
height: 32px;
color: #3b6cb5 !important;
border-color: #b3cce6 !important;
background: #f0f6ff !important;
}
.audit-export-btn:hover,
.audit-export-btn:focus {
color: #2a5699 !important;
border-color: #8cb4de !important;
background: #e2edfa !important;
}
.diff-container {
font-size: 13px;
color: #4b5563;
line-height: 1.5;
}
.diff-line {
margin-bottom: 2px;
word-break: break-all;
}
.diff-more-row {
display: flex;
align-items: center;
gap: 8px;
}
.diff-more-text {
font-size: 12px;
color: #94a3b8;
}
.pagination-wrap {
margin-top: 14px;
display: flex;
justify-content: flex-end;
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.drawer-title {
font-size: 20px;
font-weight: 700;
color: #11264d;
}
.detail-meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 14px;
}
.meta-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px;
border: 1px solid #e6edf7;
border-radius: 8px;
background: #fbfdff;
font-size: 13px;
color: #334155;
}
.meta-item-full {
grid-column: 1 / -1;
}
.meta-label {
font-size: 12px;
color: #64748b;
}
.detail-section {
margin-top: 14px;
}
.detail-section-title {
font-size: 14px;
font-weight: 700;
color: #1e3a5f;
margin-bottom: 8px;
}
.detail-lines {
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 6px;
}
.detail-line-row {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 13px;
line-height: 1.5;
}
.detail-line-index {
color: #94a3b8;
min-width: 20px;
}
.detail-line-text {
color: #475569;
word-break: break-word;
}
</style>