Files
ctms/frontend/src/views/admin/Users.vue
T
Cheng Zhou 878d1dc701 Merge branch 'dev' into codex/desktop-ui-stabilization
# Conflicts:
#	frontend/src/views/admin/Projects.test.ts
#	frontend/src/views/admin/Projects.vue
2026-07-09 15:57:08 +08:00

614 lines
17 KiB
Vue

<template>
<div class="page page--flush">
<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>
</div>
<div class="stat-body">
<span class="stat-value">{{ total }}</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>
<!-- 筛选栏 -->
<div class="filter-container unified-action-bar">
<div class="filter-form">
<div class="filter-item-form">
<el-input
v-model="searchKeyword"
:placeholder="TEXT.common.placeholders.keyword"
clearable
class="filter-input-comp"
:prefix-icon="Search"
@keyup.enter="applyFilters"
/>
</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-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" />
<el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" />
</el-select>
</div>
<div class="filter-spacer"></div>
<el-button type="primary" :icon="Plus" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
</div>
</div>
<!-- 用户表格 -->
<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">
<template #default="scope">
<div class="user-cell">
<div class="user-info">
<span class="user-name">{{ scope.row.full_name }}</span>
<span class="user-email">{{ scope.row.email }}</span>
</div>
</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">
<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>
<template #default="scope">
<span class="text-muted">{{ displayDateTime(scope.row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" align="center">
<template #default="scope">
<div class="action-row">
<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>
<el-tooltip :content="scope.row.status === 'ACTIVE' ? TEXT.common.actions.disable : TEXT.common.actions.enable" placement="top">
<el-button
link
:type="scope.row.status === 'ACTIVE' ? 'warning' : 'success'"
:icon="scope.row.status === 'ACTIVE' ? Lock : Unlock"
class="action-btn"
:disabled="isLastAdmin(scope.row)"
@click="toggleStatus(scope.row)"
/>
</el-tooltip>
<el-tooltip :content="TEXT.modules.adminUsers.resetPassword" placement="top">
<el-button link type="warning" :icon="Key" class="action-btn" @click="openReset(scope.row)" />
</el-tooltip>
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
<el-button
link
type="danger"
:icon="Delete"
class="action-btn"
:disabled="scope.row.id === auth.user?.id"
@click="onDelete(scope.row)"
/>
</el-tooltip>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20]"
:total="total"
layout="prev, pager, next, sizes, total"
small
@current-change="loadUsers"
@size-change="onPageSizeChange"
/>
</div>
</div>
</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" />
</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 { 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 { useAuthStore } from "../../store/auth";
import { displayDateTime } from "../../utils/display";
import { TEXT } from "../../locales";
const users = ref<UserInfo[]>([]);
const allUsers = ref<UserInfo[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const loading = ref(false);
const activeAdminCount = ref(0);
const formVisible = ref(false);
const resetVisible = ref(false);
const searchKeyword = ref("");
const statusFilter = ref("");
const editingUser = ref<UserInfo | null>(null);
const resetUser = ref<UserInfo | null>(null);
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 statusLabel = (status: string) => {
switch (status) {
case "ACTIVE": return TEXT.enums.userStatus.ACTIVE;
case "DISABLED": return TEXT.enums.userStatus.DISABLED;
default: return status || TEXT.common.fallback;
}
};
const isLastAdmin = (row: UserInfo) =>
row.is_admin && row.status === "ACTIVE" && activeAdminCount.value <= 1;
const loadUsers = async () => {
loading.value = true;
try {
const [{ data }, { data: adminData }] = await Promise.all([
fetchUsers({
skip: (page.value - 1) * pageSize.value,
limit: pageSize.value,
keyword: searchKeyword.value || undefined,
status: statusFilter.value || undefined,
}),
fetchUsers({ skip: 0, limit: 10000 }),
]);
const items = (data as any).items || [];
const allItems = (adminData as any).items || [];
users.value = items;
allUsers.value = allItems;
total.value = (data as any).total || items.length;
activeAdminCount.value = allItems.filter((u: UserInfo) => u.is_admin && u.status === "ACTIVE").length;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminUsers.loadFailed);
} finally {
loading.value = false;
}
};
const applyFilters = () => {
clearKeywordSearchTimer();
page.value = 1;
loadUsers();
};
const clearKeywordSearchTimer = () => {
if (!keywordSearchTimer) return;
clearTimeout(keywordSearchTimer);
keywordSearchTimer = null;
};
const scheduleKeywordSearch = () => {
clearKeywordSearchTimer();
keywordSearchTimer = setTimeout(() => {
keywordSearchTimer = null;
page.value = 1;
loadUsers();
}, 300);
};
watch(searchKeyword, () => {
scheduleKeywordSearch();
});
const onPageSizeChange = (size: number) => {
pageSize.value = size;
page.value = 1;
loadUsers();
};
const openCreate = () => {
editingUser.value = null;
formVisible.value = true;
};
const openEdit = (row: UserInfo) => {
editingUser.value = row;
formVisible.value = true;
};
const toggleStatus = async (row: UserInfo) => {
if (isLastAdmin(row)) {
ElMessage.warning(TEXT.modules.adminUsers.keepAdminWarning);
return;
}
const disable = row.status === "ACTIVE";
const ok = await ElMessageBox.confirm(
disable ? TEXT.modules.adminUsers.disableConfirm : TEXT.modules.adminUsers.enableConfirm,
disable ? TEXT.modules.adminUsers.disableTitle : TEXT.modules.adminUsers.enableTitle,
{
type: disable ? "warning" : "info",
confirmButtonText: TEXT.common.actions.confirm,
cancelButtonText: TEXT.common.actions.cancel,
}
).catch(() => null);
if (!ok) return;
try {
await updateUser(row.id, { status: disable ? "DISABLED" : "ACTIVE", is_active: !disable });
ElMessage.success(disable ? TEXT.modules.adminUsers.disableSuccess : TEXT.modules.adminUsers.enableSuccess);
loadUsers();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
}
};
const openReset = (row: UserInfo) => {
resetUser.value = row;
resetVisible.value = true;
};
const onDelete = async (row: UserInfo) => {
const { action } = await ElMessageBox.prompt(
TEXT.modules.adminUsers.deleteConfirm,
TEXT.modules.adminUsers.deleteTitle,
{
inputPlaceholder: TEXT.modules.adminUsers.deleteConfirmPlaceholder,
confirmButtonText: TEXT.common.actions.confirm,
confirmButtonClass: "el-button--danger",
cancelButtonText: TEXT.common.actions.cancel,
inputPattern: /^确认删除$/,
inputErrorMessage: TEXT.modules.adminUsers.deleteConfirmInputError,
}
).catch(() => ({ action: "cancel" }));
if (action !== "confirm") return;
try {
await deleteUser(row.id);
ElMessage.success(TEXT.modules.adminUsers.deleteSuccess);
loadUsers();
} catch (e: any) {
const detail = e?.response?.data?.detail || e?.response?.data?.message;
if (detail && detail.includes(TEXT.modules.adminProjectMembers.memberLabel)) {
ElMessage.closeAll();
const blockingProjects = await findUserProjects(row.id);
if (blockingProjects.length > 0) {
ElMessage.error(`${detail},该用户仍在以下项目中:${blockingProjects.join("、")}`);
} else {
ElMessage.error(detail);
}
} else {
ElMessage.error(detail || TEXT.modules.adminUsers.deleteFailed);
}
}
};
const findUserProjects = async (userId: string) => {
try {
const { data } = await fetchStudies();
const studies = Array.isArray(data) ? data : (data as any).items || [];
const blocking: string[] = [];
await Promise.all(studies.map(async (study: any) => {
try {
const { data: membersData } = await listMembers(study.id, { limit: 1000, include_inactive: true });
const members = Array.isArray(membersData) ? membersData : (membersData as any).items || [];
if (members.some((m: any) => m.user_id === userId)) {
blocking.push(study.name);
}
} catch {
// skip
}
}));
return blocking;
} catch {
return [];
}
};
onMounted(() => {
loadUsers();
});
onBeforeUnmount(() => {
clearKeywordSearchTimer();
});
</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%;
}
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
padding: 10px 16px;
border-bottom: 1px solid var(--unified-shell-divider);
}
.stat-card {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: 10px;
background: var(--ctms-neutral-100);
transition: var(--ctms-transition);
}
.stat-card:hover {
transform: translateY(-1px);
box-shadow: var(--ctms-shadow-sm);
}
.stat-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon svg {
width: 18px;
height: 18px;
}
.stat-card--total .stat-icon {
background: #e8edf3;
color: var(--ctms-primary);
}
.stat-card--active .stat-icon {
background: #e6f4ed;
color: var(--ctms-success);
}
.stat-card--disabled .stat-icon {
background: #fde8e8;
color: var(--ctms-danger);
}
.stat-body {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 20px;
font-weight: 700;
line-height: 1.2;
color: var(--ctms-text-main);
}
.stat-label {
font-size: 11px;
color: var(--ctms-text-secondary);
margin-top: 2px;
}
/* 筛选栏 */
.filter-form {
display: flex;
width: 100%;
gap: 10px;
align-items: center;
min-width: 0;
flex-wrap: wrap;
}
.filter-item-form {
margin-bottom: 0 !important;
margin-right: 0 !important;
min-width: 0;
}
.filter-input-comp {
width: 220px;
max-width: 100%;
}
.filter-spacer {
flex: 1;
min-width: 0;
}
/* 用户单元格 */
.user-cell {
display: flex;
align-items: center;
}
.user-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.user-name {
font-weight: 600;
font-size: 13px;
color: var(--ctms-text-main);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-email {
font-size: 12px;
color: var(--ctms-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 状态点 */
.status-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.status-dot.dot--active {
background: var(--ctms-success);
box-shadow: 0 0 0 3px rgba(63, 143, 107, 0.15);
}
.status-dot.dot--disabled {
background: var(--ctms-text-disabled);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.15);
}
.text-muted {
color: var(--ctms-text-secondary);
font-size: 12px;
}
/* 操作按钮 */
.action-row {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 2px;
border-radius: 8px;
background: rgba(248, 250, 252, 0.72);
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.14);
white-space: nowrap;
}
.action-btn {
width: 28px;
height: 28px;
min-height: 28px;
padding: 0;
margin: 0;
border-radius: 7px;
font-size: 15px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(248, 250, 252, 0.82));
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), inset 0 0 0 1px rgba(148, 163, 184, 0.16);
transition: transform 120ms ease, box-shadow 120ms ease, background-color 120ms ease;
}
.action-row :deep(.el-button + .el-button) {
margin-left: 0;
}
.action-row :deep(.el-button .el-icon) {
width: 15px;
height: 15px;
}
.action-btn:hover {
transform: translateY(-1px);
background: #fff;
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.1), inset 0 0 0 1px rgba(148, 163, 184, 0.22);
}
.action-btn.is-disabled {
transform: none;
opacity: 0.45;
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.12);
}
/* 表格区域 */
.user-table-section {
padding: 0;
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;
}
.user-table :deep(td.el-table__cell) {
padding-top: 10px;
padding-bottom: 10px;
}
.pagination-wrap {
margin-top: 10px;
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);
}
.filter-spacer {
display: none;
}
}
</style>