环境搭建 技术栈
创建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 { head : { title : '阿才的博客' , link : [ { rel : 'icon' , type : 'image/x-icon' , href : '/meng.ico' } ] } }
NuxtJS整合ElementUI 安装模块
以插件方式引入element-ui,编辑plugins/element-ui.js
import Vue from 'vue' import ElementUI from 'element-ui' Vue .use (ElementUI )
在nuxt.config.js中配置插件和css
export default { css : [ 'element-ui/lib/theme-chalk/index.css' , 'element-ui/lib/theme-chalk/display.css' ], plugins : [ '@/plugins/element-ui' ], build : { trsanspile : [/^element-ui/ ], 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 : ['element-ui/lib/theme-chalk/index.css' ,'element-ui/lib/theme-chalk/display.css' ,'@/assets/themes/index.css' ,]
添加logo和全局样式 global.css
复制到assets/css/global.css
;logo.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
Logo 编辑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模块
编辑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 : { 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>
需求
项目请求的后台数据接口,有些接口是需要通过身份认证通过后才有权限访问,如果没有身份认证是不允许访问的。
后台为了判断是否已身份认证过了,会要求应用请求数据接口时,在请求头上带上 访问令牌 accessToken , 后台收到请求后,就会校验你的 accessToken 是否有效,有效则正常响应数据。无效就会报401未认证。
创建plugins\interceptor.js
拦截器插件,在请求头上面带上令牌
可以通过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 ('请求拦截器' ) const accessToken = store.state .accessToken if (accessToken){ 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 => { if (error.response .status !== 401 ){ return Promise .reject (error) } console .log ('响应异常' , error.response .status ) }) } 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.commit ('RESET_USER_STATE' ) redirect (`${process.env.authURL} ?redirectURL=${redirectURL(route)} ` ) } } const redirectURL = (route ) =>{ if (process.client ){ return window .location .href } return process.env ._AXIOS_BASE_URL_ .replace ('api' ,'' ) + route.path }
博客首页 首页主区域分为3个区域:左侧导航、中间文章列表、右侧广告。左侧和右侧区域当窗口缩小到 sm (<992px)范围时会被隐藏。 添加index.css
和list.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" : [ { "id|+1" : 10 , "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" : [ { "id|+1" : 10 , "userId|+1" : 1 , "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
添加getCategoryList
和getAdvertList
、getArticleList
方法,分别查询技术频道和广告信息、 文章列表分页接口
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.vue
的asyncData
中调用getCategoryList
和getAdvertList
获取数据。在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 ] , "labelIds|1-5" : [ '@integer(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: "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: } }
创建更新浏览+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.css
和github-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.js
,validate
校验路由参数,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
方法,查询文章的所有评论信息
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
添加addComment
和deleteCommentById
方法,新增和删除文章的所有评论信息
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" : [ { "id|+1" : 1 , "name" : "@word" , "labelList|3" : [ { "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 { css : [ 'mavon-editor/dist/css/index.css' ], 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
中的uploadContentImg
和delContentImg
方法
<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" }
在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"> 编辑 </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" }
在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 , "userId" : "@integer(10, 30)" , "nickName" : "@cname" , "userImage" : "@image" , "title" : "@csentence" , "viewCount" : "@integer(5, 300)" , "status|1" : [ 1 , 2 ] , "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 , "userId" : "@integer(10, 30)" , "nickName" : "@cname" , "userImage" : "@image" , "title" : "@csentence" , "viewCount" : "@integer(5, 300)" , "status|1" : [ 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 , "userId" : "@integer(10, 30)" , "nickName" : "@cname" , "userImage" : "@image" , "title" : "@csentence" , "viewCount" : "@integer(5, 300)" , "status" : 1 , "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 ] , "createDate" : "@datetime" , "updateDate" : "@datetime" , "labelIds|1-5" : [ '@integer(10 , 24 )'] , "labelList|3" : [ { "id|+1" : 10 , "name" : "@word" } ] , "mdContent" : '参考官网配置:[ https: "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: } }
创建更新浏览接口
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} ` )) 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))inject ('deleteReplayById' , id => $axios.$delete(`/question/replay/${id} ` ))inject ('addQuestion' , data => $axios.$post(`/question/question` , data))inject ('updateQuestion' , data => $axios.$put(`/question/question` , data))inject ('getQuestionByLableId' , (page, labelId ) => $axios.$post(`/question/api/question/list/${labelId} ` , page))
回答详情数据渲染 直接复制文章详情页组件pages/article/_id.vue
到pages/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"> 编辑 </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
中获取cookie
的key
更改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) ->  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" }
创建更新问答接口
URL
method
description
question/question
put
更新问答接口
MockJS
{ "code" : 20000 , "message" : "修改成功" , "data" : "11" }
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" : [ { "id|+1" : 10 , "userId" : "@integer(10, 30)" , "nickName" : "@cname" , "userImage" : "@image" , "title" : "@ctitle" , "viewCount" : "@integer(0, 100000)" , "status|1" : [ 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 , "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 ] , "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"> 昵称:</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/edit 、http://localhost:3000/question/edit可直接访问 写文章、提问题 、我的主页等功能页面。 而这些功能是要登录后才可访问,我们使用中间件来拦截这些功能页面的路由请求,未认证则跳转到认证客户端。
创建权限中间件 创建middleware/auth.js
export default ({store, route, redirect, req}) => { if (!store || !store.state .userInfo ){ const redirectURL = 'http://' + req.headers .host + route.path redirect (`${process.env.authURL} ?redirectURL=${redirectURL} ` ) } }
路由组件引用权限中间件 pages
目录下的article\edit.vue
和question\edit.vue
、user\index.vue
页面组件中添加如下代码:
<script> export default { middleware: auth } </script>