Files
jczxw-pc/app/pages/login.vue
赵忠林 28dc2be2e1 feat(agreement): 更新注册协议与隐私政策内容
- 将注册协议标题及内容调整为“用户注册协议”并替换为广西决策咨询网相关内容
- 新增服务内容详细介绍,涵盖政策要闻、决策咨询、专家资讯等核心服务
- 隐私政策中更新平台名称及收集信息内容,增加申请材料和建言内容
- 登录页品牌文案和配色全面更新,采用蓝色主题并匹配新品牌形象
- 登录页和注册协议页面相关文案同步调整为广西决策咨询网风格
- 完善后台管理页面及前台多个页面适配,统一为决策咨询网专用配置
2026-04-26 01:56:00 +08:00

983 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">{{ $t('login.welcomeBack') }}</h2>
<p class="form-subtitle">{{ $t('login.loginToContinue') }}</p>
</div>
<!-- 切换标签 -->
<div class="login-tabs">
<button
class="login-tab"
:class="{ active: loginType === 'scan' }"
@click="setLoginType('scan')"
>
<QrcodeOutlined />
{{ $t('login.scanLogin') }}
</button>
<button
class="login-tab"
:class="{ active: loginType === 'sms' }"
@click="setLoginType('sms')"
>
<MobileOutlined />
{{ $t('login.phoneLogin') }}
</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="$t('login.phonePlaceholder')"
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="$t('login.smsCodePlaceholder')"
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">{{ $t('login.sendCode') }}</span>
<span v-else>{{ countdown }}{{ $t('login.resendAfter') }}</span>
</button>
</div>
</a-form-item>
<!-- 注册协议和隐私政策 -->
<div class="agreement-row">
<a-checkbox v-model:checked="form.agreement">
<span class="agreement-text">
{{ $t('login.agreeTerms') }}
<NuxtLink to="/agreement" target="_blank" class="agreement-link" @click.stop>{{ $t('login.registerAgreement') }}</NuxtLink>
{{ $t('common.and') || '' }}
<NuxtLink to="/privacy" target="_blank" class="agreement-link" @click.stop>{{ $t('login.privacyPolicy') }}</NuxtLink>
</span>
</a-checkbox>
</div>
<a-form-item>
<a-button
block
size="large"
type="primary"
:loading="loading"
class="submit-btn"
@click="submitSms"
>
{{ loading ? $t('login.loggingIn') : $t('login.loginNow') }}
</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' ? $t('login.switchToPhone') : $t('login.switchToScan') }}
</button>
</div>
</div>
</div>
<!-- 图形验证码弹窗发送短信用 -->
<a-modal v-model:open="imgCodeModalOpen" :width="360" :footer="null" :title="$t('login.securityVerify')">
<p class="modal-tip">{{ $t('login.completeVerifyFirst') }}</p>
<div class="captcha-row modal-captcha">
<a-input
v-model:value="imgCode"
size="large"
allow-clear
:maxlength="5"
:placeholder="$t('login.enterImgCode')"
@press-enter="sendSmsCode"
/>
<button class="captcha-img-btn" @click.prevent="changeCaptcha" :title="$t('login.clickRefresh')">
<img alt="captcha" :src="captcha" />
</button>
</div>
<a-button block size="large" type="primary" :loading="sendingSms" class="submit-btn" @click="sendSmsCode">
{{ $t('login.sendCode') }}
</a-button>
</a-modal>
<!-- 选择账号弹窗 -->
<a-modal v-model:open="selectUserOpen" :width="520" :footer="null" :title="$t('login.selectAccount')">
<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="`${$t('login.tenantId')}: ${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'
const { t } = useI18n()
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: t('login.enterPhone'), type: 'string' },
{ pattern: phoneReg, message: t('login.phoneFormatError'), trigger: 'blur' }
],
smsCode: [{ required: true, message: t('login.enterSmsCode'), 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 : t('login.loginFailed'))
}
}
function openImgCodeModal() {
if (!form.phone) return message.error(t('login.enterPhone'))
imgCode.value = ''
changeCaptcha()
imgCodeModalOpen.value = true
}
async function sendSmsCode() {
if (!imgCode.value) return message.error(t('login.enterImgCode'))
if (captchaText.value && imgCode.value.toLowerCase() !== captchaText.value.toLowerCase()) {
return message.error(t('login.imgCodeIncorrect'))
}
sendingSms.value = true
try {
await sendSmsCaptcha({ phone: form.phone })
message.success(t('login.smsSentSuccess'))
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 : t('login.sendFailed'))
} 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(t('login.agreeRequired'))
}
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 || t('login.loginSuccess'))
await ensureUserIdPersisted()
await goAfterLogin()
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : t('login.loginFailed'))
} 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(t('login.scanLoginSuccess'))
await goAfterLogin()
}
function onQrLoginError(error: string) {
message.error(error || t('login.scanLoginFailed'))
}
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>