release(main): 同步 dev 最新候选改动
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-07-16 17:15:50 +08:00
parent 32167fba02
commit d5279b124f
393 changed files with 51630 additions and 9711 deletions
+390 -80
View File
@@ -1,52 +1,77 @@
<template>
<div class="page page--flush">
<div class="page page--flush users-page">
<div class="main-content-flat unified-shell">
<!-- 统计卡片 -->
<div class="stats-row">
<div class="stat-card stat-card--total">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<!-- 独立状态概览 -->
<section class="account-overview" aria-labelledby="account-overview-title">
<div class="overview-heading">
<div>
<h2 id="account-overview-title">账号概览</h2>
</div>
<div class="stat-body">
<span class="stat-value">{{ total }}</span>
<span class="stat-label">总用户</span>
<span class="overview-live"><i></i>状态汇总</span>
</div>
<div class="stats-row">
<div class="stat-card stat-card--total">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ allUsers.length }}</span>
<span class="stat-label">总用户</span>
</div>
</div>
<div class="stat-card stat-card--active">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<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>
<div class="stat-body">
<span class="stat-value">{{ activeCount }}</span>
<span class="stat-label">{{ TEXT.enums.userStatus.ACTIVE }}</span>
</div>
</div>
<div class="stat-card stat-card--disabled">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="10"/>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ disabledCount }}</span>
<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>
<div class="stat-card stat-card--active">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<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>
<div class="stat-body">
<span class="stat-value">{{ activeCount }}</span>
<span class="stat-label">{{ TEXT.enums.userStatus.ACTIVE }}</span>
</div>
</div>
<div class="stat-card stat-card--disabled">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="10"/>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ disabledCount }}</span>
<span class="stat-label">{{ TEXT.enums.userStatus.DISABLED }}</span>
</div>
</div>
</div>
</section>
<!-- 筛选栏 -->
<div class="filter-container unified-action-bar">
<div class="filter-form">
<div class="filter-item-form">
<div class="filter-heading">
<el-icon><Filter /></el-icon>
<span>筛选</span>
</div>
<div class="filter-item-form filter-item-form--search">
<el-input
v-model="searchKeyword"
:placeholder="TEXT.common.placeholders.keyword"
@@ -57,11 +82,19 @@
/>
</div>
<div class="filter-item-form">
<el-select v-model="statusFilter" :placeholder="TEXT.common.fields.status" style="width: 140px" clearable class="filter-select-comp" @change="applyFilters">
<el-select v-model="statusFilter" placeholder="账号状态" clearable class="filter-select-comp" @change="applyFilters">
<el-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" />
<el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" />
</el-select>
</div>
<div class="filter-item-form">
<el-select v-model="loginStatusFilter" placeholder="登录状态" clearable class="filter-select-comp" @change="applyFilters">
<el-option label="在线" value="ONLINE" />
<el-option label="离线" value="OFFLINE" />
</el-select>
</div>
<el-button v-if="hasActiveFilters" text :icon="RefreshLeft" class="reset-filter-button" @click="resetFilters">重置筛选</el-button>
<span class="filter-result" aria-live="polite">{{ hasActiveFilters ? `筛选结果 ${total}` : `${total}` }}</span>
<div class="filter-spacer"></div>
<el-button type="primary" :icon="Plus" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
</div>
@@ -70,7 +103,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 +113,42 @@
</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" class-name="department-column" show-overflow-tooltip>
<template #default="scope">
<span class="status-dot" :class="'dot--' + (scope.row.status || '').toLowerCase()"></span>
{{ statusLabel(scope.row.status) }}
<span class="department-text">{{ scope.row.clinical_department }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" show-overflow-tooltip>
<el-table-column :label="TEXT.common.fields.status" min-width="100" class-name="account-status-column">
<template #default="scope">
<span class="text-muted">{{ displayDateTime(scope.row.created_at) }}</span>
<div class="account-status-cell">
<span class="status-dot" :class="'dot--' + (scope.row.status || '').toLowerCase()"></span>
<span>{{ statusLabel(scope.row.status) }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" align="center">
<el-table-column label="登录状态" min-width="140">
<template #default="scope">
<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="最近登录" 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="176" class-name="actions-column">
<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 +195,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, RefreshLeft, Filter } 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";
@@ -169,13 +225,18 @@ const formVisible = ref(false);
const resetVisible = ref(false);
const searchKeyword = ref("");
const statusFilter = ref("");
const loginStatusFilter = 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 hasActiveFilters = computed(() => Boolean(searchKeyword.value.trim() || statusFilter.value || loginStatusFilter.value));
const statusLabel = (status: string) => {
switch (status) {
@@ -185,6 +246,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;
@@ -197,6 +264,7 @@ const loadUsers = async () => {
limit: pageSize.value,
keyword: searchKeyword.value || undefined,
status: statusFilter.value || undefined,
login_status: loginStatusFilter.value || undefined,
}),
fetchUsers({ skip: 0, limit: 10000 }),
]);
@@ -219,6 +287,13 @@ const applyFilters = () => {
loadUsers();
};
const resetFilters = () => {
searchKeyword.value = "";
statusFilter.value = "";
loginStatusFilter.value = "";
applyFilters();
};
const clearKeywordSearchTimer = () => {
if (!keywordSearchTimer) return;
clearTimeout(keywordSearchTimer);
@@ -236,7 +311,7 @@ const scheduleKeywordSearch = () => {
watch(searchKeyword, () => {
scheduleKeywordSearch();
});
}, { flush: "sync" });
const onPageSizeChange = (size: number) => {
pageSize.value = size;
@@ -284,6 +359,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,
@@ -350,32 +430,97 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.page,
.main-content-flat,
.user-table-section {
min-width: 0;
box-sizing: border-box;
}
.main-content-flat {
width: 100%;
max-width: 100%;
}
:global(.web-layout-container .content-wrapper:has(.users-page)) {
padding: 0;
}
.account-overview {
padding: 0;
border-bottom: 1px solid var(--unified-shell-divider);
background: linear-gradient(180deg, var(--ctms-bg-card) 0%, color-mix(in srgb, var(--ctms-neutral-100) 55%, var(--ctms-bg-card)) 100%);
}
.overview-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 0;
padding: 8px 16px 6px;
}
.overview-heading h2 {
margin: 0;
color: var(--ctms-text-main);
font-size: 14px;
font-weight: 700;
line-height: 1.3;
}
.overview-live {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border: 1px solid rgba(34, 166, 99, 0.18);
border-radius: 999px;
background: rgba(34, 166, 99, 0.07);
color: #188454;
font-size: 11px;
white-space: nowrap;
}
.overview-live i {
width: 6px;
height: 6px;
border-radius: 50%;
background: #22a663;
box-shadow: 0 0 0 3px rgba(34, 166, 99, 0.12);
}
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
padding: 10px 16px;
border-bottom: 1px solid var(--unified-shell-divider);
grid-template-columns: repeat(4, 1fr);
gap: 0;
overflow: hidden;
border-top: 1px solid var(--unified-shell-divider);
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: 0;
background: var(--ctms-bg-card);
box-shadow: none;
}
.stat-card {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: 10px;
background: var(--ctms-neutral-100);
transition: var(--ctms-transition);
min-height: 48px;
padding: 6px 14px;
background: transparent;
width: 100%;
}
.stat-card:hover {
transform: translateY(-1px);
box-shadow: var(--ctms-shadow-sm);
.stat-card + .stat-card {
border-left: 1px solid var(--unified-shell-divider);
}
.stat-icon {
width: 32px;
height: 32px;
width: 30px;
height: 30px;
border-radius: 8px;
display: flex;
align-items: center;
@@ -384,8 +529,8 @@ onBeforeUnmount(() => {
}
.stat-icon svg {
width: 18px;
height: 18px;
width: 17px;
height: 17px;
}
.stat-card--total .stat-icon {
@@ -403,43 +548,100 @@ onBeforeUnmount(() => {
color: var(--ctms-danger);
}
.stat-card--online .stat-icon {
background: #e8f6ef;
color: #1f9d63;
}
.stat-body {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 20px;
font-size: 18px;
font-weight: 700;
line-height: 1.2;
color: var(--ctms-text-main);
}
.stat-label {
font-size: 11px;
font-size: 10px;
color: var(--ctms-text-secondary);
margin-top: 2px;
}
/* 筛选栏 */
.filter-container {
position: sticky;
top: 0;
z-index: 8;
background: color-mix(in srgb, var(--ctms-bg-card) 94%, transparent);
box-shadow: 0 5px 14px rgba(15, 23, 42, 0.05);
backdrop-filter: blur(12px);
}
.filter-form {
display: flex;
width: 100%;
gap: 10px;
align-items: center;
min-width: 0;
flex-wrap: wrap;
}
.filter-heading {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--ctms-text-secondary);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.filter-item-form {
margin-bottom: 0 !important;
margin-right: 0 !important;
min-width: 0;
}
.filter-item-form--search {
flex: 0 1 320px;
}
.filter-input-comp {
width: 220px;
width: 100%;
max-width: 100%;
}
.filter-form :deep(.el-input__inner),
.filter-form :deep(.el-select__placeholder),
.filter-form :deep(.el-select__selected-item) {
font-size: 12px;
}
.filter-form :deep(.el-button) {
font-size: 12px;
}
.filter-select-comp {
width: 140px;
}
.reset-filter-button {
color: var(--ctms-text-secondary);
}
.filter-result {
color: var(--ctms-text-secondary);
font-size: 11px;
white-space: nowrap;
}
.filter-spacer {
flex: 1;
min-width: 0;
}
/* 用户单元格 */
@@ -456,7 +658,7 @@ onBeforeUnmount(() => {
.user-name {
font-weight: 600;
font-size: 13px;
font-size: 12px;
color: var(--ctms-text-main);
white-space: nowrap;
overflow: hidden;
@@ -464,13 +666,26 @@ onBeforeUnmount(() => {
}
.user-email {
font-size: 12px;
font-size: 11px;
color: var(--ctms-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.department-text,
.account-status-cell {
color: var(--ctms-text-main);
font-size: 12px;
line-height: 1.25;
}
.account-status-cell {
display: inline-flex;
align-items: center;
white-space: nowrap;
}
/* 状态点 */
.status-dot {
display: inline-block;
@@ -491,6 +706,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: 11px;
}
.login-status-cell small,
.login-time-cell small {
grid-column: 2;
margin-top: 0;
color: var(--ctms-text-secondary);
font-size: 10px;
}
.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;
@@ -502,7 +754,7 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: center;
gap: 4px;
padding: 2px;
padding: 1px;
border-radius: 8px;
background: rgba(248, 250, 252, 0.72);
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.14);
@@ -510,9 +762,9 @@ onBeforeUnmount(() => {
}
.action-btn {
width: 28px;
height: 28px;
min-height: 28px;
width: 26px;
height: 26px;
min-height: 26px;
padding: 0;
margin: 0;
border-radius: 7px;
@@ -526,6 +778,13 @@ onBeforeUnmount(() => {
margin-left: 0;
}
.user-table :deep(td.actions-column .cell) {
padding-right: 6px;
padding-left: 6px;
overflow: visible;
text-overflow: clip;
}
.action-row :deep(.el-button .el-icon) {
width: 15px;
height: 15px;
@@ -545,21 +804,31 @@ onBeforeUnmount(() => {
/* 表格区域 */
.user-table-section {
padding: 0;
padding: 0 !important;
max-width: 100%;
overflow-x: hidden;
}
.user-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.user-table :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.user-table :deep(th.el-table__cell) {
padding-top: 10px;
padding-bottom: 10px;
padding-top: 7px;
padding-bottom: 7px;
}
.user-table :deep(td.el-table__cell) {
padding-top: 10px;
padding-bottom: 10px;
padding-top: 5px;
padding-bottom: 5px;
}
.user-table :deep(.cell) {
line-height: 1.25;
}
.pagination-wrap {
@@ -567,11 +836,52 @@ onBeforeUnmount(() => {
display: flex;
justify-content: flex-end;
padding: 8px 16px;
max-width: 100%;
overflow: hidden;
}
.pagination-wrap :deep(.el-pagination) {
min-width: 0;
flex-wrap: wrap;
justify-content: flex-end;
row-gap: 6px;
}
@media (max-width: 768px) {
.stats-row {
grid-template-columns: repeat(2, 1fr);
}
.stat-card:nth-child(3) {
border-left: 0;
}
.stat-card:nth-child(n + 3) {
border-top: 1px solid var(--unified-shell-divider);
}
.filter-heading {
width: 100%;
}
.filter-spacer {
display: none;
}
.filter-item-form--search {
flex: 1 1 100%;
}
.filter-select-comp {
width: 100%;
}
.filter-item-form:not(.filter-item-form--search) {
flex: 1 1 calc(50% - 5px);
}
.filter-form > .el-button--primary {
width: 100%;
}
}
</style>