feat(admin): 完善审计访问上下文与后台布局

This commit is contained in:
Cheng Zhou
2026-07-09 17:10:42 +08:00
parent 1dc10f569d
commit 0bbc125806
20 changed files with 776 additions and 112 deletions
@@ -67,6 +67,30 @@ describe("audit logs access", () => {
expect(source).not.toContain("limit: 2000");
});
it("surfaces request source, IP and location filters for audit troubleshooting", () => {
const source = readAuditLogsView();
expect(source).toContain("filters.clientType");
expect(source).toContain("filters.ipKeyword");
expect(source).toContain("TEXT.modules.adminAuditLogs.filterSource");
expect(source).toContain("TEXT.modules.adminAuditLogs.filterIpLocation");
expect(source).toContain("TEXT.modules.adminAuditLogs.columns.source");
expect(source).toContain("formatClientIpLine(scope.row)");
expect(source).toContain("client_ip: ipKeyword && isIpSearchToken(ipKeyword) ? ipKeyword : undefined");
});
it("includes request source context in audit exports", () => {
const columns = readFileSync(resolve(__dirname, "../../audit/export/auditExportColumns.ts"), "utf8");
const formatter = readFileSync(resolve(__dirname, "../../audit/export/auditExportFormatter.ts"), "utf8");
expect(columns).toContain('key: "ipLocation"');
expect(columns).toContain('key: "clientSource"');
expect(columns).toContain('key: "userAgent"');
expect(formatter).toContain("ip: event.clientIp || \"\"");
expect(formatter).toContain("clientSource: event.clientSourceLabel || \"\"");
expect(formatter).toContain("userAgent: event.userAgent || \"\"");
});
it("keeps the audit table compact by removing the duplicate content column", () => {
const source = readAuditLogsView();
+198 -34
View File
@@ -35,8 +35,8 @@
<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>
@@ -59,6 +59,22 @@
<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.clientType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterSource" @change="onServerFilterChange" class="filter-select-source">
<el-option v-for="opt in sourceTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</div>
<div class="filter-item-form">
<el-input
v-model="filters.ipKeyword"
clearable
:placeholder="TEXT.modules.adminAuditLogs.filterIpLocation"
class="filter-input-ip"
@input="onLocalFilterChange"
@clear="onServerFilterChange"
@change="onServerFilterChange"
/>
</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" />
@@ -105,6 +121,16 @@
<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 :label="TEXT.modules.adminAuditLogs.columns.source" width="230">
<template #default="scope">
<div class="source-cell">
<span class="source-main">{{ scope.row.clientSourceLabel }}</span>
<span class="source-meta">{{ formatClientMeta(scope.row) }}</span>
<span class="source-ip">{{ formatClientIpLine(scope.row) }}</span>
</div>
</template>
</el-table-column>
@@ -211,6 +237,34 @@
<span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span>
</div>
</div>
<div class="detail-meta-grid">
<div class="meta-card">
<span class="meta-label">请求来源</span>
<span class="meta-value">{{ selectedLog.clientSourceLabel || TEXT.common.fallback }}</span>
</div>
<div class="meta-card">
<span class="meta-label">来源 IP</span>
<span class="meta-value">{{ selectedLog.clientIp || "未知 IP" }}</span>
</div>
<div class="meta-card">
<span class="meta-label">IP 位置</span>
<span class="meta-value">{{ selectedLog.ipLocation || TEXT.common.fallback }}</span>
</div>
<div class="meta-card">
<span class="meta-label">客户端</span>
<span class="meta-value">{{ formatClientMeta(selectedLog) }}</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"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 0 20"/><path d="M12 2a15.3 15.3 0 0 0 0 20"/></svg>
访问上下文
</div>
<div class="context-lines">
<div><span>User-Agent</span><code>{{ selectedLog.userAgent || TEXT.common.fallback }}</code></div>
<div><span>构建信息</span><code>{{ formatBuildMeta(selectedLog) }}</code></div>
</div>
</div>
<!-- 变更明细 -->
<div class="detail-section">
@@ -265,7 +319,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";
@@ -296,13 +349,15 @@ const DIFF_PREVIEW_LIMIT = 2;
const filters = ref({
eventType: "",
operatorId: "",
clientType: "",
ipKeyword: "",
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 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 formatDiffLine = (line: any): string => {
if (typeof line === 'string') return line;
@@ -375,6 +430,11 @@ const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
value,
label: cfg.label,
}));
const sourceTypeOptions = [
{ value: "web", label: "网页端" },
{ value: "desktop", label: "桌面端" },
{ value: "unknown", label: "未知/异常" },
];
const resolveUserDisplayName = (u: any): string => {
return u?.full_name || u?.display_name || u?.username || u?.email || u?.id || TEXT.common.fallback;
};
@@ -470,6 +530,34 @@ const fetchAuditLogPages = async (baseParams: Record<string, any> = {}) => {
return allItems;
};
const isIpSearchToken = (value: string) => /^[0-9a-f:.]+$/i.test(value.trim());
const buildAuditServerParams = () => {
const ipKeyword = String(filters.value.ipKeyword || "").trim();
return {
action: filters.value.eventType || undefined,
operator_id: filters.value.operatorId || undefined,
client_type: filters.value.clientType || undefined,
client_ip: ipKeyword && isIpSearchToken(ipKeyword) ? ipKeyword : undefined,
};
};
const formatClientMeta = (log: any) => {
const meta = [log.clientType, log.clientVersion, log.clientPlatform].filter(Boolean).join("/");
if (meta) return meta;
return log.userAgent ? "未标识客户端" : TEXT.common.fallback;
};
const formatBuildMeta = (log: any) => {
const meta = [log.buildChannel, log.buildCommit].filter(Boolean).join("/");
return meta || TEXT.common.fallback;
};
const formatClientIpLine = (log: any) => {
const parts = [log.clientIp || "未知 IP", log.ipLocation].filter(Boolean);
return parts.join(" / ");
};
const loadLogs = async () => {
if (!selectedStudyId.value) return;
loading.value = true;
@@ -477,10 +565,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,20 +583,36 @@ 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[]) =>
items.filter((log) => {
if (filters.value.result && (log.result || "SUCCESS") !== filters.value.result) return false;
const ipKeyword = String(filters.value.ipKeyword || "").trim().toLowerCase();
if (ipKeyword) {
const haystack = [
log.clientIp,
log.ipLocation,
log.ipCountry,
log.ipProvince,
log.ipCity,
log.ipIsp,
log.clientSourceLabel,
log.clientType,
log.clientPlatform,
log.userAgent,
log.actorName,
log.actorAccount,
];
if (!haystack.some((value) => String(value || "").toLowerCase().includes(ipKeyword))) return false;
}
if (filters.value.range?.length === 2) {
const ts = new Date(log.timestamp);
const start = new Date(filters.value.range[0]);
@@ -582,24 +683,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 [];
@@ -701,7 +790,7 @@ onMounted(async () => {
width: 100%;
gap: 8px;
align-items: center;
flex-wrap: nowrap;
flex-wrap: wrap;
}
.filter-item-form {
@@ -712,9 +801,11 @@ onMounted(async () => {
.filter-select-comp { width: 132px; }
.filter-select-project { width: 180px; }
.filter-select-source { width: 112px; }
.filter-input-ip { width: 170px; }
.filter-select-result { width: 96px; }
.date-range-picker-comp { width: 248px !important; }
.filter-spacer { flex: 1; }
.filter-spacer { flex: 1; min-width: 16px; }
.audit-export-dropdown { flex-shrink: 0; }
.audit-export-btn {
@@ -748,12 +839,56 @@ onMounted(async () => {
}
.actor-cell {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.actor-name {
.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);
}
.source-cell {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.source-main {
display: inline-flex;
align-self: flex-start;
max-width: 100%;
padding: 1px 7px;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
color: #155e75;
background: #ecfeff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-meta,
.source-ip {
display: block;
font-size: 11px;
color: var(--ctms-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -923,6 +1058,35 @@ onMounted(async () => {
color: var(--ctms-primary);
}
.context-lines {
display: flex;
flex-direction: column;
gap: 8px;
}
.context-lines div {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 10px;
align-items: start;
}
.context-lines span {
color: var(--ctms-text-secondary);
font-size: 12px;
}
.context-lines code {
display: block;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--ctms-border-color);
background: var(--ctms-neutral-100);
color: var(--ctms-text-main);
white-space: pre-wrap;
word-break: break-all;
}
.diff-count {
font-size: 11px;
font-weight: 600;