| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143 |
- <template>
- <div class="login-container">
- <div class="login-bg-animated" />
- <div class="login-card">
- <!-- Left side - Branding -->
- <div class="login-brand">
- <div class="brand-content">
- <div class="logo-lockup">
- <img :src="brandLogoUrl" class="logo-img" alt="Logo" @error="handleBrandLogoError">
- <div class="logo-text">
- ForgeAdmin
- </div>
- </div>
- <div class="banner">
- <n-carousel
- autoplay
- :interval="4200"
- show-arrow
- class="stack-carousel"
- >
- <div class="carousel-item">
- <div class="carousel-copy">
- <div class="carousel-title">
- 开箱即用的后台模板
- </div>
- <div class="carousel-subtitle">
- 常见列表、表单、详情和权限场景快速落地
- </div>
- </div>
- <img :src="loginCarouselImage" class="carousel-image" alt="后台系统能力总览">
- </div>
- <div class="carousel-item">
- <div class="carousel-copy">
- <div class="carousel-title">
- 业务对象驱动生成
- </div>
- <div class="carousel-subtitle">
- 字段、操作、列表和表单配置保持一致
- </div>
- </div>
- <img :src="loginCarouselImage" class="carousel-image" alt="业务对象驱动生成">
- </div>
- <div class="carousel-item">
- <div class="carousel-copy">
- <div class="carousel-title">
- 权限与流程统一治理
- </div>
- <div class="carousel-subtitle">
- 多租户、数据权限和审批流程统一接入
- </div>
- </div>
- <img :src="loginCarouselImage" class="carousel-image" alt="权限与流程统一治理">
- </div>
- <template #arrow="{ prev, next }">
- <button type="button" class="arco-carousel-arrow-left" title="上一页" @click="prev">
- <i class="ai-icon:chevron-left" />
- </button>
- <button type="button" class="arco-carousel-arrow-right" title="下一页" @click="next">
- <i class="ai-icon:chevron-right" />
- </button>
- </template>
- <template #dots="{ total, currentIndex, to }">
- <div class="arco-carousel-dots" role="tablist">
- <button
- v-for="index in total"
- :key="index"
- type="button"
- class="arco-carousel-dot"
- :class="{ active: currentIndex === index - 1 }"
- :aria-selected="currentIndex === index - 1"
- @click="to(index - 1)"
- />
- </div>
- </template>
- </n-carousel>
- </div>
- </div>
- </div>
- <!-- Right side - Login form -->
- <div class="login-form-wrapper">
- <div class="login-form">
- <div class="form-header">
- <h2 class="form-title">
- 登录ForgeAdmin
- </h2>
- <p class="form-subtitle">
- {{ loginSubtitle }}
- </p>
- </div>
- <div class="form-body">
- <!-- Tenant -->
- <div v-if="tenantSelectOptions.length > 0" class="form-group tenant-form-group">
- <label for="tenant" class="form-label">登录租户</label>
- <div class="tenant-select-wrapper">
- <n-select
- id="tenant"
- v-model:value="selectedTenantId"
- class="modern-select"
- :options="tenantSelectOptions"
- :loading="tenantConfigApplying"
- :disabled="tenantConfigApplying"
- filterable
- size="large"
- placeholder="请选择登录租户"
- />
- </div>
- </div>
- <!-- Username -->
- <div class="form-group">
- <label for="username" class="form-label">用户名</label>
- <div class="input-wrapper">
- <n-input
- id="username"
- v-model:value="loginInfo.username"
- autofocus
- class="modern-input"
- placeholder="请输入用户名"
- :maxlength="20"
- size="large"
- >
- <template #prefix>
- <i class="input-icon ai-icon:user" />
- </template>
- </n-input>
- </div>
- </div>
- <!-- Password -->
- <div class="form-group">
- <label for="password" class="form-label">密码</label>
- <div class="input-wrapper">
- <n-input
- id="password"
- v-model:value="loginInfo.password"
- class="modern-input"
- type="password"
- show-password-on="click"
- placeholder="请输入密码"
- :maxlength="20"
- size="large"
- @keydown.enter="handleLogin()"
- >
- <template #prefix>
- <i class="input-icon ai-icon:lock" />
- </template>
- </n-input>
- </div>
- </div>
- <!-- 验证码区域 - 根据配置显示不同类型的验证码 -->
- <div v-if="captchaEnabled" class="form-group">
- <!-- 图形验证码 -->
- <template v-if="captchaType === 'graphical'">
- <label for="captcha" class="form-label">验证码</label>
- <div class="captcha-wrapper">
- <div class="input-wrapper flex-1">
- <n-input
- id="captcha"
- v-model:value="loginInfo.code"
- class="modern-input"
- placeholder="请输入验证码"
- :maxlength="6"
- size="large"
- @keydown.enter="handleLogin()"
- >
- <template #prefix>
- <i class="input-icon ai-icon:key" />
- </template>
- </n-input>
- </div>
- <div
- class="captcha-image"
- title="点击刷新验证码"
- role="button"
- tabindex="0"
- @click="refreshCaptcha"
- @keydown.enter="refreshCaptcha"
- >
- <img
- v-if="captchaImage"
- :src="captchaImage"
- alt="验证码"
- class="captcha-img"
- >
- <div v-else class="captcha-loading">
- <i class="ai-icon:loader animate-spin" />
- </div>
- </div>
- </div>
- </template>
- <!-- 滑块验证码 - 已改为浮层弹出形式,点击登录按钮触发 -->
- <template v-if="captchaType === 'slider'">
- <div class="slider-verify-trigger" :class="{ verified: sliderSuccess }" @click="!sliderSuccess && openSliderModal()">
- <div class="trigger-icon">
- <i v-if="sliderSuccess" class="ai-icon:check-circle" style="color:#22C55E" />
- <i v-else class="ai-icon:shield" />
- </div>
- <span>{{ sliderSuccess ? '安全验证已通过' : '点击进行安全验证' }}</span>
- <i v-if="!sliderSuccess" class="trigger-arrow ai-icon:chevron-right" />
- </div>
- </template>
- <!-- 短信验证码 -->
- <template v-if="captchaType === 'sms'">
- <label for="phone" class="form-label">手机号</label>
- <div class="input-wrapper mb-3">
- <n-input
- id="phone"
- v-model:value="loginInfo.phone"
- class="modern-input"
- placeholder="请输入手机号"
- :maxlength="11"
- size="large"
- >
- <template #prefix>
- <i class="input-icon ai-icon:phone" />
- </template>
- </n-input>
- </div>
- <label for="smsCode" class="form-label">短信验证码</label>
- <div class="captcha-wrapper">
- <div class="input-wrapper flex-1">
- <n-input
- id="smsCode"
- v-model:value="loginInfo.code"
- class="modern-input"
- placeholder="请输入短信验证码"
- :maxlength="6"
- size="large"
- @keydown.enter="handleLogin()"
- >
- <template #prefix>
- <i class="input-icon ai-icon:key" />
- </template>
- </n-input>
- </div>
- <n-button
- :disabled="smsCountdown > 0 || !isValidPhone"
- class="sms-button"
- size="large"
- @click="sendSmsCode"
- >
- {{ smsCountdown > 0 ? `${smsCountdown}s后重发` : '获取验证码' }}
- </n-button>
- </div>
- </template>
- </div>
- <!-- Remember me -->
- <div class="form-options">
- <n-checkbox
- :checked="isRemember"
- :on-update:checked="(val) => (isRemember = val)"
- >
- <span class="checkbox-label">记住我</span>
- </n-checkbox>
- </div>
- <!-- Submit button -->
- <n-button
- class="login-button"
- type="primary"
- size="large"
- :loading="loading"
- block
- @click="onLoginClick()"
- >
- <span class="button-text">登录</span>
- <i v-if="!loading" class="button-icon ai-icon:arrow-right" />
- </n-button>
- <div class="login-trust-strip">
- <span><i class="ai-icon:shield" />安全登录</span>
- <span><i class="ai-icon:layers" />租户隔离</span>
- <span><i class="ai-icon:check-circle" />操作审计</span>
- </div>
- <!-- Social login buttons -->
- <div v-if="socialPlatforms.length > 0" class="social-login-section">
- <div class="social-divider">
- <span class="divider-text">其他登录方式</span>
- </div>
- <div class="social-buttons">
- <button
- v-for="platform in socialPlatforms"
- :key="platform.platform"
- class="social-button"
- :title="platform.platformName"
- @click="handleSocialLogin(platform.platform)"
- >
- <img
- v-if="platform.platformLogoBase64"
- :src="`data:image/png;base64,${platform.platformLogoBase64}`"
- :alt="platform.platformName"
- class="social-icon"
- >
- <i v-else class="social-icon-fallback ai-icon:link" />
- </button>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- ICP备案号 -->
- <div class="icp-record">
- <span v-if="copyrightInfo">{{ copyrightInfo }}</span>
- <a v-else href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">蒙ICP备2026004895号</a>
- </div>
- </div>
- <!-- 滑块验证浮层 -->
- <Transition name="modal">
- <div v-if="showSliderModal" class="slider-modal-overlay" @click.self="closeSliderModal">
- <div class="slider-modal">
- <!-- 关闭按钮 -->
- <button class="slider-modal-close" @click="closeSliderModal">
- <i class="ai-icon:x" />
- </button>
- <!-- 标题区 -->
- <div class="slider-modal-header">
- <div class="slider-modal-icon">
- <i class="ai-icon:shield" />
- </div>
- <h3 class="slider-modal-title">
- 安全验证
- </h3>
- <p class="slider-modal-desc">
- 请拖动滑块到正确位置,完成拼图
- </p>
- </div>
- <!-- 滑块验证组件 -->
- <div class="slider-modal-body">
- <SlideVerify
- ref="slideVerifyRef"
- :w="340"
- :h="170"
- :slider-l="42"
- :slider-r="8"
- :accuracy="8"
- :imgs="slideImages"
- :show-refresh="true"
- refresh-text="刷新"
- text="拖动滑块完成拼图"
- success-text="验证成功!"
- fail-text="验证失败,请重试"
- @success="onSlideSuccess"
- @fail="onSlideFail"
- @refresh="onSlideRefresh"
- />
- </div>
- <!-- 底部辅助文字 -->
- <p class="slider-modal-tip">
- <i class="ai-icon:info" />
- 如果拖动困难,可点击刷新重试
- </p>
- </div>
- </div>
- </Transition>
- </template>
- <script setup>
- import { useStorage } from '@vueuse/core'
- import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
- import SlideVerify from 'vue3-slide-verify'
- import mainApi from '@/api'
- import loginCarouselImage from '@/assets/images/login-carousel-platform.png'
- import defaultLogoUrl from '@/assets/images/logo_text.png'
- import { useAppStore, useAuthStore, usePermissionStore, useTenantStore, useUserStore } from '@/store'
- import { lStorage } from '@/utils'
- import { loadRuntimeCryptoConfig } from '@/utils/crypto/crypto-config'
- import { encryptPassword, initKeyExchange } from '@/utils/crypto/key-exchange'
- import { request } from '@/utils/http'
- import { normalizePageTitle } from '@/utils/page-title'
- import { applyTenantConfig, resolveTenantPublicAssetUrl } from '@/utils/tenant-config'
- import api from './api'
- import 'vue3-slide-verify/dist/style.css'
- const authStore = useAuthStore()
- const userStore = useUserStore()
- const appStore = useAppStore()
- const router = useRouter()
- const route = useRoute()
- const userClient = import.meta.env.VITE_USER_CLIENT || 'pc'
- const LOGIN_TENANT_STORAGE_KEY = 'login_selected_tenant_id'
- const SOCIAL_TENANT_MAP_KEY = 'login_social_tenant_map'
- const tenantOptions = ref([])
- const selectedTenantId = ref(null)
- const tenantConfigApplying = ref(false)
- const brandLogoUrl = ref(defaultLogoUrl)
- const loginConfig = ref(null)
- const selectedTenantOption = computed(() => tenantOptions.value.find(item => String(item.value) === String(selectedTenantId.value)) || null)
- const loginSubtitle = computed(() => selectedTenantOption.value?.tenantName
- ? `${selectedTenantOption.value.tenantName} 租户入口`
- : '请输入账号密码')
- const copyrightInfo = computed(() => normalizePageTitle(loginConfig.value?.copyrightInfo))
- const tenantSelectOptions = computed(() => tenantOptions.value.map(item => ({
- label: item.label,
- value: item.value,
- })))
- let tenantInitCompleted = false
- const loginInfo = ref({
- username: '',
- password: '',
- code: '', // 验证码
- codeKey: '', // 验证码key
- phone: '', // 手机号(短信验证码使用)
- })
- const captchaImage = ref('') // 验证码图片(Base64)
- const captchaExpires = ref(0) // 验证码过期时间
- // 验证码类型:graphical(图形验证码), slider(滑块验证码), sms(短信验证码)
- const captchaType = ref('graphical')
- const captchaEnabled = computed(() => loginConfig.value?.enableCaptcha !== false)
- // 滑块验证码相关 (vue3-slide-verify)
- const slideVerifyRef = ref(null)
- const slideImages = ref([]) // 滑块验证码背景图片列表
- const sliderSuccess = ref(false)
- const sliderFail = ref(false)
- const showSliderModal = ref(false) // 滑块浮层显示状态
- // 短信验证码相关
- const smsCountdown = ref(0)
- const smsTimer = ref(null)
- const localLoginInfo = lStorage.get('loginInfo')
- if (localLoginInfo) {
- loginInfo.value.username = localLoginInfo.username || ''
- loginInfo.value.password = localLoginInfo.password || ''
- }
- const isRemember = useStorage('isRemember', true)
- const loading = ref(false)
- // 三方登录平台列表
- const socialPlatforms = ref([])
- const socialLoading = ref(false)
- watch(selectedTenantId, (tenantId) => {
- if (!tenantInitCompleted || tenantConfigApplying.value)
- return
- syncSelectedTenantToStorage(tenantId)
- tenantConfigApplying.value = true
- refreshLoginContext()
- .finally(() => {
- tenantConfigApplying.value = false
- })
- })
- // 手机号验证
- const isValidPhone = computed(() => {
- const phone = loginInfo.value.phone
- return phone && /^1[3-9]\d{9}$/.test(phone)
- })
- function normalizeTenantId(value) {
- if (Array.isArray(value)) {
- const first = value.find(item => item !== null && item !== undefined && item !== '')
- return normalizeTenantId(first)
- }
- if (value === null || value === undefined || value === '')
- return null
- const parsed = Number(value)
- return Number.isNaN(parsed) ? null : parsed
- }
- function syncSelectedTenantToStorage(tenantId) {
- const normalizedTenantId = normalizeTenantId(tenantId)
- if (normalizedTenantId === null) {
- lStorage.remove(LOGIN_TENANT_STORAGE_KEY)
- return
- }
- lStorage.set(LOGIN_TENANT_STORAGE_KEY, normalizedTenantId)
- }
- function getSelectedTenantFromStorage() {
- return normalizeTenantId(lStorage.get(LOGIN_TENANT_STORAGE_KEY))
- }
- function getSocialTenantMap() {
- return lStorage.get(SOCIAL_TENANT_MAP_KEY) || {}
- }
- function setSocialTenantMap(map) {
- lStorage.set(SOCIAL_TENANT_MAP_KEY, map)
- }
- function rememberSocialTenant(state, tenantId) {
- const normalizedTenantId = normalizeTenantId(tenantId)
- if (!state || normalizedTenantId === null)
- return
- const map = getSocialTenantMap()
- map[state] = normalizedTenantId
- setSocialTenantMap(map)
- }
- function applyBrandLogo(config) {
- brandLogoUrl.value = resolveTenantPublicAssetUrl(config, 'logo') || defaultLogoUrl
- }
- function handleBrandLogoError() {
- if (brandLogoUrl.value !== defaultLogoUrl)
- brandLogoUrl.value = defaultLogoUrl
- }
- async function applyLoginPageConfig(config) {
- loginConfig.value = config || null
- await applyTenantConfig(config, appStore)
- applyBrandLogo(config)
- }
- async function loadLoginTenantOptions() {
- try {
- const res = await api.getLoginTenantOptions()
- if (res.code !== 200) {
- tenantOptions.value = []
- return
- }
- tenantOptions.value = (res.data || []).map(item => ({
- ...item,
- value: normalizeTenantId(item.tenantId),
- label: item.tenantName || item.systemName || item.browserTitle || String(item.tenantId),
- })).filter(item => item.value !== null)
- const storedTenantId = getSelectedTenantFromStorage()
- const initialTenantId = tenantOptions.value.find(item => String(item.value) === String(storedTenantId))?.value
- ?? tenantOptions.value[0]?.value
- ?? null
- if (initialTenantId !== null) {
- selectedTenantId.value = initialTenantId
- syncSelectedTenantToStorage(initialTenantId)
- }
- }
- catch (error) {
- console.error('获取登录租户列表失败:', error)
- }
- }
- // 获取登录配置
- async function loadLoginConfig() {
- const currentTenantId = normalizeTenantId(selectedTenantId.value)
- try {
- const res = await api.getLoginConfig(userClient, currentTenantId)
- if (res.code === 200 && res.data) {
- await applyLoginPageConfig(res.data)
- captchaType.value = res.data.captchaType || 'graphical'
- // 根据验证码类型加载对应的验证码
- if (captchaEnabled.value) {
- await loadCaptchaByType()
- }
- else {
- loginInfo.value.code = ''
- loginInfo.value.codeKey = ''
- }
- }
- }
- catch (error) {
- console.error('获取登录配置失败:', error)
- // 使用默认配置
- captchaType.value = 'graphical'
- await applyLoginPageConfig(null)
- await refreshCaptcha()
- }
- }
- // 获取已启用的三方登录平台
- async function loadSocialPlatforms() {
- try {
- socialLoading.value = true
- const res = await api.getSocialPlatforms(normalizeTenantId(selectedTenantId.value))
- if (res.code === 200 && res.data) {
- socialPlatforms.value = res.data.filter(p => p.enabled)
- }
- }
- catch (error) {
- console.error('获取三方登录平台失败:', error)
- }
- finally {
- socialLoading.value = false
- }
- }
- // 处理三方登录
- async function handleSocialLogin(platform) {
- try {
- const tenantId = normalizeTenantId(selectedTenantId.value)
- const res = await api.getSocialAuthUrl(platform, tenantId)
- if (res.code === 200 && res.data) {
- rememberSocialTenant(res.data.state, tenantId)
- // 打开授权窗口
- const width = 600
- const height = 500
- const left = (window.innerWidth - width) / 2
- const top = (window.innerHeight - height) / 2
- const authWindow = window.open(
- res.data.authUrl,
- 'social_auth',
- `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,resizable=yes`,
- )
- // 监听授权窗口关闭
- const checkClosed = setInterval(() => {
- if (authWindow.closed) {
- clearInterval(checkClosed)
- }
- }, 500)
- }
- else {
- $message.error(res.msg || '获取授权链接失败')
- }
- }
- catch (error) {
- console.error('获取三方授权链接失败:', error)
- $message.error('获取授权链接失败')
- }
- }
- // 根据验证码类型加载验证码
- async function loadCaptchaByType() {
- switch (captchaType.value) {
- case 'slider':
- // 加载滑块验证码图片
- await loadSlideImages()
- // 重置滑块状态
- sliderSuccess.value = false
- sliderFail.value = false
- loginInfo.value.code = ''
- // 注意:组件实例在模板渲染后才可用,不在此处调用reset
- break
- case 'sms':
- // 短信验证码不需要预加载,用户点击发送时才获取
- break
- case 'graphical':
- default:
- await refreshCaptcha()
- break
- }
- }
- // 获取图形验证码
- async function refreshCaptcha() {
- try {
- const res = await api.getCaptcha()
- if (res.code === 200 && res.data) {
- loginInfo.value.codeKey = res.data.codeKey // 验证码key
- captchaImage.value = res.data.image || '' // 验证码图片(Base64)
- captchaExpires.value = res.data.expiresIn || 300 // 过期时间
- // 清空验证码输入框
- loginInfo.value.code = ''
- }
- }
- catch (error) {
- console.error('获取验证码失败:', error)
- $message.error('获取验证码失败')
- }
- }
- // 加载滑块验证码图片列表
- async function loadSlideImages() {
- // 使用可靠的图片源,避免加载失败
- // 实际项目中可以配置自己的图片或使用后端提供的图片
- slideImages.value = [
- 'https://picsum.photos/id/10/320/160',
- 'https://picsum.photos/id/11/320/160',
- 'https://picsum.photos/id/12/320/160',
- 'https://picsum.photos/id/13/320/160',
- 'https://picsum.photos/id/14/320/160',
- ]
- }
- // 打开滑块验证浮层
- function openSliderModal() {
- showSliderModal.value = true
- // 浮层打开后重置验证状态
- nextTick(() => {
- if (slideVerifyRef.value && typeof slideVerifyRef.value.refresh === 'function') {
- slideVerifyRef.value.refresh()
- }
- })
- }
- // 关闭滑块验证浮层
- function closeSliderModal() {
- showSliderModal.value = false
- }
- // 登录按钮点击处理
- function onLoginClick() {
- if (captchaEnabled.value && captchaType.value === 'slider' && !sliderSuccess.value) {
- openSliderModal()
- return
- }
- handleLogin()
- }
- // 滑块验证成功回调
- function onSlideSuccess() {
- sliderSuccess.value = true
- sliderFail.value = false
- loginInfo.value.code = 'verified'
- // 延迟关闭浮层再登录,让用户看到成功动画
- setTimeout(() => {
- closeSliderModal()
- handleLogin()
- }, 800)
- }
- // 滑块验证失败回调
- function onSlideFail() {
- sliderFail.value = true
- sliderSuccess.value = false
- loginInfo.value.code = ''
- }
- // 刷新滑块验证码
- function onSlideRefresh() {
- sliderSuccess.value = false
- sliderFail.value = false
- loginInfo.value.code = ''
- // 组件会自动刷新,这里可以添加额外的逻辑
- }
- // 处理登录失败
- async function handleLoginFailure() {
- if (!captchaEnabled.value)
- return
- // 根据验证码类型处理
- if (captchaType.value === 'slider') {
- // 滑块验证码需要重置组件
- sliderSuccess.value = false
- sliderFail.value = false
- loginInfo.value.code = ''
- // 等待 DOM 更新后调用组件刷新方法
- await nextTick()
- if (slideVerifyRef.value && typeof slideVerifyRef.value.refresh === 'function') {
- slideVerifyRef.value.refresh()
- }
- }
- else if (captchaType.value === 'graphical') {
- // 图形验证码刷新
- await refreshCaptcha()
- }
- // 短信验证码不需要刷新,保持原样
- }
- // 发送短信验证码
- async function sendSmsCode() {
- if (!isValidPhone.value) {
- $message.warning('请输入正确的手机号')
- return
- }
- try {
- const res = await api.sendSmsCaptcha(loginInfo.value.phone)
- if (res.code === 200 && res.data) {
- if (res.data.status === 'success') {
- $message.success('验证码发送成功')
- // 开始倒计时
- smsCountdown.value = res.data.interval || 60
- startSmsCountdown()
- }
- else {
- $message.error(res.data.message || '验证码发送失败')
- }
- }
- }
- catch (error) {
- console.error('发送短信验证码失败:', error)
- $message.error('发送短信验证码失败')
- }
- }
- // 短信验证码倒计时
- function startSmsCountdown() {
- if (smsTimer.value) {
- clearInterval(smsTimer.value)
- }
- smsTimer.value = setInterval(() => {
- if (smsCountdown.value > 0) {
- smsCountdown.value--
- }
- else {
- clearInterval(smsTimer.value)
- }
- }, 1000)
- }
- async function handleLogin() {
- const { username, password, code, codeKey, phone } = loginInfo.value
- const tenantId = normalizeTenantId(selectedTenantId.value)
- // 基础验证
- if (!username || !password)
- return $message.warning('请输入用户名和密码')
- if (tenantOptions.value.length > 0 && tenantId === null) {
- return $message.warning('请选择登录租户')
- }
- if (captchaEnabled.value) {
- // 根据验证码类型进行验证
- if (captchaType.value === 'slider') {
- if (!sliderSuccess.value) {
- return $message.warning('请完成滑块验证')
- }
- // 滑块验证码使用组件自带的验证,这里只需要确认已通过
- // 为了安全,可以添加一个临时的token或codeKey
- }
- else if (captchaType.value === 'sms') {
- if (!phone)
- return $message.warning('请输入手机号')
- if (!code)
- return $message.warning('请输入短信验证码')
- }
- else {
- if (!code)
- return $message.warning('请输入验证码')
- }
- }
- try {
- loading.value = true
- $message.loading('正在验证,请稍后...', { key: 'login' })
- await loadRuntimeCryptoConfig()
- // 登录密码 RSA 与通用 API 传输加密相互独立,以服务端登录配置为准。
- const passwordEncryptionEnabled = loginConfig.value?.enablePasswordEncryption !== false
- const submittedPassword = await encryptPassword(password, request, passwordEncryptionEnabled)
- // 构造登录参数 - 使用新的后端接口格式
- const params = {
- username,
- password: submittedPassword,
- code,
- codeKey,
- phone, // 短信验证码时需要
- tenantId,
- authType: captchaEnabled.value ? 'password_captcha' : 'password',
- userClient,
- appId: import.meta.env.VITE_APP_ID || 'forge_pc_001', // 客户端AppId
- }
- const res = await api.login(params)
- if (res.code === 200) {
- if (isRemember.value) {
- lStorage.set('loginInfo', { username, password })
- }
- else {
- lStorage.remove('loginInfo')
- }
- onLoginSuccess(res.data)
- }
- else {
- // 登录失败后刷新验证码
- await handleLoginFailure()
- }
- }
- catch (error) {
- $message.destroy('login')
- console.error(error)
- // 登录失败后刷新验证码
- await handleLoginFailure()
- }
- loading.value = false
- }
- async function onLoginSuccess(data = {}) {
- authStore.resetLoginState({ resetAuth: false })
- const accessToken = data.accessToken || data.token
- // 设置认证信息 - LoginResult 结构
- if (accessToken) {
- authStore.setToken({
- accessToken,
- tokenType: data.tokenType || 'Bearer',
- expiresIn: data.expiresIn,
- })
- // 密钥交换 - 使用 token 作为会话标识
- try {
- await initKeyExchange(request, accessToken)
- }
- catch (error) {
- console.warn('密钥交换失败,将使用降级方案:', error)
- }
- }
- // 如果返回了用户信息,设置到用户存储中
- if (data.userInfo) {
- const loginUser = data.userInfo
- loginUser.forcePasswordChange = data.forcePasswordChange === true || loginUser.forcePasswordChange === true
- userStore.setUser({
- id: loginUser.userId,
- username: loginUser.username,
- nickName: loginUser.realName || loginUser.username,
- email: loginUser.email,
- phone: loginUser.phone,
- avatar: loginUser.avatar,
- userType: loginUser.userType,
- userStatus: loginUser.userStatus,
- forcePasswordChange: loginUser.forcePasswordChange,
- tenantId: loginUser.tenantId,
- tenantName: loginUser.tenantName,
- tenantIds: loginUser.tenantIds || [],
- roleIds: loginUser.roleIds || [],
- roleKeys: loginUser.roleKeys || [],
- permissions: loginUser.permissions || [],
- apiPermissions: loginUser.apiPermissions || [],
- orgIds: loginUser.orgIds || [],
- mainOrgId: loginUser.mainOrgId,
- roles: loginUser.roleKeys ? Array.from(loginUser.roleKeys) : [],
- userInfo: loginUser,
- })
- // 同时存储到localStorage用于持久化
- lStorage.set('userInfo', loginUser)
- }
- const mustChangePassword = data.forcePasswordChange === true || data.userInfo?.forcePasswordChange === true
- if (mustChangePassword) {
- $message.warning('当前账号必须先修改初始密码', { key: 'login' })
- router.push('/profile')
- return
- }
- $message.loading('登录中...', { key: 'login' })
- try {
- // 先获取菜单数据,再跳转
- await loadAndSetMenuData(data.userInfo?.tenantId || selectedTenantId.value)
- $message.success('登录成功', { key: 'login' })
- // 使用环境变量中的默认跳转路径
- const defaultRedirectPath = import.meta.env.VITE_HOME_PATH || '/'
- // 处理重定向
- const redirectPath = route.query.redirect
- if (redirectPath && redirectPath !== '/login') {
- // 如果 redirect 不是登录页,则跳转到 redirect
- delete route.query.redirect
- router.push({ path: redirectPath, query: route.query })
- }
- else {
- // 否则跳转到首页
- router.push(defaultRedirectPath)
- }
- }
- catch (error) {
- console.error(error)
- $message.destroy('login')
- }
- }
- // 监听三方登录子窗口的消息
- async function handleSocialLoginMessage(event) {
- if (event.data?.type === 'SOCIAL_LOGIN_SUCCESS') {
- const { data } = event.data
- const accessToken = data?.accessToken || data?.token
- authStore.resetLoginState({ resetAuth: false })
- // 设置 token
- if (accessToken) {
- authStore.setToken({
- accessToken,
- tokenType: data.tokenType || 'Bearer',
- expiresIn: data.expiresIn,
- })
- }
- $message.success('登录成功')
- // 使用 window.location.href 强制刷新页面跳转
- const defaultRedirectPath = import.meta.env.VITE_HOME_PATH || '/'
- window.location.href = defaultRedirectPath
- }
- else if (event.data?.type === 'SOCIAL_LOGIN_FAILED') {
- $message.error('三方登录失败,请重试')
- }
- }
- // 页面加载时获取登录配置和验证码
- onMounted(() => {
- ;(async () => {
- try {
- tenantConfigApplying.value = true
- await loadLoginTenantOptions()
- await refreshLoginContext()
- }
- finally {
- tenantInitCompleted = true
- tenantConfigApplying.value = false
- }
- })()
- // 监听三方登录消息
- window.addEventListener('message', handleSocialLoginMessage)
- })
- // 组件卸载时清理定时器
- onUnmounted(() => {
- if (smsTimer.value) {
- clearInterval(smsTimer.value)
- }
- // 移除消息监听
- window.removeEventListener('message', handleSocialLoginMessage)
- })
- // 获取并设置菜单数据
- async function refreshLoginContext() {
- await Promise.all([
- loadLoginConfig(),
- loadSocialPlatforms(),
- ])
- }
- async function loadAndSetMenuData(loginTenantId = selectedTenantId.value) {
- try {
- const permissionStore = usePermissionStore()
- const tenantStore = useTenantStore()
- const tenantConfig = await tenantStore.loadTenantConfig(normalizeTenantId(loginTenantId) || userStore.userInfo?.tenantId)
- await applyTenantConfig(tenantConfig, appStore)
- // 获取菜单数据
- const res = await mainApi.getMenu(1)
- if (res.code === 200 && res.data) {
- // 设置菜单数据到store
- permissionStore.setMenuData(res.data)
- }
- else {
- console.error('菜单数据格式不正确:', res)
- }
- // 等待菜单数据加载完成(最多等待5秒)
- let waitCount = 0
- while (!permissionStore.menuDataLoaded && waitCount < 50) {
- await new Promise(resolve => setTimeout(resolve, 100))
- waitCount++
- }
- }
- catch (error) {
- console.error('获取菜单数据失败:', error)
- }
- }
- </script>
- <style scoped>
- /* Container */
- .login-container {
- position: relative;
- width: 100%;
- min-height: 100vh;
- min-height: 100dvh;
- display: flex;
- align-items: stretch;
- justify-content: center;
- padding: 0;
- overflow-x: hidden;
- overflow-y: auto;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
- }
- /* Static background */
- .login-bg-animated {
- position: absolute;
- inset: 0;
- background:
- radial-gradient(circle at 20% 18%, rgba(37, 99, 235, 0.18) 0%, rgba(37, 99, 235, 0) 26%),
- radial-gradient(circle at 82% 80%, rgba(20, 184, 166, 0.18) 0%, rgba(20, 184, 166, 0) 28%),
- linear-gradient(135deg, #f7fbff 0%, #eef4fb 52%, #f8fafc 100%);
- z-index: 0;
- }
- .login-bg-animated::before {
- content: '';
- position: absolute;
- inset: 0;
- background:
- linear-gradient(rgba(59, 130, 246, 0.035) 1px, transparent 1px),
- linear-gradient(90deg, rgba(59, 130, 246, 0.035) 1px, transparent 1px);
- background-size: 50px 50px;
- opacity: 0.7;
- }
- /* Login card */
- .login-card {
- position: relative;
- z-index: 2;
- display: grid;
- grid-template-columns: 1fr;
- max-width: none;
- width: 100%;
- min-height: 100vh;
- min-height: 100dvh;
- background: #fff;
- border-radius: 0;
- border: none;
- box-shadow: none;
- overflow: hidden;
- }
- .login-card::before {
- content: none;
- }
- .login-card::after {
- content: none;
- }
- @media (min-width: 960px) {
- .login-card {
- grid-template-columns: minmax(0, 48%) minmax(440px, 52%);
- }
- }
- /* Left side - Branding */
- .login-brand {
- background:
- radial-gradient(circle at 24% 18%, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0) 30%),
- radial-gradient(circle at 82% 80%, rgba(191, 219, 254, 0.22) 0%, rgba(191, 219, 254, 0) 34%),
- linear-gradient(135deg, #1e40af, #3b82f6, #60a5fa);
- min-height: 100vh;
- min-height: 100dvh;
- padding: 36px 44px;
- display: none;
- flex-direction: column;
- justify-content: flex-start;
- position: relative;
- overflow: hidden;
- }
- .login-brand::before {
- content: none;
- }
- .login-brand::after {
- content: '';
- position: absolute;
- inset: 0;
- background:
- linear-gradient(90deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px),
- linear-gradient(rgba(255, 255, 255, 0.06) 1px, transparent 1px);
- background-size:
- 44px 44px,
- 44px 44px;
- opacity: 0.38;
- mask-image: linear-gradient(180deg, transparent 0%, #000 18%, #000 82%, transparent 100%);
- pointer-events: none;
- }
- @media (min-width: 960px) {
- .login-brand {
- display: flex;
- }
- }
- .brand-content {
- position: relative;
- z-index: 1;
- display: flex;
- width: 100%;
- min-height: 100%;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- margin: 0 auto;
- text-align: left;
- }
- .logo-lockup {
- position: absolute;
- top: 0;
- left: 0;
- display: flex;
- align-items: center;
- gap: 12px;
- margin-bottom: 0;
- }
- .logo-img {
- width: 36px;
- height: 36px;
- object-fit: contain;
- border-radius: 8px;
- }
- .logo-text {
- color: #fff;
- font-size: 17px;
- font-weight: 800;
- line-height: 1;
- }
- .banner {
- position: relative;
- display: flex;
- width: 100%;
- min-height: clamp(430px, 62vh, 640px);
- align-items: stretch;
- justify-content: center;
- margin-top: 0;
- }
- .stack-carousel {
- width: 100%;
- height: clamp(430px, 62vh, 640px);
- margin: 0 auto;
- }
- .stack-carousel :deep(.n-carousel__slides) {
- width: 100%;
- height: 100%;
- align-items: stretch;
- }
- .stack-carousel :deep(.n-carousel__slide) {
- width: 100%;
- min-width: 100%;
- display: flex;
- justify-content: center;
- }
- .carousel-item {
- position: relative;
- width: 100%;
- height: clamp(430px, 62vh, 640px);
- margin: 0 auto;
- padding: 0 0 68px;
- overflow: hidden;
- }
- .carousel-image {
- position: absolute;
- top: 88px;
- left: 50%;
- width: min(620px, 100%);
- max-width: none;
- max-height: calc(100% - 136px);
- height: auto;
- object-fit: contain;
- transform: translateX(-50%);
- filter: drop-shadow(0 22px 34px rgba(30, 64, 175, 0.22));
- pointer-events: none;
- user-select: none;
- }
- .arco-carousel-arrow-left,
- .arco-carousel-arrow-right {
- position: absolute;
- top: 50%;
- z-index: 5;
- display: inline-flex;
- width: 34px;
- height: 34px;
- align-items: center;
- justify-content: center;
- border: 1px solid rgba(255, 255, 255, 0.3);
- border-radius: 50%;
- background: rgba(15, 23, 42, 0.18);
- color: #fff;
- cursor: pointer;
- transform: translateY(-50%);
- transition:
- background 0.18s ease,
- border-color 0.18s ease;
- }
- .arco-carousel-arrow-left:hover,
- .arco-carousel-arrow-right:hover {
- border-color: rgba(255, 255, 255, 0.58);
- background: rgba(255, 255, 255, 0.22);
- }
- .arco-carousel-arrow-left {
- left: 8px;
- }
- .arco-carousel-arrow-right {
- right: 8px;
- }
- .arco-carousel-dots {
- position: absolute;
- right: 0;
- bottom: 0;
- left: 0;
- z-index: 6;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- }
- .arco-carousel-dot {
- width: 16px;
- height: 4px;
- padding: 0;
- border: none;
- border-radius: 999px;
- background: rgba(255, 255, 255, 0.36);
- cursor: pointer;
- transition:
- width 0.18s ease,
- background 0.18s ease;
- }
- .arco-carousel-dot.active {
- width: 26px;
- background: #fff;
- }
- .carousel-copy {
- position: relative;
- z-index: 2;
- max-width: 560px;
- margin: 0 auto;
- text-align: center;
- }
- .carousel-title {
- color: #fff;
- font-size: 1.28rem;
- font-weight: 800;
- line-height: 1.35;
- }
- .carousel-subtitle {
- margin-top: 8px;
- color: rgba(255, 255, 255, 0.72);
- font-size: 0.94rem;
- line-height: 1.55;
- }
- /* Right side - Form */
- .login-form-wrapper {
- min-height: 100vh;
- min-height: 100dvh;
- padding: 40px 56px;
- display: flex;
- align-items: center;
- justify-content: center;
- background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)), #fff;
- overflow-y: auto;
- }
- @media (min-width: 960px) {
- /* Fixed-height internal scroll so a tall form (incl. "其他登录方式")
- stays reachable on short / low-resolution screens instead of being
- clipped below the fold. "safe center" falls back to top alignment
- when content overflows, avoiding the flexbox top-clip bug. */
- .login-form-wrapper {
- height: 100vh;
- height: 100dvh;
- min-height: 0;
- align-items: safe center;
- }
- }
- @media (max-width: 959px) {
- .login-container {
- align-items: stretch;
- padding: 0;
- }
- .login-card {
- min-height: 100vh;
- min-height: 100dvh;
- max-width: none;
- margin: 0;
- overflow: visible;
- }
- .login-form-wrapper {
- min-height: 100vh;
- min-height: 100dvh;
- align-items: flex-start;
- padding: 22px 22px 72px;
- overflow: visible;
- }
- .login-form {
- max-width: 380px;
- margin: 0 auto;
- }
- .form-header {
- margin-bottom: 16px;
- }
- .form-group {
- margin-bottom: 12px;
- }
- .form-label {
- margin-bottom: 6px;
- }
- .form-options {
- margin-bottom: 14px;
- }
- .login-button {
- margin-top: 2px;
- }
- .login-trust-strip {
- display: none;
- }
- .social-login-section {
- margin-top: 18px;
- }
- .social-divider {
- margin-bottom: 14px;
- }
- }
- @media (min-width: 960px) and (max-height: 760px) {
- .login-brand {
- padding-top: 28px;
- padding-bottom: 28px;
- }
- .logo-lockup {
- top: 0;
- }
- .banner {
- min-height: 380px;
- }
- .stack-carousel,
- .carousel-item {
- height: 380px;
- }
- .carousel-image {
- top: 76px;
- width: min(560px, 100%);
- max-height: calc(100% - 112px);
- }
- }
- .login-form {
- width: 100%;
- max-width: 360px;
- }
- .form-header {
- margin-bottom: 22px;
- text-align: left;
- }
- .form-title {
- font-size: 1.75rem;
- color: #0f172a;
- margin: 0 0 8px;
- letter-spacing: 0;
- }
- .form-subtitle {
- font-size: 0.875rem;
- color: #64748b;
- font-weight: 400;
- line-height: 1.6;
- }
- /* Form groups */
- .form-group {
- margin-bottom: 16px;
- }
- .form-label {
- display: block;
- font-size: 0.8125rem;
- font-weight: 600;
- color: #334155;
- margin-bottom: 8px;
- letter-spacing: 0.01em;
- }
- .input-wrapper {
- position: relative;
- display: flex;
- align-items: center;
- }
- .modern-input {
- width: 100%;
- }
- .tenant-select-wrapper {
- width: 100%;
- }
- .modern-select {
- width: 100%;
- }
- /* Override Naive UI input styles */
- :deep(.modern-input.n-input) {
- border-radius: 2px;
- }
- :deep(.modern-select .n-base-selection) {
- border-radius: 2px;
- }
- :deep(.modern-select .n-base-selection:not(.n-base-selection--disabled):hover) {
- border-color: #3b82f6;
- }
- :deep(.modern-select .n-base-selection:not(.n-base-selection--disabled).n-base-selection--active) {
- border-color: #3b82f6;
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
- }
- :deep(.modern-input .n-input__border),
- :deep(.modern-input .n-input__state-border) {
- border-radius: 2px;
- }
- :deep(.modern-input.n-input:not(.n-input--disabled):hover) {
- border-color: #3b82f6;
- }
- :deep(.modern-input.n-input:not(.n-input--disabled):focus-within) {
- border-color: #3b82f6;
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
- }
- .input-icon {
- color: #94a3b8;
- font-size: 16px;
- }
- /* Captcha */
- .captcha-wrapper {
- display: flex;
- gap: 12px;
- align-items: center;
- }
- .captcha-image {
- position: relative;
- width: 120px;
- height: 40px;
- padding: 3px;
- border: 1px solid #e0e0e6;
- overflow: hidden;
- cursor: pointer;
- transition:
- transform 0.2s ease,
- border-color 0.2s ease,
- box-shadow 0.2s ease;
- display: flex;
- align-items: center;
- justify-content: center;
- background:
- linear-gradient(#fff, #fff) padding-box,
- linear-gradient(135deg, rgba(59, 130, 246, 0.36), rgba(14, 165, 233, 0.18), rgba(148, 163, 184, 0.22)) border-box;
- box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, 0.9),
- 0 8px 18px rgba(15, 23, 42, 0.06);
- }
- .captcha-image::after {
- content: '换';
- position: absolute;
- top: 4px;
- right: 4px;
- z-index: 2;
- display: flex;
- align-items: center;
- justify-content: center;
- width: 18px;
- height: 18px;
- border-radius: 999px;
- background: rgba(15, 23, 42, 0.64);
- color: #fff;
- font-size: 11px;
- font-weight: 700;
- opacity: 0;
- transform: translateY(-3px);
- transition:
- opacity 0.2s ease,
- transform 0.2s ease;
- pointer-events: none;
- }
- .captcha-image:hover {
- border-color: #3b82f6;
- transform: translateY(-1px);
- box-shadow:
- 0 0 0 3px rgba(59, 130, 246, 0.1),
- 0 12px 24px rgba(37, 99, 235, 0.14);
- }
- .captcha-image:hover::after {
- opacity: 1;
- transform: translateY(0);
- }
- .captcha-image:focus {
- outline: none;
- border-color: #3b82f6;
- box-shadow:
- 0 0 0 3px rgba(59, 130, 246, 0.12),
- 0 12px 24px rgba(37, 99, 235, 0.12);
- }
- .captcha-img {
- width: 100%;
- height: 100%;
- object-fit: contain;
- border-radius: 9px;
- background: #f8fafc;
- }
- .captcha-loading {
- width: 100%;
- height: 100%;
- border-radius: 9px;
- background: linear-gradient(135deg, #f8fafc, #eef2ff);
- color: #94a3b8;
- font-size: 1.5rem;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- /* Slider verify trigger */
- .slider-verify-trigger {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 0 16px;
- height: 44px;
- border-radius: 2px;
- border: 1.5px solid #e2e8f0;
- background: #f8fafc;
- cursor: pointer;
- transition: all 0.25s ease;
- font-size: 0.875rem;
- color: #64748b;
- font-weight: 500;
- user-select: none;
- }
- .slider-verify-trigger:hover {
- border-color: #3b82f6;
- background: #eff6ff;
- color: #2563eb;
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
- }
- .slider-verify-trigger.verified {
- border-color: #22c55e;
- background: #f0fdf4;
- color: #16a34a;
- cursor: default;
- }
- .slider-verify-trigger.verified:hover {
- box-shadow: none;
- }
- .trigger-icon {
- display: flex;
- align-items: center;
- font-size: 1rem;
- }
- .trigger-arrow {
- margin-left: auto;
- font-size: 0.875rem;
- opacity: 0.5;
- transition: transform 0.2s;
- }
- .slider-verify-trigger:hover .trigger-arrow {
- transform: translateX(3px);
- opacity: 0.8;
- }
- /* Slider modal overlay */
- .slider-modal-overlay {
- position: fixed;
- inset: 0;
- background: rgba(15, 23, 42, 0.5);
- backdrop-filter: blur(8px);
- -webkit-backdrop-filter: blur(8px);
- z-index: 9999;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 1rem;
- }
- /* Slider modal card */
- .slider-modal {
- position: relative;
- background: rgba(255, 255, 255, 0.98);
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
- border-radius: 20px;
- padding: 2rem 1.75rem 1.5rem;
- width: 100%;
- max-width: 400px;
- box-shadow:
- 0 24px 48px rgba(15, 23, 42, 0.15),
- 0 8px 16px rgba(15, 23, 42, 0.08);
- border: 1px solid rgba(255, 255, 255, 0.6);
- }
- .slider-modal-close {
- position: absolute;
- top: 14px;
- right: 14px;
- width: 32px;
- height: 32px;
- border-radius: 50%;
- border: none;
- background: #f1f5f9;
- color: #94a3b8;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 0.875rem;
- transition: all 0.2s ease;
- padding: 0;
- }
- .slider-modal-close:hover {
- background: #e2e8f0;
- color: #475569;
- }
- .slider-modal-header {
- text-align: center;
- margin-bottom: 1.25rem;
- }
- .slider-modal-icon {
- width: 52px;
- height: 52px;
- background: linear-gradient(135deg, #eff6ff, #dbeafe);
- border-radius: 14px;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 1.375rem;
- color: #3b82f6;
- margin: 0 auto 12px;
- box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
- }
- .slider-modal-title {
- font-size: 1.125rem;
- font-weight: 700;
- color: #0f172a;
- margin: 0 0 4px;
- }
- .slider-modal-desc {
- font-size: 0.8125rem;
- color: #94a3b8;
- margin: 0;
- }
- .slider-modal-body {
- display: flex;
- justify-content: center;
- border-radius: 12px;
- overflow: hidden;
- }
- .slider-modal-tip {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- margin: 14px 0 0;
- font-size: 0.75rem;
- color: #cbd5e1;
- }
- /* SMS button */
- .sms-button {
- min-width: 120px;
- white-space: nowrap;
- border-radius: 2px;
- }
- .mb-3 {
- margin-bottom: 12px;
- }
- /* Form options */
- .form-options {
- margin-bottom: 24px;
- }
- .checkbox-label {
- font-size: 0.875rem;
- color: #64748b;
- }
- /* Login button */
- .login-button {
- margin-top: 8px;
- position: relative;
- overflow: hidden;
- isolation: isolate;
- border-radius: 0px !important;
- border: 1px solid rgba(22, 93, 255, 0.22);
- background: linear-gradient(
- 135deg,
- var(--primary-400) 0%,
- var(--primary-500) 55%,
- var(--primary-600) 100%
- ) !important;
- font-size: 0.9375rem;
- font-weight: 600;
- height: 52px;
- box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, 0.22),
- 0 10px 22px rgba(22, 93, 255, 0.24),
- 0 2px 6px rgba(22, 93, 255, 0.16);
- transition:
- transform 0.25s ease,
- box-shadow 0.25s ease,
- border-color 0.25s ease,
- background 0.25s ease;
- cursor: pointer;
- }
- .login-trust-strip {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 8px;
- margin-top: 14px;
- }
- .login-trust-strip span {
- display: inline-flex;
- min-width: 0;
- height: 34px;
- align-items: center;
- justify-content: center;
- gap: 5px;
- border: 1px solid #e2e8f0;
- border-radius: 2px;
- background: #f8fafc;
- color: #64748b;
- font-size: 12px;
- font-weight: 700;
- white-space: nowrap;
- }
- .login-trust-strip i {
- color: #3b82f6;
- font-size: 14px;
- }
- .login-button::after {
- content: '';
- position: absolute;
- inset: 1px;
- border-radius: 2px;
- background: linear-gradient(
- 180deg,
- rgba(255, 255, 255, 0.14) 0%,
- rgba(255, 255, 255, 0.04) 34%,
- rgba(255, 255, 255, 0) 62%
- );
- z-index: 1;
- pointer-events: none;
- opacity: 0.95;
- transition: opacity 0.25s ease;
- }
- .login-button:hover {
- transform: translateY(-2px) scale(1.005);
- border-color: rgba(22, 93, 255, 0.32);
- background: linear-gradient(
- 135deg,
- var(--primary-300) 0%,
- var(--primary-500) 58%,
- var(--primary-700) 100%
- ) !important;
- box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, 0.24),
- 0 14px 28px rgba(22, 93, 255, 0.3),
- 0 4px 10px rgba(22, 93, 255, 0.18);
- }
- .login-button:hover::after {
- opacity: 1;
- }
- .login-button:active {
- transform: translateY(0) scale(0.985);
- }
- .login-button:active::after {
- opacity: 0.86;
- }
- .login-button :deep(.n-button__content) {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- position: relative;
- z-index: 2;
- }
- .login-button :deep(.n-base-loading),
- .login-button :deep(.n-button__icon) {
- position: relative;
- z-index: 2;
- }
- .button-text {
- letter-spacing: 0.01em;
- color: #fff;
- }
- .button-icon {
- font-size: 1.125rem;
- color: #fff;
- transition:
- transform 0.25s ease,
- opacity 0.25s ease;
- }
- .login-button:hover .button-icon {
- transform: translateX(4px);
- }
- /* Social login section */
- .social-login-section {
- margin-top: 28px;
- }
- .social-divider {
- position: relative;
- text-align: center;
- margin-bottom: 20px;
- }
- .social-divider::before {
- content: '';
- position: absolute;
- left: 0;
- top: 50%;
- width: 100%;
- height: 1px;
- background: linear-gradient(90deg, transparent, #e2e8f0 20%, #e2e8f0 80%, transparent);
- }
- .divider-text {
- position: relative;
- display: inline-block;
- padding: 0 16px;
- background: #fff;
- font-size: 0.8125rem;
- color: #94a3b8;
- }
- .social-buttons {
- display: flex;
- justify-content: center;
- gap: 12px;
- }
- .social-button {
- width: 44px;
- height: 44px;
- border-radius: 2px;
- border: 1.5px solid #e2e8f0;
- background: #f8fafc;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all 0.25s ease;
- padding: 0;
- }
- .social-button:hover {
- border-color: #3b82f6;
- background: #eff6ff;
- transform: translateY(-3px);
- box-shadow: 0 6px 16px rgba(59, 130, 246, 0.15);
- }
- .social-icon {
- width: 24px;
- height: 24px;
- object-fit: contain;
- }
- .social-icon-fallback {
- font-size: 20px;
- color: #64748b;
- }
- .social-button:hover .social-icon-fallback {
- color: #3b82f6;
- }
- /* Dark mode */
- .dark .login-bg-animated {
- background:
- radial-gradient(circle at 26% 18%, rgba(59, 130, 246, 0.28) 0%, rgba(59, 130, 246, 0) 28%),
- linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%);
- }
- .dark .login-card {
- background: rgba(30, 41, 59, 0.95);
- border-color: rgba(255, 255, 255, 0.1);
- }
- .dark .login-form-wrapper {
- background: transparent;
- }
- .dark .form-title {
- color: #f1f5f9;
- }
- .dark .form-subtitle {
- color: #94a3b8;
- }
- .dark .form-label {
- color: #cbd5e1;
- }
- .dark .social-divider::before {
- background: linear-gradient(90deg, transparent, #334155 20%, #334155 80%, transparent);
- }
- .dark .divider-text {
- background: transparent;
- }
- .dark .slider-modal {
- background: rgba(30, 41, 59, 0.98);
- border-color: rgba(255, 255, 255, 0.1);
- }
- .dark .slider-modal-title {
- color: #f1f5f9;
- }
- /* Reduced motion */
- @media (prefers-reduced-motion: reduce) {
- *,
- *::before,
- *::after {
- animation-duration: 0.01ms !important;
- animation-iteration-count: 1 !important;
- transition-duration: 0.01ms !important;
- }
- .login-bg-animated::before,
- .login-brand::after,
- .login-card::after {
- animation: none;
- }
- }
- /* ICP Record */
- .icp-record {
- position: absolute;
- bottom: 12px;
- left: 50%;
- transform: translateX(-50%);
- z-index: 3;
- text-align: center;
- }
- .icp-record a {
- font-size: 0.75rem;
- color: rgba(100, 116, 139, 0.7);
- text-decoration: none;
- transition: color 0.2s ease;
- }
- .icp-record a:hover {
- color: rgba(59, 130, 246, 0.9);
- }
- .dark .icp-record a {
- color: rgba(148, 163, 184, 0.6);
- }
- .dark .icp-record a:hover {
- color: rgba(96, 165, 250, 0.8);
- }
- /* Responsive */
- @media (max-width: 767px) {
- .login-container {
- align-items: stretch;
- padding: 0;
- }
- .form-title {
- font-size: 1.375rem;
- }
- .login-card {
- border-radius: 0;
- }
- }
- </style>
|