初始化2
This commit is contained in:
974
app/pages/login.vue
Normal file
974
app/pages/login.vue
Normal file
@@ -0,0 +1,974 @@
|
||||
<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">
|
||||
<img src="/logo.png" class="brand-logo-img" alt="Websopy" />
|
||||
<div class="brand-site-name">websopy</div>
|
||||
</div>
|
||||
|
||||
<!-- 中央文案 -->
|
||||
<div class="hero-text">
|
||||
<div class="hero-tag">{{ $t('login.aiPlatform') }}</div>
|
||||
<h1 class="hero-title">{{ config?.loginTitle || $t('login.buildNextGen') }}</h1>
|
||||
<p class="hero-desc">{{ $t('login.lowcodeAccess') }}<br />{{ $t('login.fromIdeaToOnline') }}</p>
|
||||
<div class="hero-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-num">10000+</span>
|
||||
<span class="stat-label">{{ $t('login.developers') }}</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-num">500+</span>
|
||||
<span class="stat-label">{{ $t('login.aiApps') }}</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-num">99.9%</span>
|
||||
<span class="stat-label">{{ $t('login.uptime') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部 -->
|
||||
<div class="left-footer">
|
||||
<span>{{ $t('login.copyright') }}</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 || $t('login.aiAppPlatform') }}</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, #0f0c29 0%, #1a1a4e 30%, #24243e 60%, #302b63 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(10, 10, 40, 0.75) 0%, rgba(30, 20, 80, 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(99, 102, 241, 0.22) 0%, transparent 65%);
|
||||
top: -120px;
|
||||
right: -80px;
|
||||
}
|
||||
.dot-2 {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
background: radial-gradient(circle, rgba(167, 139, 250, 0.18) 0%, transparent 65%);
|
||||
bottom: 60px;
|
||||
left: -60px;
|
||||
}
|
||||
.dot-3 {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: radial-gradient(circle, rgba(236, 72, 153, 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;
|
||||
}
|
||||
/* 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%, #a5c8ff 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(99, 102, 241, 0.2);
|
||||
border: 1px solid rgba(99, 102, 241, 0.35);
|
||||
color: #a5b4fc;
|
||||
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: #6366f1;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 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 #6366f1;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #6366f1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
.sms-btn:hover:not(.disabled) {
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
}
|
||||
.sms-btn.disabled {
|
||||
border-color: #e0e0e0;
|
||||
color: #bfbfbf;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 手机号前缀 +86(prefix 模式) */
|
||||
.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, #6366f1 0%, #8b5cf6 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: #6366f1;
|
||||
color: #6366f1;
|
||||
background: rgba(99, 102, 241, 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>
|
||||
Reference in New Issue
Block a user