feat: 重构用户端手机号验证码登录页

This commit is contained in:
2026-07-10 17:08:31 +08:00
parent 808ba74fc5
commit 2e274dea51
9 changed files with 991 additions and 209 deletions

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { X } from "@lucide/vue";
defineProps<{
title: string;
labelledBy: string;
}>();
defineEmits<{ close: [] }>();
</script>
<template>
<div class="modal-backdrop" role="presentation" @click.self="$emit('close')">
<section class="app-dialog" role="dialog" aria-modal="true" :aria-labelledby="labelledBy">
<header class="dialog-header">
<h2 :id="labelledBy">{{ title }}</h2>
<button type="button" class="dialog-close" aria-label="关闭" @click="$emit('close')">
<X :size="20" aria-hidden="true" />
</button>
</header>
<div class="dialog-content"><slot /></div>
<footer v-if="$slots.footer" class="dialog-footer"><slot name="footer" /></footer>
</section>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { CircleCheck } from "@lucide/vue";
import AppDialog from "./AppDialog.vue";
defineProps<{
mode: "sms" | "login";
contact?: string;
}>();
defineEmits<{ close: [] }>();
</script>
<template>
<AppDialog
:title="mode === 'sms' ? '收不到验证码?' : '登录遇到问题'"
:labelled-by="mode === 'sms' ? 'sms-help-title' : 'login-help-title'"
@close="$emit('close')"
>
<ul class="help-list">
<li><CircleCheck :size="18" aria-hidden="true" /><span>确认输入的是后台登记的本人手机号</span></li>
<li v-if="mode === 'sms'"><CircleCheck :size="18" aria-hidden="true" /><span>检查短信拦截骚扰信息和手机信号</span></li>
<li><CircleCheck :size="18" aria-hidden="true" /><span>等待倒计时结束后再重新发送避免频繁操作</span></li>
<li><CircleCheck :size="18" aria-hidden="true" /><span>网络异常时切换稳定网络后重试</span></li>
</ul>
<p v-if="contact" class="support-contact">仍未解决请联系工作人员{{ contact }}</p>
<p v-else class="support-contact">仍未解决请联系大本营工作人员核对学员信息</p>
<template #footer>
<button type="button" class="dialog-confirm" @click="$emit('close')">知道了</button>
</template>
</AppDialog>
</template>

View File

@@ -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 startCountdown(seconds = 60) {
countdown.value = seconds;
if (countdownTimer) clearInterval(countdownTimer);
countdownTimer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0 && countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
}, 1000);
const supportContact = 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.value);
message.value = "短信验证码已发送,请查收。";
startCountdown(60);
await loadCaptcha();
await api.sendSms(phone.value, captchaId.value, normalizedCode);
captchaOpen.value = false;
completeSmsSend();
} catch (error) {
showError(error instanceof Error ? error.message : "发送失败");
await loadCaptcha();
captchaError.value = errorMessage(error, "图形验证失败,请重试");
if (/图形验证码|过期/.test(captchaError.value)) await loadCaptcha();
} finally {
loading.value = false;
captchaSubmitting.value = false;
}
}
function completeSmsSend() {
persistCountdown(phone.value, Date.now() + COUNTDOWN_SECONDS * 1000);
restoreCountdown();
showToast("验证码已发送,请注意查收");
}
async function login() {
loading.value = true;
message.value = "";
if (loggingIn.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 {
loading.value = false;
loggingIn.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>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import AppDialog from "./AppDialog.vue";
defineProps<{ policy: "agreement" | "privacy" }>();
defineEmits<{ close: [] }>();
</script>
<template>
<AppDialog
:title="policy === 'agreement' ? '用户协议' : '隐私政策'"
:labelled-by="policy === 'agreement' ? 'user-agreement-title' : 'privacy-policy-title'"
@close="$emit('close')"
>
<div v-if="policy === 'agreement'" class="policy-copy">
<p>欢迎使用大本营答疑助手登录并使用本服务前请阅读并理解本协议</p>
<h3>服务说明</h3>
<p>本服务面向已登记学员提供知识问答回答内容用于学习和信息参考不替代专业意见</p>
<h3>账号使用</h3>
<p>请使用本人登记手机号登录不得转让账号冒用他人身份或利用本服务进行违法违规活动</p>
<h3>使用规范</h3>
<p>请勿提交违法有害或侵犯他人权益的内容为保障服务安全系统可能记录必要的登录和问答日志</p>
<h3>服务变更</h3>
<p>因维护升级或不可抗力可能暂时中断服务我们会在合理范围内尽快恢复</p>
</div>
<div v-else class="policy-copy">
<p>我们重视你的个人信息安全并按照最小必要原则处理登录和服务数据</p>
<h3>收集的信息</h3>
<p>登录时处理手机号和验证码使用过程中记录必要的会话问答和安全审计信息</p>
<h3>使用目的</h3>
<p>上述信息仅用于身份验证账号登录提供答疑服务保障系统安全和排查服务问题</p>
<h3>信息保护</h3>
<p>我们采取访问控制传输保护日志审计等措施保护信息不会将验证码或登录凭证用于无关用途</p>
<h3>你的权利</h3>
<p>如需查询更正或处理账号信息请通过工作人员提供的正式渠道联系我们</p>
</div>
<template #footer>
<button type="button" class="dialog-confirm" @click="$emit('close')">我知道了</button>
</template>
</AppDialog>
</template>