续假入口 - 头卡改为「请假 / 销假 / 续假」三按钮,续假打开只含可续假请假单的列表 - 列表按模式过滤:销假只看可销假单,续假只看可续假单;空态说明续假条件(休假中 + 学校允许) - LeaveItem 增 canReport(列表含仅可续假的单,销假模式据此排除) 登录页 - 品牌渐变头图(问候语 + 功能标签 + 细网格),登录卡片上浮压住头图 - 滑块分段控件切换学工号/手机号;输入行改为图标 + 原生输入,整行聚焦高亮,已选学校带勾 - 验证码块统一尺寸并带刷新角标;短信按钮倒计时文案;主按钮固定品牌色 - 登录逻辑不变(已验证验证码加载、学校选择、/auth/login 提交体)
This commit is contained in:
@@ -28,7 +28,9 @@ export interface LeaveItem {
|
||||
reportPlaces: LeaveReportPlace[]
|
||||
/** 该请假类型销假是否需要选择审批人(leaveType.reportApplyFlow=1) */
|
||||
reportApplyFlow: boolean
|
||||
/** 是否可续假(仅新套:休假中/逾期 且 isAllowExtend) */
|
||||
/** 是否可销假(列表含仅可续假的单) */
|
||||
canReport?: boolean
|
||||
/** 是否可续假(休假中/逾期 且学校允许续假) */
|
||||
canExtend?: boolean
|
||||
/** 续假用:原假单的请假类型 ID(typeWid) */
|
||||
typeWid?: string
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<div class="pointer-events-none absolute -right-2 top-16 w-20 h-20 rounded-full bg-white/10" />
|
||||
<div class="relative flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<p class="text-lg font-bold tracking-tight">请假与销假</p>
|
||||
<p class="text-xs text-white/80 mt-0.5">异地一键请假销假,自行设置位置</p>
|
||||
<p class="text-lg font-bold tracking-tight">请假 · 销假 · 续假</p>
|
||||
<p class="text-xs text-white/80 mt-0.5">异地一键请假、销假、续假,自行设置位置</p>
|
||||
</div>
|
||||
<div class="w-11 h-11 rounded-2xl bg-white/20 flex items-center justify-center">
|
||||
<CalendarCheck class="w-5 h-5 text-white" />
|
||||
@@ -25,13 +25,21 @@
|
||||
请假
|
||||
</button>
|
||||
<button
|
||||
@click="openPendingModal"
|
||||
@click="openPendingModal('report')"
|
||||
:disabled="executing || applying"
|
||||
class="press flex-1 flex items-center justify-center gap-2 bg-white text-rose-600 disabled:opacity-50 rounded-full py-3 text-sm font-semibold shadow-md shadow-black/5 dark:bg-[#fff5f7] dark:text-[#b8325a]"
|
||||
>
|
||||
<CalendarCheck class="w-4 h-4" />
|
||||
销假
|
||||
</button>
|
||||
<button
|
||||
@click="openPendingModal('extend')"
|
||||
:disabled="executing || applying"
|
||||
class="press flex-1 flex items-center justify-center gap-2 bg-white/20 ring-1 ring-white/30 text-white disabled:opacity-50 rounded-full py-3 text-sm font-semibold"
|
||||
>
|
||||
<CalendarClock class="w-4 h-4" />
|
||||
续假
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,12 +99,12 @@
|
||||
variant="sheet"
|
||||
panel-class="shadow-none sm:max-w-sm"
|
||||
:dismissable="true"
|
||||
title="选择请假记录"
|
||||
:title="pendingMode === 'extend' ? '选择要续假的请假单' : '选择要销假的请假单'"
|
||||
@update:open="onPendingOpenChange"
|
||||
>
|
||||
<!-- 标题栏 -->
|
||||
<div class="px-4 py-3 border-b border-gray-100 flex items-center justify-between shrink-0">
|
||||
<span class="text-sm font-semibold text-gray-900">选择请假记录</span>
|
||||
<span class="text-sm font-semibold text-gray-900">{{ pendingMode === 'extend' ? '选择要续假的请假单' : '选择要销假的请假单' }}</span>
|
||||
<button @click="closePendingModal" class="w-7 h-7 flex items-center justify-center rounded-full hover:bg-gray-100 text-gray-400">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
@@ -107,14 +115,20 @@
|
||||
<div v-if="loadingLeaves" class="flex items-center justify-center py-12">
|
||||
<LoaderCircle class="w-6 h-6 text-rose-500 animate-spin" />
|
||||
</div>
|
||||
<div v-else-if="pendingLeaves.length === 0" class="text-center py-12">
|
||||
<CalendarCheck class="w-10 h-10 text-gray-200 mx-auto mb-3" />
|
||||
<p class="text-sm text-gray-400">暂无可销假/续假的记录</p>
|
||||
<p class="text-xs text-gray-300 mt-1">有正在休假、待销假或可续假的请假单时会显示在这里</p>
|
||||
<div v-else-if="visiblePendingLeaves.length === 0" class="text-center py-12 px-2">
|
||||
<CalendarCheck class="w-10 h-10 text-gray-300 mx-auto mb-3" />
|
||||
<template v-if="pendingMode === 'extend'">
|
||||
<p class="text-sm text-gray-500">暂无可续假的请假单</p>
|
||||
<p class="text-xs text-gray-400 mt-1 leading-relaxed">只有「休假中」且学校允许续假的请假单才能续假;已销假、已结束或未到假期的请假单不能续假</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-sm text-gray-500">暂无可销假的请假单</p>
|
||||
<p class="text-xs text-gray-400 mt-1">有正在休假或待销假的请假单时会显示在这里</p>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="leave in pendingLeaves"
|
||||
v-for="leave in visiblePendingLeaves"
|
||||
:key="leave.id"
|
||||
class="relative p-3 rounded-lg border border-gray-100"
|
||||
>
|
||||
@@ -132,13 +146,14 @@
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="pendingMode === 'report'"
|
||||
@click="selectLeave(leave)"
|
||||
class="press flex-1 h-9 rounded-lg border border-rose-300 text-rose-600 text-sm font-medium hover:bg-rose-50"
|
||||
class="press flex-1 h-9 rounded-lg bg-rose-500 text-white text-sm font-semibold"
|
||||
>
|
||||
销假
|
||||
</button>
|
||||
<button
|
||||
v-if="leave.canExtend"
|
||||
v-if="pendingMode === 'extend' && leave.canExtend"
|
||||
@click="startExtend(leave)"
|
||||
:disabled="extendPrefilling"
|
||||
class="press flex-1 h-9 rounded-lg bg-rose-500 text-white text-sm font-semibold disabled:opacity-60 flex items-center justify-center gap-1.5"
|
||||
@@ -739,7 +754,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CalendarCheck, CalendarPlus, LoaderCircle, Trash2, X, MapPin, Upload, MessageSquare, Smartphone, Edit3 } from 'lucide-vue-next'
|
||||
import { CalendarCheck, CalendarPlus, CalendarClock, LoaderCircle, Trash2, X, MapPin, Upload, MessageSquare, Smartphone, Edit3 } from 'lucide-vue-next'
|
||||
import * as leaveApi from '@/api/leave'
|
||||
import * as authApi from '@/api/auth'
|
||||
import type { LeaveItem, LeaveReportPlace, LeaveType } from '@/api/leave'
|
||||
@@ -792,13 +807,20 @@ async function removeLog(id: number) {
|
||||
const showPendingModal = ref(false)
|
||||
const loadingLeaves = ref(false)
|
||||
const pendingLeaves = ref<LeaveItem[]>([])
|
||||
// 列表用途:report=销假(可销假的单),extend=续假(canExtend 的单)
|
||||
const pendingMode = ref<'report' | 'extend'>('report')
|
||||
// 后端列表含「可销假 或 可续假」两类,按模式过滤;销假模式排除仅可续假的单(canReport 由后端保证在列表内即可销假)
|
||||
const visiblePendingLeaves = computed(() =>
|
||||
pendingMode.value === 'extend' ? pendingLeaves.value.filter((l) => l.canExtend) : pendingLeaves.value.filter((l) => l.canReport !== false),
|
||||
)
|
||||
|
||||
function openPendingModal() {
|
||||
// 未绑定设备码:弹窗引导去个人中心绑定,不进入销假流程
|
||||
function openPendingModal(mode: 'report' | 'extend' = 'report') {
|
||||
// 未绑定设备码:弹窗引导去个人中心绑定,不进入销假/续假流程
|
||||
if (!deviceCode.value) {
|
||||
showBindDeviceDialog.value = true
|
||||
return
|
||||
}
|
||||
pendingMode.value = mode
|
||||
showPendingModal.value = true
|
||||
loadPendingLeaves()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, onUnmounted } from 'vue'
|
||||
import { ref, computed, 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 { Search, User, Lock, Eye, EyeOff, RefreshCw, GraduationCap, LoaderCircle, AlertTriangle, ExternalLink, FileText, ShieldCheck, Smartphone, ArrowRight, MessageSquareText, CheckCircle2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -12,6 +12,17 @@ import * as ticketApi from '@/api/ticket'
|
||||
import type { SchoolBrief } from '@/api/auth'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 头图文案
|
||||
const heroGreeting = computed(() => {
|
||||
const h = new Date().getHours()
|
||||
if (h < 6) return '夜深了'
|
||||
if (h < 11) return '早上好'
|
||||
if (h < 14) return '中午好'
|
||||
if (h < 18) return '下午好'
|
||||
return '晚上好'
|
||||
})
|
||||
const heroFeatures = ['自动打卡', '一键请假', '销假续假', '查寝扫码']
|
||||
const message = useMessage()
|
||||
|
||||
const schoolKeyword = ref('')
|
||||
@@ -596,242 +607,231 @@ async function handleReportSubmit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col overflow-y-auto">
|
||||
<!-- 顶部留白(背景光斑来自 #app,见 globals.css) -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center px-5 py-8">
|
||||
<!-- Logo / 标题 -->
|
||||
<div class="text-center mb-7 rise-in">
|
||||
<div class="relative w-[72px] h-[72px] rounded-[22px] bg-brand-gradient flex items-center justify-center mx-auto mb-4 shadow-xl shadow-brand-500/30">
|
||||
<div class="absolute inset-0 rounded-[22px] bg-gradient-to-b from-white/30 to-transparent" />
|
||||
<GraduationCap class="relative w-9 h-9 text-white" />
|
||||
<div class="login-root h-full flex flex-col overflow-y-auto overscroll-contain">
|
||||
<!-- ======== 品牌头图 ======== -->
|
||||
<div class="login-hero relative overflow-hidden hero-clip flex-shrink-0 px-6 text-white" style="padding-top: calc(var(--safe-top) + 44px); padding-bottom: 76px">
|
||||
<div class="pointer-events-none absolute -right-16 -top-20 w-72 h-72 rounded-full bg-white/10" />
|
||||
<div class="pointer-events-none absolute -left-10 top-28 w-40 h-40 rounded-full bg-white/10" />
|
||||
<div class="pointer-events-none absolute inset-0 login-hero-grid" />
|
||||
<div class="relative rise-in">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative w-14 h-14 rounded-[18px] bg-white/20 ring-1 ring-white/40 flex items-center justify-center shadow-lg shadow-black/10">
|
||||
<GraduationCap class="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[26px] font-bold tracking-tight leading-tight">小助手</p>
|
||||
<p class="text-sm text-white/90">你的温馨校园小助手</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-6 text-[22px] font-bold tracking-tight leading-snug">{{ heroGreeting }},<br />欢迎回来</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span v-for="f in heroFeatures" :key="f" class="text-xs font-medium rounded-full bg-white/15 ring-1 ring-white/25 px-2.5 py-1">{{ f }}</span>
|
||||
</div>
|
||||
<h1 class="text-[26px] font-bold tracking-tight text-gray-900">小助手</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">你的温馨小助手 · 打卡请假更轻松</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表单(白卡片浮于光斑背景之上) -->
|
||||
<div class="w-full max-w-sm space-y-4 bg-white/90 dark:bg-gray-50/90 rounded-3xl border border-white/70 dark:border-gray-200 shadow-xl p-5 rise-in" style="animation-delay: 60ms">
|
||||
<!-- 登录方式切换(分段控件) -->
|
||||
<div class="flex gap-1 p-1 bg-gray-100 rounded-full">
|
||||
<!-- ======== 登录卡片(上浮压住头图) ======== -->
|
||||
<div class="relative flex-1 px-4 -mt-12 pb-6">
|
||||
<div class="login-card mx-auto w-full max-w-sm rounded-[28px] p-5 rise-in" style="animation-delay: 60ms">
|
||||
<p class="text-lg font-bold text-gray-900 tracking-tight">账号登录</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5 mb-4">使用今日校园账号登录,信息仅用于自动打卡与请假</p>
|
||||
|
||||
<!-- 登录方式切换(滑块分段控件) -->
|
||||
<div class="relative grid grid-cols-2 p-1 rounded-2xl bg-gray-100 mb-5" role="tablist" aria-label="登录方式">
|
||||
<div
|
||||
class="absolute top-1 bottom-1 left-1 rounded-xl bg-white shadow-sm ring-1 ring-black/5 transition-transform duration-300 ease-[var(--ease-spring)] dark:bg-gray-300 dark:ring-white/5"
|
||||
:style="{ width: 'calc((100% - 8px) / 2)', transform: loginMode === 'sms' ? 'translateX(100%)' : 'none' }"
|
||||
/>
|
||||
<button
|
||||
class="flex-1 h-9 rounded-full text-sm font-semibold transition-all duration-300"
|
||||
:class="loginMode === 'school' ? 'bg-white text-brand-600 shadow-sm dark:bg-gray-300 dark:text-gray-900' : 'text-gray-500'"
|
||||
role="tab"
|
||||
:aria-selected="loginMode === 'school'"
|
||||
class="relative z-10 h-10 flex items-center justify-center gap-1.5 rounded-xl text-sm transition-colors"
|
||||
:class="loginMode === 'school' ? 'font-semibold text-brand-600 dark:text-brand-700' : 'font-medium text-gray-500'"
|
||||
@click="switchLoginMode('school')"
|
||||
>
|
||||
学工号登录
|
||||
<User class="w-4 h-4" />学工号登录
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 h-9 rounded-full text-sm font-semibold transition-all duration-300"
|
||||
:class="loginMode === 'sms' ? 'bg-white text-brand-600 shadow-sm dark:bg-gray-300 dark:text-gray-900' : 'text-gray-500'"
|
||||
role="tab"
|
||||
:aria-selected="loginMode === 'sms'"
|
||||
class="relative z-10 h-10 flex items-center justify-center gap-1.5 rounded-xl text-sm transition-colors"
|
||||
:class="loginMode === 'sms' ? 'font-semibold text-brand-600 dark:text-brand-700' : 'font-medium text-gray-500'"
|
||||
@click="switchLoginMode('sms')"
|
||||
>
|
||||
手机号登录
|
||||
<Smartphone class="w-4 h-4" />手机号登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3.5">
|
||||
<!-- ======== 手机号验证码登录(无需选择学校) ======== -->
|
||||
<template v-if="loginMode === 'sms'">
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">手机号</Label>
|
||||
<div class="relative">
|
||||
<Smartphone class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input v-model="smsPhone" placeholder="请输入手机号" class="pl-9 h-11 text-sm" />
|
||||
</div>
|
||||
<div class="login-field">
|
||||
<Smartphone class="login-field-icon" />
|
||||
<input v-model="smsPhone" type="tel" inputmode="numeric" autocomplete="tel" placeholder="请输入手机号" class="login-input" aria-label="手机号" />
|
||||
</div>
|
||||
|
||||
<!-- 图片验证码(用于「发送短信验证码」) -->
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">图片验证码</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="smsSendCaptchaCode" placeholder="输入图片验证码" class="flex-1 h-11 text-sm" />
|
||||
<div
|
||||
class="flex-shrink-0 w-[120px] h-11 rounded-xl border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
|
||||
<div class="flex gap-2">
|
||||
<div class="login-field flex-1">
|
||||
<ShieldCheck class="login-field-icon" />
|
||||
<input v-model="smsSendCaptchaCode" placeholder="图片验证码" class="login-input" aria-label="发送短信用图片验证码" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="login-captcha press"
|
||||
:aria-label="'刷新图片验证码'"
|
||||
@click="loadSmsCaptcha('send')"
|
||||
>
|
||||
<img
|
||||
v-if="smsSendCaptchaImage && !smsSendCaptchaLoading"
|
||||
:src="smsSendCaptchaImage"
|
||||
alt="验证码"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else-if="!smsSendCaptchaLoading && smsSendCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-xs leading-tight text-red-400 text-center">{{ smsSendCaptchaError }}</span>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<RefreshCw v-if="smsSendCaptchaLoading" class="h-4 w-4 animate-spin text-gray-400" />
|
||||
<span v-else class="text-xs text-gray-400">加载中</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<img v-if="smsSendCaptchaImage && !smsSendCaptchaLoading" :src="smsSendCaptchaImage" alt="验证码" class="w-full h-full object-cover" />
|
||||
<span v-else-if="!smsSendCaptchaLoading && smsSendCaptchaError" class="px-1 text-xs leading-tight text-red-500 text-center">{{ smsSendCaptchaError }}</span>
|
||||
<RefreshCw v-else class="h-4 w-4 text-gray-400" :class="smsSendCaptchaLoading && 'animate-spin'" />
|
||||
<span v-if="smsSendCaptchaImage && !smsSendCaptchaLoading" class="login-captcha-refresh"><RefreshCw class="w-3 h-3" /></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">短信验证码</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="smsCode" placeholder="请输入短信验证码" class="flex-1 h-11 text-sm" />
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-shrink-0 h-11 px-4 text-sm"
|
||||
:disabled="smsSending || smsCountdown > 0"
|
||||
@click="handleSendSmsCode"
|
||||
>
|
||||
<template v-if="smsSending">发送中...</template>
|
||||
<template v-else-if="smsCountdown > 0">{{ smsCountdown }}s</template>
|
||||
<template v-else>发送验证码</template>
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<div class="login-field flex-1">
|
||||
<MessageSquareText class="login-field-icon" />
|
||||
<input v-model="smsCode" inputmode="numeric" autocomplete="one-time-code" placeholder="短信验证码" class="login-input" aria-label="短信验证码" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="login-sms-btn press"
|
||||
:disabled="smsSending || smsCountdown > 0"
|
||||
@click="handleSendSmsCode"
|
||||
>
|
||||
<template v-if="smsSending">发送中…</template>
|
||||
<template v-else-if="smsCountdown > 0">{{ smsCountdown }}s 后重发</template>
|
||||
<template v-else>获取验证码</template>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 图片验证码(用于「登录」,与发送用相互独立) -->
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">图片验证码</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="smsLoginCaptchaCode" placeholder="输入图片验证码" class="flex-1 h-11 text-sm" />
|
||||
<div
|
||||
class="flex-shrink-0 w-[120px] h-11 rounded-xl border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
|
||||
<div class="flex gap-2">
|
||||
<div class="login-field flex-1">
|
||||
<ShieldCheck class="login-field-icon" />
|
||||
<input v-model="smsLoginCaptchaCode" placeholder="登录图片验证码" class="login-input" aria-label="登录用图片验证码" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="login-captcha press"
|
||||
:aria-label="'刷新图片验证码'"
|
||||
@click="loadSmsCaptcha('login')"
|
||||
>
|
||||
<img
|
||||
v-if="smsLoginCaptchaImage && !smsLoginCaptchaLoading"
|
||||
:src="smsLoginCaptchaImage"
|
||||
alt="验证码"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else-if="!smsLoginCaptchaLoading && smsLoginCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-xs leading-tight text-red-400 text-center">{{ smsLoginCaptchaError }}</span>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<RefreshCw v-if="smsLoginCaptchaLoading" class="h-4 w-4 animate-spin text-gray-400" />
|
||||
<span v-else class="text-xs text-gray-400">加载中</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<img v-if="smsLoginCaptchaImage && !smsLoginCaptchaLoading" :src="smsLoginCaptchaImage" alt="验证码" class="w-full h-full object-cover" />
|
||||
<span v-else-if="!smsLoginCaptchaLoading && smsLoginCaptchaError" class="px-1 text-xs leading-tight text-red-500 text-center">{{ smsLoginCaptchaError }}</span>
|
||||
<RefreshCw v-else class="h-4 w-4 text-gray-400" :class="smsLoginCaptchaLoading && 'animate-spin'" />
|
||||
<span v-if="smsLoginCaptchaImage && !smsLoginCaptchaLoading" class="login-captcha-refresh"><RefreshCw class="w-3 h-3" /></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ======== 学校 + 学工号 + 密码登录 ======== -->
|
||||
<template v-else>
|
||||
<!-- 学校 -->
|
||||
<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-11 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 class="login-field" :class="selectedSchool && 'is-filled'">
|
||||
<img v-if="selectedSchool?.img" :src="selectedSchool.img" alt="" class="login-field-icon !w-5 !h-5 rounded object-contain" />
|
||||
<Search v-else-if="!selectedSchool" class="login-field-icon" />
|
||||
<GraduationCap v-else class="login-field-icon" />
|
||||
<input
|
||||
v-model="schoolKeyword"
|
||||
:placeholder="selectedSchool ? selectedSchool.name : '搜索你的学校'"
|
||||
class="login-input"
|
||||
:class="selectedSchool && 'placeholder:text-gray-900 placeholder:font-medium'"
|
||||
aria-label="学校"
|
||||
@focus="showSchoolList = schools.length > 0"
|
||||
@blur="handleBlur"
|
||||
/>
|
||||
<LoaderCircle v-if="searching && !selectedSchool" class="w-4 h-4 text-brand-500 animate-spin flex-shrink-0" />
|
||||
<CheckCircle2 v-else-if="selectedSchool" class="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||
</div>
|
||||
<!-- 学校列表 -->
|
||||
<div
|
||||
v-if="showSchoolList && schools.length > 0"
|
||||
class="absolute z-20 mt-1.5 w-full bg-white border border-gray-100 rounded-2xl shadow-xl max-h-52 overflow-y-auto p-1"
|
||||
>
|
||||
<button
|
||||
v-for="school in schools"
|
||||
:key="school.id"
|
||||
class="w-full text-left px-3 py-2.5 text-sm rounded-xl hover:bg-gray-50 transition-colors flex items-center gap-2.5"
|
||||
@mousedown.prevent="selectSchool(school)"
|
||||
>
|
||||
<img v-if="school.img" :src="school.img" alt="" class="w-6 h-6 rounded object-contain flex-shrink-0" />
|
||||
<span v-else class="w-6 h-6 rounded-lg bg-brand-50 flex items-center justify-center flex-shrink-0"><GraduationCap class="w-3.5 h-3.5 text-brand-600" /></span>
|
||||
<span class="text-gray-900">{{ school.name }}</span>
|
||||
</button>
|
||||
</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-11 text-sm" />
|
||||
<!-- 学工号 -->
|
||||
<div class="login-field">
|
||||
<User class="login-field-icon" />
|
||||
<input v-model="username" autocomplete="username" placeholder="请输入学工号" class="login-input" aria-label="学工号" />
|
||||
</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'"
|
||||
<!-- 密码 -->
|
||||
<div class="login-field">
|
||||
<Lock class="login-field-icon" />
|
||||
<input
|
||||
v-model="password"
|
||||
placeholder="请输入密码"
|
||||
class="pl-9 pr-9 h-11 text-sm"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入统一认证密码"
|
||||
class="login-input"
|
||||
aria-label="密码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
class="w-8 h-8 -mr-1 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-600"
|
||||
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
|
||||
@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-11 text-sm" />
|
||||
<div
|
||||
class="flex-shrink-0 w-[120px] h-11 rounded-xl 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="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else-if="!loadingCaptcha && captchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-xs 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 class="login-field flex-1">
|
||||
<ShieldCheck class="login-field-icon" />
|
||||
<input v-model="captchaCode" placeholder="请输入验证码" class="login-input" aria-label="验证码" @keyup.enter="handleLogin()" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="login-captcha press"
|
||||
:aria-label="'刷新图片验证码'"
|
||||
@click="loadCaptcha"
|
||||
>
|
||||
<img v-if="captchaImage && !loadingCaptcha" :src="captchaImage" alt="验证码" class="w-full h-full object-cover" />
|
||||
<span v-else-if="!loadingCaptcha && captchaError" class="px-1 text-xs leading-tight text-red-500 text-center">{{ captchaError }}</span>
|
||||
<RefreshCw v-else class="h-4 w-4 text-gray-400" :class="loadingCaptcha && 'animate-spin'" />
|
||||
<span v-if="captchaImage && !loadingCaptcha" class="login-captcha-refresh"><RefreshCw class="w-3 h-3" /></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<Button
|
||||
class="w-full h-12 mt-2 text-[15px] bg-brand-gradient"
|
||||
<button
|
||||
type="button"
|
||||
class="login-submit press mt-5"
|
||||
:disabled="logging"
|
||||
@click="loginMode === 'sms' ? handleSmsLogin() : handleLogin()"
|
||||
>
|
||||
<LogIn v-if="!logging" class="h-4 w-4" />
|
||||
<RefreshCw v-else class="h-4 w-4 animate-spin" />
|
||||
{{ logging ? '登录中...' : '登录' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<RefreshCw v-if="logging" class="h-4 w-4 animate-spin" />
|
||||
<template v-else>登录<ArrowRight class="h-4 w-4" /></template>
|
||||
<template v-if="logging">登录中…</template>
|
||||
</button>
|
||||
|
||||
<!-- 底部版本信息 -->
|
||||
<div class="text-center pb-5">
|
||||
<p class="text-xs text-gray-400">copyright © 2026</p>
|
||||
<p class="mt-4 flex items-start justify-center gap-1.5 text-xs text-gray-500 leading-relaxed text-center">
|
||||
<ShieldCheck class="w-3.5 h-3.5 mt-px flex-shrink-0 text-green-600" />
|
||||
密码加密传输,仅用于登录今日校园与自动续期
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-xs text-gray-400 mt-6">copyright © 2026</p>
|
||||
</div>
|
||||
|
||||
<!-- ======== 多因子认证(MFA)Dialog:选择验证方式 → 输入验证码 ======== -->
|
||||
@@ -1138,3 +1138,149 @@ async function handleReportSubmit() {
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 头图:品牌渐变 + 右上天空蓝光晕 + 细网格(底部淡出),白字区整体 ≥ #0b8672 保证对比度 */
|
||||
.login-hero {
|
||||
background-color: #0b8672;
|
||||
background-image:
|
||||
radial-gradient(60% 70% at 100% 0%, rgb(90 169 245 / 0.18), transparent 60%),
|
||||
linear-gradient(160deg, #0b8672 0%, #0b8672 50%, #08604f 100%);
|
||||
}
|
||||
.login-hero-grid {
|
||||
background-image:
|
||||
linear-gradient(rgb(255 255 255 / 0.07) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(255 255 255 / 0.07) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
-webkit-mask-image: linear-gradient(180deg, #000 0%, transparent 90%);
|
||||
mask-image: linear-gradient(180deg, #000 0%, transparent 90%);
|
||||
}
|
||||
.login-card {
|
||||
background-color: var(--color-card);
|
||||
border: 1px solid rgb(14 40 34 / 0.06);
|
||||
box-shadow: 0 18px 50px rgb(14 40 34 / 0.14), 0 2px 6px rgb(14 40 34 / 0.05);
|
||||
}
|
||||
/* 输入行:图标 + 原生 input(整行可点、聚焦高亮) */
|
||||
.login-field {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 50px;
|
||||
padding: 0 14px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--color-gray-50);
|
||||
border: 1.5px solid var(--color-gray-200);
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.login-field:focus-within {
|
||||
background-color: var(--color-card);
|
||||
border-color: var(--color-brand-400);
|
||||
box-shadow: 0 0 0 4px rgb(44 197 168 / 0.14);
|
||||
}
|
||||
.login-field.is-filled {
|
||||
background-color: var(--color-brand-50);
|
||||
border-color: var(--color-brand-200);
|
||||
}
|
||||
.login-field-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
.login-field:focus-within .login-field-icon {
|
||||
color: var(--color-brand-500);
|
||||
}
|
||||
.login-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: none;
|
||||
font-size: 15px;
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
.login-input::placeholder {
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
/* 图片验证码 */
|
||||
.login-captcha {
|
||||
position: relative;
|
||||
width: 108px;
|
||||
height: 50px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-gray-50);
|
||||
border: 1.5px solid var(--color-gray-200);
|
||||
}
|
||||
.login-captcha-refresh {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: rgb(0 0 0 / 0.35);
|
||||
}
|
||||
.login-sms-btn {
|
||||
height: 50px;
|
||||
min-width: 108px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-brand-600);
|
||||
background-color: var(--color-brand-50);
|
||||
border: 1.5px solid var(--color-brand-200);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.login-sms-btn:disabled {
|
||||
color: var(--color-gray-500);
|
||||
background-color: var(--color-gray-100);
|
||||
border-color: var(--color-gray-200);
|
||||
}
|
||||
/* 主按钮:固定品牌色(白字 ≥ 4.5:1) */
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
background-image: linear-gradient(135deg, #0e9a82 0%, #0b8672 55%, #08604f 100%);
|
||||
box-shadow: 0 10px 24px rgb(11 134 114 / 0.3);
|
||||
}
|
||||
.login-submit:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
html.dark .login-hero {
|
||||
background-image:
|
||||
radial-gradient(70% 90% at 100% 0%, rgb(90 169 245 / 0.14), transparent 60%),
|
||||
linear-gradient(160deg, #0c6d5d 0%, #0b5f55 60%, #094c44 100%);
|
||||
}
|
||||
html.dark .login-card {
|
||||
border-color: rgb(255 255 255 / 0.06);
|
||||
box-shadow: 0 18px 50px rgb(0 0 0 / 0.45);
|
||||
}
|
||||
html.dark .login-sms-btn {
|
||||
color: var(--color-brand-700);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user