调整账号注册与删除流程
This commit is contained in:
@@ -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