LoginPage.vue 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. <script setup>
  2. import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
  3. import { useI18n } from 'vue-i18n';
  4. import { UserOutlined, LockOutlined, KeyOutlined, SettingOutlined } from '@ant-design/icons-vue';
  5. import { HttpUtil, LanguageManager } from '@/utils';
  6. import {
  7. antdThemeConfig,
  8. currentTheme,
  9. theme as themeState,
  10. } from '@/composables/useTheme.js';
  11. import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
  12. const { t } = useI18n();
  13. const headlineWords = computed(() => [t('pages.login.hello'), t('pages.login.title')]);
  14. const HEADLINE_INTERVAL_MS = 2000;
  15. const headlineIndex = ref(0);
  16. let headlineTimer = null;
  17. onMounted(() => {
  18. headlineTimer = window.setInterval(() => {
  19. headlineIndex.value = (headlineIndex.value + 1) % headlineWords.value.length;
  20. }, HEADLINE_INTERVAL_MS);
  21. });
  22. onBeforeUnmount(() => {
  23. if (headlineTimer != null) window.clearInterval(headlineTimer);
  24. });
  25. const fetched = ref(false);
  26. const submitting = ref(false);
  27. const twoFactorEnable = ref(false);
  28. const user = reactive({
  29. username: '',
  30. password: '',
  31. twoFactorCode: '',
  32. });
  33. const basePath = window.__X_UI_BASE_PATH__ || '';
  34. onMounted(async () => {
  35. const msg = await HttpUtil.post('/getTwoFactorEnable');
  36. if (msg.success) {
  37. twoFactorEnable.value = !!msg.obj;
  38. }
  39. fetched.value = true;
  40. });
  41. async function login() {
  42. submitting.value = true;
  43. try {
  44. const msg = await HttpUtil.post('/login', user);
  45. if (msg.success) {
  46. window.location.href = basePath + 'panel/';
  47. }
  48. } finally {
  49. submitting.value = false;
  50. }
  51. }
  52. const lang = ref(LanguageManager.getLanguage());
  53. function onLangChange(next) {
  54. LanguageManager.setLanguage(next);
  55. }
  56. </script>
  57. <template>
  58. <a-config-provider :theme="antdThemeConfig">
  59. <a-layout class="login-app" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
  60. <a-layout-content class="login-content">
  61. <div class="waves-header">
  62. <div class="waves-inner-header"></div>
  63. <svg class="waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
  64. viewBox="0 24 150 28" preserveAspectRatio="none" shape-rendering="auto">
  65. <defs>
  66. <path id="gentle-wave" d="M-160 44c30 0 58-18 88-18s 58 18 88 18 58-18 88-18 58 18 88 18 v44h-352z" />
  67. </defs>
  68. <g class="parallax">
  69. <use xlink:href="#gentle-wave" x="48" y="0" />
  70. <use xlink:href="#gentle-wave" x="48" y="3" />
  71. <use xlink:href="#gentle-wave" x="48" y="5" />
  72. <use xlink:href="#gentle-wave" x="48" y="7" />
  73. </g>
  74. </svg>
  75. </div>
  76. <a-row type="flex" justify="center" align="middle" class="login-row">
  77. <a-col class="login-card">
  78. <div v-if="!fetched" class="login-loading">
  79. <a-spin size="large" />
  80. </div>
  81. <div v-else>
  82. <div class="login-settings">
  83. <a-popover :overlay-class-name="currentTheme" :title="t('menu.settings')" placement="bottomRight"
  84. trigger="click">
  85. <template #content>
  86. <a-space direction="vertical" :size="10" class="settings-popover">
  87. <ThemeSwitchLogin />
  88. <span>{{ t('pages.settings.language') }}</span>
  89. <a-select v-model:value="lang" class="lang-select" @change="onLangChange">
  90. <a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value"
  91. :value="l.value">
  92. <span :aria-label="l.name">{{ l.icon }}</span>
  93. &nbsp;&nbsp;<span>{{ l.name }}</span>
  94. </a-select-option>
  95. </a-select>
  96. </a-space>
  97. </template>
  98. <a-button shape="circle">
  99. <template #icon>
  100. <SettingOutlined />
  101. </template>
  102. </a-button>
  103. </a-popover>
  104. </div>
  105. <a-row justify="center">
  106. <a-col :span="24">
  107. <h2 class="login-title">
  108. <Transition name="headline" mode="out-in">
  109. <b :key="headlineIndex">{{ headlineWords[headlineIndex] }}</b>
  110. </Transition>
  111. </h2>
  112. </a-col>
  113. </a-row>
  114. <a-form layout="vertical" @submit.prevent="login">
  115. <a-form-item>
  116. <a-input v-model:value="user.username" autocomplete="username" name="username"
  117. :placeholder="t('username')" autofocus required>
  118. <template #prefix>
  119. <UserOutlined />
  120. </template>
  121. </a-input>
  122. </a-form-item>
  123. <a-form-item>
  124. <a-input-password v-model:value="user.password" autocomplete="current-password" name="password"
  125. :placeholder="t('password')" required>
  126. <template #prefix>
  127. <LockOutlined />
  128. </template>
  129. </a-input-password>
  130. </a-form-item>
  131. <a-form-item v-if="twoFactorEnable">
  132. <a-input v-model:value="user.twoFactorCode" autocomplete="one-time-code" name="twoFactorCode"
  133. :placeholder="t('twoFactorCode')" required>
  134. <template #prefix>
  135. <KeyOutlined />
  136. </template>
  137. </a-input>
  138. </a-form-item>
  139. <a-form-item>
  140. <a-row justify="center">
  141. <a-button type="primary" html-type="submit" :loading="submitting" block>
  142. {{ submitting ? '' : t('login') }}
  143. </a-button>
  144. </a-row>
  145. </a-form-item>
  146. </a-form>
  147. </div>
  148. </a-col>
  149. </a-row>
  150. </a-layout-content>
  151. </a-layout>
  152. </a-config-provider>
  153. </template>
  154. <style scoped>
  155. .login-app {
  156. --bg-page: #c7ebe2;
  157. --bg-wave-header: #dbf5ed;
  158. --bg-card: #ffffff;
  159. --color-title: #008771;
  160. --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.09);
  161. --wave-fill: rgba(0, 135, 113, 0.12);
  162. --wave-fill-bottom: #c7ebe2;
  163. min-height: 100vh;
  164. }
  165. .login-app.is-dark {
  166. --bg-page: #222d42;
  167. --bg-wave-header: #0a1222;
  168. --bg-card: #151f31;
  169. --color-title: rgba(255, 255, 255, 0.92);
  170. --shadow-card: 0 4px 16px rgba(0, 0, 0, 0.45);
  171. --wave-fill: #222d42;
  172. --wave-fill-bottom: #222d42;
  173. }
  174. .login-app.is-dark.is-ultra {
  175. --bg-page: #0f2d32;
  176. --bg-wave-header: #0a2227;
  177. --bg-card: #0c0e12;
  178. --wave-fill: #1f4d52;
  179. --wave-fill-bottom: #0f2d32;
  180. }
  181. .login-app,
  182. .login-app :deep(.ant-layout-content) {
  183. background: transparent;
  184. }
  185. .login-app {
  186. background: var(--bg-page);
  187. }
  188. .login-card {
  189. background: var(--bg-card);
  190. box-shadow: var(--shadow-card);
  191. }
  192. .login-title {
  193. color: var(--color-title);
  194. }
  195. .login-settings {
  196. display: flex;
  197. justify-content: flex-end;
  198. margin-bottom: 8px;
  199. }
  200. .settings-popover {
  201. min-width: 220px;
  202. }
  203. .lang-select {
  204. width: 100%;
  205. }
  206. .login-content {
  207. position: relative;
  208. }
  209. .login-row {
  210. position: relative;
  211. z-index: 1;
  212. min-height: 100vh;
  213. padding: 24px 0;
  214. }
  215. .login-card {
  216. width: clamp(280px, 90vw, 300px);
  217. border-radius: 2rem;
  218. padding: clamp(2rem, 5vw, 4rem) 1.5rem;
  219. transition: background 0.3s, box-shadow 0.3s;
  220. }
  221. .login-loading {
  222. text-align: center;
  223. padding: 40px 0;
  224. }
  225. .login-title {
  226. text-align: center;
  227. margin-bottom: 32px;
  228. font-size: 2rem;
  229. font-weight: 500;
  230. min-height: 2.5rem;
  231. }
  232. .login-title b {
  233. display: inline-block;
  234. }
  235. .headline-enter-active,
  236. .headline-leave-active {
  237. transition: opacity 0.4s ease, transform 0.4s ease;
  238. }
  239. .headline-enter-from {
  240. opacity: 0;
  241. transform: translateY(-12px);
  242. }
  243. .headline-leave-to {
  244. opacity: 0;
  245. transform: translateY(12px);
  246. }
  247. .waves-header {
  248. position: fixed;
  249. inset: 0 0 auto 0;
  250. width: 100%;
  251. z-index: 0;
  252. pointer-events: none;
  253. background: var(--bg-wave-header);
  254. }
  255. .waves-inner-header {
  256. height: 50vh;
  257. width: 100%;
  258. }
  259. .waves {
  260. position: relative;
  261. display: block;
  262. width: 100%;
  263. height: 15vh;
  264. min-height: 100px;
  265. max-height: 150px;
  266. margin-bottom: -8px;
  267. }
  268. .parallax>use {
  269. fill: var(--wave-fill);
  270. animation: move-forever 25s cubic-bezier(0.55, 0.5, 0.45, 0.5) infinite;
  271. }
  272. .parallax>use:nth-child(1) {
  273. animation-delay: -2s;
  274. animation-duration: 4s;
  275. opacity: 0.2;
  276. }
  277. .parallax>use:nth-child(2) {
  278. animation-delay: -3s;
  279. animation-duration: 7s;
  280. opacity: 0.4;
  281. }
  282. .parallax>use:nth-child(3) {
  283. animation-delay: -4s;
  284. animation-duration: 10s;
  285. opacity: 0.6;
  286. }
  287. .parallax>use:nth-child(4) {
  288. animation-delay: -5s;
  289. animation-duration: 13s;
  290. fill: var(--wave-fill-bottom);
  291. opacity: 1;
  292. }
  293. @keyframes move-forever {
  294. 0% {
  295. transform: translate3d(-90px, 0, 0);
  296. }
  297. 100% {
  298. transform: translate3d(85px, 0, 0);
  299. }
  300. }
  301. </style>