Files
competition-website/src/views/Activate.vue
qubiaobiao d16c261273 fix(auth): 口令输入框提示与后端策略对齐(6-20 位,勿用弱口令)
配套 R63 后端口令强度策略:两处 placeholder 由「至少6位」改为「6-20 位,勿用 123456 等弱口令」,让考生在前端即知道限制,避免填完才被后端拒绝。

刻意不在前端实现黑名单校验:那会与后端 lib/password_blacklist.json 形成两份会漂移的清单(后端才是权威闸门)。
2026-09-30 11:56:45 +08:00

691 lines
18 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.
<template>
<div class="activate-page">
<div class="activate-container">
<!-- 头部 -->
<div class="activate-header">
<div class="header-icon">&#9788;</div>
<h1 class="header-title">账号激活</h1>
<p class="header-subtitle">激活您的参赛账号,开始比赛之旅</p>
</div>
<!-- 步骤条 -->
<el-steps :active="currentStep" align-center finish-status="success" class="activate-steps">
<el-step title="身份验证" description="输入身份证号" />
<el-step title="短信验证" description="输入短信验证码" />
<el-step title="设置密码" description="设置登录密码" />
</el-steps>
<!-- ========== 步骤1:获取图形验证码 ========== -->
<el-card v-if="currentStep === 0" class="step-card" shadow="never">
<template #header>
<span class="step-card-title">第一步:身份验证</span>
</template>
<el-form
ref="step1FormRef"
:model="step1Form"
:rules="step1Rules"
label-position="top"
@keyup.enter="handleStep1"
>
<el-form-item label="身份证号" prop="idNumber">
<el-input
v-model="step1Form.idNumber"
placeholder="请输入您的身份证号"
size="large"
clearable
maxlength="18"
/>
</el-form-item>
<el-form-item label="点击验证码" prop="captchaCode">
<div class="click-captcha-area">
<div class="captcha-hint" v-if="captchaHint">{{ captchaHint }}</div>
<div class="captcha-chars">
<span
v-for="(char, idx) in captchaChars"
:key="idx"
class="captcha-char"
:class="{ clicked: captchaClicked.has(idx) }"
@click="handleCaptchaClick(idx, char)"
>{{ char }}</span>
</div>
<div class="captcha-sequence" v-if="captchaSequence">
已点击:<span class="seq-chars">{{ captchaSequence }}</span>
</div>
<el-link type="primary" v-guard="() => refreshCaptcha()" :underline="false" class="captcha-refresh">
<el-icon><Refresh /></el-icon><span>换一组</span>
</el-link>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="step-btn"
:loading="step1Loading"
v-guard="() => handleStep1()"
>
下一步
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- ========== 步骤2:短信验证码 ========== -->
<el-card v-if="currentStep === 1" class="step-card" shadow="never">
<template #header>
<span class="step-card-title">第二步:短信验证</span>
</template>
<el-form
ref="step2FormRef"
:model="step2Form"
:rules="step2Rules"
label-position="top"
>
<div class="phone-display">
<span class="phone-label">短信已发送至:</span>
<span class="phone-value">{{ userInfo.phone || step1Form.phone || '--' }}</span>
</div>
<el-form-item label="短信验证码" prop="smsCode">
<div class="sms-row">
<el-input
v-model="step2Form.smsCode"
placeholder="请输入 6 位短信验证码"
size="large"
maxlength="6"
class="sms-input"
/>
<el-button
size="large"
class="sms-btn"
:disabled="smsCountdown > 0"
v-guard="() => resendSms()"
>
{{ smsCountdown > 0 ? `${smsCountdown}s 后重新发送` : '重新发送' }}
</el-button>
</div>
</el-form-item>
<el-form-item>
<div class="step-btn-group">
<el-button size="large" @click="currentStep = 0">上一步</el-button>
<el-button
type="primary"
size="large"
class="step-btn"
:loading="step2Loading"
@click="handleStep2"
>
下一步
</el-button>
</div>
</el-form-item>
</el-form>
</el-card>
<!-- ========== 步骤3:设置密码 ========== -->
<el-card v-if="currentStep === 2" class="step-card" shadow="never">
<template #header>
<span class="step-card-title">第三步:设置密码</span>
</template>
<el-form
ref="step3FormRef"
:model="step3Form"
:rules="step3Rules"
label-position="top"
>
<!-- 基本信息确认 -->
<div class="info-confirm">
<el-descriptions :column="1" border size="small" title="基本信息确认">
<el-descriptions-item label="姓名">{{ userInfo.name || '--' }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ userInfo.phone || '--' }}</el-descriptions-item>
<el-descriptions-item label="参赛组别">{{ groupLabel }}</el-descriptions-item>
</el-descriptions>
</div>
<el-form-item label="设置密码" prop="password">
<el-input
v-model="step3Form.password"
type="password"
autocomplete="new-password"
placeholder="请设置登录密码(6-20 位,勿用 123456 等弱口令)"
size="large"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="step3Form.confirmPassword"
type="password"
autocomplete="new-password"
placeholder="请再次输入密码"
size="large"
show-password
/>
</el-form-item>
<el-form-item>
<div class="step-btn-group">
<el-button size="large" @click="currentStep = 1">上一步</el-button>
<el-button
type="primary"
size="large"
class="step-btn"
:loading="step3Loading"
v-guard="() => handleActivate()"
>
完成激活
</el-button>
</div>
</el-form-item>
</el-form>
</el-card>
<!-- 返回链接 -->
<div class="back-link">
<el-link type="info" @click="$router.push('/')">
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
</el-link>
<span class="link-sep">|</span>
<el-link type="info" @click="$router.push('/login')">
<el-icon><ArrowLeft /></el-icon><span>返回登录</span>
</el-link>
</div>
</div>
</div>
</template>
<script setup>
/**
* Activate.vue - 账号激活页面(公开访问)
*
* 流程:
* 步骤1:输入身份证号 -> 获取图形验证码 -> 验证
* 步骤2:发送短信验证码 -> 输入 6 位验证码 -> 验证
* 步骤3:确认基本信息 -> 设置密码 + 确认密码 -> 完成激活
*/
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, HomeFilled, Refresh } from '@element-plus/icons-vue'
import * as authApi from '@/api/auth'
import { showTestSmsCode } from '@/utils/testSms'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
// 激活页语义 = 未登录。必须主动清除浏览器中可能残留的登录态:
// 否则激活成功/点「返回登录」跳 /login 时,router guard(router/index.js:203-222)
// 见到 isLoggedIn && role==='admin' 会把用户重定向进 /admin —— 公用电脑上
// 前一位管理员未退出时,下一位激活的考生会被直接送进管理后台且接口权限可用(R61)。
const authStore = useAuthStore()
// ==================== 状态 ====================
const currentStep = ref(0)
// 点击式验证码状态
const captchaChars = ref([])
const captchaHint = ref('')
const captchaClicked = ref(new Set())
const captchaSequence = ref('')
const smsCountdown = ref(0)
let smsTimer = null
// 步骤1表单
const step1FormRef = ref(null)
const step1Form = reactive({
idNumber: '',
captchaCode: '',
phone: '',
})
const step1Loading = ref(false)
// 步骤2表单
const step2FormRef = ref(null)
const step2Form = reactive({
smsCode: '',
})
const step2Loading = ref(false)
// 步骤3表单
const step3FormRef = ref(null)
const step3Form = reactive({
password: '',
confirmPassword: '',
})
const step3Loading = ref(false)
// 用户基本信息(从服务端返回)
const userInfo = reactive({
name: '',
phone: '',
group: '',
})
// ==================== 表单验证规则 ====================
const step1Rules = {
idNumber: [
{ required: true, message: '请输入身份证号', trigger: 'blur' },
{
pattern: /^[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/,
message: '请输入正确的身份证号',
trigger: 'blur',
},
],
captchaCode: [
{ required: true, message: '请按顺序点击验证码', trigger: 'change' },
],
}
const step2Rules = {
smsCode: [
{ required: true, message: '请输入短信验证码', trigger: 'blur' },
{ len: 6, message: '请输入 6 位短信验证码', trigger: 'blur' },
],
}
const step3Rules = {
password: [
{ required: true, message: '请设置密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 个字符', trigger: 'blur' },
{ max: 20, message: '密码不超过 20 个字符', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value !== step3Form.password) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
},
trigger: 'blur',
},
],
}
// ==================== 计算属性 ====================
const groupLabel = computed(() => {
const map = {
zhongzhi: '中职组',
gaozhi: '高职组',
benke: '本科组',
jiaoshi: '教师组',
}
return map[userInfo.group] || userInfo.group || '--'
})
// ==================== 方法 ====================
/** 获取点击式验证码 */
async function refreshCaptcha() {
try {
captchaChars.value = []
captchaHint.value = ''
captchaClicked.value = new Set()
captchaSequence.value = ''
step1Form.captchaCode = ''
const data = await authApi.getCaptcha()
if (data && data.chars) {
captchaChars.value = data.chars
captchaHint.value = data.hint || ''
}
} catch (e) {
console.warn('获取验证码失败', e)
}
}
/** 点击验证码字符 */
function handleCaptchaClick(idx, char) {
if (captchaClicked.value.has(idx)) return // 已点击过
const newClicked = new Set(captchaClicked.value)
newClicked.add(idx)
captchaClicked.value = newClicked
captchaSequence.value += char
step1Form.captchaCode = captchaSequence.value
}
/** 步骤1:验证身份并发送短信 */
async function handleStep1() {
if (!step1FormRef.value) return
try {
await step1FormRef.value.validate()
step1Loading.value = true
// 发送短信验证码
const result = await authApi.sendSms(step1Form.idNumber, step1Form.captchaCode)
// 测试环境:后端随响应回传验证码,弹窗展示(生产环境不回传、不弹窗,走真实短信)
if (result?.smsCode) {
showTestSmsCode(result.smsCode, '账号激活')
}
// 后端可能返回用户基本信息
if (result) {
step1Form.phone = result.phone || ''
if (result.name) userInfo.name = result.name
if (result.phone) userInfo.phone = result.phone
if (result.group) userInfo.group = result.group
}
ElMessage.success('短信验证码已发送')
// 进入下一步
currentStep.value = 1
// 启动倒计时
startSmsCountdown()
} catch (err) {
if (err?.message) ElMessage.error(err.message)
// 刷新验证码
refreshCaptcha()
} finally {
step1Loading.value = false
}
}
/** 步骤2:验证短信验证码 */
async function handleStep2() {
if (!step2FormRef.value) return
try {
await step2FormRef.value.validate()
// 验证短信码(此处通过获取用户信息间接验证)
step2Loading.value = true
// 短信码的验证由后端在激活时完成,此处仅进入下一步
currentStep.value = 2
ElMessage.success('短信验证通过')
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} finally {
step2Loading.value = false
}
}
/** 步骤3:完成激活 */
async function handleActivate() {
if (!step3FormRef.value) return
try {
await step3FormRef.value.validate()
step3Loading.value = true
await authApi.activate({
id_number: step1Form.idNumber,
phone: step1Form.phone || step1Form.idNumber,
password: step3Form.password,
smsCode: step2Form.smsCode,
})
ElMessage.success('账号激活成功!请登录参赛')
// 兜底再清一次(R61):确保跳转瞬间 localStorage 内无残留登录态,
// 否则 /login 的守卫会把用户送进 /admin。
authStore.clearAuth()
// 跳转到登录页
setTimeout(() => {
router.push('/login')
}, 1500)
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} finally {
step3Loading.value = false
}
}
/** 重新发送短信验证码 */
async function resendSms() {
if (smsCountdown.value > 0) return
try {
await refreshCaptcha()
const result = await authApi.sendSms(step1Form.idNumber, step1Form.captchaCode)
if (result?.smsCode) {
showTestSmsCode(result.smsCode, '账号激活(重新发送)')
}
ElMessage.success('短信验证码已重新发送')
startSmsCountdown()
} catch (err) {
if (err?.message) ElMessage.error(err.message)
refreshCaptcha()
}
}
/** 启动短信倒计时 */
function startSmsCountdown() {
smsCountdown.value = 60
clearInterval(smsTimer)
smsTimer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0) {
clearInterval(smsTimer)
smsTimer = null
}
}, 1000)
}
// ==================== 生命周期 ====================
onMounted(() => {
// 进入激活页即按「未登录」处理(R61):清掉残留 token/user,
// 覆盖两条会跳 /login 的路径 —— ①本页「返回登录」按钮 ②激活成功后的跳转。
authStore.clearAuth()
refreshCaptcha()
})
onUnmounted(() => {
if (smsTimer) clearInterval(smsTimer)
})
</script>
<style scoped>
.activate-page {
min-height: 100vh;
background: linear-gradient(135deg, #f5f6f8 0%, #eef0f5 100%);
padding: 40px 20px;
display: flex;
justify-content: center;
}
.activate-container {
width: 100%;
max-width: 540px;
}
/* ==================== 头部 ==================== */
.activate-header {
text-align: center;
margin-bottom: 32px;
}
.header-icon {
font-size: 48px;
color: #c9a84c;
margin-bottom: 12px;
}
.header-title {
font-size: 28px;
font-weight: 700;
color: #1a3a5c;
margin-bottom: 8px;
}
.header-subtitle {
font-size: 14px;
color: #909399;
}
/* ==================== 步骤条 ==================== */
.activate-steps {
margin-bottom: 32px;
}
.activate-steps :deep(.el-step__title) {
font-size: 14px;
}
.activate-steps :deep(.el-step__description) {
font-size: 12px;
}
/* ==================== 步骤卡片 ==================== */
.step-card {
border-radius: 12px;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.step-card-title {
font-size: 16px;
font-weight: 600;
color: #1a3a5c;
}
/* ==================== 点击验证码 ==================== */
.click-captcha-area {
width: 100%;
}
.captcha-hint {
font-size: 14px;
color: #303133;
margin-bottom: 10px;
font-weight: 500;
}
.captcha-chars {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.captcha-char {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #dcdfe6;
border-radius: 8px;
font-size: 22px;
font-weight: bold;
color: #303133;
cursor: pointer;
user-select: none;
background: #fff;
transition: all 0.2s;
}
.captcha-char:hover {
border-color: #409eff;
background: #ecf5ff;
transform: scale(1.05);
}
.captcha-char.clicked {
border-color: #67c23a;
background: #f0f9eb;
color: #67c23a;
cursor: default;
transform: scale(1.05);
}
.captcha-sequence {
font-size: 13px;
color: #606266;
margin-bottom: 6px;
}
.seq-chars {
font-weight: bold;
color: #67c23a;
letter-spacing: 4px;
}
.captcha-refresh {
font-size: 13px;
}
/* ==================== 短信行 ==================== */
.sms-row {
display: flex;
gap: 12px;
}
.sms-input {
flex: 1;
}
.sms-btn {
flex-shrink: 0;
min-width: 130px;
}
.phone-display {
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 12px 16px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
}
.phone-label {
font-size: 14px;
color: #909399;
}
.phone-value {
font-size: 14px;
color: #303133;
font-weight: 600;
}
/* ==================== 信息确认 ==================== */
.info-confirm {
margin-bottom: 24px;
}
/* ==================== 按钮 ==================== */
.step-btn {
width: 100%;
height: 46px;
font-size: 16px;
}
.step-btn-group {
display: flex;
gap: 12px;
width: 100%;
}
.step-btn-group .el-button {
flex: 1;
}
/* ==================== 返回链接 ==================== */
.back-link {
text-align: center;
margin-top: 24px;
}
/* ==================== Responsive ==================== */
@media (max-width: 480px) {
.activate-page {
padding: 20px 12px;
}
.header-title {
font-size: 22px;
}
.captcha-img {
width: 100px;
}
.sms-btn {
min-width: 100px;
font-size: 12px;
}
}
</style>