调整账号注册与删除流程
This commit is contained in:
@@ -1,62 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db_session, is_system_admin, require_roles
|
||||
from app.crud import user as user_crud
|
||||
from app.models.user import User, UserStatus
|
||||
from app.schemas.user import AdminUserListResponse, UserAdminReviewRequest, UserResponse
|
||||
|
||||
router = APIRouter(prefix="/users")
|
||||
|
||||
|
||||
@router.get("/", response_model=AdminUserListResponse)
|
||||
async def list_users_for_review(
|
||||
status_filter: UserStatus = Query(default=UserStatus.PENDING, alias="status"),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(require_roles(["ADMIN"])),
|
||||
) -> AdminUserListResponse:
|
||||
users = await user_crud.list_users_by_status(db, status=status_filter)
|
||||
return AdminUserListResponse(items=list(users), total=len(users))
|
||||
|
||||
|
||||
async def _get_review_user(db: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
user = await user_crud.get_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
if is_system_admin(user):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="管理员账号不允许审核")
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/{user_id}/approve", response_model=UserResponse)
|
||||
async def approve_user(
|
||||
user_id: uuid.UUID,
|
||||
review: UserAdminReviewRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(require_roles(["ADMIN"])),
|
||||
) -> UserResponse:
|
||||
if review.action != "approve":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
|
||||
user = await _get_review_user(db, user_id)
|
||||
if user.status != UserStatus.PENDING:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许审核待审核用户")
|
||||
user = await user_crud.approve_user(db, user, admin_id=current_user.id)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/{user_id}/reject", response_model=UserResponse)
|
||||
async def reject_user(
|
||||
user_id: uuid.UUID,
|
||||
review: UserAdminReviewRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(require_roles(["ADMIN"])),
|
||||
) -> UserResponse:
|
||||
if review.action not in ("reject", "approve"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
|
||||
user = await _get_review_user(db, user_id)
|
||||
if user.status != UserStatus.PENDING:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许审核待审核用户")
|
||||
user = await user_crud.reject_user(db, user, admin_id=current_user.id)
|
||||
return user
|
||||
@@ -116,8 +116,8 @@ async def register(
|
||||
existing = await user_crud.get_by_email(db, payload.email)
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册")
|
||||
await user_crud.create_pending_user(db, payload)
|
||||
return {"message": "注册成功,等待管理员审核"}
|
||||
await user_crud.create_registered_user(db, payload)
|
||||
return {"message": "注册成功,请登录"}
|
||||
|
||||
|
||||
@router.get("/login-key", response_model=LoginKeyResponse)
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles
|
||||
from app.api.v1 import auth, users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles
|
||||
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
|
||||
api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["overview"])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db_session, is_system_admin, require_roles
|
||||
@@ -97,4 +98,16 @@ async def delete_user(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该账号仍在项目成员中,请先在项目成员配置中移除后再删除",
|
||||
)
|
||||
await user_crud.delete_user(db, db_user)
|
||||
if await user_crud.user_has_retained_history(db, db_user.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该账号已有审计或权限访问记录,请停用账号以保留历史追溯",
|
||||
)
|
||||
try:
|
||||
await user_crud.delete_user(db, db_user)
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该账号已有业务记录,请停用账号以保留历史追溯",
|
||||
) from exc
|
||||
|
||||
+17
-24
@@ -13,6 +13,8 @@ from app.core.config import (
|
||||
PROTECTED_ADMIN_FULL_NAME,
|
||||
)
|
||||
from app.core.security import hash_password
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.permission_access_log import PermissionAccessLog
|
||||
from app.models.study_member import StudyMember
|
||||
from app.models.user import User, UserStatus
|
||||
from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate
|
||||
@@ -53,8 +55,8 @@ async def create_user(
|
||||
return user
|
||||
|
||||
|
||||
async def create_pending_user(db: AsyncSession, user_in: UserRegisterRequest) -> User:
|
||||
return await create_user(db, UserCreate(**user_in.model_dump()), status=UserStatus.PENDING)
|
||||
async def create_registered_user(db: AsyncSession, user_in: UserRegisterRequest) -> User:
|
||||
return await create_user(db, UserCreate(**user_in.model_dump()), status=UserStatus.ACTIVE)
|
||||
|
||||
|
||||
async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User:
|
||||
@@ -150,6 +152,19 @@ async def count_active_admins(db: AsyncSession) -> int:
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
|
||||
async def user_has_retained_history(db: AsyncSession, user_id: uuid.UUID) -> bool:
|
||||
audit_count = await db.scalar(
|
||||
select(func.count()).select_from(AuditLog).where(AuditLog.operator_id == user_id)
|
||||
)
|
||||
if int(audit_count or 0) > 0:
|
||||
return True
|
||||
|
||||
permission_log_count = await db.scalar(
|
||||
select(func.count()).select_from(PermissionAccessLog).where(PermissionAccessLog.user_id == user_id)
|
||||
)
|
||||
return int(permission_log_count or 0) > 0
|
||||
|
||||
|
||||
async def list_users_by_status(
|
||||
db: AsyncSession, status: UserStatus | None = None, skip: int = 0, limit: int = 100
|
||||
) -> Sequence[User]:
|
||||
@@ -189,25 +204,3 @@ async def delete_user(db: AsyncSession, user: User) -> None:
|
||||
await db.execute(delete(StudyMember).where(StudyMember.user_id == user.id))
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def approve_user(db: AsyncSession, user: User, admin_id: uuid.UUID) -> User:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(status=UserStatus.ACTIVE, approved_by=admin_id, approved_at=func.now())
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def reject_user(db: AsyncSession, user: User, admin_id: uuid.UUID) -> User:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(status=UserStatus.REJECTED, approved_by=admin_id, approved_at=func.now())
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
@@ -78,15 +78,6 @@ class UserResponse(UserRead):
|
||||
pass
|
||||
|
||||
|
||||
class UserAdminReviewRequest(BaseModel):
|
||||
action: Literal["approve", "reject"]
|
||||
|
||||
|
||||
class AdminUserListResponse(BaseModel):
|
||||
items: list[UserResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class UserSelfUpdate(_PasswordValidator):
|
||||
full_name: Optional[str] = None
|
||||
clinical_department: Optional[str] = None
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -52,7 +52,7 @@ export const privacyPolicySections: AuthProtocolSection[] = [
|
||||
{
|
||||
title: "2. 数据使用与最小必要",
|
||||
paragraphs: [
|
||||
"平台仅在账号注册审核、身份识别、权限分配、项目协同、风险追踪、审计核查和系统安全维护所需范围内使用相关数据。",
|
||||
"平台仅在账号注册、身份识别、权限分配、项目协同、风险追踪、审计核查和系统安全维护所需范围内使用相关数据。",
|
||||
"用户应遵循最小必要原则录入、查看、导出和传播数据,不得上传与当前项目管理无关的个人信息、受试者信息或其他敏感资料。",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user