移除用户系统级角色,权限完全迁移至项目级管理

- 用户注册/创建不再需要选择角色,账号管理只管资料和状态
- 移除前后端所有系统级 role 字段的暴露,改用 is_admin 标识管理员
- PM(项目负责人)角色自动拥有全部项目权限且不可修改
- 系统管理员在项目中的成员身份不可被删除
- 管理界面全面美化

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-05-20 15:52:17 +08:00
parent 6cefa620e4
commit 5b97ea32ab
35 changed files with 1937 additions and 481 deletions
+296 -72
View File
@@ -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>