Files
ctms/frontend/src/views/admin/AuditLogs.vue
T
Cheng Zhou a17f4cc522 完善项目级权限与项目管理入口
- 新增项目成员候选用户接口,避免 PM 读取全局用户列表
- 按项目角色和权限矩阵控制项目管理、成员、中心与审计入口
- 合并侧边栏管理后台入口,统一从项目管理进入成员等模块
- 加强成员管理安全约束,禁止 PM 修改自己、系统管理员或更高权限角色
- 修复多项目角色缓存串用问题,并补充前后端回归测试
2026-05-13 14:16:15 +08:00

632 lines
20 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">
<div class="main-content-flat unified-shell">
<div class="audit-toolbar">
<el-form :inline="true" :model="filters" class="filter-form">
<div 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>
</div>
<div 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>
</div>
<div 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>
</div>
<div 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"
/>
</div>
<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" class="audit-table" style="width: 100%" stripe table-layout="fixed">
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" align="center" show-overflow-tooltip>
<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" show-overflow-tooltip />
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" show-overflow-tooltip>
<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" show-overflow-tooltip />
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" show-overflow-tooltip>
<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" show-overflow-tooltip>
<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="110" 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>
</div>
<el-drawer
v-if="detailVisible"
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 { fetchProjectRolePermissions } from "../../api/projectPermissions";
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 { getProjectRole, isSystemAdmin } from "../../utils/roles";
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 permissionMatrix = ref<any | null>(null);
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(() => isSystemAdmin(auth.user));
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
const canProjectExport = computed(() => {
if (isAdmin.value) return true;
return projectRole.value === "PM" && !!permissionMatrix.value?.roles?.[projectRole.value]?.audit_export?.read;
});
const canAccessAuditLogs = computed(() => {
if (isAdmin.value) return true;
return projectRole.value === "PM" && !!permissionMatrix.value?.roles?.[projectRole.value]?.audit_export?.read;
});
const ensureAccess = () => {
if (!canAccessAuditLogs.value) {
router.replace(study.currentStudy ? "/project/overview" : "/workbench");
}
};
const loadPermissionMatrix = async () => {
if (!study.currentStudy) return;
const { data } = await fetchProjectRolePermissions(study.currentStudy.id);
permissionMatrix.value = data;
};
const loadUsers = async () => {
try {
if (isAdmin.value) {
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 {
if (!permissionMatrix.value) {
await loadPermissionMatrix();
}
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(() => {});
}
await loadPermissionMatrix().catch(() => {});
ensureAccess();
await loadUsers();
await loadLogs();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
}
.main-content-flat {
width: 100%;
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
padding: 0;
}
.main-content-flat :deep(.el-card__body) {
padding: 0;
}
.table-section {
padding: 0;
}
.audit-toolbar {
padding: 10px 16px;
border-bottom: 1px solid var(--unified-shell-divider);
}
.filter-form {
display: flex;
width: 100%;
gap: 8px;
align-items: center;
flex-wrap: nowrap;
}
.filter-item-form {
margin-bottom: 0 !important;
margin-right: 0 !important;
flex-shrink: 0;
}
.filter-select-comp {
width: 132px;
}
.filter-select-result {
width: 96px;
}
.date-range-picker-comp {
width: 248px !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: 10px;
display: flex;
justify-content: flex-end;
padding: 0 0 6px;
}
.audit-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.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>