merge(dev): 同步审计访问上下文更新
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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(";") || "",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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(/^示例项目[::]/, ''),
|
||||
|
||||
@@ -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: "对象",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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