Files
jczxw-pc/app/pages/login.vue
赵忠林 55f1cded6b refactor(i18n): 移除多语言支持及相关组件,切换登录页文本为中文
- 删除英文和中文语言文件,去除国际化配置
- 移除语言切换组件 LangSwitch.vue
- 登录页中静态替换多语言文本为中文文本
- 站点头部登录、退出等文案替换为中文
- 更新 Nuxt 配置,移除 i18n 模块和相关配置
- 保持核心功能不变,只保留中文语言显示
2026-04-26 02:39:43 +08:00

981 lines
24 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">
<!-- 左侧品牌展示区 -->
<div class="login-left" :style="bgStyle">
<div class="left-overlay" />
<!-- 装饰网格线 -->
<div class="left-grid" />
<!-- 浮动装饰圆点 -->
<div class="dot dot-1" />
<div class="dot dot-2" />
<div class="dot dot-3" />
<div class="left-content">
<!-- 品牌 logo -->
<div class="brand">
<div class="brand-logo-text">决策咨询网</div>
<div class="brand-site-name">GX Decision Consulting</div>
</div>
<!-- 中央文案 -->
<div class="hero-text">
<div class="hero-tag">广西决策咨询中心</div>
<h1 class="hero-title">汇聚专家智慧<br>服务政府决策</h1>
<p class="hero-desc">广西决策咨询网是自治区党委政府决策咨询服务的重要平台<br>汇聚各领域专家学者提供权威决策咨询服务</p>
<div class="hero-stats">
<div class="stat-item">
<span class="stat-num">200+</span>
<span class="stat-label">认证专家</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-num">500+</span>
<span class="stat-label">会员单位</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-num">1000+</span>
<span class="stat-label">建言献策</span>
</div>
</div>
</div>
<!-- 底部 -->
<div class="left-footer">
<span>© {{ new Date().getFullYear() }} 广西决策咨询中心 保留所有权利</span>
</div>
</div>
</div>
<!-- 右侧登录表单区 -->
<div class="login-right">
<div class="form-wrapper">
<!-- 移动端 logo仅小屏显示 -->
<div class="mobile-brand">
<img :src="config?.sysLogo || defaultLogo" class="mobile-logo" alt="logo" />
<span class="mobile-brand-name">{{ config?.siteName || '广西决策咨询网' }}</span>
</div>
<!-- 登录卡片头部 -->
<div class="form-header">
<h2 class="form-title">{{ '欢迎回来' }}</h2>
<p class="form-subtitle">{{ '请登录您的账号以继续' }}</p>
</div>
<!-- 切换标签 -->
<div class="login-tabs">
<button
class="login-tab"
:class="{ active: loginType === 'scan' }"
@click="setLoginType('scan')"
>
<QrcodeOutlined />
{{ '扫码登录' }}
</button>
<button
class="login-tab"
:class="{ active: loginType === 'sms' }"
@click="setLoginType('sms')"
>
<MobileOutlined />
{{ '手机号登录' }}
</button>
</div>
<!-- 表单区域 -->
<a-form ref="formRef" :model="form" :rules="rules" class="login-form">
<!-- 手机号登录 -->
<template v-if="loginType === 'sms'">
<a-form-item name="phone">
<a-input
v-model:value="form.phone"
size="large"
allow-clear
:maxlength="11"
:placeholder="'请输入手机号码'"
class="form-input"
>
<template #prefix>
<span class="phone-prefix">+86</span>
<span class="phone-prefix-divider" />
</template>
</a-input>
</a-form-item>
<a-form-item name="smsCode">
<div class="captcha-row">
<a-input
v-model:value="form.smsCode"
size="large"
allow-clear
:maxlength="6"
:placeholder="'请输入验证码'"
class="form-input"
@press-enter="submitSms"
/>
<button
class="sms-btn"
:disabled="countdown > 0"
:class="{ disabled: countdown > 0 }"
@click.prevent="openImgCodeModal"
>
<span v-if="countdown <= 0">{{ '发送验证码' }}</span>
<span v-else>{{ countdown }}{{ 's 后重发' }}</span>
</button>
</div>
</a-form-item>
<!-- 注册协议和隐私政策 -->
<div class="agreement-row">
<a-checkbox v-model:checked="form.agreement">
<span class="agreement-text">
{{ '我已阅读并同意' }}
<NuxtLink to="/agreement" target="_blank" class="agreement-link" @click.stop>{{ '注册协议' }}</NuxtLink>
{{ '' || '' }}
<NuxtLink to="/privacy" target="_blank" class="agreement-link" @click.stop>{{ '隐私政策' }}</NuxtLink>
</span>
</a-checkbox>
</div>
<a-form-item>
<a-button
block
size="large"
type="primary"
:loading="loading"
class="submit-btn"
@click="submitSms"
>
{{ loading ? '登录中…' : '立即登录' }}
</a-button>
</a-form-item>
</template>
<!-- 扫码登录 -->
<template v-else>
<QrLogin @login-success="onQrLoginSuccess" @login-error="onQrLoginError" />
</template>
</a-form>
<!-- 底部切换扫码 -->
<div class="form-footer">
<button class="switch-scan-btn" @click="toggleScan">
<QrcodeOutlined v-if="loginType !== 'scan'" />
<MobileOutlined v-else />
{{ loginType === 'scan' ? '切换到手机号登录' : '切换到扫码登录' }}
</button>
</div>
</div>
</div>
<!-- 图形验证码弹窗发送短信用 -->
<a-modal v-model:open="imgCodeModalOpen" :width="360" :footer="null" :title="'安全验证'">
<p class="modal-tip">{{ '请先完成图形验证码验证' }}</p>
<div class="captcha-row modal-captcha">
<a-input
v-model:value="imgCode"
size="large"
allow-clear
:maxlength="5"
:placeholder="'请输入图形验证码'"
@press-enter="sendSmsCode"
/>
<button class="captcha-img-btn" @click.prevent="changeCaptcha" :title="'点击刷新'">
<img alt="captcha" :src="captcha" />
</button>
</div>
<a-button block size="large" type="primary" :loading="sendingSms" class="submit-btn" @click="sendSmsCode">
{{ '发送验证码' }}
</a-button>
</a-modal>
<!-- 选择账号弹窗 -->
<a-modal v-model:open="selectUserOpen" :width="520" :footer="null" :title="'选择账号登录'">
<a-list item-layout="horizontal" :data-source="admins">
<template #renderItem="{ item }">
<a-list-item class="list-item" @click="selectUser(item)">
<a-list-item-meta :description="`${'租户ID'}: ${item.tenantId}`">
<template #title>{{ item.tenantName || item.username }}</template>
<template #avatar>
<a-avatar :src="item.avatar" />
</template>
</a-list-item-meta>
<template #actions><RightOutlined /></template>
</a-list-item>
</template>
</a-list>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { message, type FormInstance } from 'ant-design-vue'
import {
MobileOutlined,
QrcodeOutlined,
RightOutlined
} from '@ant-design/icons-vue'
import QrLogin from '@/components/QrLogin.vue'
import { configWebsiteField, type Config } from '@/api/cms/cmsWebsiteField'
import { loginBySms, sendSmsCaptcha, getCaptcha } from '@/api/passport/login'
import type { LoginParam } from '@/api/passport/login/model'
import { listAdminsByPhoneAll } from '@/api/system/user'
import type { User } from '@/api/system/user/model'
import { getUserInfo } from '@/api/layout'
import { TEMPLATE_ID } from '@/config/setting'
import { setToken } from '@/utils/token-util'
import type { QrCodeStatusResponse } from '@/api/passport/qrLogin'
definePageMeta({ layout: false })
const route = useRoute()
const defaultLogo = 'https://oss.wsdns.cn/20240822/0252ad4ed46449cdafe12f8d3d96c2ea.svg'
const config = ref<Config>()
const loading = ref(false)
const loginType = ref<'scan' | 'sms'>('scan')
const captcha = ref('')
const captchaText = ref('')
const imgCodeModalOpen = ref(false)
const imgCode = ref('')
const sendingSms = ref(false)
const countdown = ref(0)
let countdownTimer: ReturnType<typeof setInterval> | null = null
const selectUserOpen = ref(false)
const admins = ref<User[]>([])
const formRef = ref<FormInstance>()
const form = reactive<LoginParam & { smsCode?: string; agreement?: boolean }>({
phone: '',
smsCode: '',
remember: true,
agreement: false
})
const phoneReg = /^1[3-9]\d{9}$/
const rules = reactive({
phone: [
{ required: true, message: '请输入手机号码', type: 'string' },
{ pattern: phoneReg, message: '手机号格式不正确', trigger: 'blur' }
],
smsCode: [{ required: true, message: '请输入短信验证码', type: 'string' }]
})
const bgStyle = computed(() => {
const bg = config.value?.loginBgImg
if (!bg) return {}
return { backgroundImage: `url(${bg})` }
})
function setLoginType(type: 'scan' | 'sms') {
loginType.value = type
}
function toggleScan() {
loginType.value = loginType.value === 'scan' ? 'sms' : 'scan'
}
function stopCountdown() {
if (countdownTimer) clearInterval(countdownTimer)
countdownTimer = null
countdown.value = 0
}
async function changeCaptcha() {
try {
const data = await getCaptcha()
captcha.value = data.base64
captchaText.value = data.text
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '登录失败')
}
}
function openImgCodeModal() {
if (!form.phone) return message.error('请输入手机号码')
imgCode.value = ''
changeCaptcha()
imgCodeModalOpen.value = true
}
async function sendSmsCode() {
if (!imgCode.value) return message.error('请输入图形验证码')
if (captchaText.value && imgCode.value.toLowerCase() !== captchaText.value.toLowerCase()) {
return message.error('图形验证码不正确')
}
sendingSms.value = true
try {
await sendSmsCaptcha({ phone: form.phone })
message.success('短信验证码发送成功,请注意查收')
imgCodeModalOpen.value = false
countdown.value = 30
stopCountdown()
countdown.value = 30
countdownTimer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0) stopCountdown()
}, 1000)
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '发送失败')
} finally {
sendingSms.value = false
}
}
async function goAfterLogin() {
const from = typeof route.query.from === 'string' ? route.query.from : ''
await navigateTo(from || '/')
}
function persistUserId(userId: unknown) {
if (!import.meta.client) return
const n = typeof userId === 'number' ? userId : Number(userId)
if (Number.isFinite(n) && n > 0) localStorage.setItem('UserId', String(n))
}
async function ensureUserIdPersisted(seed?: unknown) {
if (!import.meta.client) return
persistUserId(seed)
try {
if (localStorage.getItem('UserId')) return
} catch {
// ignore
}
try {
const me = await getUserInfo()
persistUserId(me.userId)
} catch {
// ignore (don't block login redirect)
}
}
async function submitSms() {
if (!formRef.value) return
// 校验协议勾选
if (!form.agreement) {
return message.error('请先阅读并同意《注册协议》和《隐私政策》')
}
loading.value = true
try {
await formRef.value.validate()
const msg = await loginBySms({
phone: form.phone,
code: String(form.smsCode || '').toLowerCase(),
tenantId: form.tenantId,
remember: !!form.remember
})
if (msg === '请选择登录用户') {
selectUserOpen.value = true
admins.value = await listAdminsByPhoneAll({
phone: form.phone,
templateId: Number(TEMPLATE_ID)
})
return
}
message.success(msg || '登录成功')
await ensureUserIdPersisted()
await goAfterLogin()
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '登录失败')
} finally {
loading.value = false
}
}
async function selectUser(user: User) {
form.tenantId = user.tenantId
selectUserOpen.value = false
await submitSms()
}
async function onQrLoginSuccess(payload: QrCodeStatusResponse) {
const accessToken = payload.accessToken || payload.access_token
if (accessToken) setToken(String(accessToken), true)
if (payload.tenantId && import.meta.client) localStorage.setItem('TenantId', String(payload.tenantId))
const seedUserId =
typeof payload.userInfo === 'object' && payload.userInfo && 'userId' in payload.userInfo
? (payload.userInfo as { userId?: unknown }).userId
: undefined
await ensureUserIdPersisted(seedUserId)
message.success('扫码登录成功')
await goAfterLogin()
}
function onQrLoginError(error: string) {
message.error(error || '扫码登录失败')
}
onMounted(async () => {
try {
config.value = await configWebsiteField({ lang: 'zh-CN' })
} catch {
// ignore config errors
}
changeCaptcha()
if (typeof route.query.loginPhone === 'string') {
form.phone = route.query.loginPhone
loginType.value = 'sms'
}
})
onUnmounted(() => {
stopCountdown()
})
</script>
<style scoped>
/* ===== 整体布局 ===== */
.login-page {
display: flex;
min-height: 100vh;
background: #f7f8fa;
}
/* ===== 左侧品牌区 ===== */
.login-left {
flex: 1;
position: relative;
background: linear-gradient(150deg, #0d1b2a 0%, #1e3a5f 30%, #2563eb 60%, #1e3a5f 100%);
background-size: cover;
background-position: center;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 100vh;
}
.left-overlay {
position: absolute;
inset: 0;
background: linear-gradient(150deg, rgba(13, 27, 42, 0.75) 0%, rgba(30, 58, 95, 0.55) 100%);
}
/* 装饰网格 */
.left-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px);
background-size: 48px 48px;
pointer-events: none;
}
/* 浮动装饰圆点 */
.dot {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.dot-1 {
width: 420px;
height: 420px;
background: radial-gradient(circle, rgba(37, 99, 235, 0.22) 0%, transparent 65%);
top: -120px;
right: -80px;
}
.dot-2 {
width: 320px;
height: 320px;
background: radial-gradient(circle, rgba(99, 179, 237, 0.18) 0%, transparent 65%);
bottom: 60px;
left: -60px;
}
.dot-3 {
width: 180px;
height: 180px;
background: radial-gradient(circle, rgba(249, 115, 22, 0.14) 0%, transparent 65%);
top: 45%;
left: 55%;
}
/* 光晕大圆 */
.login-left::before {
content: '';
position: absolute;
width: 560px;
height: 560px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.06);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.login-left::after {
content: '';
position: absolute;
width: 380px;
height: 380px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.08);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.left-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
height: 100%;
min-height: 100vh;
padding: 48px 56px;
}
/* 品牌区 */
.brand {
display: flex;
align-items: center;
gap: 0;
}
/* 品牌 logo 图片 */
.brand-logo-img {
height: 22px;
width: auto;
object-fit: contain;
}
/* 品牌文字 logo */
.brand-logo-text {
font-size: 22px;
font-weight: 800;
color: #fff;
letter-spacing: 0.04em;
text-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
/* site-name 渐变文字,与导航栏保持一致 */
.brand-site-name {
color: #fff;
font-family: 'Alimama FangYuanTi VF,sans-serif', sans-serif;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
line-height: 1;
background: linear-gradient(135deg, #ffffff 0%, #93c5fd 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-left: 8px;
}
/* Hero 文案 */
.hero-text {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 48px 0 36px;
}
.hero-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
border-radius: 20px;
background: rgba(37, 99, 235, 0.2);
border: 1px solid rgba(37, 99, 235, 0.35);
color: #93c5fd;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.5px;
margin-bottom: 24px;
width: fit-content;
}
.hero-title {
font-size: 46px;
font-weight: 800;
color: #fff;
line-height: 1.18;
margin-bottom: 22px;
letter-spacing: -1px;
white-space: pre-line;
}
.hero-desc {
font-size: 15px;
color: rgba(255, 255, 255, 0.55);
margin-bottom: 44px;
line-height: 1.8;
}
/* 统计数字 */
.hero-stats {
display: flex;
align-items: center;
gap: 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
padding: 20px 28px;
backdrop-filter: blur(8px);
width: fit-content;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 0 28px;
}
.stat-item:first-child {
padding-left: 0;
}
.stat-item:last-child {
padding-right: 0;
}
.stat-num {
font-size: 22px;
font-weight: 800;
color: #fff;
letter-spacing: -0.5px;
line-height: 1;
}
.stat-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
}
.stat-divider {
width: 1px;
height: 36px;
background: rgba(255, 255, 255, 0.12);
}
.left-footer {
color: rgba(255, 255, 255, 0.25);
font-size: 12px;
}
/* ===== 右侧表单区 ===== */
.login-right {
width: 65%;
min-width: 380px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
padding: 64px 48px;
box-shadow: -1px 0 0 0 #f0f0f0;
}
.form-wrapper {
width: 100%;
max-width: 400px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
padding: 40px;
}
/* 移动端品牌 */
.mobile-brand {
display: none;
align-items: center;
gap: 10px;
margin-bottom: 40px;
}
.mobile-logo {
width: 34px;
height: 34px;
border-radius: 8px;
}
.mobile-brand-name {
font-size: 18px;
font-weight: 700;
color: #111;
}
/* 表单头部 */
.form-header {
margin-bottom: 36px;
}
.form-title {
font-size: 28px;
font-weight: 800;
color: #0d0d0d;
margin: 0 0 10px;
letter-spacing: -0.5px;
}
.form-subtitle {
font-size: 14px;
color: #8c8c8c;
margin: 0;
line-height: 1.5;
}
/* 切换 Tab */
.login-tabs {
display: flex;
gap: 0;
margin-bottom: 32px;
border-bottom: 1.5px solid #f0f0f0;
}
.login-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 0 12px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
font-size: 14px;
font-weight: 500;
color: #a0a0a0;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
margin-bottom: -1.5px;
}
.login-tab.active {
color: #0d0d0d;
border-bottom-color: #6366f1;
font-weight: 600;
}
.login-tab:hover:not(.active) {
color: #555;
}
/* 表单输入框 */
.login-form :deep(.ant-input-affix-wrapper),
.login-form :deep(.ant-input),
.login-form :deep(.ant-input-password) {
border-radius: 10px;
border-color: #ebebeb;
background: #fafafa;
transition: all 0.2s;
padding-top: 6px;
padding-bottom: 6px;
align-items: center;
}
.login-form :deep(.ant-input-affix-wrapper:focus),
.login-form :deep(.ant-input-affix-wrapper-focused) {
border-color: #2563eb;
background: #fff;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.login-form :deep(.ant-form-item) {
margin-bottom: 18px;
}
.input-icon {
color: #bfbfbf;
font-size: 15px;
}
/* 验证码行 */
.captcha-row {
display: flex;
gap: 10px;
align-items: center;
}
.captcha-row :deep(.ant-input-affix-wrapper),
.captcha-row :deep(.ant-input) {
flex: 1;
min-width: 0;
border-radius: 10px !important;
}
/* 图形验证码按钮 */
.captcha-img-btn {
width: 148px;
height: 48px;
flex-shrink: 0;
border: 1px solid #ebebeb;
border-radius: 10px;
overflow: hidden;
background: #fafafa;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.2s;
}
.captcha-img-btn:hover {
border-color: #6366f1;
}
.captcha-img-btn img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.captcha-placeholder {
font-size: 12px;
color: #bfbfbf;
}
/* 发送验证码按钮 */
.sms-btn {
flex-shrink: 0;
padding: 0 18px;
height: 48px;
border: 1px solid #2563eb;
border-radius: 10px;
background: transparent;
color: #2563eb;
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
outline: none;
}
.sms-btn:hover:not(.disabled) {
background: #2563eb;
color: #fff;
}
.sms-btn.disabled {
border-color: #e0e0e0;
color: #bfbfbf;
cursor: not-allowed;
}
/* 手机号前缀 +86prefix 模式) */
.phone-prefix {
font-size: 14px;
color: #555;
font-weight: 500;
margin-right: 2px;
}
.phone-prefix-divider {
display: inline-block;
width: 1px;
height: 14px;
background: #d9d9d9;
margin-left: 8px;
vertical-align: middle;
}
/* 记住登录 */
.form-options {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
margin-top: -4px;
}
/* 提交按钮 */
.submit-btn.ant-btn-primary {
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%) !important;
border: none !important;
border-radius: 10px !important;
height: 48px !important;
font-size: 15px !important;
font-weight: 600 !important;
letter-spacing: 0.3px !important;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.28) !important;
transition: all 0.22s !important;
}
.submit-btn.ant-btn-primary:hover:not(:disabled) {
transform: translateY(-1px) !important;
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.38) !important;
}
/* 扫码切换 */
.form-footer {
margin-top: 28px;
text-align: center;
}
.switch-scan-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
border: 1px solid #ebebeb;
border-radius: 20px;
background: transparent;
color: #8c8c8c;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
outline: none;
}
.switch-scan-btn:hover {
border-color: #2563eb;
color: #2563eb;
background: rgba(37, 99, 235, 0.04);
}
/* 弹窗 */
.modal-tip {
font-size: 13px;
color: #8c8c8c;
margin-bottom: 16px;
}
.modal-captcha {
margin-bottom: 16px;
}
/* 账号列表 */
.list-item {
cursor: pointer;
transition: background 0.15s;
padding: 8px 12px;
border-radius: 8px;
}
.list-item:hover {
background: #f5f5f5;
}
/* 注册协议和隐私政策勾选 */
.agreement-row {
margin-bottom: 20px;
line-height: 1.6;
}
.agreement-row :deep(.ant-checkbox-wrapper) {
font-size: 13px;
color: #666;
align-items: flex-start;
}
.agreement-row :deep(.ant-checkbox) {
margin-top: 2px;
}
.agreement-text {
font-size: 13px;
color: #666;
line-height: 1.6;
}
.agreement-link {
color: #6366f1;
text-decoration: none;
font-size: 13px;
}
.agreement-link:hover {
text-decoration: underline;
}
/* ===== 响应式 ===== */
@media (max-width: 900px) {
.login-left {
display: none;
}
.login-right {
width: 100%;
min-width: unset;
padding: 48px 32px;
min-height: 100vh;
box-shadow: none;
}
.mobile-brand {
display: flex;
}
.form-wrapper {
max-width: 400px;
margin: 0 auto;
}
}
</style>