太久没有学前端,用一个小项目拾起来,参考程序员鱼皮的教程。
初始化
脚手架的搭建就不过多赘述了,可以上网搜或者查文档。编辑器我用的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目录中,结构如图
欢迎页
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可以正常显示
登录页
新建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响应的数据为空,应该是前后端数据的传输有问题。暂时没有解决
注册页
注册页和登录页是很相似的,只是加了编号和二次确认密码的环节,根据登陆页面进行更改就好了。 函数也要进行一些更改
// 注册组件
<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>
页面效果如下,这里我面对的也是同样的问题,登录态无法保存,注册登陆后无法从后端获得该用户的信息
管理页
管理员使用管理页来看到所有用户的信息并进行删除等操作。管理页的开发分为两个阶段:开发一个页面,给这个页面增加管理员权限。
页面开发
可以直接用组件库的搜索和表格组件,这里我们需要搜索栏和表格。
- 根据需要编写表格列并传入数据,组件就能自动帮我们展示出表格
<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();
});
上线项目
这里因为我做的项目还是有点问题,而且还不知道怎么改,就先不部署了。需要的时候再去看鱼皮的教程吧!