feat(监控): 完善系统监控、登录状态与访问审计能力
This commit is contained in:
@@ -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