论坛项目
希望实现的功能:
header导航在页面向下滑动一定程度后隐藏,当用户往上滑动的时候再使header出现
我们需要 给header一个v-if变量 然后记录一下滚动距离 给滚动距离做一个判断 同时判断是向上滑动还是向下滑动
这里我们是用js实现的
template部分
1
| <div class="header" v-if="showHeader"></div>
|
script部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| const showHeader = ref(true);
const getScrollTop = () => { let scrollTop = document.documentElement.scrollTop || window.pageYOffset || document.body.scrollTop; return scrollTop; }
const initScroll = () => { let initScrollTop = getScrollTop(); let scrollType = 0; window.addEventListener("scroll", () => { let scrollTop = getScrollTop(); if(scrollTop > initScrollTop){ scrollType = 1; }else{ scrollType = 0; } initScrollTop = scrollTop; if (scrollType == 1 && scrollType > 100) showHeader.value = false; else showHeader.value = true; }) }
|
封装弹窗
封装一个弹窗 我们希望它有高复用性,能够在项目的各个场景中灵活使用
这个组件的封装我们使用了很标准的父子组件通信流程 即通过 props 传数据,通过 emit 触发事件 跟上一个项目中大量使用pinia是不同的
基础结构设计
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| <template> <el-dialog :model-value="show" :title="title" :show-close="showClose" :draggable="true" :close-on-click-modal="false" :before-close="handleClose" :width="width" custom-class="cust-dialog" :top="top" @close="close" > <div class="dialog-body"> <slot></slot> </div> <template v-if="buttons && buttons.length > 0 || showCancel"> <div class="dialog-footer"> <el-button link @click="close" v-if="showCancel">取消</el-button> <el-button v-for="btn in buttons" :type="btn.type" @click="btn.click"> {{btn.text}} </el-button> </div> </template> </el-dialog> </template>
|
这里只展示整个结构 具体的实现逻辑和部分在后面写
关于这些api配置是什么 见官网Dialog 对话框 | Element Plus对的API部分!
props配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| const props = defineProps({ show: { type: Boolean, default: false, }, title: { type: String, default: "标题", }, showClose: { type: Boolean, default: true, }, width: { type: String, default: "30%" }, top: { type: String, default: "50px" }, buttons: { type: Array, default: () => [] }, showCancel: { type: Boolean, default: true } });
|
props是我们用来将父组件数据传递给子组件的 在某些开发场景中 我们可以说是用来让父组件配置子组件的
我们设置这么多props的原因是我们可以用同一个组件,通过不同参数渲染出不同效果,这就是 配置化 思想
不同的业务场景可以调整这些props以满足需求
并且这能够保证单一数据源 即子组件只能由父组件操控
| prop |
作用 |
为什么要暴露 |
show |
控制显示 / 隐藏 |
父组件需要控制什么时候弹出来,比如点击按钮或接口返回后 |
title |
弹窗标题 |
不同场景标题不同(新增 / 编辑 / 详情) |
showClose |
是否显示关闭按钮 |
有些弹窗不允许用户手动关闭(如加载中) |
width |
弹窗宽度 |
表单弹窗可能要宽一些,确认弹窗可以窄一些 |
top |
距离顶部距离 |
不同内容高度的弹窗可能需要调整位置 |
buttons |
自定义按钮 |
有的弹窗只有一个确认按钮,有的有多个操作按钮 |
showCancel |
是否显示取消按钮 |
有的业务不需要取消(比如删除确认) |
这些 props 覆盖了弹窗的 结构(标题、按钮)、样式(宽度、位置)、行为(是否可关闭) 三大方面,从而让组件变得通用。
父子组件通信
1 2 3 4
| const emit = defineEmits(); const close= () => { emit("close") };
|
在这里我们的关闭弹窗方法并没有在子组件中实现 而是作为自定义事件传递给父组件 然后在父组件中实现
我们在这里贴出在某个父组件中的用法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| <template> <el-button type="primary" plain @click="login">登录</el-button> <Dialog :show="showDialog" :buttons="buttons" @close="showDialog = false">这里是内容</Dialog> </template>
<script setup>
const showDialog = ref(false);
const buttons = [{ text: "确定", type: "primary", }]
const login = () => { showDialog.value = true; }
</script>
|
注意我们在子组件中定义的事件close并不是一个方法 而是像@click这样的一个触发方法
我们在父组件这里用的是表达式布尔判断
为什么这里要用emit方法进行父子组件通信呢
- 数据流清晰:父组件是唯一数据源,避免 “子组件偷偷改状态” 导致的调试灾难; 因为在实际业务中 关闭事件可能伴随着很多逻辑 比如上传登录数据等等 所以只能由父组件触发
- 组件通用:子组件不绑定业务逻辑,能适配所有弹窗场景,提高复用性;我们不想让子组件去关注业务具体实现逻辑
- 责任明确:谁触发的状态变更(父组件),谁就负责修改,符合 “高内聚低耦合” 的组件设计原则
登录UI与前端交互
外层用我们之前封装好的弹窗 内层用elment的表单来填充
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175
| <template> <div> <Dialog :show="dialogConfig.show" :title="dialogConfig.title" :buttons="dialogConfig.buttons" width="400px" :showCancel="false" @close="dialogConfig.show = false">
<el-form :model="formData" :rules="rules" ref="formDataRef" class="login-register" @submit.prevent > <!--邮箱--> <el-form-item prop="email" > <el-input size="large" placeholder="请输入邮箱" v-model.trim="formData.email"> <template #prefix> <span class="iconfont icon-account"></span> </template> </el-input> </el-form-item>
<!--登录密码--> <el-form-item prop="password" v-if="opType=== 1" > <el-input :type="passwordEyeType.passwordEyeOpen ? 'text' : 'password'" size="large" placeholder="请输入密码" v-model.trim="formData.password"> <template #prefix> <span class="iconfont icon-password"></span> </template> <template #suffix> <span @click="eyeChange('passwordEyeOpen')" :class="[ 'iconfont', passwordEyeType.passwordEyeOpen ? 'icon-eye' : 'icon-close-eye' ]" > </span> </template> </el-input> </el-form-item>
<!--注册部分--> <div v-if="opType=== 0 || opType=== 2" >
<!-- 邮箱验证码 --> <el-form-item prop="emailCode" > <div class="send-emai-panel"> <el-input size="large" placeholder="请输入邮箱验证码" v-model.trim="formData.emailCode"> <template #prefix> <span class="iconfont icon-checkcode"></span> </template> </el-input> <el-button class="sent-mail-btn" type="primary" size="large">获取验证码</el-button> </div>
<el-popover placement="left" :width="450" trigger="click"> <div> <p>1、在垃圾箱中查找邮箱验证码</p> <p>2、在邮箱中头像->设置->反垃圾->白名单->设置邮件地址白名单</p> <p>3、将邮箱【3312584336@qq.com】添加到白名单不知道怎么设置?</p> <a href="" target="_blank" class="a-link">不知道怎么设置?</a> </div> <template #reference> <span class="a-link" style="font-size: 14px;">未收到邮箱验证码?</span> </template> </el-popover>
</el-form-item>
<!-- 昵称 --> <el-form-item prop="nickName" v-if="opType === 0"> <el-input size="large" placeholder="请输入昵称" v-model.trim="formData.nickName"> <template #prefix> <span class="iconfont icon-account"></span> </template> </el-input> </el-form-item>
<!--注册密码--> <el-form-item prop="registerPassword" > <el-input :type="passwordEyeType.registerPasswordEyeOpen ? 'text' : 'password'" size="large" placeholder="请输入密码" v-model.trim="formData.registerPassword"> <template #prefix> <span class="iconfont icon-password"></span> </template> <template #suffix> <span @click="eyeChange('registerPasswordEyeOpen')" :class="[ 'iconfont', passwordEyeType.registerPasswordEyeOpen ? 'icon-eye' : 'icon-close-eye' ]" > </span> </template> </el-input> </el-form-item>
<!-- 重复密码 --> <el-form-item prop="reRegisterPassword" > <el-input :type="passwordEyeType.registerPasswordEyeOpen ? 'text' : 'password'" size="large" placeholder="请再次输入密码" v-model.trim="formData.reRegisterPassword" > <template #prefix> <span class="iconfont icon-password"></span> </template>
<template #suffix> <span @click="eyeChange('reRegisterPasswordEyeOpen')" :class="[ 'iconfont', passwordEyeType.reRegisterPasswordEyeOpen ? 'icon-eye' : 'icon-close-eye' ]" > </span> </template> </el-input> </el-form-item> </div> <!--验证码--> <el-form-item prop="checkCode" > <div class="check-code-panel"> <el-input size="large" placeholder="请输入验证码" v-model.trim="formData.checkCode"> <template #prefix> <span class="iconfont icon-checkcode"></span> </template> </el-input> <img :src="checkCodeUrl" class="check-code" @click="changeCheckCode(0)"> </div> </el-form-item>
<!-- 记住我 忘记密码 没有账号 --> <el-form-item v-if="opType=== 1"> <div class="rememberme-panel"> <el-checkbox v-model="formData.rememberMe">记住我</el-checkbox> </div> <div class="no-account"> <a href="javascript:void(0)" class="a-link" @click="showPanel(2)">忘记密码?</a> <a href="javascript:void(0)" class="a-link" @click="showPanel(0)">没有账号?</a> </div> </el-form-item> <!-- 已有账号 --> <el-form-item v-if="opType=== 0"> <a href="javascript:void(0)" class="a-link" @click="showPanel(1)">已有账号?</a> </el-form-item >
<!-- 去登录 --> <el-form-item v-if="opType=== 2"> <a href="javascript:void(0)" class="a-link" @click="showPanel(1)">去登录?</a> </el-form-item >
<!-- 登录按钮 --> <el-form-item> <el-button type="primary" class="op-btn">登录</el-button> </el-form-item>
</el-form> </Dialog> </div> </template>
|
大致内容不介绍 我们只在这里讲解一下一些实现细节
有几个需要注意的细节是
1.在写了dialog与表单后我们需要在script中配置
dialog的配置是我们之前自己封装的
form的部分包括规则都是我们在后台项目中介绍过的
1 2 3 4 5 6 7 8 9 10
| const dialogConfig = reactive({ show: false, title: "标题", });
const formData = ref({}); const formDataRef = ref(); const rules = { title: [{ required: true, message: "请输入内容" }], };
|
2.我们在此组件中 将登录注册找回密码的表单所需的输入框都写出来了 我们是根据表单类型选择展示的
3.登录与注册页面的密码输入框是不同的 因为登录界面我们是传递密文形式 而注册页面是传入明文形式 所以我们需要单独写一下
表单类型选择性展示
我们想要根据用户点击来划分表单类型 这里使用的同样是父子组件通信 我们通过父组件点击事件传递的type来确定表单的type
父子组件通信传递表单类型
在子组件中
子组件LoginAndRegister是封装的登录注册表单
1 2 3 4 5 6 7 8 9 10 11 12
| const opType = ref()
const showPanel = (type) => { opType.value = type;
dialogConfig.show = true;
resetForm(); }
defineExpose({ showPanel })
|
在父组件中
1 2 3 4 5 6 7
| <el-button-group :style="{'margin-left': '5px'}"> <el-button type="primary" plain @click="loginAndRegister(1)">登录</el-button> <el-button type="primary" plain @click="loginAndRegister(0)">注册</el-button> </el-button-group>
<LoginAndRegister ref="LoginAndRegisterRef" />
|
1 2 3 4 5 6
| const LoginAndRegisterRef = ref();
const loginAndRegister = (type) => { LoginAndRegisterRef.value.showPanel(type) LoginAndRegisterRef.value.type = type; }
|
我们需要根据表单的类型来判断什么输入框是需要的
根据表单类型显示输入框
这里我们就简单举一个例子来展示我们是怎么用v-if来选择性展示表单类型需要的输入框
1 2 3 4 5 6 7 8
| <!-- 昵称 --> <el-form-item prop="nickName" v-if="opType === 0"> <el-input size="large" placeholder="请输入昵称" v-model.trim="formData.nickName"> <template #prefix> <span class="iconfont icon-account"></span> </template> </el-input> </el-form-item>
|
根据表单类型设定标题
由于标题是表单的基本config配置 故而我们把这部分放在重置表单中
在上一个项目中我们已经学到了 当我们关闭弹窗的时候我们需要重置一下表单
1 2 3 4 5 6 7 8 9 10 11 12
| const resetForm = () => { dialogConfig.show = true; if (opType.value === 0) dialogConfig.title = "注册"; else if (opType.value === 1) dialogConfig.title = "登录"; else if (opType.value === 2) dialogConfig.title = "找回密码";
nextTick(() => { changeCheckCode(0); formDataRef.value.resetFields(); }); }
|
基本的逻辑跟后台管理项目中实现的功能是一样的 不同的是这里我们根据opType的值来设定title
我们在每次弹窗出现开始渲染表单的时候重置表单
1 2 3 4 5 6 7 8 9 10
| const showPanel = (type) => { opType.value = type;
dialogConfig.show = true;
resetForm(); }
defineExpose({ showPanel })
|
发送验证码弹窗
ui设计
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| <Dialog :show="dialogConfigSendMailCode.show" :title="dialogConfigSendMailCode.title" :buttons="dialogConfigSendMailCode.buttons" width="500px" :showCancel="false" @close="dialogConfigSendMailCode.show = false"> <el-form :model="formDataSendMailCode " :rules="rules" ref="formDataSendMailCodeRef" label-width="80px" @submit.prevent > <el-form-item label="邮箱" > {{ formData.email }} </el-form-item>
<el-form-item label="验证码" prop="checkCode"> <div class="check-code-panel"> <el-input size="large" placeholder="请输入验证码" v-model.trim="formDataSendMailCode.checkCode"> <template #prefix> <span class="iconfont icon-checkcode"></span> </template> </el-input> <img :src="checkCodeUrlSendMailCode" class="check-code" @click="changeCheckCode(1)"> </div> </el-form-item> </el-form> </Dialog>
|
Message封装
即封装一下error success warning几种操作结果的消息提示 基于element-plus组件库中的Message实现 目的是实现在各种使用场景下的高复用性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| import { ElMessage } from 'element-plus';
const showMessage = (msg, callback, type) => { ElMessage({ type: type, message: msg, duration: 2000, onClose: () => { if(callback) callback(); } }) }
const message = { error: (msg, callback) => { showMessage(msg,callback,'error') }, success: (msg, callback) => { showMessage(msg,callback,'success') }, warning: (msg, callback) => { showMessage(msg,callback,'warning') } }
export default message;
|
自定义一个showMessage函数用来完成基础配置 其中我们还配置了onClose关闭后的回调函数 传入了msg作为提示信息 callback为可能需要的特殊回调函数 type用来匹配几种不同信息
最后我们在全局里引入一下message即可
1 2 3
| import Message from './utils/Message'
app.config.globalProperties.Message = Message
|
有些时候我们基于已经完成的组件库进行再封装 手动去配置一些我们希望组件能够实现的功能 这其实是之前我们忽视的一点
http请求封装
在此之前我一直没有太搞清楚axios请求到底是以怎样的流程实现的 这里我写一下写完这个功能的理解
基本步骤
1.前端发送请求 通过axios发送一个请求到后端,这个请求一般会附带一些配置与信息 包括 用什么方法(url) 想要请求什么(params) 以及一些自定义的配置
2.后端返回数据 后端接受了请求后会返回一个结果数据 在这里我们可能会通过拦截器对返回的数据进行一些处理 (比如目前请求是否成功 数据是否有问题)
3.前端处理数据 前端对返回的数据进行处理 完成我们需要的逻辑
基础请求封装的结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| import axios from 'axios';
const instance = axios.create({ baseURL: '/api', timeout: 10 * 1000, })
instance.interceptors.request.use( (config) => {
}, (error) => {
} )
instance.interceptors.response.use( (config) => {}, (error) => {}, )
const request = (config) => {
}
export default request;
|
这是一段axios请求的基本结构 其中instance是我们创建的axios实例 在里面我们定义了基础方法 和 请求超时的毫秒数
拦截器 即请求前过滤器和请求后过滤器 功能在上面的基本步骤里已经写了
request是我们需要设置的请求部分 我们在里面会设置基本的请求相关配置 最后发送请求
请求前过滤器
在这个业务场景中 在请求前我们主要是实现一下加载效果 这里的加载是使用的element组件库中的加载 具体的配置在官网中都有 这里就只贴出戴拿
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| const loading = null
instance.interceptors.request.use( (config) => { if (config.showLoading) { loading = ElLoading.service({ lock: true, text: '加载中......', background: 'rgba(0, 0, 0, 0.7)' }); } return config; }, (error) => { if (config.showLoading && loading) { loading.close(); }
Message.error("请求超时!"); return Promise.reject(error); } )
|
需要注意得到是 这里我们传入的config也是我们在发送请求的时候配置的部分
在错误处理中 如果showloading应该被触发但出现错误 我们就把这个加载效果关掉 防止一直加载
需要注意的是 在请求前成功的最后要 return config 才能在后续的请求中拿到这个config
请求后过滤器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| instance.interceptors.response.use( (response) => { const { showLoading, errorCallback, showError } = response.config; if(showLoading && loading) { loading.close(); }
const responsedata = response.data;
if (responsedata.code == 200) { return responsedata; } else if (responsedata.code == 901) { return Promise.reject({showError:false,msg:"登录超时"}) } else { if (errorCallback) { errorCallback(responsedata) } return Promise.reject({showError:true,msg:responsedata.info}) }
}, (error) => { if (error.config.showLoading && loading) { loading.close(); } return Promise.reject({showError:true,msg:"网络异常!"}); }, )
|
请求后过滤器中需要封装的内容会相对更多一点 因为我们需要在这里对后端返回的数据进行一次判断 过滤掉错误信息
response 就是我们发送请求后后端返回给我们的数据

这个config即发出的配置 包含了我们结构出来的内容
只要这个请求在之前调用了加载(即showloading为true)我们就将加载关掉(因为进入了请求后过滤器的response部分说明已经请求成功了)
后面的部分我们需要对返回数据是否正确进行判断 response.data里的具体部分大致如下 当然status分为success和error
1 2 3 4 5 6
| { "status":"success", "code":200, "info":"请求成功", "data":null }
|
状态码如下
| Code状态码 |
说明 |
| 200 |
请求成功 |
| 404 |
请求地址不存在 |
| 600 |
请求参数错误 |
| 601 |
信息已经存在,重复提交 |
| 602 |
信息提交过多,触发了提交信息阈值,比如当天发帖太多,评论太多 |
| 500 |
服务器返回错误 |
| 901 |
登录超时,长时间没操作,退session过期 |
所以我们需要拿到data数据然后对code进行判断就能够过滤出错的数据
在这里我们的处理是 对于==200时 直接返回data部分 在具体业务逻辑中去处理 然后对901做了特殊的错误信息提示 其他我们直接传入responsedata的info 即后端直接给我们返回的错误原因
对于错误数据 我们直接返回promise.reject 关于reject是如何与我们之前封装的Message联系起来 在后面的部分会说
请求函数request
在这里的请求函数中 我们封装了针对两种数据类型的请求 分别是表单数据和json数据
1 2
| const contentTypeForm = 'application/x-www-form-urlencoded;charset=UTF-8'; const contentTypeJson = 'application/json';
|
在请求中具体情况具体判断
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| const request = (config) => {
const { url, params, dataType, showLoading = true, errorCallback, showError = true } = config; let contentType = contentTypeForm;
let formData = new FormData();
for (let key in params) { formData.append(key,params[key] == undefined ? '' : params[key]) }
if (dataType != null && dataType == 'json') { contentType = contentTypeJson; }
let headers = { 'Content-Type': contentType, 'X-Requested-With': 'XMLHttpRequest' }
return instance.post(url, formData, { headers:headers, showLoading: showLoading, errorCallback: errorCallback, showError: showError }).catch((error) => { if (error.showError) { Message.error(error.msg); } return null; }) }
|
解构请求所需参数
1
| const { url, params, dataType, showLoading = true, errorCallback, showError = true } = config;
|
确定请求数据类型
1 2 3 4
| let contentType = contentTypeForm; if (dataType != null && dataType == 'json') { contentType = contentTypeJson; }
|
将请求参数处理后放入表单数据
1 2 3 4 5
| let formData = new FormData();
for (let key in params) { formData.append(key,params[key] == undefined ? '' : params[key]) }
|
这里这样处理的主要目的是防止undefined和null在发送到后端后后端无法识别 进而不返回内容
如果某个参数值是undefined(比如你忘记写收件人电话了,这一项是空的),直接提交的话,后端可能 “看不懂” 这个空值,导致请求失败(快递寄不出去)。
所以代码做了一个处理:如果发现某个参数值是undefined,就自动换成空字符串(相当于在 “电话” 那栏填个空,告诉后端 “这里确实没有内容”,而不是留个 “未填写” 的状态)。
这样处理后,FormData里的每个参数都有明确的值(要么是实际内容,要么是空字符串),最后作为发送请求的参数,后端能正常解析,避免了因参数格式异常导致的请求错误。
设置请求头
1 2 3 4
| let headers = { 'Content-Type': contentType, 'X-Requested-With': 'XMLHttpRequest' }
|
'Content-Type': contentType
作用:告诉服务器客户端发送的数据格式类型
'X-Requested-With': 'XMLHttpRequest'
作用:标识这是一个 AJAX 请求
这是一个传统的约定,服务器可以通过检查这个请求头来判断:
- 请求是通过 JavaScript 发起的 AJAX 请求
- 而不是普通的页面跳转或表单提交
- 有助于服务器进行相应的处理(如返回 JSON 而不是 HTML)
发送post请求
这是整个request中最核心的部分 在这里我们调用了axios请求的post方法直接发出请求(会返回一个promise对象)
1 2 3 4 5 6 7 8 9 10 11
| return instance.post(url, formData, { headers:headers, showLoading: showLoading, errorCallback: errorCallback, showError: showError }).catch((error) => { if (error.showError) { Message.error(error.msg); } return null; })
|
url: 请求地址(从 config 中解构得到)
formData: 请求数据(经过处理的参数)
- 配置对象(第三个参数)
我们配置的这几个参数都会在拦截器中被使用到
在这里就可以解释 之前在我们的拦截器中 对于错误信息我们直接返回promise.reject 最终会以message的形式渲染到页面中
- 网络请求失败
↓
- 进入响应拦截器的 error 回调
↓
- 返回 Promise.reject({showError:true, msg:”网络异常!”})
↓
- 这个 rejected Promise 被 request 函数的 .catch() 捕获
↓
- 在 .catch() 中检查 error.showError 为 true
↓
- 调用 Message.error(error.msg) 显示错误信息
↓
- 返回 null 给调用方
这就是处理失败的全过程
完整执行流程
调用 request(config)
↓
解析配置参数
↓
处理请求数据和请求头
↓
调用 instance.post() 发起请求
↓
请求拦截器处理(显示加载动画等)
↓
服务器响应
↓
响应拦截器处理(关闭加载动画、处理业务状态码)
↓
如果有错误,进入 .catch() 处理
↓
根据 showError 决定是否显示错误消息
↓
返回 null 给调用方
Confirm提示框封装

这里的封装并不是直接封装一个vue组件 而是我们封装一个方法 让我们调用这个组件的时候无需再写多行的配置 方便我们进行调用
比如这里我们就是简化了element中ElMessageBox.confirm的调用(调用 ElMessageBox.confirm 方法以打开 confirm 框) 它是一个已经经过封装的 可以直接作为绑定事件调用的组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import { ElMessageBox } from "element-plus";
const Confirm = (msg, okfun) => { ElMessageBox.confirm(msg, "提示", { "confirm-button-text": "确定", "cancel-button-text": "取消", "type": "info" }).then(async () => { okfun() }).catch(() => {
}) }
export default Confirm
|
- 功能目的:封装后只需传入 “提示信息” 和 “确认后的回调函数”,就能快速弹出确认弹框,点击 “确定” 执行回调,点击 “取消” 则不做任何操作。
- 参数说明:
msg:弹框中显示的提示文本(如 “是否确认下载?”)。
okfun:点击 “确定” 按钮后要执行的函数(如发起下载请求、提交表单等)。
- 执行流程:
- 调用
Confirm 时,会弹出一个标题为 “提示” 的确认弹框,包含 “确定” 和 “取消” 按钮,类型为 “信息类” 弹框。
- 点击确定:执行
then 中的逻辑,调用 okfun 执行自定义操作。
- 点击取消:执行
catch 中的逻辑,此处为空,即点击取消后无任何额外操作。
Vertify校验封装
Vertify用于我们在表格的rule中进性内容正确的校验
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| const regs = { email: /^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/, number: /^([0]|[1-9][0-9]*)$/, password: /^(?=.*\d)(?=.*[a-zA-Z])[\da-zA-Z~!@#$%^&*_]{8,18}$/, } const verify = (rule, value, reg, callback) => { if (value) { if (reg.test(value)) { callback() } else { callback(new Error(rule.message)) } } else { callback() } }
export default { email: (rule, value, callback) => { return verify(rule, value, regs.email, callback) }, number: (rule, value, callback) => { return verify(rule, value, regs.number, callback) }, password: (rule, value, callback) => { return verify(rule, value, regs.password, callback) }, }
|
例如 我们在进行表单校验的时候会规定rule
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const rules = { email: [ { required: true, message: '请输入邮箱' }, { max: 150, message: '邮箱长度不能超过150个字符' }, { validator: proxy.Vertify.email, message: '请输入正确的邮箱' }, ], password: [{ required: true, message: '请输入密码' }], emailCode: [{ required: true, message: '请输入邮箱验证码' }], nickName: [{ required: true, message: '请输入昵称' }], registerPassword: [ { required: true, message: '请输入密码' }, { validator: proxy.Vertify.password, message: '请输入8-18位仅由数字字符特殊字符构成的密码' }, ], reRegisterPassword: [ { required: true, message: '请再次输入密码' }, { validator: checkRePassWord, message: '两次输入的密码不一致' }, ], }
|
1
| { validator: proxy.Vertify.email, message: '请输入正确的邮箱' },
|
即传入validator作为校验规则
Pinia状态管理
该项目中我们用pinia实现动态管理
基础使用
在src下创建store文件夹 写index.js
这里以登录界面的loginUserInfo为例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import { defineStore } from 'pinia'
export const useAllDataStore = defineStore('allData', { state: () => ({ loginUserInfo: null, }),
getters: { getLoginUserInfo: (state) => state.loginUserInfo, },
actions: { updateLoginUserInfo(value) { this.loginUserInfo = value }, }, })
|
这里我们还是使用的选项式api的写法 逻辑相对来说会更清晰一点
对于一个状态 我们需要定义的基础部分就是这三样 即定义状态 定义get方法 定义更新方法
需要注意的是vuex中的写法是我们需要传入state然后调用state.loginUserInfo 但在pinia中我们可以直接用this来访问到state getters 和其他的actions 所以这里注意这个写法
然后我们在main.js中注册一下全局的pinia
1 2 3 4
| import { createPinia } from 'pinia'
const pinia = createPinia() app.use(pinia)
|
想从store拿到数据的时候 这里我们使用的是每次都引入一下
1 2
| import { useAllDataStore } from '@/store/index' const store = userAllDataStore()
|
如果我们想全局使用这个store,也可以直接在index里将这个store导出来,然后在main.js中引用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| import { defineStore } from 'pinia'
const useAllDataStore = defineStore('allData', { state: () => ({ loginUserInfo: null, }),
getters: { getLoginUserInfo: (state) => state.loginUserInfo, },
actions: { updateLoginUserInfo(value) { this.loginUserInfo = value }, }, })
const store = useAllDataStore()
export default store
|
我们直接在index.js中
1
| import store form "./store"
|
获取状态
在这个项目中,为了使用的一致,我们都使用的watch监听,但其实很多时候直接拿pinia中的状态也是没问题的,我们在下面做一个简单的区分。
直接使用 Pinia 状态数据与通过 watch 监听状态变化,核心区别在于触发时机和使用方式,具体可以从以下几个角度理解:
直接使用 Pinia 状态:依赖响应式自动更新
Pinia 的状态是响应式的(基于 Vue 的 reactive),因此在组件模板或 setup 中直接引用状态数据时,Vue 会自动追踪其依赖。当状态变化时,所有引用该状态的地方会自动更新(比如模板重新渲染、计算属性重新计算等)。
例如,如果你直接在模板中使用 store.loginUserInfo.userId:
1 2 3 4 5 6 7
| <template> <div>{{ store.loginUserInfo.userId }}</div> </template> <script setup> import { useStore } from './store' const store = useStore() </script>
|
当 loginUserInfo 变化时,模板会自动更新显示最新的 userId,无需手动写逻辑 —— 这是响应式的 “被动更新”。
使用 watch 监听:主动处理状态变化的副作用
watch 的核心作用是监听状态变化并执行额外逻辑(副作用),这些逻辑通常是 “非渲染相关” 的操作,比如:
- 调用接口(如你的
loadMessageList())
- 手动修改其他变量(如你的
userId.value = newVal.userId)
- 操作 DOM、记录日志、跳转路由等
例如你的代码中:
1 2 3 4 5 6 7
| watch(() => route.params.type, (newVal) => { if (newVal) { activeTabName.value = newVal loadMessageList() } }, { immediate: true })
|
这里的 loadMessageList 是 “副作用”(状态变化后需要主动执行的操作),无法通过 “直接引用状态” 自动触发,必须用 watch 监听变化后手动调用。
直接引用:当你只需要在模板或计算属性中展示 / 依赖状态的值,且不需要额外逻辑时(依赖响应式自动更新)。
watch 监听:当状态变化后,需要主动执行一段逻辑(如调用接口、修改其他变量、处理复杂业务)时。
总结:直接使用 Pinia 状态是 “被动依赖响应式更新”,watch 是 “主动监听变化并处理副作用”,二者互补,分别解决 “展示 / 依赖” 和 “逻辑执行” 的问题。
比如说 我们的监听只是设置值
1 2 3 4 5 6 7
| watch( () => state.activePBoardId, (newVal, oldVal) => { activePBoardId.value = newVal }, { immediate: true, deep: true }, )
|
在这种时候我们直接调用pinia中的值也是可以的 因为我们没有进行其他逻辑
后端向前端传递图片
| 维度 |
返回文件流(Blob/Stream) |
返回 Base64 字符串 |
| 本质 |
二进制数据流(字节序列),是文件的原始二进制形式。 |
二进制数据经过 Base64 编码后的字符串(由 64 个可打印字符组成)。 |
| 前端接收的内容 |
通常是 Blob 对象(二进制大对象)或 ReadableStream 流对象。 |
一个字符串,格式通常为 data:image/png;base64,xxxxxx(包含数据类型和编码标识)。 |
| 传输大小 |
原始二进制,体积较小(无额外编码开销)。 |
编码后体积会增大约 33%(Base64 编码的特性)。 |
| 处理方式 |
需要通过 URL.createObjectURL(blob) 生成临时 URL 供前端使用,或通过 FileReader 转为 Base64。 |
可直接作为 img 标签的 src 属性值,无需额外转换。 |
| 内存占用 |
内存友好,Blob 是二进制数据的引用,临时 URL 不占用额外内存(需手动释放)。 |
字符串会直接占用内存,大图片的 Base64 字符串可能导致内存飙升。 |
后端向前端传递图片时有两种常见的方式
- 优先用文件流:大图片、对性能 / 内存敏感的场景,或需要支持文件下载功能时(
Blob 可直接通过 a 标签下载),或者需要上传的场景。
- 优先用 Base64:小图片、需要减少 HTTP 请求(如嵌入到单页应用的静态资源),或简单场景下快速集成时。
前端处理的核心差异在于:文件流需要转临时 URL,而 Base64 可直接使用,但需注意后者的体积和内存开销。
而我们实际上需要给后端传递什么数据,是要根据后端接口文档中判断的(比如标记需要传递文件流)
更具体的是,我们使用文件流的时候一般是这样
1
| /api/file/getImage/ + data.imgPath
|
这里的imgPath就是返回的文件流
前面的地址是后端接口文档中给我们的
在具体的传递参数过程中我们一定要注意这两种形式的区分
发送验证码
添加api
根据接口文档我们添加一下发送验证码的api 作为请求参数的url
1 2 3 4
| const api = { checkCode: "/api/checkCode", sendMailCode: "/sendEmailCode", }
|
调用request
上面我们封装好了http请求 现在就可以通过调用请求来实现网络上发送验证码的功能了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| const sendEmailCode = () => { formDataSendMailCodeRef.value.validate( async (valid) => { if (!valid) { return; } const params = Object.assign({}, formDataSendMailCode.value); params.type = 0;
let result = await proxy.Request({ url: api.sendMailCode, params: params, errorCallback: () => { changeCheckCode(1); } })
if (result.status == "error") { return; } else { proxy.Message.success("验证码发送成功,请前往邮箱查收") dialogConfigSendMailCode.show = false; }
}); }
|
最开始的validate是校验表单是否填写完全正确 否则的话就不能进行发送验证码的操作
1 2
| const params = Object.assign({}, formDataSendMailCode.value); params.type = 0;
|
我们把填写的表单数据给copy到params中 即拿到了我们之前填写的数据
由于我们没有单独写邮箱验证码弹框的逻辑在这里 所以补充一下 在我们打开弹框后进行了如下操作 从而拿到了需要的email
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| const showSendEmainCode = () => { formDataRef.value.validateField("email", (msg) => { if (!msg) { return; } dialogConfigSendMailCode.show = true;
nextTick(() => { changeCheckCode(1); formDataSendMailCodeRef.value.resetFields(); formDataSendMailCode.value = { email:formData.value.email, } })
}); }
|
我们这里单独设定了params的type 这里是我们看接口文档得到的
接口地址 /sendEmailCode
请求参数
| 参数名 |
说明 |
是否必填 |
| email |
注册邮箱 |
是 |
| checkCode |
图片验证码 |
是 |
| type |
类型 0:注册 1:找回密码 |
是 |
如果我们传入的参数跟接口文档不同的话就会参数错误 所以应该靠接口文档来写
1 2 3 4 5 6 7 8
| let result = await proxy.Request({ url: api.sendMailCode, params: params, errorCallback: () => { changeCheckCode(1); } })
|
最关键的一步 调用request函数 接受返回的参数
(注意这里的异步async是放在前面的校验validate后的)
我们传入之前封装的时候需要的东西 基础的url params 和错误回调函数
这里传入错误回调函数也是一个必要的设计 因为在业务场景中 当出现错误 我们希望会更新一次验证码 所以这里的回调函数就是更新验证码changecheckcode
处理结果
1 2 3 4 5 6 7
| if (result.status == "error") { return; } else { proxy.Message.success("验证码发送成功,请前往邮箱查收") dialogConfigSendMailCode.show = false; }
|
这里我跟课程中用的不一样 直接用了status判断 (关于result的内容之前有图片 里面显示了)
如果错误的话 我们直接返回即可
如果验证码发送成功 那么我们给一个正确提示 并且关闭掉弹窗
这样就大致实现了整个发送验证码的流程 在这个流程中 我们主要理解了 axios请求 前后端联系 以及业务场景处理请求这几者之间的关系
实现头像效果
当用户登录成功后 我们希望能够在右上角用户信息处显示出用户头像 并且点击用户头像能够跳转到其用户中心 这就涉及到通过用户id请求头像数据显示的问题
由于头像实际上在很多场景中都会出现 所以我们把它单独拆分为一个组件来写 在components下创建avatar.vue
头像组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| <template> <div class="avatar" :style="{ width: width + 'px', height: width + 'px', 'border-radius': width / 2 + 'px' }" > <el-image v-if="userId" :style="{ width: width + 'px', height: width + 'px', 'border-radius': width / 2 + 'px' }" :src="proxy.globalInfo.avatarUrl + userId" fit="scale-down" loading="lazy" @click="goToUcenter()" ></el-image> </div> </template>
|
在这里我们直接使用的element中的el-image来存放头像图片 原因是可以直接使用诸如懒加载之类功能
这里我们的处理是将头像的样式暴露给父组件使用,以实现高复用性 具体的逻辑我们放在下面的脚本部分
1
| :src="proxy.globalInfo.avatarUrl + userId"
|
前面说过 我们希望的头像显示效果是根据用户信息去请求其头像然后显示 故而这里使用动态src发送网络请求来拿到
接口文档中获取头像的接口如下
1
| /api/file/getAvatar/{userId}
|
我们将这个api注册到全局中 这样我们可以直接调用proxy方法来拼接userId以实现头像的获取
在main.js中
1 2 3
| app.config.globalProperties.globalInfo = { avatarUrl: '/api/file/getAvatar/', }
|
在这里自己遇到的问题是 同样是向后端发送网络请求拿到数据 但是为什么头像获取不是像获取验证码等操作一样用封装request来请求 而是这样直接在全局中注册api使用 其实主要是其的数据特性和使用场景不同
- 数据性质与更新频率
- 头像 URL 通常是静态或半静态数据(用户不频繁更换头像),且一般会在用户登录后就确定下来,存放在全局状态(如你提到的
proxy.globalInfo)中可以避免重复请求。
- 而验证码、列表数据等属于动态数据,每次使用都需要最新结果(验证码有时效性,列表可能实时更新),因此需要每次通过
request调用接口获取。
- 使用方式的差异
- 头像通常通过
<img :src="url">直接加载,这本质上是浏览器发起的资源请求(GET 方式),无需额外的业务逻辑(如权限校验、错误处理可通过 img 的 error 事件处理)。
- 其他接口请求(如登录、提交表单)需要业务逻辑介入(如携带 token、处理返回码、弹窗提示错误等),因此需要通过封装的
request统一管理这些逻辑。
- 性能与代码简洁性
- 全局存储头像 URL 可以减少重复请求,尤其在多个组件都需要显示头像时(如导航栏、个人中心),直接从全局获取比每次调用接口更高效。
- 若用
request获取头像 URL,反而会增加不必要的代码(如定义接口函数、处理响应),且浏览器原生的图片加载机制已经足够完善。
脚本部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| <script setup> import { ref, reactive, getCurrentInstance, onMounted } from "vue" import { ElMessage } from "element-plus" import { useRouter,useRoute } from "vue-router" const { proxy } = getCurrentInstance(); const router = useRouter(); const route = useRoute();
const props = defineProps({ userId: { type: String, }, width: { type: Number, default: 60 }, addLink: { type: Boolean, default: true },
})
const goToUcenter = () => { if (props.addLink) { router.push("/user/" + proxy.userId); } }
|
我们将userID暴露出去 用于实现父组件对头像的匹配 (具体的操作需要我们实现完登录功能后才可以实现)
width用于我们根据不同的业务场景控制头像显示的大小
addlink是一个布尔值 其作用是判断是否实现“点击头像跳转用户个人中心”的功能 也是为了适用于各种业务场景
1 2 3 4 5
| const goToUcenter = () => { if (props.addLink) { router.push("/user/" + proxy.userId); } }
|
这就是上面所说的跳转个人中心的功能 如果需要实现跳转功能 我们就直接将路由转到个人中心中 (当然关于用户中心的具体设计要到后面才会完成)
在登录界面的header中 我们要用到头像 并且这个头像下需要有下拉框 以实现后续个人中心的跳转与退出功能 所以这里使用了element组件中的下拉框 下拉框部分是直接使用的官方代码 没有做什么改变 这里就不赘述了
1 2 3 4 5 6 7 8 9 10 11
| <div class="user-info"> <el-dropdown> <avatar :userId="userInfo.userId" :width="50" :addLink="true"></avatar> <template #dropdown> <el-dropdown-menu> <el-dropdown-item>我的主页</el-dropdown-item> <el-dropdown-item>退出</el-dropdown-item> </el-dropdown-menu> </template> </el-dropdown> </div>
|
这里只需要注意一下父组件中是如何使用子组件头像的即可了
实现注册与找回密码功能
在前面我们已经把基本的ui给写完了 现在我们需要处理的主要是当点击注册or找回密码时我们应该怎么去提交这个表单数据
注意 注册和找回密码 包括后面的登录等功能 都是需要我们api支持去发请求的
封装api
1 2 3 4 5 6 7 8
| const api = { checkCode: "/api/checkCode", sendMailCode: "/sendEmailCode", register: "/register", login: "/login", resetPwd: "/resetPwd", }
|
处理表单提交
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| const doSubmit = () => { formDataRef.value.validate(async (valid) => { if(!valid) { return; } let params = Object.assign({}, formData.value);
if (opType.value === 0 || opType.value === 2) { params.password = params.registerPassword }
let url = null; if(opType.value === 0) url = api.register; else if(opType.value === 1) url = api.login; else if(opType.value === 2) { url = api.resetPwd; }
let result = await proxy.Request({ url: url, params: params, errorCallback: () => { changeCheckCode(0); } })
if (!result) return;
if (opType.value === 0) { proxy.Message.success("注册成功,请前往登录"); showPanel(1); } else if (opType.value === 1) { } } else if (opType.value === 2) { proxy.Message.success("密码重置成功,请前往登录"); proxy.VueCookies.remove("loginInfo"); showPanel(1); }
}); }
|
这里我们暂时省略了登录的相关逻辑 其实注册和修改密码都是相对简单的 我们只需要更改一下参数数据修改封装一下api即可
1 2 3 4
| if (opType.value === 0 || opType.value === 2) { params.password = params.registerPassword }
|
在之前写界面的时候我们为了区分几个密码写了不同的密码名 而我们发请求的时候参数要求为password 所以我们要把表单数据里的password更新一下
当然这里我们也可以delete掉原数据 但不管它是否存在 后端没有对应的字段 故而是不会造成影响的
发送请求
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| let url = null; if(opType.value === 0) url = api.register; else if(opType.value === 1) url = api.login; else if(opType.value === 2) { url = api.resetPwd; }
let result = await proxy.Request({ url: url, params: params, errorCallback: () => { changeCheckCode(0); } })
if (!result) return; if (opType.value === 0) { proxy.Message.success("注册成功,请前往登录"); showPanel(1); } else if (opType.value === 1) { } } else if (opType.value === 2) { proxy.Message.success("密码重置成功,请前往登录"); showPanel(1); }
|
由于这里表单提交是针对登录注册重置密码三个流程的 故而我们这里需要对optype做判断 把url对应到其api上
下面的request请求就是老套路了 我们期望在错误后更新一次验证码
请求成功的情况下 我们给一个成功提示 然后重新将弹窗更新成登录界面即可
当我们重置完密码之后 希望跳转到登录界面时能让之前的用户信息(其实更准确应该是把密码移除掉 如果想实现这个效果只需要将loginuserinfo定义成含有密码和账号的对象,然后根据后端字段赋值即可,这里就不再多写了)消失掉重新设置 故而这里我们在重置密码成功后也把原cookies移除掉
1 2 3 4 5
| else if (opType.value === 2) { proxy.Message.success("密码重置成功,请前往登录"); proxy.VueCookies.remove("loginInfo"); showPanel(1); }
|
实现登录功能
登录功能是一个相对复杂的功能 我们需要实现:登录后储存用户信息 登录后根据用户信息更改界面 登录的表单提交
表单提交
这里我们只写了关于登录的表单提交逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| const doSubmit = () => { formDataRef.value.validate(async (valid) => { if(!valid) { return; } let params = Object.assign({}, formData.value);
if (opType.value === 1) { let cookieLoginInfo = proxy.VueCookies.get("loginInfo"); let cookiePassword = cookieLoginInfo ? cookieLoginInfo.password : null; if (params.password !== cookiePassword) { params.password = md5(params.password); } }
let url = null;
if(opType.value === 1) url = api.login;
let result = await proxy.Request({ url: url, params: params, errorCallback: () => { changeCheckCode(0); } })
if (!result) return;
if (opType.value === 1) { if (params.rememberMe) { const loginInfo = { email: params.email, password: params.password, rememberMe: params.rememberMe } proxy.VueCookies.set("loginInfo", loginInfo, "7d"); } else proxy.VueCookies.remove("loginInfo"); dialogConfig.show = false; proxy.Message.success("登录成功"); store.updateLoginUserInfo(result.data); }
}); }
|
1 2 3 4 5 6 7
| if (opType.value === 1) { let cookieLoginInfo = proxy.VueCookies.get("loginInfo"); let cookiePassword = cookieLoginInfo ? cookieLoginInfo.password : null; if (params.password !== cookiePassword) { params.password = md5(params.password); } }
|
执行登录操作的时候 从本地储存中拿到已经保存过的登录数据(针对之前已经登录过并且记住过账号密码的情况)
密码加密
密码加密逻辑:
- 如果用户本次输入的密码(
params.password)和 Cookie 中保存的密码不一致
- 就对本次输入的密码进行 MD5 加密处理
这样做的原因是:
- Cookie 中保存的密码可能已经是加密后的版本
- 避免对已经加密过的密码再次进行加密(如果用户使用记住的密码登录)
- 确保提交到后端的密码始终是加密状态,提高安全性
注意我们这里只做向后端传递加密密码的工作 具体的密码校验是后端进行的
发送请求
与注册和登录功能一样 都是我们先把api对应好然后发请求即可
1 2 3 4 5 6 7 8 9 10 11
| let url = null; if(opType.value === 1) url = api.login; let result = await proxy.Request({ url: url, params: params, errorCallback: () => { changeCheckCode(0); } })
if (!result) return;
|
数据储存
需要特别处理的是请求成功后的处理 我们需要在登录成功后把数据放进本地储存中 在这里我们使用的是Vuecookies
VueCookies 主要用来在 Vue.js 项目中便捷地操作浏览器 Cookie,解决原生 JavaScript 处理 Cookie 时的繁琐问题,用于储存数据 并且可以快速拿到数据用于跨组件通信
在main.js中
1 2 3 4
| import VueCookies from 'vue-cookies'
app.config.globalProperties.VueCookies = VueCookies
|
在请求成功后的逻辑中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| if (opType.value === 1) { if (params.rememberMe) { const loginInfo = { email: params.email, password: params.password, rememberMe: params.rememberMe } proxy.VueCookies.set("loginInfo", loginInfo, "7d"); } else { proxy.VueCookies.remove("loginInfo"); } dialogConfig.show = false; proxy.Message.success("登录成功"); store.updateLoginUserInfo(result.data); }
|
这里我们定义一个loginInfo用于储存用户信息
如果用户选择了记住我 就放进本地储存 反之我们就消除掉
这里补充一下 记住我的储存做好了 具体的实现是怎样的呢?
在重置表单的时候 如果用户执行登录操作 那么我们从储存里把数据拿出来放进表单就实现自动填充了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| /重置表单 const resetForm = () => { dialogConfig.show = true; if (opType.value === 0) dialogConfig.title = "注册"; else if (opType.value === 1) dialogConfig.title = "登录"; else if (opType.value === 2) dialogConfig.title = "找回密码";
nextTick(() => { changeCheckCode(0); formDataRef.value.resetFields(); formData.value = {};
if (opType.value === 1) { const cookieLoginInfo = proxy.VueCookies.get("loginInfo"); if (cookieLoginInfo) { formData.value = cookieLoginInfo; } } }); }
|
动态显示
之前说到 在主页界面 未登录的时候需要显示登录注册按钮 登录后需要显示 用户头像界面 这需要我们记录登录后的状态并且这个状态
pinia动态管理
这里我们用pinia实现动态管理
在src下创建store文件夹 写index.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import { defineStore } from 'pinia'
export const useAllDataStore = defineStore('allData', { state: () => ({ loginUserInfo: null, }),
getters: { getLoginUserInfo: (state) => state.loginUserInfo, },
actions: { updateLoginUserInfo(value) { this.loginUserInfo = value }, }, })
|
这里我们还是使用的选项式api的写法 逻辑相对来说会更清晰一点
对于一个状态 我们需要定义的基础部分就是这三样 即定义状态 定义get方法 定义更新方法
需要注意的是vuex中的写法是我们需要传入state然后调用state.loginUserInfo 但在pinia中我们可以直接用this来访问到state getters 和其他的actions 所以这里注意这个写法
然后我们在main.js中注册一下全局的pinia
1 2 3 4
| import { createPinia } from 'pinia'
const pinia = createPinia() app.use(pinia)
|
现在我们有了状态管理之后 对于之前提交表单的部分还要做一些细节处理
当请求成功的时候 我们需要把返回的结果更新到store中的用户信息中 便于后面我们监听登录用户信息
1 2 3
| dialogConfig.show = false; proxy.Message.success("登录成功"); store.updateLoginUserInfo(result.data);
|
监听登录用户信息
注意这里我们需要向后端请求用户信息 因为用户信息作为加密数据我们必须通过发请求收到 而不是像之前表单的时候直接从cookies中拿
1 2 3 4
| const api = { getUserInfo: '/getUserInfo', }
|
1 2 3 4 5 6 7 8 9 10
| const getUserInfo = async () => { let res = await proxy.Request({ url: api.getUserInfo, })
if (!res) return
state.updateLoginUserInfo(res) }
|
封装后请求用户信息 这里我们请求到用户信息之后也需要更新到state的userinfo里 目的是为了在header上对应显示用户信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| onMounted(() => { initScroll() getUserInfo() })
const userInfo = ref({})
watch( () => state.loginUserInfo, (newVal, oldVal) => { if (newVal != undefined && newVal != null) { userInfo.value = newVal } else { userInfo.value = {} } }, { immediate: true, deep: true } )
|
在每次刷新之后 都要重新获取一下用户信息
由于上面我们每次都会把请求到的用户信息更新到state中 故而我们只需要监听其变化即可
页面结构
实现动态管理我们直接用v-if判断一下是否存在用户信息即可
1 2 3 4 5 6 7 8 9 10 11 12
| <template v-if="userInfo.userId"> ... </template>
<div v-if="!userInfo.userId"> <el-button-group :style="{ 'margin-left': '5px' }"> <el-button type="primary" plain @click="loginAndRegister(1)">登录</el-button> <el-button type="primary" plain @click="loginAndRegister(0)">注册</el-button> </el-button-group> </div>
|
登出
1 2 3 4 5 6 7 8 9 10
| const logout = () => { proxy.Confirm('确定要退出吗?', async () => { let res = await proxy.Request({ url: api.logout, }) if (!res) return state.updateLoginUserInfo(null) }) }
|
登出也比较简单 调用一下接口然后重置一下state中的userInfo数据即可
实现文章列表
获取文章信息
还是发送网络请求来获取文章信息
需要注意的是文章的信息很多 我们需要按需加载 这个需求就是我们传入的params参数来告诉后端 API 如何筛选和排序文章数据。
而文章内容的部分我们直接放在articleList中即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| const articleListInfo = ref({}) const loadArticle = async () => { loading.value = true let params = { pageNo: articleListInfo.value.pageNo, orderType: orderType.value, pBoardId: pBoardId.value, boardId: boardId.value, }
let res = await proxy.Request({ url: api.loadArticle, params: params, showLoading: false, })
console.log("我是") console.log(res)
loading.value = false
if (!res) return articleListInfo.value = res.data }
onMounted(() => { loadArticle() })
|
params中的内容我们都会在后面的内容中用到 这里就不写了
文章卡片
每一条文章都是基本样式不同 而内容不同于是我们当然需要封装这样一个文章卡片组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| <template> <div class="article-item"> <div class="article-item-inner"> <div class="article-body"> <div class="user-info"> <Avatar :userId="data.userId" :width="30" :addLink="false" /> <router-link :to="'/user/' + data.userId" class="link-info">{{ data.nickName }}</router-link> <el-divider direction="vertical"></el-divider> <div class="post-time">{{ data.postTime }}</div> <div class="address"> - {{ data.userIpAddress }}</div> <el-divider direction="vertical"></el-divider> <router-link :to="'/'" class="link-info">{{ data.pBoardName }}</router-link> <template v-if="data.boardName"> <span> / </span> <router-link :to="'/'" class="link-info">{{ data.boardName }}</router-link> </template> </div> <router-link :to="'/'" class="title">{{ data.title }}</router-link> <div class="summary">{{ data.summary }}</div> <div class="article-info"> <span class="iconfont icon-eye-solid"> {{ data.readCount == 0 ? '阅读' : data.readCount }} </span> <span class="iconfont icon-good"> {{ data.goodCount == 0 ? '点赞' : data.goodCount }} </span> <span class="iconfont icon-comment"> {{ data.commentCount == 0 ? '评论' : data.commentCount }} </span> </div> </div> <Cover :cover="data.cover" :width="100"></Cover> </div>
</div> </template>
<script setup> import Avatar from '@/components/Avatar.vue' import { ref, reactive, getCurrentInstance, onMounted } from 'vue' import { ElMessage } from 'element-plus' import { useRouter, useRoute } from 'vue-router' const { proxy } = getCurrentInstance() const router = useRouter() const route = useRoute()
const props = defineProps({ data: { type: Object, }, }) </script>
|
这里就是简单的根据ui写出样式即可 可能唯一需要注意的地方是 这里我们接受的数据也是作为参数暴露出去在父组件中拿到的
点赞阅读评论的图表还是直接使用的iconfont
文章封面
需要实现的功能:
1.有封面的文章将封面显示在文章右侧,没有封面的文章显示默认封面
2.控制封面的统一样式
3.封装为一个组件便于多场景复用
跟之前的头像组件很类似 我们都是直接将地址注册在main.js中的(也是静态资源)
1 2 3 4 5
| app.config.globalProperties.globalInfo = { bodyWidth: 1300, avatarUrl: '/api/file/getAvatar/', imageUrl: '/api/file/getImage/', }
|
封装Cover组件 能够由其父组件传入数据和大小样式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| <template> <div class="Cover" :style="{ width: width + 'px', height: width + 'px' }"> <el-image :style="{ wdith: width + 'px', height: width + 'px' }" fit="scale-down" :src="imgUrl"> <template #error> <div class="image-slot"> <img src="@/assets/file/images/202301/default_avatar.jpg" :style="{ width: width + 'px', height: width + 'px' }" /> </div> </template> </el-image> </div> </template>3
<script setup> import { ref, reactive, getCurrentInstance, onMounted, computed } from 'vue' import { ElMessage } from 'element-plus' import { useRouter, useRoute } from 'vue-router' const { proxy } = getCurrentInstance() const router = useRouter() const route = useRoute()
const props = defineProps({ cover: { type: String, default: '', }, width: { type: Number, default: 60, }, })
const imgUrl = computed(() => { if (props.cover) { return proxy.globalInfo.imageUrl + props.cover } return '' }) </script>
<style lang="scss"> .Cover { border-radius: 5px; background: #ddd; overflow: hidden; } </style>
|
下面我们来具体解释一下逻辑部分
1.如果文章自己有封面相关数据 我们就显示文章封面
1 2 3 4 5 6 7 8 9
| <el-image :style="{ wdith: width + 'px', height: width + 'px' }" fit="scale-down" :src="imgUrl"> ... const imgUrl = computed(() => { if (props.cover) { // 这里是父组件传入的数据部分 return proxy.globalInfo.imageUrl + props.cover } return '' })
|
2.当文章没有封面时 返回空 会进入错误插槽
注意 错误插槽是element中el-image的错误处理机制
当 el-image 加载图片资源(src 指向的地址)时,若出现加载失败(如网络错误、资源不存在、路径错误等),组件会触发内部的错误监听逻辑,自动渲染 #error 插槽的内容,从而实现 “图片加载失败时显示默认图” 的效果。
错误插槽即为
1 2 3 4 5 6 7 8
| <template #error> <div class="image-slot"> <img src="@/assets/file/images/202301/default_avatar.jpg" :style="{ width: width + 'px', height: width + 'px' }" /> </div> </template>
|
在这里面我们直接使用了本地的img 即我们自己放到src中的默认图片 样式与普通封面一样
最后 我们需要在父组件中调用一下
1
| <router-link :to="`/post/${data.articleId}`"><Cover :cover="data.cover" :width="100"></Cover></router-link>
|
在图片上面添加了一层路由跳转以直接跳转到文章详情页
这样我们就大致实现了文章封面的功能
分页功能
实际上 这里的分页类似于我们封装一个容器插槽来存放文章内容 这样能够实现整页文章的翻页效果 所以其实文章列表的渲染是层层封装组件实现的
大致的逻辑是
文章卡片组件->插入文章列表组件->插入翻页组件插槽
在组件中创建DataList组件来实现分页功能的基础组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| <template> <div v-for="item in dataSource.list" v-else> <slot :data="item"></slot> </div> <div class="pagination"> <el-pagination v-if="dataSource.pageTotal > 1" background :total="dataSource.totalCount" :current-page.sync="dataSource.pageNo" layout="prev, pager, next" @current-change="handlePageNoChange" style="text-align: right" ></el-pagination> </div> </template>
<script setup> const props = defineProps({ dataSource: { type: Object, }, })
</script>
<style lang="scss" scoped> .pagination { margin: 10px 0px 10px 10px; } </style>
|
显然template部分 上面是渲染数据的部分 下面就是分页栏 这里直接使用的elment中的分页
在使用的时候 我们只需要在文章列表组件里引入该组件直接调用即可
1 2 3 4 5 6 7 8 9
| <div class="ariticle-list"> <DataList :dataSource="articleListInfo" > <template #default="{ data }"> <ArticleListItem :data="data"></ArticleListItem> </template> </DataList> </div>
|
这里传入的就是我们请求到的文章列表内容
排序功能
这里需要解释一下我们是怎样实现排序功能的
实际上 排序的具体逻辑都是后端进行实现的 后端在对文章排序后给它们打上了对应的标记
比如说 按照最新发布时间排序的文章可能就给上了orderType的标签
那么对于前端来说 我们想要点击拿到某个排序下的文章列表的时候 只需要向后端传递我们需要的orderType即可
所以具体实现如下
页面结构 在这里通过对orderType的判断添加一下高亮
1 2 3 4 5 6 7 8 9 10 11 12 13
| <div class="top-tab"> <div :class="['tab', orderType == 0 ? 'active' : '']" @click="changeOrderType(0)"> 热榜 </div> <el-divider direction="vertical"></el-divider> <div :class="['tab', orderType == 1 ? 'active' : '']" @click="changeOrderType(1)"> 发布时间 </div> <el-divider direction="vertical"></el-divider> <div :class="['tab', orderType == 2 ? 'active' : '']" @click="changeOrderType(2)"> 最新 </div> </div>
|
点击事件 以更新(切换)文章列表
1 2 3 4 5
| const orderType = ref(0) const changeOrderType = (type) => { orderType.value = type loadArticle() }
|
每次点击之后 更换orderType 再用这个新的type向后端发送请求以拿到我们期望排序下的文章数据
板块导航
由于之前没有写header导航的部分 为了方便理解在这里补上获取board信息的部分
1 2 3 4 5 6 7 8 9 10 11 12 13
| const boardList = ref([]) const loadBoard = async () => { let res = await proxy.Request({ url: api.loadBoard, }) if (!res) return boardList.value = res.data state.saveBoardList(res.data) }
loadBoard()
|
板块导航实现的内容是通过点击header上的导航跳转到对应的部分 在这里我们需要实现的功能大致可以认为是以下两个
1.点击导航部分后跳转到对应界面
2.获取到点击部分的导航信息 从而重新渲染页面数据
router部分
我们需要新定义两个路由分别是一级板块和二级板块的对应页面内容 我们直接放在登录的children下面即可 注意我们这里只是改变了组件的文章渲染并没有跳转其他组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| routes: [ { path: '/', name: 'layout', component: () => import('@/views/Layout.vue'), children: [ { path: '/', name: '所有文章', component: () => import('@/views/forum/ArticleList.vue'), }, { path: '/forum/:pBoardId', name: '一级板块', component: () => import('@/views/forum/ArticleList.vue'), }, { path: '/forum/:pBoardId/:boardId/', name: '二级板块', component: () => import('@/views/forum/ArticleList.vue'), }, ], }, ],
|
现在我们实现一下页面的跳转 直接用routepush即可
1 2 3 4 5 6 7 8 9
| const boardClickHandler = (board) => { router.push(`/forum/${board.boardId}`) }
const subBoardClickHandler = (subBoard) => { router.push(`/forum/${subBoard.pBoardId}/${subBoard.boardId}`) }
|
但现在我们点击之后其实是没有内容显示的 原因是目前我们还需要在articleList中渲染对应的内容
现在的问题在于我们需要怎样拿到点击的信息 这里我们采用的是监听route参数 这样我们可以拿到目前跳转到什么地方 由于上面写的跳转是直接跳转到id 所以我们可以直接拿到这个id来 从而渲染文章列表
(在写这个的时候想到 其实这里我们把点击的板块的id存进pinia 同样进行监听也是可以实现功能 并且我们不用在监听中执行更新id逻辑 不过其实大概思路都差不多 这里就不写了吧)
1 2 3 4 5 6 7 8 9 10
| watch( () => route.params, (newVal, oldVal) => { pBoardId.value = newVal.pBoardId || 0 boardId.value = newVal.boardId || 0 loadArticle() }, { immediate: true, deep: true }, )
|
这里我们拿到点击的板块内容后 作为参数传入到网络请求 跟之前排序的逻辑差不多 都是我们给了后端一个要求 让它筛选出对应的文章
当我们监听到变化之后会再次调用一下loadArticle 就是为了根据新的筛选条件请求到对应的文章
这里有一个小小的bug 即当我们点开页面的时候默认跳转的“全部”板块没有文章显示 是因为这个全部板块是我们额外添加的 即其没有board信息 那么当原有参数传递给后端时显然拿不到任何的数据 故而我们在这里做一个小特判 让默认情况下能够拿到所有的文章
(全部板块给一个span添加一个点击函数即可 这里不再写)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| const loadArticle = async () => { loading.value = true let params = { pageNo: articleListInfo.value.pageNo, orderType: orderType.value || 0, pBoardId: pBoardId.value || 0, boardId: boardId.value || 0, }
if (pBoardId.value == 0 && boardId.value == 0) { delete params.pBoardId delete params.boardId }
let res = await proxy.Request({ url: api.loadArticle, params: params, showLoading: false, })
loading.value = false
if (!res) return articleListInfo.value = res.data }
|
点击主页的时候由于没有后端数据 所以传递的id为默认值0 这时我们把板块参数delete掉 这样就会拿到后端的所有文章显示在页面上
这里在添加一下“全部”选中高亮的效果
1
| <span :class="['menu-item',activePBoardId== 0 ?'active': '']" @click="backToHome">首页</span>
|
只需要判断一下activePBoardId是否为0即可 (因为点击全部是我们拿不到这个id 为默认值0)
二级板块显示

如图 我们希望当点击了板块导航后能够显示出来当前二级板块 即我们需要把导航处点击的信息拿出来用到这里 使用pinia状态管理跨组件通信
在pinia中储存一下板块信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| state: () => ({ boardList: [], }), getters: { getBoardList: (state) => state.boardList, getSubBoardList: (state) => (boardId) => { let board = state.boardList.find((item) => { return item.boardId == boardId }) return board ? board.children : [] }, }, actions: { saveBoardList(value) { this.boardList = value }, },
|
这里的思路是我们每次记录一下所有的板块 需要显示子版块信息的时候从中找到当前点击的板块 进一步找到其children
在获取板块的部分向pinia中储存当前板块信息
在layout组件中
1 2 3 4 5 6 7 8 9 10 11 12
| const boardList = ref([]) const loadBoard = async () => { let res = await proxy.Request({ url: api.loadBoard, }) if (!res) return boardList.value = res.data state.saveBoardList(res.data) }
loadBoard()
|
在articleList中获取对应的子板块
1 2 3 4 5 6 7
| const subBoardList = ref([])
const setSubBoard = () => { subBoardList.value = state.getSubBoardList(pBoardId.value) }
|
直接v-for显示二级板块信息即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <div class="sub-board" v-if="pBoardId"> <span :class="['board-item', boardId == 0 ? 'active' : '']"> <router-link :to="`/forum/${pBoardId}`">全部</router-link> </span> <span v-for="item in subBoardList" :class="['board-item', item.boardId == boardId ? 'active' : '']" > <router-link :to="`/forum/${item.pBoardId}/${item.boardId}`"> {{item.boardName}} </router-link> </span> </div>
|
这里需要注意的点是router-link的跳转问题 由于我们是通过一级板块拿到二级板块的信息的 所以是可以直接在subboardlist中的对象拿到其父板块信息的
我们这里判断高亮的条件则是当前子模块是否为我们拿到的那个被点击的子模块 很好理解
现在还有一个需要优化的点是 当我们刷新页面后 由于监听的只是路由变化 因此刷新后显示的二级信息就没有了 为了改掉这个bug 我们再监听一下板块的变化 在每次板块发生变化(刷新)之后我们也获取一下子板块信息
1 2 3 4 5 6 7 8
| watch( () => state.boardList, (newVal, oldVal) => { setSubBoard() }, { immediate: true, deep: true }, )
|
一二级板块高亮
二级板块高亮意思是当我们点击刚刚实现的显示的二级板块时 在header页导航下面的对应二级板块也会被高亮 这也需要我们记录当前点击的部分然后进行跨组件通信 故而这里我们还是使用的pinia状态管理
在pinia中储存信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| state: () => ({ activePBoardId: 0, activeBoardId: 0, }),
getters: { getActivePBoardId: (state) => state.activePBoardId, getActiveBopardId: (state) => state.activeBoardId, },
actions: { setActivePBoardId(value) { this.activePBoardId = value }, setActiveBoardId(value) { this.activeBoardId = value }, },
|
想要获取目前点击的部分 还是在监听路由参数中拿到 (注意这里 导航部分和二级板块显示部分都是可跳转的routerlink 所以监听路由参数可以同时拿到两边的变化)
在articleList中
1 2 3 4 5 6 7 8 9 10 11 12 13
| watch( () => route.params, (newVal, oldVal) => { pBoardId.value = newVal.pBoardId || 0 boardId.value = newVal.boardId || 0 setSubBoard() loadArticle() state.setActivePBoardId(pBoardId.value) state.setActiveBoardId(boardId.value) }, { immediate: true, deep: true }, )
|
注意这里有一个逻辑是 我们在这里拿到的是当前点击的id
但我们还需要在导航header里再监听一下这个activeid的状态变化从而更新自己的响应式数据(即用到显示高亮中的数据)
也就是 在Layout中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const activePBoardId = ref(0)
watch( () => state.activePBoardId, (newVal, oldVal) => { activePBoardId.value = newVal }, { immediate: true, deep: true }, )
const activeBoardId = ref(0) watch( () => state.activeBoardId, (newVal, oldVal) => { activeBoardId.value = newVal }, { immediate: true, deep: true }, )
|
我们监听一下一二级板块选中的信息 并且更新layout自身使用的响应式数据即两个id
现在只需要在header的板块显示中通过判断来更新active样式即可 (这里的样式我们都省略了)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <div class="menu-panel"> <span class="menu-item" @click="backToHome">首页</span> <template v-for="board in boardList"> <el-popover placement="bottom-start" :width="300" trigger="hover" v-if="board.children.length > 0"> <template #reference> <span :class="['menu-item',board.boardId == activePBoardId ? 'active' : '']" @click="boardClickHandler(board)">{{ board.boardName }}</span> </template> <div class="sub-board-list"> <span :class="['sub-board',subBoard.boardId == activeBoardId ? 'active' : '']" v-for="subBoard in board.children" @click="subBoardClickHandler(subBoard)" > {{ subBoard.boardName}}</span> </div> </el-popover> <span :class="['menu-item',board.boardId == activePBoardId ? 'active' : '']" v-else @click="boardClickHandler(board)">{{ board.boardName }}</span> </template> </div>
|
当然 其实这里header点击的信息也传递给了上面的二级板块显示 原因是点击函数实际上就是一个路由转换 也就会被articleList部分监听到 从而拿到选中的信息
那么现在不管是点击哪里的二级板块都能同时高亮并且跳转了
加载骨架与空内容提示
这里是两个细节优化的
1.在加载的过程中显示出骨架屏
2.在没有文章的内容时做一个提示
骨架屏这里使用的是element中的
我们需要添加一个判断是否正在加载的逻辑
在分页组件中 我们在上方添加一个骨架屏
1 2 3
| <div class="skeleton" v-if="loading"> <el-skeleton :row="2" animated></el-skeleton> </div>
|
这里我们将loading作为参数暴露给父组件 因为请求是在父组件中发出的
1 2 3 4 5 6
| const props = defineProps({ loading: { type: Boolean, default: false, }, })
|
现在我们在请求内添加一下对loading的赋值 即 开始发送请求的时候为true 返回正常数据后设为false
1 2 3 4 5 6 7 8 9 10 11 12
| const loadArticle = async () => { loading.value = true ... let res = await proxy.Request({ url: api.loadArticle, params: params, showLoading: false, }) loading.value = false if (!res) return articleListInfo.value = res.data }
|
这也就是我们在DataList中传入的loading的作用
空内容提示是当当前页面没有内容的时候我们希望有一个图标(或者说提示语)具体效果如下

使用的是iconfont图标
由于我们可能会在很多不同的地方使用提示语 所以我们希望它是高复用的 这里同样进行了两层的封装
首先是NoData组件 可以在各个场景使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| <template> <div class="no-data"> <div class="iconfont icon-empty"></div> <div class="msg">{{ msg }}</div> </div> </template>
<script setup>
const props = defineProps({ msg: { type: String, default: '暂无数据', }, })
</script>
<style lang="scss" scoped>
.no-data{ text-align: center; padding: 10px 0px; .iconfont{ font-size: 50px; color: #ccc; } .msg{ margin-top: 10px; color: #999; font-size: 14px; } }
</style>
|
我们把msg信息作为参数暴露给父组件便于灵活处理
这里有一个小点是我们在分页界面中再将msg信息暴露了一次 让它能在最终使用的场景中被灵活赋值
即在dataList中
1 2 3 4 5 6 7 8 9 10 11
| <div v-if="!loading && dataSource.list != null && dataSource.list.length == 0"> <NoData :msg="noDataMsg"></NoData> </div>
const props = defineProps({ noDataMsg: { type: String, default: '空空如也', }, })
|
我们在v-if中添加了三个判断 分别是
1.目前已经加载完毕(防止因为未加载成功出现无内容提示)
2.获取数据正常 (即list不为null)
3.list为空 即长度为0无数据
并且 对于子组件传递的msg 我们再次向外面传递 到业务场景中传入
这也是在articleList中写DataList的时候传入noMsg的原因
实现文章详情
这里我们实现点击文章后的详情页 包括如下载附件等没有接触过的知识点
渲染文章内容
我们这里不写具体的请求部分了 在之前已经写过很多次 唯一需要强调的一点是 后端给我们返回的文章详情是带有html标签的富文本 而当我们使用插值语法显示内容的时候只会显示出纯文本而不是html解析后的内容
故而我们用v-html来显示富文本信息
1
| <div class="detail" id="detail" v-html="articleInfo.content"></div>
|
左侧快捷操作
这里我们需要实现的是

这样一个快捷操作 包括了点赞 回复 与 附件 回复与附件的快捷操作都是跳转到其板块位置 而点赞是直接执行点赞逻辑
样式自适应
我们希望在各个界面显示的时候快捷栏的位置都能够正确显示在文章的左侧相对位置 所以这里快捷栏的样式我们要通过计算得到
1
| const quickPanelLeft = (window.innerWidth - proxy.globalInfo.bodyWidth) / 2 - 90
|
具体使用的时候 我们直接在最外层div中
1
| <div class="quick-panel" :style="{ left: quickPanelLeft + 'px' }">
|
标签跳转
对于评论与附件快捷栏 都是定位到其板块位置
关于评论区与附件区的具体内容先不写 只写出其占位的部分
1 2 3 4 5
| <div class="attachment-panel" v-if="attachment" id="view-attachment">...</div>
<div class="comment-panel" id="view-comment">...</div>
|
快捷栏的部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <el-badge :value="articleInfo.commentCount" // 更新数量的逻辑会放在后面评论部分 type="info" :hidden="!articleInfo.commentCount > 0" @click="goToPosition('view-comment')" > <div class="quick-item"> <span class="iconfont icon-comment"></span> </div> </el-badge> <div class="quick-item" @click="goToPosition('view-attachment')"> <span class="iconfont icon-attachment"></span> </div>
|
这里我们是通过id找到跳转部分元素 然后用该元素的滚动方法进行跳转
1 2 3
| const goToPosition = (id) => { document.querySelector('#' + id).scrollIntoView({ behavior: 'smooth' }) }
|
这也就是为什么我们给每一个部分都单独设置了一个id 同时这个id还会再后面查找元素中用到
点赞
在写逻辑的时候想到的问题是 如果只是单纯实现一个点赞效果 其实纯前端也是可以实现的 (用pinia传一下数据即可) 但这里我们所有的点赞都调用了后端接口 其后其实有开发的更深考虑
1.数据持久化,避免刷新丢失 (因为纯前端只是改变了DOM元素 不会有用户的数据储存)
2.多端/多用户数据同步 (我们通过后端来分用户储存数据 既能避免无效点赞 也能够实现数据的同步)
1 2 3 4 5 6 7 8 9 10 11
| <el-badge :value="articleInfo.goodCount" type="info" :hidden="!articleInfo.goodCount > 0" @click="goToPosition('view-good')" > <div class="quick-item" @click="doLikeHandler"> <span :class="['iconfont icon-good', haveLike ? 'have-like' : '']"></span> </div> </el-badge>
|
我们需要实现的功能其实就两个
1.通过向后端发送请求 实现点赞 更新点赞数
2.根据是否已点赞更新样式
点击函数 doLikeHandler
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| const haveLike = ref(false)
const doLikeHandler = async () => { let res = await proxy.Request({ url: api.doLike, params: { articleId: articleInfo.value.articleId, }, }) if (!res) return
haveLike.value = !haveLike.value let goodCount = 1 if (!haveLike.value) { goodCount = -1 } articleInfo.value.goodCount = articleInfo.value.goodCount + goodCount }
|
有几个需要说明的点:
1.这里没有对未登录状态进行处理 原因是未登录时 请求会返回901即登录超时 而在我们之前封装的Request中接收901后就会弹出登录框使再次登录 故而这里不需要做单独的处理了 我们在这里放出request中的对应片段
1 2 3 4 5
| else if (responsedata.code == 901) { store.updateshowLogin(true); store.updateLoginUserInfo(null); return Promise.reject({showError:false,msg:"登录超时"}) }
|
2.这里我们定义haveLike来记录是否已经点赞 一方面以此来控制数量的加减 一方面来判断点赞前和点赞后的样式
我们点赞与取消都是按同一个按钮实现的 所以每次请求后我们应该直接给这个记录值取反 同时根据其值来控制点赞数的增加或减少
1 2 3 4 5 6
| haveLike.value = !haveLike.value let goodCount = 1 if (!haveLike.value) { goodCount = -1 } articleInfo.value.goodCount = articleInfo.value.goodCount + goodCount
|
同时我们用它来控制动态样式
1
| <span :class="['iconfont icon-good', haveLike ? 'have-like' : '']"></span>
|
附件
附件展示
需要实现的内容是
1.附件展示板块 2.附件下载功能
附件展示板块
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <div class="attachment-panel" v-if="attachment" id="view-attachment"> <div class="title">附件</div> <div class="attachment-info"> <span class="iconfont icon-zip item"></span> <div class="file-name item">{{ attachment.fileName }}</div> <div class="size item">{{ proxy.Utils.sizeToStr(attachment.fileSize) }}</div> <div> 需要<span class="intergal">{{ attachment.integral }}</span >积分 </div> <div class="download-count item">已下载{{ attachment.downloadCount }}次</div> <div class="download-btn item"> <el-button type="primary" size="small" @click="downloadAttachment(attachment.fileId)" >下载</el-button > </div> </div> </div>
|
这个部分 我们展示了附件的相关信息
在显示附件内存的时候 我们封装了一个内存单位转换工具函数 来正确显示内存大小 (在其他场景也可以使用)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| export default { sizeToStr: (size) => { var data = '' if (size < 0.1 * 1024) { data = size.toFixed(2) + 'B' } else if (size < 0.1 * 1024 * 1024) { data = (size / 1024).toFixed(2) + 'KB' } else if (size < 0.1 * 1024 * 1024 * 1024) { data = (size / (1024 * 1024)).toFixed(2) + 'MB' } else { data = (size / (1024 * 1024 * 1024)).toFixed(2) + 'GB' } var sizestr = data + '' var len = sizestr.indexOf('.') var dec = sizestr.substr(len + 1, 2) if (dec === '00') { return sizestr.substring(0, len) + sizestr.substr(len + 3, 2) } return sizestr }, }
|
附件下载
文件下载是我们之前没有涉及到的东西 涉及到 文件流 的相关知识
什么是文件流
文件流(File Stream)是服务器以二进制流的形式传输文件数据的一种方式。简单来说,服务器不直接返回文件的 URL,而是把文件的二进制内容 “流式” 地传给前端,前端再把这些二进制数据解析成可下载的文件。
作用:
- 支持权限控制:比如你的代码中需要判断 “用户是否已下载”“积分是否足够”,这些逻辑需要在服务端验证后,再决定是否返回文件流。
- 隐藏真实文件地址:防止文件被随意访问或爬取。
- 处理大文件:流的方式可以避免一次性加载大文件到内存,提升性能。
具体的下载逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| const downloadDo = (fileId) => { document.location.href = api.attachmentDownload + "?fileId=" + fileId attachment.value.downloadCount += 1 }
const downloadAttachment = async (fileId) => { const currentUser = store.getLoginUserInfo if (!store.getLoginUserInfo) { store.updateshowLogin(true) return }
let res = await proxy.Request({ url: api.getUserDownloadInfo, params: { fileId: fileId, }, }) if (!res) { return } if (res.data.haveDownload) { debugger downloadDo(fileId) return }
if ( res.data.userIntegral < attachment.value.integral && currentUser.userId != articleInfo.userId ) { console.log('积分不足') proxy.Message.warning('积分不足,无法下载该附件') return }
proxy.Confirm( `你还有${res.data.userIntegral}积分,当前下载会扣除${attachment.value.integral}积分,是否继续?`, () => { downloadDo(fileId) }, ) }
|
1.权限校验阶段(downloadAttachment函数)
这个函数做了 3 层校验:
- 登录校验:如果用户未登录,弹出登录框。
- 下载记录校验:如果用户已下载过,直接跳过后续逻辑,触发下载。
- 积分校验:如果用户积分不足(且不是作者自己),提示 “积分不足”;如果积分足够,弹出确认框,确认后触发下载。
2.文件流的 “触发与下载”(downloadDo函数)
1 2 3 4
| const downloadDo = (fileId) => { document.location.href = api.attachmentDownload + "?fileId=" + fileId attachment.value.downloadCount += 1 }
|
这里的 api.attachmentDownload 接口,实际就是后端提供的 “文件流接口”。当你访问这个接口时,后端会:
- 验证
fileId对应的文件合法性;
- 以二进制流的形式返回文件内容;
- 前端的浏览器会自动识别这个二进制流,弹出下载框或直接下载文件。
评论
在评论板块我们需要实现的内容有
1.渲染评论列表与评论卡片内容
2.实现包含文字内容和图片内容的评论
其实整个评论板块与文章列表的渲染大致一样,包括渲染方式与排序方式(即orderType)这里就不再写了
与文章列表类似 我们还是给评论单独封装一个评论内容卡片 在渲染列表的时候只需要将其放入插槽
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <div class="comment-list"> <DataList :dataSource="commentListInfo" :loading="loading" @loadData="loadComment" noDataMsg = "暂无评论,快来一起讨论吧!"> <template #default="{ data }"> <CommentListItem :articleId="articleId" :commentData="data" :articleUserId="articleUserId" :currentUserId="currentUserInfo.userId" @postCommentFinish="postCommentFinish" @reloadData="loadComment" ></CommentListItem> </template> </DataList> </div>
|
loadComment的部分几乎跟之前发送的网络请求一样,这里就不写了
如果发布评论的用户是发布文章的用户的话 我们可以在其中进行 置顶操作 故而我们需要判断一下当前的用户id和文章id
在commentList中 将articleId作为参数传递给父组件 articleDetail 在拿到文章内容的同时 将拿到的id传递给commentList
我们用监听拿到currentUserId 即当前发布评论的用户账号
1 2 3 4 5 6 7 8 9 10
| const currentUserInfo = ref({})
watch( () => store.loginUserInfo, (newVal, oldVal) => { currentUserInfo.value = newVal || {} }, { immediate: true, deep: true }, )
|
这里直接拿pinia中的值也可以
置顶功能
当发表用户就是文章作者时,我们可以设定置顶评论
1 2 3 4 5 6 7 8 9 10
| <el-dropdown v-if="articleUserId == currentUserId"> <div class="iconfont icon-more"></div> <template #dropdown> <el-dropdown-menu> <el-dropdown-item @click="opTop(commentData)"> {{ commentData.topType == 0 ? '设为置顶' : '取消置顶' }} </el-dropdown-item> </el-dropdown-menu> </template> </el-dropdown>
|
这里用的elment的下拉框
这里的置顶功能 仍然需要向后端发送一次请求
1 2 3 4 5 6 7 8 9 10 11 12
| const opTop = async (data) => { let res = await proxy.Request({ url: api.changeTopType, params: { commentId: data.commentId, topType: data.topType == 1 ? 0 : 1, }, showLoading: false, }) if (!res) return emit("reloadData") }
|
需要注意的是 像我们之前改换排序的时候一样 也需要重新加载一次评论,但问题是现在这个方法是在我们评论卡片中定义的,而加载评论是在评论列表中执行的 故而我们需要暴露出去这个加载方法
这里的逻辑是 当请求发送成功后,事件reloadData会被触发 这时候在父组件commentList监听这个事件,并把这个事件绑定到loadComment方法上,最终实现评论的刷新
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| @reloadData="loadComment"
const loadComment = async () => { let params = { pageNo: commentListInfo.value.pageNo, articleId: props.articleId, orderType: orderType.value, } loading.value = true let res = await proxy.Request({ url: api.loadComment, params, showLoading: false })
loading.value = false
if (!res) { return }
commentListInfo.value = res.data }
|
那么其实 最终触发的就是loadComment函数了
发送评论
输入框结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| <template> <div class="post-comment-panel"> <Avatar :width="50" :userId="userId"></Avatar> <div class="comment-form"> <el-form :model="formData" :rules="rules" ref="formDataRef" @submit.prevent> <el-form-item prop="content"> <el-input clearable :placeholder="placeholderInfo" type="textarea" :maxlength="800" resize="none" show-word-limit v-model.trim="formData.content" ></el-input> <div class="insert-img" v-if="showInsertImg"> <div class="pre-img" v-if="commentImg"> <CommentImage :src="commentImg"></CommentImage> <span class="iconfont icon-remove" @click="removeCommentImg"></span> </div> <el-upload v-else name="file" :show-file-list="false" accept=".png,.PNG,.jpg,.JPG,.jpeg,.JPEG,.gif,.GIF,.bmp,.BMP" :multiple="false" :http-request="selectImg" > <span class="iconfont icon-image"></span> </el-upload> </div> </el-form-item> </el-form> </div> <div class="send-btn" @click="postCommentDo">发表</div> </div> </template>
|
发送评论部分的大部分参数理所应当都是从父组件传入的 在这种较复杂的页面时 父子组件通信很重要 但注意其中的逻辑 不要搞混了
文字内容的上传就是简单根据接口文档调接口就可以了 这里就不再赘述
这里主要关注上传图片的逻辑
上传图片
1 2 3 4 5 6 7 8
| <el-upload v-else name="file" :show-file-list="false" accept=".png,.PNG,.jpg,.JPG,.jpeg,.JPEG,.gif,.GIF,.bmp,.BMP" :multiple="false" :http-request="selectImg" >
|
这里使用的是elment中的上传框 其中accept是接收的文件类型
http-request作为配置项 可以用来重写http请求的逻辑 这里我们要单独写一下这个方法
1 2 3 4 5 6 7 8 9 10 11
| const commentImg = ref(null) const selectImg = (file) => { file = file.file let img = new FileReader() img.readAsDataURL(file) img.onload = ({ target }) => { let imgData = target.result commentImg.value = imgData formData.value.image = file } }
|
评论区上传评价分为小图预览和大图上传两个状态 故而我们用了两种不同的数据方式来存放
commentImg,用于存储图片的预览地址(后续会赋值为 Base64 字符串)。
seletImage这是一个处理图片选择的函数,参数 file 通常是从文件选择器获取的文件对象包装体(比如某些 UI 组件会将文件放在 file.file 属性中)。
file = file.file 从传入的 file 中提取原始的文件对象(File 类型,浏览器原生的文件对象,包含文件名、大小、类型等信息)。
let img = new FileReader() 创建文件读取器 FileReader 是浏览器提供的 API,用于异步读取本地文件的内容(将文件转为数据 URL、二进制字符串等)。
img.readAsDataURL(file) 读取文件为 Base64 格式 将文件内容读取为 Base64 编码的字符串(格式如 data:image/png;base64,xxxx...)。 这种格式的字符串可以直接作为图片的 src 属性值,实现本地预览。
也就是说本地数据本地预览 这里没有上传
img.onload = ({ target }) => { ... } 读取完成后的回调 onload 是 FileReader 的回调事件,当文件读取完成后触发。 target 指向 FileReader 实例本身,target.result 就是读取到的结果(这里即 Base64 字符串)。
存储预览地址和原始文件
1 2 3
| let imgData = target.result commentImg.value = imgData formData.value.image = file
|
commentImg.value = imgData:将 Base64 字符串赋值给响应式变量,前端可以通过 <img :src="commentImg" /> 实时预览图片。
1
| <CommentImage :src="commentImg"></CommentImage>
|
formData.value.image = file:将原始 File 对象暂存到 formData 中(通常是一个用于收集表单数据的对象),后续调用接口时,会将这个 File 对象作为文件数据上传到后端。
1
| let params = Object.assign({}, formData.value)
|
这段代码的核心流程是:用户选择图片 → 提取文件对象 → 转为 Base64 实现本地预览 → 保存原始文件用于后续上传
作用是在用户选择图片后,既能让用户即时看到预览效果,又能保留原始文件以便后续通过接口提交到服务器,是前端图片上传场景的典型处理方式。
我们希望发布评论之后能够移除输出框的图片预览 绑定在删除键上
1 2 3 4
| const removeCommentImg = () => { commentImg.value = null formData.value.image = null }
|
评论内容校验
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| const checkPostComment = (rule, value, callback) => { if (value === null && formData.value.image === null) { callback(new Error(rule.message)) } else { callback() } }
const formData = ref({}) const formDataRef = ref({}) const rules = { content: [ { required: true, message: '请输入评论内容', validator: checkPostComment }, { min: 5, message: '评论至少五个字' }, ], }
|
这里的callback是组件库表单中自带的函数 没有error传入的时候才会进入下一步 value是表单数据
发送二级评论
除了单独的评论发送框之外 还可以通过点击评论卡片下的评论按钮发布二级评论 (即回复其他人的评论)
区别是在这里我们需要拿到一二级评论的id 并且显示回复的是谁 同时我们还需要实现再次点击评论框取消评论框
1 2 3 4 5 6 7 8 9 10 11 12 13
| <div class="reply-info" v-if="commentData.showReply"> <PostComment :placeholderInfo="placeholderInfo" :articleId="articleId" :avatarwidth="30" :userId="currentUserId" :showInsertImg="false" :pCommentId="pCommentId" :replyUserId="replyUserId" @postCommentFinish="postCommentFinish" > </PostComment> </div>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const pCommentId = ref(0)
const replyUserId = ref(null)
const placeholderInfo = ref(null)
const showReplyPanel = (curData, type) => { const haveShow = props.commentData.showReply == undefined ? false : props.commentData.showReply
emit('hiddenAllReply')
props.commentData.showReply = !haveShow
pCommentId.value = props.commentData.commentId
replyUserId.value = curData.userId
placeholderInfo.value = '回复@' + curData.nickName }
|
最后我们在这里传入的数据再传入PostComment中调用即可
评论结束
在评论结束后我们还需要执行几个逻辑
1.发布一级评论后更新评论数量 (评论数量我们只统计一级评论)
2.发布一级评论后要将数据插入到list中
3.发布二级评论之后需要将数据插入到一级评论的children中
(我们之前在PostCommentDo函数中已经暴露了结束评论函数postCommentFinish
1
| emit('postCommentFinish', res.data)
|
)
一级评论发布是直接在PostComment中发布的 故而其暴露给的父组件是CommentList 评论结束函数也该在其间写
1 2 3 4 5 6
| const postCommentFinish = (resdata) => { commentListInfo.value.list.unshift(resdata) const totalCount = commentListInfo.value.totalCount + 1; commentListInfo.value.totalCount = totalCount emit("updateCommentCount",totalCount) }
|
先将评论插入list中 然后更新评论数量 我们最终将更新评论数量的函数暴露给父组件articleDetail
1 2 3 4
| const updateCommentCount = (totalCount) => { articleInfo.value.commentCount = totalCount }
|
二级评论
二级评论就相对简单,只需要插入到一级评论的children中即可
1 2 3 4 5 6
| const postCommentFinish = (resdata) => { props.commentData.children = resdata placeholderInfo.value = undefined }
|
其实这里注释的部分和实际使用的部分逻辑是相似的 只是用的更简洁一点
实现发帖与编辑功能
发帖与编辑功能都用的是同一个界面 我们只是在编辑的时候拿到原来的文章内容
我们这里使用了富文本和markdown两种编辑器
markdown编辑器
这里使用的是VMdEditor
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| <template> <v-md-editor :model-value="modelValue" :height="height + 'px'" :include-level="[1, 2, 3, 4, 5, 6]" @upload-image="uploadImageHandler" @change="change" :disabled-menus="[]" ></v-md-editor> </template>
<script setup> import VMdEditor from '@kangc/v-md-editor' import '@kangc/v-md-editor/lib/style/base-editor.css' import githubTheme from '@kangc/v-md-editor/lib/theme/github.js' import '@kangc/v-md-editor/lib/theme/style/github.css'
import hljs from 'highlight.js'
import {getCurrentInstance} from 'vue' const { proxy } = getCurrentInstance()
VMdEditor.use(githubTheme, { Hljs: hljs, })
const props = defineProps({ modelValue: { type: String, default: '', }, height: { type: Number, default: 500, }, })
const emit = defineEmits(['update:modelValue', 'htmlContent']) const change = (markdownContent, htmlContent) => { emit('update:modelValue', markdownContent) emit('htmlContent', htmlContent) }
const uploadImageHandler = async (event, insertImage, files) => { let result = await proxy.Request({ url: 'file/uploadImage', params: { file: files[0], }, }) if (!result) { return } const url = proxy.globalInfo.imageUrl + result.data.fileName
insertImage({ url: url, desc: '图片', }) } </script>
|
- 参数说明:
event:上传事件对象(可能包含原生 DOM 事件信息)。
insertImage:编辑器提供的回调函数,用于将上传成功的图片插入到 Markdown 内容中(通常会生成  格式的 Markdown 图片语法)。
files:用户选择的图片文件列表(数组形式,这里取第一个文件 files[0])。
- 逻辑步骤:
- 调用
proxy.Request 发起图片上传请求,将选中的文件 files[0] 发送到后端接口 file/uploadImage。
- 上传成功后,拼接图片的完整 URL(
proxy.globalInfo.imageUrl 是后端图片服务器的基础地址,加上返回的文件名 result.data.fileName)。
- 调用
insertImage 函数,将图片的 URL 和描述(desc: '图片')传入,自动在 Markdown 编辑器中插入图片语法(比如 )。
富文本编辑器
这里富文本编辑器使用的是wangeditor
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107
| <template> <div class="editor-html"> <Toolbar style="border-bottom: 1px solid #ccc" :editor="editorRef" :defaultConfig="toolbarConfig" :mode="mode" /> <Editor :style="{ height: height + 'px', 'overflow-y': 'hidden' }" :model-value="modelValue" :defaultConfig="editorConfig" :mode="mode" @onCreated="handleCreated" @onChange="onChange" /> </div> </template>
<script setup> import "@wangeditor/editor/dist/css/style.css"; import { onBeforeUnmount, ref, shallowRef } from "vue"; import { Editor, Toolbar } from "@wangeditor/editor-for-vue"; import { getCurrentInstance } from "vue"; const { proxy } = getCurrentInstance(); import { useAllDataStore } from "@/store";
const store = useAllDataStore();
const props = defineProps({ modelValue: { type: String, default: '', }, height: { type: Number, default: 500, }, })
const mode = ref("default") const editorRef = shallowRef()
const toolbarConfig = { excludeKeys: ["uploadVideo"], }
const editorConfig = { placeholder: "请输入内容...", excludeKeys: ["uploadVideo"], MENU_CONF: { uploadImage: { maxFileSize: 3 * 1024 * 1024, server: "/api/file/uploadImage", fieldName: "file", customInsert(responseData, insertFn) { if (responseData.code == 200) { const imgUrl = proxy.globalInfo.imageUrl + responseData.data.fileName; insertFn(imgUrl, "", ""); return; } else if (responseData.code == 901) { store.commit("showLogin", true); store.commit("updateLoginUserInfo", null); return; } proxy.Message.error(responseData.info); }, }, }, };
const emit = defineEmits();
const onChange = (editor) => { emit("update:modelValue", editor.getHtml()); }
onBeforeUnmount(() => { const editor = editorRef.value; if (editor == null) return; editor.destroy(); })
const handleCreated = (editor) => { editorRef.value = editor; } </script>
<style lang="scss" scoped> .editor-html { border: 1px solid #ddd; } </style>
|
切换编辑器
这里有一个细节是 我们在Vuecookies中储存了上次使用过的编辑器类型 来提升体验 故而在切换编辑器的时候我们需要做一下本地的储存
1 2 3 4 5 6 7 8 9 10 11 12
| const editorType = ref(proxy.VueCookies.get('editorType') || 0)
const changeEditor = () => { proxy.Confirm('切换编辑器会清空正在编辑的内容,确定要切换吗?', () => { editorType.value = editorType.value == 0 ? 1 : 0 formData.value.content = '' formData.value.markdownContent = '' proxy.VueCookies.set('editorType', editorType.value, -1) }) }
|
获取文章信息
一个需要注意的地方是 发帖和新增的逻辑没有完全分开来写 因为其实本质是相似的 即发帖的时候拿到的文章信息就是空
在所有的选择框中 我们都使用formData.value来双向绑定 故而我们只需要对formData进行操作即可
拿到当前操作文章id
1 2 3 4 5 6 7 8 9 10 11
| watch( () => route, (newVal, oldVal) => { if (newVal.path.indexOf('/editPost') != -1 || newVal.path.indexOf('/newPost') != -1) { articleId.value = newVal.params.articleId getArticleDetail() } }, { immediate: true, deep: true }, )
|
获取板块信息
不管是修改当前板块 还是直接设置板块 我们都需要加载出目前板块信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const boardProps = { multiple: false, checkStrictly: true, value: 'boardId', label: 'boardName', }
const boardList = ref([]) const loadBoardList = async () => { let res = await proxy.Request({ url: api.loadBoard, })
if (!res) return
boardList.value = res.data }
loadBoardList()
|
拿到当前文章信息
在编辑功能中 我们需要拿到文章的具体信息发放在左侧 在发帖功能中 我们需要创建一个新的文章信息 故而我们需要发送请求拿到文章信息并且设置右侧的信息栏
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| const getArticleDetail = () => { nextTick(async () => { formDataRef.value.resetFields() if (articleId.value) { let res = await proxy.Request({ url: api.articleDetail4Update, params: { articleId: articleId.value }, showError: false, errorCallBack: (response) => { ElMessageBox.alert(response.info, '错误', { 'show-close': false, callback: (action) => { router.go(-1) }, }) }, }) if (!res) return
let articleInfo = res.data.forumArticle
editorType.value = articleInfo.editorType
articleInfo.boardIds = [] articleInfo.boardIds.push(articleInfo.pBoardId) if (articleInfo.boardId != null && articleInfo.boardId != 0) { articleInfo.boardIds.push(articleInfo.boardId) }
if (articleInfo.cover) { articleInfo.cover = { imageUrl: articleInfo.cover } }
if (res.data.attachment) { articleInfo.attachment = { name: res.data.attachment.fileName, } articleInfo.integral = res.data.attachment.integral }
formData.value = articleInfo } else { formData.value = {} editorType.value = proxy.VueCookies.get('editorType') || 0 } }) }
|
发表文章
发表文章只需要注意 我们的params是直接拿的formData数据 故而需要处理一下各个参数是否存在 防止传递给后端null导致返回错误
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| const postHandler = () => { formDataRef.value.validate(async (valid) => { if (!valid) return let params = {}
Object.assign(params, formData.value)
if (params.boardIds.length == 1) { params.pBoardId = params.boardIds[0] } else if (params.boardIds.length == 2) { params.pBoardId = params.boardIds[0] params.boardId = params.boardIds[1] }
delete params.boardIds
params.editorType = editorType.value
const contentText = params.content.replace(/<(?!img).*?>/g, '') if (contentText == '') { proxy.message.warning('正文不能为空') return }
if (params.attachment != null) { params.attachmentType == 1 } else { params.attachmentType == 0 }
if ((!params.cover) instanceof File) { delete params.cover }
if ((!params.attachment) instanceof File) { delete params.attachment }
let res = await proxy.Request({ url: params.articleId ? api.updateArticle : api.postArticle, params: params, })
if (!res) return
proxy.Message.success('保存成功')
router.push(`/post/${res.data}`) }) }
|
实现个人中心
个人信息与信息编辑 文章渲染是比较简单的内容 在之前的各个场景中几乎都实现了类似的功能 这里就不再写了 只记录一下思路
根据发布/评论/点赞切换文章显示 -> 只需要记录一下当前选择的标签 标签传递给后端直接返回对应文章列表即可 点击跳转文章也只需要加一个router-push即可
用户信息记录

即这样一个可以设置开始结束时间来查找对应数据的弹窗 因为感觉比较常用 所以在这里记录一下完整的写法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147
| <template> <!-- 弹窗组件:展示用户积分记录,配置弹窗显示状态、标题、按钮等属性 --> <Dialog :show="dialogConfig.show" :title="dialogConfig.title" :buttons="dialogConfig.buttons" width="500px" :top="diaTop" :showCancel="false" @close="dialogClose" > <el-form :model="formData" <!-- 表单数据绑定对象 --> ref="formDataRef" > <el-form-item label="日期" props="createTimeRange"> <el-date-picker v-model="formData.createTimeRange" type="daterange" <!-- 日期选择类型:范围选择 --> range-separator="~" start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" @change="loadRecord" ></el-date-picker> </el-form-item> </el-form>
<div class="data-item"> <div class="record-type">类型</div> <div class="integral">积分</div> <div class="create-time">时间</div> </div>
<DataList :dataSource="recordInfo" :loading="loading" @loadData="loadRecord" noDataMsg="暂无相关记录" > <template #default="{data}"> <div class="data-item"> <div class="record-type">{{ data.operTypeName }}</div> <div :class="[\'integral\', data.integral > 0 ? \'add\' : \'reduce\']"> {{ data.integral }} </div> <div class="create-time">{{ data.createTime }}</div> </div> </template> </DataList> </Dialog> </template>
<script setup> import { ref, getCurrentInstance, nextTick } from \'vue\'; import { useRouter } from \'vue-router\';
const router = useRouter() const {proxy} = getCurrentInstance()
const api = { loadUserIntegralRecord: "/ucenter/loadUserIntegralRecord" }
const loading = ref(false)
// 弹窗距离顶部的距离:自适应计算(屏幕高度 - 弹窗内容高度680px),确保弹窗居中或合理显示 const diaTop = window.innerHeight - 680 + \'px\'
// 弹窗配置:响应式对象,控制弹窗的显示状态、标题、按钮等 const dialogConfig = ref({ show: false, / title: \'查看用户积分记录\', buttons: [ { type: \'primary\', text: \'确定\', click: (e) => { dialogConfig.value.show = false } } ] })
const dialogClose = () => { dialogConfig.value.show = false }
const formData = ref({}) const formDataRef = ref()
const showRecord = () => { dialogConfig.value.show = true // 等待DOM更新后执行(确保弹窗内的表单元素已渲染完成) nextTick(() => { formData.value.createTimeRange = null // 清空日期范围筛选条件 loadRecord() // 加载积分记录数据(默认加载第一页) }) } defineExpose({showRecord})
// 积分记录数据 const recordInfo = ref({}) const loadRecord= async () => { loading.value = true // 如果记录数据未初始化,默认设置页码为1(首次加载时) if (!recordInfo.value) { recordInfo.value.pageNo = 1 }
let params = { pageNo: recordInfo.value.pageNo, } // 如果存在日期范围筛选条件,添加到请求参数中 if(formData.value.createTimeRange) { params.createTimeStart = formData.value.createTimeRange[0] // 开始日期 params.createTimeEnd = formData.value.createTimeRange[1] // 结束日期 } let result = await proxy.Request({ url: api.loadUserIntegralRecord, / params: params, showLoading: false }) if(!result){ return; } // 更新积分记录数据:将接口返回的分页数据赋值给recordInfo recordInfo.value = result.data loading.value = false }
</script>
<style lang="scss">
... </style>
|
消息中心
根据板块加载信息
虽然与前面个人中心类似 都是根据选择的tab来加载数据 但不同的地方是 由于这里整个页面都显示的都是对应板块内容 故而我们这里可以直接做路由跳转 通过监听route来拿到对应板块即可
在router的index.js中
1 2 3 4 5
| { path: 'user/message/:type', name: '用户消息', component: ()=> import('@/views/ucenter/MessageList.vue') },
|
在消息中心组件中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| const activeTabName = ref('reply')
const changeTab = (type) => { router.push(`/user/message/${type}`) }
watch( () => route.params.type, (newVal, oldVal) => { if (newVal) { activeTabName.value = newVal loadMessageList() } }, { immediate: true, deep: true }, )
|
当然这里其实只是因为路由设置了跳转 所以我们监听拿到
其实像上面个人中心说的那样 直接双向绑定记录一下当前点击板块也是完全没问题的
后面的就很简单了 依旧 作为参数传递给后端拿到数据对应加载列表即可
消息数量同步
消息中心的点击显然会因为已读消去原来的消息数量提示 于是我们希望这种消除是同步的 即消息中心点击查看了之后 在layout上的消息提示栏那里也可以显示为已读
很显然 要实现这样的功能 我们需要用pinia状态管理
关键在于 由于消息是分版块显示的 故而我们要既对整体的消息数量做减法 还要把当前阅读的板块消息数量清零
1 2 3 4
| readMessage(value) { this.messageCntInfo.total -= this.messageCntInfo[value] this.messageCntInfo[value] = 0 },
|
现在我们只需要当每次加载对应文章数据的时候调用一下这个action即可 (因为加载对应数据的时候其实也就是已阅的状态)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| const messageListInfo = ref({})
watch( () => store.messageCntInfo, (newVal, oldVal) => { messageCntInfo.value = newVal || {} }, { immediate: true, deep: true }, )
const loadMessageList = async () => { loading.value = true let params = { pageNo: messageListInfo.value.pageNo, code: activeTabName.value, }
let res = await proxy.Request({ url: api.loadMessageList, params: params, showLoading: false, })
loading.value = false
if (!res) return messageListInfo.value = res.data store.readMessage(activeTabName.value) }
|
最后就只需要在Layout里消息部分使用pinia中的消息数量即可
注意 加载当前账号信息数量的方法是在这里调用的 即是先显示在Layout中 而消息中心的方法只是对这里拿到的数据进行处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| const gotoMessage = (type) => { router.push(`/user/message/${type}`) }
const messageCntInfo = ref({}) const loadMessageCnt = async () => { let res = await proxy.Request({ url: api.loadMessageCnt, }) if (!res) return
messageCntInfo.value = res.data state.updateMessageCntInfo(res.data) }
watch( () => state.loginUserInfo, (newVal, oldVal) => { if (newVal) loadMessageCnt() }, { immediate: true, deep: true }, )
watch( () => state.messageCntInfo, (newVal, oldVal) => { messageCntInfo.value = newVal || {} }, { immediate: true, deep: true }, )
|
搜索功能
实现了一个通过关键词搜索的功能 其实关键的搜索逻辑还是通过后端实现的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155
| <template> <div class="contaniner-body search-body" :style="{ width: proxy.globalInfo.bodyWidth + 'px' }"> <div class="search-panel" :style="{ 'padding-top': startSearch ? '0px' : searchHeight + 'px' }"> <el-form :model="formData" :rules="rules" ref="formDataRef" @submit.prevent <!-- 阻止表单默认提交行为 --> > <!-- 输入框表单项 --> <el-form-item prop=""> <el-input size="large" clearable placeholder="请输入你想要查找的关键词" v-model.trim="formData.keyword" <!-- 双向绑定关键词,自动去除首尾空格 --> @keyup.enter="search" <!-- 回车触发搜索 --> @focus="startSearchHandler" <!-- 输入框获取焦点时触发处理函数 --> @change="changeInput" <!-- 输入内容变化时触发处理函数 --> > <template #suffix> <span class="iconfont icon-search" @click="search" @blur="formData.keyword = $event.target.value.trim()" <!-- 失去焦点时去除首尾空格 --> ></span> </template> </el-input> </el-form-item> </el-form> </div>
<!-- 文章列表区域 --> <div class="ariticle-list"> <DataList :loading="loading" :dataSource="articleListInfo" @loadData="search" noDataMsg="暂无帖子,快去发帖吧~" > <template #default="{ data }"> <ArticleListItem :data="data" :showComment="showCommnet" :showHtmlTitle="true" ></ArticleListItem> </template> </DataList> </div> </div> </template>
<script setup> import ArticleListItem from '@/views/forum/ArticleListItem.vue' import { useAllDataStore } from '@/store' import { ref, watch, getCurrentInstance, onMounted } from 'vue' import { ElMessage } from 'element-plus' import { useRouter, useRoute } from 'vue-router' import message from '@/utils/Message'
const { proxy } = getCurrentInstance() const router = useRouter() const route = useRoute() const store = useAllDataStore()
const formData = ref({}) const formDataRef = ref() const rules = { keyword: [ { required: true, message: '请输入关键字' }, { min: 3, message: '关键字太少,至少三个字' }, ], }
const api = { search: '/forum/search', }
const searchHeight = (window.innerHeight - 60 - 140 - 60) / 2
const loading = ref(false)
const articleListInfo = ref({}) // 搜索函数,发起搜索请求并处理结果 const search = async () => { loading.value = true let params = { keyword: formData.value.keyword, }
let res = await proxy.Request({ url: api.search, params: params, showLoading: false, })
loading.value = false
if (!res) { return }
let list = res.data.list // 对搜索结果中的标题进行关键词高亮处理 list.forEach((element) => { element.title = element.title.replace( params.keyword, `<span style="color:red">${params.keyword}</span>`, ) })
articleListInfo.value = res.data }
// 标记是否开始搜索(用于调整搜索面板样式) const startSearch = ref(false) // 输入框获取焦点时的处理函数 const startSearchHandler = () => { startSearch.value = true }
// 是否显示评论(由全局设置控制) const showCommnet = ref(true)
// 监听全局系统设置变化,同步评论显示状态 watch( () => store.sysSetting, (newVal, oldVal) => { if (newVal) showCommnet.value = newVal.commnetOpen }, { immediate: true, deep: true }, )
// 输入内容变化时的处理函数,若关键字为空则清空文章列表 const changeInput = () => { if (formData.value.keyword == '') articleListInfo.value = {} } </script>
<style lang="scss"> .search-body { background: #fff; padding: 10px; min-height: calc(100vh - 210px); // 计算最小高度 .search-panel { display: flex; justify-content: center; .el-input { width: 700px; // 输入框宽度 } } } </style>
|
可能唯一就是把关键字标红的操作比较少见 需要学习一下
总结
在这个项目里学到的东西很多 算是初步掌握了vue开发 接下来的学习中希望能够从跟写逐步进化到自己写 在组件的逻辑链上更加清晰 在js的应用上更加灵活 使用各种三方库的时候能够看懂文档学会基本使用 加油!
项目地址:[NJYgocrazy/EasyBBS: 仿掘金,知乎,b站实现的论坛项目]: