调整账号注册与删除流程

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) existing = await user_crud.get_by_email(db, payload.email)
if existing: if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册")
await user_crud.create_pending_user(db, payload) await user_crud.create_registered_user(db, payload)
return {"message": "注册成功,等待管理员审核"} return {"message": "注册成功,请登录"}
@router.get("/login-key", response_model=LoginKeyResponse) @router.get("/login-key", response_model=LoginKeyResponse)
+1 -2
View File
@@ -1,11 +1,10 @@
from fastapi import APIRouter 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 = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) 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(users.router, prefix="/users", tags=["users"])
api_router.include_router(studies.router, prefix="/studies", tags=["studies"]) api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["overview"]) api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["overview"])
+14 -1
View File
@@ -1,6 +1,7 @@
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, is_system_admin, require_roles 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, status_code=status.HTTP_400_BAD_REQUEST,
detail="该账号仍在项目成员中,请先在项目成员配置中移除后再删除", 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, PROTECTED_ADMIN_FULL_NAME,
) )
from app.core.security import hash_password 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.study_member import StudyMember
from app.models.user import User, UserStatus from app.models.user import User, UserStatus
from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate
@@ -53,8 +55,8 @@ async def create_user(
return user return user
async def create_pending_user(db: AsyncSession, user_in: UserRegisterRequest) -> User: async def create_registered_user(db: AsyncSession, user_in: UserRegisterRequest) -> User:
return await create_user(db, UserCreate(**user_in.model_dump()), status=UserStatus.PENDING) 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: 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) 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( async def list_users_by_status(
db: AsyncSession, status: UserStatus | None = None, skip: int = 0, limit: int = 100 db: AsyncSession, status: UserStatus | None = None, skip: int = 0, limit: int = 100
) -> Sequence[User]: ) -> 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.execute(delete(StudyMember).where(StudyMember.user_id == user.id))
await db.delete(user) await db.delete(user)
await db.commit() 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 pass
class UserAdminReviewRequest(BaseModel):
action: Literal["approve", "reject"]
class AdminUserListResponse(BaseModel):
items: list[UserResponse]
total: int
class UserSelfUpdate(_PasswordValidator): class UserSelfUpdate(_PasswordValidator):
full_name: Optional[str] = None full_name: Optional[str] = None
clinical_department: 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); apiPatch<UserInfo>(`/api/v1/users/${userId}`, payload);
export const deleteUser = (userId: string) => 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. 数据使用与最小必要", title: "2. 数据使用与最小必要",
paragraphs: [ paragraphs: [
"平台仅在账号注册审核、身份识别、权限分配、项目协同、风险追踪、审计核查和系统安全维护所需范围内使用相关数据。", "平台仅在账号注册、身份识别、权限分配、项目协同、风险追踪、审计核查和系统安全维护所需范围内使用相关数据。",
"用户应遵循最小必要原则录入、查看、导出和传播数据,不得上传与当前项目管理无关的个人信息、受试者信息或其他敏感资料。", "用户应遵循最小必要原则录入、查看、导出和传播数据,不得上传与当前项目管理无关的个人信息、受试者信息或其他敏感资料。",
], ],
}, },
+3 -17
View File
@@ -329,9 +329,9 @@ export const TEXT = {
footer: "© 2025 CTMS Enterprise. 专业临床研究团队支持。", footer: "© 2025 CTMS Enterprise. 专业临床研究团队支持。",
authFailed: "认证失败,请检查您的凭据", authFailed: "认证失败,请检查您的凭据",
registerTitle: "创建账号", registerTitle: "创建账号",
registerDesc: "提交后需管理员审核通过方可登录", registerDesc: "完成邮箱验证后即可创建可用账号",
registerButton: "提交注册", registerButton: "完成注册",
registerSuccess: "注册成功,等待管理员审核", registerSuccess: "注册成功,请登录",
backToLogin: "已有账号?返回登录", backToLogin: "已有账号?返回登录",
passwordRuleMin: "密码至少 8 位", passwordRuleMin: "密码至少 8 位",
passwordRuleMix: "需包含字母和数字", passwordRuleMix: "需包含字母和数字",
@@ -775,18 +775,6 @@ export const TEXT = {
siteRequired: "中心级目录需选择分中心", siteRequired: "中心级目录需选择分中心",
}, },
}, },
adminUserApproval: {
title: "注册审核",
subtitle: "仅显示待审核账号,审核通过后即可登录",
createdAt: "注册时间",
approve: "审核通过",
reject: "拒绝",
loadFailed: "加载待审核用户失败",
approveSuccess: "已通过审核",
approveFailed: "审核失败",
rejectSuccess: "已拒绝",
rejectFailed: "拒绝失败",
},
adminUsers: { adminUsers: {
title: "账号管理(系统登录账号)", title: "账号管理(系统登录账号)",
subtitle: "账号用于登录系统,本身不具备项目或角色权限", subtitle: "账号用于登录系统,本身不具备项目或角色权限",
@@ -994,9 +982,7 @@ export const TEXT = {
CTA: "CTA", CTA: "CTA",
}, },
userStatus: { userStatus: {
PENDING: "待审核",
ACTIVE: "启用", ACTIVE: "启用",
REJECTED: "已拒绝",
DISABLED: "已禁用", DISABLED: "已禁用",
}, },
subjectStatus: { subjectStatus: {
-7
View File
@@ -14,7 +14,6 @@ import StudyHome from "../views/StudyHome.vue";
import FaqDetail from "../views/FaqDetail.vue"; import FaqDetail from "../views/FaqDetail.vue";
import AuditLogs from "../views/admin/AuditLogs.vue"; import AuditLogs from "../views/admin/AuditLogs.vue";
import AdminUsers from "../views/admin/Users.vue"; import AdminUsers from "../views/admin/Users.vue";
import AdminUserApproval from "../views/admin/AdminUserApproval.vue";
import AdminProjects from "../views/admin/Projects.vue"; import AdminProjects from "../views/admin/Projects.vue";
import AdminSites from "../views/admin/Sites.vue"; import AdminSites from "../views/admin/Sites.vue";
import PermissionManagement from "../views/admin/PermissionManagement.vue"; import PermissionManagement from "../views/admin/PermissionManagement.vue";
@@ -334,12 +333,6 @@ const routes: RouteRecordRaw[] = [
component: AdminUsers, component: AdminUsers,
meta: { title: TEXT.menu.accountManagement, requiresAdmin: true }, meta: { title: TEXT.menu.accountManagement, requiresAdmin: true },
}, },
{
path: "user-approval",
name: "AdminUserApproval",
component: AdminUserApproval,
meta: { title: TEXT.modules.adminUserApproval.title, requiresAdmin: true },
},
{ {
path: "projects", path: "projects",
name: "AdminProjects", name: "AdminProjects",
+1 -1
View File
@@ -300,7 +300,7 @@ const onSubmit = async () => {
} else if (status === 429) { } else if (status === 429) {
loginError.value = { title: "操作过于频繁", message: "登录尝试次数过多,请稍后再试" }; loginError.value = { title: "操作过于频繁", message: "登录尝试次数过多,请稍后再试" };
} else if (status === 403 || detail.includes("未激活") || detail.includes("审核") || detail.includes("pending")) { } 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")) { } else if (detail.includes("锁定") || detail.includes("locked") || detail.includes("disabled")) {
loginError.value = { title: "账号已被锁定", message: "请联系系统管理员解锁账号" }; loginError.value = { title: "账号已被锁定", message: "请联系系统管理员解锁账号" };
} else if (status === 401) { } 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"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
</svg> </svg>
</div> </div>
<h3 class="approval-title">注册申请已提交</h3> <h3 class="approval-title">注册成功</h3>
<p class="approval-message">待管理员审核通过后方可登录系统</p> <p class="approval-message">账号已启用可以立即登录系统</p>
<p class="approval-hint">管理员审核通过前请勿重复提交注册申请</p> <p class="approval-hint">请使用刚注册的邮箱和密码登录</p>
<RouterLink to="/login" class="back-to-login-btn">返回登录</RouterLink> <RouterLink to="/login" class="back-to-login-btn">返回登录</RouterLink>
</section> </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> <span class="stat-label">{{ TEXT.enums.userStatus.ACTIVE }}</span>
</div> </div>
</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-card stat-card--disabled">
<div class="stat-icon"> <div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
@@ -70,9 +58,7 @@
</div> </div>
<div class="filter-item-form"> <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-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.ACTIVE" value="ACTIVE" />
<el-option :label="TEXT.enums.userStatus.REJECTED" value="REJECTED" />
<el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" /> <el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" />
</el-select> </el-select>
</div> </div>
@@ -189,23 +175,11 @@ const auth = useAuthStore();
let keywordSearchTimer: ReturnType<typeof setTimeout> | null = null; let keywordSearchTimer: ReturnType<typeof setTimeout> | null = null;
const activeCount = computed(() => allUsers.value.filter(u => u.status === 'ACTIVE').length); 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 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) => { const statusLabel = (status: string) => {
switch (status) { switch (status) {
case "ACTIVE": return TEXT.enums.userStatus.ACTIVE; 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; case "DISABLED": return TEXT.enums.userStatus.DISABLED;
default: return status || TEXT.common.fallback; default: return status || TEXT.common.fallback;
} }
@@ -378,7 +352,7 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
.stats-row { .stats-row {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 10px; gap: 10px;
padding: 10px 16px; padding: 10px 16px;
border-bottom: 1px solid var(--unified-shell-divider); border-bottom: 1px solid var(--unified-shell-divider);
@@ -424,11 +398,6 @@ onBeforeUnmount(() => {
color: var(--ctms-success); color: var(--ctms-success);
} }
.stat-card--pending .stat-icon {
background: #fef3e2;
color: var(--ctms-warning);
}
.stat-card--disabled .stat-icon { .stat-card--disabled .stat-icon {
background: #fde8e8; background: #fde8e8;
color: var(--ctms-danger); color: var(--ctms-danger);
@@ -517,21 +486,11 @@ onBeforeUnmount(() => {
box-shadow: 0 0 0 3px rgba(63, 143, 107, 0.15); 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 { .status-dot.dot--disabled {
background: var(--ctms-text-disabled); background: var(--ctms-text-disabled);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.15); 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 { .text-muted {
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
font-size: 12px; font-size: 12px;