feat(admin): 完善审计访问上下文与后台布局
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user