Files
guoju0808/ry-vue3/src/views/auth/Login.vue
T

996 lines
30 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="login-page">
<!-- 顶部导航 -->
<header class="header">
<a class="logo" @click.prevent="$router.push('/portal/home')">
<div class="logo-icon"><img src="/logo.png" alt="logo" /></div>
<div class="logo-text">
<span class="logo-title">北京整合医学学会</span>
<span class="logo-subtitle">Beijing Association of Holistic Integrative Medicine</span>
</div>
</a>
</header>
<!-- 主体 -->
<main class="main-content">
<!-- 左侧 banner -->
<div class="banner-section">
<div class="banner-inner">
<span class="banner-tag">2025-2030</span>
<h2 class="banner-title">协同创新 共建共享<br>整合医学发展新格局</h2>
<div class="banner-divider"></div>
<p class="banner-desc">
聚焦医学整合创新系统推进七大专项计划<br>
全面构建覆盖诊疗科研人才管理公益<br>
政学协作与组织建设的协同发展体系
</p>
</div>
</div>
<!-- 右侧登录卡片 -->
<section class="login-section">
<div class="login-card">
<h1 class="login-title">{{ loginMode === 'password' ? '账号登录' : '手机号登录' }}</h1>
<p class="login-subtitle">{{ loginMode === 'password' ? '请输入您的账号信息' : '请输入手机号接收验证码' }}</p>
<div class="login-tabs">
<span class="tab" :class="{ active: loginMode === 'password' }" @click="switchMode('password')">账号密码登录</span>
<span class="tab" :class="{ active: loginMode === 'sms' }" @click="switchMode('sms')">手机验证码登录</span>
</div>
<form v-if="loginMode === 'password'" id="loginForm" @submit.prevent="onSubmit">
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<input v-model="form.username" type="text" id="username" class="form-input" placeholder="用户名 / 手机号 / 邮箱" autocomplete="username">
</div>
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<input v-model="form.password" type="password" id="password" class="form-input" placeholder="密码" autocomplete="current-password">
</div>
<div v-if="captchaEnabled" class="captcha-group">
<div class="form-group" style="margin-bottom: 0;">
<input v-model="form.code" type="text" id="captcha" class="form-input captcha-input" placeholder="验证码" maxlength="6">
</div>
<img v-if="captchaImg" :src="captchaImg" class="captcha-image" @click="loadCaptcha" title="点击刷新" alt="验证码">
<div v-else class="captcha-image" @click="loadCaptcha" title="点击刷新">加载中</div>
</div>
<button type="submit" class="login-btn" :disabled="loading">{{ loading ? '登录中...' : '登 录' }}</button>
<div class="form-bottom-links">
<a class="bottom-link" @click="onRegister">新用户注册</a>
<a class="bottom-link" @click="onForgot">忘记密码</a>
</div>
</form>
<form v-else id="smsLoginForm" @submit.prevent="onSmsLoginSubmit">
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
<input v-model="smsForm.phone" type="text" class="form-input" placeholder="请输入手机号" maxlength="11" autocomplete="tel">
</div>
<div class="form-group sms-group">
<input v-model="smsForm.smsCode" type="text" class="form-input sms-input" placeholder="短信验证码" maxlength="6">
<button type="button" class="sms-btn" :disabled="smsSmsLocked || smsSmsCountdown > 0 || !smsForm.phone" @click="sendLoginSmsCode">
{{ smsSmsLocked ? '发送中...' : smsSmsCountdown > 0 ? `${smsSmsCountdown}s 后重试` : '获取验证码' }}
</button>
</div>
<button type="submit" class="login-btn" :disabled="smsLoginLocked">{{ smsLoginLocked ? '登录中...' : '登 录' }}</button>
<div class="form-bottom-links">
<a class="bottom-link" @click="onRegister">新用户注册</a>
<a class="bottom-link" @click="onForgot">忘记密码</a>
</div>
</form>
</div>
</section>
</main>
<!-- 底部 -->
<footer class="footer">
<p>© 北京整合医学学会 BAHIM</p>
<p>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</p>
</footer>
<!-- 注册弹窗 (原型: 选择用户类别) -->
<div class="modal-overlay" :class="{ active: showRoleModal }" @click.self="closeModal">
<div class="modal">
<h2 class="modal-title">选择用户类别</h2>
<div class="user-type-list">
<label v-for="t in registerTypes" :key="t.value" class="user-type-item">
<input type="radio" name="registerUserType" :value="t.value" v-model="registerUserType">
<div class="info">
<div class="name">{{ t.name }}</div>
<div class="desc">{{ t.desc }}</div>
</div>
</label>
</div>
<div class="modal-actions">
<button class="modal-btn secondary" type="button" @click="closeModal">取消</button>
<button class="modal-btn primary" type="button" @click="confirmRegister">确定</button>
</div>
</div>
</div>
<!-- 忘记密码弹窗 -->
<div class="modal-overlay" :class="{ active: showForgotModal }" @click.self="closeForgot">
<div class="modal">
<h2 class="modal-title">忘记密码</h2>
<p class="modal-subtitle">通过注册时填写的手机号验证后重置密码</p>
<form @submit.prevent="onResetSubmit">
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
<input v-model="forgotForm.phone" type="text" class="form-input" placeholder="请输入注册时的手机号" maxlength="11" autocomplete="tel">
</div>
<div class="form-group sms-group">
<input v-model="forgotForm.smsCode" type="text" class="form-input sms-input" placeholder="短信验证码" maxlength="6">
<button type="button" class="sms-btn" :disabled="forgotSmsLocked || forgotSmsCountdown > 0 || !forgotForm.phone" @click="sendForgotSms">
{{ forgotSmsLocked ? '发送中...' : forgotSmsCountdown > 0 ? `${forgotSmsCountdown}s 后重试` : '获取验证码' }}
</button>
</div>
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<input v-model="forgotForm.password" type="password" class="form-input" placeholder="新密码 (6-20 位)" autocomplete="new-password">
</div>
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<input v-model="forgotForm.confirmPassword" type="password" class="form-input" placeholder="确认新密码" autocomplete="new-password">
</div>
<div class="modal-actions">
<button class="modal-btn secondary" type="button" @click="closeForgot">取消</button>
<button class="modal-btn primary" type="submit" :disabled="forgotLoading">{{ forgotLoading ? '提交中...' : '重置密码' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import { login, getInfo, getCaptcha, sendLoginSms, smsLogin } from '@/api/auth'
import { useUserStore } from '@/store/user'
import { useAsyncLock } from '@/utils/useAsyncLock'
// 图形验证码刷新 + 忘记密码短信 + 短信登录 各自独立锁 (避免重复请求)
const { locked: captchaLocked, run: runCaptchaOnce } = useAsyncLock()
const { locked: forgotSmsLocked, run: runForgotSmsOnce } = useAsyncLock()
const { locked: smsSmsLocked, run: runSmsOnce } = useAsyncLock()
const { locked: smsLoginLocked, run: runSmsLoginOnce } = useAsyncLock()
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const form = reactive({ username: '', password: '', code: '', uuid: '', role: 'admin' })
const captchaImg = ref('')
const captchaEnabled = ref(true)
const loading = ref(false)
const showRoleModal = ref(false)
const registerUserType = ref('')
// 登录模式: 'password' | 'sms'
const loginMode = ref('password')
const smsForm = reactive({ phone: '', smsCode: '', uuid: '' })
const smsSmsCountdown = ref(0)
let smsSmsTimer = null
const registerTypes = [
{ value: 'expert', name: '项目参与专家', desc: '负责项目方案设计与评审' },
{ value: 'executor', name: '项目执行单位(供应商)', desc: '承担项目执行,需走入库流程' },
{ value: 'sponsor', name: '项目支持单位', desc: '注册后默认为管理员角色, 可建监察员子账号' }
]
async function loadCaptcha() {
await runCaptchaOnce(async () => {
try {
const res = await getCaptcha()
captchaEnabled.value = res?.captchaEnabled !== false
if (res?.uuid) {
form.uuid = res.uuid
captchaImg.value = 'data:image/png;base64,' + (res.img || '')
}
} catch (e) {
captchaImg.value = ''
}
})
}
async function onSubmit() {
if (!form.username || !form.password) {
ElMessage.warning('请输入用户名和密码')
return
}
if (captchaEnabled.value && !form.code) {
ElMessage.warning('请输入验证码')
return
}
loading.value = true
try {
const res = await login({ username: form.username, password: form.password, code: form.code || '', uuid: form.uuid || '' })
await afterLogin(res.token, form.username)
} catch (e) {
// 登录是 POST, 拦截器对写操作不自动 toast, 需在此显式弹 (否则锁定/密码错等 msg 看不到)
ElMessage.error(e?.msg || e?.message || '登录失败')
loadCaptcha()
} finally {
loading.value = false
}
}
/**
* 登录后置: 存 token → 调 /getInfo 拿真实 user → 跳角色首页
* 密码登录和短信登录共用
* 角色单一可信源: sys_user.role_type (由 /getInfo 返回), 不再按用户名推断
*/
async function afterLogin(token, displayName) {
userStore.setToken(token)
let role = ''
try {
const info = await getInfo()
const u = info.user || {}
role = u.roleType
if (!role) {
// /getInfo 拿不到 roleType → 用户无业务角色, 不让进系统
ElMessage.error('账号角色未配置, 请联系管理员')
return router.replace({ name: 'login' })
}
userStore.setUser({
userId: u.userId,
userName: u.userName || displayName,
nickName: u.nickName || displayName,
phonenumber: u.phonenumber || '',
accountType: u.accountType || 'MAIN',
parentUserId: u.parentUserId || null,
role
})
} catch {
// /getInfo 失败: 不存残缺 user, 让用户重新登录
ElMessage.error('获取用户信息失败, 请重新登录')
return router.replace({ name: 'login' })
}
ElMessage.success(`欢迎,${displayName}`)
// 角色不在角色首页映射里 → 拒绝
if (!roleHome[role]) return router.replace({ name: 'login' })
// 所有角色统一跳门户首页 '/' (业务方 2026-08-22 要求, 各自角色菜单从导航栏进入)
// 带 redirect 回跳 (401/守卫带过来的原页面), 但必须属于当前角色 (否则跳过去被踢回 login)
const redirect = route.query.redirect
if (redirect && redirectBelongsToRole(String(redirect), role)) {
return router.replace(String(redirect))
}
router.replace('/')
}
function switchMode(mode) {
if (loginMode.value === mode) return
loginMode.value = mode
// 切换时清空两条表单, 避免脏数据
if (mode === 'password') {
smsForm.phone = ''
smsForm.smsCode = ''
smsForm.uuid = ''
if (smsSmsTimer) { clearInterval(smsSmsTimer); smsSmsTimer = null }
smsSmsCountdown.value = 0
} else {
form.username = ''
form.password = ''
form.code = ''
// 切到短信登录时不需要图形验证码, 不再 loadCaptcha
}
}
async function sendLoginSmsCode() {
if (!/^1\d{10}$/.test(smsForm.phone)) {
return ElMessage.warning('请输入正确的手机号')
}
await runSmsOnce(async () => {
try {
const r = await sendLoginSms(smsForm.phone)
smsForm.uuid = r.data || ''
ElMessage.success('验证码已发送')
smsSmsCountdown.value = 60
smsSmsTimer = setInterval(() => {
smsSmsCountdown.value--
if (smsSmsCountdown.value <= 0) {
clearInterval(smsSmsTimer)
smsSmsTimer = null
}
}, 1000)
} catch (e) {
// 短信发送是 POST, 拦截器不自动 toast, 显式弹
ElMessage.error(e?.msg || e?.message || '验证码发送失败')
smsForm.uuid = ''
}
})
}
async function onSmsLoginSubmit() {
if (!/^1\d{10}$/.test(smsForm.phone)) return ElMessage.warning('请输入正确的手机号')
if (!smsForm.smsCode) return ElMessage.warning('请输入短信验证码')
if (!smsForm.uuid) return ElMessage.warning('请先获取验证码')
await runSmsLoginOnce(async () => {
try {
const res = await smsLogin({ phone: smsForm.phone, smsCode: smsForm.smsCode, uuid: smsForm.uuid })
// 短信登录后无 username, 用 phone 作为显示名; fallbackRole 留空, 让 /getInfo 决定
await afterLogin(res.token, smsForm.phone)
} catch (e) {
// 短信登录是 POST, 拦截器不自动 toast, 显式弹
ElMessage.error(e?.msg || e?.message || '登录失败')
}
})
}
const roleHome = {
admin: '/admin/workbench',
manager: '/manager/workbench',
doctor: '/doctor/home',
executor: '/executor/submissions',
sponsor: '/sponsor/home',
}
/** redirect 是否属于当前角色: 防止 doctor 拿到 manager 的 redirect 后跳过去被踢回 login ("现在评审专家登录后不跳转" 的根本原因) */
function redirectBelongsToRole(path, role) {
if (!path) return false
// role=doctor, path=/doctor/xxx → true
// role=doctor, path=/manager/xxx → false
return path === roleHome[role] || path.startsWith('/' + role + '/')
}
function onRegister() {
showRoleModal.value = true
registerUserType.value = ''
}
function closeModal() {
showRoleModal.value = false
}
function confirmRegister() {
if (!registerUserType.value) {
ElMessage.warning('请选择用户类别')
return
}
const routeMap = {
expert: '/register-expert',
executor: '/register-executor',
sponsor: '/register-sponsor'
}
const target = routeMap[registerUserType.value]
if (!target) {
ElMessage.warning('该类别暂未开放注册')
return
}
showRoleModal.value = false
router.push(target)
}
function onForgot() {
showForgotModal.value = true
}
// ===== 忘记密码弹窗 =====
const showForgotModal = ref(false)
const forgotLoading = ref(false)
const forgotSmsCountdown = ref(0)
let forgotSmsTimer = null
const forgotForm = reactive({ phone: '', smsCode: '', password: '', confirmPassword: '' })
let forgotSmsUuid = ''
function closeForgot() {
showForgotModal.value = false
forgotForm.phone = ''
forgotForm.smsCode = ''
forgotForm.password = ''
forgotForm.confirmPassword = ''
forgotSmsUuid = ''
if (forgotSmsTimer) { clearInterval(forgotSmsTimer); forgotSmsTimer = null }
forgotSmsCountdown.value = 0
}
async function sendForgotSms() {
if (!/^1\d{10}$/.test(forgotForm.phone)) {
return ElMessage.warning('请输入正确的手机号')
}
await runForgotSmsOnce(async () => {
try {
const r = await request({ url: '/business/auth/forgotSendSms', method: 'post', data: { phone: forgotForm.phone } })
forgotSmsUuid = r.data || ''
ElMessage.success('验证码已发送')
forgotSmsCountdown.value = 60
forgotSmsTimer = setInterval(() => {
forgotSmsCountdown.value--
if (forgotSmsCountdown.value <= 0) {
clearInterval(forgotSmsTimer)
forgotSmsTimer = null
}
}, 1000)
} catch (e) {
// 忘记密码短信是 POST, 拦截器不自动 toast, 显式弹
ElMessage.error(e?.msg || e?.message || '验证码发送失败')
forgotSmsUuid = ''
}
})
}
async function onResetSubmit() {
if (!/^1\d{10}$/.test(forgotForm.phone)) return ElMessage.warning('请输入正确的手机号')
if (!forgotForm.smsCode) return ElMessage.warning('请输入短信验证码')
if (!forgotForm.password || forgotForm.password.length < 6 || forgotForm.password.length > 20) {
return ElMessage.warning('密码长度 6-20 位')
}
if (forgotForm.password !== forgotForm.confirmPassword) {
return ElMessage.warning('两次密码输入不一致')
}
forgotLoading.value = true
try {
await request({
url: '/business/auth/resetPassword',
method: 'post',
data: {
phone: forgotForm.phone,
smsCode: forgotForm.smsCode,
uuid: forgotSmsUuid,
password: forgotForm.password,
confirmPassword: forgotForm.confirmPassword
}
})
ElMessage.success('密码重置成功, 请用新密码登录')
closeForgot()
} catch (e) {
// 重置密码是 POST, 拦截器不自动 toast, 显式弹
ElMessage.error(e?.msg || e?.message || '重置失败')
} finally {
forgotLoading.value = false
}
}
onMounted(() => { loadCaptcha() })
onUnmounted(() => {
if (forgotSmsTimer) clearInterval(forgotSmsTimer)
if (smsSmsTimer) clearInterval(smsSmsTimer)
})
</script>
<style>
/* === 原型 CSS 1:1 复制自 /home/john/ry8080/proto/html/登录.html === */
.login-page *{
margin: 0;
padding: 0;
box-sizing: border-box;
}
.login-page{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
color: #333;
background: #f5f6f8;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 顶部导航 */
.login-page .header{
height: 64px;
padding: 0 60px;
background: #ffffff;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
}
.login-page .logo{
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
text-decoration: none;
transition: opacity 0.2s;
}
.login-page .logo:hover{ opacity: 0.7; }
.login-page .logo-icon{
width: 36px;
height: 36px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.login-page .logo-icon img { width: 100%; height: 100%; object-fit: contain; display: block; }
.login-page .logo-text{
display: flex;
flex-direction: column;
line-height: 1.2;
}
.login-page .logo-title{
font-size: 16px;
font-weight: 600;
color: #1f2937;
letter-spacing: 0.5px;
}
.login-page .logo-subtitle{
font-size: 11px;
color: #6b7280;
margin-top: 2px;
letter-spacing: 0.3px;
}
/* 主体 */
.login-page .main-content{
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 60px;
gap: 60px;
}
.login-page .banner-section{
flex: 1;
max-width: 560px;
}
.login-page .banner-inner{
background: var(--brand-primary);
color: #fff;
padding: 56px 56px;
height: 440px;
display: flex;
flex-direction: column;
justify-content: center;
}
.login-page .banner-tag{
display: inline-block;
padding: 4px 12px;
border: 1px solid rgba(255, 255, 255, 0.3);
font-size: 12px;
letter-spacing: 2px;
margin-bottom: 24px;
width: fit-content;
}
.login-page .banner-title{
font-size: 30px;
font-weight: 600;
line-height: 1.4;
margin-bottom: 20px;
letter-spacing: 1px;
}
.login-page .banner-divider{
width: 48px;
height: 2px;
background: #fff;
margin-bottom: 24px;
}
.login-page .banner-desc{
font-size: 14px;
line-height: 1.9;
color: rgba(255, 255, 255, 0.85);
letter-spacing: 0.5px;
}
/* 登录卡片 */
.login-page .login-section{
width: 400px;
flex-shrink: 0;
}
.login-page .login-card{
background: #ffffff;
padding: 40px 40px;
border: 1px solid #e5e7eb;
}
.login-page .login-title{
font-size: 22px;
font-weight: 600;
color: #1f2937;
margin-bottom: 8px;
letter-spacing: 1px;
}
.login-page .login-subtitle{
font-size: 13px;
color: #6b7280;
margin-bottom: 28px;
}
/* 登录模式切换 tabs */
.login-page .login-tabs{
display: flex;
gap: 24px;
margin-bottom: 24px;
border-bottom: 1px solid #e5e7eb;
}
.login-page .login-tabs .tab{
padding: 8px 0;
font-size: 14px;
color: #6b7280;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.2s, border-color 0.2s;
user-select: none;
}
.login-page .login-tabs .tab:hover{ color: var(--brand-primary); }
.login-page .login-tabs .tab.active{
color: var(--brand-primary);
border-bottom-color: var(--brand-primary);
font-weight: 500;
}
.login-page .form-group{
margin-bottom: 18px;
position: relative;
}
.login-page .form-input{
width: 100%;
height: 44px;
padding: 0 14px 0 42px;
border: 1px solid #d1d5db;
background: #fff;
font-size: 14px;
color: #1f2937;
outline: none;
transition: border-color 0.2s;
}
.login-page .form-input:focus{
border-color: var(--brand-primary);
}
.login-page .form-input::placeholder{
color: #9ca3af;
}
.login-page .input-icon{
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: #9ca3af;
pointer-events: none;
}
.login-page .captcha-group{
display: flex;
gap: 10px;
}
.login-page .captcha-group .form-group{
flex: 1;
margin-bottom: 0;
}
.login-page .captcha-input{
padding-left: 14px;
}
.login-page .captcha-input + .input-icon{
display: none;
}
.login-page .captcha-image{
width: 110px;
height: 44px;
border: 1px solid #d1d5db;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 600;
letter-spacing: 4px;
color: var(--brand-primary);
cursor: pointer;
user-select: none;
font-family: 'Courier New', monospace;
}
.login-page .form-bottom-links{
display: flex;
justify-content: space-between;
margin-top: 14px;
font-size: 13px;
}
.login-page .form-bottom-links .bottom-link{
color: var(--brand-primary);
cursor: pointer;
}
.login-page .form-bottom-links .bottom-link:hover{
text-decoration: underline;
}
.login-page .login-btn{
width: 100%;
height: 44px;
background: var(--brand-primary);
border: none;
color: #fff;
font-size: 15px;
font-weight: 500;
letter-spacing: 4px;
cursor: pointer;
margin-top: 8px;
transition: background 0.2s;
}
.login-page .login-btn:hover{
background: var(--brand-primary-deep);
}
/* 底部 */
.login-page .footer{
padding: 20px 60px;
border-top: 1px solid #e5e7eb;
background: #fff;
text-align: center;
color: #6b7280;
font-size: 12px;
line-height: 1.8;
}
.login-page .footer p{ margin-bottom: 2px; }
/* 弹窗 */
.login-page .modal-overlay{
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
}
.login-page .modal-overlay.active{ display: flex; }
.login-page .modal{
background: #fff;
padding: 32px 36px;
width: 440px;
max-width: 90vw;
border: 1px solid #e5e7eb;
}
.login-page .modal-title{
font-size: 18px;
font-weight: 600;
color: #1f2937;
text-align: center;
margin-bottom: 24px;
letter-spacing: 1px;
}
.login-page .user-type-list{
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 24px;
}
.login-page .user-type-item{
display: flex;
align-items: center;
padding: 14px 18px;
border: 1px solid #e5e7eb;
cursor: pointer;
transition: all 0.2s;
}
.login-page .user-type-item:hover{
border-color: var(--brand-primary);
background: #f9fafb;
}
.login-page .user-type-item input[type="radio"]{
margin-right: 12px;
cursor: pointer;
accent-color: var(--brand-primary);
}
.login-page .user-type-item .info{ flex: 1; }
.login-page .user-type-item .name{
font-size: 14px;
font-weight: 500;
color: #1f2937;
margin-bottom: 2px;
}
.login-page .user-type-item .desc{
font-size: 12px;
color: #6b7280;
}
.login-page .modal-actions{
display: flex;
gap: 12px;
}
.login-page .modal-btn{
flex: 1;
height: 40px;
font-size: 14px;
cursor: pointer;
border: none;
}
.login-page .modal-btn.primary{
background: var(--brand-primary);
color: #fff;
}
.login-page .modal-btn.primary:hover{ background: var(--brand-primary-deep); }
.login-page .modal-btn.secondary{
background: #fff;
color: #4b5563;
border: 1px solid #d1d5db;
}
.login-page .modal-btn.secondary:hover{
border-color: var(--brand-primary);
color: var(--brand-primary);
}
/* 响应式 */
@media (min-width: 769px) and (max-width: 1024px) {
.main-content {
flex-direction: column;
padding: 30px 30px;
gap: 30px;
}
.banner-section { width: 100%; max-width: 100%; }
.banner-inner { height: auto; padding: 40px 30px; }
.login-section { width: 100%; max-width: 400px; }
}
@media (max-width: 640px) {
.header { padding: 0 20px; }
.main-content { padding: 20px; }
.login-card { padding: 30px 24px; }
.footer { padding: 16px 20px; }
}
/* === 用户要求: 左侧保持 440px 不动, 右侧登录卡片高度追平左侧, 两栏在 main-content 中居中 === */
.login-page .main-content { align-items: center; }
.login-page .login-card { height: 440px; display: flex; flex-direction: column; justify-content: center; }
/* === 注册选择类别弹窗 (原型 1:1 抄 /home/john/ry8080/proto/html/登录.html) === */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
opacity: 0; visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
}
.modal-overlay.active { opacity: 1; visibility: visible; }
.modal {
background: #fff;
padding: 32px 36px;
width: 440px;
max-width: 90vw;
border: 1px solid #e5e7eb;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
}
.modal-title { font-size: 18px; font-weight: 600; color: #1a1a1a; margin-bottom: 20px; text-align: center; }
.user-type-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 24px; }
.user-type-item {
display: flex; align-items: center; gap: 12px;
padding: 14px 18px;
border: 1px solid #e5e7eb;
cursor: pointer;
transition: all 0.2s;
}
.user-type-item:hover { border-color: var(--brand-primary); background: #f7faff; }
.user-type-item input[type="radio"] {
width: 18px; height: 18px;
accent-color: var(--brand-primary);
cursor: pointer;
flex-shrink: 0;
}
.user-type-item .info { flex: 1; }
.user-type-item .name { font-size: 15px; color: #1a1a1a; font-weight: 500; margin-bottom: 2px; }
.user-type-item .desc { font-size: 12px; color: #909399; }
.modal-actions { display: flex; gap: 12px; }
.modal-btn {
flex: 1; height: 40px;
border: none; cursor: pointer;
font-size: 14px; font-weight: 500;
transition: all 0.2s;
}
.modal-btn.secondary { background: #f5f7fa; color: #606266; }
.modal-btn.secondary:hover { background: #ebeef5; }
.modal-btn.primary { background: var(--brand-primary); color: #fff; }
.modal-btn.primary:hover { background: var(--brand-primary-deep); }
/* ===== 忘记密码弹窗 ===== */
.modal-subtitle {
font-size: 13px;
color: #6b7280;
text-align: center;
margin: -12px 0 20px;
}
.sms-group {
display: flex;
gap: 10px;
margin-bottom: 18px;
}
.sms-group .sms-input {
flex: 1;
padding-left: 14px;
}
.sms-btn {
flex-shrink: 0;
width: 110px;
height: 44px;
border: 1px solid #d1d5db;
background: #f9fafb;
color: var(--brand-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.sms-btn:hover:not(:disabled) {
border-color: var(--brand-primary);
background: #f3f4f6;
}
.sms-btn:disabled {
color: #9ca3af;
background: #f3f4f6;
cursor: not-allowed;
}
/* mobile 端隐藏 banner + login-card 占满 (移到文件末尾, CSS 优先级最高, 覆盖上面 1024px/640px 块) */
@media (max-width: 768px) {
.login-page .main-content { flex-direction: column !important; padding: 20px !important; gap: 0 !important; }
.login-page .banner-section { display: none !important; }
.login-page .login-section { width: 100% !important; max-width: 100% !important; padding: 0 !important; flex: 0 0 auto !important; }
.login-page .login-card { width: calc(100% - 10px) !important; max-width: none !important; margin: 0 auto !important; height: auto !important; min-height: 0 !important; padding: 24px 20px !important; }
}
</style>