调整账号注册与删除流程

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
-62
View File
@@ -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
+2 -2
View File
@@ -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 -2
View File
@@ -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"])
+14 -1
View File
@@ -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
View File
@@ -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
-9
View File
@@ -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
-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;