从零搭建博客之内容管理

从零搭建博客之内容管理

2020年02月02日 阅读:35 字数:1988 阅读时长:4 分钟

内容管理是博客一个核心的功能,用于编辑文章,回复评论、查看网站运营数据等

1. 前言

博客一个最重要的功能就是内容管理,为了方便随时随地在线创作文章,管理网站配置、友链、评论等内容,所以参考vue-element-template开发了一个内容管理系统。

后台不需要SEO,所以采用的是SPA单页应用,一开始是用vue-cli搭建的独立项目,后面发现nuxt也可以实现,所以和网站前台、后台合并到一个项目里面,通过不同的nuxt配置文件区分。

技术栈:

  • Vue.js 2.0
  • Element-ui
  • Axios
  • Nuxt.js:单页应用
  • @nuxtjs/router:路由插件,以适配router.js的形式
  • Tinymce:富文本编辑器

2. 目录结构

client
├── admin 内容管理平台
|   ├── api 整合管理所有的api接口
|   ├── layouts 外部容器
|   ├── middleware
│   |   └── permission.js 登录鉴权
|   ├── plugins
│   |   ├── api.js 使上下文能使用api请求
│   |   ├── axios.js axios全局拦截器
│   |   ├── element-ui element-ui按需引入
│   |   └── svg-icon 注册svg icon组件
|   ├── store vuex
|   ├── styles 公共样式
|   ├── utils 工具集
|   ├── views 页面组件
|   └── router.js @nuxtjs/router路由配置
├── common 内容管理平台和博客前台功能模块
config 配置
│   ├── nuxt.admin.js 内容管理平台nuxt配置
│   └── nuxt.front.js 博客前台nuxt配置

3. 界面预览

登录页

首页

文章列表

文章详情

追番列表

评论系统

友情链接

4. 功能特性

4.1. 登录鉴权

使用nuxt中间件实现路由守卫功能

middleware/permission.js

import { Message } from 'element-ui'
import { getToken } from '@/utils/auth' // get token from cookie
import getPageTitle from '@/utils/get-page-title'

const whiteList = ['/login'] // no redirect whitelist

export default async function({ redirect, store, route }) {
  const { meta, path } = route
  // set page title
  document.title = getPageTitle(meta[meta.length - 1]?.title)

  // determine whether the user has logged in
  const hasToken = getToken()

  if (hasToken) {
    if (path === '/login') {
      // if is logged in, redirect to the home page
      redirect({ path: '/' })
    } else {
      const hasGetUserInfo = store.getters.userinfo.username
      if (!hasGetUserInfo) {
        try {
          // get user info
          await store.dispatch('user/getInfo')
          store.dispatch('list/getCategory')
          store.dispatch('config/getConfigs')
        } catch (error) {
          console.error(error)
          // remove token and go to login page to re-login
          await store.dispatch('user/resetToken')
          Message.error('Permission Has Error')
          redirect(`/login?redirect=${path}`)
        }
      }
    }
  } else {
    /* has no token*/
    if (!whiteList.includes(path)) {
      // other pages that do not have permission to access are redirected to the login page.
      redirect(`/login?redirect=${path}`)
    }
  }
}

nuxt.config.js

module.exports = {
  router: {
    middleware: ['permission']
  }
}

4.2. 路由管理

Nuxt.js 依据 pages 目录结构自动生成 vue-router 模块的路由配置,但由于内容管理系统原来vue-cli搭建的,所以使用@nuxtjs/router模块保留原来的路由

module.exports = {
  buildModules: [
    '@nuxtjs/router'
  ]
}

./router.js

import Vue from 'vue'
import Router from 'vue-router'

import Layout from '@/layouts/index'

import login from '@/views/login/index'
import home from '@/views/home/index'
import article from '@/views/content/index'
import bangumi from '@/views/content/bangumi'
import createContent from '@/views/content/create'
import editContent from '@/views/content/edit'
import recycle from '@/views/content/recycle'

Vue.use(Router)
/**
 * constantRoutes
 * a base page that does not have permission requirements
 * all roles can be accessed
 */
export const routes = [

  {
    path: '/login',
    component: login,
    hidden: true
  },

  {
    path: '/',
    name: 'Home',
    component: home,
    meta: { title: '首页', icon: 'dashboard', affix: true }
  },

  {
    path: '/content',
    component: Layout,
    redirect: '/content/article',
    meta: { title: '内容管理', icon: 'content' },
    children: [
      {
        path: '/content/article',
        name: 'Article',
        component: article,
        meta: { title: '文章模块' }
      },
      {
        path: '/content/bangumi',
        name: 'Bangumi',
        component: bangumi,
        meta: { title: '番剧模块' }
      },
      {
        path: '/content/create',
        name: 'ContentCreate',
        props: true,
        hidden: true,
        component: createContent,
        meta: { title: '新增文章', noCache: true }
      },
      {
        path: '/content/edit/:id',
        name: 'ContentEdit',
        props: true,
        hidden: true,
        component: editContent,
        meta: { title: '修改文章', noCache: true }
      },
      {
        path: '/content/recycle',
        name: 'Recycle',
        component: recycle,
        meta: { title: '回收站' }
      }
    ]
  }
]

export const constantRoutes = flattenDeep(routes)

/**
 * 数组降维
 * @param {Array} source
 * @param {Array} result
 */
function flattenDeep(source, result = []) {
  source.forEach(item => {
    if (item.children?.length) {
      flattenDeep(item.children, result)
    }
    const temp = { ...item }
    delete temp.children
    result.push(temp)
  })
  return result
}

export function createRouter() {
  return new Router({
    mode: 'hash',
    routes: constantRoutes
  })
}

4.3. 公共插件

插件在nuxt.config.js中配置

module.exports = {
  plugins: [
    '@/plugins/axios',
    '@/plugins/api',
    '@/plugins/svg-icon'
  ]
}

4.3.1 svg-icon

使用nuxt plugins注册公共组件

plugins/svg-icon.js

import Vue from 'vue'
import SvgIcon from '@/components/SvgIcon'// svg component

// register globally
Vue.component('svg-icon', SvgIcon)

const req = require.context('@/assets/icons/svg', false, /\.svg$/)
const requireAll = requireContext => requireContext.keys().map(requireContext)
requireAll(req)

4.3.2 axios拦截器

plugins/axios.js

export default function({ store, app: { $axios }}) {
  // request timeout
  $axios.defaults.timeout = 15000

  $axios.onResponse(response => {
    const res = response.data

    // if the custom code is not 0, it is judged as an error.
    if (res.errno !== 0) {

      // 50008: Illegal token; 50012: Other clients logged in; 50014: Token expired;
      if (res.code === 401) {
        // to re-login
      }
      return Promise.reject(res.errmsg || 'error')
    } else {
      return res
    }
  },
  error => {

    return Promise.reject(error)
  })
}

4.3.3 全局注入api

plugins/api.js

import apis from '@/api/index'

export default (ctx, inject) => {
  var apiList = {}
  for (var i in apis) {
    apiList[i] = apis[i](ctx.$axios)
  }

  // 文档: https://www.nuxtjs.cn/guide/plugins
  // inject:注入到服务端context, vue实例, vuex中
  inject('api', apiList)
}

api/index.js

const modulesFiles = require.context('./modules', true, /\.js$/)

const modules = modulesFiles.keys().reduce((modules, modulePath) => {
  const moduleName = modulePath.replace(/^\.\/(.*)\.\w+$/, '$1')

  const value = modulesFiles(modulePath)

  modules[moduleName] = value.default || value

  return modules
}, {})

export default modules

api/modules/list.js

export default axios => ({
  /**
   * 获取分页列表数据
   * @param {String} controller 控制器
   * @param {Object} query 查询条件
   */
  GetList(controller, query) {
    return axios({
      url: `/restful/${controller}/`,
      method: 'get',
      params: query
    })
  }
})

组件中使用

export default {  
  methods: {
    fetchList() {
      this.$api.list.GetList('article', { page: 1, pageSize: 20 }).then(res => {
        const { data, count } = res.data
        this.tableData = data
        this.total = count
      }).catch(() => {})
    }
}

4.4. 富文本编辑器

这里用的是Tinymce,参考vue-element-admin,它不管文档还是配置都很好,Tinymce 的复制去格式化优秀,扩展性很高,比如多图上传、word导入等等。

目前也有其他的一些富文本编辑器:

1、ckeditor:ui比较美观,号称是插件最丰富的富文本编辑器。

2、quill:写插件、api设计比较简单,但对图片操作不友善。

3、wangEditor:个人写的富文本编辑器,也相当不错了,就配置型和丰富性不足。

4.4.1 使用方法

由于目前使用npm安装Tinymce方法比较复杂且会大大增加编译的时间,所以推荐使用异步引入静态资源的方法。

const toolbar = [
  'undo redo restoredraft | cut copy paste pastetext | removeformat formatpainter | forecolor backcolor bold italic underline strikethrough subscript superscript | alignleft aligncenter alignright alignjustify outdent indent indent2em lineheight | code preview fullscreen',
  'styleselect formatselect fontselect fontsizeselect | bullist numlist | blockquote link anchor table image axupimgs gallery media emoticons hr pagebreak insertdatetime'
]

const codesampleLanguages = [
  { text: 'Text', value: 'text' },
  { text: 'HTML/XML', value: 'markup' },
  { text: 'JavaScript', value: 'javascript' },
  { text: 'JSON', value: 'json' },
  { text: 'CSS', value: 'css' },
  { text: 'Less', value: 'less' },
  { text: 'Sass/Scss', value: 'sass' },
  { text: 'Shell', value: 'shell' },
  { text: 'TypeScript', value: 'typescript' }
]

const plugins = [
  'advlist anchor autolink autosave axupimgs code codesample colorpicker contextmenu directionality emoticons formatpainter fullscreen gallery hr',
  'indent2em image imagetools insertdatetime link lists media nonbreaking noneditable pagebreak paste preview save searchreplace',
  'table template textpattern visualblocks visualchars wordcount'
]

window.tinymce.init({
  selector: '#vue-tinymce',
  language: 'zh_CN',
  height: 500,
  object_resizing: false,
  toolbar: toolbar,
  menubar: 'file edit view insert format table tools',
  plugins: plugins,
  end_container_on_empty_block: true,
  powerpaste_word_import: 'clean',
  code_dialog_height: 650,
  code_dialog_width: 1200,
  codesample_languages: codesampleLanguages,
  default_link_target: '_blank',
  nonbreaking_force_tab: true, // inserting nonbreaking space   need Nonbreaking Space Plugin
  fontsize_formats: '12px 14px 16px 18px 24px 36px 48px 56px 72px',
  font_formats: '微软雅黑=Microsoft YaHei,Helvetica Neue,PingFang SC,sans-serif;苹果苹方=PingFang SC,Microsoft YaHei,sans-serif;宋体=simsun,serif;仿宋体=FangSong,serif;黑体=SimHei,sans-serif;Arial=arial,helvetica,sans-serif;Arial Black=arial black,avant garde;Book Antiqua=book antiqua,palatino;Comic Sans MS=comic sans ms,sans-serif;Courier New=courier new,courier;Georgia=georgia,palatino;Helvetica=helvetica;Impact=impact,chicago;Terminal=terminal,monaco;Times New Roman=times new roman,times;Verdana=verdana,geneva;',
  init_instance_callback: editor => {
    if (this.value) {
      editor.setContent(this.value)
    }
    this.hasInit = true
    editor.on('Change KeyUp SetContent', (e) => {
      this.hasChange = true
      this.$emit('input', editor.getContent())
    })
  },
  relative_urls: false,
  setup(editor) {
    editor.on('FullscreenStateChanged', (e) => {
      this.fullscreen = e.state
    })
  },
  file_picker_types: 'image',
  file_picker_callback: handlerPickerFile,
  images_upload_handler: uploadImages
})

/**
   * 在图片、媒体、链接对话框中加入上传文件功能
   * @param {Function} callback 上传成功后执行的回调函数
   * @param {*} value 当前受影响的字段值
   * @param {Object} meta 包含指定对话框中其它字段值的对象
   */
function handlerPickerFile(callback, value, meta) {
  const input = document.createElement('input')
  input.setAttribute('type', 'file')
  input.setAttribute('accept', 'image/*')

  /*
    Note: In modern browsers input[type="file"] is functional without
    even adding it to the DOM, but that might not be the case in some older
    or quirky browsers like IE, so you might want to add it to the DOM
    just in case, and visually hide it. And do not forget do remove it
    once you do not need it anymore.
  */

  input.onchange = e => {
    const file = e.target.files[0]
    if (!file) return
    const formData = new FormData()
    formData.append('file', file)
    this.$api.common.UploadFiles(formData).then(res => {
      const fileList = res.data
      const { url, name } = fileList[0] || {}
      callback(url, { alt: name })
    }).catch(() => {
      this.$message({
        type: 'error',
        message: '图片上传失败.'
      })
    })
  }
  input.click()
}

/**
 * 使用自定义函数代替TinyMCE来处理上传操作
 * @param {Object} blobInfo 文件信息
 * @param {Function} success 成功回调
 * @param {Function} failure 失败回调
 */
function uploadImages(blobInfo, success, failure) {
  const formData = new FormData()
  formData.append('file', blobInfo.blob())
  this.$api.common.UploadFiles(formData).then(res => {
    const fileList = res.data
    const { url } = fileList[0] || {}
    success(url)
  }).catch(() => {
    failure('图片上传失败.')
  })
}

如果上传图片地址不正确,可以考虑设置下relative_urls

推荐阅读

恰饭区

评论区 (0)

0/500

还没有评论,快来抢第一吧