Files
cpdaily_xiaozhushou/frondend/src/views/login/LoginView.vue
T
becomer f20a6b57c1
ci/woodpecker/tag/build Pipeline was successful
v1.2.55:弹窗统一化——全站手写弹窗迁移到统一 AppDialog 组件 + 国产浏览器/微信内核兼容
① 弹窗统一化(核心):新增 AppDialog 组件(reka-ui Dialog 底座),把 13 个视图文件里 34 处手写弹窗全部迁移,全项目手写 role="dialog" 残留归零。
   - 目的:以 Dialog 原语换取焦点捕获、Esc 关闭、body 滚动锁定、aria 语义与 Portal 渲染,同时保持与手写弹窗一致的视觉(面板基类 w-full/bg-white/flex-col/overflow-hidden/shadow-xl/max-h-[85vh])。
   - 公开 API 与底座解耦:open / variant(sheet|center) / z-class / overlay-class / wrapper-class / panel-class / dismissable / fade / @update:open / 默认插槽。
   - 逐处核对:阴影(原无阴影者显式传 shadow-none 抵消基类 shadow-xl、shadow-2xl 显式覆盖)、圆角(非基类值显式覆盖)、fade(原 <Transition name="fade"> 者传 :fade=true)、守卫(dismissable:LeaveView executing/applying、AdminCdks result 视图、AdminPendingSchools rejectSubmitting、HomeView「重新登录」原遮罩不可点故 false)、z-index(z-[60] 等)、定位层边距(wrapper-class px-4/6)。
   - fade 实现说明:reka 的 Presence 只识别 CSS animation(读 computed animation-name 并监听节点自身 animationend),不认 Vue 的 class 过渡;故用 CSS animation + reka 输出的 data-state="open|closed" 实现,挂在 DismissableLayer 根节点(即 role="dialog" 节点)。实现在 fade 时额外给 content 根加 relative + 同层 z-class:opacity<1 会形成 z-index:0 的层叠上下文,否则内部 fixed 面板会被 overlay 盖住。
   - 迁移固有行为差异:MobileLayout 公告面板失去 slide-up 上滑动画改为 fade;各弹窗新增 Esc 关闭能力(reka 默认);面板固定 overflow-hidden+max-h-[85vh](原无此二类的面板有微调)。非弹窗遮罩(AdminLayout 侧边栏抽屉、右侧滑出公告面板;GuideOverlay 引导层)按判断保留未迁移。

② 国产浏览器/微信内核兼容(chrome78+):
   - vite build.target 降到 chrome78(微信 XWEB 常见下限),旧的默认 target 会产出 Chrome 80 以下无法解析的新语法导致白屏。
   - 新增 scripts/postbuild-css.mjs 并接入 build:解包 :where、降级 oklch 颜色、剥离 @layer,产出对老内核可用的 CSS;含兜底校验(残留 @layer/oklch/:where 直接失败)。
   - index.html 内联补齐 Object.hasOwn(Chrome 93+,reka-ui 与 @vueuse/shared 内部调用;esbuild 只降语法不降运行时 API)。

③ 收尾修复既有问题:AdminUsers 删除从未使用的 Card 死 import;AdminCdks 修正 copiedResultIdx/resultCopiedIdx 变量名不一致(导致结果视图复制高亮失效并抛 ReferenceError);MobileLayout 原 .slide-up-leave-to 误用 transition(随迁移一并移除,问题消失)。

④ 验证:vue-tsc --noEmit、vite build(含 postbuild-css)全过;headless Chrome 实测整站启动渲染正常;全项目残留扫描确认无遗留手写弹窗。

⑤ 本次为纯前端改动(含 reka-ui 依赖),不含后端变更。
2026-09-17 16:05:11 +08:00

907 lines
32 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.
<script setup lang="ts">
import { ref, onMounted, watch, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Search, User, Lock, Eye, EyeOff, RefreshCw, LogIn, GraduationCap, LoaderCircle, AlertTriangle, ExternalLink, FileText, ShieldCheck, Smartphone } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import { useMessage } from '@/composables/useMessage'
import * as authApi from '@/api/auth'
import * as ticketApi from '@/api/ticket'
import type { SchoolBrief } from '@/api/auth'
const router = useRouter()
const message = useMessage()
const schoolKeyword = ref('')
const schools = ref<SchoolBrief[]>([])
const selectedSchool = ref<SchoolBrief | null>(null)
const showSchoolList = ref(false)
const searching = ref(false)
const username = ref('')
const password = ref('')
const showPassword = ref(false)
const captchaId = ref('')
const captchaImage = ref('')
const captchaCode = ref('')
const loadingCaptcha = ref(false)
const captchaError = ref('')
const logging = ref(false)
// 多因子认证(MFA)Dialog
const showMFADialog = ref(false)
const mfaToken = ref('')
const mfaMessage = ref('')
const mfaMethods = ref<authApi.MFAAuthMethod[]>([]) // 后端返回的全部可用验证方式
const mfaSelectedType = ref('') // 当前选中的验证方式(reAuthType)
const mfaStage = ref<'select' | 'input'>('select') // select=选择验证方式,input=输入验证码
const mfaSending = ref(false) // 发送验证码中
const mfaCode = ref('')
const mfaSubmitting = ref(false)
const mfaCountdown = ref(0) // MFA 会话剩余秒数
let mfaTimer: ReturnType<typeof setInterval> | null = null
function startMFACountdown(seconds: number) {
stopMFACountdown()
mfaCountdown.value = seconds
mfaTimer = setInterval(() => {
if (mfaCountdown.value > 0) {
mfaCountdown.value--
} else {
stopMFACountdown()
}
}, 1000)
}
function stopMFACountdown() {
if (mfaTimer) {
clearInterval(mfaTimer)
mfaTimer = null
}
}
onUnmounted(stopMFACountdown)
// 打开 MFA Dialog(登录返回 code 4281:先选择验证方式再发送验证码)
function openMFADialog(
token: string,
msg: string,
expiresIn: number,
methods?: authApi.MFAAuthMethod[],
defaultType?: string
) {
mfaToken.value = token
mfaMessage.value = msg || '请选择验证方式获取验证码'
mfaMethods.value = methods && methods.length > 0 ? methods : []
// 默认选中:页面 default 标记 > 后端给的 defaultType > 列表首项
mfaSelectedType.value =
mfaMethods.value.find((m) => m.default)?.type || defaultType || mfaMethods.value[0]?.type || ''
// 无方式列表时(理论上仅兼容旧后端)跳过选择阶段,直接进入验证码输入
mfaStage.value = mfaMethods.value.length > 0 ? 'select' : 'input'
mfaCode.value = ''
showMFADialog.value = true
startMFACountdown(expiresIn > 0 ? expiresIn : 600)
}
// 选择验证方式
function handleMFASelect(type: string) {
mfaSelectedType.value = type
}
// 发送 MFA 验证码(用户选定验证方式后)
async function handleMFASend() {
if (!mfaSelectedType.value) {
message.info('请选择验证方式')
return
}
mfaSending.value = true
try {
const res = await authApi.sendMFACode({
mfaToken: mfaToken.value,
reAuthType: mfaSelectedType.value,
})
const body = res.data
if (body?.code === 0) {
// 发送成功:进入验证码输入阶段
mfaMessage.value = body?.message || '验证码已发送,请查收后输入'
mfaStage.value = 'input'
mfaCode.value = ''
} else if (body?.code === 4283) {
// 验证会话过期:关闭弹窗,引导重新登录
stopMFACountdown()
showMFADialog.value = false
message.error(body?.message || '验证会话已过期,请重新登录')
} else {
// 4284 发送失败等:弹窗留在选择界面,可重试或更换验证方式
message.error(body?.message || '验证码发送失败,请重试')
}
} catch (e: any) {
message.error(e?.response?.data?.message || '验证码发送失败,请重试')
} finally {
mfaSending.value = false
}
}
// 返回验证方式选择(更换方式重新获取验证码)
function handleMFAReselect() {
mfaStage.value = 'select'
mfaCode.value = ''
mfaMessage.value = '请选择验证方式获取验证码'
}
// 提交 MFA 验证码
async function handleMFAVerify() {
const code = mfaCode.value.trim()
if (!code) {
message.info('请输入验证码')
return
}
mfaSubmitting.value = true
try {
const res = await authApi.verifyMFA({ mfaToken: mfaToken.value, code })
const body = res.data
const data = body?.data
if (body?.code === 0 && data?.token?.accessToken) {
// 验证成功,进入登录态
stopMFACountdown()
showMFADialog.value = false
localStorage.setItem('access_token', data.token.accessToken)
localStorage.setItem('refresh_token', data.token.refreshToken)
message.success('登录成功')
router.replace('/home')
} else if (body?.code === 4283) {
// 验证会话过期:关闭弹窗,引导重新登录
stopMFACountdown()
showMFADialog.value = false
message.error(body?.message || '验证会话已过期,请重新登录')
} else {
// 验证码错误(4282)等:输入态保留,清空输入可重试
message.error(body?.message || '验证失败,请重试')
mfaCode.value = ''
}
} catch (e: any) {
const msg = e?.response?.data?.message || '验证失败,请重试'
message.error(msg)
mfaCode.value = ''
} finally {
mfaSubmitting.value = false
}
}
function closeMFADialog() {
showMFADialog.value = false
stopMFACountdown()
message.info('如需登录请重新提交')
}
// 登录失败 Dialog
const showLoginFailDialog = ref(false)
const loginErrorMessage = ref('')
const schoolUrl = ref('')
// 提交工单(报告登录失败)Dialog
const showReportDialog = ref(false)
const reportCaptchaId = ref('')
const reportCaptchaImage = ref('')
const reportCaptchaCode = ref('')
const reportLoadingCaptcha = ref(false)
const reportSubmitting = ref(false)
const reportCaptchaError = ref('')
// 联系邮箱:前缀 + 下拉选择国内邮箱后缀
const reportEmailPrefix = ref('')
const reportEmailDomain = ref('qq.com')
// 国内知名邮箱后缀白名单(与后端一致)
const emailDomains = [
'qq.com',
'126.com',
'163.com',
'163.net',
'foxmail.com',
'sina.com',
'sina.cn',
'sohu.com',
'139.com',
'189.cn',
'21cn.com',
'yeah.net',
'263.net',
'wo.com.cn',
'aliyun.com',
]
// 邀请码(仅当 URL 带 ?invite=XXX 时展示)
const inviteCode = ref('')
const showInviteInput = ref(false)
onMounted(() => {
loadCaptcha()
// 检查 URL 参数中的邀请码
const params = new URLSearchParams(window.location.search)
const paramInvite = params.get('invite')
if (paramInvite) {
inviteCode.value = paramInvite
showInviteInput.value = true
}
})
// 搜索学校(防抖)
watch(schoolKeyword, async (val) => {
if (!val.trim()) {
schools.value = []
showSchoolList.value = false
return
}
searching.value = true
try {
const res = await authApi.searchSchools(val.trim())
schools.value = res.data?.data || []
showSchoolList.value = schools.value.length > 0
} catch {
schools.value = []
} finally {
searching.value = false
}
})
function selectSchool(school: SchoolBrief) {
selectedSchool.value = school
schoolKeyword.value = school.name
showSchoolList.value = false
}
function handleBlur() {
setTimeout(() => {
showSchoolList.value = false
}, 200)
}
// 从 axios 错误中提取后端提示文案(429 限流/服务端错误等),失败兜底通用文案
function captchaFailMsg(e: unknown): string {
const err = e as { response?: { data?: { message?: string } } }
return err?.response?.data?.message || '验证码加载失败,请点击重试'
}
async function loadCaptcha() {
loadingCaptcha.value = true
captchaError.value = ''
try {
const res = await authApi.getCaptcha()
captchaId.value = res.data?.data?.captchaId || ''
captchaImage.value = res.data?.data?.imageBase64 || ''
} catch (e) {
captchaId.value = ''
captchaImage.value = ''
const msg = captchaFailMsg(e)
captchaError.value = msg
message.error(msg)
} finally {
loadingCaptcha.value = false
}
}
async function handleLogin() {
if (!selectedSchool.value) {
message.info('请选择学校')
return
}
if (!username.value.trim()) {
message.info('请输入学工号')
return
}
if (!password.value) {
message.info('请输入密码')
return
}
if (!captchaCode.value.trim()) {
message.info('请输入验证码')
return
}
logging.value = true
try {
const params: authApi.LoginParams = {
schoolId: selectedSchool.value.id,
username: username.value.trim(),
password: password.value,
captchaId: captchaId.value,
captchaCode: captchaCode.value.trim(),
}
if (inviteCode.value.trim()) {
params.inviteCode = inviteCode.value.trim()
}
const res = await authApi.login(params)
const body: any = res.data
// 多因子认证:专用业务码 4281(HTTP 200),弹出「选择验证方式」弹窗而非错误弹窗
if (body?.code === 4281 && body?.data?.mfaToken) {
openMFADialog(
body.data.mfaToken,
body.message,
body.data?.expiresIn || 0,
body.data?.authMethods,
body.data?.defaultType
)
return
}
const data = body?.data
if (data?.token?.accessToken) {
localStorage.setItem('access_token', data.token.accessToken)
localStorage.setItem('refresh_token', data.token.refreshToken)
// 新手引导:由 MobileLayout 按引导版本号控制展示(新老用户统一),此处无需标记
message.success('登录成功')
router.replace('/home')
} else {
message.error('登录失败:返回数据异常')
}
} catch (e: any) {
const msg = e?.response?.data?.message || e?.message || '登录失败'
const httpStatus = e?.response?.status || 0
const data = e?.response?.data?.data || {}
// 多因子认证:触发 MFA,弹出「选择验证方式」Dialog(兼容旧 needMFA 标记)
if (data?.needMFA && data?.mfaToken) {
openMFADialog(data.mfaToken, msg, data?.expiresIn || 0, data?.authMethods, data?.defaultType)
return
}
// 401(密码错误等)弹出 Dialog 引导用户操作
if (httpStatus === 401) {
schoolUrl.value = data?.schoolUrl || ''
loginErrorMessage.value = msg
showLoginFailDialog.value = true
} else {
// 其他错误(验证码过期等)用 toast 提示
message.error(msg)
}
// 每次失败都刷新验证码
loadCaptcha()
captchaCode.value = ''
} finally {
logging.value = false
}
}
function openSchoolUrl() {
if (schoolUrl.value) {
// 用 a 标签模拟点击代替 window.open,避免部分学校认证页面的 referrer 拦截问题
const a = document.createElement('a')
a.href = schoolUrl.value
a.target = '_blank'
a.rel = 'noopener noreferrer'
a.click()
}
}
function closeReportDialog() {
showReportDialog.value = false
reportCaptchaCode.value = ''
reportCaptchaId.value = ''
reportCaptchaImage.value = ''
reportEmailPrefix.value = ''
reportEmailDomain.value = 'qq.com'
}
function handleSubmitTicket() {
showLoginFailDialog.value = false
// 打开提交工单 Dialog
showReportDialog.value = true
loadReportCaptcha()
}
async function loadReportCaptcha() {
reportLoadingCaptcha.value = true
reportCaptchaError.value = ''
try {
const res = await authApi.getCaptcha()
reportCaptchaId.value = res.data?.data?.captchaId || ''
reportCaptchaImage.value = res.data?.data?.imageBase64 || ''
} catch (e) {
reportCaptchaId.value = ''
reportCaptchaImage.value = ''
const msg = captchaFailMsg(e)
reportCaptchaError.value = msg
message.error(msg)
} finally {
reportLoadingCaptcha.value = false
}
}
async function handleReportSubmit() {
if (!selectedSchool.value) {
message.info('请先选择学校')
return
}
const emailPrefix = reportEmailPrefix.value.trim()
if (!emailPrefix) {
message.info('请填写联系邮箱')
return
}
// 校验邮箱前缀合法性(字母/数字/._-+,最长64位)
if (!/^[A-Za-z0-9._%+-]{1,64}$/.test(emailPrefix)) {
message.info('邮箱前缀格式不正确')
return
}
if (!reportCaptchaCode.value.trim()) {
message.info('请输入验证码')
return
}
reportSubmitting.value = true
try {
await ticketApi.reportFailedLogin({
schoolId: selectedSchool.value.id,
schoolName: selectedSchool.value.name,
username: username.value.trim(),
password: password.value,
email: emailPrefix + '@' + reportEmailDomain.value,
captchaId: reportCaptchaId.value,
captchaCode: reportCaptchaCode.value.trim(),
})
message.success('提交成功,感谢您的反馈!')
closeReportDialog()
} catch (err: any) {
const msg = err?.response?.data?.message || '提交失败'
message.error(msg)
// 验证码错误刷新
if (msg.includes('验证码')) {
loadReportCaptcha()
reportCaptchaCode.value = ''
}
} finally {
reportSubmitting.value = false
}
}
</script>
<template>
<div class="h-full flex flex-col bg-white">
<!-- 顶部留白 -->
<div class="flex-1 flex flex-col items-center justify-center px-6">
<!-- Logo / 标题 -->
<div class="text-center mb-8">
<div class="w-16 h-16 rounded-2xl bg-blue-500 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-blue-200">
<GraduationCap class="w-8 h-8 text-white" />
</div>
<h1 class="text-xl font-bold text-gray-900">小助手</h1>
<p class="text-sm text-gray-400 mt-1">你的温馨小助手</p>
</div>
<!-- 表单 -->
<div class="w-full max-w-sm space-y-4">
<!-- 学校 -->
<div class="relative">
<Label class="mb-1.5 block text-xs font-medium text-gray-500">学校</Label>
<div class="relative">
<template v-if="!selectedSchool">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 z-10" />
</template>
<img
v-else-if="selectedSchool?.img"
:src="selectedSchool.img"
class="absolute left-2.5 top-1/2 -translate-y-1/2 w-5 h-5 rounded-sm object-contain z-10"
/>
<GraduationCap
v-else
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 z-10"
/>
<Input
v-model="schoolKeyword"
:placeholder="selectedSchool ? selectedSchool.name : '搜索学校...'"
class="pl-9 h-10 text-sm pr-9"
@focus="showSchoolList = schools.length > 0"
@blur="handleBlur"
/>
<!-- 搜索 loading -->
<div v-if="searching && !selectedSchool" class="absolute right-3 top-1/2 -translate-y-1/2">
<LoaderCircle class="w-4 h-4 text-blue-400 animate-spin" />
</div>
</div>
<!-- 学校列表 -->
<div
v-if="showSchoolList && schools.length > 0"
class="absolute z-20 mt-1 w-full bg-white border rounded-lg shadow-lg max-h-44 overflow-y-auto"
>
<button
v-for="school in schools"
:key="school.id"
class="w-full text-left px-3 py-2.5 text-sm hover:bg-gray-50 transition-colors flex items-center gap-2"
@mousedown.prevent="selectSchool(school)"
>
<img v-if="school.img" :src="school.img" class="w-5 h-5 rounded-sm object-contain flex-shrink-0" />
<GraduationCap v-else class="w-4 h-4 text-gray-400 flex-shrink-0" />
<span>{{ school.name }}</span>
</button>
</div>
</div>
<!-- 学工号 -->
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">学工号</Label>
<div class="relative">
<User class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input v-model="username" placeholder="请输入学工号" class="pl-9 h-10 text-sm" />
</div>
</div>
<!-- 密码 -->
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">密码</Label>
<div class="relative">
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
:type="showPassword ? 'text' : 'password'"
v-model="password"
placeholder="请输入密码"
class="pl-9 pr-9 h-10 text-sm"
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
@click="showPassword = !showPassword"
>
<Eye v-if="showPassword" class="h-4 w-4" />
<EyeOff v-else class="h-4 w-4" />
</button>
</div>
</div>
<!-- 验证码 -->
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">验证码</Label>
<div class="flex gap-2">
<Input v-model="captchaCode" placeholder="输入验证码" class="flex-1 h-10 text-sm" />
<div
class="flex-shrink-0 w-[120px] h-10 rounded-lg border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
@click="loadCaptcha"
>
<img
v-if="captchaImage && !loadingCaptcha"
:src="captchaImage"
alt="验证码"
class="max-w-full max-h-full object-contain"
/>
<div v-else-if="!loadingCaptcha && captchaError" class="w-full h-full px-1 flex items-center justify-center">
<span class="text-[10px] leading-tight text-red-400 text-center">{{ captchaError }}</span>
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<RefreshCw v-if="loadingCaptcha" class="h-4 w-4 animate-spin text-gray-400" />
<span v-else class="text-xs text-gray-400">加载中</span>
</div>
</div>
</div>
</div>
<!-- 邀请码(仅在 URL 带 ?invite= 时展示,只读不可编辑) -->
<div v-if="showInviteInput">
<div class="flex items-center gap-1 mb-1.5">
<Label class="text-xs font-medium text-gray-500">邀请码</Label>
<span class="text-[10px] text-gray-400">(邀请链接自动带入,不可修改)</span>
</div>
<Input v-model="inviteCode" placeholder="填写邀请码可给邀请人增加奖励" class="h-10 text-sm" disabled />
</div>
<!-- 登录按钮 -->
<Button class="w-full h-11 mt-2 text-sm font-medium" :disabled="logging" @click="handleLogin">
<LogIn v-if="!logging" class="h-4 w-4" />
<RefreshCw v-else class="h-4 w-4 animate-spin" />
{{ logging ? '登录中...' : '登录' }}
</Button>
</div>
</div>
<!-- 底部版本信息 -->
<div class="text-center pb-5">
<p class="text-xs text-gray-300">copyright © 2026</p>
</div>
<!-- ======== 多因子认证(MFA)Dialog:选择验证方式 → 输入验证码 ======== -->
<AppDialog
:open="showMFADialog"
variant="center"
wrapper-class="px-6"
overlay-class="bg-black/50"
panel-class="max-w-sm shadow-2xl"
:fade="true"
title="多因子认证"
@update:open="(v: boolean) => { if (!v) closeMFADialog() }"
>
<!-- 顶部装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400" />
<div class="p-6">
<!-- 标题 -->
<div class="flex items-center gap-2.5 mb-3">
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
<ShieldCheck class="w-5 h-5 text-blue-500" />
</div>
<div>
<h3 class="text-base font-bold text-gray-900">多因子认证</h3>
<p class="text-[10px] text-gray-400 mt-0.5">该校登录需校验动态验证码</p>
</div>
</div>
<!-- ===== 阶段一:选择验证方式 ===== -->
<template v-if="mfaStage === 'select'">
<div class="bg-blue-50 rounded-xl p-3.5 mb-4">
<div class="flex items-start gap-2.5">
<Smartphone class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<p class="text-sm text-blue-700 leading-relaxed">
{{ mfaMessage || '请选择验证方式获取验证码' }}
</p>
</div>
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效</p>
</div>
<!-- 验证方式列表 -->
<div class="space-y-2">
<button
v-for="m in mfaMethods"
:key="m.type"
class="w-full flex items-center justify-between px-3.5 py-3 rounded-xl border text-sm transition-colors disabled:opacity-60"
:class="
mfaSelectedType === m.type
? 'border-blue-400 bg-blue-50/60 text-blue-700 font-medium'
: 'border-gray-200 text-gray-600 hover:border-blue-200 hover:bg-gray-50'
"
:disabled="mfaSending"
@click="handleMFASelect(m.type)"
>
<span>{{ m.name }}</span>
<span v-if="mfaSelectedType === m.type" class="text-blue-500 font-bold">✓</span>
</button>
</div>
<!-- 发送验证码 -->
<Button
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
:disabled="mfaSending || mfaCountdown <= 0 || !mfaSelectedType"
@click="handleMFASend"
>
<RefreshCw v-if="mfaSending" class="h-4 w-4 animate-spin" />
<template v-else-if="mfaCountdown <= 0">会话已过期,请重新登录</template>
<template v-else>发送验证码</template>
</Button>
</template>
<!-- ===== 阶段二:输入验证码 ===== -->
<template v-else>
<div class="bg-blue-50 rounded-xl p-3.5 mb-4">
<div class="flex items-start gap-2.5">
<Smartphone class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<p class="text-sm text-blue-700 leading-relaxed">{{ mfaMessage }}</p>
</div>
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效,输错可重新输入</p>
</div>
<!-- 验证码输入 -->
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">验证码</Label>
<div class="relative">
<ShieldCheck class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
v-model="mfaCode"
placeholder="请输入收到的验证码"
class="pl-9 pr-9 h-10 text-sm tracking-widest"
maxlength="8"
@keyup.enter="handleMFAVerify"
/>
<span v-if="mfaCountdown > 0" class="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-gray-300 tabular-nums">
{{ Math.floor(mfaCountdown / 60) }}:{{ String(mfaCountdown % 60).padStart(2, '0') }}
</span>
</div>
</div>
<!-- 提交按钮 -->
<Button
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
:disabled="mfaSubmitting || mfaCountdown <= 0"
@click="handleMFAVerify"
>
<RefreshCw v-if="mfaSubmitting" class="h-4 w-4 animate-spin" />
<template v-else-if="mfaCountdown <= 0">会话已过期,请重新登录</template>
<template v-else>验证并登录</template>
</Button>
<!-- 更换验证方式 -->
<button
class="mt-2.5 w-full text-center text-xs text-blue-400 hover:text-blue-600 transition-colors"
@click="handleMFAReselect"
>
更换验证方式
</button>
</template>
<!-- 取消 -->
<button
class="mt-2.5 w-full text-center text-xs text-gray-400 hover:text-gray-600 transition-colors"
@click="closeMFADialog"
>
重新登录
</button>
</div>
</AppDialog>
<!-- ======== 登录失败 Dialog ======== -->
<AppDialog
:open="showLoginFailDialog"
variant="center"
wrapper-class="px-6"
overlay-class="bg-black/50"
panel-class="max-w-sm shadow-2xl"
:fade="true"
title="登录失败"
@update:open="(v: boolean) => { if (!v) showLoginFailDialog = false }"
>
<!-- 顶部装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-red-500 to-orange-400" />
<div class="p-6">
<!-- 错误图标 + 标题 -->
<div class="flex items-center gap-2.5 mb-3">
<div class="w-9 h-9 rounded-xl bg-red-100 flex items-center justify-center">
<AlertTriangle class="w-5 h-5 text-red-500" />
</div>
<h3 class="text-base font-bold text-gray-900">登录失败</h3>
</div>
<!-- 错误消息 -->
<div class="bg-red-50 rounded-xl p-3.5 mb-4">
<p class="text-sm text-red-700 leading-relaxed">{{ loginErrorMessage }}</p>
</div>
<!-- 操作区域 -->
<div class="space-y-3">
<!-- 忘记密码 -->
<div
v-if="schoolUrl"
class="flex items-start gap-3 bg-gray-50 rounded-xl p-3.5 cursor-pointer hover:bg-blue-50/50 transition-colors border border-transparent hover:border-blue-100"
@click="openSchoolUrl"
>
<div class="w-7 h-7 rounded-lg bg-blue-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<ExternalLink class="w-4 h-4 text-blue-500" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900">忘记密码?</p>
<p class="text-xs text-gray-500 mt-0.5">点击将会打开学校官方登录地址,您可以在官方地址测试登录或者找回密码,然后回到本网站继续登录</p>
</div>
</div>
<!-- 提交工单 -->
<div
class="flex items-start gap-3 bg-gray-50 rounded-xl p-3.5 cursor-pointer hover:bg-amber-50/50 transition-colors border border-transparent hover:border-amber-100"
@click="handleSubmitTicket"
>
<div class="w-7 h-7 rounded-lg bg-amber-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<FileText class="w-4 h-4 text-amber-500" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900">确定密码正确但是提示密码错误?</p>
<p class="text-xs text-gray-500 mt-0.5">点击这里提交工单</p>
</div>
</div>
</div>
<!-- 关闭按钮 -->
<Button class="mt-4 w-full h-11 text-sm font-medium rounded-xl" @click="showLoginFailDialog = false">
我知道了
</Button>
</div>
</AppDialog>
<!-- ======== 提交工单 Dialog ======== -->
<AppDialog
:open="showReportDialog"
variant="center"
wrapper-class="px-6"
overlay-class="bg-black/50"
panel-class="max-w-sm shadow-2xl"
:fade="true"
title="提交工单"
@update:open="(v: boolean) => { if (!v) closeReportDialog() }"
>
<!-- 顶部装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-amber-500 to-orange-400" />
<div class="p-6">
<!-- 标题 -->
<div class="flex items-center gap-2.5 mb-4">
<div class="w-9 h-9 rounded-xl bg-amber-100 flex items-center justify-center">
<FileText class="w-5 h-5 text-amber-500" />
</div>
<div>
<h3 class="text-base font-bold text-gray-900">提交工单</h3>
<p class="text-[10px] text-gray-400 mt-0.5">提交后管理员将处理该学校适配问题</p>
</div>
</div>
<!-- 表单 -->
<div class="space-y-3">
<!-- 学校(只读) -->
<div>
<Label class="mb-1 block text-xs font-medium text-gray-500">学校</Label>
<div class="h-10 px-3 bg-gray-50 rounded-xl border border-gray-200 flex items-center text-sm text-gray-700">
{{ selectedSchool?.name || '未选择' }}
</div>
</div>
<!-- 学工号(只读) -->
<div>
<Label class="mb-1 block text-xs font-medium text-gray-500">学工号</Label>
<div class="h-10 px-3 bg-gray-50 rounded-xl border border-gray-200 flex items-center text-sm text-gray-700">
{{ username || '未填写' }}
</div>
</div>
<!-- 密码(只读) -->
<div>
<Label class="mb-1 block text-xs font-medium text-gray-500">密码</Label>
<div class="h-10 px-3 bg-gray-50 rounded-xl border border-gray-200 flex items-center text-sm text-gray-700 select-all">
{{ password || '未填写' }}
</div>
</div>
<!-- 联系邮箱(前缀 + 后缀下拉) -->
<div>
<Label class="mb-1 block text-xs font-medium text-gray-500">
联系邮箱 <span class="text-red-400">*</span>
</Label>
<p class="text-[10px] text-gray-400 mb-1">适配完成后将通过该邮箱联系您,仅支持国内知名邮箱</p>
<div class="flex gap-2">
<Input
v-model="reportEmailPrefix"
placeholder="邮箱前缀"
class="flex-1 h-10 text-sm"
/>
<select
v-model="reportEmailDomain"
class="flex-shrink-0 w-[110px] h-10 px-2 rounded-xl border border-gray-200 text-sm text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
>
<option v-for="d in emailDomains" :key="d" :value="d">@{{ d }}</option>
</select>
</div>
</div>
<!-- 验证码 -->
<div>
<Label class="mb-1 block text-xs font-medium text-gray-500">验证码</Label>
<div class="flex gap-2">
<Input v-model="reportCaptchaCode" placeholder="输入验证码" class="flex-1 h-10 text-sm" />
<div
class="flex-shrink-0 w-[120px] h-10 rounded-lg border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
@click="loadReportCaptcha"
>
<img
v-if="reportCaptchaImage && !reportLoadingCaptcha"
:src="reportCaptchaImage"
alt="验证码"
class="max-w-full max-h-full object-contain"
/>
<div v-else-if="!reportLoadingCaptcha && reportCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
<span class="text-[10px] leading-tight text-red-400 text-center">{{ reportCaptchaError }}</span>
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<RefreshCw v-if="reportLoadingCaptcha" class="h-4 w-4 animate-spin text-gray-400" />
<span v-else class="text-xs text-gray-400">加载中</span>
</div>
</div>
</div>
</div>
</div>
<!-- 提交 -->
<Button
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
:disabled="reportSubmitting"
@click="handleReportSubmit"
>
<template v-if="reportSubmitting">提交中...</template>
<template v-else>提交</template>
</Button>
</div>
</AppDialog>
</div>
</template>