Vue3+TypeScript+Django Rest Framework 搭建個人博客(三):博客管理后臺(3)

一個完整的網(wǎng)站都是有前臺和管理后臺組成的,前臺用來給真正的用戶瀏覽和使用,后臺用來給管理員管理網(wǎng)站內(nèi)容断盛,配置各種功能和數(shù)據(jù)等。博客的管理后臺就是用來承載創(chuàng)建博客愉舔,發(fā)布博客钢猛,查看留言,管理博客用戶這些功能的子系統(tǒng)屑宠。

大家好厢洞,我是落霞孤鶩,上一篇我們已經(jīng)實現(xiàn)了管理后臺的前端部分頁面,這一章我們繼續(xù)搭建博客的管理后臺的前端躺翻,實現(xiàn)對博客網(wǎng)站的管理功能丧叽。

一、前端開發(fā)

1.4 分類和文章管理

文章和分類是關(guān)系比較密切的兩個業(yè)務對象公你,因此這里把分類管理的功能和文章管理的功能放在同一個頁面處理踊淳。

1.4.1 Type

src/types/index.ts文件中增加代碼如下:

export interface Catalog {
    id: number,
    name: string,
    parent: number,
    parents: Array<number>,
    children: Array<Catalog>

}

export interface Article {
    id: number,
    title: string,
    cover: string,
    toc: string,
    excerpt: string,
    markdown: string,
    html: string,
    create_at: string,
    views: number,
    likes: number,
    comments: number,
    words: number,
    tags: Array<number> | any,
    tags_info: Array<Tag> | any
    catalog: number,
    catalog_info: Catalog,
    created_at: string,
    modified_at: string,
    author: string,
    status?: string,
}

export interface ArticleArray {
    count: number,
    results: Array<Article> | any
}

export interface ArticleParams {
    title: string | any,
    status: string | any,
    tags: Array<number> | any,
    catalog: number | any,
    page: number,
    page_size: number,
}

1.4.2 API

這里要編寫標簽管理相關(guān)的接口,列表查詢陕靠、新增迂尝、修改、刪除剪芥。在src/api/service.ts編寫如下代碼:

export function getCatalogTree() {
    return request({
        url: '/catalog/',
        method: 'get',
    }) as unknown as Array<Catalog>
}

export function saveCatalog(method: string, data: Catalog) {
    let url = '/catalog/'
    if (['put', 'patch'].includes(method)) {
        url += data.id + '/'
    }
    // @ts-ignore
    return request({
        url,
        method,
        data,
    }) as unknown as ResponseData

}

export function deleteCatalog(catalogId: number) {

    return request({
        url: '/catalog/' + catalogId + '/',
        method: 'delete',
    }) as unknown as ResponseData

}

export function getArticleList(params: ArticleParams) {
    return request({
        url: '/list/',
        method: 'get',
        params
    }) as unknown as ArticleArray
}

export function remoteDeleteArticle(articleId: number) {
    return request({
        url: '/article/' + articleId + '/',
        method: 'delete',
    }) as unknown as ResponseData
}

export function getArticleDetail(articleId: number) {
    return request({
        url: '/article/' + articleId + '/',
        method: 'get',
    }) as unknown as Article
}

export function remoteSaveArticle(method: string, data: Article) {
    let url = '/article/'
    if (['put', 'patch'].includes(method)) {
        url += data.id + '/'
    }
    // @ts-ignore
    return request({
        url,
        method,
        data,
    }) as unknown as Article
}

export function remotePublishArticle(articleId: number) {

    // @ts-ignore
    return request({
        url: '/publish/' + articleId + '/',
        method: 'patch',
    }) as unknown as Article
}

export function remoteOfflineArticle(articleId: number) {
    return request({
        url: '/offline/' + articleId + '/',
        method: 'patch',
    }) as unknown as Article
}

1.4.3 Component

提供一個管理分類的抽屜組件垄开,因此在src/components下創(chuàng)建文件CatalogTree.vue,編寫代碼如下:

<template>
  <el-drawer
      v-model="state.visible"
      :before-close="handleClose"
      direction="rtl"
      size="500px"
      title="目錄管理"
      @opened="handleSearch"
  >
    <div class="drawer-content">
      <el-tree
          :data="state.catalogs"
          :expand-on-click-node="false"
          :props="defaultProps"
          default-expand-all
          node-key="id">
        <template #default="{ node, data }">
        <span class="custom-tree-node">
          <span>{{ node.label }}</span>
          <span>
            <el-dropdown trigger="click">
              <span class="el-dropdown-link">
                <i class="el-icon-more"/>
              </span>
              <template #dropdown>
                <el-dropdown-menu>
                  <el-dropdown-item icon="el-icon-edit">
                    <a class="more-button" @click.prevent="showEditDialog(data)">
                       修改
                    </a>
                  </el-dropdown-item>
                  <el-dropdown-item icon="el-icon-circle-plus">
                    <a class="more-button" @click.prevent="showAddDialog(data)">
                       新增
                    </a>
                  </el-dropdown-item>
                  <el-dropdown-item icon="el-icon-delete-solid">
                    <el-popconfirm :title="'確定刪除【'+data.name+'】税肪?'" cancelButtonText='取消' confirmButtonText='刪除'
                                   icon="el-icon-info" iconColor="red" @confirm="remove(data)">
                      <template #reference>
                        <a class="more-button">
                          刪除
                        </a>
                      </template>
                    </el-popconfirm>
                  </el-dropdown-item>
                </el-dropdown-menu>
              </template>
            </el-dropdown>
          </span>
        </span>
        </template>
      </el-tree>
    </div>
  </el-drawer>
  <el-dialog v-model="state.showDialog" :title="state.dialogTitle">
    <el-form class="form" label-suffix=":" label-width="120px" size="medium">
      <el-form-item label="目錄名稱">
        <el-input v-model="state.catalog.name" autocomplete="off"></el-input>
      </el-form-item>
    </el-form>
    <template #footer>
        <span class="dialog-footer">
          <el-button @click="state.showDialog=false">取 消</el-button>
          <el-button :loading="state.loading" type="primary" @click="saveCatalog">保 存</el-button>
        </span>
    </template>
  </el-dialog>
</template>

<script lang="ts">
import {defineComponent, reactive} from "vue";
import {Catalog} from "../types";
import {deleteCatalog, getCatalogTree, saveCatalog} from "../api/service";
import {ElMessage} from "element-plus";

export default defineComponent({
  name: "CatalogTree",
  props: {
    visible: {
      type: Boolean,
      require: true,
    }
  },
  watch: {
    '$props.visible': {
      handler(val, oldVal) {
        if (val != oldVal) {
          this.state.visible = val
        }
      }
    }
  },
  emits: ['close',],
  setup(props) {
    const state = reactive({
      catalogs: [] as Array<Catalog>,
      visible: props.visible,
      showDialog: false,
      catalog: {} as Catalog,
      dialogTitle: '',
      loading: false,
    })

    const handleSearch = async () => {
      state.catalogs = await getCatalogTree();
    }
    const defaultProps = {
      children: 'children',
      label: 'name',
    }
    return {
      state,
      handleSearch,
      defaultProps
    }
  },
  methods: {
    handleClose() {
      this.$emit('close')
    },
    showAddDialog(data: Catalog) {
      this.state.showDialog = true
      //@ts-ignore
      this.state.catalog.id = undefined
      //@ts-ignore
      this.state.catalog.name = undefined
      this.state.catalog.parent = data.id
      this.state.dialogTitle = '新增目錄'
    },
    showEditDialog(data: Catalog) {
      this.state.showDialog = true
      this.state.catalog = data
      this.state.dialogTitle = '修改目錄'
    },
    async saveCatalog() {
      try {
        this.state.loading = true
        const method = this.state.catalog.id ? 'patch' : 'post'
        await saveCatalog(method, this.state.catalog)
        this.state.loading = false
        this.state.showDialog = false
        ElMessage({
          message: '保存成功',
          type: 'success'
        })
        await this.handleSearch()
      } catch (e) {
        console.error(e)
        ElMessage({
          message: '保存失敗',
          type: 'error'
        })
        this.state.loading = false
      }
    },
    async remove(data: Catalog) {
      await deleteCatalog(data.id)
      ElMessage({
        message: '刪除成功',
        type: 'success'
      })
      await this.handleSearch()
    }
  }

})
</script>

<style lang="less" scoped>
.drawer-content {
  padding: 12px 0 0 24px;
  border-top: #eeeeee 1px solid;
  overflow: auto;
}

.custom-tree-node {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  font-size: 14px;
  padding-right: 32px;
}

.add-button {
  margin-bottom: 12px;
}

</style>

由于文章管理的界面需要有Markdown編輯器溉躲,因此安裝markdown編輯器的依賴

yarn add @kangc/v-md-editor@2.3.5
yarn add highlight.js@10.7.2

main.ts 中增加編輯器的 jscss和插件

import { createApp } from 'vue'
import App from './App.vue'
import router from "./router";
import { StateKey, store } from "./store";
import 'element-plus/lib/theme-chalk/index.css';
import 'element-plus/lib/theme-chalk/base.css';

// @ts-ignore
import VMdEditor from '@kangc/v-md-editor';
import '@kangc/v-md-editor/lib/style/base-editor.css';
// @ts-ignore
import githubTheme from '@kangc/v-md-editor/lib/theme/github.js';
import '@kangc/v-md-editor/lib/theme/style/github.css';

// highlightjs
import hljs from 'highlight.js';

VMdEditor.use(githubTheme, {
    Hljs: hljs,
});
import {
    ElAffix,
    ElButton,
    ElCard,
    ElCascader,
    ElCol,
    ElDescriptions,
    ElDescriptionsItem,
    ElDialog,
    ElDrawer,
    ElDropdown,
    ElDropdownItem,
    ElDropdownMenu,
    ElForm,
    ElFormItem,
    ElIcon,
    ElInput,
    ElLoading,
    ElMenu,
    ElMenuItem,
    ElMessage,
    ElMessageBox,
    ElOption,
    ElPagination,
    ElPopconfirm,
    ElProgress,
    ElRow,
    ElSelect,
    ElTable,
    ElTableColumn,
    ElTag,
    ElTimeline,
    ElTimelineItem,
    ElTooltip,
    ElTree,
    ElUpload,
} from 'element-plus';

const app = createApp(App)


const components = [
    ElAffix,
    ElButton,
    ElCard,
    ElCascader,
    ElCol,
    ElDescriptions,
    ElDescriptionsItem,
    ElDialog,
    ElDrawer,
    ElDropdown,
    ElDropdownItem,
    ElDropdownMenu,
    ElForm,
    ElFormItem,
    ElIcon,
    ElInput,
    ElLoading,
    ElMenu,
    ElMenuItem,
    ElMessage,
    ElMessageBox,
    ElOption,
    ElPagination,
    ElPopconfirm,
    ElProgress,
    ElRow,
    ElSelect,
    ElTable,
    ElTableColumn,
    ElTag,
    ElTimeline,
    ElTimelineItem,
    ElTooltip,
    ElTree,
    ElUpload,
]

const plugins = [
    ElLoading,
    ElMessage,
    ElMessageBox,
]

components.forEach(component => {
    app.component(component.name, component)
})

plugins.forEach(plugin => {
    app.use(plugin)
})

app.use(router).use(store, StateKey).use(VMdEditor).mount('#app')

提供一個編輯文章的抽屜組件益兄,因此在src/components下創(chuàng)建文件EditArticle.vue锻梳,編寫代碼如下:

<template>
  <el-drawer
      v-model="state.visible"
      :before-close="handleClose"
      :title="articleId?'修改文章':'新增文章'"
      direction="rtl"
      size="800px"
      @opened="handleSearch"
  >
    <div class="article-form" style="overflow-y: auto">
      <el-form label-suffix=":" label-width="120px">
        <el-form-item label="標題">
          <el-input ref="articleTitle" v-model="state.article.title"></el-input>
        </el-form-item>
        <el-form-item label="所屬分類">
          <el-cascader v-model="state.catalogs" :options="state.catalogTree"
                       :props="{ checkStrictly: true, value:'id',label:'name',expandTrigger: 'hover'}"
                       clearable
                       size="medium"
                       style="width: 100%"/>
        </el-form-item>
        <el-form-item label="標簽">
          <el-select v-model="state.article.tags" clearable multiple placeholder="請選擇文章標簽" size="medium"
                     style="width: 100%">
            <el-option v-for="s in state.tags" :label="s.name" :value="s.id" :key="s.id"/>
          </el-select>
        </el-form-item>
        <el-form-item label="摘要">
          <el-input v-model="state.article.excerpt" :rows="5" type="textarea"></el-input>
        </el-form-item>
        <el-form-item label="正文">
          <v-md-editor v-model="state.article.markdown" height="600px"></v-md-editor>
        </el-form-item>
        <el-form-item label="封面">
          <el-upload
              :before-upload="beforeAvatarUpload"
              :headers="csrfToken"
              :on-success="handleAvatarSuccess"
              :show-file-list="false"
              action="/api/upload/"
              class="avatar-uploader"
          >
            <img v-if="state.article.cover" :src="state.article.cover" class="avatar">
            <i v-else class="el-icon-plus avatar-uploader-icon"></i>
          </el-upload>
        </el-form-item>
      </el-form>
    </div>
    <div class="demo-drawer__footer">
      <el-button @click="handleClose">取消</el-button>
      <el-button :loading="state.loading" type="primary" @click="saveArticle">保存</el-button>
    </div>
  </el-drawer>

</template>

<script lang="ts">
import {defineComponent, reactive} from "vue";
import {getArticleDetail, getCatalogTree, getTagList, remoteSaveArticle} from "../api/service";
import {Article, Catalog, Tag, TagList} from "../types";
import {getCookie} from "../utils";

export default defineComponent({
  name: "EditArticle",
  props: {
    articleId: {
      type: Number,
      require: true,
      default: undefined,
    },
    visible: {
      type: Boolean,
      require: true,
    }
  },
  watch: {
    '$props.visible': {
      handler(val: Boolean, oldVal: Boolean) {
        if (val !== oldVal) {
          this.state.visible = val
        }
      }
    }
  },
  emits: ["close",],
  setup(props, context) {
    const state = reactive({
      article: {} as Article,
      loading: false,
      visible: false as Boolean,
      catalogTree: [] as Array<Catalog>,
      tags: [] as Array<Tag>,
      catalogs: [] as Array<number>
    })


    const saveArticle = async () => {
      try {
        state.loading = true
        if (state.catalogs.length) {
          state.article.catalog = state.catalogs[state.catalogs.length - 1]
        }
        if (props.articleId) {
          await remoteSaveArticle('put', state.article)
        } else {
          await remoteSaveArticle('post', state.article)
        }
        state.loading = false
        context.emit('close', true)
      } catch (e) {
        state.loading = false
      }
    }
    const csrfToken = {'X-CSRFToken': getCookie('csrftoken')}
    return {
      state, saveArticle, csrfToken
    }
  },
  methods: {
    async handleSearch() {
      this.$refs.articleTitle.focus()
      if (this.$props.articleId) {
        this.state.article = await getArticleDetail(this.$props.articleId)
        this.state.article.tags = this.state.article.tags_info.map((tag: Tag) => tag.id)
        this.state.catalogs = this.state.article.catalog_info.parents
      } else {
        this.state.article = {} as Article
      }
      this.state.catalogTree = await getCatalogTree()
    

      if (!this.state.tags.length) {
        const tags: TagList = await getTagList({})
        this.state.tags = tags.results
      }
    },
    handleClose(done: any) {
      this.$confirm('確認關(guān)閉抽屜?', '提示', {
        confirmButtonText: '關(guān)閉',
        cancelButtonText: '取消',
        type: 'warning'
      })
          .then((_: any): void => {
            this.$emit("close", false)
            this.state.article = {} as Article
            done();
          })
          .catch((_: any): void => {
            console.error(_)
          });
    },
    handleAvatarSuccess(res: any, file: File) {
      this.state.article.cover = res.url
    },
    beforeAvatarUpload(file: File) {
      const isImage = ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'].includes(file.type);
      const isLt2M = file.size / 1024 / 1024 < 2;

      if (!isImage) {
        this.$message.error('上傳圖片只能是 JPG 格式!');
      }
      if (!isLt2M) {
        this.$message.error('上傳圖片大小不能超過 2MB!');
      }
      return isImage && isLt2M;
    }
  }
})
</script>

<style lang="less">
.article-form {
  padding: 24px;
  overflow-y: auto;
  border-top: 1px solid #e8e8e8;
  height: calc(100% - 100px);
}


//抽屜//element-ui的drawer固定底部按鈕
.el-drawer .el-drawer__body{
  margin-bottom: 50px ;
  height: 100% !important;
}

.el-drawer__header{
  margin-bottom: 16px;
}
.demo-drawer__footer {
  width: 100%;
  position: absolute;
  bottom: 0;
  left: 0;
  border-top: 1px solid #e8e8e8;
  padding: 10px 16px;
  text-align: right;
  background-color: white;
}

//抽屜//去掉element-ui的drawer標題選中狀態(tài)

:deep(:focus){
  outline: 0;

}

.avatar-uploader {
  background-color: #fbfdff;
  border: 1px dashed #c0ccda;
  border-radius: 6px;
  box-sizing: border-box;
  width: 125px;
  height: 100px;
  cursor: pointer;
  line-height: 100px;
  text-align: center;
  font-size: 20px;
}

</style>

1.4.4 View

通過表格管理文章,通過樹形組件管理分類净捅,在src/views/admin下新增文件Article.vue文件疑枯,編寫如下代碼:

<template>
  <div>
    <div>
      <el-form :inline="true" class="demo-form-inline">
        <el-form-item label="標題">
          <el-input ref="title" v-model="state.params.title" placeholder="文章標題"/>
        </el-form-item>
        <el-form-item label="狀態(tài)">
          <el-select v-model="state.params.status" placeholder="狀態(tài)">
            <el-option label="已發(fā)布" value="Published"/>
            <el-option label="草稿" value="Draft"/>
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button :loading="state.isLoading" type="primary" @click="handleSearch">查詢</el-button>
        </el-form-item>
      </el-form>
    </div>
    <div class="button-container">
      <el-button :loading="state.isLoading" type="primary" @click="showAddDrawer"><i class="el-icon-plus"/> 新 增
      </el-button>
      <el-button circle icon="el-icon-s-unfold" @click="state.showCatalogTree=true"/>
    </div>
    <div>
      <el-table ref="articleTable" :data="state.articleList" :header-cell-style="{background:'#eef1f6',color:'#606266'}" stripe
                style="width: 100%">
        <el-table-column type="selection" width="55"/>
        <el-table-column label="ID" prop="id" width="80"/>
        <el-table-column label="標題" prop="title" width="200"/>
        <el-table-column label="狀態(tài)" prop="status" width="100"/>
        <el-table-column label="分類" prop="catalog_info.name"/>
        <el-table-column :formatter="datetimeFormatter" label="修改時間" prop="modified_at"/>
        <el-table-column fixed="right" label="操作" width="120">
          <template #default="scope">
            <el-popconfirm cancelButtonText='取消' confirmButtonText='刪除' icon="el-icon-info" iconColor="red"
                           title="確定刪除該文章嗎?" @confirm="deleteArticle(scope.$index,scope.row)">
              <template #reference>
                <el-button size="small" type="text">
                  刪除
                </el-button>
              </template>
            </el-popconfirm>
            <el-button size="small" type="text" @click.prevent="showEditDrawer(scope.$index, scope.row)">
              編輯
            </el-button>
            <el-button v-if="scope.row.status==='草稿'" size="small" type="text"
                       @click.prevent="publishArticle(scope.$index, scope.row)">
              發(fā)布
            </el-button>
            <el-button v-else size="small" type="text"
                       @click.prevent="offlineArticle(scope.$index, scope.row)">
              下線
            </el-button>
          </template>
        </el-table-column>
      </el-table>

    </div>
    <div class="pagination">
      <el-pagination :page-size="10" :total="state.total" background
                     layout="prev, pager, next"></el-pagination>
    </div>
  </div>
  <EditArticle
      :article-id="state.articleId"
      :visible="state.showDrawer"
      @close="handleCloseDrawer"
  />
  <CatalogTree
      :visible="state.showCatalogTree"
      @close="state.showCatalogTree=false"
  />
</template>

<script lang="ts">
import {defineComponent, reactive} from "vue";
import {Article, ArticleArray, ArticleParams} from "../../types";
import {getArticleList, remoteDeleteArticle, remoteOfflineArticle, remotePublishArticle} from "../../api/service";
import {timestampToTime} from "../../utils";
import {ElMessage} from "element-plus";
import EditArticle from "../../components/EditArticle.vue";
import CatalogTree from "../../components/CatalogTree.vue";

export default defineComponent({
  name: "Article",
  components: {CatalogTree, EditArticle},
  setup: function () {
    const state = reactive({
      articleList: [] as Array<Article>,
      params: {
        title: undefined,
        status: undefined,
        tags: undefined,
        catalog: undefined,
        page: 1,
        page_size: 10,
      } as ArticleParams,
      isLoading: false,
      total: 0,
      showDrawer: false,
      articleId: 0,
      showCatalogTree: false,
    });

    const handleSearch = async (): Promise<void> => {
      state.isLoading = true;
      try {
        const data: ArticleArray = await getArticleList(state.params);
        state.isLoading = false;
        state.articleList = data.results;
        state.total = data.count
      } catch (e) {
        console.error(e)
        state.isLoading = false;
      }
    };

    const publishArticle = async (index: number, row: Article) => {
      try {
        await remotePublishArticle(row.id)
        ElMessage({
          message: "發(fā)布成功蛔六!",
          type: "success",
        });
        await handleSearch()
      } catch (e) {
        console.error(e)
      }
    }

    const offlineArticle = async (index: number, row: Article) => {
      try {
        await remoteOfflineArticle(row.id)
        ElMessage({
          message: "下線成功荆永!",
          type: "success",
        });
        await handleSearch()
      } catch (e) {
        console.error(e)
      }
    }

    const deleteArticle = async (index: number, row: Article) => {
      await remoteDeleteArticle(row.id);
      ElMessage({
        message: "刪除成功!",
        type: "success",
      });
      await handleSearch()
    }

    const datetimeFormatter = (row: Article, column: number, cellValue: string, index: number) => {
      return timestampToTime(cellValue, true);
    }

    handleSearch()

    const handleCloseDrawer = (isOk: boolean) => {
      state.showDrawer = false
      if (isOk) {
        handleSearch()
      }
    }
    return {
      state,
      handleSearch,
      datetimeFormatter,
      deleteArticle,
      handleCloseDrawer,
      publishArticle,
      offlineArticle
    }
  },
  mounted() {
    this.$refs.title.focus()
  },
  methods: {
    showEditDrawer(index: number, row: Article) {
      this.$refs.articleTable.setCurrentRow(row)
      this.state.showDrawer = true;
      this.state.articleId = row.id
    },
    showAddDrawer() {
      this.state.showDrawer = true;
      this.state.articleId = 0;
    }
  }
})
</script>

<style scoped>
.pagination {
  text-align: right;
  margin-top: 12px;
}
</style>

1.4.5 Router

定義route來完成路由跳轉(zhuǎn)古今。在src/route/index.ts 文件中新增代碼:

import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import Home from "../views/client/Home.vue";

const routes: Array<RouteRecordRaw> = [
    {
        path: "/",
        name: "Home",
        component: Home,
        meta: {}
    },
    {
        path: "/login/",
        name: "Login",
        component: () =>
            import("../views/admin/Login.vue")
    },
    {
        path: '/admin',
        name: 'Admin',
        component: () => import("../views/admin/Admin.vue"),
        children: [
            {
                path: '/admin/',
                name: 'Dashboard',
                component: () => import("../views/admin/Dashboard.vue"),
            },
            {
                path: '/admin/dashboard',
                name: 'AdminDashboard',
                component: () => import("../views/admin/Dashboard.vue"),
            },
            {
                path: '/admin/user',
                name: 'UserManagement',
                component: () => import("../views/admin/User.vue"),
            },
            {
                path: '/admin/tag',
                name: 'Tag',
                component: () => import("../views/admin/Tag.vue"),
            },
            {
                path: '/admin/article',
                name: 'ArticleManagement',
                component: () => import("../views/admin/Article.vue"),
            },
        ]
    },
]

const router = createRouter({
    history: createWebHistory(import.meta.env.BASE_URL),
    routes,
});


export default router;

1.4.6 vite.config.ts

由于我們需要展示對上傳后的圖片屁魏,因此需要對上傳后的圖片代理,在vite.config.ts文件中捉腥,增加如下代理:

'/upload': {
     target: 'http://localhost:8000/',
     changeOrigin: true,
     ws: false,
     rewrite: (pathStr) => pathStr.replace('/api', ''),
     timeout: 5000,
},

1.5 評論管理

15.1 Type

src/types/index.ts文件中增加代碼如下:

export interface CommentInfo {
    id: number,
    user: number,
    user_info: User | any,
    article: number,
    article_info: Article | any,
    created_at: string,
    reply: number | any,
    content: string,
    comment_replies: CommentInfo | any,
}

export interface CommentPara {
    user: number,
    article: number,
    reply: number | any,
    content: string,
    page: number,
    page_size: number
}

1.5.2 API

這里要處理列表查詢氓拼。在src/api/service.ts編寫如下代碼:

export function getCommentList(params: CommentPara) {
    return request({
        url: '/comment/',
        method: 'get',
        params,
    }) as unknown as ResponseData
}

1.5.3 Component

由于評論無需要做修改刪除等操作,只有查看評論詳情抵碟,因此復用文章詳情頁面桃漾。

1.5.4 View

通過表格查看評論,在src/views/admin下新增文件Comment.vue文件拟逮,編寫如下代碼:

<template>
  <div>
    <div>
      <el-form :inline="true" :model="state.params" class="demo-form-inline">
        <el-form-item label="賬號">
          <el-select v-model="state.params.user" filterable placeholder="請選擇">
            <el-option
                v-for="item in state.userList"
                :key="item.id"
                :label="item.nickname || item.username"
                :value="item.id">
            </el-option>
          </el-select>
        </el-form-item>
        <el-form-item label="內(nèi)容">
          <el-input v-model="state.params.content" placeholder="評論內(nèi)容"/>
        </el-form-item>
        <el-form-item>
          <el-button :loading="state.loading" type="primary" @click="handleSearch">查詢</el-button>
        </el-form-item>
      </el-form>
    </div>
    <div>
      <el-table ref="articleTable" :data="state.commentList" :header-cell-style="{background:'#eef1f6',color:'#606266'}"
                stripe>
        <el-table-column type="selection" width="55"/>
        <el-table-column label="ID" prop="id" width="80"/>
        <el-table-column label="評論者" prop="user_info.name" width="200"/>
        <el-table-column label="評論內(nèi)容" prop="content" width="200"/>
        <el-table-column label="文章" prop="article_info.title"/>
        <el-table-column label="回復評論" prop="reply.id" width="200"/>
        <el-table-column :formatter="datetimeFormatter" label="評論時間" prop="created_at"/>
        <el-table-column label="操作">
          <template #default="scope">
            <el-button size="small" type="text"
                       @click.prevent="showDetail(scope.row)">
              詳情
            </el-button>
          </template>
        </el-table-column>
      </el-table>
    </div>
    <div class="pagination">
      <el-pagination :page-size="10" :total="state.total" background
                     layout="prev, pager, next"></el-pagination>
    </div>
  </div>
</template>

<script lang="ts">
import {defineComponent, reactive} from "vue";
import {Article, CommentInfo, CommentPara, ResponseData, User} from "../../types";
import {ElMessage} from "element-plus";
import {timestampToTime} from "../../utils";
import {getCommentList, getUserList, saveUser} from "../../api/service";
import UserDetail from "../../components/UserDetail.vue";

export default defineComponent({
  name: "Comment",
  components: {UserDetail},
  setup: function () {
    const state = reactive({
      commentList: [] as Array<CommentInfo>,
      params: {
        user: undefined,
        article: undefined,
        reply: undefined,
        content: '',
        page: 1,
        page_size: 10,
      } as unknown as CommentPara,
      total: 0,
      userList: [] as Array<User>,
      loading: false,
    });

    const handleSearch = async (): Promise<void> => {
      state.loading = true;
      try {
        const data: ResponseData = await getCommentList(state.params);
        state.loading = false;
        state.commentList = data.results;
        state.total = data.count
      } catch (e) {
        console.error(e)
        state.loading = false;
      }
    };

    const getUsers = async (): Promise<void> => {
      try {
        const data: ResponseData = await getUserList({});
        state.userList = data.results;
      } catch (e) {
        console.error(e)
      }
    };


    const datetimeFormatter = (row: Article, column: number, cellValue: string, index: number) => {
      return timestampToTime(cellValue, true);
    }

    handleSearch()
    getUsers()
    return {
      state,
      handleSearch,
      datetimeFormatter,
    }
  },
  methods: {
    showDetail(row: CommentInfo) {
      const {href} = this.$router.resolve({
        path: '/article/',
        query: {
          id: row.article_info.id
        }
      });
      window.open(href, "_blank");
    },
  }
})
</script>

<style scoped>
.pagination {
  text-align: right;
  margin-top: 12px;
}
</style>

1.5.5 Router

定義route來完成路由跳轉(zhuǎn)撬统。在src/route/index.ts 文件中新增代碼:

import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import Home from "../views/client/Home.vue";

const routes: Array<RouteRecordRaw> = [
    {
        path: "/",
        name: "Home",
        component: Home,
        meta: {}
    },
    {
        path: "/login/",
        name: "Login",
        component: () =>
            import(/* webpackChunkName: "login" */ "../views/admin/Login.vue")
    },
    {
        path: '/admin',
        name: 'Admin',
        component: () => import(/* webpackChunkName: "admin" */ "../views/admin/Admin.vue"),
        children: [
            {
                path: '/admin/',
                name: 'Dashboard',
                component: () => import("../views/admin/Dashboard.vue"),
            },
            {
                path: '/admin/dashboard',
                name: 'AdminDashboard',
                component: () => import("../views/admin/Dashboard.vue"),
            },
            {
                path: '/admin/user',
                name: 'UserManagement',
                component: () => import("../views/admin/User.vue"),
            },
            {
                path: '/admin/tag',
                name: 'Tag',
                component: () => import("../views/admin/Tag.vue"),
            },
            {
                path: '/admin/article',
                name: 'ArticleManagement',
                component: () => import("../views/admin/Article.vue"),
            },
            {
                path: '/admin/comment',
                name: 'CommentManagement',
                component: () => import("../views/admin/Comment.vue"),
            },
        ]
    },
]

const router = createRouter({
    history: createWebHistory(import.meta.env.BASE_URL),
    routes,
});


export default router;

1.6 管理后臺首頁

1.6.1 Type

src/types/index.ts文件中增加代碼如下:

export interface NumberInfo {
    views: number,
    likes: number,
    comments: number,
    messages: number
}

1.6.2 API

這里要編寫標簽管理相關(guān)的接口,列表查詢敦迄、新增恋追、修改凭迹、刪除。在src/api/service.ts編寫如下代碼:

export function getTopArticleList() {
    return request({
        url: '/top/',
        method: 'get',
    }) as unknown as ResponseData
}

export function getNumbers() {
    return request({
        url: '/number/',
        method: 'get',
    }) as unknown as NumberInfo
}

1.6.3 Component

無需提供額外的組件苦囱。

1.6.4 View

通過圖標和指標卡的形式展示網(wǎng)站的整體情況嗅绸,修改src/views/admin/Dashboard.vue,編寫如下代碼:

<template>
  <div>
    <div class="title">今日博客訪問情況</div>
    <el-row :gutter="24" class="numbers">
      <el-col :span="6" class="el-col-6">
        <el-card>
          <div class="number-card">
            <div>
              <i class="el-icon-user number-icon"></i>
            </div>
            <div class="number-right">
              <div class="number-num">{{ state.numbers.views }}</div>
              <div>用戶訪問量</div>
            </div>
          </div>
        </el-card>
      </el-col>
      <el-col :span="6" class="el-col-6">
        <el-card>
          <div class="number-card">
            <div>
              <i class="el-icon-thumb number-icon" style="background: #64d572;"></i>
            </div>
            <div class="number-right">
              <div class="number-num">{{ state.numbers.likes }}</div>
              <div>點贊量</div>
            </div>
          </div>
        </el-card>
      </el-col>
      <el-col :span="6" class="el-col-6">
        <el-card>
          <div class="number-card">
            <div>
              <i class="el-icon-chat-line-square number-icon" style="background: #f25e43;"></i>
            </div>
            <div class="number-right">
              <div class="number-num">{{ state.numbers.comments }}</div>
              <div>評論量</div>
            </div>
          </div>
        </el-card>
      </el-col>
      <el-col :span="6" class="el-col-6">
        <el-card>
          <div class="number-card">
            <div>
              <i class="el-icon-message number-icon" style="background-color: #42B983"></i>
            </div>
            <div class="number-right">
              <div class="number-num">{{ state.numbers.messages }}</div>
              <div>留言量</div>
            </div>
          </div>
        </el-card>
      </el-col>
    </el-row>
    <div class="top-articles">
      <el-card>
        <template #header>
          文章訪問量TOP10
        </template>
        <div class="article-list">
          <div v-for="( article,index) in state.articleList" class="article" @click="viewArticle(article.id)">
            <span style="font-size: 14px">{{ index + 1 + '. ' + article.title }}</span>
            <span style="color: #999999; font-size: 14px">{{ article.views }} / {{ article.likes }}</span>
          </div>

        </div>
      </el-card>
    </div>
  </div>
</template>

<script lang="ts">
import {defineComponent, reactive} from "vue";
import {Article} from "../../types";
import {getNumbers, getTopArticleList} from "../../api/service";

export default defineComponent({
  name: "Dashboard",
  setup() {
    const state = reactive({
      numbers: {
        views: 0,
        likes: 0,
        comments: 0,
        messages: 0
      },
      articleList: [{title: 'a', views: 1, likes: 1}] as Array<Article>,
    })
    return {
      state,
    }
  },

  async mounted() {
    this.state.articleList = (await getTopArticleList()).results
    this.state.numbers = await getNumbers()
  },

  methods: {
    viewArticle(id: number) {
      const {href} = this.$router.resolve({
        path: '/article/',
        query: {
          id
        }
      });
      window.open(href, "_blank");
    }
  }

})
</script>

<style lang="less" scoped>
.numbers {
  width: 100%;
}

.title {
  color: #999;
  margin: 12px 0;
  padding-left: 8px;
  font-size: 14px;
}

:deep(.el-card__body){
  margin: 0;
  padding: 0;
}

.number-card {
  margin: 0;
  padding: 0;
  display: -webkit-box;
  display: -ms-flexbox;
  display: flex;
  flex: 1;
  -webkit-box-align: center;
  -ms-flex-align: center;
  align-items: center;
  height: 80px;
  border: 1px solid #ebeef5;
  background-color: #fff;
  border-radius: 4px;
  overflow: hidden;
}

.number-right {
  -webkit-box-flex: 1;
  -ms-flex: 1;
  flex: 1;
  text-align: center;
  font-size: 14px;
  color: #999;
}

.number-num {
  font-size: 30px;
  font-weight: 700;
  color: #2d8cf0;
  text-align: center;
}


.number-icon {
  font-size: 50px;
  width: 80px;
  height: 80px;
  text-align: center;
  line-height: 80px;
  color: #fff;
  background: #2d8cf0;
}

.top-articles {
  margin: 24px 24px 24px 0;
}

.article-list {
  padding: 20px;
}

.article {
  cursor: pointer;
  display: flex;
  flex: 1;
  justify-content: space-between;
  padding: 12px 24px 12px 12px;
  border-top: #eeeeee 1px solid;
}

.article:first-child {
  border-top: none;
  padding-top: 0;
}

.article:last-child {
  padding-bottom: 0;
}

.dashboard-list {
  display: flex;
  flex: 1;
  justify-content: space-evenly;
  padding: 24px;
  margin-right: 24px;;
}

.percentage-value {
  display: block;
  margin-top: 10px;
  font-size: 28px;
}

.percentage-label {
  display: block;
  margin-top: 10px;
  font-size: 12px;
}
</style>

1.6.5Router

管理后臺已經(jīng)開發(fā)完成撕彤,因此需要在路由中做好權(quán)限控制鱼鸠,當訪問admin路徑的時候,需要判斷用戶是否登錄羹铅,且用戶是否是管理員蚀狰,因此在src/router/index.ts中增加如下代碼:

router.beforeEach((to, from, next) => {
    if (/\/admin/i.test(to.path)
        && (!store.state.user.id ||
            store.state.user.role !== 'Admin')) {
        next('/login')
        return
    }
    next()
})

src/views/admin/Login.vue中第143行后增加一行代碼:

is_superuser: data.is_superuser

至此管理后臺的前端開發(fā)完成

二、前端效果

2.1 前端管理后臺頁面效果

2.2 前端代碼結(jié)構(gòu)

下一篇我們編寫博客網(wǎng)站給用戶使用的頁面职员。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末麻蹋,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子焊切,更是在濱河造成了極大的恐慌哥蔚,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,372評論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件蛛蒙,死亡現(xiàn)場離奇詭異,居然都是意外死亡渤愁,警方通過查閱死者的電腦和手機牵祟,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,368評論 3 392
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來抖格,“玉大人诺苹,你說我怎么就攤上這事”⒅簦” “怎么了收奔?”我有些...
    開封第一講書人閱讀 162,415評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長滓玖。 經(jīng)常有香客問我坪哄,道長,這世上最難降的妖魔是什么势篡? 我笑而不...
    開封第一講書人閱讀 58,157評論 1 292
  • 正文 為了忘掉前任翩肌,我火速辦了婚禮,結(jié)果婚禮上禁悠,老公的妹妹穿的比我還像新娘念祭。我一直安慰自己,他們只是感情好碍侦,可當我...
    茶點故事閱讀 67,171評論 6 388
  • 文/花漫 我一把揭開白布粱坤。 她就那樣靜靜地躺著隶糕,像睡著了一般。 火紅的嫁衣襯著肌膚如雪站玄。 梳的紋絲不亂的頭發(fā)上枚驻,一...
    開封第一講書人閱讀 51,125評論 1 297
  • 那天,我揣著相機與錄音蜒什,去河邊找鬼测秸。 笑死,一個胖子當著我的面吹牛灾常,可吹牛的內(nèi)容都是我干的霎冯。 我是一名探鬼主播,決...
    沈念sama閱讀 40,028評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼钞瀑,長吁一口氣:“原來是場噩夢啊……” “哼沈撞!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起雕什,我...
    開封第一講書人閱讀 38,887評論 0 274
  • 序言:老撾萬榮一對情侶失蹤缠俺,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后贷岸,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體壹士,經(jīng)...
    沈念sama閱讀 45,310評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,533評論 2 332
  • 正文 我和宋清朗相戀三年偿警,在試婚紗的時候發(fā)現(xiàn)自己被綠了躏救。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,690評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡螟蒸,死狀恐怖盒使,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情七嫌,我是刑警寧澤少办,帶...
    沈念sama閱讀 35,411評論 5 343
  • 正文 年R本政府宣布,位于F島的核電站诵原,受9級特大地震影響英妓,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜皮假,卻給世界環(huán)境...
    茶點故事閱讀 41,004評論 3 325
  • 文/蒙蒙 一鞋拟、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧惹资,春花似錦贺纲、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽潦刃。三九已至,卻和暖如春懈叹,著一層夾襖步出監(jiān)牢的瞬間乖杠,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,812評論 1 268
  • 我被黑心中介騙來泰國打工澄成, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留胧洒,地道東北人。 一個月前我還...
    沈念sama閱讀 47,693評論 2 368
  • 正文 我出身青樓墨状,卻偏偏與公主長得像卫漫,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子肾砂,可洞房花燭夜當晚...
    茶點故事閱讀 44,577評論 2 353

推薦閱讀更多精彩內(nèi)容