f20a6b57c1
ci/woodpecker/tag/build Pipeline was successful
① 弹窗统一化(核心):新增 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 依赖),不含后端变更。
907 lines
32 KiB
Vue
907 lines
32 KiB
Vue
<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>
|