feat(监控): 完善系统监控、登录状态与访问审计能力
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-07-10 17:11:24 +08:00
parent 400c9be3a7
commit f11a5c84d9
82 changed files with 10283 additions and 1781 deletions
+1 -1
View File
@@ -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 -1
View File
@@ -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"
+94 -8
View File
@@ -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;");
});
});