【无标题】

基于 Vue 3、Naive UI 和 TypeScript 实现的可切换租户登录系统页面,UI 设计美观简约,背景带粒子特效

javascript 复制代码
<script setup lang="ts">
import {computed, onMounted, onUnmounted, reactive, ref} from 'vue';
import type {FormRules, SelectOption} from 'naive-ui';
import {useMessage} from 'naive-ui';
import CryptoJS from 'crypto-js';
import {fetchCaptchaCode, fetchTenantList} from '@/service/api';
import {useAuthStore} from '@/store/modules/auth';
import {localStg} from '@/utils/storage';
import {decryptWithAes, encryptWithAes} from '@/utils/crypto';
import LogoImg from '@/assets/imgs/logo2.svg';
import {useLoading} from '~/packages/hooks';
import {useI18n} from 'vue-i18n';

// ==================== 类型定义 ====================
/** 记住密码存储的数据结构 */
interface RememberData {
  tenantId: string | null;
  username: string;
  password: string;
  isAdminTenantId: boolean;
}

const { t } = useI18n();
const message = useMessage();

const authStore = useAuthStore();
/** AES 加密密钥,用于加密本地存储的记住密码数据 */
const aesKey = CryptoJS.enc.Utf8.parse(import.meta.env.VITE_REMEMBER_ME_AES_KEY || 'pC4aO6cD2uU7hA0bK6iD4vE1mV8sU8xG');
/** localStorage 中记住密码数据的存储键名 */
const REMEMBER_KEY = 'loginRember';

const model: Api.Auth.PwdLoginForm = reactive({
  tenantId: null,
  username: '',
  password: '',
  code: '',
  uuid: ''
});

const tenantEnabled = ref<boolean>(false);
const captchaEnabled = ref<boolean>(false);
const { loading: tenantLoading, startLoading: startTenantLoading, endLoading: endTenantLoading } = useLoading();
const { loading: codeLoading, startLoading: startCodeLoading, endLoading: endCodeLoading } = useLoading();
const tenantOption = ref<SelectOption[]>([]);

const formRef = ref<InstanceType<typeof NForm> | null>(null);
const loading = ref(false);
const rememberMe = ref(false);
const captchaCode = ref('');
const isAdminTenantId = ref<boolean>(false);

/** 切换管理员租户 / 业务租户模式,并恢复对应租户的记住密码数据 */
function handleChangeTen() {
  isAdminTenantId.value = !isAdminTenantId.value;
  if (isAdminTenantId.value) {
    // 切换到管理员租户
    model.tenantId = '000000';
  } else {
    // 切换到业务租户
    model.tenantId = tenantOption.value.length > 0 ? tenantOption.value[0].value : null;
  }
}

const rules = computed<FormRules>(() => ({
  tenantId: tenantEnabled.value
    ? {
        required: true,
        message: t('page.login.login5.rules.tenantId'),
        trigger: ['blur', 'change']
      }
    : {},
  username: {
    required: true,
    message: t('page.login.login5.rules.username'),
    trigger: ['blur', 'change']
  },
  password: {
    required: true,
    message: t('page.login.login5.rules.password'),
    trigger: ['blur', 'change']
  },
  code: captchaEnabled.value
    ? {
        required: true,
        message: t('page.login.login5.rules.code'),
        trigger: ['blur', 'change']
      }
    : {}
}));
/** 处理登录提交 */
async function handleLogin() {
  // 1. 表单校验
  try {
    await formRef.value?.validate();
  } catch {
    return;
  }

  // 2. 调用登录接口
  try {
    await authStore.login(model);

    // 3. 登录成功后处理「记住密码」
    if (rememberMe.value) {
      const rememberData: RememberData = {
        tenantId: model.tenantId,
        username: model.username,
        password: model.password,
        isAdminTenantId: isAdminTenantId.value
      };
      localStg.set(REMEMBER_KEY, encryptWithAes(JSON.stringify(rememberData), aesKey));
    } else {
      localStg.remove(REMEMBER_KEY);
    }
  } catch {
    // 登录失败由 authStore 内部统一处理提示,此处仅做容错
  }
}

/** 从 localStorage 恢复记住的登录信息 */
function handleLoginRemember() {
  const loginRemember = localStg.get(REMEMBER_KEY);
  if (!loginRemember) return;

  try {
    const savedData: RememberData = JSON.parse(decryptWithAes(loginRemember, aesKey));
    rememberMe.value = true;
    // 逐字段赋值,避免 Object.assign 将 isAdminTenantId 混入 model 响应式对象
    model.tenantId = savedData.tenantId;
    model.username = savedData.username;
    model.password = savedData.password;
    isAdminTenantId.value = savedData.isAdminTenantId ?? false;
  } catch {
    // 解密或解析失败时清除损坏的缓存
    localStg.remove(REMEMBER_KEY);
  }
}

const codeUrl = ref<string>();

async function handleFetchCaptchaCode() {
  startCodeLoading();
  const { data, error } = await fetchCaptchaCode();
  if (!error) {
    captchaEnabled.value = data.captchaEnabled;
    if (data.captchaEnabled) {
      model.uuid = data.uuid;
      codeUrl.value = `data:image/gif;base64,${data.img}`;
    }
  }
  endCodeLoading();
}

/** 获取租户列表,仅在未设置 tenantId 时设置默认值(防止覆盖 remember me 恢复的值) */
async function handleFetchTenantList() {
  startTenantLoading();
  const { data, error } = await fetchTenantList();
  if (error) return;
  tenantEnabled.value = data.tenantEnabled;
  if (data.tenantEnabled) {
    tenantOption.value = data.voList
      .filter(tenant => tenant.tenantId !== '000000') // 过滤掉管理员租户
      .map(tenant => ({
        label: tenant.companyName,
        value: tenant.tenantId
      }));
    // 仅当 model.tenantId 未被设置时才赋默认值,避免覆盖 handleLoginRemember 恢复的租户 ID
    if (!model.tenantId) {
      model.tenantId = tenantOption.value.length > 0 ? tenantOption.value[0].value : null;
    }
  }
  endTenantLoading();
}

onMounted(() => {
  handleLoginRemember();
  handleFetchCaptchaCode();
  handleFetchTenantList();
  window.addEventListener('keyup', e => {
    if (e.key === 'Enter') {
      // handleLogin();
    }
  });
});

onUnmounted(() => {});
</script>

<template>
  <div class="login-container">
    <!-- 背景几何装饰 -->
    <div class="geo-decoration geo-ring-top-left" aria-hidden="true"></div>
    <div class="geo-decoration geo-ring-bottom-right" aria-hidden="true"></div>
    <div class="geo-decoration geo-diamond" aria-hidden="true"></div>
    <div class="geo-decoration geo-triangle" aria-hidden="true"></div>
    <div class="geo-decoration geo-lines" aria-hidden="true"></div>

    <div class="login-box">
      <div class="login-left">
        <div class="brand-wrapper">
          <div class="logo-icon">
            <NImage :src="LogoImg" :preview-disabled="true"></NImage>
          </div>
          <h1 class="system-name">{{ $t('page.login.login5.systemName') }}</h1>
          <p class="system-slogan">{{ $t('page.login.login5.systemSlogan') }}</p>
          <div class="brand-decoration">
            <span class="deco-line"></span>
            <span class="deco-dot"></span>
            <span class="deco-line"></span>
          </div>
          <div class="brand-features">
            <span>{{ $t('page.login.login5.brandFeature1') }}</span>
            <span>{{ $t('page.login.login5.brandFeature2') }}</span>
            <span>{{ $t('page.login.login5.brandFeature3') }}</span>
          </div>
        </div>
      </div>

      <div class="login-right">
        <!-- 面板角落装饰 -->
        <div class="corner-decoration corner-top-right" aria-hidden="true"></div>
        <div class="corner-decoration corner-bottom-left" aria-hidden="true"></div>

        <div class="login-card">
          <div class="card-header">
            <h2>{{ $t('page.login.login5.welcomeTitle') }}</h2>
            <p>{{ $t('page.login.login5.welcomeSubtitle') }}</p>
          </div>

          <NForm
            ref="formRef"
            :model="model"
            :rules="rules"
            label-placement="left"
            label-width="0"
            size="large"
            class="login-form"
          >
            <NFormItem v-if="tenantEnabled && !isAdminTenantId" path="tenantId">
              <NSelect
                v-model:value="model.tenantId"
                :placeholder="$t('page.login.login5.tenantPlaceholder')"
                :options="tenantOption"
                clearable
                class="form-input"
                :style="{ height: '48px' }"
              />
            </NFormItem>

            <NFormItem path="username">
              <NInput
                v-model:value="model.username"
                :placeholder="$t('page.login.common.userNamePlaceholder')"
                clearable
                class="form-input"
                :style="{ height: '48px' }"
              >
                <template #prefix>
                  <span style="color: #8c8c8c; font-size: 18px">👤</span>
                </template>
              </NInput>
            </NFormItem>

            <NFormItem path="password">
              <NInput
                v-model:value="model.password"
                type="password"
                :placeholder="$t('page.login.common.passwordPlaceholder')"
                show-password-on="click"
                clearable @keyup.enter="handleLogin"
                class="form-input"
                :style="{ height: '48px' }"
              >
                <template #prefix>
                  <span style="color: #8c8c8c; font-size: 18px">🔒</span>
                </template>
              </NInput>
            </NFormItem>

            <NFormItem v-if="captchaEnabled" path="code">
              <div class="captcha-wrapper">
                <NInput
                  v-model:value="model.code"
                  :placeholder="$t('page.login.common.codePlaceholder')"
                  clearable
                  class="captcha-input"
                  :style="{ height: '48px' }"
                />
                <div class="captcha-image" @click="handleFetchCaptchaCode">
                  <NImage :src="codeUrl" :preview-disabled="true"></NImage>
                </div>
                <NButton
                  type="primary"
                  tertiary
                  size="small"
                  class="captcha-refresh-btn"
                  :title="$t('page.login.login5.refreshCaptcha')"
                  @click="handleFetchCaptchaCode"
                >
                  <template #icon>
                    <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
                      <path d="M1 4v6h6" stroke-linecap="round" stroke-linejoin="round" />
                      <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" stroke-linecap="round" stroke-linejoin="round" />
                    </svg>
                  </template>
                </NButton>
              </div>
            </NFormItem>

            <NFormItem>
              <NButton
                type="primary"
                size="large"
                block
                :loading="authStore.loginLoading"
                class="login-btn"
                @click="handleLogin"
              >
                {{ $t('common.login') }}
              </NButton>
            </NFormItem>

            <div class="form-footer">
              <NCheckbox v-model:checked="rememberMe">{{ $t('page.login.pwdLogin.rememberMe') }}</NCheckbox>
            </div>
          </NForm>

          <div style="margin-top: 16px">
            <NDivider>
            <span class="divider-text" @click="handleChangeTen">
              {{ isAdminTenantId ? $t('page.login.login5.businessTenantLogin') : $t('page.login.login5.adminBackend') }}
            </span>
          </NDivider>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped lang="scss">
.login-container {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  width: 100vw;
  background: #f5f9ff;
  padding: 20px;
  box-sizing: border-box;
  position: relative;
  overflow: hidden;
  background-image: radial-gradient(circle at 2px 2px, rgba(45, 124, 246, 0.1) 2px, transparent 0);
  background-size: 40px 40px;
}

.login-container::before {
  content: '';
  position: absolute;
  top: -30%;
  right: -20%;
  width: 60%;
  height: 80%;
  background: radial-gradient(ellipse, rgba(45, 124, 246, 0.06) 0%, transparent 70%);
  border-radius: 50%;
  pointer-events: none;
}

.login-container::after {
  content: '';
  position: absolute;
  bottom: -30%;
  left: -20%;
  width: 50%;
  height: 70%;
  background: radial-gradient(ellipse, rgba(45, 124, 246, 0.04) 0%, transparent 70%);
  border-radius: 50%;
  pointer-events: none;
}

/* 几何装饰基础样式 */
.geo-decoration {
  position: absolute;
  pointer-events: none;
  z-index: 0;
}

/* 左上角圆环装饰 */
.geo-ring-top-left {
  top: 8%;
  left: 6%;
  width: 100px;
  height: 100px;
  border: 2px solid rgba(45, 124, 246, 0.08);
  border-radius: 50%;
  animation: geo-rotate-slow 20s linear infinite;
}

.geo-ring-top-left::before {
  content: '';
  position: absolute;
  top: 15px;
  left: 15px;
  right: 15px;
  bottom: 15px;
  border: 1px solid rgba(103, 85, 255, 0.06);
  border-radius: 50%;
}

/* 右下角圆环装饰 */
.geo-ring-bottom-right {
  bottom: 10%;
  right: 8%;
  width: 70px;
  height: 70px;
  border: 2px solid rgba(103, 85, 255, 0.07);
  border-radius: 50%;
  animation: geo-rotate-slow 25s linear infinite reverse;
}

.geo-ring-bottom-right::after {
  content: '';
  position: absolute;
  top: 20px;
  left: 20px;
  right: 20px;
  bottom: 20px;
  border: 1px solid rgba(45, 124, 246, 0.05);
  border-radius: 50%;
}

/* 菱形装饰 */
.geo-diamond {
  top: 20%;
  right: 15%;
  width: 30px;
  height: 30px;
  border: 2px solid rgba(45, 124, 246, 0.08);
  transform: rotate(45deg);
  animation: geo-float 6s ease-in-out infinite;
}

/* 三角形装饰 */
.geo-triangle {
  bottom: 25%;
  left: 10%;
  width: 0;
  height: 0;
  border-left: 18px solid transparent;
  border-right: 18px solid transparent;
  border-bottom: 30px solid rgba(103, 85, 255, 0.06);
  animation: geo-triangle-float 8s ease-in-out infinite reverse;
}

/* 线条装饰 */
.geo-lines {
  top: 50%;
  left: 3%;
  width: 40px;
  height: 40px;
  position: absolute;
}

.geo-lines::before,
.geo-lines::after {
  content: '';
  position: absolute;
  background: rgba(45, 124, 246, 0.06);
}

.geo-lines::before {
  top: 0;
  left: 50%;
  width: 2px;
  height: 100%;
  transform: translateX(-50%);
}

.geo-lines::after {
  top: 50%;
  left: 0;
  width: 100%;
  height: 2px;
  transform: translateY(-50%);
}

/* 几何动画 */
@keyframes geo-rotate-slow {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

@keyframes geo-float {
  0%, 100% { transform: translateY(0) rotate(45deg); }
  50% { transform: translateY(-10px) rotate(50deg); }
}

/* 菱形动画修正 */
.geo-diamond {
  animation-name: geo-diamond-float;
}

@keyframes geo-diamond-float {
  0%, 100% { transform: rotate(45deg) translateY(0); }
  50% { transform: rotate(55deg) translateY(-8px); }
}

@keyframes geo-triangle-float {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-10px); }
}

.login-box {
  display: flex;
  width: 900px;
  height: 550px;
  max-width: 100%;
  background: #ffffff;
  border-radius: 24px;
  box-shadow:
    0 20px 60px rgba(45, 124, 246, 0.1),
    0 8px 24px rgba(0, 0, 0, 0.04);
  overflow: hidden;
  position: relative;
  z-index: 1;
}

.login-left {
  flex: 0 0 52%;
  background: linear-gradient(145deg, #84d0f678 0%, #6755ff45 100%);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 40px;
  position: relative;
  overflow: hidden;
}

.login-left::before {
  content: '';
  position: absolute;
  top: -40%;
  right: -30%;
  width: 80%;
  height: 80%;
  background: radial-gradient(ellipse, rgba(45, 124, 246, 0.08) 0%, transparent 70%);
  border-radius: 50%;
  pointer-events: none;
}

.login-left::after {
  content: '';
  position: absolute;
  bottom: -30%;
  left: -20%;
  width: 60%;
  height: 60%;
  background: radial-gradient(ellipse, rgba(45, 124, 246, 0.05) 0%, transparent 70%);
  border-radius: 50%;
  pointer-events: none;
}

.brand-wrapper {
  text-align: center;
  position: relative;
  z-index: 1;
}

.logo-icon {
  //width: 80px;
  //height: 80px;
  margin: 0 auto 20px;
  display: block;
  animation: float 3s ease-in-out infinite;
}

.logo-icon svg {
  width: 100%;
  height: 100%;
  display: block;
}

@keyframes float {
  0%,
  100% {
    transform: translateY(0px);
  }
  50% {
    transform: translateY(-8px);
  }
}

.system-name {
  font-size: 32px;
  font-weight: 700;
  color: #1a3a5c;
  margin: 0 0 8px 0;
  letter-spacing: 2px;
}

.system-slogan {
  font-size: 16px;
  color: #4a6a8a;
  margin: 0 0 24px 0;
  font-weight: 400;
  letter-spacing: 4px;
}

.brand-decoration {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
}

.deco-line {
  width: 40px;
  height: 2px;
  background: linear-gradient(90deg, transparent, #2d7cf6, transparent);
  border-radius: 2px;
}

.deco-dot {
  width: 6px;
  height: 6px;
  background: #2d7cf6;
  border-radius: 50%;
  opacity: 0.6;
}

.brand-features {
  display: flex;
  justify-content: center;
  gap: 24px;
  font-size: 14px;
  color: #4a6a8a;
  font-weight: 400;
}

.brand-features span {
  opacity: 0.7;
  transition: opacity 0.3s;
}

.brand-features span:hover {
  opacity: 1;
}

/* 右侧面板角落装饰 */
.corner-decoration {
  position: absolute;
  pointer-events: none;
  z-index: 0;
}

.corner-top-right {
  top: 20px;
  right: 20px;
  width: 24px;
  height: 24px;
  border-top: 2px solid rgba(45, 124, 246, 0.12);
  border-right: 2px solid rgba(45, 124, 246, 0.12);
  border-radius: 0 8px 0 0;
}

.corner-top-right::before {
  content: '';
  position: absolute;
  top: -2px;
  right: -2px;
  width: 8px;
  height: 8px;
  background: rgba(45, 124, 246, 0.08);
  border-radius: 50%;
}

.corner-bottom-left {
  bottom: 20px;
  left: 20px;
  width: 20px;
  height: 20px;
  border-bottom: 2px solid rgba(103, 85, 255, 0.1);
  border-left: 2px solid rgba(103, 85, 255, 0.1);
  border-radius: 0 0 0 6px;
}

.corner-bottom-left::after {
  content: '';
  position: absolute;
  bottom: -2px;
  left: -2px;
  width: 6px;
  height: 6px;
  border: 1px solid rgba(103, 85, 255, 0.15);
  border-radius: 50%;
}

.login-right {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 40px 48px;
  background: #ffffff;
  position: relative;
  z-index: 1;
  background-image:
    linear-gradient(135deg, rgba(45, 124, 246, 0.02) 25%, transparent 25%),
    linear-gradient(225deg, rgba(45, 124, 246, 0.02) 25%, transparent 25%),
    linear-gradient(45deg, rgba(103, 85, 255, 0.015) 25%, transparent 25%),
    linear-gradient(315deg, rgba(103, 85, 255, 0.015) 25%, transparent 25%);
  background-size: 24px 24px;
}

.login-card {
  width: 100%;
  max-width: 380px;
}

.card-header {
}

.card-header h2 {
  font-size: 28px;
  font-weight: 600;
  color: #1a3a5c;
  margin: 0 0 6px 0;
  letter-spacing: 1px;
}

.card-header p {
  font-size: 15px;
  color: #8a9aaa;
  margin: 0;
  font-weight: 400;
}

.login-form {
  width: 100%;
}

.login-form :deep(.n-form-item) {
}

.login-form :deep(.n-form-item:last-child) {
  margin-bottom: 0;
}

.form-input {
  border-radius: 10px;
}

.form-input :deep(.n-input__input-el) {
  font-size: 15px;
}

.form-input :deep(.n-input__prefix) {
  padding-right: 8px;
}

.captcha-wrapper {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
}

.captcha-input {
  flex: 1;
  border-radius: 10px;
}

.captcha-input :deep(.n-input__input-el) {
  font-size: 15px;
  letter-spacing: 2px;
  text-transform: uppercase;
}

.captcha-image {
  flex-shrink: 0;
  width: 120px;
  height: 48px;
  border-radius: 10px;
  overflow: hidden;
  cursor: pointer;
  border: 1px solid #e8edf4;
  transition:
    border-color 0.3s,
    box-shadow 0.3s;
  background: #f8faff;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
}

.captcha-image:hover {
  border-color: #2d7cf6;
  box-shadow: 0 0 0 3px rgba(45, 124, 246, 0.1);
}

.captcha-image canvas {
  width: 100%;
  height: 100%;
  display: block;
  border-radius: 9px;
}

.captcha-refresh-btn {
  flex-shrink: 0;
  width: 48px;
  height: 48px;
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #f5f9ff;
  border: 1px solid #e8edf4;
  transition: all 0.3s;
  cursor: pointer;
  padding: 0;
}

.captcha-refresh-btn:hover {
  background: #e8f0fe;
  border-color: #2d7cf6;
  transform: rotate(60deg);
}

.captcha-refresh-btn :deep(.n-button__icon) {
  margin: 0;
  color: #4a6a8a;
}

.login-btn {
  height: 48px;
  border-radius: 10px;
  font-size: 17px;
  font-weight: 600;
  letter-spacing: 4px;
  background: linear-gradient(135deg, #2d7cf6 0%, #1a5fc7 100%);
  border: none;
  box-shadow: 0 4px 16px rgba(45, 124, 246, 0.3);
  transition: all 0.3s;
}

.login-btn:hover {
  transform: translateY(-1px);
  box-shadow: 0 6px 24px rgba(45, 124, 246, 0.4);
}

.login-btn:active {
  transform: translateY(0px);
}

.form-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.form-footer :deep(.n-checkbox) {
  font-size: 14px;
  color: #6a7a8a;
}

.forgot-link {
  font-size: 14px;
  color: #2d7cf6;
  text-decoration: none;
  transition: color 0.3s;
  font-weight: 400;
}

.forgot-link:hover {
  color: #1a5fc7;
  text-decoration: underline;
}

.divider-text {
  cursor: pointer;
  font-size: 13px;
  color: #2d7cf6;
}

.divider-text:hover {
  text-decoration: underline;
}
/** 中等屏幕显示 */
@media (min-width: 1500px) {
  .login-box {
    max-height: none;
    width: 80vw;
    height: 80vh;
    max-width: 100%;
  }
}
/** 大屏幕的显示比例 */
@media (min-width: 1900px) {
  .login-box {
    max-height: none;
    width: 70vw;
    height: 70vh;
    max-width: 100%;
  }
}

/** 超大屏幕的显示比例 */
@media (min-width: 2500px) {
  .login-box {
    max-height: none;
    width: 50vw;
    height: 50vh;
    max-width: 100%;
  }
}

@media (max-width: 900px) {
  .geo-decoration,
  .corner-decoration {
    display: none;
  }

  .login-container {
    background-image: none;
  }

  .login-box {
    flex-direction: column;
    height: auto;
    max-height: none;
    width: 480px;
    max-width: 100%;
  }

  .login-left {
    flex: 0 0 auto;
    padding: 32px 24px 28px;
    width: 100%;
  }

  .login-left::before,
  .login-left::after {
    display: none;
  }

  .logo-icon {
    width: 56px;
    height: 56px;
    margin-bottom: 14px;
  }

  .system-name {
    font-size: 26px;
  }

  .system-slogan {
    font-size: 14px;
    margin-bottom: 18px;
  }

  .brand-decoration {
    margin-bottom: 20px;
  }

  .brand-features {
    gap: 16px;
    font-size: 13px;
  }

  .login-right {
    padding: 32px 28px 40px;
    width: 100%;
  }

  .login-card {
    max-width: 100%;
  }

  .card-header h2 {
    font-size: 24px;
  }

  .captcha-image {
    width: 100px;
    height: 44px;
  }

  .captcha-refresh-btn {
    width: 44px;
    height: 44px;
  }
}

@media (max-width: 480px) {
  .login-container {
    padding: 12px;
  }

  .login-box {
    border-radius: 16px;
  }

  .login-left {
    padding: 24px 16px 20px;
  }

  .system-name {
    font-size: 22px;
  }

  .brand-features {
    flex-direction: column;
    gap: 4px;
    font-size: 12px;
  }

  .brand-decoration {
    margin-bottom: 16px;
  }

  .deco-line {
    width: 24px;
  }

  .login-right {
    padding: 24px 16px 32px;
  }

  .card-header h2 {
    font-size: 22px;
  }

  .card-header p {
    font-size: 13px;
  }

  .captcha-wrapper {
    gap: 8px;
  }

  .captcha-image {
    width: 80px;
    height: 40px;
  }

  .captcha-refresh-btn {
    width: 40px;
    height: 40px;
  }

  .captcha-refresh-btn :deep(svg) {
    width: 16px;
    height: 16px;
  }

  .login-btn {
    height: 44px;
    font-size: 15px;
    letter-spacing: 2px;
  }

  .form-footer {
    flex-direction: column;
    gap: 8px;
    align-items: flex-start;
  }
}
</style>
相关推荐
_codemonster1 小时前
npm run dev 是在开发模式运行,怎么在生产环境运行
前端·npm·node.js
kyriewen1 小时前
我受够了复制报错去问 AI,花一下午给控制台做了个调试助手
前端·javascript·ai编程
IT_陈寒2 小时前
Redis的DEL命令竟然没删掉数据?我踩的这个坑你得知道
前端·人工智能·后端
anOnion2 小时前
构建无障碍组件之Menu and Menubar Pattern
前端·html·交互设计
JavaGuide3 小时前
我用 Claude Code/ZCode+GLM-5.3 从零做了一款 Agent 游戏!
前端·后端
Eason_Lou3 小时前
vue flow使用注意事项
前端·vue.js
小赵同学WoW4 小时前
1-2 TypeScript 中的 `any` 与 `unknown`
前端
six_4 小时前
C# 上位机(持续更新中)
前端·面试·c#
风雨_4 小时前
Git Worktree sourcetree完整使用指南
前端