@@ -1,145 +1,355 @@
< script setup lang = "ts" >
import { onMounted , onUnmounted , ref } from "vue" ;
import { api , saveToken } from "../services/api" ;
import {
CircleHelp ,
GraduationCap ,
RefreshCw ,
ShieldCheck ,
Smartphone ,
X ,
} from "@lucide/vue" ;
import { computed , nextTick , onMounted , onUnmounted , ref , watch } from "vue" ;
import { api , ApiError , saveToken } from "../services/api" ;
import type { UserProfile } from "../types/api" ;
import AppDialog from "./AppDialog.vue" ;
import HelpDialog from "./HelpDialog.vue" ;
import PolicyDialog from "./PolicyDialog.vue" ;
const emit = defineEmits < {
loggedIn : [ user : UserProfile ] ;
} > ( ) ;
const COUNTDOWN _SECONDS = 60 ;
const COUNTDOWN _PREFIX = "ai-kb-sms-countdown:" ;
const PHONE _PATTERN = /^1[3-9]\d{9}$/ ;
const phone = ref ( "" ) ;
const code = ref ( "" ) ;
const phoneTouched = ref ( false ) ;
const sending = ref ( false ) ;
const loggingIn = ref ( false ) ;
const countdown = ref ( 0 ) ;
const agreed = ref ( false ) ;
const agreementAttention = ref ( false ) ;
const activePolicy = ref < "agreement" | "privacy" | null > ( null ) ;
const helpMode = ref < "sms" | "login" | null > ( null ) ;
const toast = ref ( "" ) ;
const captchaOpen = ref ( false ) ;
const captchaId = ref ( "" ) ;
const captchaCode = ref ( "" ) ;
const captchaImage = ref ( "" ) ;
const loading = ref ( false ) ;
const captchaLoading = ref ( false ) ;
const message = ref ( "请先完成图形验证码,再获取短信验证码。" ) ;
const errorDialog = ref ( "" ) ;
const countdown = ref ( 0 ) ;
let countdownTimer : ReturnType < typeof setInterval > | null = null ;
const captchaSubmitting = ref ( false ) ;
const captchaError = ref ( "" ) ;
function sta rtCou ntdown ( seconds = 60 ) {
countdown . value = seconds ;
if ( countdownTimer ) clearInter val ( countdownTimer ) ;
countdownTimer = setInterval ( ( ) => {
countdown . value -- ;
if ( countdown . value <= 0 && countdownTimer ) {
clearInterval ( countdownTimer ) ;
countdownTimer = null ;
}
} , 1000 ) ;
const suppo rtContact = import . meta . env . VITE _SUPPORT _CONTACT ? . trim ( ) ? ? "" ;
const phoneValid = computed ( ( ) => PHONE _PATTERN . test ( phone . value ) ) ;
const phoneError = computed ( ( ) => phoneTouched . value && phone . value . length > 0 && ! phoneValid . value ) ;
const loginFieldsValid = computed ( ( ) => phoneValid . value && code . value . length >= 4 ) ;
const sendButtonText = computed ( ( ) => {
if ( sending . value ) return "发送中..." ;
if ( countdown . value > 0 ) return ` ${ countdown . value } s 后重发 ` ;
return "发送验证码" ;
}) ;
let countdownTimer : ReturnType < typeof setInterval > | null = null ;
let toastTimer : ReturnType < typeof setTimeout > | null = null ;
onMounted ( ( ) => restoreCountdown ( ) ) ;
onUnmounted ( ( ) => {
stopCountdownTimer ( ) ;
if ( toastTimer ) clearTimeout ( toastTimer ) ;
} ) ;
watch ( phone , ( ) => {
code . value = sanitizeDigits ( code . value , 6 ) ;
restoreCountdown ( ) ;
} ) ;
function onPhoneInput ( event : Event ) {
phone . value = sanitizeDigits ( ( event . target as HTMLInputElement ) . value , 11 ) ;
}
onUnmounted ( ( ) => {
if ( countdownTimer ) clearInterval ( countdownTimer ) ;
} ) ;
function onCodeInput ( event : Event ) {
code . value = sanitizeDigits ( ( event . target as HTMLInputElement ) . value , 6 ) ;
}
onMounted ( ( ) => {
void loadCaptcha ( ) ;
} ) ;
function sanitizeDigits ( value : string , maxLength : number ) {
return value . replace ( /\D/g , "" ) . slice ( 0 , maxLength ) ;
}
function clearPhone ( ) {
phone . value = "" ;
phoneTouched . value = false ;
}
async function sendCode ( ) {
if ( sending . value || countdown . value > 0 ) return ;
phoneTouched . value = true ;
if ( ! phoneValid . value ) {
showToast ( "请输入正确的手机号码" ) ;
return ;
}
sending . value = true ;
try {
await api . sendSms ( phone . value ) ;
completeSmsSend ( ) ;
} catch ( error ) {
if ( error instanceof ApiError && error . status === 428 ) {
await openCaptcha ( ) ;
} else {
showToast ( errorMessage ( error , "验证码发送失败,请稍后重试" ) ) ;
}
} finally {
sending . value = false ;
}
}
async function openCaptcha ( ) {
captchaOpen . value = true ;
captchaError . value = "" ;
await loadCaptcha ( ) ;
}
async function loadCaptcha ( ) {
if ( captchaLoading . value ) return ;
captchaLoading . value = true ;
captchaError . value = "" ;
try {
const result = await api . captcha ( ) ;
captchaId . value = result . captchaId ;
captchaImage . value = result . imageBase64 ;
captchaCode . value = "" ;
} catch ( error ) {
message . value = error instanceof Error ? error . message : "图形验证码加载失败" ;
captchaError . value = errorMessage ( error , "图形验证码加载失败,请重试" ) ;
} finally {
captchaLoading . value = false ;
}
}
async function sendCode ( ) {
if ( ! captchaId . value || captchaCode . value . length < 4 ) {
showError ( "请先输入图形验证码" ) ;
async function submitCaptcha ( ) {
if ( captchaSubmitting . value ) return ;
const normalizedCode = captchaCode . value . trim ( ) ;
if ( normalizedCode . length < 4 ) {
captchaError . value = "请输入图形验证码" ;
return ;
}
loading . value = true ;
message . value = "" ;
captchaSubmitting . value = true ;
captchaError . value = "" ;
try {
await api . sendSms ( phone . value , captchaId . value , captchaCode . valu e) ;
message . value = "短信验证码已发送,请查收。" ;
startCountdown ( 60 ) ;
await loadCaptcha ( ) ;
await api . sendSms ( phone . value , captchaId . value , normalizedCod e) ;
captchaOpen . value = false ;
completeSmsSend ( ) ;
} catch ( error ) {
show Error( error instanceof Error ? error. m essage : "发送失败 " ) ;
await loadCaptcha ( ) ;
captcha Error. value = errorM essage ( error , "图形验证失败,请重试 ") ;
if ( /图形验证码|过期/ . test ( captchaError . value ) ) await loadCaptcha ( ) ;
} finally {
load ing. value = false ;
captchaSubmitt ing. value = false ;
}
}
function completeSmsSend ( ) {
persistCountdown ( phone . value , Date . now ( ) + COUNTDOWN _SECONDS * 1000 ) ;
restoreCountdown ( ) ;
showToast ( "验证码已发送,请注意查收" ) ;
}
async function login ( ) {
load ing . value = true ;
message . value = "" ;
if ( logg ingIn . value || ! loginFieldsValid . value ) return ;
if ( ! agreed . value ) {
agreementAttention . value = true ;
showToast ( "请先阅读并同意用户协议和隐私政策" ) ;
window . setTimeout ( ( ) => ( agreementAttention . value = false ) , 1800 ) ;
return ;
}
loggingIn . value = true ;
try {
const result = await api . login ( phone . value , code . value ) ;
saveToken ( result . token ) ;
showToast ( "登录成功" ) ;
emit ( "loggedIn" , result . user ) ;
} catch ( error ) {
message . value = error instanceof Error ? error . message : "登录失败" ;
showToast ( errorMessage ( error , "登录失败,请稍后重试" ) ) ;
} finally {
load ing . value = false ;
logg ingIn . value = false ;
}
}
function showError ( text : string ) {
message . value = text ;
errorDialog . value = text ;
function countdownKey ( value = phone . value ) {
return ` ${ COUNTDOWN _PREFIX } ${ value } ` ;
}
function persistCountdown ( value : string , expiresAt : number ) {
window . localStorage . setItem ( countdownKey ( value ) , String ( expiresAt ) ) ;
}
function restoreCountdown ( ) {
stopCountdownTimer ( ) ;
if ( ! phoneValid . value ) {
countdown . value = 0 ;
return ;
}
updateCountdown ( ) ;
if ( countdown . value > 0 ) countdownTimer = setInterval ( updateCountdown , 1000 ) ;
}
function updateCountdown ( ) {
const stored = Number ( window . localStorage . getItem ( countdownKey ( ) ) ? ? 0 ) ;
const remaining = Math . max ( 0 , Math . ceil ( ( stored - Date . now ( ) ) / 1000 ) ) ;
countdown . value = remaining ;
if ( remaining <= 0 ) {
window . localStorage . removeItem ( countdownKey ( ) ) ;
stopCountdownTimer ( ) ;
}
}
function stopCountdownTimer ( ) {
if ( countdownTimer ) clearInterval ( countdownTimer ) ;
countdownTimer = null ;
}
function showToast ( text : string ) {
toast . value = text ;
if ( toastTimer ) clearTimeout ( toastTimer ) ;
toastTimer = setTimeout ( ( ) => ( toast . value = "" ) , 3000 ) ;
}
function errorMessage ( error : unknown , fallback : string ) {
return error instanceof Error ? error . message : fallback ;
}
async function keepFieldVisible ( event : FocusEvent ) {
await nextTick ( ) ;
( event . target as HTMLElement ) . scrollIntoView ( { block : "center" , behavior : "smooth" } ) ;
}
< / script >
< template >
< section class = "login-panel " >
< h1 > 大本营答疑助手 < / h1 >
< p > 有问题随时问 , 帮你快速找到答案 。 < / p >
< section class = "login-page " >
< div class = "login-content" >
< header class = "login-brand-area" >
< div class = "brand-heading" >
< span class = "brand-icon" aria -hidden = " true " > < GraduationCap :size = "30" stroke -width = " 2.2 " / > < / span >
< h1 > 大本营答疑助手 < / h1 >
< / div >
< p > 登录后即可开始提问 , 快速找到你需要的答案 < / p >
< ShieldCheck class = "brand-watermark" :size = "150" stroke -width = " 1.2 " aria -hidden = " true " / >
< / header >
< label >
手机号
< input v-model = "phone" inputmode="numeric" maxlength="11" placeholder="请输入 手机号" / >
< / label >
< form class = "login-card" novalidate @submit.prevent ="login" >
< div class = "form-field" >
< label for = "login-phone" > 手机号 < / label >
< div class = "input-shell" : class = "{ invalid: phoneError }" >
< Smartphone :size = "20" aria -hidden = " true " / >
< input
id = "login-phone"
:value = "phone"
type = "tel"
inputmode = "numeric"
autocomplete = "tel"
maxlength = "11"
placeholder = "请输入手机号"
@input ="onPhoneInput"
@blur ="phoneTouched = true"
@focus ="keepFieldVisible"
/ >
< button v-if = "phone" type="button" class="input-clear" aria-label="清空手机号" @click="clearPhone" >
< X :size = "17" aria -hidden = " true " / >
< / button >
< / div >
< p v-if = "phoneError" class="field-error" > 请输入正确的手机号码 < / p >
< / div >
< label >
图形验证码
< div class = "captcha-row " >
< input v-model = "captchaCode" inputmode="text" maxlength="8" placeholder="请输入图形验证码" / >
< button type = "button" class = "captcha-image" :disabled = "captchaLoading" @click ="loadCaptcha" >
< img v-if = "captchaImage" :src="captchaImage" alt="图形验证码" / >
< span v-else > {{ captchaLoading ? " 加载中 " : " 刷新 " }} < / span >
< div class = "form-field" >
< label for = "login-code" > 短信验证码 < / label >
< div class = "input-shell code-input-shell " >
< ShieldCheck :size = "20" aria -hidden = " true " / >
< input
id = "login-code"
:value = "code"
type = "text"
inputmode = "numeric"
autocomplete = "one-time-code"
maxlength = "6"
placeholder = "请输入短信验证码"
@input ="onCodeInput"
@focus ="keepFieldVisible"
/ >
< span class = "input-divider" aria -hidden = " true " > < / span >
< button
type = "button"
class = "send-code-button"
: disabled = "!phoneValid || sending || countdown > 0"
@click ="sendCode"
>
{ { sendButtonText } }
< / button >
< / div >
< p class = "security-hint" > < ShieldCheck :size = "16" aria -hidden = " true " / > 为保障账号安全 , 必要时将进行图形验证 < / p >
< / div >
< div class = "agreement-row" : class = "{ attention: agreementAttention }" >
< label class = "agreement-toggle" >
< input v-model = "agreed" type="checkbox" / >
< span > 我已阅读并同意 < / span >
< / label >
< button type = "button" class = "policy-link" @click ="activePolicy = 'agreement'" > 《 用户协议 》 < / button >
< span aria -hidden = " true " > 和 < / span >
< button type = "button" class = "policy-link" @click ="activePolicy = 'privacy'" > 《 隐私政策 》 < / button >
< / div >
< button type = "submit" class = "login-submit" : disabled = "!loginFieldsValid || loggingIn" >
{ { loggingIn ? "正在进入..." : "立即进入" } }
< / button >
< / div >
< / label >
< label >
短信验证码
< div class = "code-row" >
< input v-model = "code" inputmode="numeric" maxlength="8" placeholder="请输入短信验证码" / >
< button
type = "button"
class = "secondary"
: disabled = "loading || countdown > 0 || phone.length < 11 || captchaCode.length < 4"
@click ="sendCode"
>
{ { countdown > 0 ? ` ${ countdown } 秒后重发 ` : "发送" } }
< / button >
< / div >
< / label >
< div class = "login-help-links" >
< button type = "button" @click ="helpMode = 'sms'" > 收不到验证码 ? < / button >
< span aria -hidden = " true " > < / span >
< button type = "button" @click ="helpMode = 'login'" > 登录遇到问题 < / button >
< / div >
< / form >
< button type = "button" class = "primary" : disabled = "loading || phone.length < 11 || code.length < 4" @click ="login" >
{{ loading ? " 处理中... " : " 立即进入 " }}
< / button >
< div class = "form-tip" > { { message } } < / div >
< div v-if = "errorDialog" class="dialog-mask" role="alertdialog" aria-modal="true" aria-labelledby="login-error-title" >
< div class = "dialog-box" >
< strong id = "login-error-title" > 无法发送验证码 < / strong >
< p > { { errorDialog } } < / p >
< button type = "button" class = "primary" @click ="errorDialog = ''" > 知道了 < / button >
< / div >
< footer class = "login-security-footer" >
< span > < / span > < ShieldCheck :size = "20" aria -hidden = " true " / > < span > < / span >
< p > 仅用于身份验证与账号登录 , 不会泄露你的信息 < / p >
< / footer >
< / div >
< div v-if = "toast" class="login-toast" role="status" aria-live="polite" > {{ toast }} < / div >
< AppDialog v-if = "captchaOpen" title="安全验证" labelled-by="captcha-dialog-title" @close="captchaOpen = false" >
< div class = "captcha-dialog-copy" >
< p > 请完成图形验证后继续发送短信验证码 。 < / p >
< div class = "captcha-entry" >
< input
v -model .trim = " captchaCode "
type = "text"
inputmode = "text"
autocomplete = "off"
maxlength = "8"
placeholder = "请输入图形验证码"
@keyup.enter ="submitCaptcha"
/ >
< button type = "button" :disabled = "captchaLoading" aria -label = " 刷新图形验证码 " @click ="loadCaptcha" >
< img v-if = "captchaImage" :src="captchaImage" alt="图形验证码,点击刷新" / >
< RefreshCw v-else :size = "20" : class = "{ spinning: captchaLoading }" aria -hidden = " true " / >
< / button >
< / div >
< p class = "captcha-refresh-tip" > < CircleHelp :size = "15" aria -hidden = " true " / > 看不清 ? 点击图片刷新 < / p >
< p v-if = "captchaError" class="field-error" > {{ captchaError }} < / p >
< / div >
< template # footer >
< button type = "button" class = "dialog-confirm" :disabled = "captchaSubmitting" @click ="submitCaptcha" >
{{ captchaSubmitting ? " 验证中... " : " 确认并发送 " }}
< / button >
< / template >
< / AppDialog >
< PolicyDialog v-if = "activePolicy" :policy="activePolicy" @close="activePolicy = null" / >
< HelpDialog v-if = "helpMode" :mode="helpMode" :contact="supportContact" @close="helpMode = null" / >
< / section >
< / template >