新增登陆页面,去掉之前的窗口登陆

This commit is contained in:
xuxin
2026-06-04 17:41:07 +08:00
parent 2844d99ae1
commit 540d61b6a0
7 changed files with 747 additions and 82 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ inclusion: always
## 编码规范 ## 编码规范
- 颜色用尽量用variables.scss里的统一颜色变量,特别是背景,按钮,文字的颜色 - 颜色用尽量用variables.scss里的统一颜色变量,特别是背景,按钮,文字的颜色
- 除了Home.vue首页外,其他页面我上传了截图要写页面或组件的,需要参考图片里文字和布局结构,在保证美观前提上自由发挥 - 除了Home.vue首页外,其他页面我上传了截图要写页面或组件的,需要参考图片里文字和布局结构,在保证美观前提上自由发挥(如果我粘贴了图片内容里的完整样式过来,那就要参考我给的样式结合图片里的结构和样式代码保证还原度)
- 全局用1rem=100px的格式并注意对某些特殊元素组件的line-height行高影响,纵布局如非必要不用flex-direction: column布局 - 全局用1rem=100px的格式并注意对某些特殊元素组件的line-height行高影响,纵布局如非必要不用flex-direction: column布局
- 如果是建一个组件,这个组件看我说是用在views里哪个页面的,比如用在Profile.vue里的组件,组件名字最前面要加Profile,而且整个组件的命名不能过度简化,要容易看懂组件的用途;如果检测到某种名字开头的组件数量比如Profile开头的超过15个,就在components里新建个类似profile这样的页面名字的文件夹,把这类命名的组件都移到文件夹里并查找更新组件所有被引用地方的文件地址 - 如果是建一个组件,这个组件看我说是用在views里哪个页面的,比如用在Profile.vue里的组件,组件名字最前面要加Profile,而且整个组件的命名不能过度简化,要容易看懂组件的用途;如果检测到某种名字开头的组件数量比如Profile开头的超过15个,就在components里新建个类似profile这样的页面名字的文件夹,把这类命名的组件都移到文件夹里并查找更新组件所有被引用地方的文件地址
-2
View File
@@ -1,13 +1,11 @@
<template> <template>
<el-config-provider :locale="zhCn"> <el-config-provider :locale="zhCn">
<RouterView /> <RouterView />
<LoginDialog />
</el-config-provider> </el-config-provider>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import LoginDialog from '@/components/LoginDialog.vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn' import zhCn from 'element-plus/es/locale/lang/zh-cn'
</script> </script>
+396 -62
View File
@@ -1,87 +1,421 @@
.login-dialog { /* 登录页面 — 左右分栏全屏布局 */
width: 6rem; .login-view {
.el-dialog__header { font-size: 0.14rem;
padding: 0.16rem 0.16rem 0; display: flex;
margin-right: 0; align-items: flex-start;
} width: 100%;
height: var(--app-height, 100vh);
.el-dialog__body { background: #F7FEFC;
padding: 0 0.4rem 0.4rem;
}
} }
.login-page { /* ==================== 左侧品牌面板 ==================== */
text-align: center; .login-view__left {
.login-title {
font-size: 0.28rem;
font-weight: 700;
color: #1a1a2e;
margin-bottom: 0.4rem;
}
.login-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.16rem; justify-content: center;
} align-items: center;
width: 57%;
height: 100%;
background: linear-gradient(180deg, #0A9E97 0%, #12C7BE 50%, #06B6D4 100%);
position: relative;
overflow: hidden;
}
.login-input { /* 装饰圆圈 */
height: 0.4rem; .login-view__deco-circle {
.el-input__wrapper { border-radius: 50%;
background-color: #f5f5f7; position: absolute;
border-radius: 0.08rem; }
box-shadow: none;
padding: 0.00rem 0.16rem;
}
}
.code-row { .login-view__deco-circle--lg {
width: 4.8rem;
height: 4.8rem;
background: rgba(255, 255, 255, 0.06);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.login-view__deco-circle--sm {
width: 3.2rem;
height: 3.2rem;
background: rgba(255, 255, 255, 0.08);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
/* 左侧中心内容 */
.login-view__left-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.32rem;
position: relative;
z-index: 1;
}
/* Logo 行 */
.login-view__logo-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.12rem; gap: 0.12rem;
}
.login-input { .login-view__logo-box {
flex: 1; display: flex;
} justify-content: center;
align-items: center;
width: 0.56rem;
height: 0.56rem;
border-radius: 0.16rem;
font-weight: 700;
font-size: 0.22rem;
line-height: 1;
}
.send-code-btn { .login-view__logo-box--light {
white-space: nowrap; background: rgba(255, 255, 255, 0.2);
border-radius: 0.2rem; color: #FFFFFF;
padding: 0.08rem 0.2rem; }
.login-view__logo-box--green {
background: rgba(9, 170, 119, 0.2);
color: #351616;
}
.login-view__logo-text {
font-weight: 700;
font-size: 0.28rem;
line-height: 0.34rem;
}
.login-view__logo-text--white {
color: #FFFFFF;
}
.login-view__logo-text--dark {
color: #351616;
}
/* 标语 */
.login-view__slogan {
font-weight: 700;
font-size: 0.4rem;
line-height: 0.56rem;
text-align: center;
color: #FFFFFF;
}
.login-view__sub-slogan {
font-weight: 400;
font-size: 0.16rem;
line-height: 0.19rem;
text-align: center;
color: rgba(255, 255, 255, 0.85);
}
/* 特性药丸标签 */
.login-view__pills {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 0.24rem;
gap: 0.12rem;
}
.login-view__pill {
display: flex;
align-items: center;
padding: 0.12rem 0.2rem;
gap: 0.08rem;
background: rgba(255, 255, 255, 0.15);
border-radius: 0.24rem;
font-weight: 500;
font-size: 0.14rem; font-size: 0.14rem;
} line-height: 0.17rem;
} color: #FFFFFF;
}
.login-btn { /* ==================== 右侧表单面板 ==================== */
margin-top: 0.16rem; .login-view__right {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 43%;
height: 100%;
background: #F6FDFB;
}
.login-view__logo-row--right {
margin-bottom: 0.4rem;
}
/* 表单包裹 */
.login-view__form-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.28rem;
width: 4rem;
}
/* 标题 */
.login-view__heading {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.login-view__title {
font-weight: 700;
font-size: 0.32rem;
line-height: 0.39rem;
color: #132034;
}
.login-view__subtitle {
font-weight: 400;
font-size: 0.15rem;
line-height: 0.18rem;
color: #64748B;
}
/* ==================== 手机号输入行 ==================== */
.login-view__phone-row {
display: flex;
align-items: center;
width: 100%; width: 100%;
height: 0.52rem; height: 0.52rem;
border-radius: 0.26rem; border-radius: 0.12rem;
font-size: 0.18rem; overflow: hidden;
}
.login-view__country-code {
display: flex;
align-items: center;
padding: 0 0.16rem;
gap: 0.06rem;
width: 0.96rem;
height: 100%;
background: #F3FFFD;
border: 1px solid #BFE8E2;
border-radius: 0.12rem 0 0 0.12rem;
cursor: pointer;
}
.login-view__flag {
font-size: 0.16rem;
line-height: 1;
}
.login-view__code-text {
font-weight: 500;
font-size: 0.15rem;
line-height: 0.18rem;
color: #132034;
}
.login-view__code-arrow {
font-size: 0.11rem;
color: #94A3B8;
}
.login-view__separator {
width: 1px;
height: 100%;
background: #BFE8E2;
}
.login-view__phone-input {
flex: 1;
height: 100%;
padding: 0 0.16rem;
background: #FAFBFC;
border: 1px solid #E2E8F0;
border-left: none;
border-radius: 0 0.12rem 0.12rem 0;
font-size: 0.15rem;
color: #132034;
outline: none;
&::placeholder {
color: #94A3B8;
}
}
/* ==================== 发送验证码按钮(步骤一) ==================== */
.login-view__send-btn {
width: 100%;
height: 0.52rem;
background: linear-gradient(90deg, #12C7BE 0%, #06B6D4 100%);
border-radius: 0.12rem;
border: none;
font-weight: 600; font-weight: 600;
background-color: #1a1a2e; font-size: 0.16rem;
border-color: #1a1a2e; color: #FFFFFF;
cursor: pointer;
transition: opacity 0.2s;
&:hover, &:disabled {
&:focus { opacity: 0.5;
background-color: #2d2d44; cursor: not-allowed;
border-color: #2d2d44;
}
} }
.register-link { &:not(:disabled):hover {
margin-top: 0.08rem; opacity: 0.9;
font-size: 0.14rem; }
color: #666; }
a { /* ==================== OTP 验证码输入(步骤二) ==================== */
color: #409eff; .login-view__otp-wrap {
text-decoration: none; position: relative;
display: flex;
align-items: flex-start;
gap: 0.1rem;
width: 100%;
cursor: text;
}
.login-view__otp-hidden-input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
z-index: 2;
font-size: 0.16rem;
caret-color: transparent;
}
.login-view__otp-box {
display: flex;
justify-content: center;
align-items: center;
width: 0.56rem;
height: 0.64rem;
background: #FAFBFC;
border: 1px solid #E2E8F0;
border-radius: 0.12rem;
transition: border-color 0.2s, background 0.2s;
}
.login-view__otp-box--active {
background: #F3FFFD;
border: 2px solid #12C7BE;
}
.login-view__otp-box--filled {
background: #F3FFFD;
border: 2px solid #12C7BE;
}
.login-view__otp-digit {
font-weight: 700;
font-size: 0.24rem;
line-height: 0.29rem;
color: #132034;
}
/* 闪烁光标 */
.login-view__otp-cursor {
display: inline-block;
width: 2px;
height: 0.28rem;
background: #12C7BE;
border-radius: 1px;
animation: login-otp-blink 1s ease-in-out infinite;
}
@keyframes login-otp-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* ==================== 状态按钮(步骤二) ==================== */
.login-view__status-btn {
width: 100%;
height: 0.52rem;
background: linear-gradient(90deg, #12C7BE 0%, #06B6D4 100%);
border-radius: 0.12rem;
border: none;
font-weight: 600;
font-size: 0.16rem;
color: #FFFFFF;
cursor: default;
display: flex;
align-items: center;
justify-content: center;
gap: 0.08rem;
}
/* 登录中转圈动画 */
.login-view__spinner {
display: inline-block;
width: 0.18rem;
height: 0.18rem;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #FFFFFF;
border-radius: 50%;
animation: login-spin 0.7s linear infinite;
}
@keyframes login-spin {
to { transform: rotate(360deg); }
}
/* ==================== 协议勾选 ==================== */
.login-view__agreement {
display: flex;
align-items: center;
gap: 0.06rem;
}
.login-view__checkbox {
box-sizing: border-box;
width: 0.18rem;
height: 0.18rem;
background: #FFFFFF;
border: 1.5px solid #CBD5E1;
border-radius: 0.05rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.login-view__checkbox--checked {
background: #12C7BE;
border-color: #12C7BE;
}
.login-view__check-icon {
font-size: 0.11rem;
color: #FFFFFF;
font-weight: 700;
line-height: 1;
}
.login-view__agreement-text {
font-weight: 400;
font-size: 0.12rem;
line-height: 0.15rem;
color: #94A3B8;
}
.login-view__agreement-link {
font-weight: 500;
font-size: 0.12rem;
line-height: 0.15rem;
color: #12C7BE;
cursor: pointer;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
}
}
} }
+4
View File
@@ -1,4 +1,8 @@
<template> <template>
<!--
该弹窗登录组件已搁置登录功能已改为独立页面 /login (src/views/Login.vue)
如需恢复弹窗登录重新在 App.vue 中引用此组件即可
-->
<el-dialog <el-dialog
v-model="visible" v-model="visible"
width="480px" width="480px"
+16 -6
View File
@@ -8,7 +8,13 @@ import { checkLogin } from '@/api/auth'
*/ */
const staticRoutes: RouteRecordRaw[] = [ const staticRoutes: RouteRecordRaw[] = [
{ path: '/', name: 'Home', component: () => import('@/views/Home.vue') }, { path: '/', name: 'Home', component: () => import('@/views/Home.vue') },
{ path: '/jobs', name: 'Jobs', component: () => import('@/views/Jobs.vue') }, { path: '/login', name: 'Login', component: () => import('@/views/Login.vue') },
{
path: '/jobs',
name: 'Jobs',
component: () => import('@/views/Jobs.vue'),
meta: { requiresAuth: true },
},
{ {
path: '/resume/:id', path: '/resume/:id',
name: 'ResumeDetail', name: 'ResumeDetail',
@@ -59,6 +65,12 @@ router.beforeEach(async (to, _from, next) => {
// 每次页面切换时静默刷新路由菜单数据(更新权限状态),不阻塞导航 // 每次页面切换时静默刷新路由菜单数据(更新权限状态),不阻塞导航
store.dispatch('refreshDynamicMenus') store.dispatch('refreshDynamicMenus')
// 已登录用户访问登录页,直接跳转首页
if (to.name === 'Login' && store.state.isAuthenticated) {
next({ name: 'Home' })
return
}
// 需要鉴权的路由,每次都通过接口校验登录状态 // 需要鉴权的路由,每次都通过接口校验登录状态
if (to.meta?.requiresAuth) { if (to.meta?.requiresAuth) {
try { try {
@@ -69,16 +81,14 @@ router.beforeEach(async (to, _from, next) => {
store.commit('SET_AUTHENTICATED', true) store.commit('SET_AUTHENTICATED', true)
next() next()
} else { } else {
// 未登录或 Cookie 失效 // 未登录或 Cookie 失效 — 跳转登录页
store.commit('SET_AUTHENTICATED', false) store.commit('SET_AUTHENTICATED', false)
store.dispatch('openLogin', to.fullPath) next({ name: 'Login', query: { redirect: to.fullPath } })
next(false)
} }
} catch { } catch {
// 请求异常(网络错误等),也视为未登录 // 请求异常(网络错误等),也视为未登录
store.commit('SET_AUTHENTICATED', false) store.commit('SET_AUTHENTICATED', false)
store.dispatch('openLogin', to.fullPath) next({ name: 'Login', query: { redirect: to.fullPath } })
next(false)
} }
return return
} }
+2 -2
View File
@@ -661,9 +661,9 @@ onMounted(() => {
store.dispatch('loadCommonData') store.dispatch('loadCommonData')
}) })
/** 点击登陆按钮 — 打开登录弹窗,登录成功后跳转到 jobs 页面 */ /** 点击登陆按钮 — 跳转到登录页面,登录成功后跳转到 jobs 页面 */
function handleLoginClick() { function handleLoginClick() {
store.dispatch('openLogin', '/jobs') router.push({ name: 'Login', query: { redirect: '/jobs' } })
} }
/** 隐私协议弹窗显示状态 */ /** 隐私协议弹窗显示状态 */
+319
View File
@@ -0,0 +1,319 @@
<template>
<!-- 登录页面 左右分栏布局 -->
<div class="login-view">
<!-- 左侧品牌面板 -->
<div class="login-view__left">
<!-- 装饰圆圈 1 -->
<div class="login-view__deco-circle login-view__deco-circle--lg"></div>
<!-- 中心内容 -->
<div class="login-view__left-content">
<!-- Logo -->
<div class="login-view__logo-row">
<div class="login-view__logo-box login-view__logo-box--light">O</div>
<span class="login-view__logo-text login-view__logo-text--white">Offer派</span>
</div>
<!-- 标语 -->
<h1 class="login-view__slogan">在Offer派<br/>找到你的理想工作</h1>
<!-- 副标语 -->
<p class="login-view__sub-slogan">AI 驱动的求职平台为你匹配最合适的岗位</p>
<!-- 特性标签 -->
<div class="login-view__pills">
<div class="login-view__pill"> AI 智能岗位匹配</div>
<div class="login-view__pill"> 一键生成专属简历</div>
<div class="login-view__pill"> 模拟面试精准备考</div>
</div>
</div>
<!-- 装饰圆圈 2 -->
<div class="login-view__deco-circle login-view__deco-circle--sm"></div>
</div>
<!-- 右侧表单面板 -->
<div class="login-view__right">
<!-- 右侧 Logo -->
<div class="login-view__logo-row login-view__logo-row--right">
<div class="login-view__logo-box login-view__logo-box--green">O</div>
<span class="login-view__logo-text login-view__logo-text--dark">Offer派</span>
</div>
<!-- 表单区域 -->
<div class="login-view__form-wrap">
<!-- ========== 步骤一输入手机号发送验证码 ========== -->
<template v-if="step === 1">
<!-- 标题 -->
<div class="login-view__heading">
<h2 class="login-view__title">手机号登录/注册</h2>
<p class="login-view__subtitle">未注册用户验证后将自动注册并登录</p>
</div>
<!-- 手机号输入行 -->
<div class="login-view__phone-row">
<!-- 国家编码选择 -->
<div class="login-view__country-code">
<span class="login-view__flag">🇨🇳</span>
<span class="login-view__code-text">+86</span>
<span class="login-view__code-arrow"></span>
</div>
<!-- 分隔线 -->
<div class="login-view__separator"></div>
<!-- 手机号输入框 -->
<input
v-model="phone"
type="tel"
class="login-view__phone-input"
placeholder="请输入手机号码"
maxlength="11"
/>
</div>
<!-- 发送验证码按钮 -->
<button
class="login-view__send-btn"
:disabled="!canSend"
@click="handleSendCode"
>
发送验证码
</button>
</template>
<!-- ========== 步骤二输入验证码登录 ========== -->
<template v-if="step === 2">
<!-- 标题 -->
<div class="login-view__heading">
<h2 class="login-view__title">请输入验证码</h2>
<p class="login-view__subtitle">短信验证码已发送至 +86{{ phone }}</p>
</div>
<!-- 6位验证码输入框 -->
<div class="login-view__otp-wrap" @click="focusOtpInput">
<!-- 隐藏的真实输入框 -->
<input
ref="otpInputRef"
v-model="otpValue"
type="text"
inputmode="numeric"
class="login-view__otp-hidden-input"
maxlength="6"
:disabled="isLoggingIn"
@input="handleOtpInput"
/>
<!-- 6个显示方块 -->
<div
v-for="(_, idx) in 6"
:key="idx"
class="login-view__otp-box"
:class="{
'login-view__otp-box--active': idx === otpValue.length && !isLoggingIn,
'login-view__otp-box--filled': idx < otpValue.length
}"
>
<!-- 已输入的数字 -->
<span v-if="idx < otpValue.length" class="login-view__otp-digit">{{ otpValue[idx] }}</span>
<!-- 闪烁光标 当前待输入位 -->
<span v-else-if="idx === otpValue.length && !isLoggingIn" class="login-view__otp-cursor"></span>
</div>
</div>
<!-- 状态按钮非触发登录 -->
<button class="login-view__status-btn" disabled>
<!-- 登录中状态 -->
<template v-if="isLoggingIn">
<span class="login-view__spinner"></span>
<span>登录中</span>
</template>
<!-- 即将返回提示 -->
<template v-else-if="isReturning">
即将返回发送验证码
</template>
<!-- 倒计时状态 -->
<template v-else>
{{ countdown }} 秒后可继续发送
</template>
</button>
</template>
<!-- 协议勾选两个步骤通用 -->
<div class="login-view__agreement">
<div
class="login-view__checkbox"
:class="{ 'login-view__checkbox--checked': agreedTerms }"
@click="agreedTerms = !agreedTerms"
>
<span v-if="agreedTerms" class="login-view__check-icon"></span>
</div>
<span class="login-view__agreement-text">登录即表示同意</span>
<span class="login-view__agreement-link" @click="openAgreement('ae8065i3')">用户协议</span>
<span class="login-view__agreement-text"></span>
<span class="login-view__agreement-link" @click="openAgreement('hf8375i8')">隐私政策</span>
</div>
</div>
</div>
<!-- 协议预览弹窗 -->
<AgreementPreviewDialog v-model="showAgreementDialog" :code="agreementCode" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { useStore } from 'vuex'
import { useRouter, useRoute } from 'vue-router'
import { sendSmsCode, smsLogin } from '@/api/auth'
import AgreementPreviewDialog from '@/components/tools/AgreementPreviewDialog.vue'
const store = useStore()
const router = useRouter()
const route = useRoute()
/** 当前步骤:1=输入手机号 2=输入验证码 */
const step = ref(1)
/** 手机号 */
const phone = ref('')
/** 验证码6位值 */
const otpValue = ref('')
/** 是否已勾选协议 */
const agreedTerms = ref(false)
/** 倒计时秒数 */
const countdown = ref(0)
let countdownTimer: ReturnType<typeof setInterval> | null = null
/** 是否正在调用登录接口 */
const isLoggingIn = ref(false)
/** 是否处于"即将返回"提示阶段 */
const isReturning = ref(false)
/** 用户最后一次输入验证码的时间戳 */
let lastOtpInputTime = 0
/** OTP 输入框引用 */
const otpInputRef = ref<HTMLInputElement | null>(null)
/** 协议弹窗状态 */
const showAgreementDialog = ref(false)
const agreementCode = ref('')
/** 发送按钮是否可用 — 手机号有值且已勾选协议 */
const canSend = computed(() => phone.value.length === 11 && agreedTerms.value)
/** 打开协议预览 */
function openAgreement(code: string) {
agreementCode.value = code
showAgreementDialog.value = true
}
/** 聚焦OTP隐藏输入框 */
function focusOtpInput() {
otpInputRef.value?.focus()
}
/** 开始60秒倒计时 */
function startCountdown() {
countdown.value = 60
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0 && countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
// 倒计时结束,进入"即将返回"提示阶段
triggerReturn()
}
}, 1000)
}
/** 倒计时结束后的返回逻辑 — 考虑用户最近操作 */
function triggerReturn() {
// 计算距离用户最后一次输入过了多久
const elapsed = Date.now() - lastOtpInputTime
// 如果用户5秒内还在输入,则等待剩余时间后再提示
const waitTime = Math.max(0, 5000 - elapsed)
// 先等用户操作冷却
setTimeout(() => {
// 显示"即将返回发送验证码"提示5秒
isReturning.value = true
setTimeout(() => {
isReturning.value = false
step.value = 1
otpValue.value = ''
}, 5000)
}, waitTime)
}
/** 步骤一:发送验证码 */
async function handleSendCode() {
if (!phone.value) {
ElMessage.warning('请输入手机号')
return
}
if (!agreedTerms.value) {
ElMessage.warning('请先同意用户协议与隐私政策')
return
}
try {
const res = await sendSmsCode(phone.value)
if (res.code === '0') {
ElMessage.success('验证码已发送')
// 进入步骤二
step.value = 2
// 开始倒计时
startCountdown()
// 自动聚焦验证码输入框
nextTick(() => focusOtpInput())
} else if (res.msg && res.msg.includes('请勿重复发送')) {
// 之前发送过的验证码仍有效,直接进入步骤二让用户输入
ElMessage.warning(res.msg)
step.value = 2
startCountdown()
nextTick(() => focusOtpInput())
} else {
ElMessage.error(res.msg || '验证码发送失败')
}
} catch {
// 错误已在 request 拦截器中统一处理
}
}
/** OTP输入处理 — 限制只能数字,满6位自动登录 */
function handleOtpInput() {
// 过滤非数字字符
otpValue.value = otpValue.value.replace(/\D/g, '').slice(0, 6)
// 记录用户最后输入时间
lastOtpInputTime = Date.now()
// 满6位自动触发登录
if (otpValue.value.length === 6) {
handleLogin()
}
}
/** 调用登录接口 */
async function handleLogin() {
isLoggingIn.value = true
try {
const res = await smsLogin(phone.value, otpValue.value)
if (res.code !== '0') {
ElMessage.error('登录失败请核对验证码')
// 清空已输入的验证码
otpValue.value = ''
isLoggingIn.value = false
// 重新聚焦输入框
nextTick(() => focusOtpInput())
return
}
// 登录成功
ElMessage.success(`欢迎回来,${res.data?.nick || ''}`)
// 清除倒计时
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
countdown.value = 0
// 同步登录状态
store.commit('SET_AUTHENTICATED', true)
// 跳转到登录前目标页面
const redirect = (route.query.redirect as string) || '/'
router.replace(redirect)
} catch {
ElMessage.error('登录失败请核对验证码')
otpValue.value = ''
isLoggingIn.value = false
nextTick(() => focusOtpInput())
}
}
</script>