merge(dev): 同步审计访问上下文更新

This commit is contained in:
Cheng Zhou
2026-07-10 10:48:35 +08:00
20 changed files with 776 additions and 112 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
import { apiGet, apiPost } from "./axios";
import type { ApiListResponse } from "../types/api";
import type { ApiListResponse, AuditLogItem } from "../types/api";
export const fetchAuditLogs = (studyId: string, params?: Record<string, any>) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/audit-logs/`, { params });
apiGet<ApiListResponse<AuditLogItem>>(`/api/v1/studies/${studyId}/audit-logs/`, { params });
export const createAuditEvent = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/audit-logs/events`, payload);
+43 -1
View File
@@ -1,7 +1,49 @@
import { describe, expect, it } from "vitest";
import { normalizeAuditEvent } from ".";
import { normalizeAuditEvent, resolveAuditClientSourceLabel } from ".";
describe("normalizeAuditEvent", () => {
it("normalizes request source and IP context for access audit troubleshooting", () => {
const event = normalizeAuditEvent(
{
action: "UPDATE_SUBJECT",
detail: JSON.stringify({ targetName: "S001" }),
operator_id: "operator-1",
operator_name: "张三",
operator_email: "pm@example.com",
operator_role: "PM",
entity_type: "subject",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
client_ip: "203.0.113.10",
ip_country: "中国",
ip_province: "重庆",
ip_city: "渝中",
ip_isp: "电信",
client_type: "desktop",
client_version: "1.2.3",
client_platform: "macos",
build_channel: "release",
build_commit: "abcdef1",
user_agent: "CTMS Desktop",
created_at: "2026-07-09T10:00:00Z",
},
{}
);
expect(event.actorName).toBe("张三");
expect(event.actorAccount).toBe("pm@example.com");
expect(event.clientIp).toBe("203.0.113.10");
expect(event.ipLocation).toBe("重庆 / 渝中 / 电信");
expect(event.clientSourceLabel).toBe("桌面端 / macos");
expect(event.clientType).toBe("desktop");
expect(event.clientVersion).toBe("1.2.3");
expect(event.buildCommit).toBe("abcdef1");
});
it("labels unmarked automation clients as script or command-line sources", () => {
expect(resolveAuditClientSourceLabel({ user_agent: "curl/8.1.2" })).toBe("脚本/命令行");
expect(resolveAuditClientSourceLabel({ user_agent: "" })).toBe("未知来源");
});
it("removes UUID tokens from legacy audit detail text", () => {
const event = normalizeAuditEvent(
{
@@ -19,5 +19,9 @@ export const auditExportColumns: AuditExportColumn[] = [
{ key: "resultLabel", label: TEXT.audit.exportColumns.resultLabel },
{ key: "reason", label: TEXT.audit.exportColumns.reason },
{ key: "ip", label: TEXT.audit.exportColumns.ip },
{ key: "ipLocation", label: TEXT.audit.exportColumns.ipLocation },
{ key: "clientSource", label: TEXT.audit.exportColumns.clientSource },
{ key: "clientType", label: TEXT.audit.exportColumns.clientType },
{ key: "userAgent", label: TEXT.audit.exportColumns.userAgent },
{ key: "remark", label: TEXT.audit.exportColumns.remark },
];
@@ -43,7 +43,11 @@ export const formatAuditRow = (event: AuditEvent): AuditExportRow => {
afterStatus,
resultLabel: event.resultLabel || "",
reason: event.reason || "",
ip: "",
ip: event.clientIp || "",
ipLocation: event.ipLocation || "",
clientSource: event.clientSourceLabel || "",
clientType: [event.clientType, event.clientVersion, event.clientPlatform].filter(Boolean).join("/") || "",
userAgent: event.userAgent || "",
remark: event.diffText?.join("") || "",
};
};
+80 -1
View File
@@ -7,6 +7,7 @@ export interface AuditEvent {
eventLabel: string;
actorId: string;
actorName: string;
actorAccount?: string;
actorRole: string;
actorRoleLabel: string;
targetType: string;
@@ -21,6 +22,19 @@ export interface AuditEvent {
result: "SUCCESS" | "FAIL";
resultLabel: string;
timestamp: string;
clientIp?: string;
ipLocation?: string;
ipCountry?: string;
ipProvince?: string;
ipCity?: string;
ipIsp?: string;
clientType?: string;
clientVersion?: string;
clientPlatform?: string;
buildChannel?: string;
buildCommit?: string;
userAgent?: string;
clientSourceLabel?: string;
}
const entityTypeLabelMap: Record<string, string> = {
@@ -619,6 +633,57 @@ const buildDiffText = (
return lines;
};
const fallbackIpLocation = (ip: string | null | undefined): string => {
if (!ip) return "";
if (ip === "127.0.0.1" || ip === "::1") return "本机访问";
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/.test(ip)) return "内网地址";
return "";
};
export const formatAuditIpLocation = (raw: {
ip_country?: string | null;
ip_province?: string | null;
ip_city?: string | null;
ip_isp?: string | null;
ip_location?: string | null;
client_ip?: string | null;
clientIp?: string | null;
}): string => {
const parts = [
raw.ip_country && raw.ip_country !== "中国" ? raw.ip_country : "",
raw.ip_province,
raw.ip_city,
raw.ip_isp,
].filter(Boolean);
if (parts.length) return parts.join(" / ");
return raw.ip_location || fallbackIpLocation(raw.client_ip || raw.clientIp) || "";
};
const isAutomationUserAgent = (userAgent = ""): boolean =>
/curl|wget|python-requests|httpie|go-http-client|node-fetch|axios|postman|insomnia|okhttp|java|powershell|libwww/i.test(userAgent);
const isBrowserUserAgent = (userAgent = ""): boolean =>
/mozilla|chrome|safari|firefox|edg\//i.test(userAgent);
export const resolveAuditClientSourceLabel = (raw: {
client_type?: string | null;
client_platform?: string | null;
user_agent?: string | null;
clientType?: string | null;
clientPlatform?: string | null;
userAgent?: string | null;
}): string => {
const clientType = String(raw.client_type || raw.clientType || "").trim().toLowerCase();
const platform = String(raw.client_platform || raw.clientPlatform || "").trim();
const userAgent = String(raw.user_agent || raw.userAgent || "").trim();
if (clientType === "web") return "网页端";
if (clientType === "desktop") return platform ? `桌面端 / ${platform}` : "桌面端";
if (clientType) return `未知客户端 / ${clientType}`;
if (isAutomationUserAgent(userAgent)) return "脚本/命令行";
if (isBrowserUserAgent(userAgent)) return "浏览器(未标识)";
return "未知来源";
};
export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>): AuditEvent => {
const dict = auditDict[raw.action] || buildReadableAuditDict(raw.action, raw.entity_type);
let detailObj: any = {};
@@ -668,7 +733,8 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
eventType: raw.action,
eventLabel: dict.label,
actorId: raw.operator_id,
actorName: userMap[raw.operator_id] || raw.operator_id,
actorName: raw.operator_name || userMap[raw.operator_id] || raw.operator_id,
actorAccount: raw.operator_email || raw.operator_id,
actorRole: raw.operator_role,
actorRoleLabel: getDictLabel(roleDict, raw.operator_role),
targetType: raw.entity_type || "",
@@ -683,6 +749,19 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
result: detailObj.result || "SUCCESS",
resultLabel: detailObj.result === "FAIL" ? TEXT.audit.resultFail : TEXT.audit.resultSuccess,
timestamp: raw.created_at,
clientIp: raw.client_ip || "",
ipLocation: formatAuditIpLocation(raw),
ipCountry: raw.ip_country || "",
ipProvince: raw.ip_province || "",
ipCity: raw.ip_city || "",
ipIsp: raw.ip_isp || "",
clientType: raw.client_type || "",
clientVersion: raw.client_version || "",
clientPlatform: raw.client_platform || "",
buildChannel: raw.build_channel || "",
buildCommit: raw.build_commit || "",
userAgent: raw.user_agent || "",
clientSourceLabel: resolveAuditClientSourceLabel(raw),
};
};
@@ -73,9 +73,11 @@ describe("desktop layout shell", () => {
it("keeps web project switching routed through the workbench entry", () => {
const webLayout = readWebLayoutSource();
expect(webLayout).toContain('class="workbench-return-button"');
expect(webLayout).toContain('v-if="hasProjectContext" class="workbench-return-button"');
expect(webLayout).toContain('await router.push("/workbench")');
expect(webLayout).toContain("const showAdminNavigation = computed(() => Boolean(auth.user) && (!study.currentStudy || isAdminContext.value))");
expect(webLayout).toContain("const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);");
expect(webLayout).toContain('v-if="hasProjectContext && hasAnyProjectModuleAccess"');
expect(webLayout).toContain("const showAdminNavigation = computed(() => Boolean(auth.user) && !hasProjectContext.value)");
expect(webLayout).not.toContain("hasDropdown: true");
expect(webLayout).not.toContain("type: 'study'");
expect(webLayout).not.toContain('type: "study"');
+13 -12
View File
@@ -59,7 +59,7 @@
</el-sub-menu>
</el-menu-item-group>
<el-menu-item-group v-if="study.currentStudy && hasAnyProjectModuleAccess" class="menu-group">
<el-menu-item-group v-if="hasProjectContext && hasAnyProjectModuleAccess" class="menu-group">
<template #title>
<span class="menu-divider">{{ TEXT.menu.currentProject }}</span>
</template>
@@ -204,7 +204,7 @@
</div>
<div class="header-right">
<button v-if="study.currentStudy" class="workbench-return-button" type="button" @click="openWorkbenchEntry">
<button v-if="hasProjectContext" class="workbench-return-button" type="button" @click="openWorkbenchEntry">
<el-icon><Suitcase /></el-icon>
<span>工作台</span>
</button>
@@ -468,6 +468,8 @@ const headerReminderStats = ref({
const headerClockNow = ref(new Date());
let headerClockTimer: number | undefined;
let desktopMenuUnlisten: (() => void) | undefined;
const isAdminContext = computed(() => route.path.startsWith("/admin"));
const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy);
@@ -489,10 +491,9 @@ const userDisplayInitial = computed(() => {
return source.charAt(0).toUpperCase();
});
const isAdminContext = computed(() => route.path.startsWith("/admin"));
const showAdminNavigation = computed(() => Boolean(auth.user) && (!study.currentStudy || isAdminContext.value));
const canReadRiskIssueAes = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/sae"));
const canReadMonitoringIssues = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/monitoring-visits"));
const showAdminNavigation = computed(() => Boolean(auth.user) && !hasProjectContext.value);
const canReadRiskIssueAes = computed(() => hasProjectContext.value && canAccessProjectPath("/risk-issues/sae"));
const canReadMonitoringIssues = computed(() => hasProjectContext.value && canAccessProjectPath("/risk-issues/monitoring-visits"));
const hasAnyRiskReminderAccess = computed(() => canReadRiskIssueAes.value || canReadMonitoringIssues.value);
const toHeaderNumber = (value: unknown) => {
@@ -518,7 +519,7 @@ const headerClockText = computed(() => formatHeaderDateTime(headerClockNow.value
const loadHeaderOverviewStats = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || isAdminContext.value) {
if (!studyId || !hasProjectContext.value) {
headerOverviewStats.value = null;
return;
}
@@ -541,7 +542,7 @@ const loadHeaderOverviewStats = async () => {
const loadHeaderReminders = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || isAdminContext.value || !hasAnyRiskReminderAccess.value) {
if (!studyId || !hasProjectContext.value || !hasAnyRiskReminderAccess.value) {
headerReminderStats.value = { overdueAes: 0, overdueMonitoringIssues: 0 };
return;
}
@@ -602,7 +603,7 @@ const headerReminderBadgeValue = computed(() =>
// 顶栏中间区:项目上下文(状态 + 关键进度 + 数据时间)
const headerProjectInfo = computed(() => {
if (isAdminContext.value || !study.currentStudy) return null;
if (!hasProjectContext.value || !study.currentStudy) return null;
const s = study.currentStudy;
const status = (s.status || "").toUpperCase();
const statusLabel = (TEXT.enums.projectStatus as Record<string, string>)[status] || s.status || "";
@@ -640,7 +641,7 @@ const headerProjectInfo = computed(() => {
});
const accountProjectRoleLabel = computed(() => {
if (isAdminContext.value || !study.currentStudy) return "";
if (!hasProjectContext.value || !study.currentStudy) return "";
const roleCode = (projectRole.value || "").toUpperCase();
return roleCode ? ((TEXT.enums.userRole as Record<string, string>)[roleCode] || projectRole.value) : "";
});
@@ -673,7 +674,7 @@ const desktopNavigationItems = computed<DesktopNavigationItem[]>(() => {
}
}
if (study.currentStudy && hasAnyProjectModuleAccess.value) {
if (hasProjectContext.value && hasAnyProjectModuleAccess.value) {
const projectItems: DesktopNavigationItem[] = [
{ label: TEXT.menu.projectOverview, path: "/project/overview", group: TEXT.menu.currentProject, keywords: ["overview"] },
{ label: TEXT.menu.projectMilestones, path: "/project/milestones", group: TEXT.menu.currentProject, keywords: ["milestone"] },
@@ -896,7 +897,7 @@ const breadcrumbs = computed(() => {
});
return items;
}
} else if (study.currentStudy) {
} else if (hasProjectContext.value && study.currentStudy) {
// 项目内不提供横向切换项目;需要切换时先回工作台重新选择。
items.push({
label: (study.currentStudy.name || "").replace(/^示例项目[:]/, ''),
+8
View File
@@ -242,6 +242,10 @@ export const TEXT = {
resultLabel: "操作结果",
reason: "拒绝原因",
ip: "IP 地址",
ipLocation: "IP 位置",
clientSource: "请求来源",
clientType: "客户端标识",
userAgent: "User-Agent",
remark: "备注",
},
eventDict: {
@@ -926,6 +930,9 @@ export const TEXT = {
filterEntityType: "对象类型",
filterEvent: "操作类型",
filterOperator: "操作人",
filterSource: "来源",
filterIpLocation: "IP / 位置",
filterKeyword: "账号、IP、位置或来源",
filterResult: "结果",
rangeStart: "开始",
rangeEnd: "结束",
@@ -941,6 +948,7 @@ export const TEXT = {
columns: {
time: "时间",
actor: "操作人",
source: "请求来源",
event: "操作类型",
action: "内容",
target: "对象",
+26
View File
@@ -402,6 +402,32 @@ export interface SecurityAccessLogsResponse {
items: SecurityAccessLogItem[];
}
export interface AuditLogItem {
id: string;
study_id: string | null;
entity_type: string;
entity_id: string | null;
action: string;
detail: string | null;
operator_id: string;
operator_name: string | null;
operator_email: string | null;
operator_role: string;
client_ip: string | null;
ip_location: string;
ip_country: string;
ip_province: string;
ip_city: string;
ip_isp: string;
user_agent: string | null;
client_type: string | null;
client_version: string | null;
client_platform: string | null;
build_channel: string | null;
build_commit: string | null;
created_at: string;
}
// 系统监测 - 趋势数据
export interface TrendDataPoint {
bucket_time: string;
@@ -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;