Files
ctms/frontend/src/views/admin/AuditLogs.vue
T
Cheng Zhou d5279b124f
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
release(main): 同步 dev 最新候选改动
2026-07-16 17:15:50 +08:00

1180 lines
36 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 page--flush audit-logs-page">
<div class="main-content-flat unified-shell">
<!-- 统计概览 -->
<section class="audit-overview" aria-labelledby="audit-overview-title">
<div class="overview-heading">
<h2 id="audit-overview-title">审计概览</h2>
<span class="overview-status"><i></i>日志汇总</span>
</div>
<div class="stats-row">
<div class="stat-card stat-card--total">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ total }}</span>
<span class="stat-label">日志总数</span>
</div>
</div>
<div class="stat-card stat-card--success">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ successCount }}</span>
<span class="stat-label">{{ TEXT.audit.resultSuccess }}</span>
</div>
</div>
<div class="stat-card stat-card--fail">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ failCount }}</span>
<span class="stat-label">{{ TEXT.audit.resultFail }}</span>
</div>
</div>
<div class="stat-card stat-card--operators">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ uniqueIpCount }}</span>
<span class="stat-label">来源 IP</span>
</div>
</div>
</div>
</section>
<!-- 筛选栏 -->
<div class="audit-toolbar unified-action-bar">
<el-form :inline="true" :model="filters" class="filter-form">
<div class="filter-heading">
<el-icon><Filter /></el-icon>
<span>筛选</span>
</div>
<div class="filter-item-form project-filter-group">
<el-select v-model="selectedStudyId" filterable :placeholder="TEXT.common.fields.projectName" @change="onProjectChange" class="filter-select-project">
<el-option v-for="project in studies" :key="project.id" :label="project.name" :value="project.id" />
</el-select>
<el-tooltip content="导出当前项目日志" placement="top">
<el-button
v-if="canProjectExport"
:icon="Download"
class="project-export-button"
:loading="exportLoading"
aria-label="导出当前项目日志"
@click="confirmExport"
/>
</el-tooltip>
</div>
<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>
<el-button v-if="hasActiveFilters" text :icon="RefreshLeft" class="reset-filter-button" @click="resetFilters">重置</el-button>
</el-form>
</div>
<!-- 日志表格 -->
<div class="unified-section table-section">
<el-table :data="logs" v-loading="loading" class="audit-table" style="width: 100%" table-layout="fixed">
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="128">
<template #default="scope">
<span class="time-text">{{ displayDateTime(scope.row.timestamp) }}</span>
</template>
</el-table-column>
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="155">
<template #default="scope">
<div class="actor-cell">
<span class="actor-name">{{ scope.row.actorName }}</span>
<span v-if="scope.row.actorAccount" class="actor-account">{{ scope.row.actorAccount }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="170">
<template #default="scope">
<span class="event-tag">{{ scope.row.eventLabel }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" width="280">
<template #default="scope">
<div class="target-cell">
<span v-if="scope.row.targetTypeLabel" class="target-text">
<strong>{{ scope.row.targetTypeLabel }}</strong><span v-if="scope.row.targetName" class="text-muted">{{ scope.row.targetName }}</span>
</span>
<span v-else class="text-muted">{{ TEXT.common.fallback }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="360">
<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">{{ formatDiffLine(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" size="small" @click="openDetail(scope.row)">{{ TEXT.common.actions.view }}</el-button>
</div>
</div>
<span v-else class="text-muted">{{ TEXT.audit.emptyValue }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="72" align="center" class-name="result-column">
<template #default="scope">
<span class="result-badge" :class="scope.row.result === 'SUCCESS' ? 'result--success' : 'result--fail'">
{{ scope.row.resultLabel }}
</span>
</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="580px"
:close-on-click-modal="true"
:show-close="false"
class="audit-detail-drawer"
>
<template #header>
<div class="drawer-header">
<div class="drawer-title-row">
<div class="drawer-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="18" height="18"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<span class="drawer-title">审计详情</span>
</div>
<el-button @click="detailVisible = false" size="small">{{ TEXT.common.actions.close }}</el-button>
</div>
</template>
<template v-if="selectedLog">
<!-- 基本信息 -->
<div class="detail-meta-grid">
<div class="meta-card">
<span class="meta-label">时间</span>
<span class="meta-value">{{ displayDateTime(selectedLog.timestamp) }}</span>
</div>
<div class="meta-card">
<span class="meta-label">操作人</span>
<span class="meta-value">{{ selectedLog.actorName || TEXT.common.fallback }}</span>
</div>
<div class="meta-card">
<span class="meta-label">事件</span>
<span class="meta-value">{{ selectedLog.eventLabel || TEXT.common.fallback }}</span>
</div>
<div class="meta-card">
<span class="meta-label">结果</span>
<span class="meta-value">
<span class="result-badge" :class="selectedLog.result === 'SUCCESS' ? 'result--success' : 'result--fail'">{{ selectedLog.resultLabel }}</span>
</span>
</div>
</div>
<div class="detail-meta-grid">
<div class="meta-card">
<span class="meta-label">对象</span>
<span class="meta-value">{{ selectedLog.targetTypeLabel || TEXT.common.fallback }}<span v-if="selectedLog.targetName">{{ selectedLog.targetName }}</span></span>
</div>
<div class="meta-card">
<span class="meta-label">动作</span>
<span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span>
</div>
</div>
<!-- 变更明细 -->
<div class="detail-section">
<div class="detail-section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
变更明细
<span class="diff-count" v-if="selectedDiffGroups.length">{{ selectedDiffGroups.length }} </span>
</div>
<el-scrollbar max-height="55vh">
<div v-if="selectedDiffGroups.length" class="detail-lines">
<div v-for="(group, idx) in selectedDiffGroups" :key="`${group.title}-${idx}`" class="detail-line-row">
<span class="detail-line-index">{{ Number(idx) + 1 }}</span>
<div class="detail-line-content">
<span v-if="group.title" class="diff-group-title">{{ group.title }}</span>
<div class="diff-group-lines">
<template v-for="(item, itemIdx) in group.items" :key="itemIdx">
<div v-if="item.parsed" class="diff-item-row">
<span class="diff-field">{{ item.parsed.field }}</span>
<div class="diff-values">
<span class="diff-old">{{ item.parsed.oldVal }}</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12" class="diff-arrow"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
<span class="diff-new">{{ item.parsed.newVal }}</span>
</div>
</div>
<span v-else class="detail-line-text">{{ item.text }}</span>
</template>
</div>
</div>
</div>
</div>
<div v-else class="empty-diff">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32" style="color:var(--ctms-text-disabled)"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-muted">无变更记录</span>
</div>
</el-scrollbar>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Download, Filter, RefreshLeft } from "@element-plus/icons-vue";
import { createAuditEvent, fetchAuditLogs } from "../../api/auditLogs";
import { fetchStudies } from "../../api/studies";
import { fetchApiEndpointPermissions } 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 { exportAuditCsv } from "../../audit/export/auditExportService";
import { displayDateTime } from "../../utils/display";
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { TEXT } from "../../locales";
import type { Study } from "../../types/api";
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const exportLoading = ref(false);
const logs = ref<any[]>([]);
const users = ref<any[]>([]);
const studies = ref<Study[]>([]);
const selectedStudy = ref<Study | null>(null);
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 successCount = computed(() => allLogs.value.filter(l => (l.result || 'SUCCESS') === 'SUCCESS').length);
const failCount = computed(() => allLogs.value.filter(l => l.result === 'FAIL').length);
const uniqueIpCount = computed(() => new Set(allLogs.value.map(l => l.clientIp || "未知 IP")).size);
const hasActiveFilters = computed(() => Boolean(
filters.value.eventType ||
filters.value.operatorId ||
filters.value.result ||
filters.value.range?.length
));
const formatDiffLine = (line: any): string => {
if (typeof line === 'string') return line;
if (line === null || line === undefined) return '-';
try {
return JSON.stringify(line, null, 0);
} catch {
return String(line);
}
};
const parseDiffLine = (line: string): { field: string; oldVal: string; newVal: string } | null => {
const match = line.match(/^(.+)[:]\s*(.*?)\s*->\s*(.+)$/);
if (!match) return null;
return { field: match[1], oldVal: match[2], newVal: match[3] };
};
type DetailDiffItem = {
text: string;
parsed: { field: string; oldVal: string; newVal: string } | null;
};
type DetailDiffGroup = {
title: string;
items: DetailDiffItem[];
};
const splitGroupedDiffField = (field: string): { title: string; field: string } | null => {
const parts = String(field || "")
.split("/")
.map((item) => item.trim())
.filter(Boolean);
if (parts.length < 3) return null;
return {
title: parts.slice(0, -1).join(" / "),
field: parts[parts.length - 1],
};
};
const buildDetailDiffGroups = (lines: any[]): DetailDiffGroup[] => {
const groups: DetailDiffGroup[] = [];
const groupedIndex = new Map<string, DetailDiffGroup>();
lines.forEach((line) => {
const text = formatDiffLine(line);
const parsed = parseDiffLine(text);
const groupedField = parsed ? splitGroupedDiffField(parsed.field) : null;
if (!parsed || !groupedField) {
groups.push({ title: "", items: [{ text, parsed }] });
return;
}
let group = groupedIndex.get(groupedField.title);
if (!group) {
group = { title: groupedField.title, items: [] };
groupedIndex.set(groupedField.title, group);
groups.push(group);
}
group.items.push({
text,
parsed: { ...parsed, field: groupedField.field },
});
});
return groups;
};
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 selectedDiffGroups = computed(() => buildDetailDiffGroups(selectedLog.value?.diffText || []));
const selectedStudyId = computed({
get: () => selectedStudy.value?.id || "",
set: (studyId: string) => {
selectedStudy.value = studies.value.find((item) => item.id === studyId) || null;
},
});
const isAdmin = computed(() => isSystemAdmin(auth.user));
const projectRole = computed(() => {
const role = selectedStudy.value?.id === study.currentStudy?.id
? study.currentStudyRole
: (selectedStudy.value as any)?.role_in_study || null;
return getProjectRole(selectedStudy.value, role);
});
const canProjectExport = computed(() => {
if (isAdmin.value) return true;
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:export"]);
});
const canAccessAuditLogs = computed(() => {
if (isAdmin.value) return true;
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]);
});
const ensureAccess = () => {
if (!selectedStudy.value) {
router.replace("/admin/projects");
return;
}
if (!canAccessAuditLogs.value) {
router.replace(study.currentStudy ? "/project/overview" : "/admin/projects");
}
};
const loadAvailableStudies = async () => {
const { data } = await fetchStudies();
const items = ((data as any).items || []) as Study[];
const availableItems = isAdmin.value ? items : items.filter((item) => item.role_in_study === "PM");
studies.value = availableItems;
const queryProjectId = typeof route.query.projectId === "string" ? route.query.projectId : "";
selectedStudy.value =
availableItems.find((item) => item.id === queryProjectId) ||
availableItems.find((item) => item.id === study.currentStudy?.id) ||
availableItems[0] ||
null;
};
const loadPermissionMatrix = async () => {
if (!selectedStudyId.value) return;
const { data } = await fetchApiEndpointPermissions(selectedStudyId.value);
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 (selectedStudyId.value) {
const { data } = await listMembers(selectedStudyId.value, { 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 fetchAuditLogPages = async (baseParams: Record<string, any> = {}) => {
const batchLimit = 500;
let skip = 0;
const allItems: any[] = [];
const params: Record<string, any> = {
...baseParams,
skip,
limit: batchLimit,
};
while (true) {
params.skip = skip;
const { data } = await fetchAuditLogs(selectedStudyId.value, params);
const items = Array.isArray(data) ? data : (data as any).items || [];
allItems.push(...items);
if (items.length < batchLimit) break;
skip += items.length;
}
return allItems;
};
const buildAuditServerParams = () => {
return {
action: filters.value.eventType || undefined,
operator_id: filters.value.operatorId || undefined,
};
};
const loadLogs = async () => {
if (!selectedStudyId.value) return;
loading.value = true;
try {
if (!permissionMatrix.value) {
await loadPermissionMatrix();
}
const allItems = await fetchAuditLogPages(buildAuditServerParams());
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());
const byActor = new Map(users.value.map((user) => [user.id, resolveUserDisplayName(user)]));
allLogs.value.forEach((log) => {
if (!byActor.has(log.actorId)) {
byActor.set(log.actorId, log.actorName || log.actorAccount || 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 resetFilters = () => {
filters.value.eventType = "";
filters.value.operatorId = "";
filters.value.result = "";
filters.value.range = [];
onServerFilterChange();
};
const onProjectChange = async () => {
permissionMatrix.value = null;
users.value = [];
allLogs.value = [];
logs.value = [];
total.value = 0;
page.value = 1;
await router.replace({
path: "/admin/audit-logs",
query: { ...route.query, projectId: selectedStudyId.value || undefined },
});
await loadPermissionMatrix().catch(() => {});
ensureAccess();
await loadUsers();
await loadLogs();
};
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 (!selectedStudyId.value) return [];
exportLoading.value = true;
try {
const items = await fetchAuditLogPages(buildAuditServerParams());
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = resolveUserDisplayName(cur);
return acc;
}, {});
return filterLogs(items.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 confirmExport = async () => {
const currentStudy = selectedStudy.value;
if (!currentStudy) return;
if (!canProjectExport.value) 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 = `${TEXT.modules.adminAuditLogs.exportProjectName}_${currentStudy.code || ""}_${new Date().toISOString().slice(0, 10)}`;
exportAuditCsv(events, { fileName });
await createAuditEvent(selectedStudyId.value, {
action: "AUDIT_EXPORT_PROJECT",
}).catch(() => null);
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await loadAvailableStudies().catch(() => {});
await loadPermissionMatrix().catch(() => {});
ensureAccess();
await loadUsers();
await loadLogs();
});
</script>
<style scoped>
:global(.web-layout-container .content-wrapper:has(.audit-logs-page)) {
padding: 0;
}
/* 统计卡片 */
.audit-overview {
padding: 0;
border-bottom: 1px solid var(--unified-shell-divider);
background: linear-gradient(180deg, var(--ctms-bg-card) 0%, color-mix(in srgb, var(--ctms-neutral-100) 55%, var(--ctms-bg-card)) 100%);
}
.overview-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 38px;
padding: 6px 16px;
}
.overview-heading h2 {
margin: 0;
color: var(--ctms-text-main);
font-size: 14px;
font-weight: 700;
}
.overview-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border: 1px solid rgba(63, 143, 107, 0.18);
border-radius: 999px;
background: rgba(63, 143, 107, 0.07);
color: var(--ctms-success);
font-size: 10px;
white-space: nowrap;
}
.overview-status i {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
border-top: 1px solid var(--unified-shell-divider);
background: var(--ctms-bg-card);
}
.stat-card {
display: flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 6px 14px;
background: transparent;
}
.stat-card + .stat-card {
border-left: 1px solid var(--unified-shell-divider);
}
.stat-icon {
width: 30px;
height: 30px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon svg { width: 17px; height: 17px; }
.stat-card--total .stat-icon { background: #e8edf3; color: var(--ctms-primary); }
.stat-card--success .stat-icon { background: #e6f4ed; color: var(--ctms-success); }
.stat-card--fail .stat-icon { background: #fde8e8; color: var(--ctms-danger); }
.stat-card--operators .stat-icon { background: #eef2f6; color: var(--ctms-info); }
.stat-body { display: flex; flex-direction: column; }
.stat-value { font-size: 18px; font-weight: 700; line-height: 1.2; color: var(--ctms-text-main); }
.stat-label { font-size: 10px; color: var(--ctms-text-secondary); margin-top: 2px; }
/* 筛选栏 */
.audit-toolbar {
position: sticky;
top: 0;
z-index: 8;
border-bottom: 1px solid var(--unified-shell-divider);
background: color-mix(in srgb, var(--ctms-bg-card) 95%, transparent);
box-shadow: 0 5px 14px rgba(15, 23, 42, 0.05);
backdrop-filter: blur(12px);
padding-top: 8px;
padding-bottom: 8px;
}
.filter-form {
display: flex;
width: 100%;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.filter-item-form {
margin-bottom: 0 !important;
margin-right: 0 !important;
flex-shrink: 0;
}
.project-filter-group {
display: inline-flex;
align-items: center;
gap: 0;
}
.filter-select-project :deep(.el-select__wrapper) {
border-radius: 8px 0 0 8px;
}
.project-export-button {
width: 32px;
min-width: 32px;
height: 32px;
min-height: 32px;
padding: 0;
margin-left: -1px;
border-radius: 0 8px 8px 0;
color: var(--ctms-primary);
border-color: color-mix(in srgb, var(--ctms-primary) 24%, var(--ctms-border-color));
background: color-mix(in srgb, var(--ctms-primary) 7%, var(--ctms-bg-card));
}
.filter-heading {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--ctms-text-secondary);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.filter-form :deep(.el-input__inner),
.filter-form :deep(.el-select__placeholder),
.filter-form :deep(.el-select__selected-item),
.filter-form :deep(.el-range-input),
.filter-form :deep(.el-button) {
font-size: 12px;
}
.filter-form :deep(.el-select__wrapper),
.filter-form :deep(.el-input__wrapper),
.filter-form :deep(.el-date-editor) {
min-height: 32px;
box-shadow: 0 0 0 1px var(--ctms-border-color) inset;
}
.reset-filter-button {
color: var(--ctms-text-secondary);
}
.filter-select-comp { width: 132px; }
.filter-select-project { width: 180px; }
.filter-select-result { width: 96px; }
.date-range-picker-comp { width: 248px !important; }
/* 表格 */
.table-section { padding: 0 !important; }
.audit-table :deep(.el-table__inner-wrapper::before) { display: none; }
.audit-table :deep(.el-table__cell) {
padding: 8px 0;
}
.audit-table :deep(.cell) {
padding: 0 12px;
}
.time-text {
font-size: 12px;
color: var(--ctms-text-secondary);
font-variant-numeric: tabular-nums;
}
.actor-cell {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.actor-name,
.actor-account {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actor-name {
font-size: 13px;
}
.actor-account {
font-size: 11px;
color: var(--ctms-text-secondary);
}
.event-tag {
display: inline-flex;
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
background: #f0f4f8;
color: var(--ctms-text-regular);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.target-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.target-text {
display: inline-flex;
align-items: baseline;
max-width: 100%;
min-width: 0;
}
.target-text strong {
flex-shrink: 0;
}
.target-text .text-muted {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-muted { color: var(--ctms-text-secondary); font-size: 12px; }
.result-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.audit-table :deep(.result-column .cell) {
padding-right: 4px;
padding-left: 4px;
}
.result--success { background: #e6f4ed; color: #166534; }
.result--fail { background: #fde8e8; color: #991b1b; }
.diff-container {
font-size: 12px;
color: var(--ctms-text-regular);
line-height: 1.5;
overflow: hidden;
max-width: 100%;
}
.diff-line {
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.diff-more-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.diff-more-text { font-size: 11px; color: var(--ctms-text-disabled); }
.pagination-wrap {
margin-top: 10px;
display: flex;
justify-content: flex-end;
padding: 8px 16px;
}
/* 详情抽屉 */
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.drawer-title-row {
display: flex;
align-items: center;
gap: 10px;
}
.drawer-icon {
width: 34px;
height: 34px;
border-radius: 9px;
background: var(--ctms-primary-light);
color: var(--ctms-primary);
display: flex;
align-items: center;
justify-content: center;
}
.drawer-title {
font-size: 17px;
font-weight: 700;
color: var(--ctms-text-main);
}
.detail-meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 10px;
}
.meta-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: 1px solid var(--ctms-border-color);
border-radius: 10px;
background: var(--ctms-neutral-100);
}
.meta-label {
font-size: 11px;
color: var(--ctms-text-secondary);
font-weight: 500;
}
.meta-value {
font-size: 13px;
color: var(--ctms-text-main);
word-break: break-word;
}
.detail-section {
margin-top: 14px;
}
.detail-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 700;
color: var(--ctms-text-main);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--ctms-border-color);
}
.detail-section-title svg {
color: var(--ctms-primary);
}
.diff-count {
font-size: 11px;
font-weight: 600;
color: var(--ctms-text-secondary);
background: var(--ctms-neutral-100);
padding: 1px 6px;
border-radius: 4px;
margin-left: auto;
}
.detail-lines {
display: flex;
flex-direction: column;
gap: 6px;
}
.detail-line-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--ctms-border-color);
background: #fff;
transition: var(--ctms-transition);
}
.detail-line-row:hover {
border-color: var(--ctms-border-color-hover);
box-shadow: var(--ctms-shadow-sm);
}
.detail-line-index {
color: var(--ctms-text-disabled);
min-width: 20px;
font-size: 11px;
font-weight: 700;
text-align: center;
padding-top: 2px;
background: var(--ctms-neutral-100);
border-radius: 4px;
height: 20px;
line-height: 20px;
}
.detail-line-content {
flex: 1;
min-width: 0;
}
.diff-group-title {
display: block;
font-size: 13px;
font-weight: 700;
color: var(--ctms-primary);
margin-bottom: 8px;
word-break: break-word;
}
.diff-group-lines {
display: flex;
flex-direction: column;
gap: 8px;
}
.diff-item-row {
display: grid;
grid-template-columns: minmax(72px, 128px) minmax(0, 1fr);
gap: 10px;
align-items: start;
}
.diff-field {
display: inline-block;
font-size: 12px;
font-weight: 600;
color: var(--ctms-text-regular);
line-height: 1.8;
word-break: break-word;
}
.diff-values {
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
}
.diff-old {
font-size: 12px;
color: var(--ctms-danger);
background: #fef2f2;
padding: 2px 6px;
border-radius: 4px;
word-break: break-word;
max-width: 45%;
text-decoration: line-through;
text-decoration-color: rgba(194, 75, 75, 0.3);
}
.diff-arrow {
color: var(--ctms-text-disabled);
flex-shrink: 0;
margin-top: 3px;
}
.diff-new {
font-size: 12px;
color: var(--ctms-success);
background: #f0fdf4;
padding: 2px 6px;
border-radius: 4px;
word-break: break-word;
max-width: 45%;
}
.detail-line-text {
color: var(--ctms-text-regular);
word-break: break-word;
font-size: 13px;
line-height: 1.5;
}
.empty-diff {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 30px 20px;
}
@media (max-width: 768px) {
.stats-row { grid-template-columns: repeat(2, 1fr); }
.filter-form { flex-wrap: wrap; }
.stat-card:nth-child(3) { border-left: 0; }
.stat-card:nth-child(n + 3) { border-top: 1px solid var(--unified-shell-divider); }
.filter-heading { width: 100%; }
.diff-item-row { grid-template-columns: 1fr; gap: 4px; }
}
</style>