feat(auth): 优化PC与移动端登录注册界面并支持邀请码锁定绑定

- 重构 PC 端 LoginView.vue 与移动端 MobileRegisterView.vue,统一契合平台金橙色主体色
- 识别邀请链接并自动填入邀请码,锁定输入框为只读并展示「已绑定邀请码」专属徽章,防止篡改
- 短链 /i/:code 与 /m/i/:code 统一重定向至登录注册页,移除冗余独立落地页
- PC 端页脚完全对齐主站并固定沉底,左侧定制官方品质、安全上号、资金担保平台核心卖点
- 验证方式:npm run check(Prettier + ESLint + vue-tsc)全数通过
This commit is contained in:
yml2213
2026-10-10 13:48:14 +08:00
parent 7cddbe9463
commit 825ce83003
7 changed files with 1313 additions and 1336 deletions
File diff suppressed because it is too large Load Diff
@@ -27,7 +27,16 @@ const form = reactive({
const { countDown, sending, handleSendCode } = useSmsCountdown()
onMounted(loadCaptcha)
onMounted(() => {
if (route.query.tab === 'register' || route.query.invite_code || route.query.code) {
void router.replace({
path: '/m/register',
query: route.query,
})
return
}
void loadCaptcha()
})
function switchToPassword() {
loginMode.value = 'password'
@@ -13,10 +13,15 @@ const router = useRouter()
const route = useRoute()
const session = useSessionStore()
const siteStore = useDistributionSiteStore()
const loading = ref(false)
const agreed = ref(false)
const captchaLoading = ref(false)
const captcha = ref<AuthCaptcha | null>(null)
const isInviteLocked = ref(false)
const showPassword = ref(false)
const showConfirmPassword = ref(false)
const form = reactive({
phone: '',
captchaCode: '',
@@ -28,7 +33,14 @@ const form = reactive({
const { countDown, sending, handleSendCode } = useSmsCountdown()
onMounted(loadCaptcha)
onMounted(async () => {
await loadCaptcha()
const qInviteCode = (route.query.invite_code || route.query.code) as string | undefined
if (qInviteCode && qInviteCode.trim()) {
form.inviteCode = qInviteCode.trim()
isInviteLocked.value = true
}
})
async function loadCaptcha() {
captchaLoading.value = true
@@ -54,11 +66,20 @@ async function sendCode() {
}
async function handleRegister() {
if (!agreed.value) {
showToast({
message: '请先阅读并同意用户协议和隐私政策',
icon: 'warning-o',
})
if (!form.phone.trim()) {
showToast({ message: '请输入手机号', icon: 'warning-o' })
return
}
if (!/^1[3-9]\d{9}$/.test(form.phone.trim())) {
showToast({ message: '请输入正确的11位手机号码', icon: 'warning-o' })
return
}
if (!form.captchaCode.trim()) {
showToast({ message: '请输入图形验证码', icon: 'warning-o' })
return
}
if (!form.code.trim()) {
showToast({ message: '请输入短信验证码', icon: 'warning-o' })
return
}
if (!form.password) {
@@ -73,14 +94,27 @@ async function handleRegister() {
showToast({ message: '两次输入的密码不一致', icon: 'warning-o' })
return
}
if (!agreed.value) {
showToast({
message: '请先阅读并同意用户协议和隐私政策',
icon: 'warning-o',
})
return
}
loading.value = true
try {
await session.register(form.phone, form.code, form.password)
await session.register(
form.phone.trim(),
form.code.trim(),
form.password,
form.inviteCode.trim() || undefined
)
showToast({ message: '注册成功', icon: 'success' })
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/m/profile'
await router.replace(redirect)
} catch {
showToast({ message: '注册失败,请检查手机号和验证码', icon: 'cross' })
} catch (error) {
showToast({ message: readError(error, '注册失败,请核对信息'), icon: 'cross' })
} finally {
loading.value = false
}
@@ -90,12 +124,13 @@ async function handleRegister() {
<template>
<main class="mobile-register-shell">
<header class="page-header">
<button class="back-btn" type="button" @click="router.back()">
<button class="back-btn" type="button" aria-label="返回上一页" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
</header>
<section class="auth-body">
<!-- 品牌信息 -->
<div class="brand-lockup">
<img v-if="siteStore.logoUrl" :src="siteStore.logoUrl" alt="" class="brand-logo-img" />
<div v-else class="brand-logo">{{ siteStore.brandMark }}</div>
@@ -103,30 +138,39 @@ async function handleRegister() {
</div>
<div class="form-section">
<h1>注册</h1>
<div class="form-title-row">
<h1>注册账号</h1>
<RouterLink to="/m/login" class="title-link">已有账号?<b>去登录</b></RouterLink>
</div>
<!-- 手机号 -->
<label class="auth-input-row">
<van-icon name="phone-o" class="row-icon" />
<input
v-model="form.phone"
type="tel"
maxlength="11"
inputmode="tel"
placeholder="手机号"
placeholder="请输入手机号"
autocomplete="tel"
/>
</label>
<!-- 图形验证码 -->
<label class="auth-input-row captcha-row">
<van-icon name="shield-o" class="row-icon" />
<input
v-model="form.captchaCode"
type="text"
maxlength="4"
autocomplete="off"
placeholder="图形验证码"
placeholder="请输入图形验证码"
/>
<button
class="captcha-image-button"
type="button"
:disabled="captchaLoading"
title="点击刷新验证码"
@click="loadCaptcha"
>
<img v-if="captcha" :src="captcha.image" alt="图形验证码" />
@@ -134,13 +178,15 @@ async function handleRegister() {
</button>
</label>
<!-- 短信验证码 -->
<label class="auth-input-row code-row">
<van-icon name="comment-o" class="row-icon" />
<input
v-model="form.code"
type="text"
inputmode="numeric"
maxlength="6"
placeholder="验证码"
placeholder="请输入短信验证码"
/>
<span class="code-action">
<van-button
@@ -156,39 +202,66 @@ async function handleRegister() {
</span>
</label>
<label class="auth-input-row">
<!-- 设置密码 -->
<label class="auth-input-row pwd-row">
<van-icon name="lock" class="row-icon" />
<input
v-model="form.password"
type="password"
:type="showPassword ? 'text' : 'password'"
maxlength="20"
placeholder="设置密码(8-20 位)"
autocomplete="new-password"
/>
<button
type="button"
class="pwd-toggle-btn"
aria-label="切换密码可见性"
@click="showPassword = !showPassword"
>
<van-icon :name="showPassword ? 'eye-o' : 'closed-eye'" size="18" />
</button>
</label>
<label class="auth-input-row">
<!-- 确认密码 -->
<label class="auth-input-row pwd-row">
<van-icon name="lock" class="row-icon" />
<input
v-model="form.confirmPassword"
type="password"
:type="showConfirmPassword ? 'text' : 'password'"
maxlength="20"
placeholder="确认密码"
placeholder="确认登录密码"
autocomplete="new-password"
/>
<button
type="button"
class="pwd-toggle-btn"
aria-label="切换密码可见性"
@click="showConfirmPassword = !showConfirmPassword"
>
<van-icon :name="showConfirmPassword ? 'eye-o' : 'closed-eye'" size="18" />
</button>
</label>
<label class="auth-input-row">
<input v-model="form.inviteCode" maxlength="16" placeholder="邀请码(选填)" />
<!-- 邀请码 -->
<label class="auth-input-row invite-row" :class="{ 'is-locked': isInviteLocked }">
<van-icon name="coupon-o" class="row-icon" />
<input
v-model="form.inviteCode"
maxlength="32"
:readonly="isInviteLocked"
:placeholder="isInviteLocked ? '' : '邀请码(选填)'"
/>
<span v-if="isInviteLocked" class="invite-locked-tag">已绑定邀请码</span>
</label>
<div class="assist-row">
<span>验证码注册</span>
<RouterLink to="/m/login">已有账号?<b>去登录</b></RouterLink>
</div>
<!-- 协议勾选 -->
<div class="agreement-row">
<van-checkbox v-model="agreed" shape="square" icon-size="16px">
<van-checkbox v-model="agreed" shape="square" icon-size="16px" checked-color="#ff6a00">
我已阅读并同意《<b>用户协议</b>》和《<b>隐私政策</b>》
</van-checkbox>
</div>
<!-- 注册按钮 -->
<van-button
type="primary"
block
@@ -200,7 +273,8 @@ async function handleRegister() {
注册并登录
</van-button>
<RouterLink to="/m/login" class="secondary-entry"> 返回登录 </RouterLink>
<!-- 底部快捷跳转 -->
<RouterLink to="/m/login" class="secondary-entry"> 已有账号?去登录 </RouterLink>
</div>
</section>
</main>
@@ -209,9 +283,10 @@ async function handleRegister() {
<style scoped>
.mobile-register-shell {
min-height: 100dvh;
box-sizing: border-box;
background:
radial-gradient(circle at 50% 12%, rgba(20, 119, 255, 0.1), transparent 34%),
linear-gradient(180deg, #f8fbff 0%, #ffffff 70%, #f7fafc 100%);
radial-gradient(circle at 50% 12%, rgba(255, 106, 0, 0.12), transparent 34%),
linear-gradient(180deg, #fff7ed 0%, #ffffff 68%, #fffaf4 100%);
color: #111827;
}
@@ -224,20 +299,20 @@ async function handleRegister() {
display: flex;
align-items: center;
height: 48px;
margin: 0;
padding: 0 18px;
padding: 0 16px;
background: transparent;
}
.back-btn {
display: grid;
width: 34px;
height: 34px;
width: 36px;
height: 36px;
place-items: center;
border: none;
border-radius: 999px;
background: none;
color: #111827;
background: rgba(255, 255, 255, 0.8);
color: #1e293b;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
}
.auth-body {
@@ -246,113 +321,158 @@ async function handleRegister() {
min-height: 100dvh;
flex-direction: column;
justify-content: center;
padding: 50px 34px 22px;
padding: 56px 24px 32px;
}
/* 品牌头部 */
.brand-lockup {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin: 0 auto clamp(24px, 4vh, 38px);
margin: 0 auto clamp(20px, 3vh, 32px);
}
.brand-logo {
display: grid;
width: 46px;
height: 46px;
width: 48px;
height: 48px;
place-items: center;
border-radius: 14px;
background: linear-gradient(135deg, #1477ff, #2f8dff);
box-shadow: 0 14px 32px rgba(20, 119, 255, 0.18);
background: linear-gradient(135deg, #ff6a00, #ff8a1f);
box-shadow: 0 10px 26px rgba(255, 106, 0, 0.28);
color: #ffffff;
font-size: 19px;
font-size: 20px;
font-weight: 900;
}
.brand-logo-img {
width: 46px;
height: 46px;
width: 48px;
height: 48px;
border-radius: 14px;
object-fit: cover;
box-shadow: 0 14px 32px rgba(23, 35, 61, 0.12);
box-shadow: 0 10px 26px rgba(23, 35, 61, 0.12);
}
.brand-lockup strong {
display: block;
color: #111827;
font-size: 18px;
color: #0f172a;
font-size: 19px;
font-weight: 900;
letter-spacing: 0;
letter-spacing: 0.3px;
}
/* 表单容器 */
.form-section {
width: min(100%, 420px);
width: min(100%, 400px);
margin: 0 auto;
}
.form-section h1 {
margin: 0 0 20px;
color: #05070a;
font-size: 22px;
.form-title-row {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 20px;
}
.form-title-row h1 {
margin: 0;
color: #0f172a;
font-size: 24px;
font-weight: 900;
line-height: 1.2;
}
.title-link {
font-size: 13px;
color: #64748b;
text-decoration: none;
}
.title-link b {
color: #ff6a00;
font-weight: 700;
}
/* 输入框行 Shell */
.auth-input-row {
display: flex;
align-items: center;
min-height: 48px;
margin-bottom: 10px;
padding: 0 16px;
border: 1px solid #e1e5eb;
border-radius: 10px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.03);
margin-bottom: 12px;
padding: 0 14px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.02);
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.auth-input-row.code-row {
gap: 8px;
.auth-input-row:focus-within {
border-color: #ff6a00;
box-shadow: 0 0 0 3px rgba(255, 106, 0, 0.12);
}
.auth-input-row.captcha-row {
gap: 10px;
padding-right: 4px;
.row-icon {
font-size: 17px;
color: #94a3b8;
margin-right: 10px;
flex-shrink: 0;
}
.auth-input-row input {
width: 100%;
flex: 1;
min-width: 0;
height: 100%;
border: none;
background: transparent;
color: #0f172a;
font-size: 16px;
font-size: 15px;
outline: none;
}
.auth-input-row input::placeholder {
color: #b6beca;
color: #94a3b8;
font-size: 14px;
}
.code-action {
display: flex;
justify-content: flex-end;
/* 密码明暗切换 */
.pwd-toggle-btn {
border: none;
background: none;
color: #94a3b8;
padding: 4px;
display: grid;
place-items: center;
cursor: pointer;
}
.pwd-toggle-btn:active {
color: #ff6a00;
}
/* 图形验证码 */
.captcha-row {
padding-right: 4px;
}
.captcha-image-button {
display: grid;
flex: 0 0 116px;
width: 116px;
height: 40px;
flex: 0 0 106px;
width: 106px;
height: 38px;
place-items: center;
overflow: hidden;
border: 1px solid #dbe5f0;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
color: #1477ff;
color: #ff6a00;
font-size: 12px;
font-weight: 800;
padding: 0;
cursor: pointer;
}
.captcha-image-button:disabled {
@@ -361,104 +481,125 @@ async function handleRegister() {
.captcha-image-button img {
display: block;
width: 120px;
height: 40px;
width: 100%;
height: 100%;
object-fit: cover;
}
.code-btn {
min-width: 82px;
height: 32px;
border-color: #1477ff !important;
border-radius: 8px;
color: #1477ff !important;
font-weight: 700;
/* 短信验证码 */
.code-row {
padding-right: 4px;
}
.assist-row {
.code-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin: 12px 0 18px;
color: #111827;
font-size: 13px;
justify-content: flex-end;
}
.assist-row span {
min-width: 0;
color: #1477ff;
.code-btn {
min-width: 86px;
height: 36px;
border: 1px solid #fed7aa !important;
border-radius: 8px !important;
background: #fff7ed !important;
color: #ff6a00 !important;
font-weight: 700;
font-size: 13px !important;
}
.assist-row a {
.code-btn:disabled {
border-color: #e2e8f0 !important;
background: #f8fafc !important;
color: #94a3b8 !important;
opacity: 0.65;
}
/* 锁定邀请码 */
.auth-input-row.is-locked {
background: #f8fafc;
border-color: #e2e8f0;
}
.auth-input-row.is-locked input {
color: #0f172a;
font-weight: 700;
letter-spacing: 0.5px;
}
.invite-locked-tag {
flex-shrink: 0;
color: #22252b;
text-decoration: none;
}
.assist-row b {
color: #05070a;
font-weight: 900;
margin-left: 8px;
padding: 3px 8px;
border-radius: 4px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
color: #059669;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
/* 协议条款勾选 */
.agreement-row {
display: flex;
align-items: center;
margin: 0 0 18px;
margin: 6px 0 18px;
}
.agreement-row :deep(.van-checkbox__label) {
margin-left: 8px;
color: #252b36;
color: #64748b;
font-size: 12px;
line-height: 1.4;
}
.agreement-row :deep(.van-checkbox__label b) {
color: #1477ff;
color: #ff6a00;
font-weight: 700;
}
/* 注册主按钮(金橙色渐变) */
.primary-button {
height: 48px;
border: none !important;
border-radius: 10px !important;
background: #1477ff !important;
box-shadow: 0 12px 24px rgba(20, 119, 255, 0.18);
font-size: 17px;
font-weight: 900;
border-radius: 12px !important;
background: linear-gradient(135deg, #ff6a00, #ff8a1f) !important;
box-shadow: 0 10px 24px rgba(255, 106, 0, 0.28) !important;
font-size: 16px;
font-weight: 800;
color: #ffffff !important;
letter-spacing: 0.5px;
}
.primary-button:active {
opacity: 0.9;
}
/* 次要入口 */
.secondary-entry {
display: grid;
min-height: 44px;
margin-top: 10px;
place-items: center;
border: 1px solid #e4e8ee;
border-radius: 10px;
background: rgba(255, 255, 255, 0.82);
color: #1477ff;
font-size: 15px;
font-weight: 900;
display: block;
margin-top: 16px;
text-align: center;
color: #64748b;
font-size: 13px;
text-decoration: none;
}
@media (max-height: 760px) {
.secondary-entry:active {
color: #ff6a00;
}
@media (max-height: 740px) {
.auth-body {
justify-content: flex-start;
padding-top: 48px;
padding-top: 52px;
}
.brand-lockup {
margin-bottom: 20px;
}
.form-section h1 {
margin-bottom: 16px;
}
.assist-row {
.form-title-row {
margin-bottom: 14px;
}
}