移除用户系统级角色,权限完全迁移至项目级管理
- 用户注册/创建不再需要选择角色,账号管理只管资料和状态 - 移除前后端所有系统级 role 字段的暴露,改用 is_admin 标识管理员 - PM(项目负责人)角色自动拥有全部项目权限且不可修改 - 系统管理员在项目中的成员身份不可被删除 - 管理界面全面美化 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,71 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<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--pending">
|
||||
<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"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-value">{{ pendingCount }}</span>
|
||||
<span class="stat-label">{{ TEXT.enums.userStatus.PENDING }}</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
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
:placeholder="TEXT.common.placeholders.keyword"
|
||||
clearable
|
||||
class="filter-input-comp"
|
||||
:prefix-icon="Search"
|
||||
@keyup.enter="loadUsers"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-item-form">
|
||||
@@ -20,29 +77,42 @@
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item-form">
|
||||
<el-button @click="loadUsers">{{ TEXT.common.actions.search }}</el-button>
|
||||
<el-button @click="loadUsers" :icon="Refresh">{{ TEXT.common.actions.search }}</el-button>
|
||||
</div>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
|
||||
<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" stripe v-loading="loading" class="user-table" style="width: 100%" table-layout="fixed">
|
||||
<el-table-column prop="email" :label="TEXT.common.fields.email" show-overflow-tooltip />
|
||||
<el-table-column prop="full_name" :label="TEXT.common.fields.name" show-overflow-tooltip />
|
||||
<el-table-column prop="clinical_department" :label="TEXT.modules.adminUsers.clinicalDepartmentLabel" show-overflow-tooltip />
|
||||
<el-table-column :label="TEXT.common.fields.status">
|
||||
<el-table :data="users" v-loading="loading" class="user-table" style="width: 100%" table-layout="fixed">
|
||||
<el-table-column :label="TEXT.common.fields.name" min-width="200">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusType(scope.row.status)">
|
||||
{{ statusLabel(scope.row.status) }}
|
||||
</el-tag>
|
||||
<div class="user-cell">
|
||||
<div class="user-avatar" :class="'avatar--' + (scope.row.status || '').toLowerCase()">
|
||||
{{ getInitials(scope.row.full_name) }}
|
||||
</div>
|
||||
<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="created_at" :label="TEXT.modules.adminUsers.createdAt" show-overflow-tooltip>
|
||||
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
|
||||
<el-table-column prop="clinical_department" :label="TEXT.modules.adminUsers.clinicalDepartmentLabel" show-overflow-tooltip />
|
||||
<el-table-column :label="TEXT.common.fields.status" 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 :label="TEXT.common.labels.actions" width="220" align="center">
|
||||
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="160" 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" width="180" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<div class="action-row">
|
||||
<el-tooltip :content="TEXT.common.actions.edit" placement="top">
|
||||
@@ -93,9 +163,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Edit, Delete, Key, Lock, Unlock } from "@element-plus/icons-vue";
|
||||
import { Edit, Delete, Key, Lock, Unlock, Search, Plus, Refresh } from "@element-plus/icons-vue";
|
||||
import { fetchUsers, updateUser, deleteUser } from "../../api/users";
|
||||
import { fetchStudies } from "../../api/studies";
|
||||
import { listMembers } from "../../api/members";
|
||||
@@ -107,6 +177,7 @@ 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);
|
||||
@@ -120,44 +191,46 @@ const editingUser = ref<UserInfo | null>(null);
|
||||
const resetUser = ref<UserInfo | null>(null);
|
||||
const auth = useAuthStore();
|
||||
|
||||
const activeCount = computed(() => allUsers.value.filter(u => u.status === 'ACTIVE').length);
|
||||
const pendingCount = computed(() => allUsers.value.filter(u => u.status === 'PENDING').length);
|
||||
const disabledCount = computed(() => allUsers.value.filter(u => u.status === 'DISABLED').length);
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
if (!name) return '?';
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
const statusType = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return "success";
|
||||
case "PENDING":
|
||||
return "warning";
|
||||
case "REJECTED":
|
||||
return "danger";
|
||||
default:
|
||||
return "info";
|
||||
case "ACTIVE": return "success";
|
||||
case "PENDING": return "warning";
|
||||
case "REJECTED": return "danger";
|
||||
default: return "info";
|
||||
}
|
||||
};
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return TEXT.enums.userStatus.ACTIVE;
|
||||
case "PENDING":
|
||||
return TEXT.enums.userStatus.PENDING;
|
||||
case "REJECTED":
|
||||
return TEXT.enums.userStatus.REJECTED;
|
||||
case "DISABLED":
|
||||
return TEXT.enums.userStatus.DISABLED;
|
||||
default:
|
||||
return status || TEXT.common.fallback;
|
||||
case "ACTIVE": return TEXT.enums.userStatus.ACTIVE;
|
||||
case "PENDING": return TEXT.enums.userStatus.PENDING;
|
||||
case "REJECTED": return TEXT.enums.userStatus.REJECTED;
|
||||
case "DISABLED": return TEXT.enums.userStatus.DISABLED;
|
||||
default: return status || TEXT.common.fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const isLastAdmin = (row: UserInfo) =>
|
||||
row.role === "ADMIN" && row.status === "ACTIVE" && activeAdminCount.value <= 1;
|
||||
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,
|
||||
fetchUsers({
|
||||
skip: (page.value - 1) * pageSize.value,
|
||||
limit: pageSize.value,
|
||||
keyword: searchKeyword.value || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
}),
|
||||
@@ -166,8 +239,9 @@ const loadUsers = async () => {
|
||||
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.role === "ADMIN" && u.status === "ACTIVE").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 {
|
||||
@@ -260,19 +334,15 @@ const findUserProjects = async (userId: string) => {
|
||||
const { data } = await fetchStudies();
|
||||
const studies = Array.isArray(data) ? data : (data as any).items || [];
|
||||
const blocking: string[] = [];
|
||||
|
||||
// Check each study for membership
|
||||
// We run these in parallel but limited batch might be better if many studies.
|
||||
// For now assuming reasonable number of studies.
|
||||
await Promise.all(studies.map(async (study: any) => {
|
||||
try {
|
||||
const { data: membersData } = await listMembers(study.id, { limit: 1000, include_inactive: true }); // Large limit to catch all
|
||||
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 {
|
||||
// Ignore errors checking specific study
|
||||
// skip
|
||||
}
|
||||
}));
|
||||
return blocking;
|
||||
@@ -281,34 +351,89 @@ const findUserProjects = async (userId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers();
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--ctms-neutral-100);
|
||||
transition: var(--ctms-transition);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--ctms-shadow-sm);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.stat-card--total .stat-icon {
|
||||
background: #e8edf3;
|
||||
color: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.stat-card--active .stat-icon {
|
||||
background: #e6f4ed;
|
||||
color: var(--ctms-success);
|
||||
}
|
||||
|
||||
.stat-card--pending .stat-icon {
|
||||
background: #fef3e2;
|
||||
color: var(--ctms-warning);
|
||||
}
|
||||
|
||||
.stat-card--disabled .stat-icon {
|
||||
background: #fde8e8;
|
||||
color: var(--ctms-danger);
|
||||
}
|
||||
|
||||
.stat-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.main-content-flat {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.main-content-flat :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -322,28 +447,121 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.filter-input-comp {
|
||||
width: 190px;
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 用户单元格 */
|
||||
.user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
background: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.user-avatar.avatar--active {
|
||||
background: linear-gradient(135deg, #3f8f6b, #2d7a5a);
|
||||
}
|
||||
|
||||
.user-avatar.avatar--pending {
|
||||
background: linear-gradient(135deg, #c58b2a, #a87420);
|
||||
}
|
||||
|
||||
.user-avatar.avatar--disabled {
|
||||
background: linear-gradient(135deg, #94a3b8, #64748b);
|
||||
}
|
||||
|
||||
.user-avatar.avatar--rejected {
|
||||
background: linear-gradient(135deg, #c24b4b, #a83a3a);
|
||||
}
|
||||
|
||||
.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--pending {
|
||||
background: var(--ctms-warning);
|
||||
box-shadow: 0 0 0 3px rgba(197, 139, 42, 0.15);
|
||||
}
|
||||
|
||||
.status-dot.dot--disabled {
|
||||
background: var(--ctms-text-disabled);
|
||||
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.status-dot.dot--rejected {
|
||||
background: var(--ctms-danger);
|
||||
box-shadow: 0 0 0 3px rgba(194, 75, 75, 0.15);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.action-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 表格区域 */
|
||||
.user-table-section {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.user-table {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.user-table :deep(.el-table__inner-wrapper::before) {
|
||||
display: none;
|
||||
}
|
||||
@@ -354,14 +572,20 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.user-table :deep(td.el-table__cell) {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 0 6px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user