vue3项目用户中心

181 阅读5分钟

太久没有学前端,用一个小项目拾起来,参考程序员鱼皮的教程。

初始化

脚手架的搭建就不过多赘述了,可以上网搜或者查文档。编辑器我用的vscode,eslint和prettier的配置我也没太搞明白,但也是能用,就先这样。组件库用的ant design vue
复习一下,vue中三种标签:template对应html,script对应js,style对应css

前端模板搭建

在这个部分我们先搭建好基础的框架,比如页眉页脚、路由设置、请求、状态管理等

全局通用布局

就是多个页面中共享的元素,比如导航栏、底部信息。遵循开发规范,把布局组件BasicLayout放入layouts文件夹。
如此简单的步骤也是有很多坑,vue组件的模板需要在vue.json中按自己的需要更改。我遇到了大面积报错的问题,搜索得知是提交的换行符格式与运行环境不一致的,在右下角就进行LF/CRLF的切换就可以了。并且因为有eslint,格式要求还是比较严格的,有报错没关系,用prettier格式化解决就好啦。
全局通用布局是整个页面都要共享,所以要在项目的根页面引入这个布局

App.vue
<template>
  <!-- 需要用一个div包裹整个页面 -->
  <div id="app">
    <!-- 导入BasicLayout组件,会自动填充script中的import -->
    <BasicLayout />
  </div>
</template>

<script setup lang="ts">
import BasicLayout from "./layouts/BasicLayout.vue";
</script>

<style></style>

接下来就是设置布局的具体内容了,在此我们选择了组件库中一个最简单的上中下布局。
尾部布局最简单,一般是一些版权信息超链接;内容部分使用router-view进行内容的动态展示,并加一个空白的底部外边框以避免内容被底部栏遮住;顶部栏则包括logo、导航栏、用户登录等内容,比较复杂,需要设置单独的GlobalHeader组件,再在布局组件中引入。思路就是选择合适的组件再进行内容的填充和样式的优化

BasicLayout.vue
// 基本布局组件
<template>
  <div id="basicLayout">
    <a-layout>
      <!-- 顶部,使用GlobalHeader组件 -->
      <a-layout-header class="header">
        <GlobalHeader />
      </a-layout-header>

      <!-- 内容部分使用router路由来实现动态替换内容 -->
      <a-layout-content class="content">
        <!-- 使用router-view来动态替换内容 -->
        <router-view></router-view>
      </a-layout-content>

      <!-- 尾部一般是用超链接写版权信息 -->
      <a-layout-footer class="footer">
        <a href="https://codefather.cn" target="_blank">程序员鱼皮</a>
      </a-layout-footer>
    </a-layout>
  </div>
</template>

<script setup lang="ts">
import GlobalHeader from '@/components/GlobalHeader.vue';
</script>

<style scoped>
/* 尾部的样式 */
#basicLayout .footer {
  background: #efefef;
  text-align: center;
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  padding: 16px;
}

/* 内容的样式 */
#basicLayout .content {
  padding: 20px;
  /* 内容底部加上空白外边框,这样内容就不会被footer遮挡 */
  margin-bottom: 20px;
  /* 背景色渐变 */
  background: linear-gradient(to right, #fefefe, #fff);
}

/* 顶部的样式 */
#basicLayout .header {
  background: white;
  margin-bottom: 16px;
  color: unset;
  padding-inline: 20px;
}
</style>


GlobalHeader.vue
<template>
  <div id="globalHeader">
    <!-- 使用栅格布局,三列,标题栏放左边设为200px,菜单栏在中间自适应,登陆按钮在右边设为100px -->
    <a-row :wrap="false">
      <a-col flex="200px">
        <!-- 顶部优化,添加左logo和右标题,记得添加类名便于控制样式 -->
        <div class="title-bar">
          <img class="logo" src="../assets/logo.png" alt="logo" />
          <div class="title">用户中心</div>
        </div>
      </a-col>
      <a-col flex="auto">
        <!-- 引入菜单栏组件,左边是菜单右边是登陆状态跳转登录 -->
        <a-menu
          v-model:selectedKeys="current"
          mode="horizontal"
          :items="items"
        />
      </a-col>
      <a-col flex="100px">
        <div class="user-login-status">
          <a-button type="primary" htef="/user/login">登录</a-button>
        </div>
      </a-col>
    </a-row>
  </div>
</template>

<script lang="ts" setup>
import { h, ref } from 'vue';
import { HomeOutlined, CrownOutlined } from '@ant-design/icons-vue';
import { MenuProps } from 'ant-design-vue';
const current = ref<string[]>(['mail']);
// items表示菜单项
const items = ref<MenuProps['items']>([
  {
    key: '/',
    icon: () => h(HomeOutlined),
    label: '主页',
    title: '主页'
  },
  {
    key: '/user/login',
    label: '用户登录',
    title: '用户登录'
  },
  {
    key: '/user/register',
    label: '用户注册',
    title: '用户注册'
  },
  {
    // 用户管理页有管理员皇冠图标
    key: '/admin/userManage',
    icon: () => h(CrownOutlined),
    label: '用户管理',
    title: '用户管理'
  },
  {
    key: 'others',
    label: h(
      'a',
      { href: 'https://www.codefather.cn', target: '_blank' },
      '编程导航'
    ),
    title: '编程导航'
  }
]);
</script>

<style scoped>
.title-bar {
  display: flex;
  align-items: center;
}

.title {
  color: black;
  font-size: 18px;
  margin-left: 16px;
}

.logo {
  height: 48px;
}
</style>

路由设置

在上面我们设置好了页面大体的模板,但是点击导航栏没反应,下面我们要设置路由来实现相关页面的切换

GlobalHeader.vue
// 通过钩子函数得到路由跳转器
const router = useRouter();

// 点击菜单后的路由跳转事件,接受参数key,并使用路由跳转器的push函数控制要跳转的页面
const doMenuClick = ({ key }: { key: string }) => {
  router.push({
    path: key
  });
};

// 在每次切换到新页面时,监听路由变化,更新菜单选中状态(使用url更新current
const current = ref<string[]>(['mail']);
router.afterEach((to, from, failure) => {
  current.value = [to.path];
});

请求

一般前端只负责页面展示,数据存储和计算是由后端完成的。前端向后端发送请求获取数据,后端执行操作并响应数据给前端。请求的传统方式是使用AJAX技术,但是代码较复杂,我们引入Axios请求库,来简化发送请求的代码。
有些不同的请求都会获取到相同的数据,比较冗余。为了让各个请求中相同的信息复用,我们可以定义通用的响应处理逻辑,也就是全局自定义请求,这样就不用再每个接口请求中写相同的逻辑。比如可以再全局相应拦截器中读出结果中的data,校验code是否合法,未登录状态自动登录。
具体操作方法看文档。

request.ts
// 全局的请求定义文件
import axios from 'axios';

alert(process.env.NODE_ENV);

const myAxios = axios.create({
  // 区分开发和线上环境
  baseURL:
    process.env.NODE_ENV === 'development'
      ? 'http://localhost:8080'
      : 'https://codefather.cn',
  timeout: 10000,
  withCredentials: true
});

// 添加请求拦截器
myAxios.interceptors.request.use(
  function (config) {
    // Do something before request is sent
    return config;
  },
  function (error) {
    // Do something with request error
    return Promise.reject(error);
  }
);

// 添加响应拦截器
myAxios.interceptors.response.use(
  function (response) {
    // Any status code that lie within the range of 2xx cause this function to trigger
    // Do something with response data
    console.log(response);

    const { data } = response;
    console.log(data);
    // 未登录
    if (data.code === 40100) {
      // 不是获取用户信息接口,或者不是登录页面,则跳转到登录页面
      if (
        !response.request.responseURL.includes('user/current') &&
        !window.location.pathname.includes('/user/login')
      ) {
        window.location.href = `/user/login?redirect=${window.location.href}`;
      }
    }
    return response;
  },
  function (error) {
    // Any status codes that falls outside the range of 2xx cause this function to trigger
    // Do something with response error
    return Promise.reject(error);
  }
);

export default myAxios;


user.ts
// 接口
import myAxios from '@/request';

/**
 * 用户注册
 * @param params
 */
export const userRegister = async (params: any) => {
  return myAxios.request({
    url: '/api/user/register',
    method: 'POST',
    data: params
  });
};

/**
 * 用户登录
 * @param params
 */
export const userLogin = async (params: any) => {
  return myAxios.request({
    url: '/api/user/login',
    method: 'POST',
    data: params
  });
};

/**
 * 用户注销
 * @param params
 */
export const userLogout = async (params: any) => {
  return myAxios.request({
    url: '/api/user/logout',
    method: 'POST',
    data: params
  });
};

/**
 * 获取当前用户
 */
export const getCurrentUser = async () => {
  return myAxios.request({
    url: '/api/user/current',
    method: 'GET'
  });
};

/**
 * 获取用户列表
 * @param username
 */
export const searchUsers = async (username: any) => {
  return myAxios.request({
    url: '/api/user/search',
    method: 'GET',
    params: {
      username
    }
  });
};

/**
 * 删除用户
 * @param id
 */
export const deleteUser = async (id: string) => {
  return myAxios.request({
    url: '/api/user/delete',
    method: 'POST',
    data: id,
    // 关键点:要传递 JSON 格式的值
    headers: {
      'Content-Type': 'application/json'
    }
  });
};

全局状态管理

其实就是全局变量,比如已登录用户信息。我们使用pinia库进行状态管理,把状态都存到store目录里。在这里我们是写了一个登录信息store用来管理登录状态信息,使用组合式api,把关于这个变量的定义、方法都写好再导出。最后在其他页面中引入并使用。比如我们想在导航栏最右侧显示用户的登录信息,如果没登陆就显示登录按钮,就可以这样做:

useLoginUserStore.ts
import { getCurrentUser } from '@/api/user';
import { defineStore } from 'pinia';
import { ref } from 'vue';

export const useLoginUserStore = defineStore('LoginUser', () => {
  const loginUser = ref<any>({
    username: '未登录'
  });

  // 远程获取登陆用户信息 get
  async function fetchLoginUser() {
    const res = await getCurrentUser();
    if (res.data.code === 0 && res.data.data) {
      loginUser.value = res.data.data;
    }
    // 测试代码
    // else {
    //   setTimeout(() => {
    //     loginUser.value = { username: '小黑子', id: 1 };
    //   }, 3000);
    // }
  }

  // 单独设置信息 set
  function setLoginUser(newLoginUser: any) {
    loginUser.value = newLoginUser;
  }

  return { loginUser, fetchLoginUser, setLoginUser };
});
GlobalHeader.vue
      <a-col flex="80px">
        <div class="user-login-status">
          <!-- 已登录就显示登录信息,未登录就显示登录按钮 -->
          <div v-if="loginUserStore.loginUser.id">
            {{ JSON.stringify(loginUserStore.loginUser.username) ?? '无名' }}
          </div>
          <div v-else>
            <a-button type="primary" href="/user/login">登录</a-button>
          </div>
        </div>
      </a-col>
      
import { useLoginUserStore } from '@/store/useLoginUserStore';
const loginUserStore = useLoginUserStore();

前端页面开发

这个项目主要实现了用户登录、用户注册、用户管理这几个简单的小功能,页面不会很复杂,主要的目的是熟悉开发流程。
我们每次添加新页面,都要添加在index.ts文件中。由于我们前面已经写好了路由配置,现在只需要根据路由来写页面。把这些页面都放到src/page目录中,结构如图
image.png

欢迎页

router里是这样写的

index.ts
import HomePage from "@/pages/HomePage.vue";

const routes: Array<RouteRecordRaw> = [
  {
    path: "/",
    name: "home",
    component: HomePage,
  },
  ...
]

于是,我们新建HomePage组件,随便写点什么,看看能不能正常显示

HomePage.vue
// 欢迎页
<template>
  <div id="homePage">
    <h1>{{ msg }}</h1>
  </div>
</template>

<script setup lang="ts">
const msg = '欢迎来到用户中心!';
</script>

<style scoped>
#homePage {
}
</style>

耶( •̀ ω •́ )y可以正常显示 image.png

登录页

新建UserLoginPage.vue,找一个合适的表单组件,再进行一点点修改,比如前端校验

<template>
  <div id="userLoginPage">
    <h2 class="title">用户登录</h2>
    <a-form
      style="max-width: 480px; margin: 0 auto"
      :model="formState"
      name="basic"
      label-align="left"
      :label-col="{ span: 4 }"
      :wrapper-col="{ span: 20 }"
      autocomplete="off"
      @finish="handleSubmit"
    >
      <a-form-item
        label="账号"
        name="userAccount"
        :rules="[{ required: true, message: '请输入账号' }]"
      >
        <a-input
          v-model:value="formState.userAccount"
          placeholder="请输入账号"
        />
      </a-form-item>

      <a-form-item
        label="密码"
        name="userPassword"
        :rules="[
          { required: true, message: '请输入密码' },
          { min: 8, message: '密码长度不能少于8位' }
        ]"
      >
        <a-input-password
          v-model:value="formState.userPassword"
          placeholder="请输入密码"
        />
      </a-form-item>

      <a-form-item :wrapper-col="{ offset: 4, span: 20 }">
        <a-button type="primary" html-type="submit">登录</a-button>
      </a-form-item>
    </a-form>
  </div>
</template>

<style scoped>
#userLoginPage .title {
  text-align: center;
  margin-bottom: 16px;
}
</style>

编写提交表单后执行的handleSubmit函数,登陆成功就把登录态保存到全局状态中并跳转到主页;登陆不成功就提示登录错误

<script lang="ts" setup>
import { userLogin } from '@/api/user';
import { useLoginUserStore } from '@/store/useLoginUserStore';
import { message } from 'ant-design-vue';
import { reactive } from 'vue';
import { useRouter } from 'vue-router';

// 用于接收用户输入的变量,用v-model双向绑定
interface FormState {
  userAccount: string;
  userPassword: string;
}

const formState = reactive<FormState>({
  userAccount: '',
  userPassword: ''
});

const router = useRouter();
const loginUserStore = useLoginUserStore();

// 表单提交执行登录
const handleSubmit = async (values: any) => {
  const res = await userLogin(values);
  // 登陆成功,把登录态保存到全局状态中并跳转到主页
  if (res.data.code === 0 && res.data.data) {
    await loginUserStore.fetchLoginUser();
    message.success('登录成功');
    router.push({
      path: '/',
      replace: true
    });
  } else {
    message.error('登录失败');
  }
};
</script>

正常来讲效果应该是这样,但是我这边只能显示登陆成功并跳转,右上角没有变化。login响应的数据正常,但current响应的数据为空,应该是前后端数据的传输有问题。暂时没有解决 image.png

注册页

注册页和登录页是很相似的,只是加了编号和二次确认密码的环节,根据登陆页面进行更改就好了。 函数也要进行一些更改

// 注册组件
<template>
  <div id="userRegisterPage">
    <h2 class="title">用户注册</h2>
    <a-form
      style="max-width: 480px; margin: 0 auto"
      :model="formState"
      name="basic"
      label-align="left"
      :label-col="{ span: 4 }"
      :wrapper-col="{ span: 20 }"
      autocomplete="off"
      @finish="handleSubmit"
    >
      <a-form-item
        label="账号"
        name="userAccount"
        :rules="[{ required: true, message: '请输入账号' }]"
      >
        <a-input
          v-model:value="formState.userAccount"
          placeholder="请输入账号"
        />
      </a-form-item>

      <a-form-item
        label="密码"
        name="userPassword"
        :rules="[
          { required: true, message: '请输入密码' },
          { min: 8, message: '密码不能少于8位' }
        ]"
      >
        <a-input-password
          v-model:value="formState.userPassword"
          placeholder="请输入密码"
        />
      </a-form-item>

      <a-form-item
        label="确认密码"
        name="checkPassword"
        :rules="[
          { required: true, message: '请再次输入密码' },
          { min: 8, message: '确认密码不能少于8位' }
        ]"
      >
        <a-input-password
          v-model:value="formState.checkPassword"
          placeholder="请输入确认密码"
        />
      </a-form-item>

      <a-form-item
        label="编号"
        name="planetCode"
        :rules="[{ required: true, message: '请输入编号' }]"
      >
        <a-input
          v-model:value="formState.planetCode"
          placeholder="请输入编号"
        />
      </a-form-item>

      <a-form-item :wrapper-col="{ offset: 4, span: 20 }">
        <a-button type="primary" html-type="submit">注册</a-button>
      </a-form-item>
    </a-form>
  </div>
</template>
<script lang="ts" setup>
import { userRegister } from '@/api/user';
import { message } from 'ant-design-vue';
import { reactive } from 'vue';
import { useRouter } from 'vue-router';

// 用于接收用户输入的变量,用v-model双向绑定
interface FormState {
  userAccount: string;
  userPassword: string;
  checkPassword: string;
  planetCode: string;
}

const formState = reactive<FormState>({
  userAccount: '',
  userPassword: '',
  checkPassword: '',
  planetCode: ''
});

const router = useRouter();

// 表单提交执行注册
const handleSubmit = async (values: any) => {
  // 判断两次输入的密码是否一致
  if (formState.userPassword !== formState.checkPassword) {
    message.error('两次输入的密码不一致');
    return;
  }

  const res = await userRegister(values);
  // 注册成功,跳转到登录页面
  if (res.data.code === 0 && res.data.data) {
    message.success('注册成功');
    router.push({
      path: '/user/login',
      replace: true
    });
  } else {
    message.error('注册失败' + res.data.description);
  }
};
</script>

<style scoped>
#userRegisterPage .title {
  text-align: center;
  margin-bottom: 16px;
}
</style>

页面效果如下,这里我面对的也是同样的问题,登录态无法保存,注册登陆后无法从后端获得该用户的信息
image.png image.png

管理页

管理员使用管理页来看到所有用户的信息并进行删除等操作。管理页的开发分为两个阶段:开发一个页面,给这个页面增加管理员权限。

页面开发

可以直接用组件库的搜索和表格组件,这里我们需要搜索栏和表格。

  • 根据需要编写表格列并传入数据,组件就能自动帮我们展示出表格
<template>
  <!-- 表格中传入两个数据,列配置和数据 -->
  <a-table :columns="columns" :data-source="data">
    <template #bodyCell="{ column, record }">
      <!-- 用插槽来控制内容展示 -->
      <template v-if="column.dataIndex === 'avatarUrl'">
        <a-image :src="record.avatarUrl" :width="120"></a-image>
      </template>
      <template v-else-if="column.dataIndex === 'userRole'">
        <div v-if="record.userRole === 1">
          <a-tag color="green">管理员</a-tag>
        </div>
        <div v-else>
          <a-tag color="blue">普通用户</a-tag>
        </div>
      </template>
      <template v-else-if="column.dataIndex === 'createTime'">
        {{ dayjs(record.createTime).format('YYYY-MM-DD HH:mm:ss') }}
      </template>
      <template v-else-if="column.key === 'action'">
        <a-button danger>删除</a-button>
      </template>
    </template>
  </a-table>
</template>

<script lang="ts" setup>
import { searchUsers } from '@/api/user';
import { message } from 'ant-design-vue';
import { ref } from 'vue';
// 定义表格列
const columns = [
  // title是要展示的列名  dataIndex是数据源的字段名
  {
    title: 'id',
    dataIndex: 'id'
  },
  {
    title: '用户名',
    dataIndex: 'username'
  },
  {
    title: '账号',
    dataIndex: 'userAccount'
  },
  {
    title: '头像',
    dataIndex: 'avatarUrl'
  },
  {
    title: '性别',
    dataIndex: 'gender'
  },
  {
    title: '创建时间',
    dataIndex: 'createTime'
  },
  {
    title: '用户角色',
    dataIndex: 'userRole'
  },
  {
    title: '操作',
    key: 'action'
  }
];

// 数据
const data = ref([]);
// 获取数据
const fetchData = async () => {
  const res = await searchUsers('');
  // 如果后端返回了数据
  if (res.data.data) {
    data.value = res.data.data;
  } else {
    message.error('获取用户数据失败');
  }
};
fetchData();
</script>
  • 利用搜索组件实现对数据的搜索
    对组件进行修改:
    <!-- 搜索框,进行样式设置和函数、数据绑定 -->
    <a-input-search
      style="max-width: 320px; margin-bottom: 20px"
      v-model:value="searchValue"
      placeholder="输入用户名搜索"
      enter-button="搜索"
      size="large"
      @search="onSearch"
    />

对获取数据的函数进行改造,使之支持传入参数(搜索条件)

// 获取数据
const fetchData = async (username = '') => {
  const res = await searchUsers(username);
  // 如果后端返回了数据
  if (res.data.data) {
    data.value = res.data.data;
  } else {
    message.error('获取用户数据失败');
  }
};

定义搜索变量和搜索函数

// 输入的搜索数据
const searchValue = ref('');
// 获取后端数据,直接用前面定义过的函数来操作
const onSearch = async () => {
  fetchData(searchValue.value);
};
  • 删除功能
    删除按钮进行事件绑定并编写删除处理函数
<a-button danger @click="doDelete(record.id)">删除</a-button>

// 删除用户
const doDelete = async (id: string) => {
  if (!id) {
    return;
  }
  const res = await deleteUser(id);
  if (res.data.code === 0) {
    message.success('删除成功');
  } else {
    message.error('删除失败');
  }
};

权限控制

后端其实会进行权限控制,但是前端也要进行校验。
编写独立的全局权限控制文件,利用router的路由守卫实现,每次切换进入页面前都会检查当前用户有没有特定页面的权限。在src下新建access.ts权限校验文件。最后不要忘记在main.ts中引用

import router from '@/router';
import { useLoginUserStore } from '@/store/useLoginUserStore';
import { message } from 'ant-design-vue';

// 全局权限校验路由守卫
router.beforeEach(async (to, form, next) => {
  const loginUserStore = useLoginUserStore();
  const loginUser = loginUserStore.loginUser;
  const toUrl = to.fullPath;
  if (toUrl.startsWith('/admin')) {
    // 未登录或权限不是管理员,报错并跳转到用户登录页
    if (loginUser || loginUser.userRole != 1) {
      message.error('没有权限');
      next('/user/login?redirect=${to.fullPath}');
      return;
    }
  }
  next();
});

上线项目

这里因为我做的项目还是有点问题,而且还不知道怎么改,就先不部署了。需要的时候再去看鱼皮的教程吧!