NuxtJS开发博客实战

环境搭建

技术栈

技术 说明 官网链接
Vue.js 前端框架 https://vuejs.org/
Vue-router 路由框架 https://router.vuejs.org/
Vuex 全局状态管理框架 https://vuex.vuejs.org/
Nuxt.js 创建服务端渲染 (SSR) 应用 https://zh.nuxtjs.org/
ElementUI 前端 UI 框架 https://element.eleme.io
Axios 前端 HTTP 框架 https://github.com/axios/axios
Highlight.js 代码语法高亮插件 https://github.com/highlightjs/highlight.js
mavon-editor Markdown 编辑器 https://github.com/nhn/tui.editor

创建NuxtJS项目

安装模块

npm install -g create-nuxt-app

创建项目

create-nuxt-app nuxt-vue-blog
create-nuxt-app v5.0.0
✨ Generating Nuxt.js project in nuxt-vue-blog
? Project name: nuxt-vue-blog
? Programming language: JavaScript
? Package manager: Npm
? UI framework: None
? Template engine: HTML
? Nuxt.js modules: (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Linting tools: (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Testing framework: None
? Rendering mode: Universal (SSR / SSG)
? Deployment target: Server (Node.js hosting)
? Development tools: (Press <space> to select, <a> to toggle all, <i> to invert selection)
? What is your GitHub username? acaiblog
? Version control system: None

项目配置

编辑nuxt.config.js修改head中的title和ico

export default {
// Global page headers: https://go.nuxtjs.dev/config-head
head: {
title: '阿才的博客',
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/meng.ico' }
]
}
}

NuxtJS整合ElementUI

安装模块

npm install element-ui

以插件方式引入element-ui,编辑plugins/element-ui.js

import Vue from 'vue'
import ElementUI from 'element-ui'

Vue.use(ElementUI)

在nuxt.config.js中配置插件和css

export default {
// Global CSS: https://go.nuxtjs.dev/config-css
css: [
// 引入全局css
// elementui各组件样式
'element-ui/lib/theme-chalk/index.css',
// 自适应隐藏显示样式
'element-ui/lib/theme-chalk/display.css'
],

// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
plugins: [
// 引入插件
'@/plugins/element-ui'
],

// Build Configuration: https://go.nuxtjs.dev/config-build
build: {
// 将位于node_modules的模块导出
trsanspile: [/^element-ui/],
// webpack自定义配置
extend(config, ctx) {

}
}
}

测试 elementui 是否生效,在 pages/index.vue 页面组件中添加个 el-button 按钮组件

<template>
<div>
<el-button type="primary">主要按钮</el-button>
</div>
</template>

<script>
export default {
name: 'IndexPage'
}
</script>

自定义主题

访问根据需求自定义主题并下载,比如选择#345DC2。将下载文件的index.css和font文件夹复制到assets/themes目录下
重构nuxt.config.js中的css

css: [
// 引入全局css
// elementui各组件样式
'element-ui/lib/theme-chalk/index.css',
// 自适应隐藏显示样式
'element-ui/lib/theme-chalk/display.css',
// 引入自定义主题样式
'@/assets/themes/index.css',
]

添加logo和全局样式

global.css复制到assets/css/global.csslogo.png复制到assets/images/logo.png
资源路径:
logo.png
global.css
通过nuxt.config.js引入全局样式

css: [
// 引入全局样式
'@/assets/css/global.css'
]

跨域配置代理转发

安装axios模块

npm install @nuxtjs/axios

参考链接:https://axios.nuxtjs.org/options/#proxy
在nuxt.config.js中配置代理

modules: [
'nuxtjs/axios'
],
axios: {
proxy: true,
prefix: '/api'
},
proxy: {
'/api': {
target: 'https://mock.mengxuegu.com/mock/655dc8fa7c4edb5264415616/blog',
pathRewrite: { '^/api': ''}
}
}

项目布局设计

项目整体布局分为上中下3部分:头部区域、中间主体区域、底部区域。针对每个部分我们都分别封装为一个子组件,子组件我们都放到components 目录下

默认组件布局

创建layouts/default.vue

<template>
<!--header-->
<div class="mxg-container">
<mxg-header/>
<!-- main-->
<div class="mxg-main">
<nuxt/>
</div>
<!-- footer-->
<mxg-footer/>
</div>
</template>
<script>
import MxgHeader from '@/components/layout/Header'
import MxgFooter from '@/components/layout/Footer'
export default {
components: {
MxgHeader,
MxgFooter
}
}
</script>
<style scoped>
.mxg-main{
padding-top: 90px;
max-width: 1140px;
margin: 0 auto;
}
</style>

创建头部组件文件:components/layout/Header.vue

<template>
<div class="mxg-header mxg-header-fixed">
<div class="mxg-nav">

</div>
</div>
</template>
<style scoped>
.mxg-header{
width: 100%;
height: 60px;
border-top: 3px solid #345dc2;
background-color: #fafafa;
box-shadow: 0 2px 4px rgba(0,0,0, .12);
z-index: 1501;
}
.mxg-header-fixed{
position: fixed;
}
</style>

创建底部组件文件:components/layout/Footer.vue,参考:Footer.vue

头部布局组件

参考ElementUI布局:https://element.eleme.cn/#/zh-CN/component/layout
让头部在一行显示,则在 el-row 指定type=”flex” 布局,justify=”space-between” 水平方向两端对齐;在 el-col 指定 :span每列占格数。一行span没有指定满24列,这样每列中间会一间隔。编辑components/layout/Header.vue

<template>
<div class="mxg-header mxg-header-fixed">
<div class="mxg-nav">
<!-- 头部, space-between 表示 flex 布局下的水平排列方式-->
<el-row type="flex" justify="space-between"></el-row>
<!-- logo-->
<el-col :span="4">
Logo
</el-col>
<!-- 导航菜单-->
<el-col :span="10">
导航菜单
</el-col>
<!-- 登录注册头像-->
<el-col :span="8">
登录、注册、头像
</el-col>
</div>
</div>
</template>

自适应布局

根据浏览器窗口大小,来显示或隐藏某个区域,从而达到自适应布局效果。

参数 说明 类型 可选值 默认值
span 栅格占据的列数 number 24
offset 栅格左侧的间隔格数 number 0
push 栅格向右移动格数 number 0
pull 栅格向左移动格数 number 0
xs <768px 响应式栅格数或者栅格属性对象 number/object
sm ≥768px 响应式栅格数或者栅格属性对象 number/object
md ≥992px 响应式栅格数或者栅格属性对象 number/object
lg ≥1200px 响应式栅格数或者栅格属性对象 number/object
xl ≥1920px 响应式栅格数或者栅格属性对象 number/object
tag 自定义元素标签 string * div

头部区域修改

<template>
<div class="mxg-header mxg-header-fixed">
<div class="mxg-nav">
<!-- 头部, space-between 表示 flex 布局下的水平排列方式-->
<el-row type="flex" justify="space-between"></el-row>
<!-- logo 任意宽度都是占4格-->
<el-col class="logo" :xs="4" :sm="4" :md="4">
Logo
</el-col>
<!-- 导航菜单, 手机与平板坚屏都占0格,也就是隐藏,其他10格-->
<!-- 当设置:sm=“0” 后,如果使用 md, lg, xl,窗口宽度变小再变更大,对应列都不显示-->
<!-- :xs="0"和:sm="0"当视口 <992px `导航菜单`被隐藏,:md="10"当 ≥992px 下面div被显示。但是现在当 ≥992px 下面div 不会被显示-->
<!-- <el-col :xs="0" :sm="0" :md="10">-->
<!-- 导航菜单, 手机与平板坚屏都占0格,也就是隐藏,其他10格-->
<!-- <el-col :xs="0" :sm="0" :md="10"> 不行,隐藏后放大不显示 -->
<el-col class="hidden-sm-and-down" :md="10">
导航菜单
</el-col>
<!-- 登录、注册/头像 手机与平板坚屏都占18格,其他占8格-->
<el-col class="nav-right" :xs="18" :sm="18" :md="8">
登录、注册、头像
</el-col>
</div>
</div>
</template>

重构头部导航

参考以下链接:
NavMenu 导航菜单 https://element.eleme.cn/#/zh-CN/component/menu
Dropdown 下拉菜单 https://element.eleme.cn/#/zh-CN/component/dropdown
Avatar 头像 https://element.eleme.cn/#/zh-CN/component/avatar

编辑Header组件引入logo

<!--      logo 任意宽度都是占4格-->
<el-col class="logo" :xs="4" :sm="4" :md="4">
<nuxt-link to="/"><img src="@/assets/images/logo.png" height="40px"></nuxt-link>
</el-col>

菜单

编辑Header组件

  <!-- 导航菜单, 手机与平板坚屏都占0格,也就是隐藏,其他10格-->
<!-- <el-col :xs="0" :sm="0" :md="10"> 不行,隐藏后放大不显示 -->
<el-col class="hidden-sm-and-down" :md="10">
<!-- 导航菜单 ,horizontal 水平, router 开启 index 指定路由地址, default-active默认哪个被选中-->
<el-menu mode="horizontal" router default-active="/" active-text-color="#345dc2" background-color="#fafafa">
<el-menu-item index="/">博客</el-menu-item>
<el-menu-item index="/question">问答</el-menu-item>
<el-menu-item index="/tag">标签</el-menu-item>
</el-menu>
</el-col>

注册登录

编辑Header组件

<template>
<div class="mxg-header mxg-header-fixed">
<div class="mxg-nav">
<!-- 头部, space-between 表示 flex 布局下的水平排列方式-->
<el-row type="flex" justify="space-between">
<!-- 登录、注册/头像 手机与平板坚屏都占18格,其他占8格-->
<el-col class="nav-right" :xs="18" :sm="18" :md="8">
<div class="nav-sign">
<el-button type="text">管理后台</el-button>
<el-button type="text">登录</el-button>
<el-button type="primary" size="small" round>注册</el-button>
</div>
<el-dropdown @command="handleCommand">
<div class="el-dropdown-link">
<el-avatar src="https://acaiblog.oss-cn-hangzhou.aliyuncs.com/userImage.jpeg" icon="el-icon-user-solid"></el-avatar>
</div>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="article">写文章</el-dropdown-item>
<el-dropdown-item command="question">问答</el-dropdown-item>
<el-dropdown-item command="user">主页</el-dropdown-item>
<el-dropdown-item command="logout">退出</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>

</el-col>
</el-row>


</div>
</div>
</template>
<script>
export default {
methods: {
handleCommand(command){
this.$message('click on item '+ command)
}
}
}
</script>

定义头部样式

编辑Header组件

<style>
/*导航*/
.mxg-header .mxg-nav{
max-width: 1140px;
/* 居中*/
margin: auto;
padding: 10px;
}
/*导航右侧*/
.nav-right{
text-align: right;
}
.nav-sign{
position: absolute;
right: 0;
margin-right: 50px;
}
/*防止点击头像有边框*/
div:focus{
outline: none;
}
.hidden-sm-and-down{
margin-top: -10px;
}
</style>

回到顶部组件

当页面内容太长时,需要有快速回到顶部按钮。引用了 ElementUI 中的回到顶部组件<el-backtop :bottom="80"></el-backtop>参考链接:https://element.eleme.cn/#/zh-CN/component/backtop。在每个页面都需要回到顶部按钮,所以我们将el-backtop 组件添加到 default.vue 默认模板中。

<template>
<!--header-->
<div class="mxg-container">
<mxg-header/>
<!-- main-->
<div class="mxg-main">
<nuxt/>
</div>
<!-- footer-->
<mxg-footer/>
<!-- 回到顶部,bottom 下拉距离顶部多高时,显示回到顶部图标。注意不要加 :target -->
<el-backtop :bottom="80"></el-backtop>
</div>
</template>

单点登录

参考:Vue实现单点登录

注册功能

登录、注册、退出、刷新令牌重新登录功能全部已经在认证中心单点登录已经实现,只需要在当前应用系统调用单点登录认证中心的对应地址即可。

登录需求

项目中有些功能是要求登录后才可以访问的,要求登录就会引发跳转到登录页面,只要调用认证中心的 http://localhost:8080 跳转到登录页即可。
当登录成功后,我们要重写向回引发跳转到登录页的地址。并且当跳转回来后我们要通过 Vuex 管理用户状态信息。

Vuex状态管理

NuxtJS集成Vuex

NuxtJS会在项目根目录查找store目录,如果store目录存在它将执行以下几件事:

  • 引用vuex模块
  • 将vuex模块加载到配置中
  • 设置Vue根实例的store配置项

总结:只要在 store 目录下创建模块文件就行,Nuxt实例化好 Vuex.Store 对象,自动将 store 目录下的模块管理起来。

NuxtJS使用Vuex的两种方式

  • 模块方式:store目录下的每个.js文件会被转换成为状态树指定命名的子模块 (当然index是根模块)
  • Classic(不建议使用, Nuxt3会废弃): store/index.js返回自己创建Vuex.Store实例的方法。

无论使用那种模式,您的state的值应该始终是function为了避免返回引用类型,会导致多个实例相互影响。

Vuex管理用户登录信息

当登录成功后, 我们会获取 Cookie 中 userInfo、access_token、refresh_token 信息使用 Vuex 进行管理。所以当跳转登录页面前,我们把这些状态值先清空。
创建vuex状态管理文件store/index.js声明状态变量,和实现跳转登录页

const state = () => ({
userInfo: null,
accessToken: null,
refreshToken: null
})

const mutations = {
RESET_USER_STATE(state){
state.userInfo = null
state.accessToken = null
state.refreshToken = null
}
}

const actions = {
LoginPage({ commit }){
commit('RESET_USER_STATE')
window.location.href = `http://localhost:8080?redirectURL=${window.location.href}`
}
}

export default {
state,
mutations,
actions
}

修改头部组件跳转到SSO登录页面

编辑components/layout/Header.vue

<template>
<el-button v-if="!userInfo" type="text" @click="$store.dispatch('LoginPage')">
登录
</el-button>
</template>
<script>
export default {
computed: {
userInfo(){
return this.$store.state.userInfo
}
}
</script>

测试:登录成功后会返回当前页面

环境变量配置

开发环境时,认证中心客户端URL是http://localhost:7000/,生产环境http://login.mengxuegu.com/可以使用 cross-env 模块,根据不同运行环境动态获取不同变量值,实现步骤如下:
安装cross-env模块

npm install cross-env

编辑package.json

"scripts": {
"dev": "cross-env NODE_ENV=dev nuxt",
"build": "nuxt build",
"start": "cross-env NODE_ENV=prod nuxt start",
"generate": "nuxt generate"
}

编辑nuxt.config.js添加 env 选项根据环境变量值动态获取认证中心URL

export default {
env: {
// 认证客户端,取值 process.env.authURL
authURL: process.env.NODE_ENV === 'dev' ? '//localhost:8080' : '//login.mengxuegu.com'
}

修改store/index.js,引用process.env.authURL

const actions = {
LoginPage({ commit }){
commit('RESET_USER_STATE')
window.location.href = `${process.env.authURL}?redirectURL=${window.location.href}`
}
}

测试登录,检查是否有问题

Nuxt服务端获取Cookie赋值状态

需求

当前Vuex中的登录用户信息状态值都是空的,应该当登录成功重定向回http://localhost:3000/question后,应该从cookie中获取值赋值给对应的状态。
目前存在的问题:

  • 每次重写向的地址可能不一样,那重写向回来后在哪个地方可以统一获取cookie值后赋值给状态。解决方式:nuxt 针对 Vuex 在 actions 对象中提供了一个nuxtServerInit 方法,每次刷新页面后,会在nuxt 服务端调用这个方法。这个方法第1个参数 store 对象{commit, state},第2参数是上下文对象context。所以在这个方法里获取cookie值赋值给状态。
  • 服务端如果通过 cookie-js 获取不到浏览器 cookie 中的数据。解决方式:使用一个服务端操作 Cookie 的模块cookie-universal-nuxt ,通过这个模块可在服务端获取到cookie值 。

安装cookie-universal-nuxt模块

npm install cookie-universal-nuxt

修改根目录下的 nuxt.config.js,在modules中添加

modules: [
'@nuxtjs/axios',
'cookie-universal-nuxt'
],

nuxtServerInit刷新Vuex状态

完善 userInfo 、accessToken、refreshToken 状态信息。让刷新浏览器后获取 cookie 中的值复制给状态。编辑store/index.js

const mutations = {
UPDATE_ALL_STATE(state, data){
state.userInfo = data.userInfo
state.accessToken = data.accessToken
state.refreshToken = data.refreshToken
}
}

在actions中添加nuxtServerInit方法 ,获取cookie值后,触发UPDATE_ALL_STATE传递给state通过context.app.$cookies 进行获取cookie值

const actions = {
nuxtServerInit({ commit }, { app }){
console.log('app', app.$cookies)
const data = {}
data.userInfo = app.$cookies.get('userInfo')
data.accessToken = app.$cookies.get('accessToken')
data.refreshToken = app.$cookies.get('refreshToken')
commit('UPDATE_ALL_STATE', data)
}
}

登录后隐藏登录注册按钮

编辑components/layout/Header.vue

<el-button v-if="!userInfo" type="text" @click="$store.dispatch('LoginPage')">
登录
</el-button>
<el-button v-if="!userInfo" type="primary" size="small" round>注册</el-button>

获取Vuex中用户头像

<el-avatar :src="userInfo ? userInfo.imageUrl:null" icon="el-icon-user-solid"></el-avatar>

需求

  1. 项目请求的后台数据接口,有些接口是需要通过身份认证通过后才有权限访问,如果没有身份认证是不允许访问的。
  2. 后台为了判断是否已身份认证过了,会要求应用请求数据接口时,在请求头上带上 访问令牌 accessToken , 后台收到请求后,就会校验你的 accessToken 是否有效,有效则正常响应数据。无效就会报401未认证。
  3. 创建plugins\interceptor.js拦截器插件,在请求头上面带上令牌
  4. 可以通过axios的请求拦截器中在每个请求头中带上accessToken。不能在 utils/request.js 中声明,因为accessToken 是要通过 store 来获取状态值,而在 nuxt 中普通的js中获取 store 对象很不方便。需要借助 nuxt 插件方式来进行获取 store 对象,插件中可以接收到 context 上下文对象,从而获取到 store 对象来获取state中的状态值。

功能实现

请求拦截器插件plugins/interceptor.js

export default ({ store, route, redirect, $axios }) =>{
$axios.onRequest(config =>{
console.log('请求拦截器')
// 添加accessToken
const accessToken = store.state.accessToken
if(accessToken){
// 针对每个请求,请求头带上令牌 Authorization: Bearer token
config.headers.Authorization = 'Bearer ' + accessToken
}
return config
})

$axios.onResponse(response => {
console.log('请求响应器')
return response
})

$axios.onError(error =>{
console.log('响应异常', error.response.status)
})
}

编辑nuxt.config.js引入插件

plugins: [
// 引入插件
'@/plugins/element-ui',
'@/plugins/interceptor'
],

注册功能

因为认证中心的登录和注册都在同一页面,所以点击注册一样是跳转到http://localhost:7000/?redirectURL=xxx修改components/layout/Header.vue的注册按钮

<el-button v-if="!userInfo" type="primary" size="small" round @click="$store.dispatch('LoginPage')">注册</el-button>

退出登录

点击右上角退出系统,发送请求给认证中心http://localhost:7000/logout?redirectURL=xxx删除服务器用户登录数据,并将cookie中的用户数据清除。

定义Vuex退出action

编辑store/index.js

const actions = {
UserLogout({ commit }){
commit('RESET_USER_STATE')
window.location.href = `${process.env.authURL}?redirectURL=${window.location.href}`
}
}

编辑components/layout/Header.vue定义点击退出触发退出登录Vuex action

<script>
import { mapState } from 'vuex';
export default {
computed: {
userInfo(){
return this.$store.state.userInfo
}
},
methods: {
handleCommand(command){
if(!this.userInfo){
// 未登录跳转到登录页面
return this.$store.dispatch('LoginPage')
}
switch (command){
case 'logout':
this.$store.dispatch('UserLogout')
break
default:
break
}

}
}
}
</script>

刷新令牌获取新令牌

当前系统请求后台资源接口时,要在请求头带上accessToken去请求接口,如果accessToken有效,资源服务器正常响应数据。
如果访问令牌accessToken过期,资源服务器会响应401状态码 。当前系统接收到401状态码时,通过刷新令牌refreshToken获取去请求新令牌完成新的重新身份。
plugins/interceptor.js添加响应异常拦截器$axios.onError,在此拦截器中发送请求到认证客户端,来刷新获取新的认证信息。

export default ({ store, route, redirect, $axios }) =>{
$axios.onError(error =>{
// 非401认证直接放行
if(error.response.status !== 401){
return Promise.reject(error)
}
console.log('响应异常', error.response.status)
// 401 发送刷新令牌请求

})
}

// 定义锁,防止重复请求
let isLock = true
const sendRefreshTokenRequest = (store, route, redirect) =>{
if (isLock && store.state.refreshToken){
// 刷新令牌
isLock = false
redirect(`${process.env.authURL}/refresh?redirectURL=${redirectURL(route)}`)
}else {
isLock = true
// 没有刷新令牌跳转到登录页面,注意不要使用 store.dispatch('LoginPage') ,因为 LoginPage 里面使用了window对象,nuxt服务端是没有此对象
store.commit('RESET_USER_STATE')
// 服务端帮我们跳转到登录页面
redirect(`${process.env.authURL}?redirectURL=${redirectURL(route)}`)
}
}

// 获取重定向地址
const redirectURL = (route) =>{
// 客户端
if(process.client){
return window.location.href
}
// 服务端 process.env._AXIOS_BASE_URL_等于http://localhost:3000/api
return process.env._AXIOS_BASE_URL_.replace('api','') + route.path
}

博客首页

首页主区域分为3个区域:左侧导航、中间文章列表、右侧广告。左侧和右侧区域当窗口缩小到 sm (<992px)范围时会被隐藏。
添加index.csslist.css样式文件到assets/css/blog/目录下,资源链接:index.css list.css
pages/index.vue中引入首页样式文件

<style>
@import "@/assets/css/blog/index.css";
</style>

左侧导航栏

编辑pages/index.vue

<template>
<!-- 主体内容-->
<div>
<el-row type="flex" justify="space-between">
<!-- 左侧-->
<el-col class="hidden-sm-and-down" :md="3">
<!-- 分类-->
<el-divider content-position="left">博客分类</el-divider>
<!-- 类型-->
<el-menu active-text-color="#ffffff" router :default-active="$route.path">
<el-menu-item index="/">推荐</el-menu-item>
<el-menu-item index="1">Java</el-menu-item>
<el-menu-item index="2">Vue</el-menu-item>
<el-menu-item index="3">Linux</el-menu-item>
<el-menu-item index="4">DevOps</el-menu-item>
</el-menu>
</el-col>
</el-row>
</div>
</template>

中间广告区域

编辑pages/index.vue

<template>
<!-- 主体内容-->
<div>
<el-row type="flex" justify="space-between">
<!-- 中间-->
<el-col :xs="24" :sm="24" :md="16">
<div class="blog-center">
<el-carousel height="250px">
<el-carousel-item v-for="item in 4" :key="item">
<a target="_blank" href="https://www.aliyun.com/daily-act/ecs/ecs_trial_benefits?userCode=rqbsil2v">
<img src="https://img.alicdn.com/bao/uploaded/i2/3603079088/O1CN01rGCkfb2H0M1O7Lj45_!!0-item_pic.jpg">
</a>
</el-carousel-item>
</el-carousel>
</div>
</el-col>
</el-row>
</div>
</template>

右侧课程区域

编辑pages/index.vue

<template>
<!-- 主体内容-->
<div>
<el-row type="flex" justify="space-between">
<!-- 右侧-->
<el-col class="hidden-sm-and-down" :md="5">
<el-row>
<el-col>
<el-card class="right-card" shadow="hover" :body-style="{padding: '10px'}">
<p>课程推荐</p>
<el-carousel height="210px">
<el-carousel-item v-for="item in 4" :key="item">
<a target="_blank" href="http://www.acaiblog.top">
<img src="https://img.alicdn.com/bao/uploaded/i2/3603079088/O1CN01rGCkfb2H0M1O7Lj45_!!0-item_pic.jpg">
<span>阿才的博客</span>
</a>
</el-carousel-item>
</el-carousel>
</el-card>
</el-col>
</el-row>
</el-col>
</el-row>
</div>
</template>

添加首页嵌套自路由列表组件

文章列表采用嵌套路由使用<nuxt-child>子路由引入,根据左侧点击技术类别,文章列表显示不同内容。 每行包含文章标题,简介,有图片(和没有图标),发布人,评论数,浏览量。
编辑pages/index/_id.vue

<template>
<!--文章列表-->
<div id="list-container">
<ul class="note-list">
<!-- 无主图-->
<li>
<div class="content">
<nuxt-link to="/article/111" target="_blank">
<p class="title">adasdfasdfasdf</p>
<p class="abstract">
它本身就像一个画布,JavaScript 通过操作它的 API,在上面生成图像。它的底层是一个个像素,基本上一个可以用 JavaScript 操作的位图(bitmap)。
是脚本调用各种方法生成图像,SVG 则是一个 XML 文件,通过各种子元素生成图像。
</p>
</nuxt-link>
<div class="meta">
<nuxt-link to="/user/111" target="_blank" class="nickname">
<i class="el-icon-user-solid">梦雪谷</i>
</nuxt-link>
<span><i class="el-icon-thumb"></i>42</span>
<span><i class="el-icon-thumb"></i>422</span>
</div>
</div>
</li>
<!-- 有主图-->
<li class="have-img">
<div class="content">
<nuxt-link to="/article/22222" target="_blank">
<p class="title">走进互联网应用—从单体应用到微服务架构</p>
<p class="abstract">
介绍目前互联网应用开发的主流框架,包括:Spring、SpringMVC、MyBatis、SpringBoot以及SpringCloud,讲解技术更新迭代的过程,以及大型项目的架构设计思想
</p>
</nuxt-link>
<div class="meta">
<nuxt-link to="/user/111" target="_blank" class="nickname">
<i class="el-icon-user-solid"></i> 梦学谷
</nuxt-link>
<span><i class="el-icon-thumb"></i> 42</span>
<span><i class="el-icon-view"></i> 266</span>
</div>
</div>
<!-- 图片 -->
<nuxt-link to="/article/333" class="wrap-img" target="_blank">
<img
src="https://img.alicdn.com/bao/uploaded/i2/3603079088/O1CN01rGCkfb2H0M1O7Lj45_!!0-item_pic.jpg" >
</nuxt-link>
</li>
</ul>
</div>
</template>

编辑pages/index.vue引入文章列表

<template>
<!-- 主体内容-->
<div>
<el-row type="flex" justify="space-between">
<!-- 中间-->
<el-col :xs="24" :sm="24" :md="16">
<nuxt-child/>
</el-col>
</el-row>
</div>
</template>

首页数据接口

创建分类接口

URL method 描述
/article/api/category/list get 文章分类列表

MockJS

{
"code": 20000,
"message": "查询成功",
"data|8": [{
"id|+1": 10,
"name": "@cname"
}]
}

创建广告接口

URL method 描述
/article/api/advert/show get 获取指定位置的广告

MockJS

{
"code": 20000,
"message": "查询成功",
"data|3": [{ // 产生8条数据
"id|+1": 10, //初始值10开始,每条+1,
"title": "@ctitle", // 随机一个标题
"imageUrl": "https://img.alicdn.com/imgextra/i1/3603079088/O1CN01jYfKNE2H0Lz1YJoFC_!!3603079088.jpg",
"advertUrl": "http://www.mengxuegu.com",
"advertTarget": "_blank",
}]
}

文章列表接口

URL Method 描述
/article/api/article/list post 获取文章列表接口

MockJS

{
"code": 20000,
"message": "查询成功",
"data": {
"total": "@integer(100, 200)", // 总记录数
"records|20": [{ //生成20条数据
"id|+1": 10, //初始值10开始,每条+1
"userId|+1": 1, // 发布者id
"nickName": "@cname", // 发布者昵称
"title": "@csentence", // 标题
"summary": "@csentence(50, 100)",
"imageUrl|1": [null,
"https://img.alicdn.com/bao/uploaded/i2/3603079088/O1CN01rGCkfb2H0M1O7Lj45_!!0-item_pic.jpg"
],
"viewCount": "@integer(0, 100000)", // 浏览次数
"thumhup": "@integer(0, 100000)", // 点赞数
}]
}
}

封装API插件调用数据接口

以 plugins 插件形式封装调用数据接口 api ,将调用接口api方法同时绑定到 Context 和 Vue 实例上,这样在组件中和 store 中都可以调用到此 api 方法。
创建api\article.js添加getCategoryListgetAdvertListgetArticleList方法,分别查询技术频道和广告信息、 文章列表分页接口

import {inject} from "vue";
import {query} from "vue/src/platforms/web/util";

export default ({$axios}, inject) => {
// 查询分类接口
inject('getCategoryList', () => $axios.get('/article/api/category/list'))

// 获取广告数据接口
inject('getAdvertList', position => $axios.get(`/article/api/advert/show/${position}`))
// 文章列表接口
inject('getArticleList', query => $axios.$post('/article/api/article/list',query))
}

编辑nuxt.config.js引入插件

plugins: [
// 引入插件
'@/plugins/element-ui',
'@/plugins/interceptor',
'@/api/article'
]

分类与广告数据渲染

pages/index.vueasyncData中调用getCategoryListgetAdvertList获取数据。在asyncData()方法接收{app}参数来调用api/article.js中定义的方法,其中方法名使用$作为前缀。

<script>
export default {
async asyncData({app}){
// 获取分类
const { data: categoryList } = await app.$getCategoryList()
console.log('categoryList', categoryList)
// 获取主页广告
const { data: mainAdvertList } = await app.$getAdvertList(1)
console.log('mainAdvertList', mainAdvertList)
// 获取右侧课程
const { data: courseAdvertList } = await app.$getAdvertList(2)
console.log('courseAdvertList', courseAdvertList)
}
}
</script>

编辑pages/index.vue渲染模版数据

<template>
<!-- 主体内容-->
<div>
<el-row type="flex" justify="space-between">
<!-- 左侧-->
<el-col class="hidden-sm-and-down" :md="3">
<!-- 分类-->
<el-divider content-position="left">博客分类</el-divider>
<!-- 类型-->
<el-menu active-text-color="#ffffff" router :default-active="$route.path">
<el-menu-item v-for="item in categoryList.data" :key="item.id" :index="'/'+item.id">{{item.name}}</el-menu-item>
</el-menu>
</el-col>
</el-row>
</div>
</template>

文章列表数据渲染

pages/index/_id.vue导入article.js,validate校验路由参数,asyncData中调用getArticleList获取文章列表数据。

<script>
import api from '@/api/article'
export default {
// 校验路由合法性 检查 params 对象中是否有一个名为 id 的属性。如果存在,将该属性值赋给 id 变量;否则,将 0 赋给 id 变量
validate({params}){
const id = params.id ? params.id : 0
return /^\d+$/.test(id)
},
async asyncData({params, app}){
const categoryId = params.id ? params.id : null
// 获取文章列表
const query = { categoryId, current:1, size:20 }
const { data } = await app.$getArticleList(query)
return {query, articleList: data.records}
}
}
</script>

模版渲染,编辑pages/index/_id.vue

<li :class="{'have-img': item.imageUrl}" v-for="item in articleList">
<div class="content">
<nuxt-link :to="`/article/${item.id}}`" target="_blank">
<p class="title">{{ item.title }}</p>
<p class="abstract">
{{item.summary}}
</p>
</nuxt-link>
<div class="meta">
<nuxt-link :to="`/user/${item.userId}`" target="_blank" class="nickname">
<i class="el-icon-user-solid"></i>{{ item.nickName }}
</nuxt-link>
<span><i class="el-icon-thumb"></i>{{ item.thumhub }}</span>
<span><i class="el-icon-view"></i>{{ item.viewCount }}</span>
</div>
</div>
<!-- 图片 -->
<div v-if="item.imageUrl">
<nuxt-link :to="`/article/${item.id}`" class="wrap-img" target="_blank">
<img :src="item.imageUrl">
</nuxt-link>
</div>
</li>

滚动式分页

添加滚动式分页按钮, noMore=true 表示没有更多数据了,load是点击按钮后触发获取数据的方法,loading=true 加载数据中。编辑pages/index/_id.vue

    </ul>
<el-row class="page" type="flex" justify="center">
<el-tag v-if="noMore || articleList.length === 0" type="primary">
没有更多了
</el-tag>
<el-button v-else @click="load" :loading="loading" type="primary" size="small" round>
{{ loading ? '加载中': '点击加载更新' }}
</el-button>
</el-row>

this.query.current++每次点击按钮就是查询下一页在Vue组件方法中,使用this.$插件方法名调用插件方法,如this.$getArticleList()。编辑pages/index/_id.vue

<script>
import api from '@/api/article'
export default {
data(){
return {
noMore: false,
loading: false
}
},
methods: {
async load(){
// 加载中
this.loading = true
// 页码+1
this.query.current++
const { data } = await this.$getArticleList(this.query)
// 有数据
if(data.records && data.records.length>0){
this.articleList = this.articleList.concat(data.records)
}else {
// 没有数据
this.noMore = true
}
// 加载完成
this.loading = false
}
}
}
</script>

文章详情页

布局

创建pages/article/_id.vue展示文章详情,分左右布局,左侧显示文章内容和评论区,右侧显示文章目录导航

<template>
<div>
<el-row type="flex">
<el-col :xs="24" :sm="24" :md="18">
<div class="article-left">
<el-card>
<!-- 标题-->
<div class="article-title">
<h1>Nuext.js+Vue.js+ElementUI+Axios 梦学谷博客</h1>
<div class="article-count">
<nuxt-link to="/user/" target="_blank" class="nickname"> <i class="el-icon-user-solid"></i> 小梦</nuxt-link>
<span>
<i class="el-icon-date"></i> 1小时前
<i class="el-icon-thumb"></i> 100
<i class="el-icon-view"></i> 9969
</span>
</div>
<el-tag size="small">html</el-tag>
<el-tag size="small">css</el-tag>
<el-tag size="small">vue.js</el-tag>
</div>
<!-- 内容 -->
<div class="article-content">
<div class="markdown-body" >内容区</div>
</div>
<el-button icon="el-icon-thumb" type="primary" size="medium" plain>赞</el-button>
</el-card>
<!-- 评论区 -->
<div>
<h2>评论区</h2>
<el-card>
</el-card>
</div>
</div>
</el-col>
<!-- 右侧-->
<el-col class="hidden-sm-and-down" :md="6"> <el-row >
<el-col>
文章目录
</el-col>
</el-row>
</el-col>
</el-row>
</div>
</template>

<style scoped>
@import "@/assets/css/blog/article.css";
</style>

文章内容显示

创建MockJS接口

添加文章详情数据接口

URL method description
/article/api/api/article/{id} get 文章详情接口

mockjs

{
"code": 20000,
"message": "成功",
"data": {
"id": "10",
"userId": "1",
"nickName": "@cname",
"title": "@csentence", // 标题
"summary": "@csentence(50, 100)",
"viewCount": "@integer(0,100)",
"thumhup": "@integer(0,10)",
"createDate": "@datetime",
"updateDate": "@datetime", // 详情显示的时间
"ispublic|1": [0, 1], // 0: 不公开 1:公开
"labelIds|1-5": ['@integer(10, 24)'], //随机产生1到5个元素的数字数组,数字取值10到24间
"labelList|3": [{ // 所属标签集合
"id|+1": 10,
"name": "@word" // 标签名
}],
"imageUrl|1": [null, "https://img.alicdn.com/bao/uploaded/i2/3603079088/O1CN01rGCkfb2H0M1O7Lj45_!!0-item_pic.jpg"],
"mdContent": '# vue-element-admin 概述\n\n[vue-element-admin](https://panjiachen.github.io/vue-element-admin) 是一个后台前端解决方案,它基于 [vue](https://github.com/vuejs/vue) 和 [element-ui](https://github.com/ElemeFE/element)实现。它使用了最新的前端技术栈,内置了 i18n 国际化解决方案,动态路由,权限验证,提炼了典型的业务模型,提供了丰富的功能组件,它可以帮助你快速搭建企业级中后台产品原型。\n\n[vue-element-admin](https://panjiachen.github.io/vue-element-admin) 定位是后台集成方案,不太适合当基础模板来进行二次开发,因为本项目集成了很多你可能用不到的功能,会造成不少的代码冗余。\n\n官方还提供了一套基础模板 [vue-admin-template](https://github.com/PanJiaChen/vue-admin-template) ,我们基于它进行二次开发,想要什么功能或者组件就去 `vue-element-admin` 那里复制过来。\n\n\n\n## 参考:\n\n- 官方文档 :https://panjiachen.gitee.io/vue-element-admin-site/zh/\n\n- [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin) 脚手架: \n\n 在线预览:https://panjiachen.gitee.io/vue-element-admin\n\n `gitee` :https://gitee.com/mirrors/vue-element-admin\n\n `github` :https://github.com/PanJiaChen/vue-element-admin\n\n- [vue-admin-template](https://github.com/PanJiaChen/vue-admin-template) 脚手架: \n\n 在线预览:https://panjiachen.gitee.io/vue-admin-template\n\n `github` 源码:https://github.com/PanJiaChen/vue-admin-template\n\n',
"htmlContent": '<h1><a id=\"vueelementadmin__0\"></a>vue-element-admin 概述</h1>\n<p><a href=\"https://panjiachen.github.io/vue-element-admin\" target=\"_blank\">vue-element-admin</a> 是一个后台前端解决方案,它基于 <a href=\"https://github.com/vuejs/vue\" target=\"_blank\">vue</a> 和 <a href=\"https://github.com/ElemeFE/element\" target=\"_blank\">element-ui</a>实现。它使用了最新的前端技术栈,内置了 i18n 国际化解决方案,动态路由,权限验证,提炼了典型的业务模型,提供了丰富的功能组件,它可以帮助你快速搭建企业级中后台产品原型。</p>\n<p><a href=\"https://panjiachen.github.io/vue-element-admin\" target=\"_blank\">vue-element-admin</a> 定位是后台集成方案,不太适合当基础模板来进行二次开发,因为本项目集成了很多你可能用不到的功能,会造成不少的代码冗余。</p>\n<p>官方还提供了一套基础模板 <a href=\"https://github.com/PanJiaChen/vue-admin-template\" target=\"_blank\">vue-admin-template</a> ,我们基于它进行二次开发,想要什么功能或者组件就去 <code>vue-element-admin</code> 那里复制过来。</p>\n<h2>参考:</h2>\n<ul>\n<li>\n<p>官方文档 :https://panjiachen.gitee.io/vue-element-admin-site/zh/</p>\n</li>\n<li>\n<p><a href=\"https://github.com/PanJiaChen/vue-element-admin\" target=\"_blank\">vue-element-admin</a> 脚手架:</p>\n<p>在线预览:https://panjiachen.gitee.io/vue-element-admin</p>\n<p><code>gitee</code> :https://gitee.com/mirrors/vue-element-admin</p>\n<p><code>github</code> :https://github.com/PanJiaChen/vue-element-admin</p>\n</li>\n<li>\n<p><a href=\"https://github.com/PanJiaChen/vue-admin-template\" target=\"_blank\">vue-admin-template</a> 脚手架:</p>\n<p>在线预览:https://panjiachen.gitee.io/vue-admin-template</p>\n<p><code>github</code> 源码:https://github.com/PanJiaChen/vue-admin-template</p>\n</li>\n</ul>\n<p>参考官网配置:<br /><a href="https://cli.vuejs.org/zh/guide/html-and-static-assets.html#public-%E6%96%87%E4%BB%B6%E5%A4%B9" target="_blank">https://cli.vuejs.org/zh/guid…</a></p><p>需要设置<code>BASE_URL</code></p><pre><div class="hljs"><code class="lang-js">data () { <span class="hljs-keyword">return</span> { <span class="hljs-attr">publicPath</span>: process.env.BASE_URL }}</code></div></pre><p>然后</p><pre><div class="hljs"><code class="lang-html"><span class="hljs-tag">&lt;<span class="hljs-name">img</span> <span class="hljs-attr">:src</span>=<span class="hljs-string">"`${publicPath}my-image.png`"</span>&gt;</span></code></div></pre><p><mark>特别强调</mark></p>'
}
}

创建更新浏览+1接口

URL method description
/article/api/article/viewCount/{id} put 浏览+1接口

mockjs

{
"code": 20000,
"message": "成功"
}

API调用数据接口

api/article.js添加getArticleById查询文章详情updateArticleViewCount调用更新浏览数接口

//   文章详情接口
inject('getArticleById', id => $axios.$get(`/article/api/article/${id}`))
// 更新张文浏览量
inject('updateArticleViewCount', id => $axios.$put(`/article/api/article/viewCount/${id}`))

引入代码高亮样式

添加github-markdown.cssgithub-min.css样式文件,参考:
https://gitee.com/acaiblog/nuxt-vue-blog/raw/master/assets/css/md/github-markdown.css
https://gitee.com/acaiblog/nuxt-vue-blog/raw/master/assets/css/md/github-min.css

文章详情数据渲染

pages/article/_id.vue导入article.jsvalidate校验路由参数,asyncData中调用getArticleById获取文章详情数据。并导入高亮显示样式。app.$cookies.set 保存到cookie中

<script>
export default {
validate({params}){
// 必须是number类型
return /^\d+$/.test(params.id)
},
head(){
return {
title: this.data.title // 浏览器显示标题
}
},
// 获取数据
async asyncData({params, app}){
// 查询文章详情
const { data } = await app.$getArticleById(params.id)
// 更新浏览量,判断cookie中是否存在
const isView = app.$cookies.get(`article-view-${data.id}`)
if(!isView){
const { code } = await app.$updateArticleViewCount(data.id)
if(code === 20000){
data.viewCount++
}
app.$cookies.set(`article-view-${data.id}`, true)
}
return { data } //等价于{data: data}
}
}
</script>

模版渲染

<template>
<div>
<el-row type="flex">
<el-col :xs="24" :sm="24" :md="18">
<div class="article-left">
<el-card>
<!-- 内容 -->
<div class="article-content">
<div class="markdown-body" v-html="data.htmlContent">内容区</div>
</div>
<el-button icon="el-icon-thumb" type="primary" size="medium" plain>赞</el-button>
</el-card>
<!-- 评论区 -->
<div>
<h2>评论区</h2>
<el-card>
</el-card>
</div>
</div>
</el-col>
<!-- 右侧-->
<el-col class="hidden-sm-and-down" :md="6"> <el-row >
<el-col>
文章目录
</el-col>
</el-row>
</el-col>
</el-row>
</div>
</template>

日期优化

新建utils/date.js,具体参考:date.js
编辑pages/article/_id.vue

<script>
import { dateFormat } from "@/utils/date";

export default {
methods: {
getDateFormat(date){
return dateFormat(date)
}
}
}
</script>

编辑pages/article/_id.vue在模版中引用

<span>
<i class="el-icon-date"></i>{{ getDateFormate(data.updateDate) }}
<i class="el-icon-thumb"></i>{{data.thumhub}}
<i class="el-icon-view"></i>{{data.viewCount}}
</span>

点赞实现

创建mock数据接口

URL method description
/article/article/thumb/{articleId}/{count} put 点赞接口

mockjs

{
"code": 20000,
"message": "成功"
}

api/article.js添加updateArticleThumb方法调用更新点赞数量接口

//   点赞接口
inject('updateArticleThumb', (articleId, count) => $axios.$put(`/article/article/thumb/${articleId}/${count}`))

编辑pages/article/_id.vue

<template>
<div>
<el-row type="flex">
<el-col :xs="24" :sm="24" :md="18">
<div class="article-left">
<el-card>
<el-button :disabled="!$store.state.userInfo" @click="handleThumb()" :plain="!isThumb" icon="el-icon-thumb" :type="isThumb ? 'primary':'info'" size="medium">赞</el-button>
</el-card>
</div>
</el-col>
</el-row>
</div>
</template>
<script>
import { dateFormat } from "@/utils/date";

export default {
data(){
return {
isThumb: this.$cookies.get(`article-thumb-${this.$route.params.id}`) ? this.$cookies.get(`article-thumb-${this.$route.params.id}`) : false
}
},
methods: {
async handleThumb(){
console.log('点赞被点击')
// 点击后取反
this.isThumb = !this.isThumb
// 1 点赞 -1 取消
const count = this.isThumb ? 1: -1
// 获取当前文章id
const articleId = this.$route.params.id
// 更新点赞数
const { code } = await this.$updateArticleThumb(articleId, count)
if(code === 20000){
this.data.thumhub = this.data.thumhub + count
console.log('this.data.thumhub', this.data.thumhub)
console.log(this.data)
console.log(count)
// 保存到cookie 保存5年
this.$cookies.set(`article-view-${this.$route.params.id}`, this.isThumb, {maxAge: 60*60*24*360*5})
}
}
}
}
</script>

生成文章目录

编辑pages/article/_id.vue导入组件

<script>
import { dateFormat } from "@/utils/date";
import MxgAffix from "@/components/common/Affix"
import MxgDirectory from "@/components/common/Directory"

export default {
components: {MxgAffix, MxgDirectory}
}
</script>

编辑pages/article/_id.vue编辑模版

    <!-- 右侧-->
<el-col class="hidden-sm-and-down" :md="6">
<el-row >
<el-col>
<!-- 固钉距离80px-->
<mxg-affix :offset="80">
<!-- parentClass 指定文章内容的父元素class值-->
<mxg-directory parent-class="article-content"></mxg-directory>
</mxg-affix>
文章目录
</el-col>
</el-row>
</el-col>

渲染评论数据

pages/article/_id.vue中引入组件

<script>
import { dateFormat } from "@/utils/date";
import MxgAffix from "@/components/common/Affix"
import MxgDirectory from "@/components/common/Directory"
import MxgComment from "@/components/common/Comment"

export default {
components: { MxgAffix,MxgDirectory,MxgComment },
data(){
return {
userId: this.$store.state.userInfo && this.$store.state.userInfo.uid,
userImage: this.$store.state.userInfo && this.$store.state.userInfo.imageUrl,
commentList: []
}
},
methods: {
doSend(content){
console.log(`针对文章ID=${this.$route.params.id} 发布内容:${content}`)
},
doChildSend(content, parentId) {
// 回复评论:父评论ID,评论内容,文章ID,登录用户信息(用户id,用户头像,用户昵称,用户头像)
console.log(`对父评论ID=${parentId} 发布的回复评论内容:${content}`)
},
// 删除评论
doRemove(id) {
console.log(`删除评论id${id}`) },
}
}
</script>

模版中引用

        <!-- 评论区 -->
<div>
<h2>评论区</h2>
<!-- 未登录-->
<el-card v-if="!$store.state.userInfo">
<h4>登录后参与交流、获取续后更新提醒 {{ $store.state.userInfo }}</h4>
<div>
<!-- 不要以/开头 LoginPage-->
<el-button @click="$store.dispatch('LoginPage')" type="primary" size="small">登录</el-button>
</div>
</el-card>
<el-card>
<!-- userId 当前登录用户id,userImage 当前登录用户头像,showComment 显示评论区doSend 公共评论事件函数,doChidSend 回复评论事件函数, doRemove 删除-->
<mxg-comment
:user-id="userId"
:user-image="userImage"
:author-id="data.userId"
:show-comment="$store.state.userInfo ? true : false"
@doSend="doSend" @doChildSend="doChildSend" @doRemove="doRemove"
:comment-list="commentList">

</mxg-comment>
</el-card>
</div>

评论组件事件及属性

props属性

名称 类型 说明 默认值
userId String 当前登录用户id
userImage String 当前登录用户头像
placeholder String 文本框提示内容 写下你的评论…
minRows Number 文本框最小行数 4
maxRows Number 文本框最大行数 8
label String 标签名 作者
commentWidth String 文本框宽度 80%
commentList Array 评论列表 包含内容较多,此处略
showComment Boolean 是否显示评论输入框 true

comment包含字段

名称 类型 说明
id String 评论 id
userId String 评论所属用户ID
nickName String 评论所属用户昵称
userImage String 评论所属用户头像
content String 评论内容
createDate String 评论时间
children Array 子评论列表

事件Event

名称 类型 说明
doSend Event 初始文本框发送事件
参数: 评论内容
doChidSend Event 评论列表中文本框发送事件
参数: 评论内容, 父级评论id
doRemove Event 删除评论

创建MockJS接口

URL method description
/article/api/comment/list/{articleId} get 通过文章ID查询评论

MockJS

{
"code": 20000,
"message": "成功",
"data": [{
"id": "1",
"parentId": "-1",
"userId": "1",
"nickName": "@cname",
"userImage": "",
"articleId": "10",
"content": "很好",
"createDate": "2024-03-10T04:54:23.000+0000",
"children": []
},
{
"id": "2",
"parentId": "-1",
"userId": "2",
"nickName": "@cname",
"userImage": "https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg",
"articleId": "10",
"content": "@csentence",
"createDate": "@datetime",
"children": [{
"id": "3",
"parentId": "2",
"userId": "3",
"nickName": "@cname",
"userImage": null,
"articleId": "10",
"content": "@csentence",
"createDate": "@datetime",
"children": [{
"id": "5",
"parentId": "3",
"userId": "4",
"nickName": "@cname",
"userImage": null,
"articleId": "10",
"content": "@csentence",
"createDate": "@datetime",
"children": []
}]
},
{
"id": "4",
"parentId": "2",
"userId": "4",
"nickName": "@cname",
"userImage": null,
"articleId": "10",
"content": "@csentence",
"createDate": "@datetime",
"children": []
}
]
}
]
}

api\article.js添加getCommentListByArticleId方法,查询文章的所有评论信息

//   通过文章ID查询所有评论数据接口
inject('getCommentListByArticleId', articleId => $axios.$get(`/article/api/comment/list/${articleId}`))

调用API接口获取数据

<script>

export default {
components: { MxgAffix,MxgDirectory,MxgComment },
data(){
return {
// commentList: []
}
},
// 获取数据
async asyncData({params, app}){
// 查询文章详情
const { data } = await app.$getArticleById(params.id)
// 更新浏览量,判断cookie中是否存在
const isView = app.$cookies.get(`article-view-${data.id}`)
if(!isView){
const { code } = await app.$updateArticleViewCount(data.id)
if(code === 20000){
data.viewCount++
}
app.$cookies.set(`article-view-${data.id}`, true)
}
// 通过文章ID查询所有评论信息
const { data: commentList } = await app.$getCommentListByArticleId(data.id)
return { data, commentList} //等价于{data: data}
}
}
</script>

提交与删除评论信息

创建新增文章评论接口

URL method description MockJS
/article/comment post 新增文章评论接口 {"code":20000,'message':"新增成功"}

创建删除文章评论接口

URL method description MockJS
/article/comment/{id} delete 删除文章评论接口 {"code":20000,'message':"删除成功"}

api\article.js添加addCommentdeleteCommentById方法,新增和删除文章的所有评论信息

//   新增文章评论
inject('createComment', data => $axios.post(`/article/comment`, data))
// 删除文章评论
inject('deleteCommentById', id => $axios.delete(`article/comment/${id}`))

评论组件调用API,编辑pages/article/_id.vue

<script>
import { dateFormat } from "@/utils/date";
import MxgAffix from "@/components/common/Affix"
import MxgDirectory from "@/components/common/Directory"
import MxgComment from "@/components/common/Comment"

export default {
methods: {
doSend(content){
this.doChildSend(content)
},
doChildSend(content, parentId) {
// 回复评论:父评论ID,评论内容,文章ID,登录用户信息(用户id,用户头像,用户昵称,用户头像)
const data = {
content,
parentId,
articleId: this.$route.params.id,
userId: this.userId,
userImage: this.userImage,
nickName: this.$store.state.userInfo && this.$store.state.userInfo.nickName
}
// 提交
this.$createComment(data).then(response =>{
// 刷新评论
this.refreshComment()
})
},
// 获取评论
async refreshComment(){
const { data } = await this.$getCommentListByArticleId(this.$route.params.id)
this.commentList = data
},
// 删除评论
async doRemove(id) {
const { code } = await this.$deleteCommentById(id)
if(code ===20000){
this.refreshComment()
}
}
}
}
</script>

新增与修改文章

新增文章

标签输入框,我们使用了ElementUI中的<el-cascader>级联选择器

  • style="display: block"输入框宽度全屏
  • :show-all-levels="false"选择后,输入框中仅显示最后一级
  • clearable有清空按钮
  • :filterable="true"可搜索
  • v-model选中的值
  • :options下拉渲染的数组
  • :props参数
属性名 描述 类型 默认值
emitPath 在选中节点改变时,是否返回各级菜单值的数组 boolean true
multiple 是否多选 boolean -
value 指定选项的值为选项对象的某个属性值 string ‘value’
label 指定选项标签为选项对象的某个属性值 string ‘label’
children 指定选项的子选项为选项对象的某个属性值 string ‘children’

创建新增和修改的组件,pages/article/edit.vue,参考:edit.vue
重构头部组件components/layout/Header.vue

<script>
export default {
methods: {
handleCommand(command){
switch (command){
case 'article':
// 打开窗口
let routeData = this.$router.resolve('/article/edit')
window.open(routeData.href, '_blank')
break
}

}
}
}
</script>

分类和标签多级选择器实现

创建MockJS接口

URL method description
/article/api/category/label/list get 获取所有正常状态的分类和标签

MockJS

{
"code": 20000,
"message": "查询成功",
"data|5": [{ // 5个分类
"id|+1": 1, // 分类id, 初始值1开始,每条+1 "name": "@cname", // 分类名称
"name": "@word",
"labelList|3": [{ // 分类下的有3个标签"id|+1": 10, // 标签id
"id|+1": 10,
"name": '@word' //标签名
}]
}]
}

api/article.js添加调用接口代码如下

//   获取标签
inject('getCategoryAndLabel', () => $axios.get(`article/api/category/label/list`))

编辑pages/article/edit.vue

<template>
<div>
<!-- ref 表示在vue中引用表单可以使用this.$refs.xxxx来引用表单数据-->
<el-form ref="formData" :model="formData" label-width="100px" label-position="right">
<el-form-item label="标签:" prop="labelIds">
<el-cascader
style="display: block"
:options="labelOptions"
:props="{ multiple: true, emitPath: false, children: 'labelList', value: 'id', label: 'name' }"
:show-all-levels="false"
:filterable="true"
clearable
v-model="formData.labelIds"
:disabled="disabled"></el-cascader>
</el-form-item>
</el-form>
</div>
</template>
<script>
export default {
data(){
return {
formData: { labelIds: 1},
disabled: false, //标签输入框默认可输入
labelOptions: [],
}
},
async asyncData({ query, app }){
const { data } = await app.$getCategoryAndLabel()
return { labelOptions: data.data }
}
}
</script>

上传图片

使用elementui upload组件,参考链接:https://element.eleme.io/#/zh-CN/component/upload
编辑pages/article/edit.vue

<template>
<div>
<!-- ref 表示在vue中引用表单可以使用this.$refs.xxxx来引用表单数据-->
<el-form ref="formData" :model="formData" label-width="100px" label-position="right">
<el-form-item label="主图" prop="imageUrl">
<el-upload class="avator-upload"
accept="image/*"
action=""
:show-file-list="false"
:http-request="uploadMainImg">
<img v-if="formData.imageUrl" :src="formData.imageUrl" class="avatar">
<i v-else class="el-icon-plus avatar-upload-icon"/>
</el-upload>
</el-form-item>
</el-form>
</div>
</template>
<script>
export default {
methods: {
uploadMainImg(){

}
}
}
</script>
<style scoped>
/*.avator-upload在.el-upload前面代表.avator-upload样式优先*/
.avator-upload .el-upload{
/*dashed 虚线*/
border: 1px dashed #d9d9d9;
/*圆角*/
border-radius: 6px;
/*鼠标悬停 鼠标样式*/
cursor: pointer;
/*定位方式为相对定位*/
position: relative;
/*如果元素超过样式超过部分隐藏*/
overflow: hidden;
}
.avator-upload .el-upload:hover{
border-color: #409EFF;
}
.avatar-upload-icon{
font-size: 28px;
color: #8c939d;
width: 178px;
height: 178px;
line-height: 178px;
text-align: center;
}
.avatar{
width: 178px;
height: 178px;
display: block;
}
</style>

创建上传图片接口

URL method description
/article/file/upload post 上传图片接口

MockJS

{
"code": 20000,
"message": "上传成功",
"data": "https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg"
}

创建删除图片接口

URL method description MockJS
/artitle/file/delete delete 删除图片接口 {"code":20000,'message':"删除成功"}

创建api/common.js

import {inject} from "vue";

export default ({ $axios }, inject) =>{
// 上传图片
inject('uploadImg', data => $axios.post(`/article/file/upload`, data))
// 删除图片
inject('deleteImg', fileUrl => $axios.delete(`/article/file/delete`, {params: {fileUrl}}))
}

编辑nuxt.config.js引用api/common.js

export default {
plugins: [
'@/api/common'
],
}

编辑pages/article/edit.vue在组件中调用API接口

<script>
export default {
methods: {
uploadMainImg(file){
const data = file
console.log('file', file)
console.log('data', data)
this.$uploadImg(data).then(response =>{
this.deleteImg()

this.formData.imageUrl = response.data
console.log('response.data', response.data)
}).catch(error =>{
this.$message.error("上传图片失败", error)
})
},
deleteImg(){
console.log('imageUrl', imageUrl)
if(this.formData.imageUrl){
console.log('formData.imageUrl', this.formData.imageUrl)
this.$deleteImg(this.formData.imageUrl)
}
}
}
}
</script>

MarkDown编辑器编辑文章

项目地址:https://github.com/hinesboy/mavonEditor
安装md编辑器

npm install mavon-editor --save

以插件方式引入md编辑器,创建plugins/mavon-edit.js

import Vue from "vue";
import mavonEditor from "mavon-editor"

Vue.use(mavonEditor)

在全局配置文件nuxt.config.js中引入插件和css样式

export default {
// Global CSS: https://go.nuxtjs.dev/config-css
css: [
// md编辑器
'mavon-editor/dist/css/index.css'
],

// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
plugins: [
// 客户端渲染
{src: '@/plugins/mavon-editor', mode: client}
],
}

编辑pages/article/edit.vue使用md编辑器

<template>
<div>
<!-- ref 表示在vue中引用表单可以使用this.$refs.xxxx来引用表单数据-->
<el-form ref="formData" :model="formData" label-width="100px" label-position="right">
<el-form-item label="内容" prop="content">
<!-- 主题内容-->
<mavon-editor :autofocus="false" ref="md" v-model="formData.mdContent" @change="getMdHtml"
@imgAdd="uploadContentImg" @imgDel="delContentImg"/>
</el-form-item>
</el-form>
</div>
</template>
<script>
export default {

methods: {
getMdHtml(mdContent, htmlContent){
// mdContent: md 内容,htmlContent:转成后的html
console.log('mdContent', mdContent)
console.log('htmlContent', htmlContent)
this.formData.mdContent = mdContent
this.formData.htmlContent = htmlContent
},
uploadContentImg(pos,file){
console.log('上传文件图片')
},
delContentImg(urlAndFileArr){
const fileUrl = urlAndFileArr[0]
const file = urlAndFileArr[1]
console.log("删除文章图片", fileUrl, file)
}

}
}
</script>

上传删除文章图片

重构pages/article/edit.vue中的uploadContentImgdelContentImg方法

<script>
export default {
methods: {
uploadContentImg(pos,file){
console.log('上传文件图片')
const fd = new FormData()
fd.append('file', file)
this.$uploadImg(fd).then(response =>{
console.log('response', response)
this.$refs.md.$img2Url(pos,response.data.data)
})
},
delContentImg(urlAndFileArr){
const fileUrl = urlAndFileArr[0]
const file = urlAndFileArr[1]
console.log("删除文章图片", fileUrl, file)
this.$deleteImg(fileUrl)
}

}
}
</script>

表单数据校验

edit.vue组件的el-form上绑定属性:rules="rules"

<template>
<div>
<!-- ref 表示在vue中引用表单可以使用this.$refs.xxxx来引用表单数据-->
<el-form :rules="rules"></el-form>
</div>
</template>

data选项中添加rules属性进行校验,在return关键字上面加上自定义校验器,标签最多可以选择5个,超过5个提示和禁止禁用标签框this.disabled = true

<script>
export default {
data(){
const validateLabel = (rule, value, callback) =>{
if(value && value.length > 5){
this.disabled = true
callback(new Error('最多选择5个标签'))
}else {
callback()
this.disabled = false
}
}
const validateContent = (rule, value, callback) => {
if(this.formData.mdContent && this.formData.htmlContent){
// 放行
callback()
}else {
// 阻塞
callback(new Error('请输入文章内容'))
}
}
return {
rules: {
title: [{ required: true, message: '请输入标题', trigger: 'blur'}],
labelIds: [{ required: true, message: '请选择标签', trigger: 'blur' },{ validator: validateLabel, trigger: 'change' }],
isPublic: [{ required: true, message: '请选择是否公开', trigger: 'change' }],
summary: [{ required: true, message: '请输入简介', trigger: 'blur' }],
content: [{ validator: validateContent, trigger: 'blur' }]
},
}
},
methods: {
subitForm(formName){
this.$refs[formName].validate((valid) =>{
if(valid){
// 校验通过提交数据
this.submitData()
}else {
// 校验不通过
return false
}
})
},
submitData(){

},

}
}
</script>

提交表单数据

新增MockJS接口

URL method description
/article/article post 提交文章数据
{
"code": 20000,
"message": "新增成功",
"data": "10" // 新增的文章id
}

api\article.js添加调用新增接口的方法

//   提交文章数据
inject('addArticle', data => $axios.post(`article/article`, data))

pages\article\edit.vue中的submitData异步方法提交数据

<script>
export default {
methods: {
async submitData(){
const response = await this.$addArticle(this.formData)
console.log('response', response)
if (response.data.code === 20000){
this.$message.success('提交成功')
// 跳转到详情页, data封装的就是保存后的文章id
this.$router.push(`/article/${response.data.data}`)
}
},

}
}
</script>

文章修改

文章详情页添加编辑按钮

pages/article/_id.vue添加编辑按钮,只能编辑自己的,不是自己的则隐藏编辑按钮

<template>
<div>
<el-row type="flex">
<el-col :xs="24" :sm="24" :md="18">
<div class="article-left">
<el-card>
<!-- 标题-->
<div class="article-title">
<div class="article-count">
<!-- 文章编辑功能-->
<nuxt-link v-if="this.$store.state.userInfo && this.$store.state.userInfo.uid === data.userId"
:to="{path: '/article/edit', query:{id: data.id}}" class="nickname">
&nbsp;&nbsp;编辑
</nuxt-link>
</div>
</div>
</el-card>
</div>
</el-col>
</el-row>
</div>
</template>

修改页面回显文章信息

pages/article/edit.vue添加validate方法判断存在id则为修改,校验参数是否有效。asyncData方法中添加查询文章详情,注意方法上传递{query},即查询详情回显数据。

<script>
export default {
validate({query}){
if(query.id){
// 必须是number类型
return /^\d+$/.test(query.id)
}
return true
},
async asyncData({ query, app }){
const { data } = await app.$getCategoryAndLabel()
if(query.id){
const { data: formData } = await app.$getArticleById(query.id)
return { labelOptions: data.data, formData }
}
return { labelOptions: data.data }
},
}
</script>

创建MockJS接口

URL method description
/article/article put 更新文章接口

MockJS

{
"code": 20000,
"message": "修改成功",
"data": "10" // 修改的文章id
}

api\article.js添加更新方法updateArticle

//   更新文章数据
inject('updateArticle', data => $axios.$put( `/article/article`, data ) )

pages/article/edit.vue组件中的submitData方法加上一个判断this.formData.id存在则为更新,否则是新增操作。

<script>
export default {
methods: {
async submitData(){
if(this.formData.id){
const response = await this.$updateArticle(this.formData)
}else {
const response = await this.$addArticle(this.formData)
}
console.log('response', response)
if (response.data.code === 20000){
this.$message.success('提交成功')
// 跳转到详情页, data封装的就是保存后的文章id
this.$router.push(`/article/${response.data.data}`)
}
}
}
}
</script>

问答列表页面

使用 el-tabs 标签页组件,不同选项卡展示不同类型的问答列表:热门回答、最新问答、等待回答。参考 Tabs 标签页:https://element.eleme.cn/#/zh-CN/component/tabs

问答标签页模版

创建pages/question/index.vue文件,使用el-tabs组件实现点击不同标签展示不同数据

<template>
<el-tabs v-model="hot" @tab-click="handleClick">
<el-tab-pane label="热门回答" name="first">hot</el-tab-pane>
<el-tab-pane label="最新回答" name="second">new</el-tab-pane>
<el-tab-pane label="等待回答" name="third">wait</el-tab-pane>
</el-tabs>
</template>
<script>
export default {
data(){
return {
hot: null
}
},
methods: {
handleClick(tab, event) {
console.log(tab, event);
}
}
};
</script>

问答列表子组件

每个标签页下展示的列表效果都是一样的,只是展示不同类型的数据而已。因此我们创建一个列表子组件List.vue引入到标签体中,通过点击不同的标签查询不同的数据,然后传递给列表子组件进行渲染。自组件内容参考:https://gitee.com/acaiblog/nuxt-vue-blog/raw/master/components/question/List.vue
编辑pages/question/index.vue引用自组件

<template>
<div class="question-container">
<el-row>
<el-tabs v-model="hot" @tab-click="handleClick">
<el-tab-pane label="热门回答" name="hot"><List/></el-tab-pane>
<el-tab-pane label="最新回答" name="new"><List/></el-tab-pane>
<el-tab-pane label="等待回答" name="wait"><List/></el-tab-pane>
</el-tabs>
</el-row>
</div>
</template>
<script>
import List from "@/components/question/List.vue";
export default {
components: {List},
data(){
return {
// 默认选中热门回答
hot: 'hot'
}
}
};
</script>

创建MockJS接口

创建热门问答MockJS接口

URL method description
/question/api/question/hot post 热门问答查询接口

MockJS

{
"code": 20000,
"message": "查询成功",
"data": {
"total": "@integer(100, 200)", // 总记录数
"records|20": [{
"id|+1": 10, //初始值10开始,每条+1
"userId": "@integer(10, 30)",
"nickName": "@cname",
"userImage": "@image",
"title": "@csentence", // 标题
"viewCount": "@integer(5, 300)", // 浏览数"thumhup": "@integer(2, 20)", // 点赞数"reply": "@integer(1, 10)", // 回复数
"status|1": [1, 2], // 1:未解决,2:已解决"createDate": "@datetime",
"updateDate": "@datetime"
}]
}
}

创建最新问答MockJS接口

URL method description
/question/api/question/new post 最新问答查询接口

MockJS

{
"code": 20000,
"message": "查询成功",
"data": {
"total": "@integer(100, 200)", // 总记录数
"records|20": [{
"id|+1": 10, //初始值10开始,每条+1
"userId": "@integer(10, 30)",
"nickName": "@cname",
"userImage": "@image",
"title": "@csentence", // 标题
"viewCount": "@integer(5, 300)", // 浏览数"thumhup": "@integer(2, 20)", // 点赞数"reply": "@integer(1, 10)", // 回复数
"status|1": [1, 2], // 1:未解决,2:已解决
"createDate": "@datetime",
"updateDate": "@datetime"
}]
}
}

新建等待问答MockJS接口

URL method description
question/api/question/wait post 等待问答查询接口

MockJS

{
"code": 20000,
"message": "查询成功",
"data": {
"total": "@integer(100, 200)", // 总记录数
"records|20": [{
"id|+1": 10, //初始值10开始,每条+1
"userId": "@integer(10, 30)",
"nickName": "@cname",
"userImage": "@image",
"title": "@csentence", // 标题
"viewCount": "@integer(5, 300)", // 浏览数"thumhup": "@integer(2, 20)", // 点赞数"reply": 0, // 回复数
"status": 1, // 1:未解决,2:已解决
"createDate": "@datetime",
"updateDate": "@datetime"
}]
}
}

创建api\question.js添加调用接口的方法

import {inject} from "vue/src/v3";

export default ({ $axios}, inject) =>{
// 热门问答
inject('getHotList', page => $axios.post(`/question/api/question/hot`, page))
// 最新问答
inject('getNewList', page => $axios.post(`/question/api/question/new`, page))
// 等待问答
inject('getWaitList', page => $axios.post(`/question/api/question/wait`, page))
}

nuxt.config.js中以插件方式引入question api

plugins: [
'@/api/question',
]

渲染热门问答数据

首次加载问答页面默认是查询热门问答列表 ,即在nuxt.js中的asyncData方法查询热门数据。编辑pages/question/index.vue

<script>
export default {
async asyncData({app}){
const page = {
total: 0,
current: 1,
size: 20
}
// 查询热门问答
const { data } = await app.$getHotList(page)
page.total = data.data.total
return { page, listData: data.data.records }
}
};
</script>

向模板中引用的子组件List传递props和绑定fetch-data事件函数,编辑pages/question/index.vue

<template>
<div class="question-container">
<el-row>
<el-tabs v-model="hot" @tab-click="handleClick">
<el-tab-pane label="热门回答" name="hot">
<List name="hot" :page="page" :listData="listData" @fetch-data="fetchData"/>
</el-tab-pane>
</el-tabs>
</el-row>
</div>
</template>
<script>
import List from "@/components/question/List.vue";
export default {
components: {List},
data(){
return {
// 默认选中热门回答
hot: 'hot'
}
},
async asyncData({app}){
const page = {
total: 0,
current: 1,
size: 20
}
// 查询热门问答
const { data } = await app.$getHotList(page)
page.total = data.data.total
console.log('listData', data.data.records)
return { page, listData: data.data.records }
},
methods: {
handleClick(tab, event) {
console.log(tab, event);
},
fetchData(){

}
}
};
</script>

渲染最新、等待问答和问答分页

分页、切换标签功能实现

当点击分页页码时,会触发fetchData方法。切换标签页会触发handleClick方法,编辑pages/question/index.vue

<script>
export default {
methods: {
handleClick(tab, event) {
console.log(tab, event);
},
async fetchData(pageName, current){
// 接受两个参数标签名称和页码
this.page.current = current
let response = null
if(pageName === 'hot'){
console.log(pageName, current)
response = await this.$getHotList(this.page)
}else if (pageName === 'new'){
response = await this.$getNewList(this.page)
}else if (pageName === 'wait'){
response = await this.$getWaitList(this.page)
}
if (response && response.code === 20000){
this.page.total = response.data.total
this.listData = response.data.records
}
}
}
};
</script>

问答详情页

创建MockJS接口

创建问答详情接口

URL method description
/question/api/question/{id} get 问答详情页面

MockJS

{
"code": 20000,
"message": "成功",
"data": {
"id": "10",
"userId": "1",
"nickName": "@cname",
"userImage": "https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif",
"title": "@csentence", // 标题
"viewCount": "@integer(0,100)",
"thumhup": "@integer(0,10)",
"reply": "@integer(0,3)",
"status|1": [1, 2], //1:未解决,2:已解决
"createDate": "@datetime",
"updateDate": "@datetime", // 详情显示的时间
"labelIds|1-5": ['@integer(10, 24)'], //随机产生1到5个元素的数字数组,数字取值10到24间
"labelList|3": [{ // 所属标签集合
"id|+1": 10,
"name": "@word" // 标签名
}],
"mdContent": '参考官网配置:[https://cli.vuejs.org/zh/guid...](https://cli.vuejs.org/zh/guide/html-and-static-assets.html#public-文件夹)需要设置`BASE_URL````jsdata () { return { publicPath: process.env.BASE_URL }}```然后```html<img :src="`${publicPath}my-image.png`">```',

"htmlContent": '<p>参考官网配置:<br /><a href="https://cli.vuejs.org/zh/guide/html-and-static-assets.html#public-%E6%96%87%E4%BB%B6%E5%A4%B9" target="_blank">https://cli.vuejs.org/zh/guid…</a></p><p>需要设置<code>BASE_URL</code></p><pre><div class="hljs"><code class="lang-js">data () { <span class="hljs-keyword">return</span> { <span class="hljs-attr">publicPath</span>: process.env.BASE_URL }}</code></div></pre><p>然后</p><pre><div class="hljs"><code class="lang-html"><span class="hljs-tag">&lt;<span class="hljs-name">img</span> <span class="hljs-attr">:src</span>=<span class="hljs-string">"`${publicPath}my-image.png`"</span>&gt;</span></code></div></pre>'
}
}

创建更新浏览接口

URL method description
/question/api/question/viewCount/{id} put 更新浏览量接口

MockJS

{
"code": 20000,
"message": "成功"
}

通过问题ID查询所有问答接口

URL method description
/question/api/replay/list/{questionId} get 通过问题ID查询所有问答接口

MockJS

{
"code": 20000,
"message": "成功",
"data": [{
"id": "1",
"parentId": "-1",
"userId": "1",
"nickName": "@cname",
"userImage": "@image",
"questionId": "10",
"mdContent": '改成相对路径试试```shpublicPath:"./"```',
"htmlContent": '<p>改成相对路径试试</p><pre><div class="hljs"><code class="lang-sh">publicPath:<span class="hljs-string">"./"</span></code></div></pre>',
"createDate": "@datetime",
"children": []
},
{
"id": "2",
"parentId": "-1",
"userId": "2",
"nickName": "@cname",
"userImage": "@image",
"questionId": "10",
"mdContent": "==特别强调==",
"htmlContent": "<p><mark>特别强调</mark></p>",
"createDate": "@datetime",
"children": [{
"id": "3",
"parentId": "2",
"userId": "3",
"nickName": "@cname",
"userImage": "@image",
"questionId": "10",
"mdContent": "@csentence",
"htmlContent": "@csentence",
"createDate": "@datetime",
"children": [{
"id": "4",
"parentId": "3",
"userId": "5",
"nickName": "@cname",
"userImage": "@image",
"questionId": "10",
"mdContent": "@csentence",
"htmlContent": "@csentence",
"createDate": "@datetime",
"children": []
}]
}]
},
{
"id": "5",
"parentId": "-1",
"userId": "6",
"nickName": "@cname",
"userImage": "@image",
"questionId": "10",
"mdContent": "需要设置`BASE_URL`",
"htmlContent": "<p>需要设置<code>BASE_URL</code></p>",
"createDate": "@datetime",
"children": []
}
]
}

创建更新问题点赞数量接口

URL method description
/question/question/thumb/{questionId}/{count} put 创建更新问题点赞数量接口

MockJS

{
"code": 20000,
'message': "成功"
}

创建新增回答数据接口

URL method description
question/replay post 创建新增回答数据接口

MockJS

{
"code": 20000,
'message': "新增成功"
}

创建删除回答数据接口

URL method description
question/replay/{id} delete 删除回答数据接口

MockJS

{
"code": 20000,
'message': "删除成功"
}

API接口调用

编辑api/questions.js

// 查询问题详情 
inject('getQuestionById', id => $axios.$get(`/question/api/question/${id}`))

// 更新浏览数
inject('updateQuestionViewCount', id => $axios.$put(`/question/api/question/viewCount/${id}`))

// 通过问题id查询回复数据接口
inject('getReplayByQuestionById', questionId => $axios.$get(`/question/api/replay/list/${questionId}`))

// 更新点赞数
inject('updateQuestionThumb', (questionId,count) => $axios.$put(`/question/question/thumb/${questionId}/${count}`))

// 新增问题回答内容
inject('addReplay', data => $axios.$post(`/question/replay`, data))

// 删除回答内容, id 是回答内容的id
inject('deleteReplayById', id => $axios.$delete(`/question/replay/${id}`))

// 新增问题
inject('addQuestion', data => $axios.$post(`/question/question`, data))

// 修改问题
inject('updateQuestion', data => $axios.$put(`/question/question`, data))

// 通过标签id查询问题列表
inject('getQuestionByLableId', (page, labelId) => $axios.$post(`/question/api/question/list/${labelId}`, page))

回答详情数据渲染

直接复制文章详情页组件pages/article/_id.vuepages/question/_id.vue作为问答详情组件
编辑pages/question/_id.vue

<script>
export default {
// 获取数据
async asyncData({params, app}){
const { data } = await app.$getQuestionById(params.id)
// 更新浏览量,判断cookie中是否存在
const isView = app.$cookies.get(`question-view-${data.id}`)
if(!isView){
const { code } = await app.$updateQuestionViewCount(data.id)
if(code === 20000){
data.viewCount++
}
app.$cookies.set(`question-view-${data.id}`, true)
}
// 通过问题ID查询所有回复数据接口
const { data: commentList } = await app.$getReplayByQuestionById(data.id)
return { data, commentList} //等价于{data: data}
},
}
</script>

修改模版,将编辑按钮跳转路径修改为/question/edit

<nuxt-link v-if="this.$store.state.userInfo && this.$store.state.userInfo.uid === data.userId"
:to="{path: '/question/edit', query:{id: data.id}}" class="nickname">
&nbsp;&nbsp;编辑
</nuxt-link>

将之前评论区修改为精彩回答循环出每个问答对象,转成一个个数组 [comment],评论没有子评论的情况下一样可以循环出评论当前评论。 mxg-comment组件上加上:showComment="false"不显示评论输入框

<template>
<!-- 回答区 -->
<div>
<h2>精彩回答</h2>
<!-- 暂无回答-->
<el-card v-if="!commentList || commentList.length===0">暂无回答</el-card>
<el-card>
<!-- userId 当前登录用户id,userImage 当前登录用户头像,doSend 公共评论事件函数,doChidSend 回复评论事件函数, doRemove 删除-->
<mxg-comment
:user-id="userId"
:user-image="userImage"
:author-id="data.userId"
:show-comment="false"
@doChildSend="doChildSend" @doRemove="doRemove"
:comment-list="[comment]">

</mxg-comment>
</el-card>
</div>
</template>
<script>
export default {
data(){
return{
commentList:[]
}
}
}
</script>

data中获取cookiekey更改question-,调用点赞接口,发送问答子评论接口和删除接口

<script>
export default {
// 获取数据
async asyncData({params, app}){
const { data } = await app.$getQuestionById(params.id)
// 更新浏览量,判断cookie中是否存在
const isView = app.$cookies.get(`question-view-${data.id}`)
if(!isView){
const { code } = await app.$updateQuestionViewCount(data.id)
if(code === 20000){
data.viewCount++
}
app.$cookies.set(`question-view-${data.id}`, true)
}
// 通过问题ID查询所有回复数据接口
const { data: commentList } = await app.$getReplayByQuestionById(data.id)
return { data, commentList} //等价于{data: data}
},
methods: {
async handleThumb(){
// 点击后取反
this.isThumb = !this.isThumb
// 1 点赞 -1 取消
const count = this.isThumb ? 1: -1
// 获取当前文章id
const questionId = this.$route.params.id
// 更新点赞数
const { code } = await this.$updateQuestionThumb(questionId, count)
if(code === 20000){
this.data.thumhub = this.data.thumhub + count
// 保存到cookie 保存5年
this.$cookies.set(`question-view-${this.$route.params.id}`, this.isThumb, {maxAge: 60*60*24*360*5})
}
},
doChildSend(content, parentId) {
// 回复评论:父评论ID,评论内容,文章ID,登录用户信息(用户id,用户头像,用户昵称,用户头像)
const data = {
content,
parentId,
questionId: this.$route.params.id,
userId: this.userId,
userImage: this.userImage,
nickName: this.$store.state.userInfo && this.$store.state.userInfo.nickName
}
// 提交
this.$addReplay(data).then(response =>{
// 刷新评论
this.refreshReplay()
})
},
// 获取评论
async refreshReplay(){
const { data } = await this.$getReplayByQuestionById(this.$route.params.id)
this.commentList = data
},
// 删除评论
async doRemove(id) {
const { code } = await this.$deleteReplayById(id)
if(code ===20000){
this.refreshReplay()
}
}
}
}
</script>

回答组件实现

在精彩回答下面添加一个编写回答 ,引用的是mavon-editor组件

<template>
<!-- 编写回答-->
<div>
<h2>编写回答</h2>
<!-- 未登录-->
<el-card v-if="!$store.state.userInfo">
<h4>登录后参与交流,获取后续更新提醒</h4>
<div>
<el-button @click="$store.dispatch('LoginPage')" type="primary" size="small">登录</el-button>
<el-button @click="$store.dispatch('LoginPage')" type="primary" size="small">注册</el-button>
</div>
</el-card>
<div v-else>
<!-- 主体内容,使用 mavon-editor 渲染出markdown编辑器:autofocus="false" 输入框不自动获取焦点-->
<mavon-editor :autofocus="false" ref="md" v-model="mdContent" @change="getMdHtml"
@imgAdd="uploadContentImg" @imgDel="deleteContentImg"/>
<el-row style="margin-top: 20px" type="flex" justify="center">
<el-button type="primary" @click="submitReplay">提交问答</el-button>
</el-row>
</div>
</div>
</template>
<script>
export default {
data(){
return{
mdContent: '',
htmlContent: ''
}
}
}
</script>

pages/question/_id.vue导入api/common.js,getMdHtml方法赋值内容、uploadContentImg上传内容图片、delContentImg删除内容图片、submitReplay提交回答内容

<script>
import commonApi from "@/api/common"
import api from "@/api/questions"

export default {
methods: {
// 获取回答内容 mdContent: md 内容,htmlContent:转成后的html
getMdHtml(mdContent, htmlContent) {
this.mdContent = mdContent
this.htmlContent = htmlContent
},
// 上传内容图片 (图片位置编号, File对象)
uploadContentImg(pos, file) {
// 第一步.将图片上传到服务器
var fd = new FormData()
fd.append('file', file)
this.$uploadImg(fd).then(response =>{
console.log('response', response.data.data)
// 第二步.将返回的url替换到文本原位置! [...](0) -> ![...](url)
this.$refs.md.$img2Url(pos, response.data.data)
}).catch(error =>{
this.$refs.md.$img2Url(pos, '图片上传失败')
})
},
// 删除图片
deleteContentImg(urlAndFileArrar){
const fileUrl = urlAndFileArrar[0]
const file = urlAndFileArrar[1]
console.log('删除图片', fileUrl, file)
commonApi.deleteImg(fileUrl)
},
// 提交回答
submitReplay(){
if(this.htmlContent){
this.doChildSend(this.htmlContent, -1, this.mdContent)
}else {
this.$message.error('请输入回答内容')
}
},
doChildSend(content, parentId) {
// 回复评论:父评论ID,评论内容,文章ID,登录用户信息(用户id,用户头像,用户昵称,用户头像)
const data = {
content,
parentId,
questionId: this.$route.params.id,
userId: this.userId,
userImage: this.userImage,
nickName: this.$store.state.userInfo && this.$store.state.userInfo.nickName
}
// 提交
api.addReplay(data).then(response =>{
// 清空。
this.mdContent = ''
this.htmlContent = ''
this.refreshReplay()
})
},
}
}
</script>

问答新增与删除

创建MockJS接口

创建新增问答接口

URL method description
question/question post 新增问答接口

MockJS

{
"code": 20000,
"message": "新增成功",
"data": "10" // 新增的问题id
}

创建更新问答接口

URL method description
question/question put 更新问答接口

MockJS

{
"code": 20000,
"message": "修改成功",
"data": "11" // 修改的问题id
}

API接口调用

编辑api/questions.js

// 新增问题 
inject('addQuestion', data => $axios.$post( `/question/question`, data ) )
// 更新问题
inject('updateQuestion', data => $axios.$put( `/question/question`, data ) )

新增更新问答模版渲染

复制pages/article/edit.vue组件到pages/question/edit.vue删除pages/question/edit.vue模板中的:上传主图、是否公开、简介
修改data选项中的rules校验规则,把是否公开ispublic、简介summary校验规则删除,formData: {}不用写imageUrl

<script>
export default {
data(){
return {
rules: {
title: [{ required: true, message: '请输入标题', trigger: 'blur'}],
labelIds: [{ required: true, message: '请选择标签', trigger: 'blur' },{ validator: validateLabel, trigger: 'change' }],
content: [{ validator: validateContent, trigger: 'blur' }]
},
formData: {},
}
}
}
</script>

修改asyncData方法,查询问题详情查询分类与标签查询文章详情改为查询问题详情

async asyncData({ query, app }){
const { data } = await app.$getCategoryAndLabel()
if(query.id){
const { data: formData } = await app.$getQuestionById(query.id)
return { labelOptions: data.data, formData }
}
return { labelOptions: data.data }
}

重构头部导航组件components/layout/Header.vue,点击提问题 新窗口方式进入到/question/edit.vue

switch (command){
case 'article':
// 打开窗口
let routeData = this.$router.resolve('/article/edit')
window.open(routeData.href, '_blank')
break
case 'question':
routeData = this.$router.resolve('/question/edit')
window.open(routeData.href, '_blank')
break
case 'logout':
this.$cookies.remove('accessToken')
this.$cookies.remove('userInfo')
this.$cookies.remove('refreshToken')
this.$store.dispatch('UserLogout')
break
default:
break
}

检查pages/question/_id.vue问题详情组件中的编辑按钮的路由是否指定为/question/edit

<nuxt-link v-if="this.$store.state.userInfo && this.$store.state.userInfo.uid === data.userId"
:to="{path: '/question/edit', query:{id: data.id}}" class="nickname">

提交数据

修改提交数据方法submitData,编辑pages/question/edit.vue

async submitData(){
let response = null
if(this.formData.id){
const response = await this.$updateQuestion(this.formData)
}else {
const response = await this.$addQuestion(this.formData)
}
console.log('response', response)
if (response.data.code === 20000){
this.$message.success('提交成功')
// 跳转到详情页, data封装的就是保存后的文章id
this.$router.push(`/article/${response.data.data}`)
}
}

标签渲染文章与问答

标签列表页面

创建pages/label/index.vue,参考链接:https://gitee.com/acaiblog/nuxt-vue-blog/raw/master/pages/label/index.vue
核实头部组件components/layout/Header.vue中针对标签是否指定了路由地址

<el-menu mode="horizontal" router default-active="/" active-text-color="#345dc2" background-color="#fafafa">
<el-menu-item index="/">博客</el-menu-item>
<el-menu-item index="/question">问答</el-menu-item>
<el-menu-item index="/label">标签</el-menu-item>
</el-menu>

动态加载标签页面数据

调用查询所有分类名以及分类下所有标签的数据接口,编辑pages/label/index.vue

async asyncData({app}) {
// 查询分类和标签
const {data} = await app.$getCategoryAndLabel()
console.log('data', data)
return {data: data.data}
}

模板动态渲染数据,编辑pages/label/index.vue

<template>
<div class="label-main">
<el-row :gutter="10">
<el-col v-for="(category,index) in data" :key="index" :xs="24" :sm="24" :md="6">
<el-card shadow="hover">
<div slot="header" class="clearfix">
<!-- 分类名 -->
<span>{{category.name}}</span>
</div>
<!-- 分类下的标签 -->
<div >
<nuxt-link v-for="label in category.labelList" :key="label.id" :to="{path: `/label/${label.id}`, query: {name: label.name}}">
<el-tag size="small">
{{label.name}}
</el-tag>
</nuxt-link>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>

动态高亮显示菜单

编辑components/layout/Header.vue

<script>
import { mapState } from 'vuex';
export default {
computed: {
userInfo() {
return this.$store.state.userInfo
},
defaultActive() {
// 是否存在多级自路由
let routePath = this.$route.matched[0].path || '/'
// 从第2个字符开始往后找是否存在 /
if (routePath.indexOf('/', 1) !== -1) {
// 有子路由,截取一级路由地址
routePath = routePath.substring(0, routePath.indexOf('/', 1))
}
// /article 也转成 / 高亮 `博客`
return routePath.indexOf('/article') !== -1 ? '/' : routePath
},
}
}
</script>

导航处引用计算属性:default-active="defaultActive"

<el-menu mode="horizontal" router :default-active="defaultActive" active-text-color="#345dc2" background-color="#fafafa">
<el-menu-item index="/">博客</el-menu-item>
<el-menu-item index="/question">问答</el-menu-item>
<el-menu-item index="/label">标签</el-menu-item>
</el-menu>

我的主页

主页组件渲染

编辑components/layout/Header.vue点击主页跳转到/user

<script>
import { mapState } from 'vuex';
export default {
methods: {
handleCommand(command){
switch (command){
case 'user':
let routeData = this.$router.resolve('/user')
window.open(routeData.href, '_blank')
break
default:
break
}

}
}
}
</script>

创建MockJS接口

查询个人用户信息

URL method description
system/user/{id} get 查询个人用户信息

MockJS

{
"code": 20000,
"message": "查询成功",
"data": {
"id": 1,
"username": "@name",
"password": "xxxxxxx",
"nickName": "@cname",
"imageUrl": "https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif",
"mobile": /1\d{10}/,
"email": "@email()",
"createDate": "@date",
"updateDate": "@date",
}
}

提交修改个人用户信息

URL method description
/system/user put 提交修改个人用户信息

MockJS

{
"code": 20000,
'message': "修改成功"
}

查询个人文章

URL method description
/article/article/user post 查询个人文章

MockJS

{
"code": 20000,
"message": "查询成功",
"data": {
"total": "@integer(100, 200)", // 总记录数
"records|20": [{ //生成20条数据
"id|+1": 10, //初始值10开始,每条+1
"userId": "@integer(10, 30)",
"nickName": "@cname",
"userImage": "@image",
"title": "@ctitle", // 标题
"viewCount": "@integer(0, 100000)", // 浏览次数"thumhup": "@integer(0, 100000)", // 点赞数"ispublic|1": [0,1], // 0: 不公开 1:公开
"status|1": [1, 2], // 1:未审核,2:已审核
"updateDate": "@date",
}]
}
}

查询我的提问

URL method description
/question/question/user post 查询我的提问

MockJS

{
"code": 20000,
"message": "查询成功",
"data": {
"total": "@integer(100, 200)", // 总记录数
"records|20": [{
"id|+1": 10, //初始值10开始,每条+1
"userId": "@integer(10, 30)",
"nickName": "@cname",
"userImage": "@image",
"title": "@csentence", // 标题
"viewCount": "@integer(5, 300)", // 浏览数
"thumhup": "@integer(2, 20)", // 点赞数
"reply": "@integer(1, 10)", // 回复数
"status|1": [1, 2], // 1:未解决,2:已解决"createDate": "@datetime",
"updateDate": "@datetime"
}]
}
}

添加校验原密码接口

URL method description
/system/user/check/password post 校验原密码

MockJs

{
"code": 20000,
"message": "校验成功"
}

添加修改密码接口

URL method description
/system/user/password put 修改密码接口

MockJS

{
"code": 20000,
"message": "修改成功"
}

API调用接口

创建api/user.js文件,添加接口调用api方法

export default ({$axios}, inject) => {
// 查询个人用户信息
inject('getUserInfo', id => $axios.$get( `/system/user/${id}` ) )
// 提交修改个人用户信息
inject('updateUserInfo', data => $axios.$put( `/system/user`, data) )
// 查询个人公开或私密文章
inject('findUserArticle', query =>
$axios.$post( `/article/article/user`, query) )
// 查询我的提问
inject('findUserQuestion', query =>
$axios.$post(`/question/question/user`, query) )
// 校验原密码
inject('checkOldPassword', data =>
$axios.$post(`/system/user/check/password`, data) )
// 提交修改新密码
inject('updatePassword', data =>
$axios.$put(`/system/user/password`, data) )
}

在nuxt.config.js插件中引入user

plugins: [
'@/api/user',
]

加载用户信息和文章

编辑pages/user/index.vue添加 asyncData 方法查询用户信息和公开文章

async asyncData( {app, store} ) {
// 1. 查询用户信息
const userId = store.state.userInfo && store.state.userInfo.uid
const {data: userInfo} = await app.$getUserInfo(userId)

// 2. 查询公开文章列表
const query = {
current: 1,
size: 20,
total: 0,
isPublic: 1, // 1.公开,0.未公开
userId
}
const {data} = await app.$findUserArticle(query)
query.total = data.total
return {userInfo, query, articleList: data.records}
}

重构模版中的代码

<div class="info">
<div>
<span class="meta-block">&nbsp;&nbsp;&nbsp;昵称:</span>
<span class="name">{{userInfo.nickName}}</span>
</div>
<div>
<span class="meta-block">用户名:</span>
<span class="name">{{userInfo.username}}</span>
</div>
</div>

分页查询文章列表

findUserArticleList方法中添加分页查询公开和未公开的文章列表,在handleClick方法中调用findUserArticleList来实现点击标签查询对应数据。

<script>
import ArticleList from '@/components/article/List'
import QuestionList from '@/components/question/List'
import UserEdit from '@/components/user/Edit'
import UserPassword from '@/components/user/Password'

export default {
methods: {
// 查询我的提问列表(
async findUserQuestionList(paneName, current) {
// 当前查询页码
this.query.current = current
// 将isPlulic 删除
delete this.query.isPublic

const {data} = await this.$findUserQuestion(this.query)

// 总记录数
this.query.total = data.total
// 提问列表数据
this.questionList = data.records
},

// 查询用户的文章列表
async findUserArticleList(paneName, current) {
this.query.current = current
// 1 公开 ,0未公开
this.query.isPublic = paneName === 'public' ? 1 : 0
// 发送分页查询请求
const {data} = await this.$findUserArticle(this.query)
// 总记录数
this.query.total = data.total
// 列表数据
this.articleList = data.records
},

// 切换标签页
handleClick(tab, event) {
switch (tab.paneName) {
case 'public':
// 公开
this.findUserArticleList(tab.paneName, 1)
break;
case 'nopublic':
// 私密
this.findUserArticleList(tab.paneName, 1)
break;
case 'question':
this.findUserQuestionList(tab.paneName, 1)
break;
case 'user':
// 用户不用查询,在加载此页面时已经查询了
break;
}
},
}
}
</script>

重构模板中的私密文章标签

<el-tab-pane label="私密文章" name="nopublic">
<article-list name="nopublic" :page="query" :listData="articleList" @fetch-data="findUserArticleList"/>
</el-tab-pane>

分页查询我的提问列表

data选项中添加一个questionList属性来接收 我的提问 列表数据

data() {
return {
questionList: [] // 提问列表
}
}

methods选项下声明findUserQuestionList方法,实现分页查询我的提问列表,在handleClick方法中调用findUserQuestionList来实现点击标签查询对应数据。

methods: {
// 查询我的提问列表(
async findUserQuestionList(paneName, current) {
// 当前查询页码
this.query.current = current
// 将isPlulic 删除
delete this.query.isPublic

const {data} = await this.$findUserQuestion(this.query)

// 总记录数
this.query.total = data.total
// 提问列表数据
this.questionList = data.records
},

// 切换标签页
handleClick(tab, event) {
switch (tab.paneName) {
case 'public':
// 公开
this.findUserArticleList(tab.paneName, 1)
break;
case 'nopublic':
// 私密
this.findUserArticleList(tab.paneName, 1)
break;
case 'question':
this.findUserQuestionList(tab.paneName, 1)
break;
case 'user':
// 用户不用查询,在加载此页面时已经查询了
break;
}
},
}

修改我的提问

<el-tab-pane label="我的提问" name="question">
<question-list name="question" :page="query" :listData="questionList" @fetch-data="findUserQuestionList"/>
</el-tab-pane>

修改个人资料

submitUserForm方法中提交修改个人资料

async submitUserForm() {
this.loading = true
// 提交修改的个人信息
this.userInfo
const {code, message} = await this.$updateUserInfo(this.userInfo)
if(code === 20000) {
this.$message.success('修改成功')
}else {
this.$message.error(message)
}
this.loading = false
}

修改密码

submitPasswordForm中提交修改密码

async submitPasswordForm() {
// 确认中
this.loading = true
// 封装数据
this.passwordData.userId =
this.$store.state.userInfo && this.$store.state.userInfo.uid
const {code, message} = await this.$updatePassword(this.passwordData)

if(code === 20000) {
// 清空修改密码表单
this.passwordData = {}
// 跳转到登录页
this.$store.dispatch('LoginPage')
}else {
// 修改失败
this.$message.error(message)
}

this.loading = false
}

上传头像

uploadMainImg方法实现上传逻辑,和deleteImg方法实现删除原来头像逻辑

// 上传头像
uploadMainImg(file) {
// 封装上传头像表单数据
const data = new FormData()
data.append('file', file.file)
this.$uploadImg(data).then(response => {
if(response.code === 20000) {
// 删除原图片
this.deleteImg()
// 回显上传后的图片
this.userInfo.imageUrl = response.data
}
}).catch(()=> {
this.$message.error('上传头像失败')
})
},

// 删除头像, 上传成功后删除原来的头像
deleteImg() {
if(this.userInfo.imageUrl) {
// 如果有原图地址,则删除它,
this.$deleteImg(this.userInfo.imageUrl)
}
},

中间件路由权限拦截

如果未登录时,可在浏览器地址拦输入地址http://localhost:3000/article/edithttp://localhost:3000/question/edit可直接访问 写文章、提问题 、我的主页等功能页面。 而这些功能是要登录后才可访问,我们使用中间件来拦截这些功能页面的路由请求,未认证则跳转到认证客户端。

创建权限中间件

创建middleware/auth.js

// 定义权限判断中间件,中间件的第1个参数是context
export default ({store, route, redirect, req}) => {
if(!store || !store.state.userInfo){
// 如果未认证,前往认证中心
// 这种方式,在生产环境通过域名访问项目时,获取的也是 http://localhost:3000/api
// 而不是获取域名 htpp://blog.mengxuegu.com/api
// const redirectURL = process.env._AXIOS_BASE_URL_.replace('api', '') + route.path
// 方式2:通过 req.headers.host 在生产环境可获取域名
const redirectURL = 'http://' + req.headers.host + route.path
redirect(`${process.env.authURL}?redirectURL=${redirectURL}`)
}
}

路由组件引用权限中间件

pages目录下的article\edit.vuequestion\edit.vueuser\index.vue页面组件中添加如下代码:

<script>
export default {
middleware: auth
}
</script>
文章作者: 慕容峻才
文章链接: https://www.acaiblog.top/NuxtJS开发博客实战/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 阿才的博客
微信打赏
支付宝打赏