release(main): 同步 dev 最新候选改动
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

This commit is contained in:
Cheng Zhou
2026-07-16 17:15:50 +08:00
parent 32167fba02
commit d5279b124f
393 changed files with 51630 additions and 9711 deletions
+210 -93
View File
@@ -1,8 +1,13 @@
<template>
<div class="page page--flush">
<div class="page page--flush audit-logs-page">
<div class="main-content-flat unified-shell">
<!-- 统计概览 -->
<div class="stats-row">
<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>
@@ -35,19 +40,34 @@
<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">{{ operatorCount }}</span>
<span class="stat-label">操作人数</span>
<span class="stat-value">{{ uniqueIpCount }}</span>
<span class="stat-label">来源 IP</span>
</div>
</div>
</div>
</div>
</section>
<!-- 筛选栏 -->
<div class="audit-toolbar unified-action-bar">
<el-form :inline="true" :model="filters" class="filter-form">
<div class="filter-item-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">
@@ -78,42 +98,32 @@
@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">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
导出 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="canProjectExport" command="project">{{ TEXT.modules.adminAuditLogs.exportProject }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<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="150">
<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="120">
<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="145">
<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" min-width="240">
<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">
@@ -135,7 +145,7 @@
<span v-else class="text-muted">{{ TEXT.audit.emptyValue }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="76" align="center">
<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 }}
@@ -211,7 +221,6 @@
<span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span>
</div>
</div>
<!-- 变更明细 -->
<div class="detail-section">
<div class="detail-section-title">
@@ -256,7 +265,7 @@
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { ArrowDown } from "@element-plus/icons-vue";
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";
@@ -265,7 +274,6 @@ 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";
@@ -302,7 +310,13 @@ const filters = ref({
const successCount = computed(() => allLogs.value.filter(l => (l.result || 'SUCCESS') === 'SUCCESS').length);
const failCount = computed(() => allLogs.value.filter(l => l.result === 'FAIL').length);
const operatorCount = computed(() => new Set(allLogs.value.map(l => l.actorId || l.actorName)).size);
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;
@@ -470,6 +484,13 @@ const fetchAuditLogPages = async (baseParams: Record<string, any> = {}) => {
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;
@@ -477,10 +498,7 @@ const loadLogs = async () => {
if (!permissionMatrix.value) {
await loadPermissionMatrix();
}
const allItems = await fetchAuditLogPages({
action: filters.value.eventType || undefined,
operator_id: filters.value.operatorId || undefined,
});
const allItems = await fetchAuditLogPages(buildAuditServerParams());
enrichLogs(allItems);
refreshPagedLogs();
} catch (e: any) {
@@ -498,15 +516,13 @@ const enrichLogs = (items: any[]) => {
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 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[]) =>
@@ -540,6 +556,14 @@ const onLocalFilterChange = () => {
refreshPagedLogs();
};
const resetFilters = () => {
filters.value.eventType = "";
filters.value.operatorId = "";
filters.value.result = "";
filters.value.range = [];
onServerFilterChange();
};
const onProjectChange = async () => {
permissionMatrix.value = null;
users.value = [];
@@ -582,24 +606,12 @@ const fetchAllForExport = async () => {
if (!selectedStudyId.value) return [];
exportLoading.value = true;
try {
const items = await fetchAuditLogPages({
action: filters.value.eventType || undefined,
operator_id: filters.value.operatorId || undefined,
});
const items = await fetchAuditLogPages(buildAuditServerParams());
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));
return filterLogs(items.map((log: any) => normalizeAuditEvent(log, userMap)));
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.exportLoadFailed);
return [];
@@ -608,10 +620,6 @@ const fetchAllForExport = async () => {
}
};
const handleExportCommand = (command: string) => {
if (command === "project") confirmExport();
};
const confirmExport = async () => {
const currentStudy = selectedStudy.value;
if (!currentStudy) return;
@@ -647,33 +655,77 @@ onMounted(async () => {
</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: 10px;
padding: 10px 16px;
border-bottom: 1px solid var(--unified-shell-divider);
gap: 0;
border-top: 1px solid var(--unified-shell-divider);
background: var(--ctms-bg-card);
}
.stat-card {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: 10px;
background: var(--ctms-neutral-100);
transition: var(--ctms-transition);
min-height: 48px;
padding: 6px 14px;
background: transparent;
}
.stat-card:hover {
transform: translateY(-1px);
box-shadow: var(--ctms-shadow-sm);
.stat-card + .stat-card {
border-left: 1px solid var(--unified-shell-divider);
}
.stat-icon {
width: 32px;
height: 32px;
width: 30px;
height: 30px;
border-radius: 8px;
display: flex;
align-items: center;
@@ -681,19 +733,27 @@ onMounted(async () => {
flex-shrink: 0;
}
.stat-icon svg { width: 18px; height: 18px; }
.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: 20px; font-weight: 700; line-height: 1.2; color: var(--ctms-text-main); }
.stat-label { font-size: 11px; color: var(--ctms-text-secondary); margin-top: 2px; }
.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 {
@@ -701,7 +761,7 @@ onMounted(async () => {
width: 100%;
gap: 8px;
align-items: center;
flex-wrap: nowrap;
flex-wrap: wrap;
}
.filter-item-form {
@@ -710,29 +770,65 @@ onMounted(async () => {
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; }
.filter-spacer { flex: 1; }
.audit-export-dropdown { flex-shrink: 0; }
.audit-export-btn {
border-radius: 8px !important;
font-weight: 600;
height: 32px;
color: var(--ctms-primary) !important;
border-color: #c0cdd7 !important;
background: #f0f6ff !important;
}
.audit-export-btn:hover {
color: var(--ctms-primary-hover) !important;
border-color: #9bb1c2 !important;
background: #e2edfa !important;
}
/* 表格 */
.table-section { padding: 0; }
.table-section { padding: 0 !important; }
.audit-table :deep(.el-table__inner-wrapper::before) { display: none; }
.audit-table :deep(.el-table__cell) {
padding: 8px 0;
@@ -748,17 +844,29 @@ onMounted(async () => {
}
.actor-cell {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.actor-name {
.actor-name,
.actor-account {
display: block;
font-size: 13px;
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;
@@ -807,6 +915,12 @@ onMounted(async () => {
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; }
@@ -1057,6 +1171,9 @@ onMounted(async () => {
@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>