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
+184 -175
View File
@@ -1,230 +1,239 @@
<template> <template>
<el-container class="review-page"> <div class="page-container page-shell review-page">
<el-header class="header"> <header class="page-heading">
<h2>资料审核</h2> <div>
<el-tag type="warning" v-if="stats.pending > 0">待审核 {{ stats.pending }}</el-tag> <span class="page-kicker">用户与资料审核</span>
<el-tag type="info" v-if="stats.processing > 0">审核中 {{ stats.processing }}</el-tag> <h1>资料审核</h1>
</el-header> <p>按提交顺序核验必要资料,审核操作保留清晰意见和审计记录。</p>
</div>
<div class="stat-tags">
<el-tag type="warning">待审核 {{ stats.pending }}</el-tag>
<el-tag type="info">审核中 {{ stats.processing }}</el-tag>
</div>
</header>
<el-main> <el-alert v-if="errorMessage" :title="errorMessage" type="error" :closable="false" show-icon>
<template #default><el-button link type="primary" @click="loadPage">重新加载</el-button></template>
</el-alert>
<section v-else class="surface-card review-panel">
<el-tabs v-model="activeTab"> <el-tabs v-model="activeTab">
<el-tab-pane label="待审核队列" name="pending"> <el-tab-pane :label="`待审核队列(${pendingTasks.length})`" name="pending">
<el-table :data="pendingTasks" v-loading="loading"> <el-table :data="pendingTasks" v-loading="loading" row-key="taskId">
<el-table-column prop="userId" label="用户ID" width="120" /> <el-table-column prop="userId" label="用户编号" min-width="170" show-overflow-tooltip />
<el-table-column prop="reviewType" label="审核类型" width="120" /> <el-table-column prop="reviewType" label="审核类型" min-width="130">
<el-table-column prop="submittedAt" label="提交时间" width="180"> <template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
</el-table-column>
<el-table-column prop="submittedAt" label="提交时间" min-width="180">
<template #default="{ row }">{{ formatTime(row.submittedAt) }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
{{ formatTime(row.submittedAt) }} <el-button v-if="canProcess" type="primary" link @click="handleClaim(row)">领取审核</el-button>
</template> <span v-else class="muted">只读</span>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleClaim(row)">领取</el-button>
</template> </template>
</el-table-column> </el-table-column>
<template #empty><el-empty description="暂无待审核任务" /></template>
</el-table> </el-table>
<el-empty v-if="!loading && pendingTasks.length === 0" description="暂无待审核任务" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="我的审核" name="my"> <el-tab-pane :label="`我的审核(${myTasks.length})`" name="my">
<el-table :data="myTasks" v-loading="loading"> <el-table :data="myTasks" v-loading="loading" row-key="taskId">
<el-table-column prop="userId" label="用户ID" width="120" /> <el-table-column prop="userId" label="用户编号" min-width="170" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100"> <el-table-column prop="reviewType" label="审核类型" min-width="130">
<template #default="{ row }"> <template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column> </el-table-column>
<el-table-column prop="completedAt" label="完成时间" width="180"> <el-table-column prop="status" label="状态" width="110">
<template #default="{ row }"> <template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag></template>
{{ row.completedAt ? formatTime(row.completedAt) : '-' }}
</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="120"> <el-table-column prop="submittedAt" label="提交时间" min-width="180">
<template #default="{ row }"> <template #default="{ row }">{{ formatTime(row.submittedAt) }}</template>
<el-button size="small" @click="handleView(row)">查看</el-button>
</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }"><el-button link type="primary" @click="handleView(row)">查看</el-button></template>
</el-table-column>
<template #empty><el-empty description="暂无已领取的审核任务" /></template>
</el-table> </el-table>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
</el-main> </section>
<!-- 审核详情抽屉 --> <el-drawer v-model="detailVisible" title="审核详情" size="min(560px, 92vw)">
<el-drawer v-model="detailVisible" title="审核详情" size="500px"> <div v-if="detailLoading" v-loading="true" class="drawer-loading"></div>
<template v-if="currentTask"> <template v-else-if="currentTask">
<el-descriptions :column="1" border> <el-descriptions :column="1" border>
<el-descriptions-item label="用户ID">{{ currentTask.userId }}</el-descriptions-item> <el-descriptions-item label="用户编号">{{ currentTask.userId }}</el-descriptions-item>
<el-descriptions-item label="审核类型">{{ currentTask.reviewType }}</el-descriptions-item> <el-descriptions-item label="审核类型">{{ reviewTypeText(currentTask.reviewType) }}</el-descriptions-item>
<el-descriptions-item label="提交时间">{{ formatTime(currentTask.submittedAt) }}</el-descriptions-item> <el-descriptions-item label="提交时间">{{ formatTime(currentTask.submittedAt) }}</el-descriptions-item>
<el-descriptions-item label="当前状态"> <el-descriptions-item label="当前状态">
<el-tag :type="statusType(currentTask.status)">{{ statusText(currentTask.status) }}</el-tag> <el-tag :type="statusType(currentTask.status)">{{ statusText(currentTask.status) }}</el-tag>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
<el-divider>用户资料</el-divider> <h3 class="drawer-heading">用户资料</h3>
<el-descriptions :column="2" border v-if="currentTask.userProfile"> <el-descriptions v-if="currentTask.userProfile" :column="2" border>
<el-descriptions-item label="姓名">{{ currentTask.userProfile.displayName }}</el-descriptions-item> <el-descriptions-item label="显示名称">{{ currentTask.userProfile.displayName || '-' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ currentTask.userProfile.gender }}</el-descriptions-item> <el-descriptions-item label="性别">{{ genderText(currentTask.userProfile.gender) }}</el-descriptions-item>
<el-descriptions-item label="年龄">{{ currentTask.userProfile.age }}岁</el-descriptions-item> <el-descriptions-item label="年龄">{{ currentTask.userProfile.age || '-' }}</el-descriptions-item>
<el-descriptions-item label="身高">{{ currentTask.userProfile.heightCm }}cm</el-descriptions-item> <el-descriptions-item label="身高">{{ currentTask.userProfile.heightCm ? `${currentTask.userProfile.heightCm} cm` : '-' }}</el-descriptions-item>
<el-descriptions-item label="婚姻状况">{{ currentTask.userProfile.maritalStatus }}</el-descriptions-item> <el-descriptions-item label="区域">{{ currentTask.userProfile.regionName || '-' }}</el-descriptions-item>
<el-descriptions-item label="学历">{{ currentTask.userProfile.educationCode }}</el-descriptions-item> <el-descriptions-item label="学历">{{ currentTask.userProfile.educationCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="自我介绍" :span="2"> <el-descriptions-item label="自我介绍" :span="2">{{ currentTask.userProfile.selfIntro || '未填写' }}</el-descriptions-item>
{{ currentTask.userProfile.selfIntro || '暂未填写' }}
</el-descriptions-item>
</el-descriptions> </el-descriptions>
<el-empty v-else description="暂无资料快照" />
<el-divider>审核操作</el-divider> <template v-if="canProcess">
<el-form :model="reviewForm" label-width="80px"> <h3 class="drawer-heading">审核操作</h3>
<el-form-item label="审核意见"> <el-form label-position="top">
<el-input v-model="reviewForm.comment" type="textarea" :rows="3" placeholder="请输入审核意见" /> <el-form-item label="审核意见">
</el-form-item> <el-input v-model.trim="reviewForm.comment" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请写明判断依据或需要补充的内容" />
<el-form-item label="审核原因"> </el-form-item>
<el-select v-model="reviewForm.reasonCodes" multiple placeholder="可选" style="width:100%"> <el-form-item label="原因标签">
<el-option <el-select v-model="reviewForm.reasonCodes" multiple class="full-width" placeholder="按需选择">
v-for="reason in reasonOptions" <el-option v-for="reason in reasonOptions" :key="reason.code" :label="reason.name" :value="reason.code" />
:key="reason.code" </el-select>
:label="reason.name" </el-form-item>
:value="reason.code" <div class="review-actions">
/> <el-button type="success" :loading="submitting" @click="runDecision('approve', '审核通过')">通过</el-button>
</el-select> <el-button type="warning" :loading="submitting" @click="runDecision('need-edit', '退回修改')">需修改</el-button>
</el-form-item> <el-button type="danger" :loading="submitting" @click="runDecision('reject', '拒绝资料')">拒绝</el-button>
<el-form-item> </div>
<el-button type="success" @click="handleApprove" :loading="submitting">通过</el-button> </el-form>
<el-button type="warning" @click="handleNeedEdit" :loading="submitting">需修改</el-button> </template>
<el-button type="danger" @click="handleReject" :loading="submitting">拒绝</el-button>
</el-form-item>
</el-form>
</template> </template>
</el-drawer> </el-drawer>
</el-container> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { get, post } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const activeTab = ref('pending') const activeTab = ref('pending')
const loading = ref(false) const loading = ref(false)
const detailLoading = ref(false)
const submitting = ref(false) const submitting = ref(false)
const detailVisible = ref(false) const detailVisible = ref(false)
const errorMessage = ref('')
const stats = ref({ pending: 0, processing: 0 }) const stats = ref({ pending: 0, processing: 0 })
const pendingTasks = ref<any[]>([]) const tasks = ref<any[]>([])
const myTasks = ref<any[]>([])
const currentTask = ref<any>(null) const currentTask = ref<any>(null)
const reasonOptions = ref<any[]>([]) const reasonOptions = ref<any[]>([])
const reviewForm = ref({ comment: '', reasonCodes: [] as string[] })
const canProcess = computed(() => authStore.permissions.includes('review.process'))
const pendingTasks = computed(() => tasks.value.filter((task) => !task.assignedOperatorId))
const myTasks = computed(() => tasks.value.filter((task) => task.assignedOperatorId === authStore.operator?.operatorId))
const reviewForm = ref({ async function loadPage() {
comment: '', loading.value = true
reasonCodes: [] as string[] errorMessage.value = ''
}) try {
const [statsResponse, tasksResponse, reasonsResponse]: any[] = await Promise.all([
async function loadStats() { get('/operator/reviews/stats'),
// TODO: Phase 05 实现真实API调用 get('/operator/reviews/pending', { page: 1, pageSize: 20 }),
stats.value = { pending: 12, processing: 3 } get('/operator/reviews/reasons'),
])
stats.value = statsResponse.data || { pending: 0, processing: 0 }
tasks.value = tasksResponse.data || []
reasonOptions.value = reasonsResponse.data || []
} catch (error: any) {
tasks.value = []
errorMessage.value = error.response?.status === 403
? '您没有查看资料审核的权限'
: '审核任务加载失败,请检查服务状态后重试'
} finally {
loading.value = false
}
} }
function formatTime(ts?: string) { async function handleClaim(row: any) {
if (!ts) return '-' try {
return new Date(ts).toLocaleString('zh-CN') const response: any = await post(`/operator/reviews/${row.taskId || row.id}/claim`)
if (!response.success) throw new Error(response.message || '领取失败')
ElMessage.success('审核任务领取成功')
await loadPage()
await openDetail(row.taskId || row.id)
} catch (error: any) {
ElMessage.error(error.message || '领取失败,任务可能已被其他审核员领取')
}
}
async function openDetail(taskId: string) {
detailVisible.value = true
detailLoading.value = true
currentTask.value = null
reviewForm.value = { comment: '', reasonCodes: [] }
try {
const response: any = await get(`/operator/reviews/${taskId}`)
currentTask.value = response.data
} finally {
detailLoading.value = false
}
}
async function handleView(row: any) {
await openDetail(row.taskId || row.id)
}
async function runDecision(action: 'approve' | 'need-edit' | 'reject', label: string) {
if (!currentTask.value || submitting.value) return
if (action !== 'approve' && !reviewForm.value.comment) {
ElMessage.warning('请填写审核意见,说明需要修改或拒绝的原因')
return
}
if (action === 'reject') {
await ElMessageBox.confirm('拒绝后用户需要重新处理资料,确认继续吗?', '确认拒绝资料', {
type: 'warning',
confirmButtonText: '确认拒绝',
cancelButtonText: '取消',
})
}
submitting.value = true
try {
const response: any = await post(`/operator/reviews/${currentTask.value.taskId}/${action}`, {
comment: reviewForm.value.comment,
reasonCodes: reviewForm.value.reasonCodes,
})
if (!response.success) throw new Error(response.message || `${label}失败`)
ElMessage.success(label)
detailVisible.value = false
await loadPage()
} catch (error: any) {
ElMessage.error(error.message || `${label}失败,请重试`)
} finally {
submitting.value = false
}
}
function reviewTypeText(value?: string) {
return value ? ({ identity: '实名认证', profile: '婚恋资料', local_eligibility: '本地资格' }[value] || value) : '-'
}
function genderText(value?: string) {
return value === 'male' ? '男' : value === 'female' ? '女' : '-'
} }
function statusType(status: string) { function statusType(status: string) {
const map: Record<string, string> = { return ({ pending: 'warning', processing: 'info', approved: 'success', need_edit: 'warning', rejected: 'danger' } as Record<string, string>)[status] || 'info'
pending: 'warning',
processing: 'info',
approved: 'success',
need_edit: 'warning',
rejected: 'danger'
}
return map[status] || ''
} }
function statusText(status: string) { function statusText(status: string) {
const map: Record<string, string> = { return ({ pending: '待审核', processing: '审核中', approved: '已通过', need_edit: '需修改', rejected: '已拒绝' } as Record<string, string>)[status] || status || '-'
pending: '待审核',
processing: '审核中',
approved: '已通过',
need_edit: '需修改',
rejected: '已拒绝'
}
return map[status] || status
} }
function handleClaim(_row: any) { function formatTime(time?: string) {
// TODO: 调用 API 领取 return time ? new Date(time).toLocaleString('zh-CN') : '-'
ElMessage.success('领取成功')
} }
function handleView(row: any) { onMounted(loadPage)
currentTask.value = row
detailVisible.value = true
}
async function handleApprove() {
submitting.value = true
try {
// TODO: 调用 API
ElMessage.success('审核通过')
detailVisible.value = false
await loadStats()
} finally {
submitting.value = false
}
}
async function handleNeedEdit() {
submitting.value = true
try {
ElMessage.success('已标记需修改')
detailVisible.value = false
await loadStats()
} finally {
submitting.value = false
}
}
async function handleReject() {
submitting.value = true
try {
ElMessage.success('已拒绝')
detailVisible.value = false
await loadStats()
} finally {
submitting.value = false
}
}
onMounted(() => {
loadStats()
})
</script> </script>
<style scoped> <style scoped>
.review-page { .page-container{padding:28px}.page-kicker{color:var(--admin-primary);font-size:12px;font-weight:650;letter-spacing:1px}.stat-tags{display:flex;gap:8px}.review-panel{padding:20px 24px}.muted{color:var(--color-text-tertiary);font-size:12px}.drawer-loading{min-height:240px}.drawer-heading{margin:24px 0 12px;font-size:16px}.full-width{width:100%}.review-actions{display:flex;flex-wrap:wrap;gap:8px}@media(max-width:760px){.page-container{padding:18px}}
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: #fff;
padding: 16px 24px;
display: flex;
align-items: center;
gap: 16px;
border-bottom: 1px solid #e4e7ed;
}
.header h2 {
margin: 0;
font-size: 18px;
color: #303133;
}
.el-main {
flex: 1;
padding: 20px;
overflow: auto;
}
</style> </style>
+88 -131
View File
@@ -1,176 +1,133 @@
<template> <template>
<div class="page-container" v-loading="loading"> <div class="page-container page-shell" v-loading="loading">
<el-card shadow="never" v-if="user"> <header class="page-heading">
<template #header> <div>
<div class="card-header"> <el-button class="back-button" link @click="router.push('/users')">
<span>用户详情</span> <el-icon><ArrowLeft /></el-icon>返回用户列表
<el-tag :type="accountStatusType(user.accountStatus)">{{ accountStatusText(user.accountStatus) }}</el-tag> </el-button>
<el-tag :type="riskLevelType(user.riskLevel)">{{ riskLevelText(user.riskLevel) }}</el-tag> <h1>用户详情</h1>
</div> <p>敏感信息遵循最小权限原则,查看与处置操作均需审计。</p>
</template> </div>
<div v-if="user" class="heading-tags">
<el-tabs v-model="activeTab"> <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-tab-pane label="基础信息" name="basic">
<el-descriptions :column="2" border> <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="性别">{{ genderText(user.gender) }}</el-descriptions-item>
<el-descriptions-item label="年龄">{{ user.age || '-' }}</el-descriptions-item> <el-descriptions-item label="年龄">{{ user.age || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机"> <el-descriptions-item label="手机号">
<template v-if="user.sensitiveMasked"> <template v-if="user.sensitiveMasked"><span class="masked">{{ user.phoneMasked || '已脱敏' }}</span><el-button link type="primary" @click="requestSensitive">权限说明</el-button></template>
<span class="masked">{{ user.phoneMasked }}</span> <template v-else>{{ user.phone || '-' }}</template>
<el-button size="small" link @click="requestSensitive">需要查看</el-button>
</template>
<template v-else>{{ user.phone }}</template>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="区域">{{ user.regionName || '-' }}</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.registeredAt) }}</el-descriptions-item>
<el-descriptions-item label="最近登录">{{ formatTime(user.lastLoginAt) }}</el-descriptions-item> <el-descriptions-item label="最近登录">{{ formatTime(user.lastLoginAt) }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-tab-pane> </el-tab-pane>
<!-- 婚恋资料 -->
<el-tab-pane label="婚恋资料" name="profile"> <el-tab-pane label="婚恋资料" name="profile">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="婚姻状态">{{ maritalText(user.maritalStatus) }}</el-descriptions-item> <el-descriptions-item label="婚姻状态">{{ maritalText(user.maritalStatus) }}</el-descriptions-item>
<el-descriptions-item label="子女情况">{{ childrenText(user.childrenStatus) }}</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="学历">{{ user.educationCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="职业">{{ occText(user.occupationCode) }}</el-descriptions-item> <el-descriptions-item label="职业">{{ 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="资料状态">{{ profileStatusText(user.profileStatus) }}</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-descriptions>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="安全与账号" name="risk">
<!-- 风险记录 --> <div class="risk-summary">
<el-tab-pane label="风险记录" name="risk"> <article><span>风险分数</span><strong>{{ user.totalRiskScore ?? 0 }}</strong></article>
<el-descriptions :column="2" border> <article><span>历史举报</span><strong>{{ user.totalReportsCount ?? 0 }}</strong></article>
<el-descriptions-item label="风险分数">{{ user.totalRiskScore ?? 0 }}</el-descriptions-item> <article><span>当前处置</span><strong>{{ user.activeActionsCount ?? 0 }}</strong></article>
<el-descriptions-item label="风险等级"> </div>
<el-tag :type="riskLevelType(user.riskLevel)">{{ riskLevelText(user.riskLevel) }}</el-tag> <div class="danger-zone">
</el-descriptions-item> <div><h3><el-icon><WarningFilled /></el-icon>账号状态处置</h3><p>状态变更会影响用户登录和平台功能,提交前请确认处置依据。</p></div>
<el-descriptions-item label="历史举报数">{{ user.totalReportsCount ?? 0 }}</el-descriptions-item> <el-button v-if="canChangeStatus" type="danger" plain @click="openStatusDialog">修改账号状态</el-button>
<el-descriptions-item label="当前处置数">{{ user.activeActionsCount ?? 0 }}</el-descriptions-item> <el-tag v-else type="info">无状态修改权限</el-tag>
</el-descriptions>
<div style="margin-top:16px">
<el-button type="primary" size="small" @click="showStatusDialog = true">修改账号状态</el-button>
</div> </div>
</el-tab-pane> </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-tabs>
</el-card> </section>
<el-empty v-else-if="!loading" description="未找到用户信息" />
<!-- 修改状态对话框 --> <el-dialog v-model="showStatusDialog" title="修改账号状态" width="420px">
<el-dialog v-model="showStatusDialog" title="修改账号状态" width="400px"> <el-alert title="该操作将被写入审计日志,请谨慎处理。" type="warning" :closable="false" show-icon />
<el-form label-width="100px"> <el-form label-position="top" class="status-form">
<el-form-item label="新状态"> <el-form-item label="新状态">
<el-select v-model="newStatus" style="width:100%"> <el-select v-model="newStatus" class="full-width">
<el-option label="正常" value="normal" /> <el-option label="正常" value="normal" /><el-option label="风险审核中" value="reviewing_risk" />
<el-option label="风险审核中" value="reviewing_risk" /> <el-option label="功能受限" value="limited" /><el-option label="临时暂停" value="suspended" />
<el-option label="功能受限" value="limited" /> <el-option label="封禁" value="banned" /><el-option label="已注销" value="closed" />
<el-option label="临时暂停" value="suspended" />
<el-option label="封禁" value="banned" />
<el-option label="已注销" value="closed" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="原因">
<el-input v-model="statusNote" type="textarea" :rows="3" placeholder="请输入修改原因" />
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer><el-button @click="showStatusDialog = false">取消</el-button><el-button type="danger" :loading="updating" @click="updateStatus">确认变更</el-button></template>
<el-button @click="showStatusDialog = false">取消</el-button>
<el-button type="primary" :loading="updating" @click="updateStatus">确认</el-button>
</template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' // eslint-disable-next-line 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' import { get, put } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
const route = useRoute() const route = useRoute(); const router = useRouter(); const authStore = useAuthStore()
const loading = ref(true) const loading = ref(true); const errorMessage = ref(''); const user = ref<any>(null); const activeTab = ref('basic')
const user = ref<any>(null) const showStatusDialog = ref(false); const newStatus = ref('normal'); const updating = ref(false)
const activeTab = ref('basic') const accountId = String(route.query.id || '')
const showStatusDialog = ref(false) const canChangeStatus = computed(() => authStore.permissions.includes('user.status.change'))
const newStatus = ref('normal')
const statusNote = ref('')
const updating = ref(false)
const accountId = route.query.id as string
const ACCOUNT_MAP: Record<string, { text: string; type: string }> = { const ACCOUNT_MAP: Record<string, { text: string; type: string }> = {
normal: { text: '正常', type: 'success' }, normal: { text: '正常', type: 'success' }, banned: { text: '封禁', type: 'danger' },
banned: { text: '封禁', type: 'danger' }, suspended: { text: '暂停', type: 'warning' }, reviewing_risk: { text: '风险审核', type: 'danger' },
suspended: { text: '暂停', type: 'warning' }, limited: { text: '受限', type: 'info' }, closed: { text: '已注销', type: 'info' },
reviewing_risk: { text: '风险审核', type: 'danger' },
limited: { text: '受限', type: 'info' }
} }
const RISK_MAP: Record<string, { text: string; type: string }> = { const RISK_MAP: Record<string, { text: string; type: string }> = {
normal: { text: '正常', type: 'info' }, normal: { text: '正常', type: 'info' }, watch: { text: '关注', type: 'warning' },
watch: { text: '关注', type: '' }, high: { text: '高风险', type: 'warning' }, critical: { text: '极高风险', type: 'danger' },
high: { text: '高风险', type: 'warning' },
critical: { text: '极高风险', type: 'danger' }
} }
async function loadDetail() { async function loadDetail() {
if (!accountId) return if (!accountId) { loading.value = false; errorMessage.value = '缺少用户编号,请从用户列表重新进入'; return }
loading.value = true loading.value = true; errorMessage.value = ''
try { try { const response: any = await get(`/operator/users/${accountId}`); user.value = response.data }
const token = localStorage.getItem('accessToken') catch (error: any) { user.value = null; errorMessage.value = error.response?.status === 403 ? '您没有查看该用户详情的权限' : '用户详情加载失败,请检查服务状态后重试' }
const res: any = await get(`/operator/users/${accountId}`, {}, { Authorization: `Bearer ${token}` })
user.value = res
} catch (e) { console.error(e) }
finally { loading.value = false } finally { loading.value = false }
} }
function openStatusDialog() { newStatus.value = user.value?.accountStatus || 'normal'; showStatusDialog.value = true }
async function updateStatus() { 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 updating.value = true
try { try { await put(`/operator/users/${accountId}/status`, { accountStatus: newStatus.value }); ElMessage.success('账号状态已更新'); showStatusDialog.value = false; await loadDetail() }
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) }
finally { updating.value = false } finally { updating.value = false }
} }
function requestSensitive() { ElMessage.info('当前内容已脱敏;只有具备敏感数据权限的角色可以查看明文。') }
function requestSensitive() { /* TODO: 需要敏感权限的申请流程 */ } function genderText(value?: string) { return value === 'male' ? '男' : value === 'female' ? '女' : '-' }
function maritalText(value?: string) { return value ? ({ single: '未婚', divorced: '离异', widowed: '丧偶' }[value] || value) : '-' }
function genderText(g?: string) { return g === 'male' ? '男' : g === 'female' ? '女' : '-' } function childrenText(value?: string) { return value ? ({ none: '无子女', with_children: '有子女' }[value] || value) : '-' }
function maritalText(m?: string) { const m2 = m as string | undefined; return m2 && { single: '未婚', divorced: '离异', widowed: '丧偶' }[m2] || '-' } function profileStatusText(value?: string) { return value ? ({ approved: '已审核', pending: '待审核', need_edit: '需修改' }[value] || value) : '-' }
function childrenText(c?: string) { const c2 = c as string | undefined; return c2 && { none: '无子女', with_children: '有子女' }[c2] || '-' } function relationshipText(value?: string) { return value ? ({ searching: '寻找中', getting_to_know: '正在了解', matched_successfully: '已牵手', paused: '暂停' }[value] || value) : '-' }
function eduText(e?: string) { return e || '-' } function accountStatusType(status: string) { return ACCOUNT_MAP[status]?.type || 'info' }
function occText(o?: string) { return o || '-' } function accountStatusText(status: string) { return ACCOUNT_MAP[status]?.text || status || '-' }
function profileStatusText(s?: string) { const s2 = s as string | undefined; return s2 && { approved: '已审核', pending: '待审核', need_edit: '需修改' }[s2] || s } function riskLevelType(level: string) { return RISK_MAP[level]?.type || 'info' }
function relationshipText(r?: string) { const r2 = r as string | undefined; return r2 && { searching: '寻找中', getting_to_know: '正在了解', matched_successfully: '已牵手', paused: '暂停' }[r2] || r || '-' } function riskLevelText(level: string) { return RISK_MAP[level]?.text || level || '-' }
function accountStatusType(s: string) { return ACCOUNT_MAP[s]?.type || 'info' } function formatTime(time?: string) { return time ? new Date(time).toLocaleString('zh-CN') : '-' }
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') : '-' }
onMounted(loadDetail) onMounted(loadDetail)
</script> </script>
<style scoped> <style scoped>
.page-container { padding: 24px; } .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}}
.card-header { display: flex; align-items: center; gap: 12px; }
.masked { color: #999; font-style: italic; }
</style> </style>
+193 -60
View File
@@ -1,103 +1,170 @@
<template> <template>
<div class="page-container"> <div class="page-container page-shell">
<el-card shadow="never"> <header class="page-heading">
<template #header> <div>
<div class="card-header"> <span class="page-kicker">用户与资料审核</span>
<span>用户管理</span> <h1>用户管理</h1>
<el-input v-model="keyword" placeholder="搜索用户ID" clearable style="width:200px" /> <p>仅按当前角色权限查询用户,手机号等敏感信息默认脱敏。</p>
<el-select v-model="accountStatus" placeholder="账号状态" clearable style="width:140px"> </div>
<el-option label="正常" value="normal" /> <el-tag type="info" effect="plain">共 {{ total }} 人</el-tag>
<el-option label="封禁" value="banned" /> </header>
<el-option label="暂停" value="suspended" />
<el-option label="风险审核中" value="reviewing_risk" />
</el-select>
<el-button type="primary" @click="loadData" :loading="loading">查询</el-button>
</div>
</template>
<el-table :data="tableData" v-loading="loading" border stripe> <section class="surface-card filter-panel" aria-label="用户筛选">
<el-table-column prop="id" label="用户ID" width="140" show-overflow-tooltip /> <el-input
<el-table-column prop="displayName" label="姓名" width="120" /> v-model.trim="keyword"
<el-table-column prop="gender" label="性别" width="60"> class="keyword-input"
<template #default="{row}">{{ row.gender === 'male' ? '男' : row.gender === 'female' ? '女' : '-' }}</template> clearable
aria-label="用户编号"
placeholder="输入用户编号"
@keyup.enter="search"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select v-model="accountStatus" clearable placeholder="全部账号状态" aria-label="账号状态">
<el-option label="正常" value="normal" />
<el-option label="封禁" value="banned" />
<el-option label="暂停" value="suspended" />
<el-option label="风险审核中" value="reviewing_risk" />
</el-select>
<el-button type="primary" :loading="loading" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</section>
<section class="surface-card table-panel">
<el-alert
v-if="errorMessage"
:title="errorMessage"
type="error"
:closable="false"
show-icon
>
<template #default><el-button link type="primary" @click="loadData">重新加载</el-button></template>
</el-alert>
<el-table v-else :data="tableData" v-loading="loading" row-key="id">
<el-table-column prop="id" label="用户编号" min-width="170" show-overflow-tooltip />
<el-table-column prop="displayName" label="显示名称" min-width="110">
<template #default="{ row }">{{ row.displayName || '未设置' }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="age" label="年龄" width="60" /> <el-table-column prop="gender" label="性别" width="70">
<el-table-column prop="phoneMasked" label="手机尾号" width="120" /> <template #default="{ row }">{{ genderText(row.gender) }}</template>
<el-table-column prop="regionName" label="区域" width="100" /> </el-table-column>
<el-table-column prop="accountStatus" label="账号状态" width="100"> <el-table-column prop="age" label="年龄" width="70">
<template #default="{row}"> <template #default="{ row }">{{ row.age || '-' }}</template>
<el-tag :type="accountStatusType(row.accountStatus)">{{ accountStatusText(row.accountStatus) }}</el-tag> </el-table-column>
<el-table-column prop="phoneMasked" label="手机号" min-width="130">
<template #default="{ row }">{{ row.phoneMasked || '未绑定' }}</template>
</el-table-column>
<el-table-column prop="regionName" label="区域" min-width="100">
<template #default="{ row }">{{ row.regionName || '-' }}</template>
</el-table-column>
<el-table-column prop="accountStatus" label="账号状态" min-width="105">
<template #default="{ row }">
<el-tag :type="accountStatusType(row.accountStatus)" effect="light">
{{ accountStatusText(row.accountStatus) }}
</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="riskLevel" label="风险等级" width="100"> <el-table-column prop="riskLevel" label="风险等级" min-width="105">
<template #default="{row}"> <template #default="{ row }">
<el-tag :type="riskLevelType(row.riskLevel)">{{ riskLevelText(row.riskLevel) }}</el-tag> <el-tag :type="riskLevelType(row.riskLevel)" effect="plain">
{{ riskLevelText(row.riskLevel) }}
</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="registeredAt" label="注册时间" width="180"> <el-table-column prop="registeredAt" label="注册时间" min-width="170">
<template #default="{row}">{{ formatTime(row.registeredAt) }}</template> <template #default="{ row }">{{ formatTime(row.registeredAt) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="80"> <el-table-column label="操作" width="86" fixed="right">
<template #default="{row}"> <template #default="{ row }">
<el-button size="small" @click="goDetail(row.id)">查看</el-button> <el-button link type="primary" @click="goDetail(row.id)">查看详情</el-button>
</template> </template>
</el-table-column> </el-table-column>
<template #empty>
<el-empty description="没有找到符合条件的用户" />
</template>
</el-table> </el-table>
<el-pagination v-if="total > 0" style="margin-top:16px;text-align:right" <el-pagination
layout="total, prev, pager, next" :total="total" :page-size="pageSize" v-if="!errorMessage && total > 0"
:current-page="page" @current-change="handlePageChange" /> class="pagination"
</el-card> layout="total, prev, pager, next"
:total="total"
:page-size="pageSize"
:current-page="page + 1"
@current-change="handlePageChange"
/>
</section>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Search } from '@element-plus/icons-vue'
import { get } from '@/api/client' import { get } from '@/api/client'
const router = useRouter() const router = useRouter()
const loading = ref(false) const loading = ref(false)
const errorMessage = ref('')
const keyword = ref('') const keyword = ref('')
const accountStatus = ref('') const accountStatus = ref('')
const tableData = ref<any[]>([]) const tableData = ref<any[]>([])
const total = ref(0) const total = ref(0)
const page = ref(0) const page = ref(0)
const pageSize = ref(20) const pageSize = 20
const ACCOUNT_MAP: Record<string, { text: string; type: string }> = { const ACCOUNT_MAP: Record<string, { text: string; type: string }> = {
normal: { text: '正常', type: 'success' }, normal: { text: '正常', type: 'success' },
banned: { text: '封禁', type: 'danger' }, banned: { text: '封禁', type: 'danger' },
suspended: { text: '暂停', type: 'warning' }, suspended: { text: '暂停', type: 'warning' },
reviewing_risk: { text: '风险审核', type: 'danger' }, reviewing_risk: { text: '风险审核', type: 'danger' },
limited: { text: '受限', type: 'info' } limited: { text: '受限', type: 'info' },
} }
const RISK_MAP: Record<string, { text: string; type: string }> = { const RISK_MAP: Record<string, { text: string; type: string }> = {
normal: { text: '正常', type: 'info' }, normal: { text: '正常', type: 'info' },
watch: { text: '关注', type: '' }, watch: { text: '关注', type: 'warning' },
high: { text: '高风险', type: 'warning' }, high: { text: '高风险', type: 'warning' },
critical: { text: '极高风险', type: 'danger' } critical: { text: '极高风险', type: 'danger' },
} }
async function loadData() { async function loadData() {
loading.value = true loading.value = true
errorMessage.value = ''
try { try {
const token = localStorage.getItem('accessToken') const response: any = await get('/operator/users', {
const res: any = await get('/operator/users', {
keyword: keyword.value || undefined, keyword: keyword.value || undefined,
accountStatus: accountStatus.value || undefined, accountStatus: accountStatus.value || undefined,
page: page.value, page: page.value,
pageSize: pageSize.value pageSize,
}, { Authorization: `Bearer ${token}` }) })
tableData.value = res.data?.list || [] tableData.value = response.data?.list || []
total.value = res.data?.total || 0 total.value = response.data?.total || 0
} catch (e) { console.error(e) } } catch (error: any) {
finally { loading.value = false } tableData.value = []
total.value = 0
errorMessage.value = error.response?.status === 403
? '您没有查看用户列表的权限'
: '用户数据加载失败,请检查服务状态后重试'
} finally {
loading.value = false
}
} }
function handlePageChange(p: number) { function search() {
page.value = p - 1 page.value = 0
loadData()
}
function resetFilters() {
keyword.value = ''
accountStatus.value = ''
search()
}
function handlePageChange(current: number) {
page.value = current - 1
loadData() loadData()
} }
@@ -105,16 +172,82 @@ function goDetail(id: string) {
router.push({ path: '/users/detail', query: { id } }) router.push({ path: '/users/detail', query: { id } })
} }
function accountStatusType(s: string) { return ACCOUNT_MAP[s]?.type || 'info' } function genderText(gender?: string) {
function accountStatusText(s: string) { return ACCOUNT_MAP[s]?.text || s } return gender === 'male' ? '男' : gender === 'female' ? '女' : '-'
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 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(loadData) onMounted(loadData)
</script> </script>
<style scoped> <style scoped>
.page-container { padding: 24px; } .page-container {
.card-header { display: flex; align-items: center; gap: 12px; } padding: 28px;
}
.page-kicker {
color: var(--admin-primary);
font-size: 12px;
font-weight: 650;
letter-spacing: 1px;
}
.filter-panel {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 18px;
}
.keyword-input {
width: 260px;
}
.filter-panel .el-select {
width: 180px;
}
.table-panel {
margin-top: 16px;
padding: 20px;
}
.table-panel .el-alert {
margin-bottom: 12px;
}
.pagination {
justify-content: flex-end;
margin-top: 18px;
}
@media (max-width: 760px) {
.page-container {
padding: 18px;
}
.keyword-input,
.filter-panel .el-select {
width: 100%;
}
}
</style> </style>
+9 -7
View File
@@ -5,30 +5,32 @@
<div class="date-card"><span>{{ today }}</span><strong>今日服务</strong></div> <div class="date-card"><span>{{ today }}</span><strong>今日服务</strong></div>
</section> </section>
<section class="metrics"> <section class="metrics">
<article v-for="item in metrics" :key="item.label" class="metric-card surface-card"><div class="metric-top"><span>{{item.label}}</span><span class="metric-icon" :class="item.tone">{{item.icon}}</span></div><strong>{{item.value}}</strong><small>{{item.hint}}</small></article> <article v-for="item in metrics" :key="item.label" class="metric-card surface-card"><div class="metric-top"><span>{{item.label}}</span><span class="metric-icon" :class="item.tone"><el-icon><component :is="item.icon"/></el-icon></span></div><strong>{{item.value}}</strong><small>{{item.hint}}</small></article>
</section> </section>
<section class="dashboard-grid"> <section class="dashboard-grid">
<article class="surface-card focus-card"><header><div><span class="section-kicker">今日优先</span><h2>{{profile.focusTitle}}</h2></div><el-button type="primary" plain @click="go(profile.primaryPath)">{{profile.primaryAction}}</el-button></header><div class="task-list"><button v-for="task in profile.tasks" :key="task.title" type="button" class="task-row" @click="go(task.path)"><span class="task-dot" :class="task.tone"></span><span class="task-copy"><strong>{{task.title}}</strong><small>{{task.desc}}</small></span><span class="task-count">{{task.count}}</span><el-icon><ArrowRight/></el-icon></button></div></article> <article class="surface-card focus-card"><header><div><span class="section-kicker">今日优先</span><h2>{{profile.focusTitle}}</h2></div><el-button type="primary" plain @click="go(profile.primaryPath)">{{profile.primaryAction}}</el-button></header><div class="task-list"><button v-for="task in profile.tasks" :key="task.title" type="button" class="task-row" @click="go(task.path)"><span class="task-dot" :class="task.tone"></span><span class="task-copy"><strong>{{task.title}}</strong><small>{{task.desc}}</small></span><span class="task-count">{{task.count}}</span><el-icon><ArrowRight/></el-icon></button></div></article>
<aside class="surface-card quick-card"><header><span class="section-kicker">常用操作</span><h2>快速开始</h2></header><button v-for="action in profile.actions" :key="action.label" type="button" class="quick-action" @click="go(action.path)"><span class="quick-icon">{{action.icon}}</span><span><strong>{{action.label}}</strong><small>{{action.desc}}</small></span><el-icon><ArrowRight/></el-icon></button><div class="privacy-note"><el-icon><Lock/></el-icon><span><strong>数据访问受保护</strong><small>仅可查看职责范围内的服务信息,敏感操作全程留痕。</small></span></div></aside> <aside class="surface-card quick-card"><header><span class="section-kicker">常用操作</span><h2>快速开始</h2></header><button v-for="action in profile.actions" :key="action.label" type="button" class="quick-action" @click="go(action.path)"><span class="quick-icon"><el-icon><component :is="actionIcon(action.label)"/></el-icon></span><span><strong>{{action.label}}</strong><small>{{action.desc}}</small></span><el-icon><ArrowRight/></el-icon></button><div class="privacy-note"><el-icon><Lock/></el-icon><span><strong>数据访问受保护</strong><small>仅可查看职责范围内的服务信息,敏感操作全程留痕。</small></span></div></aside>
</section> </section>
<section class="surface-card service-overview"><header><div><span class="section-kicker">公益服务概况</span><h2>本周服务进展</h2></div><span class="preview-label" v-if="preview">界面预览数据</span></header><div class="progress-grid"><div v-for="item in progress" :key="item.label" class="progress-item"><div><span>{{item.label}}</span><strong>{{item.value}}</strong></div><el-progress :percentage="item.percent" :stroke-width="8" :show-text="false" :color="item.color"/><small>{{item.note}}</small></div></div></section> <section class="surface-card service-overview"><header><div><span class="section-kicker">公益服务概况</span><h2>本周服务进展</h2></div><span class="preview-label" v-if="preview">界面预览数据</span></header><div class="progress-grid"><div v-for="item in progress" :key="item.label" class="progress-item"><div><span>{{item.label}}</span><strong>{{item.value}}</strong></div><el-progress :percentage="item.percent" :stroke-width="8" :show-text="false" :color="item.color"/><small>{{item.note}}</small></div></div></section>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed,onMounted,ref} from 'vue';import {useRouter} from 'vue-router';import {ArrowRight,Lock} from '@element-plus/icons-vue';import {get} from '@/api/client';import {useAuthStore} from '@/stores/auth' import {computed,onMounted,ref} from 'vue';import {useRouter} from 'vue-router';import {ArrowRight,ChatDotRound,CircleCheck,Connection,DocumentChecked,Location,Lock,Memo,Service,TrendCharts,User,Warning} from '@element-plus/icons-vue';import {get} from '@/api/client';import {useAuthStore} from '@/stores/auth'
type Task={title:string;desc:string;count:number;tone:string;path:string};type Action={label:string;desc:string;icon:string;path:string};type Profile={badge:string;subtitle:string;focusTitle:string;primaryAction:string;primaryPath:string;tasks:Task[];actions:Action[]} type Task={title:string;desc:string;count:number;tone:string;path:string};type Action={label:string;desc:string;icon:any;path:string};type Profile={badge:string;subtitle:string;focusTitle:string;primaryAction:string;primaryPath:string;tasks:Task[];actions:Action[]}
const router=useRouter();const auth=useAuthStore();const loading=ref(false);const preview=ref(false);const stats=ref<Record<string,number>>({}) const router=useRouter();const auth=useAuthStore();const loading=ref(false);const preview=ref(false);const stats=ref<Record<string,number>>({})
const operatorName=computed(()=>auth.operator?.name||'工作人员');const today=new Intl.DateTimeFormat('zh-CN',{month:'long',day:'numeric',weekday:'long'}).format(new Date());const greeting=computed(()=>new Date().getHours()<12?'上午好':new Date().getHours()<18?'下午好':'晚上好') const operatorName=computed(()=>auth.operator?.name||'工作人员');const today=new Intl.DateTimeFormat('zh-CN',{month:'long',day:'numeric',weekday:'long'}).format(new Date());const greeting=computed(()=>new Date().getHours()<12?'上午好':new Date().getHours()<18?'下午好':'晚上好')
const p=computed(()=>auth.permissions||[]);const has=(code:string)=>p.value.includes(code) const p=computed(()=>auth.permissions||[]);const has=(code:string)=>p.value.includes(code)
const profile=computed<Profile>(()=>{if(has('counseling.service.view'))return{badge:'婚恋咨询工作台',subtitle:'只处理明确分配给你的咨询服务,尊重每一位来访者的隐私。',focusTitle:'我的待办咨询',primaryAction:'进入咨询服务',primaryPath:'/counseling',tasks:[{title:'待首次联系',desc:'请在服务时限内完成首次沟通',count:stats.value.pendingCounseling||3,tone:'rose',path:'/counseling'},{title:'今日预约',desc:'确认时间与服务方式',count:stats.value.todayAppointments||2,tone:'amber',path:'/counseling'},{title:'待补充记录',desc:'仅记录必要的服务事实',count:1,tone:'blue',path:'/counseling'}],actions:[{label:'查看我的咨询',desc:'仅限已分配服务',icon:'咨',path:'/counseling'},{label:'转介协作',desc:'需要其他专业支持',icon:'转',path:'/referral'}]};if(has('service_appointment.view'))return{badge:'服务站工作台',subtitle:'聚焦今天的现场预约与指定服务,不浏览全平台用户库。',focusTitle:'今日到站服务',primaryAction:'管理预约',primaryPath:'/service-appointment',tasks:[{title:'待确认预约',desc:'确认服务时间与到站方式',count:stats.value.pendingAppointments||6,tone:'rose',path:'/service-appointment'},{title:'即将到站',desc:'提前准备现场服务资料',count:4,tone:'green',path:'/service-appointment'},{title:'待完成回访',desc:'记录服务结果与后续安排',count:2,tone:'blue',path:'/service-appointment'}],actions:[{label:'预约管理',desc:'查看今日预约',icon:'约',path:'/service-appointment'},{label:'服务站信息',desc:'维护开放时间与项目',icon:'站',path:'/service-station'}]};if(has('review.view'))return{badge:'资料审核工作台',subtitle:'核验真实信息,帮助平台建立安全、可信的相遇环境。',focusTitle:'今日审核队列',primaryAction:'开始审核',primaryPath:'/review',tasks:[{title:'待初审资料',desc:'按提交时间依次处理',count:stats.value.pendingReviews||18,tone:'rose',path:'/review'},{title:'待复核资料',desc:'关注证件与资料一致性',count:5,tone:'amber',path:'/review'},{title:'待补充材料',desc:'清晰说明需要用户补充的内容',count:7,tone:'blue',path:'/review'}],actions:[{label:'进入资料审核',desc:'处理待审资料',icon:'审',path:'/review'},{label:'用户查询',desc:'按权限查询用户',icon:'查',path:'/users'}]};return{badge:'公益服务工作台',subtitle:'从今天最重要的待办开始,让每一项服务都有回应。',focusTitle:'今日待办',primaryAction:'查看全部',primaryPath:'/dashboard',tasks:[{title:'待处理服务',desc:'请按服务时限及时响应',count:stats.value.pendingTasks||12,tone:'rose',path:'/dashboard'},{title:'待跟进事项',desc:'查看最近更新与反馈',count:8,tone:'amber',path:'/dashboard'},{title:'安全提醒',desc:'关注需要优先处理的风险',count:2,tone:'blue',path:'/risk/reports'}],actions:[{label:'用户服务',desc:'进入当前职责模块',icon:'服',path:'/dashboard'},{label:'数据概览',desc:'查看授权范围统计',icon:'数',path:'/analytics'}]}}) const profile=computed<Profile>(()=>{if(has('counseling.service.view'))return{badge:'婚恋咨询工作台',subtitle:'只处理明确分配给你的咨询服务,尊重每一位来访者的隐私。',focusTitle:'我的待办咨询',primaryAction:'进入咨询服务',primaryPath:'/counseling',tasks:[{title:'待首次联系',desc:'请在服务时限内完成首次沟通',count:stats.value.pendingCounseling||3,tone:'rose',path:'/counseling'},{title:'今日预约',desc:'确认时间与服务方式',count:stats.value.todayAppointments||2,tone:'amber',path:'/counseling'},{title:'待补充记录',desc:'仅记录必要的服务事实',count:1,tone:'blue',path:'/counseling'}],actions:[{label:'查看我的咨询',desc:'仅限已分配服务',icon:'咨',path:'/counseling'},{label:'转介协作',desc:'需要其他专业支持',icon:'转',path:'/referral'}]};if(has('service_appointment.view'))return{badge:'服务站工作台',subtitle:'聚焦今天的现场预约与指定服务,不浏览全平台用户库。',focusTitle:'今日到站服务',primaryAction:'管理预约',primaryPath:'/service-appointment',tasks:[{title:'待确认预约',desc:'确认服务时间与到站方式',count:stats.value.pendingAppointments||6,tone:'rose',path:'/service-appointment'},{title:'即将到站',desc:'提前准备现场服务资料',count:4,tone:'green',path:'/service-appointment'},{title:'待完成回访',desc:'记录服务结果与后续安排',count:2,tone:'blue',path:'/service-appointment'}],actions:[{label:'预约管理',desc:'查看今日预约',icon:'约',path:'/service-appointment'},{label:'服务站信息',desc:'维护开放时间与项目',icon:'站',path:'/service-station'}]};if(has('review.view'))return{badge:'资料审核工作台',subtitle:'核验真实信息,帮助平台建立安全、可信的相遇环境。',focusTitle:'今日审核队列',primaryAction:'开始审核',primaryPath:'/review',tasks:[{title:'待初审资料',desc:'按提交时间依次处理',count:stats.value.pendingReviews||18,tone:'rose',path:'/review'},{title:'待复核资料',desc:'关注证件与资料一致性',count:5,tone:'amber',path:'/review'},{title:'待补充材料',desc:'清晰说明需要用户补充的内容',count:7,tone:'blue',path:'/review'}],actions:[{label:'进入资料审核',desc:'处理待审资料',icon:'审',path:'/review'},{label:'用户查询',desc:'按权限查询用户',icon:'查',path:'/users'}]};return{badge:'公益服务工作台',subtitle:'从今天最重要的待办开始,让每一项服务都有回应。',focusTitle:'今日待办',primaryAction:'查看全部',primaryPath:'/dashboard',tasks:[{title:'待处理服务',desc:'请按服务时限及时响应',count:stats.value.pendingTasks||12,tone:'rose',path:'/dashboard'},{title:'待跟进事项',desc:'查看最近更新与反馈',count:8,tone:'amber',path:'/dashboard'},{title:'安全提醒',desc:'关注需要优先处理的风险',count:2,tone:'blue',path:'/risk/reports'}],actions:[{label:'用户服务',desc:'进入当前职责模块',icon:'服',path:'/dashboard'},{label:'数据概览',desc:'查看授权范围统计',icon:'数',path:'/analytics'}]}})
const metrics=computed(()=>[{label:'今日待办',value:profile.value.tasks.reduce((n,t)=>n+t.count,0),hint:'按优先级及时处理',icon:'待',tone:'rose'},{label:'本周已完成',value:stats.value.weekCompleted||46,hint:'比上周多 8 项',icon:'成',tone:'green'},{label:'服务中',value:stats.value.activeServices||23,hint:'均在正常服务时限内',icon:'服',tone:'blue'},{label:'需关注',value:stats.value.attention||2,hint:'包含临期与风险事项',icon:'醒',tone:'amber'}]) const metrics=computed(()=>[{label:'今日待办',value:profile.value.tasks.reduce((n,t)=>n+t.count,0),hint:'按优先级及时处理',icon:DocumentChecked,tone:'rose'},{label:'本周已完成',value:stats.value.weekCompleted??0,hint:'已完成服务事项',icon:CircleCheck,tone:'green'},{label:'服务中',value:stats.value.activeServices??0,hint:'处于正常服务流程',icon:Service,tone:'blue'},{label:'需关注',value:stats.value.attention??0,hint:'包含临期与风险事项',icon:Warning,tone:'amber'}])
const progress=[{label:'资料审核及时率',value:'96%',percent:96,note:'目标 95%',color:'#2E9B68'},{label:'预约按时完成率',value:'91%',percent:91,note:'本周 31 次服务',color:'#4A7CD4'},{label:'服务回访完成率',value:'84%',percent:84,note:'还有 5 项待回访',color:'#E59A32'}] const progress=[{label:'资料审核及时率',value:'96%',percent:96,note:'目标 95%',color:'#2E9B68'},{label:'预约按时完成率',value:'91%',percent:91,note:'本周 31 次服务',color:'#4A7CD4'},{label:'服务回访完成率',value:'84%',percent:84,note:'还有 5 项待回访',color:'#E59A32'}]
function actionIcon(label:string){return ({'查看我的咨询':ChatDotRound,'转介协作':Connection,'预约管理':Memo,'服务站信息':Location,'进入资料审核':DocumentChecked,'用户查询':User,'数据概览':TrendCharts} as Record<string,any>)[label]||Service}
function go(path:string){if(path)router.push(path)} function go(path:string){if(path)router.push(path)}
async function load(){if(auth.token==='ui-preview-only'){preview.value=true;return}loading.value=true;try{stats.value=(await get('/operator/analytics/dashboard')) as unknown as Record<string,number>}catch{preview.value=true}finally{loading.value=false}} async function load(){if(auth.token==='ui-preview-only'){preview.value=true;return}loading.value=true;try{const response:any=await get('/operator/analytics/dashboard');stats.value=response.data||{}}finally{loading.value=false}}
onMounted(load) onMounted(load)
</script> </script>
<style scoped> <style scoped>
.dashboard{padding-top:28px;padding-bottom:40px}.welcome{min-height:150px;padding:30px 34px;display:flex;align-items:center;justify-content:space-between;border-radius:18px;background:linear-gradient(120deg,#FFF1EE,#FFF9F2 56%,#F4F8F6);border:1px solid #F6E4DE;position:relative;overflow:hidden}.welcome:after{content:'缘';position:absolute;right:130px;top:-54px;font-size:210px;font-weight:700;color:rgba(233,107,107,.07)}.eyebrow,.section-kicker{font-size:12px;color:var(--color-primary);font-weight:650;letter-spacing:1px}.welcome h1{margin-top:9px;font-size:28px}.welcome p{margin-top:10px;color:var(--color-text-secondary)}.date-card{z-index:1;padding:14px 18px;border-radius:13px;background:rgba(255,255,255,.75);border:1px solid rgba(255,255,255,.9);display:flex;flex-direction:column;text-align:right}.date-card span{font-size:12px;color:var(--color-text-tertiary)}.date-card strong{margin-top:4px;font-size:16px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-top:18px}.metric-card{padding:20px}.metric-top{display:flex;justify-content:space-between;color:var(--color-text-secondary)}.metric-icon{width:34px;height:34px;display:grid;place-items:center;border-radius:11px;font-weight:650}.metric-icon.rose{background:var(--color-primary-light);color:var(--color-primary)}.metric-icon.green{background:#ECF8F2;color:var(--color-success)}.metric-icon.blue{background:#EEF4FF;color:var(--color-info)}.metric-icon.amber{background:#FFF7E9;color:var(--color-warning)}.metric-card>strong{display:block;margin-top:8px;font-size:30px}.metric-card>small{display:block;margin-top:6px;color:var(--color-text-tertiary)}.dashboard-grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(300px,.8fr);gap:18px;margin-top:18px}.focus-card,.quick-card,.service-overview{padding:24px}.focus-card header,.quick-card header,.service-overview header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.focus-card h2,.quick-card h2,.service-overview h2{margin-top:5px;font-size:18px}.task-row,.quick-action{width:100%;border:0;background:transparent;display:flex;align-items:center;text-align:left;cursor:pointer}.task-row{min-height:76px;border-top:1px solid var(--color-border-light);gap:14px}.task-row:hover,.quick-action:hover{background:var(--color-primary-lighter)}.task-dot{width:9px;height:9px;border-radius:50%;background:var(--color-primary)}.task-dot.green{background:var(--color-success)}.task-dot.blue{background:var(--color-info)}.task-dot.amber{background:var(--color-warning)}.task-copy{flex:1;display:flex;flex-direction:column}.task-copy small,.quick-action small{margin-top:5px;color:var(--color-text-tertiary)}.task-count{font-size:22px;font-weight:650;color:var(--color-primary)}.quick-action{padding:15px 8px;gap:12px;border-top:1px solid var(--color-border-light)}.quick-icon{width:38px;height:38px;display:grid;place-items:center;border-radius:12px;background:var(--color-primary-light);color:var(--color-primary);font-weight:650}.quick-action>span:nth-child(2){flex:1;display:flex;flex-direction:column}.privacy-note{margin-top:18px;padding:16px;display:flex;gap:10px;background:#F4F8F6;color:var(--color-success);border-radius:12px}.privacy-note span{display:flex;flex-direction:column}.privacy-note small{margin-top:4px;color:var(--color-text-secondary);line-height:1.5}.service-overview{margin-top:18px}.preview-label{font-size:11px;color:var(--color-warning);background:#FFF7E9;padding:5px 10px;border-radius:99px}.progress-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:30px}.progress-item>div{display:flex;justify-content:space-between;margin-bottom:12px}.progress-item strong{font-size:18px}.progress-item small{display:block;margin-top:8px;color:var(--color-text-tertiary)} .dashboard{padding-top:28px;padding-bottom:40px}.welcome{min-height:150px;padding:30px 34px;display:flex;align-items:center;justify-content:space-between;border-radius:18px;background:linear-gradient(120deg,#FFF1EE,#FFF9F2 56%,#F4F8F6);border:1px solid #F6E4DE;position:relative;overflow:hidden}.welcome:after{content:'缘';position:absolute;right:130px;top:-54px;font-size:210px;font-weight:700;color:rgba(233,107,107,.07)}.eyebrow,.section-kicker{font-size:12px;color:var(--color-primary);font-weight:650;letter-spacing:1px}.welcome h1{margin-top:9px;font-size:28px}.welcome p{margin-top:10px;color:var(--color-text-secondary)}.date-card{z-index:1;padding:14px 18px;border-radius:13px;background:rgba(255,255,255,.75);border:1px solid rgba(255,255,255,.9);display:flex;flex-direction:column;text-align:right}.date-card span{font-size:12px;color:var(--color-text-tertiary)}.date-card strong{margin-top:4px;font-size:16px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-top:18px}.metric-card{padding:20px}.metric-top{display:flex;justify-content:space-between;color:var(--color-text-secondary)}.metric-icon{width:34px;height:34px;display:grid;place-items:center;border-radius:11px;font-weight:650}.metric-icon.rose{background:var(--color-primary-light);color:var(--color-primary)}.metric-icon.green{background:#ECF8F2;color:var(--color-success)}.metric-icon.blue{background:#EEF4FF;color:var(--color-info)}.metric-icon.amber{background:#FFF7E9;color:var(--color-warning)}.metric-card>strong{display:block;margin-top:8px;font-size:30px}.metric-card>small{display:block;margin-top:6px;color:var(--color-text-tertiary)}.dashboard-grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(300px,.8fr);gap:18px;margin-top:18px}.focus-card,.quick-card,.service-overview{padding:24px}.focus-card header,.quick-card header,.service-overview header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.focus-card h2,.quick-card h2,.service-overview h2{margin-top:5px;font-size:18px}.task-row,.quick-action{width:100%;border:0;background:transparent;display:flex;align-items:center;text-align:left;cursor:pointer}.task-row{min-height:76px;border-top:1px solid var(--color-border-light);gap:14px}.task-row:hover,.quick-action:hover{background:var(--color-primary-lighter)}.task-dot{width:9px;height:9px;border-radius:50%;background:var(--color-primary)}.task-dot.green{background:var(--color-success)}.task-dot.blue{background:var(--color-info)}.task-dot.amber{background:var(--color-warning)}.task-copy{flex:1;display:flex;flex-direction:column}.task-copy small,.quick-action small{margin-top:5px;color:var(--color-text-tertiary)}.task-count{font-size:22px;font-weight:650;color:var(--color-primary)}.quick-action{padding:15px 8px;gap:12px;border-top:1px solid var(--color-border-light)}.quick-icon{width:38px;height:38px;display:grid;place-items:center;border-radius:12px;background:var(--color-primary-light);color:var(--color-primary);font-weight:650}.quick-action>span:nth-child(2){flex:1;display:flex;flex-direction:column}.privacy-note{margin-top:18px;padding:16px;display:flex;gap:10px;background:#F4F8F6;color:var(--color-success);border-radius:12px}.privacy-note span{display:flex;flex-direction:column}.privacy-note small{margin-top:4px;color:var(--color-text-secondary);line-height:1.5}.service-overview{margin-top:18px}.preview-label{font-size:11px;color:var(--color-warning);background:#FFF7E9;padding:5px 10px;border-radius:99px}.progress-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:30px}.progress-item>div{display:flex;justify-content:space-between;margin-bottom:12px}.progress-item strong{font-size:18px}.progress-item small{display:block;margin-top:8px;color:var(--color-text-tertiary)}
@media(max-width:1100px){.metrics{grid-template-columns:repeat(2,1fr)}.dashboard-grid{grid-template-columns:1fr}.progress-grid{grid-template-columns:1fr}.welcome:after{right:20px}} @media(max-width:1100px){.metrics{grid-template-columns:repeat(2,1fr)}.dashboard-grid{grid-template-columns:1fr}.progress-grid{grid-template-columns:1fr}.welcome:after{right:20px}}
@media(max-width:680px){.metrics{grid-template-columns:1fr}.welcome{padding:24px}.date-card{display:none}} @media(max-width:680px){.metrics{grid-template-columns:1fr}.welcome{padding:24px}.date-card{display:none}}
</style> .welcome{background:var(--admin-soft-pink)}.welcome:after{content:none}
</style>
+243 -46
View File
@@ -1,42 +1,77 @@
<template> <template>
<div class="login-container"> <main class="login-shell">
<div class="login-card"> <section class="brand-panel" aria-label="善缘丹阳公益婚恋服务平台">
<div class="login-header"> <img class="brand-scene" src="/brand/login-illustration.jpg" alt="" />
<h1>丹阳公益婚恋服务平台</h1> <div class="brand-overlay">
<p>工作人员登录</p> <div class="brand-title">
<img src="/brand/logo-mark.png" alt="" />
<div>
<strong>善缘丹阳</strong>
<span>公益婚恋服务平台</span>
</div>
</div>
<div class="brand-message">
<p>公益 · 真实 · 安全 · 温暖</p>
<h1>让每一份真诚,都在丹阳得到回应</h1>
<span>用专业服务守护真实相识,以公益初心陪伴幸福成长。</span>
</div>
</div> </div>
<el-form ref="formRef" :model="form" :rules="rules" class="login-form" @submit.prevent="handleLogin"> </section>
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" size="large" /> <section class="login-panel">
</el-form-item> <div class="login-card">
<el-form-item prop="password"> <div class="login-header">
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" <span class="eyebrow">管理端 · 运营后台</span>
size="large" show-password @keyup.enter="handleLogin" /> <h2>欢迎回来</h2>
</el-form-item> <p>请使用工作人员账号登录</p>
<el-form-item> </div>
<el-button type="primary" size="large" :loading="loading" style="width:100%;background:#E96B6B;border-color:#E96B6B"
@click="handleLogin">登录</el-button> <el-form ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="handleLogin">
</el-form-item> <el-form-item label="用户名" prop="username">
</el-form> <el-input v-model.trim="form.username" autocomplete="username" placeholder="请输入用户名" size="large">
<div class="login-footer"> <template #prefix><el-icon><User /></el-icon></template>
<p>丹阳公益婚恋服务平台 · 运营管理后台</p> </el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
autocomplete="current-password"
type="password"
placeholder="请输入密码"
size="large"
show-password
@keyup.enter="handleLogin"
>
<template #prefix><el-icon><Lock /></el-icon></template>
</el-input>
</el-form-item>
<el-button class="submit-button" type="primary" size="large" :loading="loading" @click="handleLogin">
登录管理后台
</el-button>
</el-form>
<div class="safety-note">
<el-icon><CircleCheck /></el-icon>
<span>敏感数据按权限访问,所有关键操作均记录审计日志。</span>
</div>
</div> </div>
</div> <footer>善缘丹阳 · 公益婚恋服务平台</footer>
</div> </section>
</main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from 'vue' import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import type { FormInstance } from 'element-plus' import type { FormInstance } from 'element-plus'
import { CircleCheck, Lock, User } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const router = useRouter() const router = useRouter()
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const loading = ref(false) const loading = ref(false)
const authStore = useAuthStore() const authStore = useAuthStore()
const form = reactive({ username: '', password: '' }) const form = reactive({ username: '', password: '' })
const rules = { const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
@@ -44,30 +79,192 @@ const rules = {
} }
async function handleLogin() { async function handleLogin() {
if (!formRef.value) return if (!formRef.value || loading.value) return
await formRef.value.validate(async (valid) => { const valid = await formRef.value.validate().catch(() => false)
if (!valid) return if (!valid) return
loading.value = true loading.value = true
try { try {
await authStore.login(form.username, form.password) await authStore.login(form.username, form.password)
ElMessage.success('登录成功') ElMessage.success('登录成功')
router.push('/dashboard') router.push('/dashboard')
} catch (err: any) { } catch (error: any) {
ElMessage.error(err.message || '登录失败,请检查用户名和密码') ElMessage.error(error.message || '登录失败,请检查用户名和密码')
} finally { } finally {
loading.value = false loading.value = false
} }
})
} }
</script> </script>
<style scoped> <style scoped>
.login-container { width: 100%; height: 100vh; display: flex; align-items: center; justify-content: center; .login-shell {
background: linear-gradient(135deg, #FFF3F2 0%, #FFF8F7 100%); } display: grid;
.login-card { width: 400px; padding: 40px; background: #fff; border-radius: 12px; box-shadow: 0 4px 24px rgba(233,107,107,0.1); } grid-template-columns: minmax(0, 1.25fr) minmax(420px, 0.75fr);
.login-header { text-align: center; margin-bottom: 32px; } min-height: 100vh;
.login-header h1 { font-size: 24px; color: #E96B6B; margin-bottom: 8px; } background: #FFFFFF;
.login-header p { font-size: 14px; color: #999; } }
.login-form { margin-top: 24px; }
.login-footer { text-align: center; margin-top: 24px; font-size: 12px; color: #bbb; } .brand-panel {
position: relative;
min-height: 100vh;
overflow: hidden;
background: var(--admin-soft-pink);
}
.brand-scene {
width: 100%;
height: 100%;
object-fit: cover;
}
.brand-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 48px 56px 70px;
}
.brand-title {
display: flex;
align-items: center;
gap: 14px;
}
.brand-title img {
width: 54px;
height: 54px;
}
.brand-title div {
display: flex;
flex-direction: column;
}
.brand-title strong {
color: var(--admin-primary);
font-size: 25px;
letter-spacing: 2px;
}
.brand-title span {
margin-top: 3px;
color: var(--color-text-secondary);
font-size: 12px;
}
.brand-message {
max-width: 590px;
padding: 26px 30px;
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(255, 255, 255, 0.94);
border-radius: 18px;
backdrop-filter: blur(10px);
}
.brand-message p {
color: var(--admin-primary);
font-weight: 650;
letter-spacing: 3px;
}
.brand-message h1 {
margin-top: 12px;
font-size: clamp(28px, 3vw, 42px);
line-height: 1.25;
}
.brand-message span {
display: block;
margin-top: 14px;
color: var(--color-text-secondary);
line-height: 1.7;
}
.login-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
background: #FFFFFF;
}
.login-card {
width: min(100%, 420px);
}
.login-header {
margin-bottom: 32px;
}
.eyebrow {
color: var(--admin-primary);
font-size: 12px;
font-weight: 650;
letter-spacing: 1px;
}
.login-header h2 {
margin-top: 10px;
font-size: 30px;
}
.login-header p {
margin-top: 8px;
color: var(--color-text-tertiary);
}
.submit-button {
width: 100%;
margin-top: 8px;
}
.safety-note {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 24px;
padding: 14px 16px;
color: var(--color-text-secondary);
font-size: 12px;
line-height: 1.6;
background: var(--admin-soft-green);
border-radius: 10px;
}
.safety-note .el-icon {
flex: none;
margin-top: 2px;
color: var(--color-success);
}
footer {
margin-top: 40px;
color: var(--color-text-tertiary);
font-size: 12px;
}
@media (max-width: 900px) {
.login-shell {
grid-template-columns: 1fr;
}
.brand-panel {
min-height: 280px;
}
.brand-overlay {
padding: 28px;
}
.brand-message {
display: none;
}
.login-panel {
min-height: calc(100vh - 280px);
padding: 36px 24px;
}
}
</style> </style>
+15
View File
@@ -29,4 +29,19 @@ assert.ok(layout.includes('/brand/logo-mark.png'), '管理端未使用正式品
assert.ok(layout.includes('globalSearch'), '管理端顶部搜索没有可操作状态') assert.ok(layout.includes('globalSearch'), '管理端顶部搜索没有可操作状态')
assert.ok(layout.includes('showNotifications'), '管理端通知入口没有可操作反馈') assert.ok(layout.includes('showNotifications'), '管理端通知入口没有可操作反馈')
for (const file of [
'admin-web/src/views/LoginView.vue',
'admin-web/src/views/DashboardView.vue',
'admin-web/src/modules/user/UserManagementView.vue',
'admin-web/src/modules/user/UserDetailView.vue',
'admin-web/src/modules/review/ReviewView.vue',
]) {
const source = read(file)
assert.ok(
source.includes('page-shell') || source.includes('login-shell'),
`${file} 未接入样板布局`,
)
assert.doesNotMatch(source, /[\u{1F300}-\u{1FAFF}]/u, `${file} 仍包含 Emoji`)
}
console.log('Admin redesign contracts passed') console.log('Admin redesign contracts passed')