index.vue 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143
  1. <template>
  2. <div class="login-container">
  3. <div class="login-bg-animated" />
  4. <div class="login-card">
  5. <!-- Left side - Branding -->
  6. <div class="login-brand">
  7. <div class="brand-content">
  8. <div class="logo-lockup">
  9. <img :src="brandLogoUrl" class="logo-img" alt="Logo" @error="handleBrandLogoError">
  10. <div class="logo-text">
  11. ForgeAdmin
  12. </div>
  13. </div>
  14. <div class="banner">
  15. <n-carousel
  16. autoplay
  17. :interval="4200"
  18. show-arrow
  19. class="stack-carousel"
  20. >
  21. <div class="carousel-item">
  22. <div class="carousel-copy">
  23. <div class="carousel-title">
  24. 开箱即用的后台模板
  25. </div>
  26. <div class="carousel-subtitle">
  27. 常见列表、表单、详情和权限场景快速落地
  28. </div>
  29. </div>
  30. <img :src="loginCarouselImage" class="carousel-image" alt="后台系统能力总览">
  31. </div>
  32. <div class="carousel-item">
  33. <div class="carousel-copy">
  34. <div class="carousel-title">
  35. 业务对象驱动生成
  36. </div>
  37. <div class="carousel-subtitle">
  38. 字段、操作、列表和表单配置保持一致
  39. </div>
  40. </div>
  41. <img :src="loginCarouselImage" class="carousel-image" alt="业务对象驱动生成">
  42. </div>
  43. <div class="carousel-item">
  44. <div class="carousel-copy">
  45. <div class="carousel-title">
  46. 权限与流程统一治理
  47. </div>
  48. <div class="carousel-subtitle">
  49. 多租户、数据权限和审批流程统一接入
  50. </div>
  51. </div>
  52. <img :src="loginCarouselImage" class="carousel-image" alt="权限与流程统一治理">
  53. </div>
  54. <template #arrow="{ prev, next }">
  55. <button type="button" class="arco-carousel-arrow-left" title="上一页" @click="prev">
  56. <i class="ai-icon:chevron-left" />
  57. </button>
  58. <button type="button" class="arco-carousel-arrow-right" title="下一页" @click="next">
  59. <i class="ai-icon:chevron-right" />
  60. </button>
  61. </template>
  62. <template #dots="{ total, currentIndex, to }">
  63. <div class="arco-carousel-dots" role="tablist">
  64. <button
  65. v-for="index in total"
  66. :key="index"
  67. type="button"
  68. class="arco-carousel-dot"
  69. :class="{ active: currentIndex === index - 1 }"
  70. :aria-selected="currentIndex === index - 1"
  71. @click="to(index - 1)"
  72. />
  73. </div>
  74. </template>
  75. </n-carousel>
  76. </div>
  77. </div>
  78. </div>
  79. <!-- Right side - Login form -->
  80. <div class="login-form-wrapper">
  81. <div class="login-form">
  82. <div class="form-header">
  83. <h2 class="form-title">
  84. 登录ForgeAdmin
  85. </h2>
  86. <p class="form-subtitle">
  87. {{ loginSubtitle }}
  88. </p>
  89. </div>
  90. <div class="form-body">
  91. <!-- Tenant -->
  92. <div v-if="tenantSelectOptions.length > 0" class="form-group tenant-form-group">
  93. <label for="tenant" class="form-label">登录租户</label>
  94. <div class="tenant-select-wrapper">
  95. <n-select
  96. id="tenant"
  97. v-model:value="selectedTenantId"
  98. class="modern-select"
  99. :options="tenantSelectOptions"
  100. :loading="tenantConfigApplying"
  101. :disabled="tenantConfigApplying"
  102. filterable
  103. size="large"
  104. placeholder="请选择登录租户"
  105. />
  106. </div>
  107. </div>
  108. <!-- Username -->
  109. <div class="form-group">
  110. <label for="username" class="form-label">用户名</label>
  111. <div class="input-wrapper">
  112. <n-input
  113. id="username"
  114. v-model:value="loginInfo.username"
  115. autofocus
  116. class="modern-input"
  117. placeholder="请输入用户名"
  118. :maxlength="20"
  119. size="large"
  120. >
  121. <template #prefix>
  122. <i class="input-icon ai-icon:user" />
  123. </template>
  124. </n-input>
  125. </div>
  126. </div>
  127. <!-- Password -->
  128. <div class="form-group">
  129. <label for="password" class="form-label">密码</label>
  130. <div class="input-wrapper">
  131. <n-input
  132. id="password"
  133. v-model:value="loginInfo.password"
  134. class="modern-input"
  135. type="password"
  136. show-password-on="click"
  137. placeholder="请输入密码"
  138. :maxlength="20"
  139. size="large"
  140. @keydown.enter="handleLogin()"
  141. >
  142. <template #prefix>
  143. <i class="input-icon ai-icon:lock" />
  144. </template>
  145. </n-input>
  146. </div>
  147. </div>
  148. <!-- 验证码区域 - 根据配置显示不同类型的验证码 -->
  149. <div v-if="captchaEnabled" class="form-group">
  150. <!-- 图形验证码 -->
  151. <template v-if="captchaType === 'graphical'">
  152. <label for="captcha" class="form-label">验证码</label>
  153. <div class="captcha-wrapper">
  154. <div class="input-wrapper flex-1">
  155. <n-input
  156. id="captcha"
  157. v-model:value="loginInfo.code"
  158. class="modern-input"
  159. placeholder="请输入验证码"
  160. :maxlength="6"
  161. size="large"
  162. @keydown.enter="handleLogin()"
  163. >
  164. <template #prefix>
  165. <i class="input-icon ai-icon:key" />
  166. </template>
  167. </n-input>
  168. </div>
  169. <div
  170. class="captcha-image"
  171. title="点击刷新验证码"
  172. role="button"
  173. tabindex="0"
  174. @click="refreshCaptcha"
  175. @keydown.enter="refreshCaptcha"
  176. >
  177. <img
  178. v-if="captchaImage"
  179. :src="captchaImage"
  180. alt="验证码"
  181. class="captcha-img"
  182. >
  183. <div v-else class="captcha-loading">
  184. <i class="ai-icon:loader animate-spin" />
  185. </div>
  186. </div>
  187. </div>
  188. </template>
  189. <!-- 滑块验证码 - 已改为浮层弹出形式,点击登录按钮触发 -->
  190. <template v-if="captchaType === 'slider'">
  191. <div class="slider-verify-trigger" :class="{ verified: sliderSuccess }" @click="!sliderSuccess && openSliderModal()">
  192. <div class="trigger-icon">
  193. <i v-if="sliderSuccess" class="ai-icon:check-circle" style="color:#22C55E" />
  194. <i v-else class="ai-icon:shield" />
  195. </div>
  196. <span>{{ sliderSuccess ? '安全验证已通过' : '点击进行安全验证' }}</span>
  197. <i v-if="!sliderSuccess" class="trigger-arrow ai-icon:chevron-right" />
  198. </div>
  199. </template>
  200. <!-- 短信验证码 -->
  201. <template v-if="captchaType === 'sms'">
  202. <label for="phone" class="form-label">手机号</label>
  203. <div class="input-wrapper mb-3">
  204. <n-input
  205. id="phone"
  206. v-model:value="loginInfo.phone"
  207. class="modern-input"
  208. placeholder="请输入手机号"
  209. :maxlength="11"
  210. size="large"
  211. >
  212. <template #prefix>
  213. <i class="input-icon ai-icon:phone" />
  214. </template>
  215. </n-input>
  216. </div>
  217. <label for="smsCode" class="form-label">短信验证码</label>
  218. <div class="captcha-wrapper">
  219. <div class="input-wrapper flex-1">
  220. <n-input
  221. id="smsCode"
  222. v-model:value="loginInfo.code"
  223. class="modern-input"
  224. placeholder="请输入短信验证码"
  225. :maxlength="6"
  226. size="large"
  227. @keydown.enter="handleLogin()"
  228. >
  229. <template #prefix>
  230. <i class="input-icon ai-icon:key" />
  231. </template>
  232. </n-input>
  233. </div>
  234. <n-button
  235. :disabled="smsCountdown > 0 || !isValidPhone"
  236. class="sms-button"
  237. size="large"
  238. @click="sendSmsCode"
  239. >
  240. {{ smsCountdown > 0 ? `${smsCountdown}s后重发` : '获取验证码' }}
  241. </n-button>
  242. </div>
  243. </template>
  244. </div>
  245. <!-- Remember me -->
  246. <div class="form-options">
  247. <n-checkbox
  248. :checked="isRemember"
  249. :on-update:checked="(val) => (isRemember = val)"
  250. >
  251. <span class="checkbox-label">记住我</span>
  252. </n-checkbox>
  253. </div>
  254. <!-- Submit button -->
  255. <n-button
  256. class="login-button"
  257. type="primary"
  258. size="large"
  259. :loading="loading"
  260. block
  261. @click="onLoginClick()"
  262. >
  263. <span class="button-text">登录</span>
  264. <i v-if="!loading" class="button-icon ai-icon:arrow-right" />
  265. </n-button>
  266. <div class="login-trust-strip">
  267. <span><i class="ai-icon:shield" />安全登录</span>
  268. <span><i class="ai-icon:layers" />租户隔离</span>
  269. <span><i class="ai-icon:check-circle" />操作审计</span>
  270. </div>
  271. <!-- Social login buttons -->
  272. <div v-if="socialPlatforms.length > 0" class="social-login-section">
  273. <div class="social-divider">
  274. <span class="divider-text">其他登录方式</span>
  275. </div>
  276. <div class="social-buttons">
  277. <button
  278. v-for="platform in socialPlatforms"
  279. :key="platform.platform"
  280. class="social-button"
  281. :title="platform.platformName"
  282. @click="handleSocialLogin(platform.platform)"
  283. >
  284. <img
  285. v-if="platform.platformLogoBase64"
  286. :src="`data:image/png;base64,${platform.platformLogoBase64}`"
  287. :alt="platform.platformName"
  288. class="social-icon"
  289. >
  290. <i v-else class="social-icon-fallback ai-icon:link" />
  291. </button>
  292. </div>
  293. </div>
  294. </div>
  295. </div>
  296. </div>
  297. </div>
  298. <!-- ICP备案号 -->
  299. <div class="icp-record">
  300. <span v-if="copyrightInfo">{{ copyrightInfo }}</span>
  301. <a v-else href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">蒙ICP备2026004895号</a>
  302. </div>
  303. </div>
  304. <!-- 滑块验证浮层 -->
  305. <Transition name="modal">
  306. <div v-if="showSliderModal" class="slider-modal-overlay" @click.self="closeSliderModal">
  307. <div class="slider-modal">
  308. <!-- 关闭按钮 -->
  309. <button class="slider-modal-close" @click="closeSliderModal">
  310. <i class="ai-icon:x" />
  311. </button>
  312. <!-- 标题区 -->
  313. <div class="slider-modal-header">
  314. <div class="slider-modal-icon">
  315. <i class="ai-icon:shield" />
  316. </div>
  317. <h3 class="slider-modal-title">
  318. 安全验证
  319. </h3>
  320. <p class="slider-modal-desc">
  321. 请拖动滑块到正确位置,完成拼图
  322. </p>
  323. </div>
  324. <!-- 滑块验证组件 -->
  325. <div class="slider-modal-body">
  326. <SlideVerify
  327. ref="slideVerifyRef"
  328. :w="340"
  329. :h="170"
  330. :slider-l="42"
  331. :slider-r="8"
  332. :accuracy="8"
  333. :imgs="slideImages"
  334. :show-refresh="true"
  335. refresh-text="刷新"
  336. text="拖动滑块完成拼图"
  337. success-text="验证成功!"
  338. fail-text="验证失败,请重试"
  339. @success="onSlideSuccess"
  340. @fail="onSlideFail"
  341. @refresh="onSlideRefresh"
  342. />
  343. </div>
  344. <!-- 底部辅助文字 -->
  345. <p class="slider-modal-tip">
  346. <i class="ai-icon:info" />
  347. 如果拖动困难,可点击刷新重试
  348. </p>
  349. </div>
  350. </div>
  351. </Transition>
  352. </template>
  353. <script setup>
  354. import { useStorage } from '@vueuse/core'
  355. import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
  356. import SlideVerify from 'vue3-slide-verify'
  357. import mainApi from '@/api'
  358. import loginCarouselImage from '@/assets/images/login-carousel-platform.png'
  359. import defaultLogoUrl from '@/assets/images/logo_text.png'
  360. import { useAppStore, useAuthStore, usePermissionStore, useTenantStore, useUserStore } from '@/store'
  361. import { lStorage } from '@/utils'
  362. import { loadRuntimeCryptoConfig } from '@/utils/crypto/crypto-config'
  363. import { encryptPassword, initKeyExchange } from '@/utils/crypto/key-exchange'
  364. import { request } from '@/utils/http'
  365. import { normalizePageTitle } from '@/utils/page-title'
  366. import { applyTenantConfig, resolveTenantPublicAssetUrl } from '@/utils/tenant-config'
  367. import api from './api'
  368. import 'vue3-slide-verify/dist/style.css'
  369. const authStore = useAuthStore()
  370. const userStore = useUserStore()
  371. const appStore = useAppStore()
  372. const router = useRouter()
  373. const route = useRoute()
  374. const userClient = import.meta.env.VITE_USER_CLIENT || 'pc'
  375. const LOGIN_TENANT_STORAGE_KEY = 'login_selected_tenant_id'
  376. const SOCIAL_TENANT_MAP_KEY = 'login_social_tenant_map'
  377. const tenantOptions = ref([])
  378. const selectedTenantId = ref(null)
  379. const tenantConfigApplying = ref(false)
  380. const brandLogoUrl = ref(defaultLogoUrl)
  381. const loginConfig = ref(null)
  382. const selectedTenantOption = computed(() => tenantOptions.value.find(item => String(item.value) === String(selectedTenantId.value)) || null)
  383. const loginSubtitle = computed(() => selectedTenantOption.value?.tenantName
  384. ? `${selectedTenantOption.value.tenantName} 租户入口`
  385. : '请输入账号密码')
  386. const copyrightInfo = computed(() => normalizePageTitle(loginConfig.value?.copyrightInfo))
  387. const tenantSelectOptions = computed(() => tenantOptions.value.map(item => ({
  388. label: item.label,
  389. value: item.value,
  390. })))
  391. let tenantInitCompleted = false
  392. const loginInfo = ref({
  393. username: '',
  394. password: '',
  395. code: '', // 验证码
  396. codeKey: '', // 验证码key
  397. phone: '', // 手机号(短信验证码使用)
  398. })
  399. const captchaImage = ref('') // 验证码图片(Base64)
  400. const captchaExpires = ref(0) // 验证码过期时间
  401. // 验证码类型:graphical(图形验证码), slider(滑块验证码), sms(短信验证码)
  402. const captchaType = ref('graphical')
  403. const captchaEnabled = computed(() => loginConfig.value?.enableCaptcha !== false)
  404. // 滑块验证码相关 (vue3-slide-verify)
  405. const slideVerifyRef = ref(null)
  406. const slideImages = ref([]) // 滑块验证码背景图片列表
  407. const sliderSuccess = ref(false)
  408. const sliderFail = ref(false)
  409. const showSliderModal = ref(false) // 滑块浮层显示状态
  410. // 短信验证码相关
  411. const smsCountdown = ref(0)
  412. const smsTimer = ref(null)
  413. const localLoginInfo = lStorage.get('loginInfo')
  414. if (localLoginInfo) {
  415. loginInfo.value.username = localLoginInfo.username || ''
  416. loginInfo.value.password = localLoginInfo.password || ''
  417. }
  418. const isRemember = useStorage('isRemember', true)
  419. const loading = ref(false)
  420. // 三方登录平台列表
  421. const socialPlatforms = ref([])
  422. const socialLoading = ref(false)
  423. watch(selectedTenantId, (tenantId) => {
  424. if (!tenantInitCompleted || tenantConfigApplying.value)
  425. return
  426. syncSelectedTenantToStorage(tenantId)
  427. tenantConfigApplying.value = true
  428. refreshLoginContext()
  429. .finally(() => {
  430. tenantConfigApplying.value = false
  431. })
  432. })
  433. // 手机号验证
  434. const isValidPhone = computed(() => {
  435. const phone = loginInfo.value.phone
  436. return phone && /^1[3-9]\d{9}$/.test(phone)
  437. })
  438. function normalizeTenantId(value) {
  439. if (Array.isArray(value)) {
  440. const first = value.find(item => item !== null && item !== undefined && item !== '')
  441. return normalizeTenantId(first)
  442. }
  443. if (value === null || value === undefined || value === '')
  444. return null
  445. const parsed = Number(value)
  446. return Number.isNaN(parsed) ? null : parsed
  447. }
  448. function syncSelectedTenantToStorage(tenantId) {
  449. const normalizedTenantId = normalizeTenantId(tenantId)
  450. if (normalizedTenantId === null) {
  451. lStorage.remove(LOGIN_TENANT_STORAGE_KEY)
  452. return
  453. }
  454. lStorage.set(LOGIN_TENANT_STORAGE_KEY, normalizedTenantId)
  455. }
  456. function getSelectedTenantFromStorage() {
  457. return normalizeTenantId(lStorage.get(LOGIN_TENANT_STORAGE_KEY))
  458. }
  459. function getSocialTenantMap() {
  460. return lStorage.get(SOCIAL_TENANT_MAP_KEY) || {}
  461. }
  462. function setSocialTenantMap(map) {
  463. lStorage.set(SOCIAL_TENANT_MAP_KEY, map)
  464. }
  465. function rememberSocialTenant(state, tenantId) {
  466. const normalizedTenantId = normalizeTenantId(tenantId)
  467. if (!state || normalizedTenantId === null)
  468. return
  469. const map = getSocialTenantMap()
  470. map[state] = normalizedTenantId
  471. setSocialTenantMap(map)
  472. }
  473. function applyBrandLogo(config) {
  474. brandLogoUrl.value = resolveTenantPublicAssetUrl(config, 'logo') || defaultLogoUrl
  475. }
  476. function handleBrandLogoError() {
  477. if (brandLogoUrl.value !== defaultLogoUrl)
  478. brandLogoUrl.value = defaultLogoUrl
  479. }
  480. async function applyLoginPageConfig(config) {
  481. loginConfig.value = config || null
  482. await applyTenantConfig(config, appStore)
  483. applyBrandLogo(config)
  484. }
  485. async function loadLoginTenantOptions() {
  486. try {
  487. const res = await api.getLoginTenantOptions()
  488. if (res.code !== 200) {
  489. tenantOptions.value = []
  490. return
  491. }
  492. tenantOptions.value = (res.data || []).map(item => ({
  493. ...item,
  494. value: normalizeTenantId(item.tenantId),
  495. label: item.tenantName || item.systemName || item.browserTitle || String(item.tenantId),
  496. })).filter(item => item.value !== null)
  497. const storedTenantId = getSelectedTenantFromStorage()
  498. const initialTenantId = tenantOptions.value.find(item => String(item.value) === String(storedTenantId))?.value
  499. ?? tenantOptions.value[0]?.value
  500. ?? null
  501. if (initialTenantId !== null) {
  502. selectedTenantId.value = initialTenantId
  503. syncSelectedTenantToStorage(initialTenantId)
  504. }
  505. }
  506. catch (error) {
  507. console.error('获取登录租户列表失败:', error)
  508. }
  509. }
  510. // 获取登录配置
  511. async function loadLoginConfig() {
  512. const currentTenantId = normalizeTenantId(selectedTenantId.value)
  513. try {
  514. const res = await api.getLoginConfig(userClient, currentTenantId)
  515. if (res.code === 200 && res.data) {
  516. await applyLoginPageConfig(res.data)
  517. captchaType.value = res.data.captchaType || 'graphical'
  518. // 根据验证码类型加载对应的验证码
  519. if (captchaEnabled.value) {
  520. await loadCaptchaByType()
  521. }
  522. else {
  523. loginInfo.value.code = ''
  524. loginInfo.value.codeKey = ''
  525. }
  526. }
  527. }
  528. catch (error) {
  529. console.error('获取登录配置失败:', error)
  530. // 使用默认配置
  531. captchaType.value = 'graphical'
  532. await applyLoginPageConfig(null)
  533. await refreshCaptcha()
  534. }
  535. }
  536. // 获取已启用的三方登录平台
  537. async function loadSocialPlatforms() {
  538. try {
  539. socialLoading.value = true
  540. const res = await api.getSocialPlatforms(normalizeTenantId(selectedTenantId.value))
  541. if (res.code === 200 && res.data) {
  542. socialPlatforms.value = res.data.filter(p => p.enabled)
  543. }
  544. }
  545. catch (error) {
  546. console.error('获取三方登录平台失败:', error)
  547. }
  548. finally {
  549. socialLoading.value = false
  550. }
  551. }
  552. // 处理三方登录
  553. async function handleSocialLogin(platform) {
  554. try {
  555. const tenantId = normalizeTenantId(selectedTenantId.value)
  556. const res = await api.getSocialAuthUrl(platform, tenantId)
  557. if (res.code === 200 && res.data) {
  558. rememberSocialTenant(res.data.state, tenantId)
  559. // 打开授权窗口
  560. const width = 600
  561. const height = 500
  562. const left = (window.innerWidth - width) / 2
  563. const top = (window.innerHeight - height) / 2
  564. const authWindow = window.open(
  565. res.data.authUrl,
  566. 'social_auth',
  567. `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,resizable=yes`,
  568. )
  569. // 监听授权窗口关闭
  570. const checkClosed = setInterval(() => {
  571. if (authWindow.closed) {
  572. clearInterval(checkClosed)
  573. }
  574. }, 500)
  575. }
  576. else {
  577. $message.error(res.msg || '获取授权链接失败')
  578. }
  579. }
  580. catch (error) {
  581. console.error('获取三方授权链接失败:', error)
  582. $message.error('获取授权链接失败')
  583. }
  584. }
  585. // 根据验证码类型加载验证码
  586. async function loadCaptchaByType() {
  587. switch (captchaType.value) {
  588. case 'slider':
  589. // 加载滑块验证码图片
  590. await loadSlideImages()
  591. // 重置滑块状态
  592. sliderSuccess.value = false
  593. sliderFail.value = false
  594. loginInfo.value.code = ''
  595. // 注意:组件实例在模板渲染后才可用,不在此处调用reset
  596. break
  597. case 'sms':
  598. // 短信验证码不需要预加载,用户点击发送时才获取
  599. break
  600. case 'graphical':
  601. default:
  602. await refreshCaptcha()
  603. break
  604. }
  605. }
  606. // 获取图形验证码
  607. async function refreshCaptcha() {
  608. try {
  609. const res = await api.getCaptcha()
  610. if (res.code === 200 && res.data) {
  611. loginInfo.value.codeKey = res.data.codeKey // 验证码key
  612. captchaImage.value = res.data.image || '' // 验证码图片(Base64)
  613. captchaExpires.value = res.data.expiresIn || 300 // 过期时间
  614. // 清空验证码输入框
  615. loginInfo.value.code = ''
  616. }
  617. }
  618. catch (error) {
  619. console.error('获取验证码失败:', error)
  620. $message.error('获取验证码失败')
  621. }
  622. }
  623. // 加载滑块验证码图片列表
  624. async function loadSlideImages() {
  625. // 使用可靠的图片源,避免加载失败
  626. // 实际项目中可以配置自己的图片或使用后端提供的图片
  627. slideImages.value = [
  628. 'https://picsum.photos/id/10/320/160',
  629. 'https://picsum.photos/id/11/320/160',
  630. 'https://picsum.photos/id/12/320/160',
  631. 'https://picsum.photos/id/13/320/160',
  632. 'https://picsum.photos/id/14/320/160',
  633. ]
  634. }
  635. // 打开滑块验证浮层
  636. function openSliderModal() {
  637. showSliderModal.value = true
  638. // 浮层打开后重置验证状态
  639. nextTick(() => {
  640. if (slideVerifyRef.value && typeof slideVerifyRef.value.refresh === 'function') {
  641. slideVerifyRef.value.refresh()
  642. }
  643. })
  644. }
  645. // 关闭滑块验证浮层
  646. function closeSliderModal() {
  647. showSliderModal.value = false
  648. }
  649. // 登录按钮点击处理
  650. function onLoginClick() {
  651. if (captchaEnabled.value && captchaType.value === 'slider' && !sliderSuccess.value) {
  652. openSliderModal()
  653. return
  654. }
  655. handleLogin()
  656. }
  657. // 滑块验证成功回调
  658. function onSlideSuccess() {
  659. sliderSuccess.value = true
  660. sliderFail.value = false
  661. loginInfo.value.code = 'verified'
  662. // 延迟关闭浮层再登录,让用户看到成功动画
  663. setTimeout(() => {
  664. closeSliderModal()
  665. handleLogin()
  666. }, 800)
  667. }
  668. // 滑块验证失败回调
  669. function onSlideFail() {
  670. sliderFail.value = true
  671. sliderSuccess.value = false
  672. loginInfo.value.code = ''
  673. }
  674. // 刷新滑块验证码
  675. function onSlideRefresh() {
  676. sliderSuccess.value = false
  677. sliderFail.value = false
  678. loginInfo.value.code = ''
  679. // 组件会自动刷新,这里可以添加额外的逻辑
  680. }
  681. // 处理登录失败
  682. async function handleLoginFailure() {
  683. if (!captchaEnabled.value)
  684. return
  685. // 根据验证码类型处理
  686. if (captchaType.value === 'slider') {
  687. // 滑块验证码需要重置组件
  688. sliderSuccess.value = false
  689. sliderFail.value = false
  690. loginInfo.value.code = ''
  691. // 等待 DOM 更新后调用组件刷新方法
  692. await nextTick()
  693. if (slideVerifyRef.value && typeof slideVerifyRef.value.refresh === 'function') {
  694. slideVerifyRef.value.refresh()
  695. }
  696. }
  697. else if (captchaType.value === 'graphical') {
  698. // 图形验证码刷新
  699. await refreshCaptcha()
  700. }
  701. // 短信验证码不需要刷新,保持原样
  702. }
  703. // 发送短信验证码
  704. async function sendSmsCode() {
  705. if (!isValidPhone.value) {
  706. $message.warning('请输入正确的手机号')
  707. return
  708. }
  709. try {
  710. const res = await api.sendSmsCaptcha(loginInfo.value.phone)
  711. if (res.code === 200 && res.data) {
  712. if (res.data.status === 'success') {
  713. $message.success('验证码发送成功')
  714. // 开始倒计时
  715. smsCountdown.value = res.data.interval || 60
  716. startSmsCountdown()
  717. }
  718. else {
  719. $message.error(res.data.message || '验证码发送失败')
  720. }
  721. }
  722. }
  723. catch (error) {
  724. console.error('发送短信验证码失败:', error)
  725. $message.error('发送短信验证码失败')
  726. }
  727. }
  728. // 短信验证码倒计时
  729. function startSmsCountdown() {
  730. if (smsTimer.value) {
  731. clearInterval(smsTimer.value)
  732. }
  733. smsTimer.value = setInterval(() => {
  734. if (smsCountdown.value > 0) {
  735. smsCountdown.value--
  736. }
  737. else {
  738. clearInterval(smsTimer.value)
  739. }
  740. }, 1000)
  741. }
  742. async function handleLogin() {
  743. const { username, password, code, codeKey, phone } = loginInfo.value
  744. const tenantId = normalizeTenantId(selectedTenantId.value)
  745. // 基础验证
  746. if (!username || !password)
  747. return $message.warning('请输入用户名和密码')
  748. if (tenantOptions.value.length > 0 && tenantId === null) {
  749. return $message.warning('请选择登录租户')
  750. }
  751. if (captchaEnabled.value) {
  752. // 根据验证码类型进行验证
  753. if (captchaType.value === 'slider') {
  754. if (!sliderSuccess.value) {
  755. return $message.warning('请完成滑块验证')
  756. }
  757. // 滑块验证码使用组件自带的验证,这里只需要确认已通过
  758. // 为了安全,可以添加一个临时的token或codeKey
  759. }
  760. else if (captchaType.value === 'sms') {
  761. if (!phone)
  762. return $message.warning('请输入手机号')
  763. if (!code)
  764. return $message.warning('请输入短信验证码')
  765. }
  766. else {
  767. if (!code)
  768. return $message.warning('请输入验证码')
  769. }
  770. }
  771. try {
  772. loading.value = true
  773. $message.loading('正在验证,请稍后...', { key: 'login' })
  774. await loadRuntimeCryptoConfig()
  775. // 登录密码 RSA 与通用 API 传输加密相互独立,以服务端登录配置为准。
  776. const passwordEncryptionEnabled = loginConfig.value?.enablePasswordEncryption !== false
  777. const submittedPassword = await encryptPassword(password, request, passwordEncryptionEnabled)
  778. // 构造登录参数 - 使用新的后端接口格式
  779. const params = {
  780. username,
  781. password: submittedPassword,
  782. code,
  783. codeKey,
  784. phone, // 短信验证码时需要
  785. tenantId,
  786. authType: captchaEnabled.value ? 'password_captcha' : 'password',
  787. userClient,
  788. appId: import.meta.env.VITE_APP_ID || 'forge_pc_001', // 客户端AppId
  789. }
  790. const res = await api.login(params)
  791. if (res.code === 200) {
  792. if (isRemember.value) {
  793. lStorage.set('loginInfo', { username, password })
  794. }
  795. else {
  796. lStorage.remove('loginInfo')
  797. }
  798. onLoginSuccess(res.data)
  799. }
  800. else {
  801. // 登录失败后刷新验证码
  802. await handleLoginFailure()
  803. }
  804. }
  805. catch (error) {
  806. $message.destroy('login')
  807. console.error(error)
  808. // 登录失败后刷新验证码
  809. await handleLoginFailure()
  810. }
  811. loading.value = false
  812. }
  813. async function onLoginSuccess(data = {}) {
  814. authStore.resetLoginState({ resetAuth: false })
  815. const accessToken = data.accessToken || data.token
  816. // 设置认证信息 - LoginResult 结构
  817. if (accessToken) {
  818. authStore.setToken({
  819. accessToken,
  820. tokenType: data.tokenType || 'Bearer',
  821. expiresIn: data.expiresIn,
  822. })
  823. // 密钥交换 - 使用 token 作为会话标识
  824. try {
  825. await initKeyExchange(request, accessToken)
  826. }
  827. catch (error) {
  828. console.warn('密钥交换失败,将使用降级方案:', error)
  829. }
  830. }
  831. // 如果返回了用户信息,设置到用户存储中
  832. if (data.userInfo) {
  833. const loginUser = data.userInfo
  834. loginUser.forcePasswordChange = data.forcePasswordChange === true || loginUser.forcePasswordChange === true
  835. userStore.setUser({
  836. id: loginUser.userId,
  837. username: loginUser.username,
  838. nickName: loginUser.realName || loginUser.username,
  839. email: loginUser.email,
  840. phone: loginUser.phone,
  841. avatar: loginUser.avatar,
  842. userType: loginUser.userType,
  843. userStatus: loginUser.userStatus,
  844. forcePasswordChange: loginUser.forcePasswordChange,
  845. tenantId: loginUser.tenantId,
  846. tenantName: loginUser.tenantName,
  847. tenantIds: loginUser.tenantIds || [],
  848. roleIds: loginUser.roleIds || [],
  849. roleKeys: loginUser.roleKeys || [],
  850. permissions: loginUser.permissions || [],
  851. apiPermissions: loginUser.apiPermissions || [],
  852. orgIds: loginUser.orgIds || [],
  853. mainOrgId: loginUser.mainOrgId,
  854. roles: loginUser.roleKeys ? Array.from(loginUser.roleKeys) : [],
  855. userInfo: loginUser,
  856. })
  857. // 同时存储到localStorage用于持久化
  858. lStorage.set('userInfo', loginUser)
  859. }
  860. const mustChangePassword = data.forcePasswordChange === true || data.userInfo?.forcePasswordChange === true
  861. if (mustChangePassword) {
  862. $message.warning('当前账号必须先修改初始密码', { key: 'login' })
  863. router.push('/profile')
  864. return
  865. }
  866. $message.loading('登录中...', { key: 'login' })
  867. try {
  868. // 先获取菜单数据,再跳转
  869. await loadAndSetMenuData(data.userInfo?.tenantId || selectedTenantId.value)
  870. $message.success('登录成功', { key: 'login' })
  871. // 使用环境变量中的默认跳转路径
  872. const defaultRedirectPath = import.meta.env.VITE_HOME_PATH || '/'
  873. // 处理重定向
  874. const redirectPath = route.query.redirect
  875. if (redirectPath && redirectPath !== '/login') {
  876. // 如果 redirect 不是登录页,则跳转到 redirect
  877. delete route.query.redirect
  878. router.push({ path: redirectPath, query: route.query })
  879. }
  880. else {
  881. // 否则跳转到首页
  882. router.push(defaultRedirectPath)
  883. }
  884. }
  885. catch (error) {
  886. console.error(error)
  887. $message.destroy('login')
  888. }
  889. }
  890. // 监听三方登录子窗口的消息
  891. async function handleSocialLoginMessage(event) {
  892. if (event.data?.type === 'SOCIAL_LOGIN_SUCCESS') {
  893. const { data } = event.data
  894. const accessToken = data?.accessToken || data?.token
  895. authStore.resetLoginState({ resetAuth: false })
  896. // 设置 token
  897. if (accessToken) {
  898. authStore.setToken({
  899. accessToken,
  900. tokenType: data.tokenType || 'Bearer',
  901. expiresIn: data.expiresIn,
  902. })
  903. }
  904. $message.success('登录成功')
  905. // 使用 window.location.href 强制刷新页面跳转
  906. const defaultRedirectPath = import.meta.env.VITE_HOME_PATH || '/'
  907. window.location.href = defaultRedirectPath
  908. }
  909. else if (event.data?.type === 'SOCIAL_LOGIN_FAILED') {
  910. $message.error('三方登录失败,请重试')
  911. }
  912. }
  913. // 页面加载时获取登录配置和验证码
  914. onMounted(() => {
  915. ;(async () => {
  916. try {
  917. tenantConfigApplying.value = true
  918. await loadLoginTenantOptions()
  919. await refreshLoginContext()
  920. }
  921. finally {
  922. tenantInitCompleted = true
  923. tenantConfigApplying.value = false
  924. }
  925. })()
  926. // 监听三方登录消息
  927. window.addEventListener('message', handleSocialLoginMessage)
  928. })
  929. // 组件卸载时清理定时器
  930. onUnmounted(() => {
  931. if (smsTimer.value) {
  932. clearInterval(smsTimer.value)
  933. }
  934. // 移除消息监听
  935. window.removeEventListener('message', handleSocialLoginMessage)
  936. })
  937. // 获取并设置菜单数据
  938. async function refreshLoginContext() {
  939. await Promise.all([
  940. loadLoginConfig(),
  941. loadSocialPlatforms(),
  942. ])
  943. }
  944. async function loadAndSetMenuData(loginTenantId = selectedTenantId.value) {
  945. try {
  946. const permissionStore = usePermissionStore()
  947. const tenantStore = useTenantStore()
  948. const tenantConfig = await tenantStore.loadTenantConfig(normalizeTenantId(loginTenantId) || userStore.userInfo?.tenantId)
  949. await applyTenantConfig(tenantConfig, appStore)
  950. // 获取菜单数据
  951. const res = await mainApi.getMenu(1)
  952. if (res.code === 200 && res.data) {
  953. // 设置菜单数据到store
  954. permissionStore.setMenuData(res.data)
  955. }
  956. else {
  957. console.error('菜单数据格式不正确:', res)
  958. }
  959. // 等待菜单数据加载完成(最多等待5秒)
  960. let waitCount = 0
  961. while (!permissionStore.menuDataLoaded && waitCount < 50) {
  962. await new Promise(resolve => setTimeout(resolve, 100))
  963. waitCount++
  964. }
  965. }
  966. catch (error) {
  967. console.error('获取菜单数据失败:', error)
  968. }
  969. }
  970. </script>
  971. <style scoped>
  972. /* Container */
  973. .login-container {
  974. position: relative;
  975. width: 100%;
  976. min-height: 100vh;
  977. min-height: 100dvh;
  978. display: flex;
  979. align-items: stretch;
  980. justify-content: center;
  981. padding: 0;
  982. overflow-x: hidden;
  983. overflow-y: auto;
  984. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
  985. }
  986. /* Static background */
  987. .login-bg-animated {
  988. position: absolute;
  989. inset: 0;
  990. background:
  991. radial-gradient(circle at 20% 18%, rgba(37, 99, 235, 0.18) 0%, rgba(37, 99, 235, 0) 26%),
  992. radial-gradient(circle at 82% 80%, rgba(20, 184, 166, 0.18) 0%, rgba(20, 184, 166, 0) 28%),
  993. linear-gradient(135deg, #f7fbff 0%, #eef4fb 52%, #f8fafc 100%);
  994. z-index: 0;
  995. }
  996. .login-bg-animated::before {
  997. content: '';
  998. position: absolute;
  999. inset: 0;
  1000. background:
  1001. linear-gradient(rgba(59, 130, 246, 0.035) 1px, transparent 1px),
  1002. linear-gradient(90deg, rgba(59, 130, 246, 0.035) 1px, transparent 1px);
  1003. background-size: 50px 50px;
  1004. opacity: 0.7;
  1005. }
  1006. /* Login card */
  1007. .login-card {
  1008. position: relative;
  1009. z-index: 2;
  1010. display: grid;
  1011. grid-template-columns: 1fr;
  1012. max-width: none;
  1013. width: 100%;
  1014. min-height: 100vh;
  1015. min-height: 100dvh;
  1016. background: #fff;
  1017. border-radius: 0;
  1018. border: none;
  1019. box-shadow: none;
  1020. overflow: hidden;
  1021. }
  1022. .login-card::before {
  1023. content: none;
  1024. }
  1025. .login-card::after {
  1026. content: none;
  1027. }
  1028. @media (min-width: 960px) {
  1029. .login-card {
  1030. grid-template-columns: minmax(0, 48%) minmax(440px, 52%);
  1031. }
  1032. }
  1033. /* Left side - Branding */
  1034. .login-brand {
  1035. background:
  1036. radial-gradient(circle at 24% 18%, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0) 30%),
  1037. radial-gradient(circle at 82% 80%, rgba(191, 219, 254, 0.22) 0%, rgba(191, 219, 254, 0) 34%),
  1038. linear-gradient(135deg, #1e40af, #3b82f6, #60a5fa);
  1039. min-height: 100vh;
  1040. min-height: 100dvh;
  1041. padding: 36px 44px;
  1042. display: none;
  1043. flex-direction: column;
  1044. justify-content: flex-start;
  1045. position: relative;
  1046. overflow: hidden;
  1047. }
  1048. .login-brand::before {
  1049. content: none;
  1050. }
  1051. .login-brand::after {
  1052. content: '';
  1053. position: absolute;
  1054. inset: 0;
  1055. background:
  1056. linear-gradient(90deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px),
  1057. linear-gradient(rgba(255, 255, 255, 0.06) 1px, transparent 1px);
  1058. background-size:
  1059. 44px 44px,
  1060. 44px 44px;
  1061. opacity: 0.38;
  1062. mask-image: linear-gradient(180deg, transparent 0%, #000 18%, #000 82%, transparent 100%);
  1063. pointer-events: none;
  1064. }
  1065. @media (min-width: 960px) {
  1066. .login-brand {
  1067. display: flex;
  1068. }
  1069. }
  1070. .brand-content {
  1071. position: relative;
  1072. z-index: 1;
  1073. display: flex;
  1074. width: 100%;
  1075. min-height: 100%;
  1076. flex-direction: column;
  1077. align-items: center;
  1078. justify-content: center;
  1079. margin: 0 auto;
  1080. text-align: left;
  1081. }
  1082. .logo-lockup {
  1083. position: absolute;
  1084. top: 0;
  1085. left: 0;
  1086. display: flex;
  1087. align-items: center;
  1088. gap: 12px;
  1089. margin-bottom: 0;
  1090. }
  1091. .logo-img {
  1092. width: 36px;
  1093. height: 36px;
  1094. object-fit: contain;
  1095. border-radius: 8px;
  1096. }
  1097. .logo-text {
  1098. color: #fff;
  1099. font-size: 17px;
  1100. font-weight: 800;
  1101. line-height: 1;
  1102. }
  1103. .banner {
  1104. position: relative;
  1105. display: flex;
  1106. width: 100%;
  1107. min-height: clamp(430px, 62vh, 640px);
  1108. align-items: stretch;
  1109. justify-content: center;
  1110. margin-top: 0;
  1111. }
  1112. .stack-carousel {
  1113. width: 100%;
  1114. height: clamp(430px, 62vh, 640px);
  1115. margin: 0 auto;
  1116. }
  1117. .stack-carousel :deep(.n-carousel__slides) {
  1118. width: 100%;
  1119. height: 100%;
  1120. align-items: stretch;
  1121. }
  1122. .stack-carousel :deep(.n-carousel__slide) {
  1123. width: 100%;
  1124. min-width: 100%;
  1125. display: flex;
  1126. justify-content: center;
  1127. }
  1128. .carousel-item {
  1129. position: relative;
  1130. width: 100%;
  1131. height: clamp(430px, 62vh, 640px);
  1132. margin: 0 auto;
  1133. padding: 0 0 68px;
  1134. overflow: hidden;
  1135. }
  1136. .carousel-image {
  1137. position: absolute;
  1138. top: 88px;
  1139. left: 50%;
  1140. width: min(620px, 100%);
  1141. max-width: none;
  1142. max-height: calc(100% - 136px);
  1143. height: auto;
  1144. object-fit: contain;
  1145. transform: translateX(-50%);
  1146. filter: drop-shadow(0 22px 34px rgba(30, 64, 175, 0.22));
  1147. pointer-events: none;
  1148. user-select: none;
  1149. }
  1150. .arco-carousel-arrow-left,
  1151. .arco-carousel-arrow-right {
  1152. position: absolute;
  1153. top: 50%;
  1154. z-index: 5;
  1155. display: inline-flex;
  1156. width: 34px;
  1157. height: 34px;
  1158. align-items: center;
  1159. justify-content: center;
  1160. border: 1px solid rgba(255, 255, 255, 0.3);
  1161. border-radius: 50%;
  1162. background: rgba(15, 23, 42, 0.18);
  1163. color: #fff;
  1164. cursor: pointer;
  1165. transform: translateY(-50%);
  1166. transition:
  1167. background 0.18s ease,
  1168. border-color 0.18s ease;
  1169. }
  1170. .arco-carousel-arrow-left:hover,
  1171. .arco-carousel-arrow-right:hover {
  1172. border-color: rgba(255, 255, 255, 0.58);
  1173. background: rgba(255, 255, 255, 0.22);
  1174. }
  1175. .arco-carousel-arrow-left {
  1176. left: 8px;
  1177. }
  1178. .arco-carousel-arrow-right {
  1179. right: 8px;
  1180. }
  1181. .arco-carousel-dots {
  1182. position: absolute;
  1183. right: 0;
  1184. bottom: 0;
  1185. left: 0;
  1186. z-index: 6;
  1187. display: flex;
  1188. align-items: center;
  1189. justify-content: center;
  1190. gap: 8px;
  1191. }
  1192. .arco-carousel-dot {
  1193. width: 16px;
  1194. height: 4px;
  1195. padding: 0;
  1196. border: none;
  1197. border-radius: 999px;
  1198. background: rgba(255, 255, 255, 0.36);
  1199. cursor: pointer;
  1200. transition:
  1201. width 0.18s ease,
  1202. background 0.18s ease;
  1203. }
  1204. .arco-carousel-dot.active {
  1205. width: 26px;
  1206. background: #fff;
  1207. }
  1208. .carousel-copy {
  1209. position: relative;
  1210. z-index: 2;
  1211. max-width: 560px;
  1212. margin: 0 auto;
  1213. text-align: center;
  1214. }
  1215. .carousel-title {
  1216. color: #fff;
  1217. font-size: 1.28rem;
  1218. font-weight: 800;
  1219. line-height: 1.35;
  1220. }
  1221. .carousel-subtitle {
  1222. margin-top: 8px;
  1223. color: rgba(255, 255, 255, 0.72);
  1224. font-size: 0.94rem;
  1225. line-height: 1.55;
  1226. }
  1227. /* Right side - Form */
  1228. .login-form-wrapper {
  1229. min-height: 100vh;
  1230. min-height: 100dvh;
  1231. padding: 40px 56px;
  1232. display: flex;
  1233. align-items: center;
  1234. justify-content: center;
  1235. background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)), #fff;
  1236. overflow-y: auto;
  1237. }
  1238. @media (min-width: 960px) {
  1239. /* Fixed-height internal scroll so a tall form (incl. "其他登录方式")
  1240. stays reachable on short / low-resolution screens instead of being
  1241. clipped below the fold. "safe center" falls back to top alignment
  1242. when content overflows, avoiding the flexbox top-clip bug. */
  1243. .login-form-wrapper {
  1244. height: 100vh;
  1245. height: 100dvh;
  1246. min-height: 0;
  1247. align-items: safe center;
  1248. }
  1249. }
  1250. @media (max-width: 959px) {
  1251. .login-container {
  1252. align-items: stretch;
  1253. padding: 0;
  1254. }
  1255. .login-card {
  1256. min-height: 100vh;
  1257. min-height: 100dvh;
  1258. max-width: none;
  1259. margin: 0;
  1260. overflow: visible;
  1261. }
  1262. .login-form-wrapper {
  1263. min-height: 100vh;
  1264. min-height: 100dvh;
  1265. align-items: flex-start;
  1266. padding: 22px 22px 72px;
  1267. overflow: visible;
  1268. }
  1269. .login-form {
  1270. max-width: 380px;
  1271. margin: 0 auto;
  1272. }
  1273. .form-header {
  1274. margin-bottom: 16px;
  1275. }
  1276. .form-group {
  1277. margin-bottom: 12px;
  1278. }
  1279. .form-label {
  1280. margin-bottom: 6px;
  1281. }
  1282. .form-options {
  1283. margin-bottom: 14px;
  1284. }
  1285. .login-button {
  1286. margin-top: 2px;
  1287. }
  1288. .login-trust-strip {
  1289. display: none;
  1290. }
  1291. .social-login-section {
  1292. margin-top: 18px;
  1293. }
  1294. .social-divider {
  1295. margin-bottom: 14px;
  1296. }
  1297. }
  1298. @media (min-width: 960px) and (max-height: 760px) {
  1299. .login-brand {
  1300. padding-top: 28px;
  1301. padding-bottom: 28px;
  1302. }
  1303. .logo-lockup {
  1304. top: 0;
  1305. }
  1306. .banner {
  1307. min-height: 380px;
  1308. }
  1309. .stack-carousel,
  1310. .carousel-item {
  1311. height: 380px;
  1312. }
  1313. .carousel-image {
  1314. top: 76px;
  1315. width: min(560px, 100%);
  1316. max-height: calc(100% - 112px);
  1317. }
  1318. }
  1319. .login-form {
  1320. width: 100%;
  1321. max-width: 360px;
  1322. }
  1323. .form-header {
  1324. margin-bottom: 22px;
  1325. text-align: left;
  1326. }
  1327. .form-title {
  1328. font-size: 1.75rem;
  1329. color: #0f172a;
  1330. margin: 0 0 8px;
  1331. letter-spacing: 0;
  1332. }
  1333. .form-subtitle {
  1334. font-size: 0.875rem;
  1335. color: #64748b;
  1336. font-weight: 400;
  1337. line-height: 1.6;
  1338. }
  1339. /* Form groups */
  1340. .form-group {
  1341. margin-bottom: 16px;
  1342. }
  1343. .form-label {
  1344. display: block;
  1345. font-size: 0.8125rem;
  1346. font-weight: 600;
  1347. color: #334155;
  1348. margin-bottom: 8px;
  1349. letter-spacing: 0.01em;
  1350. }
  1351. .input-wrapper {
  1352. position: relative;
  1353. display: flex;
  1354. align-items: center;
  1355. }
  1356. .modern-input {
  1357. width: 100%;
  1358. }
  1359. .tenant-select-wrapper {
  1360. width: 100%;
  1361. }
  1362. .modern-select {
  1363. width: 100%;
  1364. }
  1365. /* Override Naive UI input styles */
  1366. :deep(.modern-input.n-input) {
  1367. border-radius: 2px;
  1368. }
  1369. :deep(.modern-select .n-base-selection) {
  1370. border-radius: 2px;
  1371. }
  1372. :deep(.modern-select .n-base-selection:not(.n-base-selection--disabled):hover) {
  1373. border-color: #3b82f6;
  1374. }
  1375. :deep(.modern-select .n-base-selection:not(.n-base-selection--disabled).n-base-selection--active) {
  1376. border-color: #3b82f6;
  1377. box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
  1378. }
  1379. :deep(.modern-input .n-input__border),
  1380. :deep(.modern-input .n-input__state-border) {
  1381. border-radius: 2px;
  1382. }
  1383. :deep(.modern-input.n-input:not(.n-input--disabled):hover) {
  1384. border-color: #3b82f6;
  1385. }
  1386. :deep(.modern-input.n-input:not(.n-input--disabled):focus-within) {
  1387. border-color: #3b82f6;
  1388. box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
  1389. }
  1390. .input-icon {
  1391. color: #94a3b8;
  1392. font-size: 16px;
  1393. }
  1394. /* Captcha */
  1395. .captcha-wrapper {
  1396. display: flex;
  1397. gap: 12px;
  1398. align-items: center;
  1399. }
  1400. .captcha-image {
  1401. position: relative;
  1402. width: 120px;
  1403. height: 40px;
  1404. padding: 3px;
  1405. border: 1px solid #e0e0e6;
  1406. overflow: hidden;
  1407. cursor: pointer;
  1408. transition:
  1409. transform 0.2s ease,
  1410. border-color 0.2s ease,
  1411. box-shadow 0.2s ease;
  1412. display: flex;
  1413. align-items: center;
  1414. justify-content: center;
  1415. background:
  1416. linear-gradient(#fff, #fff) padding-box,
  1417. linear-gradient(135deg, rgba(59, 130, 246, 0.36), rgba(14, 165, 233, 0.18), rgba(148, 163, 184, 0.22)) border-box;
  1418. box-shadow:
  1419. inset 0 1px 0 rgba(255, 255, 255, 0.9),
  1420. 0 8px 18px rgba(15, 23, 42, 0.06);
  1421. }
  1422. .captcha-image::after {
  1423. content: '换';
  1424. position: absolute;
  1425. top: 4px;
  1426. right: 4px;
  1427. z-index: 2;
  1428. display: flex;
  1429. align-items: center;
  1430. justify-content: center;
  1431. width: 18px;
  1432. height: 18px;
  1433. border-radius: 999px;
  1434. background: rgba(15, 23, 42, 0.64);
  1435. color: #fff;
  1436. font-size: 11px;
  1437. font-weight: 700;
  1438. opacity: 0;
  1439. transform: translateY(-3px);
  1440. transition:
  1441. opacity 0.2s ease,
  1442. transform 0.2s ease;
  1443. pointer-events: none;
  1444. }
  1445. .captcha-image:hover {
  1446. border-color: #3b82f6;
  1447. transform: translateY(-1px);
  1448. box-shadow:
  1449. 0 0 0 3px rgba(59, 130, 246, 0.1),
  1450. 0 12px 24px rgba(37, 99, 235, 0.14);
  1451. }
  1452. .captcha-image:hover::after {
  1453. opacity: 1;
  1454. transform: translateY(0);
  1455. }
  1456. .captcha-image:focus {
  1457. outline: none;
  1458. border-color: #3b82f6;
  1459. box-shadow:
  1460. 0 0 0 3px rgba(59, 130, 246, 0.12),
  1461. 0 12px 24px rgba(37, 99, 235, 0.12);
  1462. }
  1463. .captcha-img {
  1464. width: 100%;
  1465. height: 100%;
  1466. object-fit: contain;
  1467. border-radius: 9px;
  1468. background: #f8fafc;
  1469. }
  1470. .captcha-loading {
  1471. width: 100%;
  1472. height: 100%;
  1473. border-radius: 9px;
  1474. background: linear-gradient(135deg, #f8fafc, #eef2ff);
  1475. color: #94a3b8;
  1476. font-size: 1.5rem;
  1477. display: flex;
  1478. align-items: center;
  1479. justify-content: center;
  1480. }
  1481. /* Slider verify trigger */
  1482. .slider-verify-trigger {
  1483. display: flex;
  1484. align-items: center;
  1485. gap: 10px;
  1486. padding: 0 16px;
  1487. height: 44px;
  1488. border-radius: 2px;
  1489. border: 1.5px solid #e2e8f0;
  1490. background: #f8fafc;
  1491. cursor: pointer;
  1492. transition: all 0.25s ease;
  1493. font-size: 0.875rem;
  1494. color: #64748b;
  1495. font-weight: 500;
  1496. user-select: none;
  1497. }
  1498. .slider-verify-trigger:hover {
  1499. border-color: #3b82f6;
  1500. background: #eff6ff;
  1501. color: #2563eb;
  1502. box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
  1503. }
  1504. .slider-verify-trigger.verified {
  1505. border-color: #22c55e;
  1506. background: #f0fdf4;
  1507. color: #16a34a;
  1508. cursor: default;
  1509. }
  1510. .slider-verify-trigger.verified:hover {
  1511. box-shadow: none;
  1512. }
  1513. .trigger-icon {
  1514. display: flex;
  1515. align-items: center;
  1516. font-size: 1rem;
  1517. }
  1518. .trigger-arrow {
  1519. margin-left: auto;
  1520. font-size: 0.875rem;
  1521. opacity: 0.5;
  1522. transition: transform 0.2s;
  1523. }
  1524. .slider-verify-trigger:hover .trigger-arrow {
  1525. transform: translateX(3px);
  1526. opacity: 0.8;
  1527. }
  1528. /* Slider modal overlay */
  1529. .slider-modal-overlay {
  1530. position: fixed;
  1531. inset: 0;
  1532. background: rgba(15, 23, 42, 0.5);
  1533. backdrop-filter: blur(8px);
  1534. -webkit-backdrop-filter: blur(8px);
  1535. z-index: 9999;
  1536. display: flex;
  1537. align-items: center;
  1538. justify-content: center;
  1539. padding: 1rem;
  1540. }
  1541. /* Slider modal card */
  1542. .slider-modal {
  1543. position: relative;
  1544. background: rgba(255, 255, 255, 0.98);
  1545. backdrop-filter: blur(20px);
  1546. -webkit-backdrop-filter: blur(20px);
  1547. border-radius: 20px;
  1548. padding: 2rem 1.75rem 1.5rem;
  1549. width: 100%;
  1550. max-width: 400px;
  1551. box-shadow:
  1552. 0 24px 48px rgba(15, 23, 42, 0.15),
  1553. 0 8px 16px rgba(15, 23, 42, 0.08);
  1554. border: 1px solid rgba(255, 255, 255, 0.6);
  1555. }
  1556. .slider-modal-close {
  1557. position: absolute;
  1558. top: 14px;
  1559. right: 14px;
  1560. width: 32px;
  1561. height: 32px;
  1562. border-radius: 50%;
  1563. border: none;
  1564. background: #f1f5f9;
  1565. color: #94a3b8;
  1566. cursor: pointer;
  1567. display: flex;
  1568. align-items: center;
  1569. justify-content: center;
  1570. font-size: 0.875rem;
  1571. transition: all 0.2s ease;
  1572. padding: 0;
  1573. }
  1574. .slider-modal-close:hover {
  1575. background: #e2e8f0;
  1576. color: #475569;
  1577. }
  1578. .slider-modal-header {
  1579. text-align: center;
  1580. margin-bottom: 1.25rem;
  1581. }
  1582. .slider-modal-icon {
  1583. width: 52px;
  1584. height: 52px;
  1585. background: linear-gradient(135deg, #eff6ff, #dbeafe);
  1586. border-radius: 14px;
  1587. display: flex;
  1588. align-items: center;
  1589. justify-content: center;
  1590. font-size: 1.375rem;
  1591. color: #3b82f6;
  1592. margin: 0 auto 12px;
  1593. box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
  1594. }
  1595. .slider-modal-title {
  1596. font-size: 1.125rem;
  1597. font-weight: 700;
  1598. color: #0f172a;
  1599. margin: 0 0 4px;
  1600. }
  1601. .slider-modal-desc {
  1602. font-size: 0.8125rem;
  1603. color: #94a3b8;
  1604. margin: 0;
  1605. }
  1606. .slider-modal-body {
  1607. display: flex;
  1608. justify-content: center;
  1609. border-radius: 12px;
  1610. overflow: hidden;
  1611. }
  1612. .slider-modal-tip {
  1613. display: flex;
  1614. align-items: center;
  1615. justify-content: center;
  1616. gap: 6px;
  1617. margin: 14px 0 0;
  1618. font-size: 0.75rem;
  1619. color: #cbd5e1;
  1620. }
  1621. /* SMS button */
  1622. .sms-button {
  1623. min-width: 120px;
  1624. white-space: nowrap;
  1625. border-radius: 2px;
  1626. }
  1627. .mb-3 {
  1628. margin-bottom: 12px;
  1629. }
  1630. /* Form options */
  1631. .form-options {
  1632. margin-bottom: 24px;
  1633. }
  1634. .checkbox-label {
  1635. font-size: 0.875rem;
  1636. color: #64748b;
  1637. }
  1638. /* Login button */
  1639. .login-button {
  1640. margin-top: 8px;
  1641. position: relative;
  1642. overflow: hidden;
  1643. isolation: isolate;
  1644. border-radius: 0px !important;
  1645. border: 1px solid rgba(22, 93, 255, 0.22);
  1646. background: linear-gradient(
  1647. 135deg,
  1648. var(--primary-400) 0%,
  1649. var(--primary-500) 55%,
  1650. var(--primary-600) 100%
  1651. ) !important;
  1652. font-size: 0.9375rem;
  1653. font-weight: 600;
  1654. height: 52px;
  1655. box-shadow:
  1656. inset 0 1px 0 rgba(255, 255, 255, 0.22),
  1657. 0 10px 22px rgba(22, 93, 255, 0.24),
  1658. 0 2px 6px rgba(22, 93, 255, 0.16);
  1659. transition:
  1660. transform 0.25s ease,
  1661. box-shadow 0.25s ease,
  1662. border-color 0.25s ease,
  1663. background 0.25s ease;
  1664. cursor: pointer;
  1665. }
  1666. .login-trust-strip {
  1667. display: grid;
  1668. grid-template-columns: repeat(3, minmax(0, 1fr));
  1669. gap: 8px;
  1670. margin-top: 14px;
  1671. }
  1672. .login-trust-strip span {
  1673. display: inline-flex;
  1674. min-width: 0;
  1675. height: 34px;
  1676. align-items: center;
  1677. justify-content: center;
  1678. gap: 5px;
  1679. border: 1px solid #e2e8f0;
  1680. border-radius: 2px;
  1681. background: #f8fafc;
  1682. color: #64748b;
  1683. font-size: 12px;
  1684. font-weight: 700;
  1685. white-space: nowrap;
  1686. }
  1687. .login-trust-strip i {
  1688. color: #3b82f6;
  1689. font-size: 14px;
  1690. }
  1691. .login-button::after {
  1692. content: '';
  1693. position: absolute;
  1694. inset: 1px;
  1695. border-radius: 2px;
  1696. background: linear-gradient(
  1697. 180deg,
  1698. rgba(255, 255, 255, 0.14) 0%,
  1699. rgba(255, 255, 255, 0.04) 34%,
  1700. rgba(255, 255, 255, 0) 62%
  1701. );
  1702. z-index: 1;
  1703. pointer-events: none;
  1704. opacity: 0.95;
  1705. transition: opacity 0.25s ease;
  1706. }
  1707. .login-button:hover {
  1708. transform: translateY(-2px) scale(1.005);
  1709. border-color: rgba(22, 93, 255, 0.32);
  1710. background: linear-gradient(
  1711. 135deg,
  1712. var(--primary-300) 0%,
  1713. var(--primary-500) 58%,
  1714. var(--primary-700) 100%
  1715. ) !important;
  1716. box-shadow:
  1717. inset 0 1px 0 rgba(255, 255, 255, 0.24),
  1718. 0 14px 28px rgba(22, 93, 255, 0.3),
  1719. 0 4px 10px rgba(22, 93, 255, 0.18);
  1720. }
  1721. .login-button:hover::after {
  1722. opacity: 1;
  1723. }
  1724. .login-button:active {
  1725. transform: translateY(0) scale(0.985);
  1726. }
  1727. .login-button:active::after {
  1728. opacity: 0.86;
  1729. }
  1730. .login-button :deep(.n-button__content) {
  1731. display: flex;
  1732. align-items: center;
  1733. justify-content: center;
  1734. gap: 8px;
  1735. position: relative;
  1736. z-index: 2;
  1737. }
  1738. .login-button :deep(.n-base-loading),
  1739. .login-button :deep(.n-button__icon) {
  1740. position: relative;
  1741. z-index: 2;
  1742. }
  1743. .button-text {
  1744. letter-spacing: 0.01em;
  1745. color: #fff;
  1746. }
  1747. .button-icon {
  1748. font-size: 1.125rem;
  1749. color: #fff;
  1750. transition:
  1751. transform 0.25s ease,
  1752. opacity 0.25s ease;
  1753. }
  1754. .login-button:hover .button-icon {
  1755. transform: translateX(4px);
  1756. }
  1757. /* Social login section */
  1758. .social-login-section {
  1759. margin-top: 28px;
  1760. }
  1761. .social-divider {
  1762. position: relative;
  1763. text-align: center;
  1764. margin-bottom: 20px;
  1765. }
  1766. .social-divider::before {
  1767. content: '';
  1768. position: absolute;
  1769. left: 0;
  1770. top: 50%;
  1771. width: 100%;
  1772. height: 1px;
  1773. background: linear-gradient(90deg, transparent, #e2e8f0 20%, #e2e8f0 80%, transparent);
  1774. }
  1775. .divider-text {
  1776. position: relative;
  1777. display: inline-block;
  1778. padding: 0 16px;
  1779. background: #fff;
  1780. font-size: 0.8125rem;
  1781. color: #94a3b8;
  1782. }
  1783. .social-buttons {
  1784. display: flex;
  1785. justify-content: center;
  1786. gap: 12px;
  1787. }
  1788. .social-button {
  1789. width: 44px;
  1790. height: 44px;
  1791. border-radius: 2px;
  1792. border: 1.5px solid #e2e8f0;
  1793. background: #f8fafc;
  1794. cursor: pointer;
  1795. display: flex;
  1796. align-items: center;
  1797. justify-content: center;
  1798. transition: all 0.25s ease;
  1799. padding: 0;
  1800. }
  1801. .social-button:hover {
  1802. border-color: #3b82f6;
  1803. background: #eff6ff;
  1804. transform: translateY(-3px);
  1805. box-shadow: 0 6px 16px rgba(59, 130, 246, 0.15);
  1806. }
  1807. .social-icon {
  1808. width: 24px;
  1809. height: 24px;
  1810. object-fit: contain;
  1811. }
  1812. .social-icon-fallback {
  1813. font-size: 20px;
  1814. color: #64748b;
  1815. }
  1816. .social-button:hover .social-icon-fallback {
  1817. color: #3b82f6;
  1818. }
  1819. /* Dark mode */
  1820. .dark .login-bg-animated {
  1821. background:
  1822. radial-gradient(circle at 26% 18%, rgba(59, 130, 246, 0.28) 0%, rgba(59, 130, 246, 0) 28%),
  1823. linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%);
  1824. }
  1825. .dark .login-card {
  1826. background: rgba(30, 41, 59, 0.95);
  1827. border-color: rgba(255, 255, 255, 0.1);
  1828. }
  1829. .dark .login-form-wrapper {
  1830. background: transparent;
  1831. }
  1832. .dark .form-title {
  1833. color: #f1f5f9;
  1834. }
  1835. .dark .form-subtitle {
  1836. color: #94a3b8;
  1837. }
  1838. .dark .form-label {
  1839. color: #cbd5e1;
  1840. }
  1841. .dark .social-divider::before {
  1842. background: linear-gradient(90deg, transparent, #334155 20%, #334155 80%, transparent);
  1843. }
  1844. .dark .divider-text {
  1845. background: transparent;
  1846. }
  1847. .dark .slider-modal {
  1848. background: rgba(30, 41, 59, 0.98);
  1849. border-color: rgba(255, 255, 255, 0.1);
  1850. }
  1851. .dark .slider-modal-title {
  1852. color: #f1f5f9;
  1853. }
  1854. /* Reduced motion */
  1855. @media (prefers-reduced-motion: reduce) {
  1856. *,
  1857. *::before,
  1858. *::after {
  1859. animation-duration: 0.01ms !important;
  1860. animation-iteration-count: 1 !important;
  1861. transition-duration: 0.01ms !important;
  1862. }
  1863. .login-bg-animated::before,
  1864. .login-brand::after,
  1865. .login-card::after {
  1866. animation: none;
  1867. }
  1868. }
  1869. /* ICP Record */
  1870. .icp-record {
  1871. position: absolute;
  1872. bottom: 12px;
  1873. left: 50%;
  1874. transform: translateX(-50%);
  1875. z-index: 3;
  1876. text-align: center;
  1877. }
  1878. .icp-record a {
  1879. font-size: 0.75rem;
  1880. color: rgba(100, 116, 139, 0.7);
  1881. text-decoration: none;
  1882. transition: color 0.2s ease;
  1883. }
  1884. .icp-record a:hover {
  1885. color: rgba(59, 130, 246, 0.9);
  1886. }
  1887. .dark .icp-record a {
  1888. color: rgba(148, 163, 184, 0.6);
  1889. }
  1890. .dark .icp-record a:hover {
  1891. color: rgba(96, 165, 250, 0.8);
  1892. }
  1893. /* Responsive */
  1894. @media (max-width: 767px) {
  1895. .login-container {
  1896. align-items: stretch;
  1897. padding: 0;
  1898. }
  1899. .form-title {
  1900. font-size: 1.375rem;
  1901. }
  1902. .login-card {
  1903. border-radius: 0;
  1904. }
  1905. }
  1906. </style>