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
+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;