395 lines
14 KiB
Vue
395 lines
14 KiB
Vue
<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>
|