feat(监控): 完善系统监控、登录状态与访问审计能力
This commit is contained in:
@@ -33,6 +33,11 @@ export type LoginKeyResponse = {
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
export type SessionHeartbeatResponse = {
|
||||
status: "online";
|
||||
last_seen_at: string;
|
||||
};
|
||||
|
||||
export type EncryptedPasswordRequest = {
|
||||
key_id: string;
|
||||
challenge: string;
|
||||
@@ -53,4 +58,18 @@ export const extendToken = (token: string): Promise<AxiosResponse<ExtendResponse
|
||||
export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> =>
|
||||
authClient.get<LoginKeyResponse>("/api/v1/auth/login-key");
|
||||
|
||||
export const heartbeatSession = (token: string): Promise<AxiosResponse<SessionHeartbeatResponse>> =>
|
||||
authClient.post<SessionHeartbeatResponse>(
|
||||
"/api/v1/auth/session/heartbeat",
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${token}` }, timeout: 5000 },
|
||||
);
|
||||
|
||||
export const logoutSession = (token: string): Promise<AxiosResponse<void>> =>
|
||||
authClient.post<void>(
|
||||
"/api/v1/auth/session/logout",
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
|
||||
export default authClient;
|
||||
|
||||
@@ -78,6 +78,9 @@ export const fetchAccessLogs = (params: {
|
||||
allowed?: boolean;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
client_ip?: string;
|
||||
client_type?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) => apiGet<AccessLogsResponse>(`/api/v1/permission-monitoring/access-logs`, { params });
|
||||
@@ -87,6 +90,13 @@ export const fetchSecurityAccessLogs = (params?: {
|
||||
auth_status?: string;
|
||||
client_type?: string;
|
||||
client_version?: string;
|
||||
client_ip?: string;
|
||||
category?: string;
|
||||
severity?: string;
|
||||
keyword?: string;
|
||||
events_only?: boolean;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) => apiGet<SecurityAccessLogsResponse>(`/api/v1/permission-monitoring/security-logs`, { params });
|
||||
@@ -97,7 +107,7 @@ export const fetchPermissionTrends = (period: "24h" | "7d" | "30d" = "24h") =>
|
||||
export const fetchTopDenied = (params?: { days?: number; limit?: number }) =>
|
||||
apiGet<TopDeniedResponse>(`/api/v1/permission-monitoring/top-denied`, { params });
|
||||
|
||||
export const fetchIpLocations = (params?: { days?: number; limit?: number }) =>
|
||||
export const fetchIpLocations = (params?: { days?: number; all_time?: boolean; limit?: number }) =>
|
||||
apiGet<IpLocationsResponse>(`/api/v1/permission-monitoring/ip-locations`, { params });
|
||||
|
||||
export const fetchStatsSummary = () =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiGet, apiPatch, apiPost } from "./axios";
|
||||
import type { ApiListResponse, UserInfo } from "../types/api";
|
||||
import type { ApiListResponse, UserInfo, UserLoginActivity } from "../types/api";
|
||||
import { apiDelete } from "./axios";
|
||||
|
||||
export const fetchUsers = (params?: Record<string, any>) =>
|
||||
@@ -21,3 +21,8 @@ export const updateUser = (
|
||||
|
||||
export const deleteUser = (userId: string) =>
|
||||
apiDelete<void>(`/api/v1/users/${userId}`, { suppressErrorMessage: true });
|
||||
|
||||
export const fetchUserLoginActivities = (userId: string, limit = 30) =>
|
||||
apiGet<UserLoginActivity[]>(`/api/v1/users/${userId}/login-activities`, {
|
||||
params: { limit },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = (path: string) => readFileSync(resolve(__dirname, path), "utf8");
|
||||
|
||||
describe("account connection status", () => {
|
||||
it("keeps the compact indicator and detailed account diagnostics in both shells", () => {
|
||||
const component = readSource("./AccountConnectionStatus.vue");
|
||||
const webLayout = readSource("./WebLayout.vue");
|
||||
const desktopLayout = readSource("./DesktopLayout.vue");
|
||||
|
||||
expect(component).toContain('mode === \'indicator\'');
|
||||
expect(component).toContain("当前账号与连接状态");
|
||||
expect(component).toContain("API 延迟");
|
||||
expect(component).toContain("会话状态");
|
||||
expect(component).toContain("不代表系统健康评分");
|
||||
expect(component).not.toContain("ip_address");
|
||||
expect(component).not.toContain("access_token");
|
||||
expect(webLayout).toContain('<AccountConnectionStatus mode="indicator" />');
|
||||
expect(webLayout).toContain('<AccountConnectionStatus mode="details" />');
|
||||
expect(desktopLayout).toContain('<AccountConnectionStatus mode="indicator" />');
|
||||
expect(desktopLayout).toContain('<AccountConnectionStatus mode="details" />');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<span
|
||||
v-if="mode === 'indicator'"
|
||||
class="account-connection-indicator"
|
||||
:class="`is-${status}`"
|
||||
:title="`账号会话:${statusLabel}`"
|
||||
>
|
||||
<i class="account-connection-dot"></i>
|
||||
<span>{{ statusLabel }}</span>
|
||||
</span>
|
||||
|
||||
<div v-else class="account-connection-details" aria-label="当前账号与连接状态" @click.stop>
|
||||
<div class="connection-details-head">
|
||||
<span class="connection-details-title">当前账号</span>
|
||||
<span class="connection-state-badge" :class="`is-${status}`">
|
||||
<i class="account-connection-dot"></i>
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<strong class="connection-account-name">{{ userDisplayName }}</strong>
|
||||
<span class="connection-account-email">{{ auth.user?.email || "未提供邮箱" }}</span>
|
||||
|
||||
<dl class="connection-details-grid">
|
||||
<div>
|
||||
<dt>角色</dt>
|
||||
<dd>{{ roleLabel }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>客户端</dt>
|
||||
<dd>{{ clientLabel }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>运行环境</dt>
|
||||
<dd>{{ environmentLabel }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>会话状态</dt>
|
||||
<dd>{{ sessionRemainingLabel }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>API 延迟</dt>
|
||||
<dd>{{ latencyLabel }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>最近通信</dt>
|
||||
<dd>{{ lastCommunicationLabel }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="connection-details-note">连接状态仅表示会话与后端通信,不代表系统健康评分。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted } from "vue";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useSessionStore } from "../store/session";
|
||||
import { getAppMetadata, isTauriRuntime } from "../runtime";
|
||||
import { IDLE_TIMEOUT_MINUTES } from "../session/sessionManager";
|
||||
import { parseJwtExp } from "../session/jwt";
|
||||
import { useConnectionStatus } from "../composables/useConnectionStatus";
|
||||
|
||||
withDefaults(defineProps<{ mode?: "indicator" | "details" }>(), {
|
||||
mode: "indicator",
|
||||
});
|
||||
|
||||
const auth = useAuthStore();
|
||||
const session = useSessionStore();
|
||||
const isDesktop = isTauriRuntime();
|
||||
const appMetadata = getAppMetadata();
|
||||
const {
|
||||
status,
|
||||
statusLabel,
|
||||
latencyMs,
|
||||
lastSuccessAt,
|
||||
lastCheckedAt,
|
||||
startConnectionMonitor,
|
||||
} = useConnectionStatus();
|
||||
let stopConnectionMonitor: (() => void) | undefined;
|
||||
|
||||
const userDisplayName = computed(
|
||||
() => auth.user?.full_name || auth.user?.username || auth.user?.email || "当前用户",
|
||||
);
|
||||
const roleLabel = computed(() => (auth.user?.is_admin ? "系统管理员" : "普通账号"));
|
||||
const clientLabel = computed(() => {
|
||||
if (!isDesktop) return "网页端";
|
||||
const platform = appMetadata.platform === "macos" ? "macOS" : appMetadata.platform || "桌面系统";
|
||||
return `桌面端 · ${platform}`;
|
||||
});
|
||||
const environmentLabel = computed(() =>
|
||||
import.meta.env.VITE_RUNTIME_ENV === "production" ? "生产环境" : "开发环境",
|
||||
);
|
||||
const effectiveNow = computed(() => Math.max(lastCheckedAt.value || 0, Date.now()));
|
||||
const sessionDeadlineAt = computed(() => {
|
||||
const tokenExpiryAt = auth.token ? parseJwtExp(auth.token) : null;
|
||||
if (!tokenExpiryAt) return null;
|
||||
if (isDesktop) return tokenExpiryAt;
|
||||
return Math.min(tokenExpiryAt, session.lastUserActiveAt + IDLE_TIMEOUT_MINUTES * 60 * 1000);
|
||||
});
|
||||
const sessionRemainingLabel = computed(() => {
|
||||
if (!auth.token) return "未登录";
|
||||
if (!sessionDeadlineAt.value) return "有效";
|
||||
const remainingMs = sessionDeadlineAt.value - effectiveNow.value;
|
||||
if (remainingMs <= 0) return "即将失效";
|
||||
const totalMinutes = Math.max(1, Math.ceil(remainingMs / 60_000));
|
||||
if (totalMinutes < 60) return `剩余 ${totalMinutes} 分钟`;
|
||||
const totalHours = Math.floor(totalMinutes / 60);
|
||||
if (totalHours < 24) return `剩余 ${totalHours} 小时`;
|
||||
return `剩余 ${Math.floor(totalHours / 24)} 天`;
|
||||
});
|
||||
const latencyLabel = computed(() => (latencyMs.value === null ? "--" : `${latencyMs.value} ms`));
|
||||
const lastCommunicationLabel = computed(() => {
|
||||
if (!lastSuccessAt.value) return status.value === "checking" ? "检测中" : "暂无成功记录";
|
||||
return new Date(lastSuccessAt.value).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
stopConnectionMonitor = startConnectionMonitor();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopConnectionMonitor?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.account-connection-indicator,
|
||||
.connection-state-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-connection-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: 0 0 7px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.13);
|
||||
}
|
||||
|
||||
.is-online .account-connection-dot {
|
||||
background: #22a663;
|
||||
box-shadow: 0 0 0 3px rgba(34, 166, 99, 0.13);
|
||||
}
|
||||
|
||||
.is-degraded .account-connection-dot {
|
||||
background: #d99b21;
|
||||
box-shadow: 0 0 0 3px rgba(217, 155, 33, 0.14);
|
||||
}
|
||||
|
||||
.is-offline .account-connection-dot {
|
||||
background: #e05252;
|
||||
box-shadow: 0 0 0 3px rgba(224, 82, 82, 0.14);
|
||||
}
|
||||
|
||||
.account-connection-details {
|
||||
box-sizing: border-box;
|
||||
width: 290px;
|
||||
padding: 12px 14px 11px;
|
||||
color: #273449;
|
||||
}
|
||||
|
||||
.connection-details-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.connection-details-title {
|
||||
color: #7a8aa0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.connection-state-badge {
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: #f3f6f9;
|
||||
}
|
||||
|
||||
.connection-account-name,
|
||||
.connection-account-email {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.connection-account-name {
|
||||
color: #172033;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.connection-account-email {
|
||||
margin-top: 2px;
|
||||
color: #7a8aa0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.connection-details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px 12px;
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid #edf1f5;
|
||||
border-bottom: 1px solid #edf1f5;
|
||||
}
|
||||
|
||||
.connection-details-grid div,
|
||||
.connection-details-grid dt,
|
||||
.connection-details-grid dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.connection-details-grid dt {
|
||||
margin-bottom: 2px;
|
||||
color: #91a0b3;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.connection-details-grid dd {
|
||||
overflow: hidden;
|
||||
color: #334155;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.connection-details-note {
|
||||
margin: 9px 0 0;
|
||||
color: #8a98aa;
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .account-connection-details {
|
||||
color: #dbe5f1;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .connection-account-name,
|
||||
:global([data-ctms-theme="dark"]) .connection-details-grid dd {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .connection-details-grid {
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .connection-state-badge {
|
||||
background: #243247;
|
||||
}
|
||||
</style>
|
||||
@@ -246,10 +246,12 @@
|
||||
<button class="account-button" type="button">
|
||||
<span class="account-avatar">{{ userDisplayInitial }}</span>
|
||||
<span class="account-name">{{ userDisplayName }}</span>
|
||||
<AccountConnectionStatus mode="indicator" />
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<AccountConnectionStatus mode="details" />
|
||||
<el-dropdown-item command="profile">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ TEXT.menu.profile }}</span>
|
||||
@@ -496,6 +498,7 @@ import { useDesktopShortcuts } from "../composables/useDesktopShortcuts";
|
||||
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
|
||||
import type { DesktopCommand } from "../types/desktopCommands";
|
||||
import DesktopCommandPalette from "./DesktopCommandPalette.vue";
|
||||
import AccountConnectionStatus from "./AccountConnectionStatus.vue";
|
||||
import DesktopPreferences from "../views/DesktopPreferences.vue";
|
||||
import ProfileSettings from "../views/ProfileSettings.vue";
|
||||
import {
|
||||
@@ -1993,7 +1996,7 @@ useDesktopShortcuts(
|
||||
|
||||
.account-button {
|
||||
gap: 7px;
|
||||
max-width: 188px;
|
||||
max-width: 228px;
|
||||
padding: 0 8px 0 3px;
|
||||
}
|
||||
|
||||
@@ -2011,7 +2014,7 @@ useDesktopShortcuts(
|
||||
}
|
||||
|
||||
.account-name {
|
||||
max-width: 110px;
|
||||
max-width: 96px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -2321,6 +2324,9 @@ useDesktopShortcuts(
|
||||
}
|
||||
|
||||
.desktop-route-shell {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
@@ -475,6 +475,15 @@ describe("desktop layout shell", () => {
|
||||
expect(styles).toContain(":root[data-ctms-theme=\"dark\"] .desktop-workbench .desktop-route-shell .hero-stat");
|
||||
});
|
||||
|
||||
it("keeps routed desktop pages stretched to the content boundary", () => {
|
||||
const layout = readDesktopLayoutSource();
|
||||
|
||||
expect(layout).toContain(".desktop-route-shell {");
|
||||
expect(layout).toContain("width: 100%;");
|
||||
expect(layout).toContain("max-width: 100%;");
|
||||
expect(layout).toContain("box-sizing: border-box;");
|
||||
});
|
||||
|
||||
it("keeps authentication styles self-contained for desktop CSP", () => {
|
||||
const sources = [
|
||||
readMainStyleSource(),
|
||||
|
||||
@@ -15,18 +15,38 @@ describe("PermissionAccessLogs", () => {
|
||||
expect(source).toContain('parts.join(" / ")');
|
||||
});
|
||||
|
||||
it("renders user behavior audit instead of endpoint table or region detail", () => {
|
||||
it("renders access log audit with IP ranking as a dialog instead of side panels", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("访问日志");
|
||||
expect(source).toContain("accessLogDialogVisible");
|
||||
expect(source).toContain("accessLogDialogVisible");
|
||||
expect(source).toContain("accessDialogLines");
|
||||
expect(source).toContain("log-surface");
|
||||
expect(source).toContain("access-table");
|
||||
expect(source).toContain("filters.keyword");
|
||||
expect(source).toContain("clientSourceLabel");
|
||||
expect(source).toContain("IP访问排行");
|
||||
expect(source).toContain("接口访问审计日志");
|
||||
expect(source).toContain("terminal-lines");
|
||||
expect(source).toContain("keyword");
|
||||
expect(source).toContain("ipRankingDialogVisible");
|
||||
expect(source).toContain("openIpRankingDialog");
|
||||
expect(source).toContain("ipRankingPeriodOptions");
|
||||
expect(source).toContain('class="access-toolbar"');
|
||||
expect(source).toContain('class="access-toolbar-actions"');
|
||||
expect(source).toContain('@click="loadData">刷新');
|
||||
expect(source).not.toContain("来自 permission_access_logs");
|
||||
expect(source).not.toContain("安全访问只记录匿名、无效令牌和异常状态请求");
|
||||
expect(source).not.toContain("audit-ranking-section");
|
||||
expect(source).not.toContain("安全访问日志");
|
||||
expect(source).not.toContain("log-mode-card");
|
||||
expect(source).not.toContain("raw-log-panel");
|
||||
expect(source).not.toContain("终端式访问流水");
|
||||
expect(source).not.toContain("每行保留接口访问情况,便于快速扫日志");
|
||||
expect(source).not.toContain("来源地域明细");
|
||||
expect(source).not.toContain('prop="endpoint_key" label="接口"');
|
||||
expect(source).not.toContain("fetchIpLocations");
|
||||
expect(source).toContain("访问日志加载失败,表格中可能是上次成功获取的数据");
|
||||
expect(source).toContain("lastUpdatedAt");
|
||||
expect(source).not.toContain("logs.value = []");
|
||||
});
|
||||
|
||||
it("exposes QA and CTA in role filter presets", () => {
|
||||
@@ -47,24 +67,32 @@ describe("PermissionAccessLogs", () => {
|
||||
expect(source).not.toContain('label="医学审核" value="QA"');
|
||||
});
|
||||
|
||||
it("uses narrow metric cards without helper subtitles", () => {
|
||||
it("integrates compact metrics into the access overview without helper subtitles", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("min-height: 64px");
|
||||
expect(source).toContain("padding: 10px 16px");
|
||||
expect(source).toContain('class="access-overview"');
|
||||
expect(source).toContain('aria-label="访问指标"');
|
||||
expect(source).toContain("min-height: 44px");
|
||||
expect(source).toContain("padding: 5px 12px");
|
||||
expect(source).toContain("border-left: 1px solid #edf1f6");
|
||||
expect(source).not.toContain("<small>{{ card.hint }}</small>");
|
||||
expect(source).not.toContain("hint:");
|
||||
expect(source).not.toContain("筛选范围内行为总量");
|
||||
});
|
||||
|
||||
it("keeps audit grid widths consistent with SecurityCenter", () => {
|
||||
it("uses a full-width log layout instead of the old left-right split", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);");
|
||||
expect(source).toContain("access-log-card");
|
||||
expect(source).toContain("log-surface");
|
||||
expect(source).toContain("@media (max-width: 1100px)");
|
||||
expect(source).toContain("grid-template-columns: repeat(2, minmax(0, 1fr));");
|
||||
expect(source).toContain("@media (max-width: 720px)");
|
||||
expect(source).toContain("grid-template-columns: 1fr;");
|
||||
expect(source).not.toContain("audit-ranking-section");
|
||||
expect(source).not.toContain("grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));");
|
||||
expect(source).not.toContain("audit-grid");
|
||||
expect(source).not.toContain("grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);");
|
||||
});
|
||||
|
||||
it("removes the duplicated audit hero copy", () => {
|
||||
@@ -78,117 +106,254 @@ describe("PermissionAccessLogs", () => {
|
||||
expect(source).not.toContain("查看详细的接口访问与安全事件记录");
|
||||
});
|
||||
|
||||
it("streams terminal logs from top to bottom and refreshes in realtime", () => {
|
||||
it("keeps structured access rows compact and delegates long fields to details", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("terminalLogRows");
|
||||
expect(source).toContain("new Date(a.created_at).getTime() - new Date(b.created_at).getTime()");
|
||||
expect(source).toContain("REALTIME_POLL_INTERVAL_MS");
|
||||
expect(source).toContain("startRealtimePolling");
|
||||
expect(source).toContain("onBeforeUnmount(stopRealtimePolling)");
|
||||
expect(source).toContain('ref="terminalWindowRef"');
|
||||
expect(source).toContain("scrollTerminalToBottom");
|
||||
expect(source).toContain("terminal.scrollTop = terminal.scrollHeight");
|
||||
expect(source).toContain('label="时间" width="160"');
|
||||
expect(source).toContain('label="IP" width="120"');
|
||||
expect(source).toContain('label="位置" width="110"');
|
||||
expect(source).toContain('label="账号" min-width="160"');
|
||||
expect(source).toContain('label="请求" min-width="380"');
|
||||
expect(source).toContain('label="耗时" width="100"');
|
||||
expect(source).toContain('label="操作" width="64"');
|
||||
expect(source).toContain("account-cell");
|
||||
expect(source).toContain("request-cell");
|
||||
expect(source).toContain("elapsed-cell");
|
||||
expect(source).toContain("accountSecondary(row)");
|
||||
expect(source).toContain("requestMain(row)");
|
||||
expect(source).toContain("requestSub(row)");
|
||||
expect(source).toContain("row.elapsed_ms.toFixed(1)");
|
||||
expect(source).not.toContain('label="账号/IP"');
|
||||
expect(source).not.toContain('label="类型" width="88"');
|
||||
expect(source).not.toContain('label="来源" width="132"');
|
||||
expect(source).not.toContain('label="接口/角色"');
|
||||
expect(source).not.toContain('label="状态" width="104"');
|
||||
expect(source).not.toContain('label="账号" min-width="180"');
|
||||
expect(source).not.toContain('label="请求来源"');
|
||||
expect(source).not.toContain('label="结果" width="92"');
|
||||
});
|
||||
|
||||
it("surfaces source IPs in terminal logs and user ranking for network monitoring", () => {
|
||||
it("opens all raw access logs in the same dialog pattern as security logs", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("buildTerminalLine");
|
||||
expect(source).toContain("REALTIME_POLL_INTERVAL_MS");
|
||||
expect(source).toContain("startRealtimePolling");
|
||||
expect(source).toContain("document.addEventListener(\"visibilitychange\", onVisibilityChange)");
|
||||
expect(source).toContain("document.removeEventListener(\"visibilitychange\", onVisibilityChange)");
|
||||
expect(source).toContain("stopTimers()");
|
||||
expect(source).toContain("const REALTIME_POLL_INTERVAL_MS = 30_000;");
|
||||
expect(source).toContain('const timeRangePreset = ref<TimeRangePreset>("24h")');
|
||||
expect(source).toContain("accessLogDialogVisible");
|
||||
expect(source).toContain("accessTerminalWindowRef");
|
||||
expect(source).toContain("accessDialogLines.join");
|
||||
expect(source).toContain("openAccessLogDialog");
|
||||
expect(source).toContain("scrollAccessTerminalToBottom");
|
||||
expect(source).toContain("fetchAllRawAccessLogLines");
|
||||
expect(source).toContain("downloadInterfaceLog");
|
||||
expect(source).toContain("<el-dialog v-model=\"accessLogDialogVisible\"");
|
||||
expect(source).not.toContain('ref="terminalWindowRef"');
|
||||
expect(source).not.toContain("scrollTerminalToBottom");
|
||||
});
|
||||
|
||||
it("surfaces source IPs in table, terminal logs and the IP ranking dialog", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("ip=${ip}");
|
||||
expect(source).toContain("user.sample_ip_address || \"未知 IP\"");
|
||||
expect(source).toContain("ipRankingItems");
|
||||
expect(source).toContain('label="IP" width="120"');
|
||||
expect(source).toContain('label="位置" width="110"');
|
||||
expect(source).toContain("formatIpLocation(row)");
|
||||
expect(source).toContain("row.ip_address || \"未知 IP\"");
|
||||
expect(source).toContain("IP访问排行");
|
||||
expect(source).toContain("账号");
|
||||
expect(source).toContain("rank-location");
|
||||
expect(source).toContain("loadIpRankingData");
|
||||
expect(source).toContain("fetchAccessRowsForIpRanking");
|
||||
expect(source).toContain("buildIpRankingRows");
|
||||
expect(source).toContain('width="min(1180px, 96vw)"');
|
||||
expect(source).toContain("ip-rank-grid");
|
||||
expect(source).toContain("ip-rank-card");
|
||||
expect(source).toContain("rank-card-stats");
|
||||
expect(source).toContain("grid-template-columns: repeat(5, minmax(0, 1fr));");
|
||||
expect(source).toContain("rank-no");
|
||||
expect(source).toContain("riskDescriptorForRow");
|
||||
expect(source).toContain("row.riskLabel");
|
||||
expect(source).toContain("row.riskTagType");
|
||||
expect(source).toContain("riskRankCardClass");
|
||||
expect(source).toContain("risk-rank-card--danger");
|
||||
expect(source).toContain("risk-rank-card--warning");
|
||||
expect(source).toContain("risk-rank-card--info");
|
||||
expect(source).toContain("权限拒绝");
|
||||
expect(source).toContain("敏感探测");
|
||||
expect(source).toContain("TOP {{ ipRankingRows.length }}");
|
||||
expect(source).toContain("ranking-period-cards");
|
||||
expect(source).toContain("ranking-period-card");
|
||||
expect(source).toContain("selectIpRankingPeriod");
|
||||
expect(source).toContain("全部");
|
||||
expect(source).toContain("当前时段暂无风险访问");
|
||||
expect(source).not.toContain("ranking-period-group");
|
||||
expect(source).not.toContain("ipLine(row)");
|
||||
expect(source).not.toContain("user.sample_ip_address || \"未知 IP\"");
|
||||
expect(source).not.toContain("ipRankingItems");
|
||||
expect(source).not.toContain("ip-rank-row");
|
||||
expect(source).not.toContain("rank-location");
|
||||
expect(source).not.toContain('class="rank-risk-tag">异常IP');
|
||||
expect(source).not.toContain("TOP {{ userStats.length }}");
|
||||
expect(source).not.toContain("rank-ip-line");
|
||||
expect(source).not.toContain("来源IP");
|
||||
expect(source).not.toContain("去重IP");
|
||||
});
|
||||
|
||||
it("merges abnormal security IPs into the IP ranking and limits the list to top 10", () => {
|
||||
it("includes security events in the unified access log without a separate security dialog", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("const IP_RANKING_LIMIT = 10;");
|
||||
expect(source).toContain("const SECURITY_RANKING_PAGE_SIZE = 200;");
|
||||
expect(source).toContain("const ipRankingItems = computed<IpRankingItem[]>(() =>");
|
||||
expect(source).toContain("securityLogs.value.forEach((event) =>");
|
||||
expect(source).toContain('const isAbnormal = event.category === "ABNORMAL_IP";');
|
||||
expect(source).toContain('ABNORMAL_IP: "异常IP"');
|
||||
expect(source).toContain(".slice(0, IP_RANKING_LIMIT)");
|
||||
expect(source).toContain("TOP {{ ipRankingItems.length }}");
|
||||
expect(source).toContain('v-for="(item, index) in ipRankingItems"');
|
||||
expect(source).toContain("异常IP</el-tag>");
|
||||
});
|
||||
|
||||
it("sorts IP ranking by overall access total before risk labels", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("existing.total += user.total_count;");
|
||||
expect(source).toContain("existing.riskCount += user.denied_count;");
|
||||
expect(source).toContain("if (b.total !== a.total) return b.total - a.total;");
|
||||
expect(source.indexOf("if (b.total !== a.total) return b.total - a.total;")).toBeLessThan(
|
||||
source.indexOf("if (b.riskCount !== a.riskCount) return b.riskCount - a.riskCount;"),
|
||||
);
|
||||
expect(source.indexOf("if (b.total !== a.total) return b.total - a.total;")).toBeLessThan(
|
||||
source.indexOf("if (b.isAbnormal !== a.isAbnormal) return Number(b.isAbnormal) - Number(a.isAbnormal);"),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads all security pages for complete abnormal IP ranking data", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("const first = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: SECURITY_RANKING_PAGE_SIZE });");
|
||||
expect(source).toContain("const totalPages = Math.ceil(first.data.total / SECURITY_RANKING_PAGE_SIZE);");
|
||||
expect(source).toContain("for (let nextPage = 2; nextPage <= totalPages; nextPage += 1)");
|
||||
expect(source).toContain("allItems.push(...res.data.items);");
|
||||
expect(source).toContain("securityLogs.value = allItems;");
|
||||
expect(source).not.toContain("page_size: 80");
|
||||
});
|
||||
|
||||
it("renders low-level security access logs for anonymous and invalid requests", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("showSecurityLog");
|
||||
expect(source).toContain('v-if="showSecurityLog"');
|
||||
expect(source).toContain("fetchSecurityAccessLogs");
|
||||
expect(source).toContain("安全事件审计日志");
|
||||
expect(source).toContain("securityTerminalLines");
|
||||
expect(source).toContain("auth_status");
|
||||
expect(source).toContain("account_label");
|
||||
expect(source).toContain("client_ip");
|
||||
expect(source).toContain("status_code");
|
||||
expect(source).toContain("ANONYMOUS");
|
||||
expect(source).toContain("INVALID_TOKEN");
|
||||
expect(source).toContain("isSecurityLog");
|
||||
expect(source).toContain("安全事件");
|
||||
expect(source).toContain("业务访问");
|
||||
expect(source).toContain("SECURITY_CATEGORY_LABELS");
|
||||
expect(source).toContain("SECURITY_SEVERITY_LABELS");
|
||||
expect(source).toContain("endpointTitle(row)");
|
||||
expect(source).toContain("endpointMeta(row)");
|
||||
expect(source).toContain("accessResultLabel(row)");
|
||||
expect(source).toContain("authStatusLabel(row.auth_status)");
|
||||
expect(source).toContain("[SECURITY:");
|
||||
expect(source).not.toContain("showSecurityLog");
|
||||
expect(source).not.toContain('v-if="showSecurityLog"');
|
||||
expect(source).not.toContain("fetchSecurityAccessLogs");
|
||||
expect(source).not.toContain("安全访问日志");
|
||||
expect(source).not.toContain("记录匿名、无效令牌、拒绝和异常状态请求,用于排查未知 IP 攻击");
|
||||
expect(source).not.toContain("安全事件审计日志");
|
||||
expect(source).not.toContain("securityTerminalLines");
|
||||
expect(source).not.toContain("securityLogDialogVisible");
|
||||
expect(source).not.toContain("SecurityAccessLogItem");
|
||||
});
|
||||
|
||||
it("shows interface audit log total instead of current page line count", () => {
|
||||
it("shows total access logs in the table and access log dialog", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("{{ formatNumber(total) }} 条");
|
||||
expect(source).toContain("共 {{ formatNumber(total) }} 条");
|
||||
expect(source).toContain(":total=\"total\"");
|
||||
expect(source).toContain("v-model:page-size=\"pageSize\"");
|
||||
expect(source).toContain(":page-sizes=\"[50, 100, 200]\"");
|
||||
expect(source).toContain("accessLogDialogVisible");
|
||||
expect(source).not.toContain("{{ terminalLines.length }} 条");
|
||||
expect(source).not.toContain("最近 {{ terminalLines.length }} 条");
|
||||
});
|
||||
|
||||
it("opens both audit logs in realtime downloadable dialogs", () => {
|
||||
it("renders access rows directly and keeps raw access logs downloadable", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("interfaceLogDialogVisible");
|
||||
expect(source).toContain("securityLogDialogVisible");
|
||||
expect(source).toContain("openInterfaceLogDialog");
|
||||
expect(source).toContain("openSecurityLogDialog");
|
||||
expect(source).toContain("if (!showSecurityLog.value) return");
|
||||
expect(source).toContain("detailDrawerVisible");
|
||||
expect(source).toContain("selectedLog");
|
||||
expect(source).toContain("openLogDetail");
|
||||
expect(source).toContain("accessLogDialogVisible");
|
||||
expect(source).toContain("openAccessLogDialog");
|
||||
expect(source).toContain("downloadInterfaceLog");
|
||||
expect(source).toContain("downloadSecurityLog");
|
||||
expect(source).toContain("downloadLogFile");
|
||||
expect(source).toContain("<el-dialog v-model=\"interfaceLogDialogVisible\"");
|
||||
expect(source).toContain("<el-dialog v-model=\"securityLogDialogVisible\"");
|
||||
expect(source).toContain("accessLogDialogVisible");
|
||||
expect(source).toContain("<el-drawer v-model=\"detailDrawerVisible\"");
|
||||
expect(source).toContain("请求概览");
|
||||
expect(source).toContain("原始 HTTP 请求");
|
||||
expect(source).toContain("User-Agent");
|
||||
expect(source).toContain("更多审计信息");
|
||||
expect(source).toContain("日志标识");
|
||||
expect(source).toContain("传输信息");
|
||||
expect(source).toContain("客户端标识");
|
||||
expect(source).toContain("requestHeaderEntries(row)");
|
||||
expect(source).toContain("requestOverviewFieldItems(selectedLog)");
|
||||
expect(source).toContain("auditMetadataFieldItems(selectedLog)");
|
||||
expect(source).toContain("requestSnapshotFieldItems(selectedLog)");
|
||||
expect(source).toContain("buildRawRequestBlock(selectedLog)");
|
||||
expect(source).toContain("rawRequestBodyLine");
|
||||
expect(source).toContain("hasCapturedRequestSnapshot");
|
||||
expect(source).toContain("历史日志未采集原始请求快照");
|
||||
expect(source).toContain("legacy_permission_endpoint");
|
||||
expect(source).toContain("# audit_line: ${buildTerminalLine(row)}");
|
||||
expect(source).not.toContain("detailFieldItems(selectedLog)");
|
||||
expect(source).not.toContain("<h4>接口</h4>");
|
||||
expect(source).not.toContain("<h4>访问字段</h4>");
|
||||
expect(source).not.toContain("<h4>请求快照</h4>");
|
||||
expect(source).not.toContain("<h4>请求头</h4>");
|
||||
expect(source).not.toContain("requestHeaderEntries(selectedLog)");
|
||||
expect(source).not.toContain("未记录请求头");
|
||||
expect(source).not.toContain("原始请求快照(脱敏)");
|
||||
expect(source).not.toContain("原始日志行");
|
||||
expect(source).not.toContain("请求头(脱敏)");
|
||||
expect(source).toContain("<el-dialog v-model=\"accessLogDialogVisible\"");
|
||||
expect(source).toContain("下载日志");
|
||||
expect(source).toContain("securityTerminalWindowRef");
|
||||
expect(source).toContain("scrollSecurityTerminalToBottom");
|
||||
expect(source).not.toContain("securityLogDialogVisible");
|
||||
expect(source).not.toContain("openSecurityLogDialog");
|
||||
expect(source).not.toContain("downloadSecurityLog");
|
||||
expect(source).not.toContain("<el-dialog v-model=\"securityLogDialogVisible\"");
|
||||
expect(source).not.toContain("securityTerminalWindowRef");
|
||||
expect(source).not.toContain("scrollSecurityTerminalToBottom");
|
||||
expect(source).not.toContain("interfaceLogDialogVisible");
|
||||
});
|
||||
|
||||
it("exports all raw access logs across paginated backend pages", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("const ACCESS_LOG_EXPORT_PAGE_SIZE = 200;");
|
||||
expect(source).toContain("const rawExportLoading = ref(false);");
|
||||
expect(source).toContain("fetchAllRawAccessLogLines");
|
||||
expect(source).toContain("page_size: ACCESS_LOG_EXPORT_PAGE_SIZE");
|
||||
expect(source).toContain("const totalPages = Math.ceil(first.data.total / ACCESS_LOG_EXPORT_PAGE_SIZE);");
|
||||
expect(source).toContain("for (let nextPage = 2; nextPage <= totalPages; nextPage += 1)");
|
||||
expect(source).toContain("raw-access-logs.log");
|
||||
expect(source).not.toContain("interface-access-audit.log");
|
||||
});
|
||||
|
||||
it("supports account, location and client source filtering on the server query", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('placeholder="搜索账号、IP、属地、接口或来源"');
|
||||
expect(source).toContain("filter-time-presets");
|
||||
expect(source).toContain("timeRangePresetOptions");
|
||||
expect(source).toContain("onTimePresetChange");
|
||||
expect(source).toContain("buildTimeRangeFromPreset");
|
||||
expect(source).toContain("全部");
|
||||
expect(source).toContain("24小时");
|
||||
expect(source).toContain("7天");
|
||||
expect(source).toContain("30天");
|
||||
expect(source).toContain("clientSourceOptions");
|
||||
expect(source).toContain("filters.clientType");
|
||||
expect(source).toContain("params.client_type = filters.clientType");
|
||||
expect(source).toContain("params.keyword = keywordToken");
|
||||
expect(source).toContain("params.client_ip = keywordToken");
|
||||
expect(source).toContain("onKeywordInput");
|
||||
});
|
||||
|
||||
it("keeps access-log filter controls on one compact visual scale", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("--filter-control-height: 26px");
|
||||
expect(source).toContain("--filter-font-size: 12px");
|
||||
expect(source).toContain(".filter-time-presets :deep(.el-radio-button__inner)");
|
||||
expect(source).toContain(".audit-filters :deep(.el-select__wrapper)");
|
||||
expect(source).toContain(".audit-filters :deep(.el-input__wrapper)");
|
||||
expect(source).toContain('class="filter-reset"');
|
||||
expect(source).toContain("height: var(--filter-control-height)");
|
||||
expect(source).toContain("font-size: var(--filter-font-size)");
|
||||
});
|
||||
|
||||
it("does not render the manual date range filter in access logs", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).not.toContain("el-date-picker");
|
||||
expect(source).not.toContain("datetimerange");
|
||||
expect(source).not.toContain("dateRange");
|
||||
expect(source).not.toContain("onDateRangeChange");
|
||||
expect(source).not.toContain("filter-date");
|
||||
expect(source).not.toContain("开始时间");
|
||||
expect(source).not.toContain("结束时间");
|
||||
});
|
||||
|
||||
it("includes request headers in the access log item contract", () => {
|
||||
const source = readFileSync(resolve(__dirname, "../types/api.ts"), "utf8");
|
||||
|
||||
expect(source).toContain('event_type?: "permission" | "security" | string;');
|
||||
expect(source).toContain("request_headers: Record<string, string> | null;");
|
||||
expect(source).toContain("request_snapshot: RequestSnapshot | null;");
|
||||
expect(source).toContain("export interface RequestSnapshot");
|
||||
expect(source).toContain("query_params?: RequestSnapshotParam[] | null;");
|
||||
expect(source).toContain("status_code?: number | null;");
|
||||
expect(source).toContain("auth_status?: string | null;");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionIpLocations.vue"), "utf8");
|
||||
|
||||
describe("PermissionIpLocations", () => {
|
||||
it("integrates source metrics into a compact overview above the map", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('class="ip-overview"');
|
||||
expect(source).toContain('aria-label="来源指标"');
|
||||
expect(source).toContain('class="distribution-card"');
|
||||
expect(source).toContain("min-height: 44px");
|
||||
expect(source).toContain("grid-template-columns: minmax(0, 1fr) 300px");
|
||||
expect(source).toContain("padding: 10px");
|
||||
});
|
||||
|
||||
it("keeps compact metric grids responsive", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("@media (max-width: 1100px)");
|
||||
expect(source).toContain("@media (max-width: 520px)");
|
||||
expect(source).toContain(".metric-card:nth-child(odd)");
|
||||
});
|
||||
|
||||
it("uses the complete desktop viewport for fullscreen maps", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('modal-class="source-map-overlay"');
|
||||
expect(source).toContain(':option="fullscreenSourceMapOption"');
|
||||
expect(source).toContain("buildFlowMapOption(flowSourceData.value, \"ctms-world\", 1, \"全球访问链路\")");
|
||||
expect(source).toContain("buildFlowMapOption(chinaFlowSourceData.value, \"ctms-china\", 0.92, \"中国访问链路\")");
|
||||
expect(source).toContain(".source-map-overlay .source-map-dialog.el-dialog.is-fullscreen");
|
||||
expect(source).toContain("inset: 0 !important");
|
||||
expect(source).toContain("flex: 1 1 auto");
|
||||
expect(source).toContain("height: 100%;");
|
||||
expect(source).toContain("min-height: 48px");
|
||||
});
|
||||
|
||||
it("keeps the all-time label and request semantics consistent", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('all: "全部"');
|
||||
expect(source).toContain("all: undefined");
|
||||
expect(source).toContain('all_time: timeRangePreset.value === "all"');
|
||||
expect(source).not.toContain('all: "近 90 天"');
|
||||
});
|
||||
|
||||
it("keeps fullscreen animation sharp without multiplying large canvas layers", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('v-if="!fullscreenVisible"');
|
||||
expect(source).toContain("useDirtyRect: true");
|
||||
expect(source).toContain("Math.min(getDevicePixelRatio(), 2)");
|
||||
expect(source).not.toContain("getDevicePixelRatio() * 1.5");
|
||||
expect(source).not.toContain("zlevel:");
|
||||
expect(source).toContain("constantSpeed: 72");
|
||||
expect(source).toContain("trailLength: 0");
|
||||
expect(source).toContain('symbol: "circle"');
|
||||
expect(source).toContain('type: "scatter"');
|
||||
expect(source).toContain("chartAutoresize = { throttle: 120 }");
|
||||
});
|
||||
|
||||
it("does not classify foreign locations as abnormal by geography alone", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('mapMetric = ref<"traffic" | "risk">("traffic")');
|
||||
expect(source).toContain("riskColor(item.risk_level)");
|
||||
expect(source).not.toContain('item.country !== "中国"');
|
||||
});
|
||||
|
||||
it("uses backend coordinates and keeps unmapped locations in the ranking", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("item.longitude");
|
||||
expect(source).toContain("item.latitude");
|
||||
expect(source).toContain("chinaRankRows");
|
||||
expect(source).toContain('全部(保留期 ${periodMetadata.value.retention_days} 天)');
|
||||
expect(source).not.toContain('from "./worldMapSource"');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,19 +35,26 @@ vi.mock("@/api/projectPermissions", () => ({
|
||||
status: "healthy", health_score: 95, issues: [],
|
||||
sample_sufficient: true,
|
||||
score_breakdown: {
|
||||
performance: { weight: 40, deduction: 0 },
|
||||
performance: { weight: 25, deduction: 0 },
|
||||
deny_rate: { weight: 20, deduction: 0 },
|
||||
cache: {
|
||||
weight: 25, deduction: 5, sample_sufficient: true,
|
||||
scope: "sliding_window", window_seconds: 3600,
|
||||
},
|
||||
alerts: { weight: 15, deduction: 0 },
|
||||
alerts: { weight: 10, deduction: 0 },
|
||||
runtime: { weight: 20, deduction: 0 },
|
||||
},
|
||||
last_hour: {
|
||||
total_checks: 30, denied_checks: 2, deny_rate: 6.67,
|
||||
avg_elapsed_ms: 3.5, error_alerts: 0, warning_alerts: 0,
|
||||
},
|
||||
cache_stats: { cache_items: { total_count: 60 }, cache_metrics: {} },
|
||||
components: {
|
||||
database: { status: "healthy", latency_ms: 2.1 },
|
||||
permission_log_writer: { status: "healthy", running: true, queue_size: 0, queue_capacity: 10000, dropped_entries: 0 },
|
||||
security_log_writer: { status: "healthy", running: true, queue_size: 0, queue_capacity: 20000, dropped_entries: 0 },
|
||||
retention: { status: "healthy", running: true, access_log_retention_days: 90, metric_retention_days: 400 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }),
|
||||
@@ -98,11 +105,13 @@ describe("PermissionMonitoring.vue", () => {
|
||||
expect(typeof wrapper.vm.refresh).toBe("function");
|
||||
});
|
||||
|
||||
it("passes admin-only access to security logs", () => {
|
||||
it("keeps security events in security center instead of access logs", () => {
|
||||
const source = readFileSync(resolve(__dirname, "./PermissionMonitoring.vue"), "utf8");
|
||||
|
||||
expect(source).toContain("isAdmin?: boolean");
|
||||
expect(source).toContain(':show-security-log="props.isAdmin"');
|
||||
expect(source).toContain('<SecurityCenter v-if="props.isAdmin"');
|
||||
expect(source).toContain('<PermissionAccessLogs ref="accessLogsRef" />');
|
||||
expect(source).not.toContain(':show-security-log="props.isAdmin"');
|
||||
});
|
||||
|
||||
it("uses a full-height source-analysis tab without forcing scroll on other tabs", () => {
|
||||
@@ -133,24 +142,51 @@ describe("PermissionMonitoring.vue", () => {
|
||||
expect(source).toContain('label="运行概览"');
|
||||
expect(source).toContain('label="安全中心"');
|
||||
expect(source).toContain('label="性能趋势"');
|
||||
expect(source).toContain('label="访问审计"');
|
||||
expect(source).toContain('label="访问日志"');
|
||||
expect(source).toContain('label="来源分析"');
|
||||
expect(source).toContain('label="性能趋势" name="trends" lazy');
|
||||
expect(source).toContain('label="访问日志" name="logs" lazy');
|
||||
expect(source).toContain('label="来源分析" name="ip-locations" lazy');
|
||||
expect(source).toContain('<SecurityCenter v-if="props.isAdmin"');
|
||||
expect(source).toContain('name="security"');
|
||||
expect(source).toContain('label: "今日监测事件"');
|
||||
expect(source).toContain('label: "每分钟事件"');
|
||||
expect(source).toContain('label: "今日通过率"');
|
||||
expect(source).toContain('label: "历史事件总数"');
|
||||
expect(source).toContain('class="metric-strip"');
|
||||
expect(source).toContain('class="metric-strip-detail"');
|
||||
expect(source).toContain('detail: `历史 ${statsSummary.value.total_logs.toLocaleString()}`');
|
||||
expect(source).toContain("运行指标");
|
||||
expect(source).toContain('const formatResponseTime');
|
||||
expect(source).toContain('return "<0.1ms"');
|
||||
expect(source).toContain("业务样本不足");
|
||||
expect(source).toContain("组件在线不代表指标已完成评估");
|
||||
expect(source).toContain('class="overview-pane"');
|
||||
expect(source).toContain('class="overview-hero-card"');
|
||||
expect(source).toContain('class="operations-card"');
|
||||
expect(source).toContain('class="operations-grid"');
|
||||
expect(source).toContain('class="alerts-section"');
|
||||
expect(source).toContain("grid-template-columns: minmax(210px, 0.85fr)");
|
||||
expect(source).toContain('class="status-section runtime-section"');
|
||||
expect(source).toContain('class="empty-alert-state"');
|
||||
expect(source).toContain('const hasCacheSamples = computed');
|
||||
expect(source).toContain("异常拒绝排行");
|
||||
expect(source).toContain("告警数据加载失败,不能据此判断系统正常");
|
||||
expect(source).toContain("const formatUpdatedTime");
|
||||
expect(source).toContain("更新 {{ formatUpdatedTime(lastUpdatedAt) }}");
|
||||
expect(source).toContain("runtimeComponentItems");
|
||||
|
||||
expect(source).not.toContain('label="实时概览"');
|
||||
expect(source).not.toContain('label="趋势分析"');
|
||||
expect(source).not.toContain('label="访问日志"');
|
||||
expect(source).not.toContain('label="访问审计"');
|
||||
expect(source).not.toContain('label="IP属地"');
|
||||
expect(source).not.toContain('label: "今日总检查"');
|
||||
expect(source).not.toContain('label: "每分钟请求"');
|
||||
expect(source).not.toContain('label: "今日允许率"');
|
||||
expect(source).not.toContain('label: "历史总记录"');
|
||||
expect(source).not.toContain('label: "历史事件总数"');
|
||||
expect(source).not.toContain('class="summary-card"');
|
||||
expect(source).not.toContain('class="overview-toolbar"');
|
||||
expect(source).not.toContain("被拒绝最多的权限");
|
||||
expect(source).not.toContain("暂无告警,系统运行正常");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTrendCharts.vue"), "utf8");
|
||||
|
||||
describe("PermissionTrendCharts", () => {
|
||||
it("renders explicit empty states instead of zero-value charts when samples are unavailable", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("暂无有效响应时间样本");
|
||||
expect(source).toContain("暂无缓存访问样本");
|
||||
expect(source).toContain("暂无权限检查样本");
|
||||
expect(source).toContain("const hasResponseSamples = computed");
|
||||
expect(source).toContain("const hasCacheSamples = computed");
|
||||
});
|
||||
|
||||
it("sorts points chronologically and includes the date in 24-hour labels", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('String(d.getHours()).padStart(2, "0")');
|
||||
expect(source).toContain("dataPoints.value = [...res.data.data_points].sort(");
|
||||
expect(source).toContain("new Date(left.bucket_time).getTime() - new Date(right.bucket_time).getTime()");
|
||||
});
|
||||
|
||||
it("keeps period selection, update time and refresh together in the tab toolbar", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('class="toolbar-actions"');
|
||||
expect(source).toContain("最近成功更新:{{ formatLastUpdated(lastUpdatedAt) }}");
|
||||
expect(source).toContain('@click="loadData">刷新');
|
||||
});
|
||||
|
||||
it("uses compact chart surfaces while preserving a two-column desktop layout", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("padding: 0 12px 12px");
|
||||
expect(source).toContain("grid-template-columns: repeat(2, 1fr)");
|
||||
expect(source).toContain("min-height: 276px");
|
||||
expect(source).toContain("height: 212px");
|
||||
expect(source).toContain("gap: 10px");
|
||||
});
|
||||
});
|
||||
@@ -3,50 +3,72 @@
|
||||
<div class="trend-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="toolbar-title">系统性能趋势</span>
|
||||
<span v-if="lastUpdatedAt" class="ctms-updated-at">最近成功更新:{{ formatLastUpdated(lastUpdatedAt) }}</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<TimeRangeSelector v-model="timeRangePreset" @change="loadData" />
|
||||
<el-button size="small" :loading="loading" @click="loadData">刷新</el-button>
|
||||
</div>
|
||||
<el-radio-group v-model="period" size="small" @change="loadData">
|
||||
<el-radio-button value="24h">24小时</el-radio-button>
|
||||
<el-radio-button value="7d">7天</el-radio-button>
|
||||
<el-radio-button value="30d">30天</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="loadError" :title="loadError" type="warning" show-icon :closable="false" class="load-alert" />
|
||||
|
||||
<div v-loading="loading" class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<div class="chart-card" :class="{ 'is-empty': !hasEventSamples }">
|
||||
<div class="chart-header">
|
||||
<div class="chart-icon blue">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
</div>
|
||||
<h4>监测事件趋势</h4>
|
||||
<div class="chart-title-group">
|
||||
<h4>监测事件趋势</h4>
|
||||
<span>允许与拒绝</span>
|
||||
</div>
|
||||
<strong class="chart-latest">{{ latestEventLabel }}</strong>
|
||||
</div>
|
||||
<v-chart :option="checksChartOption" autoresize class="chart" />
|
||||
<v-chart v-if="hasEventSamples" :option="checksChartOption" autoresize class="chart" />
|
||||
<div v-else class="trend-empty-state">所选时段暂无监测事件</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-card" :class="{ 'is-empty': !hasResponseSamples }">
|
||||
<div class="chart-header">
|
||||
<div class="chart-icon cyan">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
</div>
|
||||
<h4>响应时间趋势</h4>
|
||||
<div class="chart-title-group">
|
||||
<h4>响应时间趋势</h4>
|
||||
<span>平均值与最大值</span>
|
||||
</div>
|
||||
<strong class="chart-latest">{{ latestResponseLabel }}</strong>
|
||||
</div>
|
||||
<v-chart :option="responseTimeOption" autoresize class="chart" />
|
||||
<v-chart v-if="hasResponseSamples" :option="responseTimeOption" autoresize class="chart" />
|
||||
<div v-else class="trend-empty-state">暂无有效响应时间样本</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-card" :class="{ 'is-empty': !hasCacheSamples }">
|
||||
<div class="chart-header">
|
||||
<div class="chart-icon green">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
</div>
|
||||
<h4>缓存命中率</h4>
|
||||
<div class="chart-title-group">
|
||||
<h4>缓存命中率</h4>
|
||||
<span>命中 / 总访问</span>
|
||||
</div>
|
||||
<strong class="chart-latest">{{ latestCacheLabel }}</strong>
|
||||
</div>
|
||||
<v-chart :option="cacheHitOption" autoresize class="chart" />
|
||||
<v-chart v-if="hasCacheSamples" :option="cacheHitOption" autoresize class="chart" />
|
||||
<div v-else class="trend-empty-state">暂无缓存访问样本</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-card" :class="{ 'is-empty': !hasDenySamples }">
|
||||
<div class="chart-header">
|
||||
<div class="chart-icon danger">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
|
||||
</div>
|
||||
<h4>异常拒绝率趋势</h4>
|
||||
<div class="chart-title-group">
|
||||
<h4>异常拒绝率趋势</h4>
|
||||
<span>拒绝事件占比</span>
|
||||
</div>
|
||||
<strong class="chart-latest">{{ latestDenyLabel }}</strong>
|
||||
</div>
|
||||
<v-chart :option="denyRateOption" autoresize class="chart" />
|
||||
<v-chart v-if="hasDenySamples" :option="denyRateOption" autoresize class="chart" />
|
||||
<div v-else class="trend-empty-state">暂无权限检查样本</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,21 +88,65 @@ import {
|
||||
} from "echarts/components";
|
||||
import { fetchPermissionTrends } from "@/api/projectPermissions";
|
||||
import type { TrendDataPoint } from "@/types/api";
|
||||
import TimeRangeSelector from "./TimeRangeSelector.vue";
|
||||
|
||||
use([CanvasRenderer, LineChart, BarChart, TitleComponent, TooltipComponent, GridComponent, LegendComponent]);
|
||||
|
||||
const period = ref<"24h" | "7d" | "30d">("24h");
|
||||
type TimeRangePreset = "all" | "24h" | "7d" | "30d";
|
||||
const timeRangePreset = ref<TimeRangePreset>("24h");
|
||||
const timeRangeOptions = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "24h", label: "24小时" },
|
||||
{ value: "7d", label: "7天" },
|
||||
{ value: "30d", label: "30天" },
|
||||
] as const;
|
||||
const loading = ref(false);
|
||||
const dataPoints = ref<TrendDataPoint[]>([]);
|
||||
const loadError = ref("");
|
||||
const lastUpdatedAt = ref<Date | null>(null);
|
||||
|
||||
const formatLastUpdated = (value: Date) => {
|
||||
return `${value.getFullYear()}/${value.getMonth() + 1}/${value.getDate()} ${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}:${String(value.getSeconds()).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const formatTime = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
if (period.value === "24h") return `${d.getHours()}:00`;
|
||||
if (timeRangePreset.value === "24h") return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, "0")}:00`;
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
};
|
||||
|
||||
const xLabels = computed(() => dataPoints.value.map((p: TrendDataPoint) => formatTime(p.bucket_time)));
|
||||
|
||||
const latestPoint = computed(() => dataPoints.value.at(-1) || null);
|
||||
const hasEventSamples = computed(() => dataPoints.value.some((point) => point.total_checks > 0));
|
||||
const hasResponseSamples = computed(() =>
|
||||
dataPoints.value.some((point) => point.avg_elapsed_ms > 0 || point.max_elapsed_ms > 0)
|
||||
);
|
||||
const hasCacheSamples = computed(() =>
|
||||
dataPoints.value.some((point) => point.cache_hits + point.cache_misses > 0)
|
||||
);
|
||||
const hasDenySamples = computed(() => dataPoints.value.some((point) => point.total_checks > 0));
|
||||
|
||||
const latestEventLabel = computed(() =>
|
||||
latestPoint.value && latestPoint.value.total_checks > 0
|
||||
? `${latestPoint.value.total_checks} 次`
|
||||
: "暂无样本"
|
||||
);
|
||||
const latestResponseLabel = computed(() =>
|
||||
latestPoint.value && (latestPoint.value.avg_elapsed_ms > 0 || latestPoint.value.max_elapsed_ms > 0)
|
||||
? `${latestPoint.value.avg_elapsed_ms.toFixed(1)} ms`
|
||||
: "暂无样本"
|
||||
);
|
||||
const latestCacheLabel = computed(() =>
|
||||
latestPoint.value && latestPoint.value.cache_hits + latestPoint.value.cache_misses > 0
|
||||
? `${latestPoint.value.cache_hit_rate.toFixed(1)}%`
|
||||
: "暂无样本"
|
||||
);
|
||||
const latestDenyLabel = computed(() => {
|
||||
if (!latestPoint.value || latestPoint.value.total_checks <= 0) return "暂无样本";
|
||||
return `${((latestPoint.value.denied_checks / latestPoint.value.total_checks) * 100).toFixed(1)}%`;
|
||||
});
|
||||
|
||||
const baseGridOption = {
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 25 },
|
||||
tooltip: { trigger: "axis" as const },
|
||||
@@ -186,7 +252,7 @@ const denyRateOption = computed(() => ({
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
data: dataPoints.value.map((p: TrendDataPoint) =>
|
||||
p.total_checks > 0 ? Math.round((p.denied_checks / p.total_checks) * 1000) / 10 : 0
|
||||
p.total_checks > 0 ? Math.round((p.denied_checks / p.total_checks) * 1000) / 10 : null
|
||||
),
|
||||
itemStyle: { color: "#ef4444" },
|
||||
lineStyle: { width: 2.5 },
|
||||
@@ -198,10 +264,15 @@ const denyRateOption = computed(() => ({
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetchPermissionTrends(period.value);
|
||||
dataPoints.value = res.data.data_points;
|
||||
const apiPeriod = timeRangePreset.value === "all" ? "30d" : timeRangePreset.value;
|
||||
const res = await fetchPermissionTrends(apiPeriod);
|
||||
dataPoints.value = [...res.data.data_points].sort(
|
||||
(left, right) => new Date(left.bucket_time).getTime() - new Date(right.bucket_time).getTime(),
|
||||
);
|
||||
loadError.value = "";
|
||||
lastUpdatedAt.value = new Date();
|
||||
} catch {
|
||||
dataPoints.value = [];
|
||||
loadError.value = "趋势数据加载失败,图表中可能是上次成功获取的数据";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -217,16 +288,21 @@ defineExpose({ refresh: loadData });
|
||||
--trend-ink: #1a2332;
|
||||
--trend-muted: #64748b;
|
||||
--trend-border: #e2e8f0;
|
||||
--trend-radius: 16px;
|
||||
--trend-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
--trend-radius: 14px;
|
||||
--trend-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 6px 18px rgba(15, 23, 42, 0.025);
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0 12px 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.trend-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
min-height: 48px;
|
||||
padding: 8px 14px;
|
||||
margin-bottom: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--trend-border);
|
||||
border-radius: var(--trend-radius);
|
||||
@@ -235,8 +311,8 @@ defineExpose({ refresh: loadData });
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
@@ -245,22 +321,37 @@ defineExpose({ refresh: loadData });
|
||||
color: var(--trend-ink);
|
||||
}
|
||||
|
||||
.toolbar-desc {
|
||||
font-size: 12px;
|
||||
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-updated-at {
|
||||
color: var(--trend-muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.load-alert {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--trend-border);
|
||||
border-radius: var(--trend-radius);
|
||||
padding: 18px;
|
||||
min-height: 276px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: var(--trend-shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
@@ -274,22 +365,22 @@ defineExpose({ refresh: loadData });
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chart-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chart-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.chart-icon.blue { background: #eff6ff; color: #3b82f6; }
|
||||
@@ -304,20 +395,80 @@ defineExpose({ refresh: loadData });
|
||||
color: var(--trend-ink);
|
||||
}
|
||||
|
||||
.chart-title-group {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chart-title-group span {
|
||||
color: var(--trend-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chart-latest {
|
||||
padding: 4px 7px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: var(--trend-ink);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chart {
|
||||
height: 240px;
|
||||
height: 212px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trend-empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 212px;
|
||||
border: 1px dashed #dbe3ef;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.trend-toolbar {
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
min-height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.trend-charts {
|
||||
padding: 0 8px 10px;
|
||||
}
|
||||
|
||||
.trend-toolbar,
|
||||
.toolbar-left {
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,21 +5,33 @@ import { resolve } from "node:path";
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./SecurityCenter.vue"), "utf8");
|
||||
|
||||
describe("SecurityCenter", () => {
|
||||
it("renders the security dashboard in the same audit-card style as access logs", () => {
|
||||
it("renders a compact security analysis summary in the same audit-card style as access logs", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("audit-filters");
|
||||
expect(source).toContain("audit-metrics");
|
||||
expect(source).toContain("audit-grid");
|
||||
expect(source).toContain("metric-card");
|
||||
expect(source).toContain("ranking-card");
|
||||
expect(source).toContain("security-analysis-card");
|
||||
expect(source).toContain("risk-preview-list");
|
||||
expect(source).toContain("risk-summary-panel");
|
||||
expect(source).toContain("risk-summary-title");
|
||||
expect(source).toContain("topRiskPreview");
|
||||
expect(source).toContain('class="security-overview"');
|
||||
expect(source).toContain('aria-label="安全指标"');
|
||||
expect(source).toContain('class="security-toolbar"');
|
||||
expect(source).toContain("timeRangePreset");
|
||||
expect(source).toContain("timeRangeOptions");
|
||||
expect(source).toContain("buildTimeRangeFromPreset");
|
||||
expect(source).toContain("onTimeRangeChange");
|
||||
expect(source).not.toContain("audit-grid");
|
||||
expect(source).not.toContain("ranking-card");
|
||||
expect(source).not.toContain("security-summary");
|
||||
expect(source).not.toContain("security-card");
|
||||
});
|
||||
|
||||
it("places filters inside the event details section instead of the page header", () => {
|
||||
const source = readSource();
|
||||
const tableIndex = source.indexOf('<section class="security-table-wrap">');
|
||||
const tableIndex = source.indexOf('<section ref="securityEventsSectionRef" class="security-table-wrap">');
|
||||
const filtersIndex = source.indexOf('<section class="audit-filters detail-filters">');
|
||||
|
||||
expect(tableIndex).toBeGreaterThan(-1);
|
||||
@@ -27,20 +39,32 @@ describe("SecurityCenter", () => {
|
||||
expect(source).not.toContain('<section class="audit-filters">\n <el-select');
|
||||
});
|
||||
|
||||
it("surfaces abnormal IP location ranking, endpoint type distribution and risk level distribution", () => {
|
||||
it("keeps risk levels on the page and opens abnormal sources in a compact ranking dialog", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("异常排行");
|
||||
expect(source).toContain("事件分布");
|
||||
expect(source).toContain("访问的接口类型");
|
||||
expect(source).toContain("异常分析");
|
||||
expect(source).toContain("查看异常分析");
|
||||
expect(source).toContain("analysisDialogVisible");
|
||||
expect(source).toContain("openAnalysisDialog");
|
||||
expect(source).toContain("jumpToSecurityEvents");
|
||||
expect(source).toContain("securityEventsSectionRef");
|
||||
expect(source).toContain("scrollIntoView");
|
||||
expect(source).toContain('<el-dialog v-model="analysisDialogVisible"');
|
||||
expect(source).toContain('width="min(1180px, 96vw)"');
|
||||
expect(source).toContain("analysis-ranking-dialog");
|
||||
expect(source).toContain("analysis-ranking-head");
|
||||
expect(source).toContain("endpoint-summary-chips");
|
||||
expect(source).toContain("analysis-rank-grid");
|
||||
expect(source).toContain("analysis-rank-card");
|
||||
expect(source).toContain("analysis-rank-card-stats");
|
||||
expect(source).toContain("查看事件明细");
|
||||
expect(source).not.toContain('<section class="analysis-dialog-panel');
|
||||
expect(source).toContain("风险等级");
|
||||
expect(source).toContain("event-distribution-card");
|
||||
expect(source).toContain("distribution-section");
|
||||
expect(source).toContain(".event-distribution-card > .section-head");
|
||||
expect(source).toContain("padding-top: 24px");
|
||||
expect(source).toContain("ipRiskStats");
|
||||
expect(source).toContain("endpointTypeStats");
|
||||
expect(source).toContain("riskLevelStats");
|
||||
expect(source.match(/v-for="item in riskLevelStats"/g)).toHaveLength(1);
|
||||
expect(source).not.toContain("compact-risk-section");
|
||||
expect(source).toContain("resolveEndpointType");
|
||||
expect(source).toContain("formatIpLocation");
|
||||
expect(source).toContain("fallbackIpLocation");
|
||||
@@ -49,11 +73,13 @@ describe("SecurityCenter", () => {
|
||||
expect(source).toContain("row.ip_isp");
|
||||
expect(source).toContain("item.location");
|
||||
expect(source).toContain("categoryText");
|
||||
expect(source).toContain("rank-main-line");
|
||||
expect(source).toContain("rank-meta-line");
|
||||
expect(source).toContain("analysis-rank-card-ip");
|
||||
expect(source).toContain("analysis-rank-card-meta");
|
||||
expect(source).toContain("lastSeenAt");
|
||||
expect(source).toContain("最后访问");
|
||||
expect(source).toContain("slice(0, 10)");
|
||||
expect(source).toContain("securitySummary.value.top_ips");
|
||||
expect(source).toContain("securitySummary.value.endpoint_type_counts");
|
||||
expect(source).toContain("securitySummary.value.severity_counts");
|
||||
expect(source).not.toContain("公网位置待解析");
|
||||
expect(source).not.toContain("异常IP(位置)排行");
|
||||
expect(source).not.toContain("risk-level-card");
|
||||
@@ -62,37 +88,84 @@ describe("SecurityCenter", () => {
|
||||
it("keeps detailed security events searchable and inspectable", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("detailFilteredEvents");
|
||||
expect(source).toContain("securityQueryParams");
|
||||
expect(source).toContain("keyword: keyword.value.trim() || undefined");
|
||||
expect(source).toContain("paginatedEvents");
|
||||
expect(source).toContain("openDetail");
|
||||
expect(source).toContain("安全事件明细");
|
||||
expect(source).toContain("security-table-wrap");
|
||||
expect(source).toContain('label="IP"');
|
||||
expect(source).toContain('label="位置"');
|
||||
expect(source).not.toContain('label="来源 IP"');
|
||||
expect(source).not.toContain('label="IP来源"');
|
||||
expect(source).toContain("搜索 IP、位置、账号、路径或 UA");
|
||||
expect(source).toContain("loadSecurityEvents");
|
||||
expect(source).toContain("安全事件加载失败,页面中可能是上次成功获取的数据");
|
||||
expect(source).toContain("lastUpdatedAt");
|
||||
expect(source).toContain("安全事件详情");
|
||||
expect(source).toContain("请求概览");
|
||||
expect(source).toContain("原始 HTTP 请求");
|
||||
expect(source).toContain("User-Agent");
|
||||
expect(source).toContain("构建信息");
|
||||
expect(source).toContain("更多审计信息");
|
||||
expect(source).toContain("日志标识");
|
||||
expect(source).toContain("传输信息");
|
||||
expect(source).toContain("客户端标识");
|
||||
expect(source).toContain("securityRequestOverviewFields(selectedEvent)");
|
||||
expect(source).toContain("securityAuditMetadataFields(selectedEvent)");
|
||||
expect(source).toContain("securityRequestSnapshotFields(selectedEvent)");
|
||||
expect(source).toContain("buildSecurityRawRequestBlock(selectedEvent)");
|
||||
expect(source).toContain("securityClientSourceLabel(selectedEvent)");
|
||||
expect(source).toContain("历史日志未采集原始请求快照");
|
||||
expect(source).toContain("历史日志未采集原始 HTTP 请求");
|
||||
expect(source).not.toContain("securityDetailFieldItems(selectedEvent)");
|
||||
expect(source).not.toContain("<h4>接口</h4>");
|
||||
expect(source).not.toContain("<h4>访问字段</h4>");
|
||||
expect(source).not.toContain("<h4>请求快照</h4>");
|
||||
expect(source).not.toContain("原始请求快照(脱敏)");
|
||||
expect(source).not.toContain("请求头(脱敏)");
|
||||
expect(source).not.toContain("未记录请求头");
|
||||
});
|
||||
|
||||
it("opens full security logs as a dialog inside security center", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("安全日志");
|
||||
expect(source).toContain("securityLogDialogVisible");
|
||||
expect(source).toContain("securityLogDialogLoading");
|
||||
expect(source).toContain("securityTerminalWindowRef");
|
||||
expect(source).toContain("securityTerminalLines");
|
||||
expect(source).toContain("buildSecurityTerminalLine");
|
||||
expect(source).toContain("openSecurityLogDialog");
|
||||
expect(source).toContain("scrollSecurityTerminalToBottom");
|
||||
expect(source).toContain("downloadSecurityLog");
|
||||
expect(source).toContain("saveFileWithFeedback");
|
||||
expect(source).toContain("securityRequestTarget(event)");
|
||||
expect(source).toContain("<el-dialog v-model=\"securityLogDialogVisible\"");
|
||||
expect(source).toContain("securityTerminalLines.join");
|
||||
expect(source).toContain("security-access-logs.log");
|
||||
expect(source).toContain("terminal-window dialog-terminal-window");
|
||||
});
|
||||
|
||||
it("labels abnormal IP events and paginates details like account management", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('{ label: "异常IP", value: "ABNORMAL_IP" }');
|
||||
expect(source).not.toContain('value: "ABNORMAL_IP"');
|
||||
expect(source).toContain('label: "风险来源 IP"');
|
||||
expect(source).toContain('v-model:current-page="page"');
|
||||
expect(source).toContain('v-model:page-size="pageSize"');
|
||||
expect(source).toContain(':page-sizes="[10, 20, 50]"');
|
||||
expect(source).toContain('layout="prev, pager, next, sizes, total"');
|
||||
});
|
||||
|
||||
it("calculates dashboard statistics from all events instead of detail filters or current page", () => {
|
||||
it("uses server-side pagination and aggregate facets instead of loading every event", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("statsEvents");
|
||||
expect(source).toContain("detailFilteredEvents");
|
||||
expect(source).toContain("securitySummary");
|
||||
expect(source).toContain("loadSecurityStats");
|
||||
expect(source).toContain("SECURITY_STATS_PAGE_SIZE");
|
||||
expect(source).toContain("statsEvents.value");
|
||||
expect(source).not.toContain("const uniqueIpCount = new Set(events.value");
|
||||
expect(source).not.toContain("filteredStatsEvents");
|
||||
expect(source).toContain("response.data.summary");
|
||||
expect(source).toContain("@current-change=\"loadSecurityEvents\"");
|
||||
expect(source).not.toContain("for (let nextPage = 2; nextPage <= totalPages; nextPage += 1) {\n const res = await fetchSecurityAccessLogs");
|
||||
expect(source).toContain("fetchAllSecurityEvents");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<el-radio-group
|
||||
:model-value="props.modelValue"
|
||||
size="small"
|
||||
@change="onChange"
|
||||
>
|
||||
<el-radio-button
|
||||
v-for="item in currentOptions"
|
||||
:key="item.value"
|
||||
:label="item.value"
|
||||
>{{ item.label }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
interface OptionItem {
|
||||
value: string | number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | number;
|
||||
options?: readonly OptionItem[] | OptionItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string | number): void;
|
||||
(e: "change", value: string | number): void;
|
||||
}>();
|
||||
|
||||
const defaultOptions = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "24h", label: "24小时" },
|
||||
{ value: "7d", label: "7天" },
|
||||
{ value: "30d", label: "30天" },
|
||||
] as const;
|
||||
|
||||
const currentOptions = computed(() => props.options || defaultOptions);
|
||||
|
||||
const onChange = (val: string | number) => {
|
||||
emit("update:modelValue", val);
|
||||
emit("change", val);
|
||||
};
|
||||
</script>
|
||||
@@ -316,10 +316,12 @@
|
||||
{{ userDisplayInitial }}
|
||||
</el-avatar>
|
||||
<span class="username">{{ userDisplayName }}</span>
|
||||
<AccountConnectionStatus mode="indicator" />
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="user-dropdown-menu">
|
||||
<AccountConnectionStatus mode="details" />
|
||||
<el-dropdown-item command="workbench">
|
||||
<el-icon><Suitcase /></el-icon>
|
||||
<span>工作台</span>
|
||||
@@ -429,6 +431,7 @@ import { dispatchDesktopRefreshCurrentView } from "../composables/useDesktopRefr
|
||||
import { useDesktopShortcuts } from "../composables/useDesktopShortcuts";
|
||||
import type { DesktopCommand } from "../types/desktopCommands";
|
||||
import DesktopCommandPalette from "./DesktopCommandPalette.vue";
|
||||
import AccountConnectionStatus from "./AccountConnectionStatus.vue";
|
||||
import ProfileSettings from "../views/ProfileSettings.vue";
|
||||
import DesktopPreferences from "../views/DesktopPreferences.vue";
|
||||
import { getActiveLayoutPath } from "./layout/navigation";
|
||||
@@ -2364,6 +2367,7 @@ useDesktopShortcuts(
|
||||
}
|
||||
|
||||
.ctms-route-shell {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
// 地图数据来源:https://unpkg.com/echarts@4.9.0/map/json/world.json
|
||||
// 组件直接导入本地缓存的 ECharts 世界地图 GeoJSON,避免运行时依赖外部网络。
|
||||
|
||||
import type { IpLocationStatItem } from "../types/api";
|
||||
|
||||
export const normalizeWorldCountryName = (value?: string | null) => {
|
||||
const country = String(value || "").trim();
|
||||
const aliases: Record<string, string> = {
|
||||
中国: "China",
|
||||
China: "China",
|
||||
"Mainland China": "China",
|
||||
中国香港: "China",
|
||||
中国澳门: "China",
|
||||
中国台湾: "China",
|
||||
美国: "United States",
|
||||
"United States": "United States",
|
||||
"United States of America": "United States",
|
||||
USA: "United States",
|
||||
Australia: "Australia",
|
||||
澳大利亚: "Australia",
|
||||
Japan: "Japan",
|
||||
日本: "Japan",
|
||||
Singapore: "Singapore",
|
||||
新加坡: "Singapore",
|
||||
Germany: "Germany",
|
||||
德国: "Germany",
|
||||
France: "France",
|
||||
法国: "France",
|
||||
"United Kingdom": "United Kingdom",
|
||||
英国: "United Kingdom",
|
||||
Canada: "Canada",
|
||||
加拿大: "Canada",
|
||||
India: "India",
|
||||
印度: "India",
|
||||
Russia: "Russia",
|
||||
俄罗斯: "Russia",
|
||||
};
|
||||
return aliases[country] || country;
|
||||
};
|
||||
|
||||
export const countryCoordinates: Record<string, [number, number]> = {
|
||||
China: [104.1954, 35.8617],
|
||||
"United States": [-95.7129, 37.0902],
|
||||
Netherlands: [5.2913, 52.1326],
|
||||
Türkiye: [35.2433, 38.9637],
|
||||
Turkey: [35.2433, 38.9637],
|
||||
Australia: [133.7751, -25.2744],
|
||||
Japan: [138.2529, 36.2048],
|
||||
Singapore: [103.8198, 1.3521],
|
||||
Germany: [10.4515, 51.1657],
|
||||
France: [2.2137, 46.2276],
|
||||
"United Kingdom": [-3.436, 55.3781],
|
||||
Canada: [-106.3468, 56.1304],
|
||||
India: [78.9629, 20.5937],
|
||||
Russia: [105.3188, 61.524],
|
||||
};
|
||||
|
||||
export const cityCoordinates: Record<string, [number, number]> = {
|
||||
"局域网": [121.86, 31.45],
|
||||
"北京市": [116.4074, 39.9042],
|
||||
"天津市": [117.2008, 39.0842],
|
||||
"河北省": [114.5025, 38.0455],
|
||||
"山西省": [112.5492, 37.857],
|
||||
"内蒙古自治区": [111.6708, 40.8183],
|
||||
"辽宁省": [123.4315, 41.8057],
|
||||
"吉林省": [125.3245, 43.8868],
|
||||
"黑龙江省": [126.6424, 45.7567],
|
||||
"上海市": [121.4737, 31.2304],
|
||||
"江苏省": [118.7633, 32.0617],
|
||||
"浙江省": [120.1551, 30.2741],
|
||||
"安徽省": [117.2272, 31.8206],
|
||||
"福建省": [119.2965, 26.0745],
|
||||
"江西省": [115.8582, 28.682],
|
||||
"山东省": [117.1201, 36.6512],
|
||||
"河南省": [113.6254, 34.7466],
|
||||
"湖北省": [114.3055, 30.5928],
|
||||
"湖南省": [112.9388, 28.2282],
|
||||
"广东省": [113.2644, 23.1291],
|
||||
"广西壮族自治区": [108.3669, 22.817],
|
||||
"海南省": [110.3312, 20.0311],
|
||||
"重庆": [106.5516, 29.563],
|
||||
"重庆市": [106.5516, 29.563],
|
||||
"四川省": [104.0665, 30.5723],
|
||||
"贵州省": [106.6302, 26.647],
|
||||
"云南省": [102.8329, 24.8801],
|
||||
"西藏自治区": [91.1322, 29.6604],
|
||||
"陕西省": [108.9398, 34.3416],
|
||||
"甘肃省": [103.8343, 36.0611],
|
||||
"青海省": [101.7782, 36.6171],
|
||||
"宁夏回族自治区": [106.2309, 38.4872],
|
||||
"新疆维吾尔自治区": [87.6168, 43.8256],
|
||||
"台湾省": [121.5654, 25.033],
|
||||
"香港特别行政区": [114.1694, 22.3193],
|
||||
"澳门特别行政区": [113.5439, 22.1987],
|
||||
"南京": [118.7969, 32.0603],
|
||||
"南京市": [118.7969, 32.0603],
|
||||
"San Jose": [-121.8863, 37.3382],
|
||||
"South Holland": [4.493, 52.0208],
|
||||
"Istanbul": [28.9784, 41.0082],
|
||||
};
|
||||
|
||||
export const resolveSourceCoordinate = (item: IpLocationStatItem): [number, number] | null => {
|
||||
if (item.location === "局域网") return cityCoordinates["局域网"];
|
||||
const city = String(item.city || "").trim();
|
||||
if (city && cityCoordinates[city]) return cityCoordinates[city];
|
||||
const province = String(item.province || "").trim();
|
||||
if (province && cityCoordinates[province]) return cityCoordinates[province];
|
||||
const country = normalizeWorldCountryName(item.country);
|
||||
return country ? countryCoordinates[country] || null : null;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { heartbeatSessionMock, getTokenMock } = vi.hoisted(() => ({
|
||||
heartbeatSessionMock: vi.fn(),
|
||||
getTokenMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../api/authClient", () => ({
|
||||
heartbeatSession: heartbeatSessionMock,
|
||||
}));
|
||||
|
||||
vi.mock("../utils/auth", () => ({
|
||||
getToken: getTokenMock,
|
||||
}));
|
||||
|
||||
describe("connection status heartbeat", () => {
|
||||
beforeEach(() => {
|
||||
heartbeatSessionMock.mockReset();
|
||||
heartbeatSessionMock.mockResolvedValue({ data: { status: "online" } });
|
||||
getTokenMock.mockReturnValue("session-token");
|
||||
Object.defineProperty(window.navigator, "onLine", { configurable: true, value: true });
|
||||
});
|
||||
|
||||
it("sends an authenticated heartbeat and reacts to browser offline state", async () => {
|
||||
const { useConnectionStatus } = await import("./useConnectionStatus");
|
||||
const connection = useConnectionStatus();
|
||||
const stop = connection.startConnectionMonitor();
|
||||
|
||||
await vi.waitFor(() => expect(connection.status.value).toBe("online"));
|
||||
expect(heartbeatSessionMock).toHaveBeenCalledWith("session-token");
|
||||
expect(connection.latencyMs.value).toBeGreaterThan(0);
|
||||
expect(connection.lastSuccessAt.value).not.toBeNull();
|
||||
|
||||
Object.defineProperty(window.navigator, "onLine", { configurable: true, value: false });
|
||||
window.dispatchEvent(new Event("offline"));
|
||||
expect(connection.status.value).toBe("offline");
|
||||
|
||||
stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { computed, readonly, ref } from "vue";
|
||||
import { heartbeatSession } from "../api/authClient";
|
||||
import { getToken } from "../utils/auth";
|
||||
|
||||
export type ConnectionStatus = "checking" | "online" | "degraded" | "offline";
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
|
||||
const status = ref<ConnectionStatus>("checking");
|
||||
const latencyMs = ref<number | null>(null);
|
||||
const lastSuccessAt = ref<number | null>(null);
|
||||
const lastCheckedAt = ref<number | null>(null);
|
||||
let consumerCount = 0;
|
||||
let heartbeatTimer: number | undefined;
|
||||
let heartbeatPromise: Promise<void> | null = null;
|
||||
let listenersRegistered = false;
|
||||
|
||||
const checkConnection = async () => {
|
||||
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
||||
status.value = "offline";
|
||||
latencyMs.value = null;
|
||||
lastCheckedAt.value = Date.now();
|
||||
return;
|
||||
}
|
||||
if (heartbeatPromise) return heartbeatPromise;
|
||||
heartbeatPromise = (async () => {
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
status.value = "offline";
|
||||
latencyMs.value = null;
|
||||
return;
|
||||
}
|
||||
await heartbeatSession(token);
|
||||
latencyMs.value = Math.max(1, Math.round(performance.now() - startedAt));
|
||||
lastSuccessAt.value = Date.now();
|
||||
status.value = "online";
|
||||
} catch (error: any) {
|
||||
latencyMs.value = null;
|
||||
status.value = error?.response ? "degraded" : "offline";
|
||||
} finally {
|
||||
lastCheckedAt.value = Date.now();
|
||||
heartbeatPromise = null;
|
||||
}
|
||||
})();
|
||||
return heartbeatPromise;
|
||||
};
|
||||
|
||||
const handleOnline = () => {
|
||||
status.value = "checking";
|
||||
void checkConnection();
|
||||
};
|
||||
|
||||
const handleOffline = () => {
|
||||
status.value = "offline";
|
||||
latencyMs.value = null;
|
||||
lastCheckedAt.value = Date.now();
|
||||
};
|
||||
|
||||
const registerNetworkListeners = () => {
|
||||
if (listenersRegistered || typeof window === "undefined") return;
|
||||
listenersRegistered = true;
|
||||
window.addEventListener("online", handleOnline);
|
||||
window.addEventListener("offline", handleOffline);
|
||||
};
|
||||
|
||||
const startConnectionMonitor = () => {
|
||||
consumerCount += 1;
|
||||
registerNetworkListeners();
|
||||
if (consumerCount === 1) {
|
||||
void checkConnection();
|
||||
heartbeatTimer = window.setInterval(() => void checkConnection(), HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
return () => {
|
||||
consumerCount = Math.max(0, consumerCount - 1);
|
||||
if (consumerCount === 0 && heartbeatTimer) {
|
||||
window.clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = undefined;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (status.value === "online") return "在线";
|
||||
if (status.value === "degraded") return "连接异常";
|
||||
if (status.value === "offline") return "已断开";
|
||||
return "检测中";
|
||||
});
|
||||
|
||||
export const useConnectionStatus = () => ({
|
||||
status: readonly(status),
|
||||
statusLabel,
|
||||
latencyMs: readonly(latencyMs),
|
||||
lastSuccessAt: readonly(lastSuccessAt),
|
||||
lastCheckedAt: readonly(lastCheckedAt),
|
||||
checkConnection,
|
||||
startConnectionMonitor,
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import packageInfo from "../../package.json";
|
||||
import { clientRuntime } from "./clientRuntime";
|
||||
import { getAppMetadata } from "./appMetadata";
|
||||
import { getAppMetadata, getAppMetadataHeaders } from "./appMetadata";
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
||||
@@ -15,6 +15,7 @@ describe("client runtime", () => {
|
||||
commit: "local",
|
||||
channel: "local",
|
||||
clientType: "web",
|
||||
clientSource: "ctms-web",
|
||||
platform: "web",
|
||||
});
|
||||
expect(clientRuntime.capabilities()).toEqual({
|
||||
@@ -30,10 +31,26 @@ describe("client runtime", () => {
|
||||
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
|
||||
|
||||
expect(getAppMetadata().clientType).toBe("desktop");
|
||||
expect(getAppMetadata().clientSource).toBe("ctms-desktop");
|
||||
expect(clientRuntime.capabilities().serverConfiguration).toBe(true);
|
||||
expect(clientRuntime.capabilities().secureSessionStorage).toBe(false);
|
||||
});
|
||||
|
||||
it("injects explicit CTMS client source headers for audit classification", () => {
|
||||
expect(getAppMetadataHeaders()).toMatchObject({
|
||||
"X-CTMS-Client-Type": "web",
|
||||
"X-CTMS-Client-Source": "ctms-web",
|
||||
"X-CTMS-Client-Version": packageInfo.version,
|
||||
"X-CTMS-Client-Platform": "web",
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
|
||||
expect(getAppMetadataHeaders()).toMatchObject({
|
||||
"X-CTMS-Client-Type": "desktop",
|
||||
"X-CTMS-Client-Source": "ctms-desktop",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts release build metadata only from the expected CI values", () => {
|
||||
vi.stubEnv("VITE_BUILD_CHANNEL", "release");
|
||||
vi.stubEnv("VITE_BUILD_COMMIT", "0123456789abcdef0123456789abcdef01234567");
|
||||
|
||||
@@ -2,6 +2,7 @@ import packageInfo from "../../package.json";
|
||||
import { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
|
||||
|
||||
export type ClientType = "web" | "desktop";
|
||||
export type ClientSource = "ctms-web" | "ctms-desktop";
|
||||
export type BuildChannel = "dev" | "main" | "release" | "local";
|
||||
|
||||
export interface AppMetadata {
|
||||
@@ -9,6 +10,7 @@ export interface AppMetadata {
|
||||
commit: string;
|
||||
channel: BuildChannel;
|
||||
clientType: ClientType;
|
||||
clientSource: ClientSource;
|
||||
platform: RuntimePlatform;
|
||||
}
|
||||
|
||||
@@ -26,6 +28,7 @@ export const getAppMetadata = (): AppMetadata => ({
|
||||
commit: resolveBuildCommit(import.meta.env.VITE_BUILD_COMMIT),
|
||||
channel: resolveBuildChannel(import.meta.env.VITE_BUILD_CHANNEL),
|
||||
clientType: isTauriRuntime() ? "desktop" : "web",
|
||||
clientSource: isTauriRuntime() ? "ctms-desktop" : "ctms-web",
|
||||
platform: getRuntimePlatform(),
|
||||
});
|
||||
|
||||
@@ -33,6 +36,7 @@ export const getAppMetadataHeaders = (): Record<string, string> => {
|
||||
const metadata = getAppMetadata();
|
||||
return {
|
||||
"X-CTMS-Client-Type": metadata.clientType,
|
||||
"X-CTMS-Client-Source": metadata.clientSource,
|
||||
"X-CTMS-Client-Version": metadata.version,
|
||||
"X-CTMS-Client-Platform": metadata.platform,
|
||||
"X-CTMS-Build-Channel": metadata.channel,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useAuthStore } from "../store/auth";
|
||||
import { useSessionStore } from "../store/session";
|
||||
import { isTauriRuntime } from "../runtime";
|
||||
import { getToken, setToken } from "../utils/auth";
|
||||
import { extendToken } from "../api/authClient";
|
||||
import { extendToken, logoutSession } from "../api/authClient";
|
||||
import { parseJwtExp } from "./jwt";
|
||||
|
||||
export const IDLE_TIMEOUT_MINUTES = 30;
|
||||
@@ -172,6 +172,10 @@ const performLogout = async (reason?: string) => {
|
||||
setLogoutReason(reason);
|
||||
const auth = useAuthStore();
|
||||
const session = useSessionStore();
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
await logoutSession(token).catch(() => undefined);
|
||||
}
|
||||
const logoutPromise = auth.logout();
|
||||
session.resetActivity();
|
||||
router.replace("/login");
|
||||
|
||||
@@ -1056,9 +1056,11 @@ body.is-desktop-runtime .el-dialog {
|
||||
max-width: min(calc(100vw - 72px), var(--el-dialog-width, 960px));
|
||||
}
|
||||
|
||||
/* profile-settings-dialog / desktop-preferences-dialog:移除外层卡片外壳,内容区自带圆角阴影 */
|
||||
/* 这些对话框的内容区已具备独立结构,移除桌面端通用的外层卡片外壳。 */
|
||||
body.is-desktop-runtime .profile-settings-dialog,
|
||||
body.is-desktop-runtime .desktop-preferences-dialog {
|
||||
body.is-desktop-runtime .desktop-preferences-dialog,
|
||||
body.is-desktop-runtime .user-form-dialog,
|
||||
body.is-desktop-runtime .reset-password-dialog {
|
||||
overflow: visible !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
@@ -1108,6 +1110,29 @@ body.is-desktop-runtime .el-dialog__body {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 账号表单去掉外框后,内容区仍需作为标题与底栏之间的连续面板。 */
|
||||
body.is-desktop-runtime .user-form-dialog .el-dialog__body,
|
||||
body.is-desktop-runtime .reset-password-dialog .el-dialog__body {
|
||||
background: rgba(248, 250, 252, 0.98);
|
||||
}
|
||||
|
||||
/* 账号操作弹窗保留单层面板:以小圆角和细边框明确边界,关闭按钮置于标题栏内。 */
|
||||
body.is-desktop-runtime .user-form-dialog,
|
||||
body.is-desktop-runtime .reset-password-dialog {
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
border: 1px solid rgba(148, 163, 184, 0.5) !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
body.is-desktop-runtime .user-form-dialog .el-dialog__headerbtn,
|
||||
body.is-desktop-runtime .reset-password-dialog .el-dialog__headerbtn {
|
||||
top: 5px;
|
||||
right: 7px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
body.is-desktop-runtime .desktop-preferences-dialog {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
@@ -1195,6 +1220,8 @@ body.is-desktop-runtime .msgbox-fade-leave-to .el-message-box {
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog,
|
||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .user-form-dialog,
|
||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .reset-password-dialog,
|
||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body {
|
||||
background: transparent !important;
|
||||
}
|
||||
@@ -1216,6 +1243,16 @@ body.is-desktop-runtime .msgbox-fade-leave-to .el-message-box {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .user-form-dialog .el-dialog__body,
|
||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .reset-password-dialog .el-dialog__body {
|
||||
background: rgba(17, 24, 39, 0.98);
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .user-form-dialog,
|
||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .reset-password-dialog {
|
||||
border-color: rgba(71, 85, 105, 0.9) !important;
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog__footer,
|
||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-message-box__btns {
|
||||
border-top-color: rgba(51, 65, 85, 0.92);
|
||||
@@ -1230,3 +1267,106 @@ body.is-desktop-runtime .msgbox-fade-leave-to .el-message-box {
|
||||
transition-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ctms-updated-at {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 12px !important;
|
||||
color: #64748b !important;
|
||||
font-weight: 400 !important;
|
||||
line-height: 1.5 !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] .ctms-updated-at {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
/* 触发弹窗/新视窗的专属物理按钮动效 */
|
||||
.ctms-btn-dialog {
|
||||
transition: transform 0.15s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.15s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.15s ease !important;
|
||||
font-weight: 550 !important;
|
||||
}
|
||||
|
||||
.ctms-btn-dialog:hover {
|
||||
transform: translateY(-1px) !important;
|
||||
box-shadow: 0 4px 10px rgba(59, 130, 246, 0.12) !important;
|
||||
}
|
||||
|
||||
.ctms-btn-dialog.primary-dialog:hover {
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.25) !important;
|
||||
}
|
||||
|
||||
.ctms-btn-dialog:active {
|
||||
transform: translateY(1px) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ctms-btn-link-dialog {
|
||||
font-weight: 600 !important;
|
||||
transition: color 0.15s ease, opacity 0.15s ease !important;
|
||||
}
|
||||
|
||||
.ctms-btn-link-dialog:hover {
|
||||
opacity: 0.85 !important;
|
||||
text-decoration: underline !important;
|
||||
text-underline-offset: 3px !important;
|
||||
}
|
||||
|
||||
/* 专属物理按钮圆角胶囊与渐变色方案 */
|
||||
|
||||
/* 1. 危险/高危胶囊(查看异常分析) */
|
||||
.ctms-btn-capsule-danger {
|
||||
background: #fff5f5 !important;
|
||||
color: #e11d48 !important;
|
||||
border: 1px solid #fee2e2 !important;
|
||||
border-radius: 999px !important;
|
||||
font-size: 11px !important;
|
||||
height: 24px !important;
|
||||
padding: 0 10px !important;
|
||||
}
|
||||
.ctms-btn-capsule-danger:hover {
|
||||
background: #ffe4e6 !important;
|
||||
border-color: #fecdd3 !important;
|
||||
color: #be123c !important;
|
||||
}
|
||||
|
||||
/* 2. 统计/排行胶囊(IP访问排行) */
|
||||
.ctms-btn-capsule-info {
|
||||
background: #eff6ff !important;
|
||||
color: #1d4ed8 !important;
|
||||
border: 1px solid #dbeafe !important;
|
||||
border-radius: 999px !important;
|
||||
}
|
||||
.ctms-btn-capsule-info:hover {
|
||||
background: #dbeafe !important;
|
||||
border-color: #bfdbfe !important;
|
||||
color: #1e40af !important;
|
||||
}
|
||||
|
||||
/* 3. 靛紫渐变胶囊(安全日志) */
|
||||
.ctms-btn-gradient-indigo {
|
||||
background: linear-gradient(135deg, #4f46e5, #6366f1) !important;
|
||||
color: #ffffff !important;
|
||||
border: none !important;
|
||||
border-radius: 999px !important;
|
||||
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.15) !important;
|
||||
}
|
||||
.ctms-btn-gradient-indigo:hover {
|
||||
background: linear-gradient(135deg, #4338ca, #4f46e5) !important;
|
||||
color: #ffffff !important;
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3) !important;
|
||||
}
|
||||
|
||||
/* 4. 蓝绿双向渐变胶囊(访问日志) */
|
||||
.ctms-btn-gradient-blue {
|
||||
background: linear-gradient(135deg, #0284c7, #0369a1) !important;
|
||||
color: #ffffff !important;
|
||||
border: none !important;
|
||||
border-radius: 999px !important;
|
||||
box-shadow: 0 2px 6px rgba(2, 132, 199, 0.15) !important;
|
||||
}
|
||||
.ctms-btn-gradient-blue:hover {
|
||||
background: linear-gradient(135deg, #0369a1, #075985) !important;
|
||||
color: #ffffff !important;
|
||||
box-shadow: 0 4px 12px rgba(2, 132, 199, 0.3) !important;
|
||||
}
|
||||
|
||||
+171
-1
@@ -38,6 +38,7 @@ export interface LoginKeyResponse {
|
||||
}
|
||||
|
||||
export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED";
|
||||
export type UserLoginStatus = "ONLINE" | "OFFLINE";
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
@@ -52,6 +53,22 @@ export interface UserInfo {
|
||||
approved_at?: string | null;
|
||||
approved_by?: string | null;
|
||||
avatar_url?: string | null;
|
||||
login_status?: UserLoginStatus;
|
||||
last_login_at?: string | null;
|
||||
last_seen_at?: string | null;
|
||||
last_client_type?: "web" | "desktop" | null;
|
||||
active_session_count?: number;
|
||||
}
|
||||
|
||||
export interface UserLoginActivity {
|
||||
id: string;
|
||||
client_type: "web" | "desktop";
|
||||
client_platform?: string | null;
|
||||
client_version?: string | null;
|
||||
login_at: string;
|
||||
last_seen_at: string;
|
||||
ended_at?: string | null;
|
||||
end_reason?: string | null;
|
||||
}
|
||||
|
||||
export type UserMeResponse = UserInfo;
|
||||
@@ -298,6 +315,7 @@ export interface HealthResponse {
|
||||
window_seconds: number;
|
||||
};
|
||||
alerts: HealthScoreItem;
|
||||
runtime: HealthScoreItem;
|
||||
};
|
||||
last_hour: {
|
||||
total_checks: number;
|
||||
@@ -308,6 +326,25 @@ export interface HealthResponse {
|
||||
warning_alerts: number;
|
||||
};
|
||||
cache_stats: CacheStatsResponse;
|
||||
components?: {
|
||||
database: HealthRuntimeComponent & { latency_ms?: number };
|
||||
permission_log_writer: HealthWriterComponent;
|
||||
security_log_writer: HealthWriterComponent;
|
||||
retention: HealthRuntimeComponent & {
|
||||
running?: boolean;
|
||||
access_log_retention_days?: number;
|
||||
metric_retention_days?: number;
|
||||
last_success_at?: string | null;
|
||||
last_error_at?: string | null;
|
||||
};
|
||||
};
|
||||
storage?: {
|
||||
permission_access_logs: number;
|
||||
security_access_logs: number;
|
||||
permission_metric_snapshots: number;
|
||||
oldest_permission_access_log_at: string | null;
|
||||
oldest_security_access_log_at: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HealthScoreItem {
|
||||
@@ -315,12 +352,59 @@ export interface HealthScoreItem {
|
||||
deduction: number;
|
||||
}
|
||||
|
||||
export interface HealthRuntimeComponent {
|
||||
status: "healthy" | "degraded" | "unhealthy" | "unknown";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface HealthWriterComponent extends HealthRuntimeComponent {
|
||||
running?: boolean;
|
||||
queue_size?: number;
|
||||
queue_capacity?: number;
|
||||
queue_usage_percent?: number;
|
||||
written_entries?: number;
|
||||
dropped_entries?: number;
|
||||
failed_batches?: number;
|
||||
last_success_at?: string | null;
|
||||
last_error_at?: string | null;
|
||||
}
|
||||
|
||||
// 系统监测 - 访问日志
|
||||
export interface RequestSnapshotParam {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface RequestSnapshot {
|
||||
method?: string | null;
|
||||
path?: string | null;
|
||||
query_string?: string | null;
|
||||
query_params?: RequestSnapshotParam[] | null;
|
||||
headers?: Record<string, string> | null;
|
||||
http_version?: string | null;
|
||||
scheme?: string | null;
|
||||
client?: {
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
} | null;
|
||||
content?: {
|
||||
type?: string | null;
|
||||
length?: string | null;
|
||||
} | null;
|
||||
body?: {
|
||||
captured?: boolean;
|
||||
reason?: string | null;
|
||||
} | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AccessLogItem {
|
||||
id: string;
|
||||
event_type?: "permission" | "security" | string;
|
||||
study_id: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_email: string | null;
|
||||
endpoint_key: string;
|
||||
role: string;
|
||||
allowed: boolean;
|
||||
@@ -331,6 +415,22 @@ export interface AccessLogItem {
|
||||
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;
|
||||
request_headers: Record<string, string> | null;
|
||||
request_snapshot: RequestSnapshot | null;
|
||||
request_id?: string | null;
|
||||
method?: string | null;
|
||||
path?: string | null;
|
||||
status_code?: number | null;
|
||||
auth_status?: string | null;
|
||||
account_label?: string | null;
|
||||
category?: string | null;
|
||||
severity?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -345,6 +445,7 @@ export interface AccessLogsSummary {
|
||||
export interface AccessLogUserStat {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_email: string | null;
|
||||
role: string;
|
||||
total_count: number;
|
||||
denied_count: number;
|
||||
@@ -381,6 +482,9 @@ export interface SecurityAccessLogItem {
|
||||
client_platform: string | null;
|
||||
build_channel: string | null;
|
||||
build_commit: string | null;
|
||||
request_headers: Record<string, string> | null;
|
||||
request_snapshot: RequestSnapshot | null;
|
||||
request_id?: string | null;
|
||||
auth_status: "ANONYMOUS" | "INVALID_TOKEN" | "AUTHENTICATED" | string;
|
||||
user_identifier: string | null;
|
||||
account_label: string;
|
||||
@@ -398,6 +502,20 @@ export interface SecurityAccessLogsResponse {
|
||||
anonymous_count: number;
|
||||
invalid_token_count: number;
|
||||
error_count: number;
|
||||
unique_ip_count: number;
|
||||
high_risk_count: number;
|
||||
server_error_count: number;
|
||||
category_counts: Record<string, number>;
|
||||
severity_counts: Record<string, number>;
|
||||
endpoint_type_counts: Record<string, { count: number; sample_path: string }>;
|
||||
top_ips: Array<{
|
||||
ip: string;
|
||||
location: string;
|
||||
category: string;
|
||||
total_count: number;
|
||||
high_risk_count: number;
|
||||
last_seen_at: string | null;
|
||||
}>;
|
||||
};
|
||||
items: SecurityAccessLogItem[];
|
||||
}
|
||||
@@ -461,25 +579,77 @@ export interface TopDeniedResponse {
|
||||
|
||||
export interface IpLocationStatItem {
|
||||
country: string;
|
||||
country_code: string;
|
||||
province: string;
|
||||
region_code: string;
|
||||
city: string;
|
||||
isp: string;
|
||||
location: string;
|
||||
longitude: number | null;
|
||||
latitude: number | null;
|
||||
accuracy_level: "city" | "region" | "country" | "private" | "unknown";
|
||||
total_count: number;
|
||||
allowed_count: number;
|
||||
denied_count: number;
|
||||
denied_rate: number;
|
||||
unique_ip_count: number;
|
||||
unique_user_count: number;
|
||||
security_event_count: number;
|
||||
high_risk_count: number;
|
||||
risk_score: number;
|
||||
risk_level: "normal" | "attention" | "high";
|
||||
risk_reasons: string[];
|
||||
categories: string[];
|
||||
first_seen_at: string | null;
|
||||
last_seen_at: string | null;
|
||||
}
|
||||
|
||||
export interface IpLocationsResponse {
|
||||
days: number;
|
||||
days: number | null;
|
||||
generated_at: string;
|
||||
period: {
|
||||
mode: "rolling" | "retained_all";
|
||||
requested_days: number | null;
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
observed_end_at: string | null;
|
||||
retention_days: number;
|
||||
};
|
||||
server_location: {
|
||||
name: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
accuracy_level: string;
|
||||
resolution_source: "configured_public_ip" | "frontend_dns" | "public_ip_discovery";
|
||||
} | null;
|
||||
data_quality: {
|
||||
resolver: string;
|
||||
located_ip_count: number;
|
||||
private_ip_count: number;
|
||||
unknown_ip_count: number;
|
||||
location_coverage_rate: number;
|
||||
returned_region_count: number;
|
||||
};
|
||||
timeline_granularity: "hour" | "day";
|
||||
timeline_complete_through: string;
|
||||
timeline: Array<{
|
||||
bucket_time: string;
|
||||
total_count: number;
|
||||
allowed_count: number;
|
||||
denied_count: number;
|
||||
security_event_count: number;
|
||||
high_risk_count: number;
|
||||
unique_ip_count: number;
|
||||
unique_user_count: number;
|
||||
}>;
|
||||
summary: {
|
||||
total_count: number;
|
||||
allowed_count: number;
|
||||
denied_count: number;
|
||||
unique_ip_count: number;
|
||||
unique_user_count: number;
|
||||
security_event_count: number;
|
||||
high_risk_count: number;
|
||||
};
|
||||
items: IpLocationStatItem[];
|
||||
}
|
||||
|
||||
@@ -486,7 +486,7 @@ const onSubmit = async () => {
|
||||
justify-content: stretch;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", sans-serif;
|
||||
}
|
||||
|
||||
.login-split-container {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSystemMonitoringPage = () => readFileSync(resolve(__dirname, "./SystemMonitoringPage.vue"), "utf8");
|
||||
|
||||
describe("SystemMonitoringPage desktop shell", () => {
|
||||
it("reuses the web monitoring component and fits it within the desktop workspace", () => {
|
||||
const source = readSystemMonitoringPage();
|
||||
|
||||
expect(source).toContain('<PermissionMonitoring :is-admin="true" />');
|
||||
expect(source).toContain('import { isTauriRuntime } from "@/runtime";');
|
||||
expect(source).toContain("const isDesktop = isTauriRuntime();");
|
||||
expect(source).toContain("system-monitoring-page--desktop");
|
||||
expect(source).toContain("height: 100%;");
|
||||
expect(source).toContain(".overview-hero-card");
|
||||
expect(source).toContain(".access-logs .audit-filters");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
<template>
|
||||
<div class="system-monitoring-page">
|
||||
<div class="system-monitoring-page" :class="{ 'system-monitoring-page--desktop': isDesktop }">
|
||||
<PermissionMonitoring :is-admin="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
||||
import { isTauriRuntime } from "@/runtime";
|
||||
|
||||
const isDesktop = isTauriRuntime();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -19,4 +22,49 @@ import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
||||
overflow: hidden;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
/*
|
||||
* DesktopLayout already reserves space for its title toolbar and workspace
|
||||
* tabs. Keep the shared monitoring view inside that remaining area instead
|
||||
* of sizing it from the browser viewport as the web shell does.
|
||||
*/
|
||||
.system-monitoring-page--desktop {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1450px) {
|
||||
/*
|
||||
* At the minimum desktop window width, the sidebar leaves the monitoring
|
||||
* surface with roughly 900px. These rules preserve the web information
|
||||
* hierarchy while giving the shared cards room to wrap cleanly.
|
||||
*/
|
||||
.system-monitoring-page--desktop :deep(.overview-hero-card) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.system-monitoring-page--desktop :deep(.overview-title-group) {
|
||||
flex: 1 1 112px;
|
||||
}
|
||||
|
||||
.system-monitoring-page--desktop :deep(.overview-hero-actions) {
|
||||
flex: 0 1 auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.system-monitoring-page--desktop :deep(.metric-strip) {
|
||||
flex-basis: 100%;
|
||||
order: 3;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid #edf1f6;
|
||||
}
|
||||
|
||||
.system-monitoring-page--desktop :deep(.access-logs .audit-filters) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.system-monitoring-page--desktop :deep(.access-logs .filter-keyword) {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
append-to=".layout-main .content-wrapper"
|
||||
append-to="body"
|
||||
:title="user ? TEXT.modules.adminUsers.editTitle : TEXT.modules.adminUsers.newTitle"
|
||||
width="540px"
|
||||
v-model="visibleProxy"
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visibleProxy"
|
||||
title="登录记录"
|
||||
direction="rtl"
|
||||
size="min(520px, 92vw)"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="login-activity-drawer">
|
||||
<div v-if="user" class="drawer-user-summary">
|
||||
<div class="user-meta-info">
|
||||
<span class="user-fullname">{{ user.full_name }}</span>
|
||||
<span class="user-email-text">{{ user.email }}</span>
|
||||
</div>
|
||||
<span class="status-badge" :class="user.login_status === 'ONLINE' ? 'is-online' : 'is-offline'">
|
||||
<span class="status-badge-dot"></span>
|
||||
<span>{{ user.login_status === 'ONLINE' ? `在线 (${user.active_session_count || 1})` : '离线' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="activities" class="login-activity-table" empty-text="暂无登录记录">
|
||||
<el-table-column label="状态" width="102">
|
||||
<template #default="scope">
|
||||
<span class="status-badge compact" :class="activityStatusClass(scope.row)">
|
||||
<span class="status-badge-dot"></span>
|
||||
<span>{{ activityStatusLabel(scope.row) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户端" min-width="130">
|
||||
<template #default="scope">
|
||||
<div class="client-cell">
|
||||
<strong>{{ scope.row.client_type === 'desktop' ? '桌面端' : '网页端' }}</strong>
|
||||
<span>{{ clientDescription(scope.row) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="登录 / 最近活动" min-width="160">
|
||||
<template #default="scope">
|
||||
<div class="time-cell">
|
||||
<span>登录 {{ displayDateTime(scope.row.login_at) }}</span>
|
||||
<span>活动 {{ displayDateTime(scope.row.last_seen_at) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { fetchUserLoginActivities } from "../../api/users";
|
||||
import type { UserInfo, UserLoginActivity } from "../../types/api";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
user: UserInfo | null;
|
||||
}>();
|
||||
const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
|
||||
|
||||
const activities = ref<UserLoginActivity[]>([]);
|
||||
const loading = ref(false);
|
||||
const visibleProxy = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit("update:modelValue", value),
|
||||
});
|
||||
|
||||
const isRecent = (value: string) => Date.now() - new Date(value).getTime() <= 5 * 60 * 1000;
|
||||
const activityStatusLabel = (item: UserLoginActivity) => {
|
||||
if (item.ended_at) return "已退出";
|
||||
return isRecent(item.last_seen_at) ? "在线" : "已离线";
|
||||
};
|
||||
const activityStatusClass = (item: UserLoginActivity) => {
|
||||
if (item.ended_at) return "is-ended";
|
||||
return isRecent(item.last_seen_at) ? "is-online" : "is-offline";
|
||||
};
|
||||
const clientDescription = (item: UserLoginActivity) =>
|
||||
[item.client_platform, item.client_version].filter(Boolean).join(" · ") || "--";
|
||||
|
||||
const loadActivities = async () => {
|
||||
if (!props.user) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await fetchUserLoginActivities(props.user.id);
|
||||
activities.value = data;
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.detail || "登录记录加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(isOpen) => {
|
||||
if (isOpen) void loadActivities();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-activity-drawer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.drawer-user-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 14px 18px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
|
||||
.user-meta-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.user-fullname {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.user-email-text {
|
||||
color: #64748b;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* 状态徽标 */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.status-badge-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-badge.is-online {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
.status-badge.is-online .status-badge-dot {
|
||||
background-color: #10b981;
|
||||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
|
||||
.status-badge.is-offline {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.status-badge.is-offline .status-badge-dot {
|
||||
background-color: #64748b;
|
||||
}
|
||||
|
||||
.status-badge.is-ended {
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.status-badge.is-ended .status-badge-dot {
|
||||
background-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.status-badge.compact {
|
||||
padding: 2.5px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-badge.compact .status-badge-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.login-activity-table {
|
||||
width: 100%;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-activity-table :deep(.el-table__header-wrapper) th {
|
||||
background-color: #f8fafc !important;
|
||||
color: #475569 !important;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
height: 38px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.login-activity-table :deep(.el-table__row) td {
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.client-cell,
|
||||
.time-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.client-cell strong {
|
||||
color: #334155;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.client-cell span,
|
||||
.time-cell span {
|
||||
color: #64748b;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.time-cell span {
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
append-to=".layout-main .content-wrapper"
|
||||
append-to="body"
|
||||
:title="TEXT.modules.adminUsers.resetTitle"
|
||||
width="540px"
|
||||
v-model="visibleProxy"
|
||||
|
||||
@@ -41,6 +41,19 @@
|
||||
<span class="stat-label">{{ TEXT.enums.userStatus.DISABLED }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--online">
|
||||
<div class="stat-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M5 12a7 7 0 0 1 14 0"/>
|
||||
<path d="M8 12a4 4 0 0 1 8 0"/>
|
||||
<circle cx="12" cy="16" r="1"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-value">{{ onlineCount }}</span>
|
||||
<span class="stat-label">当前在线</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
@@ -70,7 +83,7 @@
|
||||
<!-- 用户表格 -->
|
||||
<div class="unified-section user-table-section">
|
||||
<el-table :data="users" v-loading="loading" class="user-table" style="width: 100%" table-layout="fixed">
|
||||
<el-table-column :label="TEXT.common.fields.name" width="360">
|
||||
<el-table-column :label="TEXT.common.fields.name" min-width="200">
|
||||
<template #default="scope">
|
||||
<div class="user-cell">
|
||||
<div class="user-info">
|
||||
@@ -80,21 +93,36 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="clinical_department" :label="TEXT.modules.adminUsers.clinicalDepartmentLabel" show-overflow-tooltip />
|
||||
<el-table-column :label="TEXT.common.fields.status">
|
||||
<el-table-column prop="clinical_department" :label="TEXT.modules.adminUsers.clinicalDepartmentLabel" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column :label="TEXT.common.fields.status" min-width="100">
|
||||
<template #default="scope">
|
||||
<span class="status-dot" :class="'dot--' + (scope.row.status || '').toLowerCase()"></span>
|
||||
{{ statusLabel(scope.row.status) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" show-overflow-tooltip>
|
||||
<el-table-column label="登录状态" min-width="140">
|
||||
<template #default="scope">
|
||||
<span class="text-muted">{{ displayDateTime(scope.row.created_at) }}</span>
|
||||
<div class="login-status-cell">
|
||||
<span class="status-dot" :class="'dot--' + (scope.row.login_status || 'OFFLINE').toLowerCase()"></span>
|
||||
<span>{{ loginStatusLabel(scope.row) }}</span>
|
||||
<small>{{ scope.row.last_client_type === 'desktop' ? '桌面端' : scope.row.last_client_type === 'web' ? '网页端' : '暂无会话' }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" align="center">
|
||||
<el-table-column label="最近登录" min-width="150">
|
||||
<template #default="scope">
|
||||
<div class="login-time-cell">
|
||||
<span>{{ scope.row.last_login_at ? displayDateTime(scope.row.last_login_at) : '从未登录' }}</span>
|
||||
<small v-if="scope.row.last_seen_at">活动 {{ displayDateTime(scope.row.last_seen_at) }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" align="center" width="168">
|
||||
<template #default="scope">
|
||||
<div class="action-row">
|
||||
<el-tooltip content="登录记录" placement="top">
|
||||
<el-button link type="info" :icon="Clock" class="action-btn" aria-label="查看登录记录" @click="openLoginActivities(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="TEXT.common.actions.edit" placement="top">
|
||||
<el-button link type="primary" :icon="Edit" class="action-btn" @click="openEdit(scope.row)" />
|
||||
</el-tooltip>
|
||||
@@ -141,19 +169,21 @@
|
||||
</div>
|
||||
<UserForm v-if="formVisible" v-model:visible="formVisible" :user="editingUser" :admin-count="activeAdminCount" @saved="loadUsers" />
|
||||
<UserResetPassword v-if="resetVisible" v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" />
|
||||
<UserLoginActivitiesDrawer v-if="loginActivitiesVisible" v-model="loginActivitiesVisible" :user="loginActivitiesUser" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Edit, Delete, Key, Lock, Unlock, Search, Plus } from "@element-plus/icons-vue";
|
||||
import { Edit, Delete, Key, Lock, Unlock, Search, Plus, Clock } from "@element-plus/icons-vue";
|
||||
import { fetchUsers, updateUser, deleteUser } from "../../api/users";
|
||||
import { fetchStudies } from "../../api/studies";
|
||||
import { listMembers } from "../../api/members";
|
||||
import type { UserInfo } from "../../types/api";
|
||||
import UserForm from "./UserForm.vue";
|
||||
import UserResetPassword from "./UserResetPassword.vue";
|
||||
import UserLoginActivitiesDrawer from "./UserLoginActivitiesDrawer.vue";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import { TEXT } from "../../locales";
|
||||
@@ -171,11 +201,14 @@ const searchKeyword = ref("");
|
||||
const statusFilter = ref("");
|
||||
const editingUser = ref<UserInfo | null>(null);
|
||||
const resetUser = ref<UserInfo | null>(null);
|
||||
const loginActivitiesUser = ref<UserInfo | null>(null);
|
||||
const loginActivitiesVisible = ref(false);
|
||||
const auth = useAuthStore();
|
||||
let keywordSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const activeCount = computed(() => allUsers.value.filter(u => u.status === 'ACTIVE').length);
|
||||
const disabledCount = computed(() => allUsers.value.filter(u => u.status === 'DISABLED').length);
|
||||
const onlineCount = computed(() => allUsers.value.filter(u => u.login_status === 'ONLINE').length);
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
@@ -185,6 +218,12 @@ const statusLabel = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const loginStatusLabel = (row: UserInfo) => {
|
||||
if (row.status !== "ACTIVE") return "账号不可用";
|
||||
if (row.login_status === "ONLINE") return row.active_session_count && row.active_session_count > 1 ? `${row.active_session_count} 个会话` : "在线";
|
||||
return "离线";
|
||||
};
|
||||
|
||||
const isLastAdmin = (row: UserInfo) =>
|
||||
row.is_admin && row.status === "ACTIVE" && activeAdminCount.value <= 1;
|
||||
|
||||
@@ -284,6 +323,11 @@ const openReset = (row: UserInfo) => {
|
||||
resetVisible.value = true;
|
||||
};
|
||||
|
||||
const openLoginActivities = (row: UserInfo) => {
|
||||
loginActivitiesUser.value = row;
|
||||
loginActivitiesVisible.value = true;
|
||||
};
|
||||
|
||||
const onDelete = async (row: UserInfo) => {
|
||||
const { action } = await ElMessageBox.prompt(
|
||||
TEXT.modules.adminUsers.deleteConfirm,
|
||||
@@ -364,7 +408,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
@@ -415,6 +459,11 @@ onBeforeUnmount(() => {
|
||||
color: var(--ctms-danger);
|
||||
}
|
||||
|
||||
.stat-card--online .stat-icon {
|
||||
background: #e8f6ef;
|
||||
color: #1f9d63;
|
||||
}
|
||||
|
||||
.stat-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -508,6 +557,43 @@ onBeforeUnmount(() => {
|
||||
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.status-dot.dot--online {
|
||||
background: #22a663;
|
||||
box-shadow: 0 0 0 3px rgba(34, 166, 99, 0.15);
|
||||
}
|
||||
|
||||
.status-dot.dot--offline {
|
||||
background: #94a3b8;
|
||||
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.login-status-cell,
|
||||
.login-time-cell {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
column-gap: 6px;
|
||||
color: var(--ctms-text-main);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.login-status-cell small,
|
||||
.login-time-cell small {
|
||||
grid-column: 2;
|
||||
margin-top: 2px;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.login-time-cell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.login-time-cell small {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = (path: string) => readFileSync(resolve(__dirname, path), "utf8");
|
||||
|
||||
describe("admin user login status", () => {
|
||||
it("shows server-provided session state and opens a privacy-safe activity drawer", () => {
|
||||
const users = readSource("./Users.vue");
|
||||
const drawer = readSource("./UserLoginActivitiesDrawer.vue");
|
||||
|
||||
expect(users).toContain("当前在线");
|
||||
expect(users).toContain("login_status");
|
||||
expect(users).toContain("last_login_at");
|
||||
expect(users).toContain("openLoginActivities");
|
||||
expect(users).toContain("UserLoginActivitiesDrawer");
|
||||
expect(drawer).toContain("登录记录");
|
||||
expect(drawer).not.toContain("不会展示 Token、密码或原始 IP");
|
||||
expect(drawer).not.toContain("access_token");
|
||||
});
|
||||
|
||||
it("mounts account edit and emergency-reset dialogs to a container shared by web and desktop layouts", () => {
|
||||
const userForm = readSource("./UserForm.vue");
|
||||
const resetPassword = readSource("./UserResetPassword.vue");
|
||||
|
||||
expect(userForm).toContain('append-to="body"');
|
||||
expect(resetPassword).toContain('append-to="body"');
|
||||
expect(userForm).not.toContain('append-to=".layout-main .content-wrapper"');
|
||||
expect(resetPassword).not.toContain('append-to=".layout-main .content-wrapper"');
|
||||
});
|
||||
|
||||
it("uses the desktop dialog treatment without an additional outer card for account actions", () => {
|
||||
const desktopStyles = readFileSync(resolve(__dirname, "../../styles/main.css"), "utf8");
|
||||
|
||||
expect(desktopStyles).toContain("body.is-desktop-runtime .user-form-dialog");
|
||||
expect(desktopStyles).toContain("body.is-desktop-runtime .reset-password-dialog");
|
||||
expect(desktopStyles).toContain("background: transparent !important;");
|
||||
expect(desktopStyles).toContain("box-shadow: none !important;");
|
||||
expect(desktopStyles).toContain(".user-form-dialog .el-dialog__body");
|
||||
expect(desktopStyles).toContain(".reset-password-dialog .el-dialog__body");
|
||||
expect(desktopStyles).toContain("border-radius: 8px !important;");
|
||||
expect(desktopStyles).toContain("padding: 0 !important;");
|
||||
expect(desktopStyles).toContain(".user-form-dialog .el-dialog__headerbtn");
|
||||
expect(desktopStyles).toContain("top: 5px;");
|
||||
expect(desktopStyles).toContain("right: 7px;");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user