后台管理系统开发(登录接口 前端篇)

338 阅读2分钟

实现效果

image.png

image.png

image.png

根据后端模型定义 Ts 接口

image.png

Apifox 有方便导出 Ts

image.png

types/entity.d.ts (declare 项目内无需导入)

declare interface BaseEntity {
  /**
   * 创建时间
   */
  createAt: Date;
  /**
   * 创建人
   */
  createBy: number;
  /**
   * 逻辑删除 `0` 未删除 `null` 删除
   */
  deleted: boolean;
  /**
   * 备注
   */
  remark: string;
  /**
   * 是否启用 `0` 关闭  `1` 开启
   */
  status: boolean;
  /**
   * 最后更新时间
   */
  updateAt: Date;
  /**
   * 最后更新人
   */
  updateBy: number;
  /**
   * 乐观锁
   */
  version: number;
  [property: string]: any;
}

declare interface UserEntity extends BaseEntity {
  /**
   * 地址
   */
  address: string;
  /**
   * 地址码
   */
  addressCode: string;
  /**
   * 头像路径
   */
  avatarUrl: string;
  /**
   * 用户邮箱
   */
  email: string;
  /**
   * 性别
   */
  gender: Gender;
  /**
   * ipv4 数字地址
   */
  ip: number;
  /**
   * 昵称
   */
  nickName: string;
  /**
   * 手机号码
   */
  phoneNumber: string;
  /**
   * 用户主键
   */
  userId: number;
  /**
   * 用户名
   */
  username: string;
}

/**
 * RoleEntity,角色表
 */
declare interface RoleEntity {
  /**
   * ipv4 数字地址
   */
  ip: number;
  /**
   * 权限列表
   */
  perEntities: PerEntity[];
  /**
   * 角色编码
   */
  roleCode: string;
  /**
   * 角色主键
   */
  roleId: number;
  /**
   * 角色名称
   */
  roleName: string;
}

/**
 * PerEntity,权限表
 */
declare interface PerEntity {
  /**
   * ipv4 数字地址
   */
  ip: number;
  /**
   * 权限标识
   */
  perCode: string;
  /**
   * 权限主键
   */
  perId: number;
  /**
   * 权限名称
   */
  perName: string;
  /**
   * 资源 url
   */
  resourceUrl: string;
}

types/dto/user.ts

export interface UserAccountLoginDto {
  /**
   * 验证码
   */
  code: string;
  /**
   * 用户登录密码
   */
  password: string;
  /**
   * 记住我
   */
  rememberMe: boolean;
  /**
   * 用户登录名称
   */
  username: string;
  [property: string]: any;
}

/**
 * LoginDetails,数据
 */
export interface LoginDetails {
  /**
   * 访问token
   */
  accessToken: string;
  /**
   * 权限符号列表
   */
  authorities: string[];
  /**
   * ipv4 数字地址
   */
  ip: string;
  /**
   * 权限符列
   */
  permissions: string[];
  /**
   * 刷新token次数
   */
  refreshCount: number;
  /**
   * 刷新token
   */
  refreshToken: string;
  roles: RoleEntity[];
  user: UserEntity;
  /**
   * 用户代理
   */
  userAgent: string;
  userId: number;
  username: string;
}

/**
 * 邮件登录
 */
export interface UserEmailLoginDto {
  /**
   * 验证码
   */
  code: string;
  /**
   * 用户登录邮件
   */
  email: string;
  /**
   * 记住我
   */
  rememberMe?: boolean;
}

type/vo/captcha.ts

export interface ImageCaptchaVo {
  base64Data: string;
  ip: string;
  token: string;
  [property: string]: any;
}

接口函数

  • useHttpGet,useHttpPost... 为 nuxt3 useFetch 二次封装函数

api/captcha.ts

import type { ImageCaptchaVo } from '~/types/vo/captcha';

/**
 * 获取图片验证码
 */
export const getImageCaptchaApi = () => {
  return useHttpGet<ImageCaptchaVo>({
    url: '/captcha/img',
  });
};

/**
 * 发送邮箱验证码
 *
 * @param email 邮箱
 * @return 过期秒数
 */
export const postEmailCaptchaApi = (email: string) => {
  return useHttpPost<number>({
    url: '/captcha/login',
    params: {
      email,
    },
  });
};

api/user.ts

import type {
  LoginDetails,
  UserAccountLoginDto,
  UserEmailLoginDto,
} from '~/types/dto/user';

/**
 * 获取用户信息
 */
export const getUserInfoApi = () => {
  return useHttpGet<UserEntity>({
    url: '/my/info',
  });
};

/**
 * 账号密码登录
 *
 * @param body body
 */
export const postAccountLoginApi = (body: UserAccountLoginDto) => {
  return useHttpPost<LoginDetails>({
    url: '/user/login',
    body,
  });
};

/**
 * 邮箱验证码登录
 *
 * @param body 邮件登录 DTO
 */
export const postEmailCaptchaLoginApi = (body: UserEmailLoginDto) => {
  return useHttpPost<LoginDetails>({
    url: '/user/mail-login',
    body,
  });
};

请求前的拦截

nuxt3 useFetch 二次封装函数

import type { UseFetchOptions } from 'nuxt/app';
import { useUserStore } from '~/composables/useUserStore';

//这里是 env 变量, 根据不同开发环境配置多个环境变量
const BASE_URL = import.meta.env.VITE_APP_SERVICE_API;
const SERVICE_TIMEOUT = import.meta.env.VITE_APP_SERVICE_TIMEOUT;

export interface RequestParams<T = unknown> extends UseFetchOptions<T> {
  url: string;
}
export type RequestMethodParams<T> = RequestParams<T>;
const useHttp = <T = unknown>({ url, ...options }: RequestParams<T>) => {
  return new Promise<T>((resolve, reject) => {
    useFetch(url, {
      ...options,
      baseURL: BASE_URL,
      timeout: SERVICE_TIMEOUT,
      onRequest: ({ options }) => {
        const userStore = useUserStore();

        // 基于 bearer jwt
        if (userStore.accessToken) {
          const accessToken = userStore.accessToken;
          options.headers = {
            ...options.headers,
            Authorization: `Bearer ${accessToken}`,
          };
        }
      },
      onResponse: ({ response }) => {
        const commonEntity: CommonEntity<T> = response._data;
        const { code, message, data } = commonEntity || {};

        // 简单判定
        if (code === 200) {
          resolve(data);
          return;
        }
        reject(new Error(message));
      },
      onRequestError: ({ error }) => {
        Message.error({
          id: 'network-error',
          content: '网络异常,请检查当前网络是否正常',
        });
        reject(error);
      },
      onResponseError: ({ error }) => {
        Message.error({
          id: 'request-error',
          content: '请求失败',
        });
        reject(error);
      },
    });
  });
};

const useHttpGet = <T>(options: RequestMethodParams<T>) => {
  return useHttp<T>({
    ...options,
    method: 'GET',
  });
};

const useHttpPost = <T>(options: RequestMethodParams<T>) => {
  return useHttp<T>({
    ...options,
    method: 'POST',
  });
};

const useHttpPut = <T>(options: RequestMethodParams<T>) => {
  return useHttp<T>({
    ...options,
    method: 'PUT',
  });
};

const useHttpDelete = <T>(options: RequestMethodParams<T>) => {
  return useHttp<T>({
    ...options,
    method: 'DELETE',
  });
};

export { useHttp, useHttpGet, useHttpPost, useHttpPut, useHttpDelete };

页面编写

  • vueuse
  • unocss
  • pinia
  • arco-design
  • nuxt3

登录页面主内容[pages/login/index.vue]

<script lang="ts" setup>
  import AccountLogin from './components/AccountLogin.vue';
  import EmailLogin from './components/EmailLogin.vue';
  import LoginSide from './components/LoginSide.vue';

  definePageMeta({
    layout: 'single',
  });

  enum LoginMode {
    ACCOUNT,
    EMAIL,
  }

  const status = reactive<{
    rememberMe: boolean;
    accountLoginRef: InstanceType<typeof AccountLogin> | null;
    loginMode: LoginMode;
  }>({
    rememberMe: true,
    accountLoginRef: null,
    loginMode: LoginMode.ACCOUNT,
  });

  const { rememberMe, accountLoginRef } = toRefs(status);

  // 点击登录
  const onLogin = () => {
    accountLoginRef.value?.onLogin();
  };

  // 验证码加载中
  const captchaLoading = computed(() => {
    return accountLoginRef.value?.captchaLoading || false;
  });

  const { state: loginMode, next } = useCycleList([
    LoginMode.ACCOUNT,
    LoginMode.EMAIL,
  ]);
</script>

<template>
  <div class="login-container">
    <a-card :bordered="false" class="pl-8 pr-12 py-2 shadow-md rounded-3xl!">
      <div class="flex justify-center">
        <LoginSide />

        <div class="login-main">
          <div class="flex justify-between items-center">
            <LogoBanner />

            <a-button @click="next()">
              <template #icon>
                <i class="i-line-md-linkedin" />
              </template>
            </a-button>
          </div>

          <a-divider class="mt-5! mb-0!" />

          <!--    main    -->
          <div class="py-5">
            <AccountLogin
              v-if="loginMode === LoginMode.ACCOUNT"
              ref="accountLoginRef"
              :remember-me="rememberMe"
            />
            <EmailLogin
              v-else-if="loginMode === LoginMode.EMAIL"
              ref="emailLoginRef"
              :remember-me="rememberMe"
            />
            <a-empty v-else>未开发的登录方式</a-empty>
          </div>
          <!--    main    -->

          <div class="flex w-full justify-between">
            <a-space>
              <a-button
                :shape="'circle'"
                :size="'mini'"
                :type="rememberMe ? 'primary' : 'secondary'"
                @click="rememberMe = !rememberMe"
              >
                <i class="i-streamline-target-solid" />
              </a-button>
              <span>记住我</span>
            </a-space>

            <a-link>
              <span>忘记密码</span>
              <template #icon>
                <i class="i-line-md-chevron-small-double-left" />
              </template>
            </a-link>
          </div>

          <div class="pt-3 pb-4" />

          <div class="flex w-full justify-between">
            <a-space>
              <ThemeButton />

              <a-button :shape="'circle'">
                <template #icon>
                  <i
                    class="i-streamline-ai-settings-spark hover:i-streamline-ai-settings-spark-solid"
                  />
                </template>
              </a-button>
            </a-space>

            <a-space>
              <a-button :shape="'round'" :size="'medium'">
                注册用户
                <template #icon>
                  <i class="i-streamline-fingerprint-2" />
                </template>
              </a-button>

              <a-button
                v-if="loginMode === LoginMode.ACCOUNT"
                :loading="captchaLoading"
                :shape="'round'"
                :size="'medium'"
                type="primary"
                @click="onLogin"
              >
                登录用户

                <template #icon>
                  <i
                    class="i-streamline-interface-login-dial-pad-finger-password-dial-pad-dot-finger"
                  />
                </template>
              </a-button>
            </a-space>
          </div>
        </div>
      </div>
    </a-card>
  </div>
</template>

<style scoped>
  .login-container {
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;

    .login-main {
      float: left;
      width: 450px;
    }
  }
</style>

登录页面侧边内容[pages/login/components/LoginSide.vue]

<script lang="ts" setup>
  const userStore = useUserStore();
  const { user, isLogin } = storeToRefs(userStore);

  /**
   * 清空所有缓存
   */
  const onClearCache = () => {
    localStorage.clear();
    sessionStorage.clear();
    Message.success('缓存已清空');
  };
</script>

<template>
  <div class="pr-12">
    <a-space
      :align="'center'"
      :direction="'vertical'"
      :size="'large'"
      class="pt-4"
    >
      <a-space :align="'center'" :direction="'vertical'">
        <a-avatar
          :image-url="user.avatarUrl || 'avatar.jpg'"
          :shape="'square'"
          :size="86"
        >
          {{ user.username }}
        </a-avatar>

        <a-typography-text>
          <a-link v-if="isLogin" :status="'success'" @click="navigateTo('/')">
            {{ user.username }}
          </a-link>
        </a-typography-text>
      </a-space>

      <a-space :direction="'vertical'" :size="'medium'">
        <a-button :size="'small'" @click="userStore.loadAnonymous">
          <span>访客登录</span>

          <template #icon>
            <i class="i-streamline-cyborg-2" />
          </template>
        </a-button>

        <a-button :size="'small'" @click="onClearCache">
          <span>清空缓存</span>

          <template #icon>
            <i class="i-streamline-emergency-exit" />
          </template>
        </a-button>
      </a-space>

      <a-image
        :preview="false"
        :src="'memphis-pointe.png'"
        height="130"
        width="130"
      />
    </a-space>
  </div>
</template>

<style scoped></style>

账号密码登录方式[pages/login/components/AccountLogin.vue]

<script lang="ts" setup>
  import Form from '@arco-design/web-vue/es/form';
  import type { UserAccountLoginDto } from '~/types/dto/user';
  import { formRules } from '~/pages/rules';
  import { getImageCaptchaApi } from '~/api/captcha';
  import { useRequest, useUserStore } from '#imports';
  import { postAccountLoginApi } from '~/api/user';

  const userStore = useUserStore();

  const props = withDefaults(
    defineProps<{
      rememberMe: boolean;
    }>(),
    {
      rememberMe: false,
    }
  );

  // 监听同步 isRememberMe
  watch(
    () => props.rememberMe,
    (isRememberMe) => {
      accountForm.value.rememberMe = isRememberMe;
    }
  );

  // 表单数据
  const accountForm = ref<UserAccountLoginDto>({
    username: 'admin123',
    password: 'admin123',
    rememberMe: false,
    code: '',
  });

  const formRef = ref<InstanceType<typeof Form>>();

  // 计时器状态
  const imageCaptchaCountdownStart = ref(false);

  // 倒计时 毫秒值
  const countdown = ref(0);

  // 图像验证码窗口是否显示
  const imageCaptchaVisible = ref(false);

  // 请求 图形验证码接口
  const {
    data: imageCaptchaData,
    run: imageCaptchaRun,
    refresh: imageCaptchaRefresh,
    loading: captchaLoading,
  } = useRequest(getImageCaptchaApi, {
    onSuccess: () => {
      console.log('image captcha success');
      // 显示图像验证码窗口
      imageCaptchaVisible.value = true;

      // 启动计时器
      countdown.value = Date.now() + 60000 * 3;
      imageCaptchaCountdownStart.value = true;

      // 清空旧验证码
      accountForm.value.code = '';
    },
  });

  // 请求 登录接口
  const { run: accountLoginRun, loading: loginLoading } = useRequest(
    postAccountLoginApi,
    {
      debounceInterval: 300,
      onSuccess: (loginDetails) => {
        // 登录成功, 关闭图像验证码窗口
        imageCaptchaVisible.value = false;

        // 存储登录信息
        userStore.loadUserDetails(loginDetails);
      },
      onError: ({ message }) => {
        // 刷新验证码
        imageCaptchaRefresh();

        // 清空旧验证码
        accountForm.value.code = '';

        // 提示消息
        Message.error(message);
      },
    }
  );

  /**
   * 输入验证码完成时,触发登录
   *
   */
  const onVerificationCodeFinish = () => {
    accountLoginRun({ ...unref(accountForm) });
  };

  /**
   * 点击登录触发
   */
  const onLogin = async () => {
    const validate = await formRef.value?.validate();
    if (!validate) {
      imageCaptchaRun();
    }
  };

  defineExpose({
    /**
     * 点击登录触发
     */
    onLogin,
    /**
     * 图形验证码是否加载中
     */
    captchaLoading,
    /**
     * 登录中
     */
    loginLoading,
  });
</script>

<template>
  <!--  图像验证码弹框 -->
  <a-modal
    :closable="true"
    :footer="false"
    :hide-title="true"
    :simple="true"
    :visible="imageCaptchaVisible"
    modal-class="mt-0! pt-0!"
    @ok="imageCaptchaVisible = false"
  >
    <a-typography>
      <div class="w-full flex justify-between items-center mt-4 mb-2">
        <h5 class="m-0! text-lg text-orange-4">
          <span>图形验证码</span>
        </h5>
        <a-countdown
          :start="imageCaptchaCountdownStart"
          :value="countdown"
          :value-style="{ fontSize: '1.4em' }"
          format="mm:ss"
          @finish="imageCaptchaVisible = false"
        />
      </div>
      <a-typography-text :type="'secondary'">
        图形验证码由五位字符组成,验证码时效为
        <a-tag>3分钟</a-tag>
        ,输入验证后即可登录
      </a-typography-text>
    </a-typography>

    <a-divider />
    <a-space
      :align="'center'"
      :direction="'vertical'"
      :size="'large'"
      class="overflow-hidden"
      fill
    >
      <a-spin :loading="captchaLoading">
        <a-image
          :src="imageCaptchaData?.base64Data"
          :width="200"
          class="pt-2 pb-4"
        />
      </a-spin>
      <a-verification-code
        v-model="accountForm.code"
        :length="5"
        style="width: 240px"
        @finish="onVerificationCodeFinish"
      />

      <a-space fill>
        <a-button :loading="captchaLoading" @click="imageCaptchaRefresh">
          刷新
        </a-button>
        <a-button @click="imageCaptchaVisible = false">取消</a-button>
      </a-space>
    </a-space>
  </a-modal>

  <!-- 账号登录表单 -->
  <a-form
    ref="formRef"
    :layout="'vertical'"
    :model="accountForm"
    :rules="formRules"
    class="login-form"
  >
    <a-form-item
      class="w-[440px]!"
      field="username"
      hide-asterisk
      validate-trigger="input"
    >
      <a-input
        v-model="accountForm.username"
        :max-length="30"
        placeholder="请输入登录账号"
      >
        <template #prefix>
          <i class="i-streamline-user-profile-focus pr-1" />
        </template>
      </a-input>
    </a-form-item>
    <a-form-item
      class="w-[440px]!"
      field="password"
      hide-asterisk
      validate-trigger="input"
    >
      <a-input-password
        v-model="accountForm.password"
        :max-length="30"
        placeholder="请输入登录密码"
      >
        <template #prefix>
          <i
            class="i-streamline-interface-user-lock-actions-lock-geometric-human-person-single-up-user pr-1"
          />
        </template>
      </a-input-password>
    </a-form-item>
  </a-form>
</template>

<style scoped></style>

邮件验证码登录方式[pages/login/components/EmailLogin.vue]

<script lang="ts" setup>
  import Form from '@arco-design/web-vue/es/form';
  import { formRules } from '~/pages/rules';
  import { postEmailCaptchaApi } from '~/api/captcha';
  import { useRequest, useUserStore } from '#imports';
  import { postEmailCaptchaLoginApi } from '~/api/user';

  const userStore = useUserStore();

  const props = withDefaults(
    defineProps<{
      rememberMe: boolean;
    }>(),
    {
      rememberMe: false,
    }
  );

  const loginForm = ref({
    email: '577393000@qq.com',
    rememberMe: true,
    code: '',
  });

  // 监听同步 isRememberMe
  watch(
    () => props.rememberMe,
    (isRememberMe) => {
      loginForm.value.rememberMe = isRememberMe;
    }
  );

  const formRef = ref<InstanceType<typeof Form>>();

  // 开始倒计时
  const countdownStart = ref(false);

  // 毫秒倒计时
  const countdown = ref(0);

  // 验证窗口显示
  const captchaVisible = ref(false);

  const { run: emailLoginRun } = useRequest(postEmailCaptchaLoginApi, {
    debounceInterval: 300,
    onSuccess: (loginDetails) => {
      // 登录成功, 关闭图像验证码窗口
      captchaVisible.value = false;

      // 存储登录信息
      userStore.loadUserDetails(loginDetails);
    },
    onError: ({ message }) => {
      // 清空旧验证码
      loginForm.value.code = '';

      // 提示消息
      Message.error(message);
    },
  });

  /**
   * 邮件验证码请求
   */
  const { run: emailCaptchaRun, loading: emailCaptchaLoading } = useRequest(
    postEmailCaptchaApi,
    {
      onSuccess: (expiredSeconds = 0) => {
        // 启动验证码计时器
        countdown.value = Date.now() + 1000 * expiredSeconds;
        countdownStart.value = true;

        // 显示验证码窗口
        captchaVisible.value = true;

        // 提示消息
        const form = unref(loginForm);
        Message.success(`邮件验证码已发送成功, 请到 ${form.email} 邮箱中查看`);
      },
      onError: ({ message }) => {
        // 发送邮件失败
        formRef.value?.setFields({
          email: {
            status: 'error',
            message,
          },
        });
      },
    }
  );

  /**
   * 发送邮件验证码
   */
  const onEmailCaptcha = async () => {
    const emailValid = await formRef.value?.validateField('email');
    if (!emailValid) {
      const email = loginForm.value.email;
      console.log(`邮件验证成功 ${email}`);

      emailCaptchaRun(email);
    }
  };

  /**
   * 完成邮件验证码登录
   */
  const emailCaptchaFinish = () => {
    // 触发邮件登录
    emailLoginRun({ ...unref(loginForm) });
  };
</script>

<template>
  <a-form
    ref="formRef"
    :layout="'vertical'"
    :model="loginForm"
    :rules="formRules"
    class="login-form"
  >
    <a-form-item field="email" hide-asterisk>
      <a-space :size="'large'">
        <EmailInput v-model="loginForm.email" style="width: 340px" />
        <a-button
          :disabled="captchaVisible"
          :loading="emailCaptchaLoading"
          type="primary"
          @click="onEmailCaptcha"
        >
          <span>验证码</span>
          <template #icon>
            <i
              class="dark:i-streamline-send-email i-streamline-send-email-solid"
            />
          </template>
        </a-button>
      </a-space>
    </a-form-item>
    <a-form-item>
      <a-space v-if="captchaVisible" :direction="'vertical'" :size="'medium'">
        <a-space :size="40" class="justify-between" fill>
          <span class="text-lg">验证码</span>
          <span class="text-md">验证码时效</span>
        </a-space>

        <a-space :size="40" class="justify-between" fill>
          <a-verification-code
            v-model="loginForm.code"
            :length="5"
            style="width: 340px"
            @finish="emailCaptchaFinish"
          />

          <a-countdown
            :start="countdownStart"
            :value="countdown"
            format="mm:ss"
            @finish="captchaVisible = false"
          />
        </a-space>
      </a-space>
    </a-form-item>
  </a-form>
</template>