调整账号注册与删除流程

This commit is contained in:
Cheng Zhou
2026-06-29 16:25:04 +08:00
parent e242e067f0
commit 6c2bcc59b2
15 changed files with 44 additions and 589 deletions
-12
View File
@@ -1,12 +0,0 @@
import type { AxiosResponse } from "axios";
import { apiGet, apiPost } from "./axios";
import type { AdminUserListResponse, UserInfo, UserStatus } from "../types/api";
export const listPendingUsers = (status: UserStatus = "PENDING"): Promise<AxiosResponse<AdminUserListResponse>> =>
apiGet("/api/v1/admin/users/", { params: { status } });
export const approveUser = (userId: string): Promise<AxiosResponse<UserInfo>> =>
apiPost(`/api/v1/admin/users/${userId}/approve`, { action: "approve" });
export const rejectUser = (userId: string): Promise<AxiosResponse<UserInfo>> =>
apiPost(`/api/v1/admin/users/${userId}/reject`, { action: "reject" });
+1 -1
View File
@@ -20,4 +20,4 @@ export const updateUser = (
apiPatch<UserInfo>(`/api/v1/users/${userId}`, payload);
export const deleteUser = (userId: string) =>
apiDelete<void>(`/api/v1/users/${userId}`);
apiDelete<void>(`/api/v1/users/${userId}`, { suppressErrorMessage: true });
+1 -1
View File
@@ -52,7 +52,7 @@ export const privacyPolicySections: AuthProtocolSection[] = [
{
title: "2. 数据使用与最小必要",
paragraphs: [
"平台仅在账号注册审核、身份识别、权限分配、项目协同、风险追踪、审计核查和系统安全维护所需范围内使用相关数据。",
"平台仅在账号注册、身份识别、权限分配、项目协同、风险追踪、审计核查和系统安全维护所需范围内使用相关数据。",
"用户应遵循最小必要原则录入、查看、导出和传播数据,不得上传与当前项目管理无关的个人信息、受试者信息或其他敏感资料。",
],
},
+3 -17
View File
@@ -329,9 +329,9 @@ export const TEXT = {
footer: "© 2025 CTMS Enterprise. 专业临床研究团队支持。",
authFailed: "认证失败,请检查您的凭据",
registerTitle: "创建账号",
registerDesc: "提交后需管理员审核通过方可登录",
registerButton: "提交注册",
registerSuccess: "注册成功,等待管理员审核",
registerDesc: "完成邮箱验证后即可创建可用账号",
registerButton: "完成注册",
registerSuccess: "注册成功,请登录",
backToLogin: "已有账号?返回登录",
passwordRuleMin: "密码至少 8 位",
passwordRuleMix: "需包含字母和数字",
@@ -775,18 +775,6 @@ export const TEXT = {
siteRequired: "中心级目录需选择分中心",
},
},
adminUserApproval: {
title: "注册审核",
subtitle: "仅显示待审核账号,审核通过后即可登录",
createdAt: "注册时间",
approve: "审核通过",
reject: "拒绝",
loadFailed: "加载待审核用户失败",
approveSuccess: "已通过审核",
approveFailed: "审核失败",
rejectSuccess: "已拒绝",
rejectFailed: "拒绝失败",
},
adminUsers: {
title: "账号管理(系统登录账号)",
subtitle: "账号用于登录系统,本身不具备项目或角色权限",
@@ -994,9 +982,7 @@ export const TEXT = {
CTA: "CTA",
},
userStatus: {
PENDING: "待审核",
ACTIVE: "启用",
REJECTED: "已拒绝",
DISABLED: "已禁用",
},
subjectStatus: {
-7
View File
@@ -14,7 +14,6 @@ import StudyHome from "../views/StudyHome.vue";
import FaqDetail from "../views/FaqDetail.vue";
import AuditLogs from "../views/admin/AuditLogs.vue";
import AdminUsers from "../views/admin/Users.vue";
import AdminUserApproval from "../views/admin/AdminUserApproval.vue";
import AdminProjects from "../views/admin/Projects.vue";
import AdminSites from "../views/admin/Sites.vue";
import PermissionManagement from "../views/admin/PermissionManagement.vue";
@@ -334,12 +333,6 @@ const routes: RouteRecordRaw[] = [
component: AdminUsers,
meta: { title: TEXT.menu.accountManagement, requiresAdmin: true },
},
{
path: "user-approval",
name: "AdminUserApproval",
component: AdminUserApproval,
meta: { title: TEXT.modules.adminUserApproval.title, requiresAdmin: true },
},
{
path: "projects",
name: "AdminProjects",
+1 -1
View File
@@ -300,7 +300,7 @@ const onSubmit = async () => {
} else if (status === 429) {
loginError.value = { title: "操作过于频繁", message: "登录尝试次数过多,请稍后再试" };
} else if (status === 403 || detail.includes("未激活") || detail.includes("审核") || detail.includes("pending")) {
loginError.value = { title: "账号待审核", message: "您的账号尚未通过管理员审批,请耐心等待或联系管理员" };
loginError.value = { title: "账号不可用", message: "请联系系统管理员确认账号状态" };
} else if (detail.includes("锁定") || detail.includes("locked") || detail.includes("disabled")) {
loginError.value = { title: "账号已被锁定", message: "请联系系统管理员解锁账号" };
} else if (status === 401) {
+3 -3
View File
@@ -109,9 +109,9 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
</svg>
</div>
<h3 class="approval-title">注册申请已提交</h3>
<p class="approval-message">待管理员审核通过后方可登录系统</p>
<p class="approval-hint">管理员审核通过前请勿重复提交注册申请</p>
<h3 class="approval-title">注册成功</h3>
<p class="approval-message">账号已启用可以立即登录系统</p>
<p class="approval-hint">请使用刚注册的邮箱和密码登录</p>
<RouterLink to="/login" class="back-to-login-btn">返回登录</RouterLink>
</section>
@@ -1,405 +0,0 @@
<template>
<div class="page page--flush">
<div class="main-content-flat unified-shell">
<!-- 顶部状态切换 -->
<div class="approval-header unified-action-bar">
<div class="filter-form">
<div class="status-tabs">
<button
v-for="s in statusOptions"
:key="s.value"
class="status-tab"
:class="{ active: status === s.value }"
@click="status = s.value; loadUsers()"
>
<span class="tab-dot" :class="'dot--' + s.value.toLowerCase()"></span>
{{ s.label }}
<span v-if="s.value === 'PENDING' && pendingCount > 0" class="tab-badge">{{ pendingCount }}</span>
</button>
</div>
</div>
</div>
<!-- 内容区域 -->
<div class="approval-content">
<!-- 空状态 -->
<div v-if="!loading && users.length === 0" class="empty-state">
<div class="empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<p class="empty-title">{{ status === 'PENDING' ? '暂无待审批用户' : '暂无数据' }}</p>
<p class="empty-desc">{{ status === 'PENDING' ? '所有注册申请已处理完毕' : '当前筛选条件下没有用户' }}</p>
</div>
<!-- 用户卡片列表 -->
<div v-else class="user-cards" v-loading="loading">
<div v-for="user in users" :key="user.id" class="user-card" :class="'card--' + (user.status || '').toLowerCase()">
<div class="card-main">
<div class="card-avatar" :class="'avatar--' + (user.status || '').toLowerCase()">
{{ getInitials(user.full_name) }}
</div>
<div class="card-info">
<div class="card-name">{{ user.full_name }}</div>
<div class="card-email">{{ user.email }}</div>
<div class="card-meta">
<span class="meta-item" v-if="user.clinical_department">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
<path d="M19 21V5a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-1 4h1"/>
</svg>
{{ user.clinical_department }}
</span>
<span class="meta-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
{{ displayDateTime(user.created_at) }}
</span>
</div>
</div>
</div>
<div class="card-actions">
<template v-if="user.status === 'PENDING'">
<el-button type="success" @click="onApprove(user.id)" class="action-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="14" height="14">
<polyline points="20 6 9 17 4 12"/>
</svg>
{{ TEXT.modules.adminUserApproval.approve }}
</el-button>
<el-button type="danger" plain @click="onReject(user.id)" class="action-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="14" height="14">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
{{ TEXT.modules.adminUserApproval.reject }}
</el-button>
</template>
<template v-else>
<el-tag :type="statusType(user.status)" effect="light" class="status-tag">
{{ displayEnum(TEXT.enums.userStatus, user.status) }}
</el-tag>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { approveUser, listPendingUsers, rejectUser } from "../../api/admin";
import type { UserInfo, UserStatus } from "../../types/api";
import { displayDateTime, displayEnum } from "../../utils/display";
import { TEXT } from "../../locales";
const users = ref<UserInfo[]>([]);
const loading = ref(false);
const status = ref<UserStatus>("PENDING");
const pendingCount = ref(0);
const statusOptions = [
{ value: "PENDING" as UserStatus, label: TEXT.enums.userStatus.PENDING },
{ value: "ACTIVE" as UserStatus, label: TEXT.enums.userStatus.ACTIVE },
{ value: "REJECTED" as UserStatus, label: TEXT.enums.userStatus.REJECTED },
{ value: "DISABLED" as UserStatus, label: TEXT.enums.userStatus.DISABLED },
];
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 = (s: UserStatus) => {
switch (s) {
case "ACTIVE": return "success";
case "PENDING": return "warning";
case "REJECTED": return "danger";
default: return "info";
}
};
const loadUsers = async () => {
loading.value = true;
try {
const { data } = await listPendingUsers(status.value);
users.value = data.items || [];
if (status.value === "PENDING") {
pendingCount.value = users.value.length;
}
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || TEXT.modules.adminUserApproval.loadFailed);
} finally {
loading.value = false;
}
};
const loadPendingCount = async () => {
try {
const { data } = await listPendingUsers("PENDING");
pendingCount.value = (data.items || []).length;
} catch {
// skip
}
};
const onApprove = async (id: string) => {
try {
await approveUser(id);
ElMessage.success(TEXT.modules.adminUserApproval.approveSuccess);
loadUsers();
loadPendingCount();
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || TEXT.modules.adminUserApproval.approveFailed);
}
};
const onReject = async (id: string) => {
try {
await rejectUser(id);
ElMessage.success(TEXT.modules.adminUserApproval.rejectSuccess);
loadUsers();
loadPendingCount();
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || TEXT.modules.adminUserApproval.rejectFailed);
}
};
onMounted(() => {
loadUsers();
if (status.value !== "PENDING") {
loadPendingCount();
}
});
</script>
<style scoped>
.approval-header {
border-bottom: 1px solid var(--unified-shell-divider);
}
.status-tabs {
display: flex;
gap: 4px;
}
.status-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: none;
border-radius: 8px;
background: transparent;
font-size: 13px;
font-weight: 600;
color: var(--ctms-text-secondary);
cursor: pointer;
transition: var(--ctms-transition);
}
.status-tab:hover {
background: var(--ctms-neutral-100);
color: var(--ctms-text-main);
}
.status-tab.active {
background: var(--ctms-primary-light);
color: var(--ctms-primary);
}
.tab-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.tab-dot.dot--pending { color: var(--ctms-warning); }
.tab-dot.dot--active { color: var(--ctms-success); }
.tab-dot.dot--rejected { color: var(--ctms-danger); }
.tab-dot.dot--disabled { color: var(--ctms-text-disabled); }
.tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--ctms-danger);
color: #fff;
font-size: 11px;
font-weight: 700;
}
.approval-content {
padding: 16px;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
}
.empty-icon {
width: 64px;
height: 64px;
border-radius: 16px;
background: #e6f4ed;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.empty-icon svg {
width: 32px;
height: 32px;
color: var(--ctms-success);
}
.empty-title {
font-size: 15px;
font-weight: 600;
color: var(--ctms-text-main);
margin: 0 0 4px;
}
.empty-desc {
font-size: 13px;
color: var(--ctms-text-secondary);
margin: 0;
}
/* 用户卡片 */
.user-cards {
display: flex;
flex-direction: column;
gap: 10px;
}
.user-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid var(--ctms-border-color);
background: #fff;
transition: var(--ctms-transition);
}
.user-card:hover {
border-color: var(--ctms-border-color-hover);
box-shadow: var(--ctms-shadow-sm);
}
.user-card.card--pending {
border-left: 3px solid var(--ctms-warning);
}
.card-main {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
flex: 1;
}
.card-avatar {
width: 42px;
height: 42px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
color: #fff;
flex-shrink: 0;
background: var(--ctms-primary);
}
.card-avatar.avatar--active { background: linear-gradient(135deg, #3f8f6b, #2d7a5a); }
.card-avatar.avatar--pending { background: linear-gradient(135deg, #c58b2a, #a87420); }
.card-avatar.avatar--disabled { background: linear-gradient(135deg, #94a3b8, #64748b); }
.card-avatar.avatar--rejected { background: linear-gradient(135deg, #c24b4b, #a83a3a); }
.card-info {
min-width: 0;
}
.card-name {
font-size: 14px;
font-weight: 600;
color: var(--ctms-text-main);
}
.card-email {
font-size: 12px;
color: var(--ctms-text-secondary);
margin-top: 1px;
}
.card-meta {
display: flex;
gap: 12px;
margin-top: 6px;
flex-wrap: wrap;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--ctms-text-secondary);
}
.meta-item svg {
opacity: 0.6;
}
.card-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
margin-left: 16px;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 4px;
}
.status-tag {
font-weight: 600;
}
@media (max-width: 640px) {
.user-card {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.card-actions {
margin-left: 56px;
}
}
</style>
+1 -42
View File
@@ -29,18 +29,6 @@
<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">
@@ -70,9 +58,7 @@
</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.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" />
<el-option :label="TEXT.enums.userStatus.REJECTED" value="REJECTED" />
<el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" />
</el-select>
</div>
@@ -189,23 +175,11 @@ const auth = useAuthStore();
let keywordSearchTimer: ReturnType<typeof setTimeout> | null = null;
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 statusType = (status: string) => {
switch (status) {
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;
}
@@ -378,7 +352,7 @@ onBeforeUnmount(() => {
<style scoped>
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
gap: 10px;
padding: 10px 16px;
border-bottom: 1px solid var(--unified-shell-divider);
@@ -424,11 +398,6 @@ onBeforeUnmount(() => {
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);
@@ -517,21 +486,11 @@ onBeforeUnmount(() => {
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;