feat: deliver admin redesign pilot

This commit is contained in:
2026-09-11 17:27:12 +08:00
parent d8338f225b
commit d8f3c2a42e
6 changed files with 732 additions and 419 deletions
+88 -131
View File
@@ -1,176 +1,133 @@
<template>
<div class="page-container" v-loading="loading">
<el-card shadow="never" v-if="user">
<template #header>
<div class="card-header">
<span>用户详情</span>
<el-tag :type="accountStatusType(user.accountStatus)">{{ accountStatusText(user.accountStatus) }}</el-tag>
<el-tag :type="riskLevelType(user.riskLevel)">{{ riskLevelText(user.riskLevel) }}</el-tag>
</div>
</template>
<el-tabs v-model="activeTab">
<!-- 基础信息 -->
<div class="page-container page-shell" v-loading="loading">
<header class="page-heading">
<div>
<el-button class="back-button" link @click="router.push('/users')">
<el-icon><ArrowLeft /></el-icon>返回用户列表
</el-button>
<h1>用户详情</h1>
<p>敏感信息遵循最小权限原则,查看与处置操作均需审计。</p>
</div>
<div v-if="user" class="heading-tags">
<el-tag :type="accountStatusType(user.accountStatus)">{{ accountStatusText(user.accountStatus) }}</el-tag>
<el-tag :type="riskLevelType(user.riskLevel)" effect="plain">{{ riskLevelText(user.riskLevel) }}</el-tag>
</div>
</header>
<el-alert v-if="errorMessage" :title="errorMessage" type="error" :closable="false" show-icon>
<template #default><el-button v-if="accountId" link type="primary" @click="loadDetail">重新加载</el-button></template>
</el-alert>
<section v-else-if="user" class="surface-card detail-card">
<div class="identity-summary">
<el-avatar :size="58">{{ (user.displayName || '用')[0] }}</el-avatar>
<div><h2>{{ user.displayName || '未设置显示名称' }}</h2><span>用户编号:{{ user.id }}</span></div>
<el-progress class="completion" type="dashboard" :percentage="user.profileCompletion || 0" :width="72" :stroke-width="7" color="#EE666B" />
</div>
<el-tabs v-model="activeTab" class="detail-tabs">
<el-tab-pane label="基础信息" name="basic">
<el-descriptions :column="2" border>
<el-descriptions-item label="用户ID">{{ user.id }}</el-descriptions-item>
<el-descriptions-item label="显示名称">{{ user.displayName }}</el-descriptions-item>
<el-descriptions-item label="显示名称">{{ user.displayName || '-' }}</el-descriptions-item>
<el-descriptions-item label="实名认证">{{ user.idVerified ? '已认证' : '未认证' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderText(user.gender) }}</el-descriptions-item>
<el-descriptions-item label="年龄">{{ user.age || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机">
<template v-if="user.sensitiveMasked">
<span class="masked">{{ user.phoneMasked }}</span>
<el-button size="small" link @click="requestSensitive">需要查看</el-button>
</template>
<template v-else>{{ user.phone }}</template>
<el-descriptions-item label="手机号">
<template v-if="user.sensitiveMasked"><span class="masked">{{ user.phoneMasked || '已脱敏' }}</span><el-button link type="primary" @click="requestSensitive">权限说明</el-button></template>
<template v-else>{{ user.phone || '-' }}</template>
</el-descriptions-item>
<el-descriptions-item label="区域">{{ user.regionName || '-' }}</el-descriptions-item>
<el-descriptions-item label="注册时间">{{ formatTime(user.registeredAt) }}</el-descriptions-item>
<el-descriptions-item label="最近登录">{{ formatTime(user.lastLoginAt) }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<!-- 婚恋资料 -->
<el-tab-pane label="婚恋资料" name="profile">
<el-descriptions :column="2" border>
<el-descriptions-item label="婚姻状态">{{ maritalText(user.maritalStatus) }}</el-descriptions-item>
<el-descriptions-item label="子女情况">{{ childrenText(user.childrenStatus) }}</el-descriptions-item>
<el-descriptions-item label="学历">{{ eduText(user.educationCode) }}</el-descriptions-item>
<el-descriptions-item label="职业">{{ occText(user.occupationCode) }}</el-descriptions-item>
<el-descriptions-item label="自我介绍" :span="2">{{ user.selfIntro || '未填写' }}</el-descriptions-item>
<el-descriptions-item label="资料完整度">{{ user.profileCompletion }}%</el-descriptions-item>
<el-descriptions-item label="学历">{{ user.educationCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="职业">{{ user.occupationCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="资料状态">{{ profileStatusText(user.profileStatus) }}</el-descriptions-item>
<el-descriptions-item label="关系状态">{{ relationshipText(user.relationshipStatus) }}</el-descriptions-item>
<el-descriptions-item label="自我介绍" :span="2">{{ user.selfIntro || '未填写' }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<!-- 风险记录 -->
<el-tab-pane label="风险记录" name="risk">
<el-descriptions :column="2" border>
<el-descriptions-item label="风险分数">{{ user.totalRiskScore ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="风险等级">
<el-tag :type="riskLevelType(user.riskLevel)">{{ riskLevelText(user.riskLevel) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="历史举报数">{{ user.totalReportsCount ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="当前处置数">{{ user.activeActionsCount ?? 0 }}</el-descriptions-item>
</el-descriptions>
<div style="margin-top:16px">
<el-button type="primary" size="small" @click="showStatusDialog = true">修改账号状态</el-button>
<el-tab-pane label="安全与账号" name="risk">
<div class="risk-summary">
<article><span>风险分数</span><strong>{{ user.totalRiskScore ?? 0 }}</strong></article>
<article><span>历史举报</span><strong>{{ user.totalReportsCount ?? 0 }}</strong></article>
<article><span>当前处置</span><strong>{{ user.activeActionsCount ?? 0 }}</strong></article>
</div>
<div class="danger-zone">
<div><h3><el-icon><WarningFilled /></el-icon>账号状态处置</h3><p>状态变更会影响用户登录和平台功能,提交前请确认处置依据。</p></div>
<el-button v-if="canChangeStatus" type="danger" plain @click="openStatusDialog">修改账号状态</el-button>
<el-tag v-else type="info">无状态修改权限</el-tag>
</div>
</el-tab-pane>
<!-- 状态记录 -->
<el-tab-pane label="状态记录" name="status">
<el-descriptions :column="2" border>
<el-descriptions-item label="账号状态">
<el-tag :type="accountStatusType(user.accountStatus)">{{ accountStatusText(user.accountStatus) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="婚恋状态">{{ relationshipText(user.relationshipStatus) }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 修改状态对话框 -->
<el-dialog v-model="showStatusDialog" title="修改账号状态" width="400px">
<el-form label-width="100px">
</section>
<el-empty v-else-if="!loading" description="未找到用户信息" />
<el-dialog v-model="showStatusDialog" title="修改账号状态" width="420px">
<el-alert title="该操作将被写入审计日志,请谨慎处理。" type="warning" :closable="false" show-icon />
<el-form label-position="top" class="status-form">
<el-form-item label="新状态">
<el-select v-model="newStatus" style="width:100%">
<el-option label="正常" value="normal" />
<el-option label="风险审核中" value="reviewing_risk" />
<el-option label="功能受限" value="limited" />
<el-option label="临时暂停" value="suspended" />
<el-option label="封禁" value="banned" />
<el-option label="已注销" value="closed" />
<el-select v-model="newStatus" class="full-width">
<el-option label="正常" value="normal" /><el-option label="风险审核中" value="reviewing_risk" />
<el-option label="功能受限" value="limited" /><el-option label="临时暂停" value="suspended" />
<el-option label="封禁" value="banned" /><el-option label="已注销" value="closed" />
</el-select>
</el-form-item>
<el-form-item label="原因">
<el-input v-model="statusNote" type="textarea" :rows="3" placeholder="请输入修改原因" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showStatusDialog = false">取消</el-button>
<el-button type="primary" :loading="updating" @click="updateStatus">确认</el-button>
</template>
<template #footer><el-button @click="showStatusDialog = false">取消</el-button><el-button type="danger" :loading="updating" @click="updateStatus">确认变更</el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router' // eslint-disable-next-line
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, WarningFilled } from '@element-plus/icons-vue'
import { get, put } from '@/api/client'
const route = useRoute()
const loading = ref(true)
const user = ref<any>(null)
const activeTab = ref('basic')
const showStatusDialog = ref(false)
const newStatus = ref('normal')
const statusNote = ref('')
const updating = ref(false)
const accountId = route.query.id as string
import { useAuthStore } from '@/stores/auth'
const route = useRoute(); const router = useRouter(); const authStore = useAuthStore()
const loading = ref(true); const errorMessage = ref(''); const user = ref<any>(null); const activeTab = ref('basic')
const showStatusDialog = ref(false); const newStatus = ref('normal'); const updating = ref(false)
const accountId = String(route.query.id || '')
const canChangeStatus = computed(() => authStore.permissions.includes('user.status.change'))
const ACCOUNT_MAP: Record<string, { text: string; type: string }> = {
normal: { text: '正常', type: 'success' },
banned: { text: '封禁', type: 'danger' },
suspended: { text: '暂停', type: 'warning' },
reviewing_risk: { text: '风险审核', type: 'danger' },
limited: { text: '受限', type: 'info' }
normal: { text: '正常', type: 'success' }, banned: { text: '封禁', type: 'danger' },
suspended: { text: '暂停', type: 'warning' }, reviewing_risk: { text: '风险审核', type: 'danger' },
limited: { text: '受限', type: 'info' }, closed: { text: '已注销', type: 'info' },
}
const RISK_MAP: Record<string, { text: string; type: string }> = {
normal: { text: '正常', type: 'info' },
watch: { text: '关注', type: '' },
high: { text: '高风险', type: 'warning' },
critical: { text: '极高风险', type: 'danger' }
normal: { text: '正常', type: 'info' }, watch: { text: '关注', type: 'warning' },
high: { text: '高风险', type: 'warning' }, critical: { text: '极高风险', type: 'danger' },
}
async function loadDetail() {
if (!accountId) return
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const res: any = await get(`/operator/users/${accountId}`, {}, { Authorization: `Bearer ${token}` })
user.value = res
} catch (e) { console.error(e) }
if (!accountId) { loading.value = false; errorMessage.value = '缺少用户编号,请从用户列表重新进入'; return }
loading.value = true; errorMessage.value = ''
try { const response: any = await get(`/operator/users/${accountId}`); user.value = response.data }
catch (error: any) { user.value = null; errorMessage.value = error.response?.status === 403 ? '您没有查看该用户详情的权限' : '用户详情加载失败,请检查服务状态后重试' }
finally { loading.value = false }
}
function openStatusDialog() { newStatus.value = user.value?.accountStatus || 'normal'; showStatusDialog.value = true }
async function updateStatus() {
if (!newStatus.value) return
if (!newStatus.value || updating.value) return
await ElMessageBox.confirm(`确认将该用户账号状态修改为“${accountStatusText(newStatus.value)}”吗?`, '确认账号状态变更', { type: 'warning', confirmButtonText: '确认变更', cancelButtonText: '取消' })
updating.value = true
try {
const token = localStorage.getItem('accessToken')
await put(`/operator/users/${accountId}/status`, { accountStatus: newStatus.value }, {
Authorization: `Bearer ${token}`
})
showStatusDialog.value = false
loadDetail()
} catch (e) { console.error(e) }
try { await put(`/operator/users/${accountId}/status`, { accountStatus: newStatus.value }); ElMessage.success('账号状态已更新'); showStatusDialog.value = false; await loadDetail() }
finally { updating.value = false }
}
function requestSensitive() { /* TODO: 需要敏感权限的申请流程 */ }
function genderText(g?: string) { return g === 'male' ? '男' : g === 'female' ? '女' : '-' }
function maritalText(m?: string) { const m2 = m as string | undefined; return m2 && { single: '未婚', divorced: '离异', widowed: '丧偶' }[m2] || '-' }
function childrenText(c?: string) { const c2 = c as string | undefined; return c2 && { none: '无子女', with_children: '有子女' }[c2] || '-' }
function eduText(e?: string) { return e || '-' }
function occText(o?: string) { return o || '-' }
function profileStatusText(s?: string) { const s2 = s as string | undefined; return s2 && { approved: '已审核', pending: '待审核', need_edit: '需修改' }[s2] || s }
function relationshipText(r?: string) { const r2 = r as string | undefined; return r2 && { searching: '寻找中', getting_to_know: '正在了解', matched_successfully: '已牵手', paused: '暂停' }[r2] || r || '-' }
function accountStatusType(s: string) { return ACCOUNT_MAP[s]?.type || 'info' }
function accountStatusText(s: string) { return ACCOUNT_MAP[s]?.text || s }
function riskLevelType(l: string) { return RISK_MAP[l]?.type || 'info' }
function riskLevelText(l: string) { return RISK_MAP[l]?.text || l }
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
function requestSensitive() { ElMessage.info('当前内容已脱敏;只有具备敏感数据权限的角色可以查看明文。') }
function genderText(value?: string) { return value === 'male' ? '男' : value === 'female' ? '女' : '-' }
function maritalText(value?: string) { return value ? ({ single: '未婚', divorced: '离异', widowed: '丧偶' }[value] || value) : '-' }
function childrenText(value?: string) { return value ? ({ none: '无子女', with_children: '有子女' }[value] || value) : '-' }
function profileStatusText(value?: string) { return value ? ({ approved: '已审核', pending: '待审核', need_edit: '需修改' }[value] || value) : '-' }
function relationshipText(value?: string) { return value ? ({ searching: '寻找中', getting_to_know: '正在了解', matched_successfully: '已牵手', paused: '暂停' }[value] || value) : '-' }
function accountStatusType(status: string) { return ACCOUNT_MAP[status]?.type || 'info' }
function accountStatusText(status: string) { return ACCOUNT_MAP[status]?.text || status || '-' }
function riskLevelType(level: string) { return RISK_MAP[level]?.type || 'info' }
function riskLevelText(level: string) { return RISK_MAP[level]?.text || level || '-' }
function formatTime(time?: string) { return time ? new Date(time).toLocaleString('zh-CN') : '-' }
onMounted(loadDetail)
</script>
<style scoped>
.page-container { padding: 24px; }
.card-header { display: flex; align-items: center; gap: 12px; }
.masked { color: #999; font-style: italic; }
.page-container{padding:28px}.back-button{margin-bottom:8px}.heading-tags{display:flex;gap:8px}.detail-card{padding:24px}.identity-summary{display:flex;align-items:center;gap:14px;padding-bottom:20px;border-bottom:1px solid var(--color-border-light)}.identity-summary .el-avatar{color:#fff;background:var(--admin-primary)}.identity-summary h2{font-size:20px}.identity-summary span{display:block;margin-top:6px;color:var(--color-text-tertiary);font-size:12px}.completion{margin-left:auto}.detail-tabs{margin-top:18px}.masked{margin-right:8px;color:var(--color-text-tertiary)}.risk-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.risk-summary article{padding:18px;background:var(--admin-soft-pink);border-radius:12px}.risk-summary span{color:var(--color-text-secondary)}.risk-summary strong{display:block;margin-top:8px;font-size:26px}.danger-zone{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:20px;padding:18px;border:1px solid #F3CCCC;border-radius:12px}.danger-zone h3{display:flex;align-items:center;gap:7px;color:var(--color-danger)}.danger-zone p{margin-top:6px;color:var(--color-text-secondary)}.status-form{margin-top:18px}.full-width{width:100%}@media(max-width:760px){.page-container{padding:18px}.risk-summary{grid-template-columns:1fr}.danger-zone{align-items:flex-start;flex-direction:column}}
</style>