美文网首页
Vue3+TypeScript+Django Rest Fram

Vue3+TypeScript+Django Rest Fram

作者: 落霞__孤鹜 | 来源:发表于2021-08-25 07:44 被阅读0次

    一个完整的网站都是有前台和管理后台组成的,前台用来给真正的用户浏览和使用,后台用来给管理员管理网站内容,配置各种功能和数据等。博客的管理后台就是用来承载创建博客,发布博客,查看留言,管理博客用户这些功能的子系统。

    大家好,我是落霞孤鹜,上一篇我们已经实现了管理后台的前端部分页面,这一章我们继续搭建博客的管理后台的前端,实现对博客网站的管理功能。

    一、前端开发

    1.4 分类和文章管理

    文章和分类是关系比较密切的两个业务对象,因此这里把分类管理的功能和文章管理的功能放在同一个页面处理。

    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

    这里要编写标签管理相关的接口,列表查询、新增、修改、删除。在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下创建文件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下创建文件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('确认关闭抽屉?', '提示', {
            confirmButtonText: '关闭',
            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标题选中状态
    
    :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="状态">
              <el-select v-model="state.params.status" placeholder="状态">
                <el-option label="已发布" 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="状态" 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)">
                  发布
                </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: "发布成功!",
              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来完成路由跳转。在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="内容">
              <el-input v-model="state.params.content" placeholder="评论内容"/>
            </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="评论内容" 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来完成路由跳转。在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

    这里要编写标签管理相关的接口,列表查询、新增、修改、删除。在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

    通过图标和指标卡的形式展示网站的整体情况,修改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

    管理后台已经开发完成,因此需要在路由中做好权限控制,当访问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
    

    至此管理后台的前端开发完成

    二、前端效果

    2.1 前端管理后台页面效果

    2.2 前端代码结构

    下一篇我们编写博客网站给用户使用的页面。

    相关文章

      网友评论

          本文标题:Vue3+TypeScript+Django Rest Fram

          本文链接:https://www.haomeiwen.com/subject/qmxdiltx.html