878d1dc701
# Conflicts: # frontend/src/views/admin/Projects.test.ts # frontend/src/views/admin/Projects.vue
614 lines
17 KiB
Vue
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>
|