Files
hfb_sys/frontend/src/features/auth/views/RealnameView.vue
T
yml f410be3797 代码格式化: prettier --write 统一代码风格
39 个文件按 .prettierrc.json (printWidth 100 / no semi / singleQuote) 统一格式, 纯空白与换行调整, 无逻辑变更。为接入代码门禁做准备, 让 npm run check (format:check + lint:strict + typecheck) 干净通过。
2026-06-14 16:24:01 +08:00

598 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { readError } from '@/shared/utils/error'
import {
CircleCheckFilled,
CreditCard,
DocumentChecked,
Lock,
Postcard,
RefreshRight,
User,
WarningFilled,
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getRealnameStatus, startRealname, type RealnameStatus } from '@/features/auth/api/realname'
import { useSessionStore } from '@/stores/session'
import { realnameStatusLabel } from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time'
const session = useSessionStore()
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const status = ref<RealnameStatus | null>(null)
const form = reactive({
name: '',
idNo: '',
})
const currentStatus = computed(() => status.value?.status || 'unverified')
const statusMeta = computed(() => {
switch (currentStatus.value) {
case 'verified':
return {
icon: CircleCheckFilled,
tone: 'verified',
title: '认证已完成',
summary: '可发布账号并参与需要实名的交易流程。',
}
case 'pending':
return {
icon: DocumentChecked,
tone: 'pending',
title: '认证处理中',
summary: '认证结果返回后会自动同步到账号状态。',
}
case 'rejected':
return {
icon: WarningFilled,
tone: 'rejected',
title: '认证未通过',
summary: status.value?.fail_reason || '请核对姓名与身份证号后重新提交。',
}
default:
return {
icon: Postcard,
tone: 'unverified',
title: '等待认证',
summary: '提交真实姓名与身份证号后即可完成核验。',
}
}
})
const canSubmit = computed(() => !!session.token && currentStatus.value !== 'verified')
onMounted(loadStatus)
function redirectAfterVerified() {
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : ''
if (redirect) {
router.replace(redirect)
}
}
async function loadStatus() {
if (!session.token) return
try {
status.value = await getRealnameStatus()
if (status.value.status === 'verified') {
await session.loadMe()
redirectAfterVerified()
}
} catch {
status.value = null
}
}
async function handleSubmit() {
if (!form.name.trim()) {
ElMessage.warning('请输入真实姓名')
return
}
if (!form.idNo.trim() || form.idNo.length < 15) {
ElMessage.warning('请输入有效的证件号')
return
}
loading.value = true
try {
status.value = await startRealname(form.name, form.idNo)
await session.loadMe()
ElMessage.success('实名认证已通过')
redirectAfterVerified()
} catch (error) {
ElMessage.error(readError(error, '实名认证失败'))
} finally {
loading.value = false
}
}
</script>
<template>
<section class="realname-page">
<div class="realname-hero">
<div class="hero-copy">
<span class="eyebrow">Realname</span>
<h1>实名认证</h1>
<p>完成实名后可发布账号、进入交易流程,并提升账号可信度。</p>
</div>
<div class="hero-status" :class="`is-${statusMeta.tone}`">
<el-icon><component :is="statusMeta.icon" /></el-icon>
<div>
<span>当前状态</span>
<strong>{{ realnameStatusLabel(currentStatus) }}</strong>
</div>
</div>
</div>
<el-alert
v-if="!session.token"
class="login-alert"
type="warning"
:closable="false"
title="请先登录后再实名认证"
/>
<div class="realname-layout">
<section class="verify-panel">
<div class="panel-head">
<div class="panel-icon">
<el-icon><CreditCard /></el-icon>
</div>
<div>
<h2>{{ currentStatus === 'verified' ? '实名信息' : '提交认证' }}</h2>
<p>
{{
currentStatus === 'verified'
? '实名信息已完成脱敏展示。'
: '请使用本人真实身份信息完成核验。'
}}
</p>
</div>
</div>
<div v-if="currentStatus === 'verified'" class="verified-summary">
<div class="verified-mark">
<el-icon><CircleCheckFilled /></el-icon>
</div>
<div class="verified-copy">
<strong>认证通过</strong>
<span>{{
status?.verified_at ? formatDateTime(status.verified_at) : '已完成实名核验'
}}</span>
</div>
</div>
<el-form v-else class="verify-form" label-position="top">
<el-form-item label="姓名">
<el-input
v-model="form.name"
:prefix-icon="User"
placeholder="请输入真实姓名"
size="large"
/>
</el-form-item>
<el-form-item label="身份证号">
<el-input
v-model="form.idNo"
:prefix-icon="Postcard"
maxlength="18"
placeholder="请输入 18 位身份证号"
size="large"
/>
</el-form-item>
<el-button
type="primary"
size="large"
:icon="DocumentChecked"
:disabled="!canSubmit"
:loading="loading"
@click="handleSubmit"
>
提交认证
</el-button>
</el-form>
</section>
<aside class="status-card" :class="`is-${statusMeta.tone}`">
<div class="status-visual">
<el-icon><component :is="statusMeta.icon" /></el-icon>
</div>
<div class="status-content">
<span class="status-kicker">认证状态</span>
<h2>{{ statusMeta.title }}</h2>
<p>{{ statusMeta.summary }}</p>
</div>
<dl class="status-details">
<div>
<dt>姓名</dt>
<dd>{{ status?.masked_name || '待提交' }}</dd>
</div>
<div>
<dt>证件号</dt>
<dd>{{ status?.masked_id_no || '待提交' }}</dd>
</div>
<div>
<dt>通过时间</dt>
<dd>{{ status?.verified_at ? formatDateTime(status.verified_at) : '暂无' }}</dd>
</div>
</dl>
<div class="security-note">
<el-icon><Lock /></el-icon>
<span>身份信息仅用于实名核验,页面只展示脱敏结果。</span>
</div>
</aside>
</div>
<div class="realname-actions">
<RouterLink class="secondary-action" to="/seller/listings/create">
<el-icon><RefreshRight /></el-icon>
<span>返回发布账号</span>
</RouterLink>
</div>
</section>
</template>
<style scoped>
.realname-page {
width: min(1180px, 100%);
margin: 0 auto;
padding: 10px 0 40px;
}
.realname-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
min-height: 156px;
padding: 34px 36px;
border: 1px solid #eef1f5;
border-radius: 18px;
background:
linear-gradient(
135deg,
rgba(255, 106, 0, 0.12) 0%,
rgba(22, 163, 74, 0.08) 52%,
rgba(37, 99, 235, 0.08) 100%
),
#ffffff;
box-shadow: 0 16px 36px rgba(23, 35, 61, 0.08);
}
.hero-copy {
max-width: 640px;
}
.eyebrow {
display: inline-flex;
align-items: center;
margin-bottom: 12px;
color: #ff6a00;
font-size: 13px;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
.hero-copy h1 {
margin: 0;
color: #17233d;
font-size: 36px;
line-height: 1.2;
}
.hero-copy p {
margin: 14px 0 0;
color: #64748b;
font-size: 16px;
font-weight: 600;
line-height: 1.7;
}
.hero-status {
display: flex;
align-items: center;
gap: 14px;
min-width: 210px;
padding: 18px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(255, 255, 255, 0.9);
box-shadow: 0 12px 28px rgba(23, 35, 61, 0.08);
}
.hero-status .el-icon {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: 12px;
font-size: 24px;
}
.hero-status span,
.status-kicker {
color: #8b9cb5;
font-size: 13px;
font-weight: 800;
}
.hero-status strong {
display: block;
margin-top: 4px;
color: #17233d;
font-size: 22px;
line-height: 1.2;
}
.hero-status.is-verified .el-icon,
.status-card.is-verified .status-visual {
background: #ecfdf3;
color: #16a34a;
}
.hero-status.is-pending .el-icon,
.status-card.is-pending .status-visual {
background: #fff7ed;
color: #f97316;
}
.hero-status.is-rejected .el-icon,
.status-card.is-rejected .status-visual {
background: #fff1f2;
color: #e11d48;
}
.hero-status.is-unverified .el-icon,
.status-card.is-unverified .status-visual {
background: #eff6ff;
color: #2563eb;
}
.login-alert {
margin-top: 18px;
}
.realname-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 22px;
margin-top: 22px;
}
.verify-panel,
.status-card {
border: 1px solid #eef1f5;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 10px 28px rgba(23, 35, 61, 0.06);
}
.verify-panel {
min-height: 430px;
padding: 28px;
}
.panel-head {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 28px;
}
.panel-icon {
display: grid;
width: 48px;
height: 48px;
place-items: center;
border-radius: 12px;
background: #fff3e8;
color: #ff6a00;
font-size: 24px;
}
.panel-head h2,
.status-content h2 {
margin: 0;
color: #17233d;
font-size: 22px;
line-height: 1.3;
}
.panel-head p,
.status-content p {
margin: 6px 0 0;
color: #64748b;
font-size: 14px;
line-height: 1.6;
}
.verify-form {
display: grid;
gap: 6px;
max-width: 620px;
}
.verify-form :deep(.el-form-item__label) {
color: #475569;
font-size: 14px;
font-weight: 800;
}
.verify-form :deep(.el-input__wrapper) {
min-height: 48px;
border-radius: 10px;
box-shadow: 0 0 0 1px #dbe3ee inset;
}
.verify-form :deep(.el-input__wrapper.is-focus) {
box-shadow:
0 0 0 1px #ff6a00 inset,
0 0 0 4px rgba(255, 106, 0, 0.08);
}
.verify-form .el-button {
width: 168px;
height: 46px;
margin-top: 8px;
border: none;
border-radius: 10px;
background: #ff6a00;
font-weight: 900;
box-shadow: 0 10px 20px rgba(255, 106, 0, 0.18);
}
.verified-summary {
display: flex;
align-items: center;
gap: 18px;
max-width: 620px;
padding: 24px;
border-radius: 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.verified-mark {
display: grid;
width: 58px;
height: 58px;
place-items: center;
border-radius: 16px;
background: #ecfdf3;
color: #16a34a;
font-size: 34px;
}
.verified-copy strong {
display: block;
color: #17233d;
font-size: 22px;
}
.verified-copy span {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 14px;
font-weight: 700;
}
.status-card {
padding: 24px;
}
.status-visual {
display: grid;
width: 56px;
height: 56px;
place-items: center;
border-radius: 16px;
font-size: 30px;
}
.status-content {
margin-top: 18px;
}
.status-details {
display: grid;
gap: 12px;
margin: 24px 0 0;
}
.status-details div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 46px;
padding: 0 14px;
border-radius: 10px;
background: #f8fafc;
}
.status-details dt {
color: #8b9cb5;
font-size: 13px;
font-weight: 800;
}
.status-details dd {
margin: 0;
color: #334155;
font-size: 14px;
font-weight: 800;
text-align: right;
}
.security-note {
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: 22px;
padding: 14px;
border-radius: 12px;
background: #f8fafc;
color: #64748b;
font-size: 13px;
font-weight: 700;
line-height: 1.6;
}
.security-note .el-icon {
margin-top: 2px;
color: #2563eb;
font-size: 18px;
flex-shrink: 0;
}
.realname-actions {
display: flex;
justify-content: flex-end;
margin-top: 18px;
}
.secondary-action {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 0 16px;
border-radius: 10px;
background: #ffffff;
border: 1px solid #e2e8f0;
color: #475569;
font-size: 14px;
font-weight: 800;
text-decoration: none;
transition: all 0.2s;
}
.secondary-action:hover {
border-color: #ff6a00;
color: #ff6a00;
box-shadow: 0 8px 18px rgba(255, 106, 0, 0.08);
}
@media (max-width: 1100px) {
.realname-layout {
grid-template-columns: 1fr;
}
.realname-hero {
align-items: flex-start;
flex-direction: column;
}
.hero-status {
width: 100%;
}
}
</style>